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/// Synchronous byte store for versioned transaction-log objects.
278///
279/// Implementations usually adapt the same native storage system used for
280/// blobs and roots. Each object is one lease-version log for one database.
281pub trait NativeLogStorage: Send + Sync {
282    /// Reads the encoded bytes for `name` and `version`.
283    ///
284    /// # Errors
285    /// Returns an error when the native backend cannot read the version
286    /// object or when backend data cannot be represented as log bytes.
287    fn read_version(&self, name: &str, version: u64) -> Result<Option<Vec<u8>>, LogError>;
288    /// Compare-and-swap writes encoded bytes for `name` and `version`.
289    ///
290    /// # Errors
291    /// Returns an error when the compare-and-swap fails, the native backend
292    /// cannot publish the version object, or the backend reports invalid data.
293    fn cas_version(
294        &self,
295        name: &str,
296        version: u64,
297        expected: Option<&[u8]>,
298        new: &[u8],
299    ) -> Result<(), LogError>;
300    /// Lists versions present for `name`, sorted ascending.
301    ///
302    /// # Errors
303    /// Returns an error when the native backend cannot enumerate version
304    /// objects or returns an invalid version identifier.
305    fn versions(&self, name: &str) -> Result<Vec<u64>, LogError>;
306    /// Deletes all versions for `name`.
307    ///
308    /// # Errors
309    /// Returns an error when the native backend cannot remove a version object.
310    fn delete_versions(&self, name: &str) -> Result<(), LogError>;
311}
312
313/// Versioned transaction log backed by a native key/value-style store.
314pub struct NativeVersionedLog<S: ?Sized> {
315    storage: Arc<S>,
316    name: String,
317    write_version: u64,
318    next_t: RwLock<u64>,
319}
320
321impl<S: NativeLogStorage + ?Sized + 'static> NativeVersionedLog<S> {
322    /// Opens the log for writing under `write_version`.
323    ///
324    /// # Errors
325    /// Returns an error when stored records cannot be read or decoded.
326    pub fn open(storage: Arc<S>, name: &str, write_version: u64) -> Result<Self, LogError> {
327        let records = read_native_merged(storage.as_ref(), name)?;
328        Ok(Self {
329            storage,
330            name: name.to_owned(),
331            write_version,
332            next_t: RwLock::new(records.last().map_or(1, |r| r.t + 1)),
333        })
334    }
335}
336
337impl<S: NativeLogStorage + ?Sized + 'static> TransactionLog for NativeVersionedLog<S> {
338    fn append(&self, record: &TxRecord) -> Result<(), LogError> {
339        let mut next_t = self.next_t.write().expect("poisoned log lock");
340        if *next_t != record.t {
341            return Err(LogError::Corrupt);
342        }
343        let current = self.storage.read_version(&self.name, self.write_version)?;
344        let current_bytes = current.as_deref().unwrap_or_default();
345        let existing = decode_framed_records(current_bytes)?;
346        if existing.last().map_or(*next_t, |r| r.t + 1) != record.t {
347            return Err(LogError::Corrupt);
348        }
349        let mut new = current_bytes.to_vec();
350        append_framed_record(&mut new, record)?;
351        self.storage
352            .cas_version(&self.name, self.write_version, current.as_deref(), &new)?;
353        *next_t += 1;
354        Ok(())
355    }
356
357    fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError> {
358        let _guard = self.next_t.read().expect("poisoned log lock");
359        Ok(read_native_merged(self.storage.as_ref(), &self.name)?
360            .into_iter()
361            .filter(|r| r.t >= start && end.is_none_or(|e| r.t < e))
362            .collect())
363    }
364}
365
366fn read_native_merged<S: NativeLogStorage + ?Sized>(
367    storage: &S,
368    name: &str,
369) -> Result<Vec<TxRecord>, LogError> {
370    let mut per_version = Vec::new();
371    for version in storage.versions(name)? {
372        let bytes = storage.read_version(name, version)?.unwrap_or_default();
373        per_version.push(decode_framed_records(&bytes)?);
374    }
375    let merged = merge_versions(per_version);
376    for pair in merged.windows(2) {
377        if pair[1].t != pair[0].t + 1 {
378            return Err(LogError::Corrupt);
379        }
380    }
381    Ok(merged)
382}
383
384/// Shared store of one log's records, each tagged with the lease version it
385/// was appended under.
386type VersionedRecords = Arc<Mutex<Vec<(u64, TxRecord)>>>;
387
388/// Process-shared registry of in-memory transaction logs, keyed by database
389/// name. It plays the role the log directory plays for [`VersionedLog`]:
390/// opening the same name (under any lease version) reaches the same records,
391/// so a mem-backed transactor recovers state across `open`/`create` calls
392/// within one process. Cloning a registry shares its storage.
393#[derive(Clone, Default)]
394pub struct MemLogRegistry {
395    logs: Arc<Mutex<HashMap<String, VersionedRecords>>>,
396}
397
398impl MemLogRegistry {
399    /// Creates an empty registry.
400    #[must_use]
401    pub fn new() -> Self {
402        Self::default()
403    }
404
405    fn entry(&self, name: &str) -> VersionedRecords {
406        Arc::clone(
407            self.logs
408                .lock()
409                .unwrap_or_else(std::sync::PoisonError::into_inner)
410                .entry(name.to_owned())
411                .or_default(),
412        )
413    }
414
415    /// Opens the named log for writing under `write_version`, mirroring
416    /// [`VersionedLog::open`] with in-memory storage.
417    #[must_use]
418    pub fn open(&self, name: &str, write_version: u64) -> MemVersionedLog {
419        let records = self.entry(name);
420        let next_t = {
421            let guard = records
422                .lock()
423                .unwrap_or_else(std::sync::PoisonError::into_inner);
424            MemVersionedLog::merged(&guard)
425                .last()
426                .map_or(1, |r| r.t + 1)
427        };
428        MemVersionedLog {
429            records,
430            write_version,
431            next_t: Mutex::new(next_t),
432        }
433    }
434
435    /// Reports whether any records exist for the named log.
436    #[must_use]
437    pub fn exists(&self, name: &str) -> bool {
438        self.logs
439            .lock()
440            .unwrap_or_else(std::sync::PoisonError::into_inner)
441            .get(name)
442            .is_some_and(|entry| {
443                !entry
444                    .lock()
445                    .unwrap_or_else(std::sync::PoisonError::into_inner)
446                    .is_empty()
447            })
448    }
449
450    /// Discards every record for the named log.
451    pub fn delete_all(&self, name: &str) {
452        self.logs
453            .lock()
454            .unwrap_or_else(std::sync::PoisonError::into_inner)
455            .remove(name);
456    }
457}
458
459/// An in-memory transaction log with the same per-lease-version merge
460/// semantics as [`VersionedLog`], obtained from a [`MemLogRegistry`]. Used by
461/// the mem-backed transactor: fully ephemeral, confined to one process.
462pub struct MemVersionedLog {
463    records: VersionedRecords,
464    write_version: u64,
465    /// The next `t` this writer will accept, tracked per opened instance
466    /// exactly as [`VersionedLog`] does — a deposed writer keeps appending
467    /// under its own stale count, and the merge cutoff discards those records.
468    next_t: Mutex<u64>,
469}
470
471impl MemVersionedLog {
472    fn merged(records: &[(u64, TxRecord)]) -> Vec<TxRecord> {
473        let mut versions: Vec<u64> = records.iter().map(|(version, _)| *version).collect();
474        versions.sort_unstable();
475        versions.dedup();
476        let per_version = versions
477            .into_iter()
478            .map(|version| {
479                records
480                    .iter()
481                    .filter(|(record_version, _)| *record_version == version)
482                    .map(|(_, record)| record.clone())
483                    .collect::<Vec<_>>()
484            })
485            .collect();
486        merge_versions(per_version)
487    }
488}
489
490impl TransactionLog for MemVersionedLog {
491    fn append(&self, record: &TxRecord) -> Result<(), LogError> {
492        let mut next_t = self
493            .next_t
494            .lock()
495            .unwrap_or_else(std::sync::PoisonError::into_inner);
496        if *next_t != record.t {
497            return Err(LogError::Corrupt);
498        }
499        self.records
500            .lock()
501            .unwrap_or_else(std::sync::PoisonError::into_inner)
502            .push((self.write_version, record.clone()));
503        *next_t += 1;
504        Ok(())
505    }
506
507    fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError> {
508        let records = self
509            .records
510            .lock()
511            .unwrap_or_else(std::sync::PoisonError::into_inner);
512        Ok(Self::merged(&records)
513            .into_iter()
514            .filter(|r| r.t >= start && end.is_none_or(|e| r.t < e))
515            .collect())
516    }
517}
518
519fn version_path(dir: &Path, name: &str, version: u64) -> PathBuf {
520    if version == 0 {
521        dir.join(format!("{name}.log"))
522    } else {
523        dir.join(format!("{name}.v{version}.log"))
524    }
525}
526
527/// Existing version files for `name`, sorted by version.
528fn version_files(dir: &Path, name: &str) -> Vec<(u64, PathBuf)> {
529    let mut files = Vec::new();
530    let legacy = version_path(dir, name, 0);
531    if legacy.is_file() {
532        files.push((0, legacy));
533    }
534    let prefix = format!("{name}.v");
535    if let Ok(entries) = fs::read_dir(dir) {
536        for entry in entries.flatten() {
537            let file_name = entry.file_name();
538            let Some(text) = file_name.to_str() else {
539                continue;
540            };
541            if let Some(version) = text
542                .strip_prefix(&prefix)
543                .and_then(|rest| rest.strip_suffix(".log"))
544                .and_then(|v| v.parse::<u64>().ok())
545                && version > 0
546            {
547                files.push((version, entry.path()));
548            }
549        }
550    }
551    files.sort_by_key(|(version, _)| *version);
552    files
553}
554
555/// Merges every version file, applying the takeover cutoff rule, and
556/// verifies the surviving sequence is contiguous.
557fn read_merged(dir: &Path, name: &str) -> Result<Vec<TxRecord>, LogError> {
558    let files = version_files(dir, name);
559    let mut per_file: Vec<Vec<TxRecord>> = Vec::with_capacity(files.len());
560    for (_, path) in &files {
561        per_file.push(read_records(path)?.0);
562    }
563    // A record in an older file is dead once any later file starts at or
564    // below its t: every record acked under version v precedes the first
565    // record of every later version (the successor replayed it before
566    // choosing its own first t), so only never-acked stale appends die.
567    let merged = merge_versions(per_file);
568    for pair in merged.windows(2) {
569        if pair[1].t != pair[0].t + 1 {
570            return Err(LogError::Corrupt);
571        }
572    }
573    Ok(merged)
574}
575
576fn encode_record(record: &TxRecord) -> Vec<u8> {
577    let mut out = Vec::new();
578    out.extend_from_slice(&record.t.to_be_bytes());
579    out.extend_from_slice(&record.tx_instant.to_be_bytes());
580    out.extend_from_slice(&(record.datoms.len() as u64).to_be_bytes());
581    for d in &record.datoms {
582        out.extend_from_slice(&d.e.raw().to_be_bytes());
583        out.extend_from_slice(&d.a.raw().to_be_bytes());
584        out.extend_from_slice(&d.tx.raw().to_be_bytes());
585        out.push(u8::from(d.added));
586        let v = encode_value(&d.v);
587        out.extend_from_slice(&(v.len() as u64).to_be_bytes());
588        out.extend_from_slice(&v);
589    }
590    out
591}
592fn decode_record(mut bytes: &[u8]) -> Result<TxRecord, LogError> {
593    fn take<'a>(bytes: &mut &'a [u8], n: usize) -> Result<&'a [u8], LogError> {
594        let value = bytes.get(..n).ok_or(LogError::Corrupt)?;
595        *bytes = &bytes[n..];
596        Ok(value)
597    }
598    fn u64_be(bytes: &mut &[u8]) -> Result<u64, LogError> {
599        Ok(u64::from_be_bytes(
600            take(bytes, 8)?.try_into().map_err(|_| LogError::Corrupt)?,
601        ))
602    }
603    let t = u64_be(&mut bytes)?;
604    let tx_instant = i64::from_be_bytes(
605        take(&mut bytes, 8)?
606            .try_into()
607            .map_err(|_| LogError::Corrupt)?,
608    );
609    let count = u64_be(&mut bytes)?;
610    let mut datoms = Vec::new();
611    for _ in 0..count {
612        let e = EntityId::from_raw(u64_be(&mut bytes)?);
613        let a = EntityId::from_raw(u64_be(&mut bytes)?);
614        let tx = EntityId::from_raw(u64_be(&mut bytes)?);
615        let added = take(&mut bytes, 1)?[0] != 0;
616        let len = usize::try_from(u64_be(&mut bytes)?).map_err(|_| LogError::Corrupt)?;
617        let raw = take(&mut bytes, len)?;
618        let (v, used) = decode_value(raw).map_err(|_| LogError::Corrupt)?;
619        if used != len {
620            return Err(LogError::Corrupt);
621        }
622        datoms.push(Datom { e, a, v, tx, added });
623    }
624    if !bytes.is_empty() {
625        return Err(LogError::Corrupt);
626    }
627    Ok(TxRecord {
628        t,
629        tx_instant,
630        datoms,
631    })
632}
633/// Reads fully written records plus the byte length of that durable prefix.
634///
635/// A record cut short by a crash mid-append (truncated length prefix or
636/// payload) ends the scan; a fully present record that fails to decode is
637/// genuine corruption and errors.
638fn read_records(path: &Path) -> Result<(Vec<TxRecord>, u64), LogError> {
639    let mut file = File::open(path)?;
640    let mut records = Vec::new();
641    let mut durable_len = 0_u64;
642    loop {
643        let mut len = [0; 8];
644        match file.read_exact(&mut len) {
645            Ok(()) => {}
646            Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break,
647            Err(e) => return Err(e.into()),
648        }
649        let len = usize::try_from(u64::from_be_bytes(len)).map_err(|_| LogError::Corrupt)?;
650        let mut payload = vec![0; len];
651        match file.read_exact(&mut payload) {
652            Ok(()) => {}
653            Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break,
654            Err(e) => return Err(e.into()),
655        }
656        records.push(decode_record(&payload)?);
657        durable_len += 8 + len as u64;
658    }
659    Ok((records, durable_len))
660}
661
662/// Appends one length-prefixed encoded record to `out`.
663///
664/// # Errors
665/// Returns an error if the record payload length is not representable.
666pub fn append_framed_record(out: &mut Vec<u8>, record: &TxRecord) -> Result<(), LogError> {
667    let payload = encode_record(record);
668    out.extend_from_slice(
669        &u64::try_from(payload.len())
670            .map_err(|_| LogError::Corrupt)?
671            .to_be_bytes(),
672    );
673    out.extend_from_slice(&payload);
674    Ok(())
675}
676
677/// Decodes all records from a length-prefixed byte slice.
678///
679/// Unlike filesystem crash recovery, native stores publish whole values
680/// atomically, so any trailing partial frame is treated as corruption.
681///
682/// # Errors
683/// Returns an error when any frame is truncated, has an invalid length, or
684/// contains a corrupt encoded transaction record.
685pub fn decode_framed_records(mut bytes: &[u8]) -> Result<Vec<TxRecord>, LogError> {
686    let mut records = Vec::new();
687    while !bytes.is_empty() {
688        if bytes.len() < 8 {
689            return Err(LogError::Corrupt);
690        }
691        let len = usize::try_from(u64::from_be_bytes(
692            bytes[..8].try_into().map_err(|_| LogError::Corrupt)?,
693        ))
694        .map_err(|_| LogError::Corrupt)?;
695        bytes = &bytes[8..];
696        let payload = bytes.get(..len).ok_or(LogError::Corrupt)?;
697        records.push(decode_record(payload)?);
698        bytes = &bytes[len..];
699    }
700    Ok(records)
701}