Skip to main content

corium_log/
lib.rs

1//! Durable append-only transaction logs with replay and range scans.
2
3use 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/// One committed transaction record.
17#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct TxRecord {
19    /// Monotonic transaction number.
20    pub t: u64,
21    /// Monotonic UTC millisecond timestamp.
22    pub tx_instant: i64,
23    /// Facts asserted/retracted by the transaction.
24    pub datoms: Vec<Datom>,
25}
26
27/// Log errors.
28#[derive(Debug, Error)]
29pub enum LogError {
30    /// Filesystem error.
31    #[error("log I/O failed: {0}")]
32    Io(#[from] io::Error),
33    /// Malformed or incomplete log data.
34    #[error("corrupt transaction log")]
35    Corrupt,
36    /// Native store backend failure.
37    #[error("native transaction log store failed: {0}")]
38    Native(String),
39}
40
41/// Common transaction log interface.
42pub trait TransactionLog: Send + Sync {
43    /// Durably appends exactly the next transaction.
44    ///
45    /// # Errors
46    /// Returns an error for I/O failure, corruption, or a non-contiguous `t`.
47    fn append(&self, record: &TxRecord) -> Result<(), LogError>;
48    /// Returns records in the half-open transaction range `[start, end)`.
49    ///
50    /// # Errors
51    /// Returns an error when stored records cannot be read or decoded.
52    fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError>;
53    /// Replays every committed record.
54    ///
55    /// # Errors
56    /// Returns an error when stored records cannot be read or decoded.
57    fn replay(&self) -> Result<Vec<TxRecord>, LogError> {
58        self.tx_range(0, None)
59    }
60}
61
62/// In-memory log implementation.
63#[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
86/// Filesystem append log. Each append is flushed and `fsync`ed before returning.
87///
88/// A crash mid-append leaves a torn, never-acked record at the tail; `open`
89/// truncates it away so replay stops at the durability point of the last
90/// acked transaction and later appends extend a clean tail.
91pub struct FileLog {
92    path: PathBuf,
93    next_t: RwLock<u64>,
94}
95impl FileLog {
96    /// Opens or creates a log file, dropping any torn tail left by a crash.
97    ///
98    /// # Errors
99    /// Returns an error if the file cannot be created or a fully written
100    /// record is corrupt.
101    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
147/// A transaction log split into per-lease-version files for HA append
148/// isolation (see `docs/design/log-and-transactor.md`).
149///
150/// The active writer under lease version `V` appends only to
151/// `{name}.v{V}.log` (the pre-HA `{name}.log` reads as version 0). Readers
152/// merge the files in version order and drop any record in an older file
153/// whose `t` is at or past the first record of a later file: such records
154/// were appended by a deposed writer after a takeover and were never
155/// acknowledged, because acknowledgement re-verifies lease ownership after
156/// the durable append. A deposed writer therefore cannot corrupt or fork
157/// the log — its stale appends land in a file nobody considers current.
158pub struct VersionedLog {
159    dir: PathBuf,
160    name: String,
161    write_path: PathBuf,
162    next_t: RwLock<u64>,
163}
164
165impl VersionedLog {
166    /// Opens the log for writing under `write_version`, creating the
167    /// version file if needed and dropping any torn tail it carries.
168    /// Files of other versions are never modified.
169    ///
170    /// # Errors
171    /// Returns an error if files cannot be read/created or a fully written
172    /// record is corrupt.
173    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    /// Opens the log read-only (offline inspection, backup); appends fail.
197    ///
198    /// # Errors
199    /// Returns an error when the directory cannot be read or a fully
200    /// written record is corrupt.
201    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    /// Reports whether any log file exists for this database.
212    #[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    /// Deletes every version file for this database.
218    ///
219    /// # Errors
220    /// Returns an error when a file cannot be removed.
221    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
261/// Applies the takeover cutoff rule to per-version record lists, in the same
262/// way [`read_merged`] does for on-disk files: a record in an older version
263/// dies once any later version begins at or below its `t`, dropping only the
264/// never-acked stale appends of a deposed writer.
265fn 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
277/// Target maximum size of a live log chunk. Once the chunk a writer is
278/// appending to reaches this size, the next append rolls to a fresh chunk, so
279/// per-append rewrite cost stays bounded by this constant instead of growing
280/// with the whole log. A large individual record still fits in its own chunk.
281pub(crate) const LOG_CHUNK_MAX_BYTES: usize = 256 * 1024;
282
283/// Synchronous byte store for chunked transaction-log objects.
284///
285/// Implementations usually adapt the same native storage system used for blobs
286/// and roots. A database's log for one lease version is a sequence of chunk
287/// objects `(name, version, chunk)`, each a run of framed records; a writer
288/// appends to the highest chunk and rolls to the next once it fills, so no
289/// single object grows without bound. Chunk `0` of a version is the whole-log
290/// object earlier releases wrote, so existing logs read back as their chunk `0`.
291pub trait NativeLogStorage: Send + Sync {
292    /// Reads the encoded bytes for one `(name, version, chunk)` object.
293    ///
294    /// # Errors
295    /// Returns an error when the native backend cannot read the chunk object
296    /// or when backend data cannot be represented as log bytes.
297    fn read_chunk(&self, name: &str, version: u64, chunk: u64)
298    -> Result<Option<Vec<u8>>, LogError>;
299    /// Compare-and-swap writes encoded bytes for one `(name, version, chunk)`
300    /// object.
301    ///
302    /// # Errors
303    /// Returns an error when the compare-and-swap fails, the native backend
304    /// cannot publish the chunk object, or the backend reports invalid data.
305    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    /// Lists every `(version, chunk)` pair present for `name`.
314    ///
315    /// # Errors
316    /// Returns an error when the native backend cannot enumerate log objects
317    /// or returns an invalid identifier.
318    fn list_chunks(&self, name: &str) -> Result<Vec<(u64, u64)>, LogError>;
319    /// Deletes every chunk of every version for `name`.
320    ///
321    /// # Errors
322    /// Returns an error when the native backend cannot remove a chunk object.
323    fn delete_all(&self, name: &str) -> Result<(), LogError>;
324}
325
326/// The mutable append state of a [`NativeVersionedLog`] writer: the next
327/// transaction number it will accept and a cached copy of the live chunk it is
328/// currently appending to.
329///
330/// The writer is the sole appender of its version's chunks (the lease fence
331/// gives each active owner its own version; a deposed writer's stale appends
332/// land in a version the takeover cutoff discards). Caching the live chunk and
333/// tracking `next_t` lets an append extend the buffer in place and
334/// compare-and-swap it, instead of re-reading, re-decoding, and re-copying the
335/// whole log on every transaction — the original behavior made each append cost
336/// proportional to the entire history, so write throughput fell off
337/// quadratically as the database grew. Rolling to a new chunk once the live one
338/// fills additionally bounds the per-append rewrite to [`LOG_CHUNK_MAX_BYTES`].
339struct WriteState {
340    /// Next `t` this writer will accept.
341    next_t: u64,
342    /// Index of the chunk currently being appended to.
343    chunk: u64,
344    /// Cached encoded bytes of the live chunk, kept in lock-step with the store
345    /// by only advancing it after a successful CAS.
346    bytes: Vec<u8>,
347    /// Whether the live chunk object exists in the store yet, so the first
348    /// append to it inserts (expected `None`) and later ones compare against
349    /// the prior bytes.
350    exists: bool,
351}
352
353/// Versioned transaction log backed by a native key/value-style store.
354pub 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    /// Opens the log for writing under `write_version`.
363    ///
364    /// # Errors
365    /// Returns an error when stored records cannot be read or decoded.
366    pub fn open(storage: Arc<S>, name: &str, write_version: u64) -> Result<Self, LogError> {
367        // The merged view across every version establishes the next `t` (the
368        // takeover cutoff may place it past this writer's own last record).
369        let records = read_native_merged(storage.as_ref(), name)?;
370        let next_t = records.last().map_or(1, |r| r.t + 1);
371        // Resume at this version's highest existing chunk (0 when none exists),
372        // caching it so appends extend it in place rather than reading and
373        // decoding the whole log every time.
374        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        // Roll to a fresh chunk once the live one is full, so the rewritten
407        // object stays bounded. Never roll away from an empty chunk, so a
408        // single oversized record still lands somewhere.
409        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        // Two overlapping shared borrows: `expected` is the pre-append prefix,
419        // `new` the full buffer. When the chunk is absent the first append
420        // inserts (expected `None`), mirroring the prior read-then-CAS.
421        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                // Leave the cache exactly as the store still holds it.
440                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        // Range/replay must merge every version (for the takeover cutoff), so
448        // they read the store; the lock only serializes them with appends.
449        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    // Read every chunk, ordered by (version, chunk) so a version's chunks
465    // concatenate in transaction order, then group them per version.
466    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
492/// Shared store of one log's records, each tagged with the lease version it
493/// was appended under.
494type VersionedRecords = Arc<Mutex<Vec<(u64, TxRecord)>>>;
495
496/// Process-shared registry of in-memory transaction logs, keyed by database
497/// name. It plays the role the log directory plays for [`VersionedLog`]:
498/// opening the same name (under any lease version) reaches the same records,
499/// so a mem-backed transactor recovers state across `open`/`create` calls
500/// within one process. Cloning a registry shares its storage.
501#[derive(Clone, Default)]
502pub struct MemLogRegistry {
503    logs: Arc<Mutex<HashMap<String, VersionedRecords>>>,
504}
505
506impl MemLogRegistry {
507    /// Creates an empty registry.
508    #[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    /// Opens the named log for writing under `write_version`, mirroring
524    /// [`VersionedLog::open`] with in-memory storage.
525    #[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    /// Reports whether any records exist for the named log.
544    #[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    /// Discards every record for the named log.
559    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
567/// An in-memory transaction log with the same per-lease-version merge
568/// semantics as [`VersionedLog`], obtained from a [`MemLogRegistry`]. Used by
569/// the mem-backed transactor: fully ephemeral, confined to one process.
570pub struct MemVersionedLog {
571    records: VersionedRecords,
572    write_version: u64,
573    /// The next `t` this writer will accept, tracked per opened instance
574    /// exactly as [`VersionedLog`] does — a deposed writer keeps appending
575    /// under its own stale count, and the merge cutoff discards those records.
576    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
635/// Existing version files for `name`, sorted by version.
636fn 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
663/// Merges every version file, applying the takeover cutoff rule, and
664/// verifies the surviving sequence is contiguous.
665fn 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    // A record in an older file is dead once any later file starts at or
672    // below its t: every record acked under version v precedes the first
673    // record of every later version (the successor replayed it before
674    // choosing its own first t), so only never-acked stale appends die.
675    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}
741/// Reads fully written records plus the byte length of that durable prefix.
742///
743/// A record cut short by a crash mid-append (truncated length prefix or
744/// payload) ends the scan; a fully present record that fails to decode is
745/// genuine corruption and errors.
746fn 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
770/// Appends one length-prefixed encoded record to `out`.
771///
772/// # Errors
773/// Returns an error if the record payload length is not representable.
774pub 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
785/// Decodes all records from a length-prefixed byte slice.
786///
787/// Unlike filesystem crash recovery, native stores publish whole values
788/// atomically, so any trailing partial frame is treated as corruption.
789///
790/// # Errors
791/// Returns an error when any frame is truncated, has an invalid length, or
792/// contains a corrupt encoded transaction record.
793pub 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}