Skip to main content

corium_log/
lib.rs

1//! Durable append-only transaction logs with replay and range scans.
2
3use 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/// One committed transaction record.
18#[derive(Clone, Debug, Eq, PartialEq)]
19pub struct TxRecord {
20    /// Monotonic transaction number.
21    pub t: u64,
22    /// Monotonic UTC millisecond timestamp.
23    pub tx_instant: i64,
24    /// Facts asserted/retracted by the transaction.
25    pub datoms: Vec<Datom>,
26}
27
28/// Log errors.
29#[derive(Debug, Error)]
30pub enum LogError {
31    /// Filesystem error.
32    #[error("log I/O failed: {0}")]
33    Io(#[from] io::Error),
34    /// Malformed or incomplete log data.
35    #[error("corrupt transaction log")]
36    Corrupt,
37    /// Native store backend failure.
38    #[error("native transaction log store failed: {0}")]
39    Native(String),
40    /// The operation requires the asynchronous log interface.
41    #[error("this transaction log requires asynchronous access")]
42    AsyncOnly,
43}
44
45/// Common transaction log interface.
46#[async_trait]
47pub trait TransactionLog: Send + Sync {
48    /// Durably appends exactly the next transaction.
49    ///
50    /// # Errors
51    /// Returns an error for I/O failure, corruption, or a non-contiguous `t`.
52    fn append(&self, record: &TxRecord) -> Result<(), LogError>;
53    /// Durably appends exactly the next transaction without blocking an async
54    /// runtime worker. Synchronous logs use [`Self::append`] by default;
55    /// storage-backed logs override this method and await their backend.
56    ///
57    /// # Errors
58    /// Returns an error for I/O failure, corruption, or a non-contiguous `t`.
59    async fn append_async(&self, record: &TxRecord) -> Result<(), LogError> {
60        self.append(record)
61    }
62    /// Returns records in the half-open transaction range `[start, end)`.
63    ///
64    /// # Errors
65    /// Returns an error when stored records cannot be read or decoded.
66    fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError>;
67    /// Asynchronous form of [`Self::tx_range`].
68    ///
69    /// # Errors
70    /// Returns an error when stored records cannot be read or decoded.
71    async fn tx_range_async(
72        &self,
73        start: u64,
74        end: Option<u64>,
75    ) -> Result<Vec<TxRecord>, LogError> {
76        self.tx_range(start, end)
77    }
78    /// Replays every committed record.
79    ///
80    /// # Errors
81    /// Returns an error when stored records cannot be read or decoded.
82    fn replay(&self) -> Result<Vec<TxRecord>, LogError> {
83        self.tx_range(0, None)
84    }
85    /// Asynchronously replays every committed record.
86    ///
87    /// # Errors
88    /// Returns an error when stored records cannot be read or decoded.
89    async fn replay_async(&self) -> Result<Vec<TxRecord>, LogError> {
90        self.tx_range_async(0, None).await
91    }
92}
93
94/// In-memory log implementation.
95#[derive(Clone, Default)]
96pub struct MemoryLog(Arc<RwLock<Vec<TxRecord>>>);
97impl TransactionLog for MemoryLog {
98    fn append(&self, record: &TxRecord) -> Result<(), LogError> {
99        let mut records = self.0.write().expect("poisoned log lock");
100        if records.last().map_or(1, |r| r.t + 1) != record.t {
101            return Err(LogError::Corrupt);
102        }
103        records.push(record.clone());
104        Ok(())
105    }
106    fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError> {
107        Ok(self
108            .0
109            .read()
110            .expect("poisoned log lock")
111            .iter()
112            .filter(|r| r.t >= start && end.is_none_or(|e| r.t < e))
113            .cloned()
114            .collect())
115    }
116}
117
118/// Filesystem append log. Each append is flushed and `fsync`ed before returning.
119///
120/// A crash mid-append leaves a torn, never-acked record at the tail; `open`
121/// truncates it away so replay stops at the durability point of the last
122/// acked transaction and later appends extend a clean tail.
123pub struct FileLog {
124    path: PathBuf,
125    next_t: RwLock<u64>,
126}
127impl FileLog {
128    /// Opens or creates a log file, dropping any torn tail left by a crash.
129    ///
130    /// # Errors
131    /// Returns an error if the file cannot be created or a fully written
132    /// record is corrupt.
133    pub fn open(path: impl AsRef<Path>) -> Result<Self, LogError> {
134        let path = path.as_ref().to_path_buf();
135        if let Some(parent) = path.parent() {
136            fs::create_dir_all(parent)?;
137        }
138        OpenOptions::new().create(true).append(true).open(&path)?;
139        let (records, durable_len) = read_records(&path)?;
140        if fs::metadata(&path)?.len() > durable_len {
141            let file = OpenOptions::new().write(true).open(&path)?;
142            file.set_len(durable_len)?;
143            file.sync_all()?;
144        }
145        Ok(Self {
146            path,
147            next_t: RwLock::new(records.last().map_or(1, |r| r.t + 1)),
148        })
149    }
150}
151impl TransactionLog for FileLog {
152    fn append(&self, record: &TxRecord) -> Result<(), LogError> {
153        let mut next_t = self.next_t.write().expect("poisoned log lock");
154        if *next_t != record.t {
155            return Err(LogError::Corrupt);
156        }
157        let payload = encode_record(record);
158        let mut file = OpenOptions::new().append(true).open(&self.path)?;
159        file.write_all(
160            &u64::try_from(payload.len())
161                .map_err(|_| LogError::Corrupt)?
162                .to_be_bytes(),
163        )?;
164        file.write_all(&payload)?;
165        file.sync_all()?;
166        *next_t += 1;
167        Ok(())
168    }
169    fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError> {
170        let _guard = self.next_t.read().expect("poisoned log lock");
171        Ok(read_records(&self.path)?
172            .0
173            .into_iter()
174            .filter(|r| r.t >= start && end.is_none_or(|e| r.t < e))
175            .collect())
176    }
177}
178
179/// A transaction log split into per-lease-version files for HA append
180/// isolation (see `docs/design/log-and-transactor.md`).
181///
182/// The active writer under lease version `V` appends only to
183/// `{name}.v{V}.log` (the pre-HA `{name}.log` reads as version 0). Readers
184/// merge the files in version order and drop any record in an older file
185/// whose `t` is at or past the first record of a later file: such records
186/// were appended by a deposed writer after a takeover and were never
187/// acknowledged, because acknowledgement re-verifies lease ownership after
188/// the durable append. A deposed writer therefore cannot corrupt or fork
189/// the log — its stale appends land in a file nobody considers current.
190pub struct VersionedLog {
191    dir: PathBuf,
192    name: String,
193    write_path: PathBuf,
194    next_t: RwLock<u64>,
195}
196
197impl VersionedLog {
198    /// Opens the log for writing under `write_version`, creating the
199    /// version file if needed and dropping any torn tail it carries.
200    /// Files of other versions are never modified.
201    ///
202    /// # Errors
203    /// Returns an error if files cannot be read/created or a fully written
204    /// record is corrupt.
205    pub fn open(dir: impl AsRef<Path>, name: &str, write_version: u64) -> Result<Self, LogError> {
206        let dir = dir.as_ref().to_path_buf();
207        fs::create_dir_all(&dir)?;
208        let write_path = version_path(&dir, name, write_version);
209        OpenOptions::new()
210            .create(true)
211            .append(true)
212            .open(&write_path)?;
213        let (_, durable_len) = read_records(&write_path)?;
214        if fs::metadata(&write_path)?.len() > durable_len {
215            let file = OpenOptions::new().write(true).open(&write_path)?;
216            file.set_len(durable_len)?;
217            file.sync_all()?;
218        }
219        let records = read_merged(&dir, name)?;
220        Ok(Self {
221            dir,
222            name: name.to_owned(),
223            write_path,
224            next_t: RwLock::new(records.last().map_or(1, |r| r.t + 1)),
225        })
226    }
227
228    /// Opens the log read-only (offline inspection, backup); appends fail.
229    ///
230    /// # Errors
231    /// Returns an error when the directory cannot be read or a fully
232    /// written record is corrupt.
233    pub fn open_read_only(dir: impl AsRef<Path>, name: &str) -> Result<Self, LogError> {
234        let dir = dir.as_ref().to_path_buf();
235        Ok(Self {
236            write_path: PathBuf::new(),
237            name: name.to_owned(),
238            next_t: RwLock::new(u64::MAX),
239            dir,
240        })
241    }
242
243    /// Reports whether any log file exists for this database.
244    #[must_use]
245    pub fn exists(dir: impl AsRef<Path>, name: &str) -> bool {
246        !version_files(dir.as_ref(), name).is_empty()
247    }
248
249    /// Deletes every version file for this database.
250    ///
251    /// # Errors
252    /// Returns an error when a file cannot be removed.
253    pub fn delete_all(dir: impl AsRef<Path>, name: &str) -> Result<(), LogError> {
254        for (_, path) in version_files(dir.as_ref(), name) {
255            match fs::remove_file(&path) {
256                Ok(()) => {}
257                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
258                Err(error) => return Err(error.into()),
259            }
260        }
261        Ok(())
262    }
263}
264
265impl TransactionLog for VersionedLog {
266    fn append(&self, record: &TxRecord) -> Result<(), LogError> {
267        let mut next_t = self.next_t.write().expect("poisoned log lock");
268        if *next_t != record.t {
269            return Err(LogError::Corrupt);
270        }
271        let payload = encode_record(record);
272        let mut file = OpenOptions::new().append(true).open(&self.write_path)?;
273        file.write_all(
274            &u64::try_from(payload.len())
275                .map_err(|_| LogError::Corrupt)?
276                .to_be_bytes(),
277        )?;
278        file.write_all(&payload)?;
279        file.sync_all()?;
280        *next_t += 1;
281        Ok(())
282    }
283
284    fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError> {
285        let _guard = self.next_t.read().expect("poisoned log lock");
286        Ok(read_merged(&self.dir, &self.name)?
287            .into_iter()
288            .filter(|r| r.t >= start && end.is_none_or(|e| r.t < e))
289            .collect())
290    }
291}
292
293/// Applies the takeover cutoff rule to per-version record lists, in the same
294/// way [`read_merged`] does for on-disk files: a record in an older version
295/// dies once any later version begins at or below its `t`, dropping only the
296/// never-acked stale appends of a deposed writer.
297fn merge_versions(mut per_version: Vec<Vec<TxRecord>>) -> Vec<TxRecord> {
298    let mut cutoff = u64::MAX;
299    for records in per_version.iter_mut().rev() {
300        let first = records.first().map(|r| r.t);
301        records.retain(|r| r.t < cutoff);
302        if let Some(first) = first {
303            cutoff = cutoff.min(first);
304        }
305    }
306    per_version.into_iter().flatten().collect()
307}
308
309/// Target maximum size of a live log chunk. Once the chunk a writer is
310/// appending to reaches this size, the next append rolls to a fresh chunk, so
311/// per-append rewrite cost stays bounded by this constant instead of growing
312/// with the whole log. A large individual record still fits in its own chunk.
313pub(crate) const LOG_CHUNK_MAX_BYTES: usize = 256 * 1024;
314
315/// Asynchronous byte store for chunked transaction-log objects.
316///
317/// Implementations usually adapt the same native storage system used for blobs
318/// and roots. A database's log for one lease version is a sequence of chunk
319/// objects `(name, version, chunk)`, each a run of framed records; a writer
320/// appends to the highest chunk and rolls to the next once it fills, so no
321/// single object grows without bound. Chunk `0` of a version is the whole-log
322/// object earlier releases wrote, so existing logs read back as their chunk `0`.
323#[async_trait]
324pub trait NativeLogStorage: Send + Sync {
325    /// Reads the encoded bytes for one `(name, version, chunk)` object.
326    ///
327    /// # Errors
328    /// Returns an error when the native backend cannot read the chunk object
329    /// or when backend data cannot be represented as log bytes.
330    async fn read_chunk(
331        &self,
332        name: &str,
333        version: u64,
334        chunk: u64,
335    ) -> Result<Option<Vec<u8>>, LogError>;
336    /// Compare-and-swap writes encoded bytes for one `(name, version, chunk)`
337    /// object.
338    ///
339    /// # Errors
340    /// Returns an error when the compare-and-swap fails, the native backend
341    /// cannot publish the chunk object, or the backend reports invalid data.
342    async fn cas_chunk(
343        &self,
344        name: &str,
345        version: u64,
346        chunk: u64,
347        expected: Option<&[u8]>,
348        new: &[u8],
349    ) -> Result<(), LogError>;
350    /// Lists every `(version, chunk)` pair present for `name`.
351    ///
352    /// # Errors
353    /// Returns an error when the native backend cannot enumerate log objects
354    /// or returns an invalid identifier.
355    async fn list_chunks(&self, name: &str) -> Result<Vec<(u64, u64)>, LogError>;
356    /// Deletes every chunk of every version for `name`.
357    ///
358    /// # Errors
359    /// Returns an error when the native backend cannot remove a chunk object.
360    async fn delete_all(&self, name: &str) -> Result<(), LogError>;
361}
362
363/// The mutable append state of a [`NativeVersionedLog`] writer: the next
364/// transaction number it will accept and a cached copy of the live chunk it is
365/// currently appending to.
366///
367/// The writer is the sole appender of its version's chunks (the lease fence
368/// gives each active owner its own version; a deposed writer's stale appends
369/// land in a version the takeover cutoff discards). Caching the live chunk and
370/// tracking `next_t` lets an append extend the buffer in place and
371/// compare-and-swap it, instead of re-reading, re-decoding, and re-copying the
372/// whole log on every transaction — the original behavior made each append cost
373/// proportional to the entire history, so write throughput fell off
374/// quadratically as the database grew. Rolling to a new chunk once the live one
375/// fills additionally bounds the per-append rewrite to [`LOG_CHUNK_MAX_BYTES`].
376struct WriteState {
377    /// Next `t` this writer will accept.
378    next_t: u64,
379    /// Index of the chunk currently being appended to.
380    chunk: u64,
381    /// Cached encoded bytes of the live chunk, kept in lock-step with the store
382    /// by only advancing it after a successful CAS.
383    bytes: Vec<u8>,
384    /// Whether the live chunk object exists in the store yet, so the first
385    /// append to it inserts (expected `None`) and later ones compare against
386    /// the prior bytes.
387    exists: bool,
388}
389
390/// Versioned transaction log backed by a native key/value-style store.
391pub struct NativeVersionedLog<S: ?Sized> {
392    storage: Arc<S>,
393    name: String,
394    write_version: u64,
395    write: tokio::sync::Mutex<WriteState>,
396}
397
398impl<S: NativeLogStorage + ?Sized + 'static> NativeVersionedLog<S> {
399    /// Opens the log for writing under `write_version`.
400    ///
401    /// # Errors
402    /// Returns an error when stored records cannot be read or decoded.
403    pub async fn open(storage: Arc<S>, name: &str, write_version: u64) -> Result<Self, LogError> {
404        // The merged view across every version establishes the next `t` (the
405        // takeover cutoff may place it past this writer's own last record).
406        let records = read_native_merged(storage.as_ref(), name).await?;
407        let next_t = records.last().map_or(1, |r| r.t + 1);
408        // Resume at this version's highest existing chunk (0 when none exists),
409        // caching it so appends extend it in place rather than reading and
410        // decoding the whole log every time.
411        let chunk = storage
412            .list_chunks(name)
413            .await?
414            .into_iter()
415            .filter_map(|(version, chunk)| (version == write_version).then_some(chunk))
416            .max()
417            .unwrap_or(0);
418        let current = storage.read_chunk(name, write_version, chunk).await?;
419        let exists = current.is_some();
420        let bytes = current.unwrap_or_default();
421        Ok(Self {
422            storage,
423            name: name.to_owned(),
424            write_version,
425            write: tokio::sync::Mutex::new(WriteState {
426                next_t,
427                chunk,
428                bytes,
429                exists,
430            }),
431        })
432    }
433}
434
435#[async_trait]
436impl<S: NativeLogStorage + ?Sized + 'static> TransactionLog for NativeVersionedLog<S> {
437    fn append(&self, record: &TxRecord) -> Result<(), LogError> {
438        let _ = record;
439        Err(LogError::AsyncOnly)
440    }
441
442    async fn append_async(&self, record: &TxRecord) -> Result<(), LogError> {
443        let mut write = self.write.lock().await;
444        if write.next_t != record.t {
445            return Err(LogError::Corrupt);
446        }
447        // Prepare a candidate without changing the cached durable state. In
448        // particular, cancellation while the backend future is pending must
449        // not leave the in-process cache claiming bytes were committed.
450        let roll = write.exists && write.bytes.len() >= LOG_CHUNK_MAX_BYTES;
451        let chunk = write.chunk + u64::from(roll);
452        let exists = write.exists && !roll;
453        let mut candidate = if roll {
454            Vec::new()
455        } else {
456            write.bytes.clone()
457        };
458        let old_len = candidate.len();
459        append_framed_record(&mut candidate, record)?;
460        let expected = exists.then_some(&candidate[..old_len]);
461        match self
462            .storage
463            .cas_chunk(&self.name, self.write_version, chunk, expected, &candidate)
464            .await
465        {
466            Ok(()) => {
467                write.chunk = chunk;
468                write.exists = true;
469                write.bytes = candidate;
470                write.next_t += 1;
471                Ok(())
472            }
473            Err(error) => Err(error),
474        }
475    }
476
477    fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError> {
478        let _ = (start, end);
479        Err(LogError::AsyncOnly)
480    }
481
482    async fn tx_range_async(
483        &self,
484        start: u64,
485        end: Option<u64>,
486    ) -> Result<Vec<TxRecord>, LogError> {
487        // Range/replay must merge every version (for the takeover cutoff), so
488        // they read the store; the lock only serializes them with appends.
489        let _guard = self.write.lock().await;
490        Ok(read_native_merged(self.storage.as_ref(), &self.name)
491            .await?
492            .into_iter()
493            .filter(|r| r.t >= start && end.is_none_or(|e| r.t < e))
494            .collect())
495    }
496}
497
498async fn read_native_merged<S: NativeLogStorage + ?Sized>(
499    storage: &S,
500    name: &str,
501) -> Result<Vec<TxRecord>, LogError> {
502    // Read every chunk, ordered by (version, chunk) so a version's chunks
503    // concatenate in transaction order, then group them per version.
504    let mut chunks = storage.list_chunks(name).await?;
505    chunks.sort_unstable();
506    let mut per_version: Vec<Vec<TxRecord>> = Vec::new();
507    let mut current_version: Option<u64> = None;
508    for (version, chunk) in chunks {
509        if current_version != Some(version) {
510            per_version.push(Vec::new());
511            current_version = Some(version);
512        }
513        let bytes = storage
514            .read_chunk(name, version, chunk)
515            .await?
516            .unwrap_or_default();
517        per_version
518            .last_mut()
519            .expect("a version group was pushed")
520            .extend(decode_framed_records(&bytes)?);
521    }
522    let merged = merge_versions(per_version);
523    for pair in merged.windows(2) {
524        if pair[1].t != pair[0].t + 1 {
525            return Err(LogError::Corrupt);
526        }
527    }
528    Ok(merged)
529}
530
531/// Shared store of one log's records, each tagged with the lease version it
532/// was appended under.
533type VersionedRecords = Arc<Mutex<Vec<(u64, TxRecord)>>>;
534
535/// Process-shared registry of in-memory transaction logs, keyed by database
536/// name. It plays the role the log directory plays for [`VersionedLog`]:
537/// opening the same name (under any lease version) reaches the same records,
538/// so a mem-backed transactor recovers state across `open`/`create` calls
539/// within one process. Cloning a registry shares its storage.
540#[derive(Clone, Default)]
541pub struct MemLogRegistry {
542    logs: Arc<Mutex<HashMap<String, VersionedRecords>>>,
543}
544
545impl MemLogRegistry {
546    /// Creates an empty registry.
547    #[must_use]
548    pub fn new() -> Self {
549        Self::default()
550    }
551
552    fn entry(&self, name: &str) -> VersionedRecords {
553        Arc::clone(
554            self.logs
555                .lock()
556                .unwrap_or_else(std::sync::PoisonError::into_inner)
557                .entry(name.to_owned())
558                .or_default(),
559        )
560    }
561
562    /// Opens the named log for writing under `write_version`, mirroring
563    /// [`VersionedLog::open`] with in-memory storage.
564    #[must_use]
565    pub fn open(&self, name: &str, write_version: u64) -> MemVersionedLog {
566        let records = self.entry(name);
567        let next_t = {
568            let guard = records
569                .lock()
570                .unwrap_or_else(std::sync::PoisonError::into_inner);
571            MemVersionedLog::merged(&guard)
572                .last()
573                .map_or(1, |r| r.t + 1)
574        };
575        MemVersionedLog {
576            records,
577            write_version,
578            next_t: Mutex::new(next_t),
579        }
580    }
581
582    /// Reports whether any records exist for the named log.
583    #[must_use]
584    pub fn exists(&self, name: &str) -> bool {
585        self.logs
586            .lock()
587            .unwrap_or_else(std::sync::PoisonError::into_inner)
588            .get(name)
589            .is_some_and(|entry| {
590                !entry
591                    .lock()
592                    .unwrap_or_else(std::sync::PoisonError::into_inner)
593                    .is_empty()
594            })
595    }
596
597    /// Discards every record for the named log.
598    pub fn delete_all(&self, name: &str) {
599        self.logs
600            .lock()
601            .unwrap_or_else(std::sync::PoisonError::into_inner)
602            .remove(name);
603    }
604}
605
606/// An in-memory transaction log with the same per-lease-version merge
607/// semantics as [`VersionedLog`], obtained from a [`MemLogRegistry`]. Used by
608/// the mem-backed transactor: fully ephemeral, confined to one process.
609pub struct MemVersionedLog {
610    records: VersionedRecords,
611    write_version: u64,
612    /// The next `t` this writer will accept, tracked per opened instance
613    /// exactly as [`VersionedLog`] does — a deposed writer keeps appending
614    /// under its own stale count, and the merge cutoff discards those records.
615    next_t: Mutex<u64>,
616}
617
618impl MemVersionedLog {
619    fn merged(records: &[(u64, TxRecord)]) -> Vec<TxRecord> {
620        let mut versions: Vec<u64> = records.iter().map(|(version, _)| *version).collect();
621        versions.sort_unstable();
622        versions.dedup();
623        let per_version = versions
624            .into_iter()
625            .map(|version| {
626                records
627                    .iter()
628                    .filter(|(record_version, _)| *record_version == version)
629                    .map(|(_, record)| record.clone())
630                    .collect::<Vec<_>>()
631            })
632            .collect();
633        merge_versions(per_version)
634    }
635}
636
637impl TransactionLog for MemVersionedLog {
638    fn append(&self, record: &TxRecord) -> Result<(), LogError> {
639        let mut next_t = self
640            .next_t
641            .lock()
642            .unwrap_or_else(std::sync::PoisonError::into_inner);
643        if *next_t != record.t {
644            return Err(LogError::Corrupt);
645        }
646        self.records
647            .lock()
648            .unwrap_or_else(std::sync::PoisonError::into_inner)
649            .push((self.write_version, record.clone()));
650        *next_t += 1;
651        Ok(())
652    }
653
654    fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError> {
655        let records = self
656            .records
657            .lock()
658            .unwrap_or_else(std::sync::PoisonError::into_inner);
659        Ok(Self::merged(&records)
660            .into_iter()
661            .filter(|r| r.t >= start && end.is_none_or(|e| r.t < e))
662            .collect())
663    }
664}
665
666fn version_path(dir: &Path, name: &str, version: u64) -> PathBuf {
667    if version == 0 {
668        dir.join(format!("{name}.log"))
669    } else {
670        dir.join(format!("{name}.v{version}.log"))
671    }
672}
673
674/// Existing version files for `name`, sorted by version.
675fn version_files(dir: &Path, name: &str) -> Vec<(u64, PathBuf)> {
676    let mut files = Vec::new();
677    let legacy = version_path(dir, name, 0);
678    if legacy.is_file() {
679        files.push((0, legacy));
680    }
681    let prefix = format!("{name}.v");
682    if let Ok(entries) = fs::read_dir(dir) {
683        for entry in entries.flatten() {
684            let file_name = entry.file_name();
685            let Some(text) = file_name.to_str() else {
686                continue;
687            };
688            if let Some(version) = text
689                .strip_prefix(&prefix)
690                .and_then(|rest| rest.strip_suffix(".log"))
691                .and_then(|v| v.parse::<u64>().ok())
692                && version > 0
693            {
694                files.push((version, entry.path()));
695            }
696        }
697    }
698    files.sort_by_key(|(version, _)| *version);
699    files
700}
701
702/// Merges every version file, applying the takeover cutoff rule, and
703/// verifies the surviving sequence is contiguous.
704fn read_merged(dir: &Path, name: &str) -> Result<Vec<TxRecord>, LogError> {
705    let files = version_files(dir, name);
706    let mut per_file: Vec<Vec<TxRecord>> = Vec::with_capacity(files.len());
707    for (_, path) in &files {
708        per_file.push(read_records(path)?.0);
709    }
710    // A record in an older file is dead once any later file starts at or
711    // below its t: every record acked under version v precedes the first
712    // record of every later version (the successor replayed it before
713    // choosing its own first t), so only never-acked stale appends die.
714    let merged = merge_versions(per_file);
715    for pair in merged.windows(2) {
716        if pair[1].t != pair[0].t + 1 {
717            return Err(LogError::Corrupt);
718        }
719    }
720    Ok(merged)
721}
722
723fn encode_record(record: &TxRecord) -> Vec<u8> {
724    let mut out = Vec::new();
725    out.extend_from_slice(&record.t.to_be_bytes());
726    out.extend_from_slice(&record.tx_instant.to_be_bytes());
727    out.extend_from_slice(&(record.datoms.len() as u64).to_be_bytes());
728    for d in &record.datoms {
729        out.extend_from_slice(&d.e.raw().to_be_bytes());
730        out.extend_from_slice(&d.a.raw().to_be_bytes());
731        out.extend_from_slice(&d.tx.raw().to_be_bytes());
732        out.push(u8::from(d.added));
733        let v = encode_value(&d.v);
734        out.extend_from_slice(&(v.len() as u64).to_be_bytes());
735        out.extend_from_slice(&v);
736    }
737    out
738}
739fn decode_record(mut bytes: &[u8]) -> Result<TxRecord, LogError> {
740    fn take<'a>(bytes: &mut &'a [u8], n: usize) -> Result<&'a [u8], LogError> {
741        let value = bytes.get(..n).ok_or(LogError::Corrupt)?;
742        *bytes = &bytes[n..];
743        Ok(value)
744    }
745    fn u64_be(bytes: &mut &[u8]) -> Result<u64, LogError> {
746        Ok(u64::from_be_bytes(
747            take(bytes, 8)?.try_into().map_err(|_| LogError::Corrupt)?,
748        ))
749    }
750    let t = u64_be(&mut bytes)?;
751    let tx_instant = i64::from_be_bytes(
752        take(&mut bytes, 8)?
753            .try_into()
754            .map_err(|_| LogError::Corrupt)?,
755    );
756    let count = u64_be(&mut bytes)?;
757    let mut datoms = Vec::new();
758    for _ in 0..count {
759        let e = EntityId::from_raw(u64_be(&mut bytes)?);
760        let a = EntityId::from_raw(u64_be(&mut bytes)?);
761        let tx = EntityId::from_raw(u64_be(&mut bytes)?);
762        let added = take(&mut bytes, 1)?[0] != 0;
763        let len = usize::try_from(u64_be(&mut bytes)?).map_err(|_| LogError::Corrupt)?;
764        let raw = take(&mut bytes, len)?;
765        let (v, used) = decode_value(raw).map_err(|_| LogError::Corrupt)?;
766        if used != len {
767            return Err(LogError::Corrupt);
768        }
769        datoms.push(Datom { e, a, v, tx, added });
770    }
771    if !bytes.is_empty() {
772        return Err(LogError::Corrupt);
773    }
774    Ok(TxRecord {
775        t,
776        tx_instant,
777        datoms,
778    })
779}
780/// Reads fully written records plus the byte length of that durable prefix.
781///
782/// A record cut short by a crash mid-append (truncated length prefix or
783/// payload) ends the scan; a fully present record that fails to decode is
784/// genuine corruption and errors.
785fn read_records(path: &Path) -> Result<(Vec<TxRecord>, u64), LogError> {
786    let mut file = File::open(path)?;
787    let mut records = Vec::new();
788    let mut durable_len = 0_u64;
789    loop {
790        let mut len = [0; 8];
791        match file.read_exact(&mut len) {
792            Ok(()) => {}
793            Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break,
794            Err(e) => return Err(e.into()),
795        }
796        let len = usize::try_from(u64::from_be_bytes(len)).map_err(|_| LogError::Corrupt)?;
797        let mut payload = vec![0; len];
798        match file.read_exact(&mut payload) {
799            Ok(()) => {}
800            Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break,
801            Err(e) => return Err(e.into()),
802        }
803        records.push(decode_record(&payload)?);
804        durable_len += 8 + len as u64;
805    }
806    Ok((records, durable_len))
807}
808
809/// Appends one length-prefixed encoded record to `out`.
810///
811/// # Errors
812/// Returns an error if the record payload length is not representable.
813pub fn append_framed_record(out: &mut Vec<u8>, record: &TxRecord) -> Result<(), LogError> {
814    let payload = encode_record(record);
815    out.extend_from_slice(
816        &u64::try_from(payload.len())
817            .map_err(|_| LogError::Corrupt)?
818            .to_be_bytes(),
819    );
820    out.extend_from_slice(&payload);
821    Ok(())
822}
823
824/// Decodes all records from a length-prefixed byte slice.
825///
826/// Unlike filesystem crash recovery, native stores publish whole values
827/// atomically, so any trailing partial frame is treated as corruption.
828///
829/// # Errors
830/// Returns an error when any frame is truncated, has an invalid length, or
831/// contains a corrupt encoded transaction record.
832pub fn decode_framed_records(mut bytes: &[u8]) -> Result<Vec<TxRecord>, LogError> {
833    let mut records = Vec::new();
834    while !bytes.is_empty() {
835        if bytes.len() < 8 {
836            return Err(LogError::Corrupt);
837        }
838        let len = usize::try_from(u64::from_be_bytes(
839            bytes[..8].try_into().map_err(|_| LogError::Corrupt)?,
840        ))
841        .map_err(|_| LogError::Corrupt)?;
842        bytes = &bytes[8..];
843        let payload = bytes.get(..len).ok_or(LogError::Corrupt)?;
844        records.push(decode_record(payload)?);
845        bytes = &bytes[len..];
846    }
847    Ok(records)
848}