Skip to main content

rushdb_storage/
log_store.rs

1//! Segmented, append-only write-ahead log (log-first).
2//!
3//! The log-first substrate makes this log the **durable truth**: a commit is
4//! acknowledged only after its framed record is appended and fsynced, and the
5//! redb materialization is recovered by replaying log records beyond its
6//! recorded checkpoint LSN. See the manual's
7//! `docs/manual/architecture/storage-engine.md`.
8//!
9//! Layout: one directory of segment files named by their first LSN
10//! (`00000000000000000001.seg`), each with a fixed header followed by
11//! self-describing records produced by [`crate::wal::encode_wal_record`]. A
12//! record's own 32-byte header carries its payload length and CRC, so a torn
13//! trailing write (a crash mid-append) is detectable and truncatable, while a
14//! torn or corrupt record in a sealed segment is a hard error.
15//!
16//! This module is additive: it is not yet wired into the publisher. Phase 1 of
17//! the plan wires it as the durable acknowledgement and demotes redb to a
18//! checkpointed materialization.
19
20use crate::wal::{decode_wal_record, WAL_FORMAT_VERSION};
21use std::fs::{File, OpenOptions};
22use std::io::{self, Read, Write};
23use std::path::{Path, PathBuf};
24
25/// Magic identifying a live log segment file.
26const LOG_SEGMENT_MAGIC: [u8; 8] = *b"RDBWSEG1";
27
28/// Segment header: magic(8) + format version(4) + shard id(4) + first LSN(8).
29const LOG_SEGMENT_HEADER_LEN: usize = 8 + 4 + 4 + 8;
30
31/// A framed record's fixed header, as emitted by
32/// [`crate::wal::encode_wal_record`]: magic(8) + version(4) + shard(4) +
33/// lsn(8) + payload length(4) + crc(4).
34const LOG_RECORD_HEADER_LEN: usize = 32;
35
36/// Record ceiling, matching [`crate::wal`]. `pub(crate)` so `Store::append_log`
37/// can size a chained commit's fragments against the same hard ceiling this
38/// module enforces, rather than duplicating the literal.
39pub(crate) const MAX_RECORD_BYTES: usize = 64 * 1024 * 1024;
40
41/// Errors returned by the segmented log.
42#[derive(Debug, thiserror::Error)]
43pub enum LogError {
44    /// An underlying file operation failed.
45    #[error("log I/O error: {0}")]
46    Io(#[from] io::Error),
47    /// A segment failed structural validation.
48    #[error("corrupt log segment {path}: {detail}")]
49    Corrupt {
50        /// Path of the offending segment.
51        path: String,
52        /// Why the segment is rejected.
53        detail: String,
54    },
55    /// A record was appended with an LSN that breaks the contiguous sequence.
56    #[error("log append out of order: expected LSN {expected}, got {actual}")]
57    OutOfOrder {
58        /// The LSN the log required next.
59        expected: u64,
60        /// The LSN the caller supplied.
61        actual: u64,
62    },
63    /// A record exceeds the 64 MiB ceiling.
64    #[error("log record {0} bytes exceeds the 64 MiB ceiling")]
65    Oversized(usize),
66}
67
68/// One sealed or active log segment's in-memory descriptor.
69#[derive(Debug, Clone)]
70pub struct SegmentInfo {
71    /// First LSN stored in the segment.
72    pub first_lsn: u64,
73    /// Last LSN stored in the segment (`first_lsn - 1` when empty).
74    pub last_lsn: u64,
75    /// Segment file path.
76    pub path: PathBuf,
77    /// Valid bytes in the segment (after any torn tail is truncated).
78    pub bytes: u64,
79}
80
81/// The currently-open segment receiving appends.
82#[derive(Debug)]
83struct ActiveSegment {
84    file: File,
85    first_lsn: u64,
86    bytes: u64,
87}
88
89/// A directory of append-only log segments with a contiguous LSN sequence.
90#[derive(Debug)]
91pub struct LogStore {
92    dir: PathBuf,
93    shard_id: u32,
94    segment_bytes: u64,
95    segments: Vec<SegmentInfo>,
96    next_lsn: u64,
97    active: Option<ActiveSegment>,
98}
99
100impl LogStore {
101    /// Open (creating if absent) a log directory and recover its state.
102    ///
103    /// Existing segments are validated in order. A torn trailing record in the
104    /// **last** segment is truncated away (an interrupted append); the same
105    /// damage in a sealed segment is [`LogError::Corrupt`]. The next expected
106    /// LSN is one past the highest recovered record (1 for an empty log).
107    ///
108    /// # Errors
109    /// Returns [`LogError`] if the directory cannot be created or read, or a
110    /// segment is corrupt.
111    pub fn open(dir: &Path, shard_id: u32, segment_bytes: u64) -> Result<Self, LogError> {
112        std::fs::create_dir_all(dir)?;
113        let mut paths: Vec<(u64, PathBuf)> = std::fs::read_dir(dir)?
114            .filter_map(Result::ok)
115            .filter_map(|entry| {
116                let name = entry.file_name().into_string().ok()?;
117                let first_lsn = name.strip_suffix(".seg")?.parse::<u64>().ok()?;
118                Some((first_lsn, entry.path()))
119            })
120            .collect();
121        paths.sort_by_key(|(first_lsn, _)| *first_lsn);
122
123        // A crash while the newest segment was being created can leave it
124        // shorter than its header. Appends are acknowledged only after the
125        // header and the record are durable, so such a segment holds nothing:
126        // remove it and resume as if it had never been started. An older
127        // segment that short is corruption, as before.
128        if let Some((_, newest)) = paths.last() {
129            if std::fs::metadata(newest)?.len() < LOG_SEGMENT_HEADER_LEN as u64 {
130                std::fs::remove_file(newest)?;
131                File::open(dir)?.sync_all()?;
132                paths.pop();
133            }
134        }
135
136        let mut segments = Vec::with_capacity(paths.len());
137        let mut next_lsn = 1u64;
138        let last_index = paths.len().saturating_sub(1);
139        for (index, (first_lsn, path)) in paths.iter().enumerate() {
140            let is_active = index == last_index;
141            let (records, valid_bytes, file_len) = read_segment(path, shard_id, is_active)?;
142            if valid_bytes != file_len {
143                if !is_active {
144                    return Err(LogError::Corrupt {
145                        path: path.display().to_string(),
146                        detail: "sealed segment ends with a torn record".into(),
147                    });
148                }
149                // Interrupted append in the active segment: drop the tail so
150                // later appends resume on a clean record boundary.
151                let file = OpenOptions::new().write(true).open(path)?;
152                file.set_len(valid_bytes)?;
153                file.sync_all()?;
154            }
155            let last_lsn = records
156                .last()
157                .map_or(first_lsn.saturating_sub(1), |(lsn, _)| *lsn);
158            next_lsn = last_lsn.saturating_add(1);
159            segments.push(SegmentInfo {
160                first_lsn: *first_lsn,
161                last_lsn,
162                path: path.clone(),
163                bytes: valid_bytes,
164            });
165        }
166
167        // Reuse the last segment for appends when it still has room; otherwise
168        // the next append starts a fresh segment.
169        let active = match segments.last() {
170            Some(last) if last.bytes < segment_bytes => {
171                let file = OpenOptions::new().append(true).open(&last.path)?;
172                Some(ActiveSegment {
173                    file,
174                    first_lsn: last.first_lsn,
175                    bytes: last.bytes,
176                })
177            }
178            _ => None,
179        };
180
181        Ok(Self {
182            dir: dir.to_path_buf(),
183            shard_id,
184            segment_bytes,
185            segments,
186            next_lsn,
187            active,
188        })
189    }
190
191    /// Seed `next_lsn` from `floor` when this log has no existing segments
192    /// (a freshly created, never-before-written directory) and `floor`
193    /// exceeds the default start of 1. For opening the WAL beside a
194    /// materialization whose checkpoint is already ahead of an empty WAL —
195    /// a store opened from a restored base backup with no accompanying WAL
196    /// directory, for example — so the next commit continues the sequence
197    /// instead of colliding with LSNs the materialization already has. A
198    /// no-op once any segment exists: a real recovered `next_lsn` is
199    /// authoritative and must never be overridden.
200    pub(crate) fn seed_next_lsn_if_empty(&mut self, floor: u64) {
201        if self.segments.is_empty() && self.active.is_none() && floor > self.next_lsn {
202            self.next_lsn = floor;
203        }
204    }
205
206    /// The physical shard this log belongs to.
207    #[must_use]
208    pub fn shard_id(&self) -> u32 {
209        self.shard_id
210    }
211
212    /// The LSN the next appended record must carry.
213    #[must_use]
214    pub fn next_lsn(&self) -> u64 {
215        self.next_lsn
216    }
217
218    /// The highest LSN appended so far (`0` when empty). After [`Self::sync`],
219    /// this is the durable LSN.
220    #[must_use]
221    pub fn durable_lsn(&self) -> u64 {
222        self.segments.last().map_or(0, |segment| segment.last_lsn)
223    }
224
225    /// The segment descriptors in LSN order.
226    #[must_use]
227    pub fn segments(&self) -> &[SegmentInfo] {
228        &self.segments
229    }
230
231    /// Append framed records (each produced by [`crate::wal::encode_wal_record`]
232    /// or [`crate::wal::encode_chained_ops_record`]) with contiguous, ascending
233    /// LSNs. **Chaining (large-transaction support):** several
234    /// consecutive entries may share one LSN — a chained commit's fragments,
235    /// which must all be present in this same call (never split across two
236    /// `append_batch` calls: see the caller's own invariant in
237    /// `Store::append_log`) — and are grouped and written as one atomic unit:
238    /// `next_lsn` advances only once per group, and the whole group is kept
239    /// in one segment file (rotating *before* its first fragment if it would
240    /// not otherwise fit, rather than splitting it across two segments).
241    ///
242    /// Records are written to the active segment, rotating to a new segment
243    /// once the configured size is reached. The write is buffered by the OS;
244    /// call [`Self::sync`] to make it durable before acknowledging.
245    ///
246    /// # Errors
247    /// Returns [`LogError::OutOfOrder`] if an LSN is not the expected next LSN,
248    /// [`LogError::Oversized`] if a record exceeds the ceiling, or
249    /// [`LogError::Io`] on write failure.
250    pub fn append_batch(&mut self, records: &[(u64, Vec<u8>)]) -> Result<(), LogError> {
251        let mut i = 0;
252        while i < records.len() {
253            let lsn = records[i].0;
254            if lsn != self.next_lsn {
255                return Err(LogError::OutOfOrder {
256                    expected: self.next_lsn,
257                    actual: lsn,
258                });
259            }
260            let mut group_end = i + 1;
261            while group_end < records.len() && records[group_end].0 == lsn {
262                group_end += 1;
263            }
264            let group = &records[i..group_end];
265            for (_, framed) in group {
266                if framed.len() > MAX_RECORD_BYTES {
267                    return Err(LogError::Oversized(framed.len()));
268                }
269            }
270            let group_bytes: u64 = group.iter().map(|(_, framed)| framed.len() as u64).sum();
271            let would_overflow = self.active.as_ref().is_some_and(|active| {
272                active.bytes > 0 && active.bytes + group_bytes > self.segment_bytes
273            });
274            if would_overflow {
275                self.seal_active()?;
276            }
277            if self.active.is_none() {
278                self.create_active(lsn)?;
279            }
280            for (_, framed) in group {
281                let active = self.active.as_mut().ok_or_else(|| LogError::Corrupt {
282                    path: self.dir.display().to_string(),
283                    detail: "active segment missing after creation".into(),
284                })?;
285                active.file.write_all(framed)?;
286                active.bytes += framed.len() as u64;
287                if let Some(last) = self
288                    .segments
289                    .last_mut()
290                    .filter(|last| last.first_lsn == active.first_lsn)
291                {
292                    last.last_lsn = lsn;
293                    last.bytes = active.bytes;
294                }
295            }
296            self.next_lsn = lsn.saturating_add(1);
297            i = group_end;
298        }
299        Ok(())
300    }
301
302    /// Make all appended records durable (fsync the active segment and the
303    /// directory so a freshly created segment name survives a crash).
304    ///
305    /// # Errors
306    /// Returns [`LogError::Io`] if the fsync fails.
307    pub fn sync(&mut self) -> Result<(), LogError> {
308        if let Some(active) = self.active.as_mut() {
309            active.file.sync_data()?;
310        }
311        sync_dir(&self.dir)?;
312        Ok(())
313    }
314
315    /// Read every record with LSN `>= from_lsn`, in order, as framed bytes.
316    ///
317    /// # Errors
318    /// Returns [`LogError`] if a segment cannot be read or is corrupt.
319    pub fn read_from(&self, from_lsn: u64) -> Result<Vec<(u64, Vec<u8>)>, LogError> {
320        let last_index = self.segments.len().saturating_sub(1);
321        let mut out = Vec::new();
322        for (index, segment) in self.segments.iter().enumerate() {
323            if segment.last_lsn < from_lsn {
324                continue;
325            }
326            let (records, _, _) = read_segment(&segment.path, self.shard_id, index == last_index)?;
327            for (lsn, framed) in records {
328                if lsn >= from_lsn {
329                    out.push((lsn, framed));
330                }
331            }
332        }
333        Ok(out)
334    }
335
336    /// Descriptors of the segments that may hold LSNs in `[from_lsn,
337    /// until_lsn)`, each flagged when it is the active (last) segment. Callers
338    /// read them with [`read_segment_records`] **after** releasing the log
339    /// lock, so a retention sweep never blocks WAL appends while it reads.
340    #[must_use]
341    pub fn segments_in(&self, from_lsn: u64, until_lsn: u64) -> Vec<(SegmentInfo, bool)> {
342        let last_index = self.segments.len().saturating_sub(1);
343        self.segments
344            .iter()
345            .enumerate()
346            .filter(|(_, segment)| segment.last_lsn >= from_lsn && segment.first_lsn < until_lsn)
347            .map(|(index, segment)| (segment.clone(), index == last_index))
348            .collect()
349    }
350
351    /// Delete sealed segments that end before `lsn`. The active segment is
352    /// never removed, so the log always retains its live tail. Returns the
353    /// number of segments deleted.
354    ///
355    /// # Errors
356    /// Returns [`LogError::Io`] if a segment file cannot be removed.
357    pub fn truncate_before(&mut self, lsn: u64) -> Result<usize, LogError> {
358        let active_first = self.active.as_ref().map(|active| active.first_lsn);
359        let mut removed = 0;
360        let mut keep = Vec::with_capacity(self.segments.len());
361        for segment in &self.segments {
362            let is_active = active_first == Some(segment.first_lsn);
363            if !is_active && segment.last_lsn < lsn {
364                std::fs::remove_file(&segment.path)?;
365                removed += 1;
366            } else {
367                keep.push(segment.clone());
368            }
369        }
370        self.segments = keep;
371        Ok(removed)
372    }
373
374    /// Seal the active segment: fsync it, then stop appending to it.
375    fn seal_active(&mut self) -> Result<(), LogError> {
376        if let Some(active) = self.active.take() {
377            active.file.sync_all()?;
378        }
379        sync_dir(&self.dir)?;
380        Ok(())
381    }
382
383    /// Create and open a new segment whose first record is at `first_lsn`.
384    fn create_active(&mut self, first_lsn: u64) -> Result<(), LogError> {
385        let path = self.dir.join(segment_file_name(first_lsn));
386        let mut file = OpenOptions::new()
387            .create(true)
388            .write(true)
389            .truncate(true)
390            .open(&path)?;
391        let mut header = Vec::with_capacity(LOG_SEGMENT_HEADER_LEN);
392        header.extend_from_slice(&LOG_SEGMENT_MAGIC);
393        header.extend_from_slice(&WAL_FORMAT_VERSION.to_le_bytes());
394        header.extend_from_slice(&self.shard_id.to_le_bytes());
395        header.extend_from_slice(&first_lsn.to_le_bytes());
396        file.write_all(&header)?;
397        self.segments.push(SegmentInfo {
398            first_lsn,
399            last_lsn: first_lsn.saturating_sub(1),
400            path,
401            bytes: LOG_SEGMENT_HEADER_LEN as u64,
402        });
403        self.active = Some(ActiveSegment {
404            file,
405            first_lsn,
406            bytes: LOG_SEGMENT_HEADER_LEN as u64,
407        });
408        sync_dir(&self.dir)?;
409        Ok(())
410    }
411}
412
413/// Segment file name: zero-padded first LSN so lexical order is LSN order.
414fn segment_file_name(first_lsn: u64) -> String {
415    format!("{first_lsn:020}.seg")
416}
417
418/// fsync a directory so file creations/removals survive a crash.
419fn sync_dir(dir: &Path) -> io::Result<()> {
420    File::open(dir)?.sync_all()
421}
422
423/// Outcome of trying to fill a buffer from a reader.
424enum ReadFull {
425    /// The buffer was filled.
426    Full,
427    /// The reader was already at end of stream.
428    CleanEof,
429    /// The stream ended partway through the buffer.
430    Partial,
431}
432
433fn read_full<R: Read>(reader: &mut R, buf: &mut [u8]) -> io::Result<ReadFull> {
434    let mut filled = 0;
435    while filled < buf.len() {
436        match reader.read(&mut buf[filled..]) {
437            Ok(0) => {
438                return Ok(if filled == 0 {
439                    ReadFull::CleanEof
440                } else {
441                    ReadFull::Partial
442                });
443            }
444            Ok(n) => filled += n,
445            Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
446            Err(error) => return Err(error),
447        }
448    }
449    Ok(ReadFull::Full)
450}
451
452/// Read one segment: validate its header and records, returning the decoded
453/// `(lsn, framed_bytes)` records and the byte offset after the last valid
454/// record. When `allow_tail` is set, a torn trailing record is tolerated
455/// (recovery of the active segment); otherwise it is corruption. The same
456/// tolerance (truncate in the active segment, hard error in a sealed one)
457/// applies to a **logically** incomplete trailing chain (a large
458/// transaction) — a run of physically well-formed
459/// `CHAIN_CONTINUE` records with no terminating `CHAIN_FINAL`, from a crash
460/// mid-chain — even though each individual record in it passes its own
461/// checksum. A sealed segment can never legitimately end mid-chain: writing
462/// one never spans two `append_batch` calls, and appends and retention
463/// truncation share one lock, so a chain is always either fully durable or
464/// never started by the time any other segment could have been sealed.
465/// Read one segment's framed records (see [`LogStore::segments_in`]).
466///
467/// # Errors
468/// Returns [`LogError`] if the segment cannot be read or is corrupt.
469pub fn read_segment_records(
470    path: &Path,
471    shard_id: u32,
472    allow_tail: bool,
473) -> Result<Vec<(u64, Vec<u8>)>, LogError> {
474    read_segment(path, shard_id, allow_tail).map(|(records, _, _)| records)
475}
476
477fn read_segment(
478    path: &Path,
479    shard_id: u32,
480    allow_tail: bool,
481) -> Result<(Vec<(u64, Vec<u8>)>, u64, u64), LogError> {
482    let mut file = File::open(path)?;
483    let file_len = file.metadata()?.len();
484    let corrupt = |detail: String| LogError::Corrupt {
485        path: path.display().to_string(),
486        detail,
487    };
488
489    let mut header = [0u8; LOG_SEGMENT_HEADER_LEN];
490    if !matches!(read_full(&mut file, &mut header)?, ReadFull::Full) {
491        return Err(corrupt("segment shorter than its header".into()));
492    }
493    if header[0..8] != LOG_SEGMENT_MAGIC {
494        return Err(corrupt("invalid segment magic".into()));
495    }
496    let version = u32::from_le_bytes(header[8..12].try_into().expect("slice"));
497    if version != WAL_FORMAT_VERSION {
498        return Err(corrupt(format!("unsupported segment version {version}")));
499    }
500    let segment_shard = u32::from_le_bytes(header[12..16].try_into().expect("slice"));
501    if segment_shard != shard_id {
502        return Err(corrupt(format!(
503            "segment shard {segment_shard} does not match {shard_id}"
504        )));
505    }
506
507    let mut offset = LOG_SEGMENT_HEADER_LEN as u64;
508    let mut records = Vec::new();
509    let mut record_offsets = Vec::new();
510    loop {
511        let record_start = offset;
512        let mut record_header = [0u8; LOG_RECORD_HEADER_LEN];
513        match read_full(&mut file, &mut record_header)? {
514            ReadFull::Full => {}
515            ReadFull::CleanEof => break,
516            ReadFull::Partial => {
517                if allow_tail {
518                    break;
519                }
520                return Err(corrupt("torn record header".into()));
521            }
522        }
523        let payload_len =
524            u32::from_le_bytes(record_header[24..28].try_into().expect("slice")) as usize;
525        if payload_len > MAX_RECORD_BYTES {
526            if allow_tail {
527                break;
528            }
529            return Err(corrupt("record payload exceeds 64 MiB".into()));
530        }
531        let mut raw = Vec::with_capacity(LOG_RECORD_HEADER_LEN + payload_len);
532        raw.extend_from_slice(&record_header);
533        raw.resize(LOG_RECORD_HEADER_LEN + payload_len, 0);
534        if !matches!(
535            read_full(&mut file, &mut raw[LOG_RECORD_HEADER_LEN..])?,
536            ReadFull::Full
537        ) {
538            if allow_tail {
539                break;
540            }
541            return Err(corrupt("torn record payload".into()));
542        }
543        match decode_wal_record(&raw) {
544            Ok((record, _)) => {
545                records.push((record.lsn, raw));
546                record_offsets.push(record_start);
547                offset += (LOG_RECORD_HEADER_LEN + payload_len) as u64;
548            }
549            Err(error) => {
550                if allow_tail {
551                    break;
552                }
553                return Err(corrupt(format!("record checksum/format: {error}")));
554            }
555        }
556    }
557
558    // Chain completeness: every physically well-formed record read above
559    // must also form logically complete chains. `valid_count` shrinks from
560    // `records.len()` only if a chain-structure error is found (treated
561    // exactly like a torn physical record) or the scan ends with a chain
562    // still open (a crash mid-chain) — see this function's doc comment.
563    let mut assembler = crate::wal::ChainAssembler::default();
564    let mut valid_count = records.len();
565    for (i, (_, raw)) in records.iter().enumerate() {
566        if let Err(detail) = assembler.feed(raw, record_offsets[i]) {
567            if !allow_tail {
568                return Err(corrupt(detail));
569            }
570            valid_count = i;
571            break;
572        }
573    }
574    if valid_count == records.len() {
575        if let Some(open_start) = assembler.open_start_offset() {
576            if !allow_tail {
577                return Err(corrupt(
578                    "sealed segment ends with an incomplete chained commit".into(),
579                ));
580            }
581            valid_count = record_offsets.partition_point(|&o| o < open_start);
582        }
583    }
584    let valid_bytes = record_offsets.get(valid_count).copied().unwrap_or(offset);
585    records.truncate(valid_count);
586
587    Ok((records, valid_bytes, file_len))
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593    use crate::wal::{encode_chained_ops_record, encode_wal_record};
594
595    fn temp_dir() -> crate::TempPath {
596        crate::TempPath::dir("logstore")
597    }
598
599    /// Fragments of one chained commit at `lsn`, as `append_batch` expects
600    /// them: several entries sharing one LSN.
601    fn chained_records(
602        lsn: u64,
603        ops: &[crate::Op],
604        max_record_bytes: usize,
605    ) -> Vec<(u64, Vec<u8>)> {
606        encode_chained_ops_record(0, lsn, ops, None, max_record_bytes)
607            .into_iter()
608            .map(|framed| (lsn, framed))
609            .collect()
610    }
611
612    fn big_ops(n: usize, value_len: usize) -> Vec<crate::Op> {
613        (0..n)
614            .map(|i| crate::Op::Put(format!("k{i}").into_bytes(), vec![b'x'; value_len]))
615            .collect()
616    }
617
618    #[test]
619    fn a_newest_segment_torn_during_creation_holds_nothing_and_is_dropped() {
620        let dir = temp_dir();
621        {
622            let mut log = LogStore::open(&dir, 0, 1 << 30).unwrap();
623            log.append_batch(&[record(1, b"one"), record(2, b"two")])
624                .unwrap();
625            log.sync().unwrap();
626        }
627        // A crash while the next segment was being created: fewer bytes than
628        // its header.
629        std::fs::write(dir.join(format!("{:020}.seg", 3)), [0x52, 0x44]).unwrap();
630        let mut log = LogStore::open(&dir, 0, 1 << 30).unwrap();
631        assert_eq!(log.next_lsn(), 3, "the durable records are all there");
632        assert!(!dir.join(format!("{:020}.seg", 3)).exists());
633        log.append_batch(&[record(3, b"three")]).unwrap();
634        log.sync().unwrap();
635        drop(log);
636        let log = LogStore::open(&dir, 0, 1 << 30).unwrap();
637        assert_eq!(log.read_from(1).unwrap().len(), 3);
638
639        // The only segment torn: an empty log.
640        let fresh = temp_dir();
641        std::fs::create_dir_all(&fresh).unwrap();
642        std::fs::write(fresh.join(format!("{:020}.seg", 1)), []).unwrap();
643        let log = LogStore::open(&fresh, 0, 1 << 30).unwrap();
644        assert_eq!(log.next_lsn(), 1);
645    }
646
647    #[test]
648    fn an_older_segment_shorter_than_its_header_is_corrupt() {
649        let dir = temp_dir();
650        {
651            let mut log = LogStore::open(&dir, 0, 1 << 30).unwrap();
652            log.append_batch(&[record(1, b"one")]).unwrap();
653            log.sync().unwrap();
654        }
655        // A short segment ordered before the valid one, so not the newest.
656        std::fs::write(dir.join(format!("{:020}.seg", 0)), [0x52]).unwrap();
657        assert!(matches!(
658            LogStore::open(&dir, 0, 1 << 30),
659            Err(LogError::Corrupt { .. })
660        ));
661    }
662
663    #[test]
664    fn chained_commit_advances_next_lsn_once_not_per_fragment() {
665        let dir = temp_dir();
666        let mut log = LogStore::open(&dir, 0, 1 << 30).unwrap();
667        let ops = big_ops(20, 200);
668        let fragments = chained_records(1, &ops, 1024);
669        assert!(fragments.len() > 1, "expected this commit to need chaining");
670        log.append_batch(&fragments).unwrap();
671        assert_eq!(
672            log.next_lsn(),
673            2,
674            "a multi-fragment commit must advance next_lsn exactly once"
675        );
676        log.sync().unwrap();
677        let all = log.read_from(1).unwrap();
678        assert_eq!(
679            all.len(),
680            fragments.len(),
681            "every physical fragment is durable"
682        );
683
684        // The next commit continues at lsn 2, unaffected by how many physical
685        // records the chained commit took.
686        log.append_batch(&[record(2, b"small")]).unwrap();
687        assert_eq!(log.next_lsn(), 3);
688    }
689
690    #[test]
691    fn chained_commit_never_splits_across_a_segment_boundary() {
692        let dir = temp_dir();
693        // A small rotation threshold that a chained commit's total size will
694        // comfortably exceed on its own, to prove rotation happens *before*
695        // the chain starts, not partway through it.
696        let mut log = LogStore::open(&dir, 0, 512).unwrap();
697        let ops = big_ops(30, 100);
698        let fragments = chained_records(1, &ops, 1024);
699        assert!(fragments.len() > 1);
700        log.append_batch(&fragments).unwrap();
701        log.sync().unwrap();
702
703        // Every one of the chain's fragments must land in exactly one
704        // segment (the active one at the time the chain started).
705        let owning_segments: std::collections::HashSet<u64> = log
706            .segments()
707            .iter()
708            .filter(|s| s.first_lsn <= 1 && s.last_lsn >= 1)
709            .map(|s| s.first_lsn)
710            .collect();
711        assert_eq!(
712            owning_segments.len(),
713            1,
714            "a chained commit must not be split across segment files"
715        );
716    }
717
718    #[test]
719    fn reopen_discards_an_incomplete_trailing_chain_and_retry_at_the_same_lsn_succeeds() {
720        let dir = temp_dir();
721        {
722            let mut log = LogStore::open(&dir, 0, 1 << 30).unwrap();
723            log.append_batch(&[record(1, b"first")]).unwrap();
724            log.sync().unwrap();
725        }
726        // Simulate a crash mid-chain: append only the non-final fragments of
727        // a chained commit at lsn 2 (drop the final fragment, as if the
728        // process died before writing it).
729        let ops = big_ops(10, 200);
730        let fragments = encode_chained_ops_record(0, 2, &ops, None, 512);
731        assert!(
732            fragments.len() > 2,
733            "need at least one continue + the final"
734        );
735        {
736            let mut file = OpenOptions::new()
737                .append(true)
738                .open(dir.join(segment_file_name(1)))
739                .unwrap();
740            for framed in &fragments[..fragments.len() - 1] {
741                file.write_all(framed).unwrap();
742            }
743            file.sync_all().unwrap();
744        }
745        let before_len = std::fs::metadata(dir.join(segment_file_name(1)))
746            .unwrap()
747            .len();
748
749        let mut log = LogStore::open(&dir, 0, 1 << 30).unwrap();
750        assert_eq!(
751            log.next_lsn(),
752            2,
753            "the incomplete chain must not advance past its own lsn"
754        );
755        assert_eq!(
756            log.read_from(1).unwrap().len(),
757            1,
758            "only the first, complete commit survives"
759        );
760        let after_len = std::fs::metadata(dir.join(segment_file_name(1)))
761            .unwrap()
762            .len();
763        assert!(
764            after_len < before_len,
765            "the incomplete chain's bytes must be truncated away"
766        );
767
768        // A retry at the same, abandoned lsn must succeed cleanly — proving
769        // the stale fragments were truly removed, not just ignored (which
770        // would otherwise corrupt a later replay by gluing new fragments
771        // onto old garbage sharing the same lsn).
772        log.append_batch(&[record(2, b"retry")]).unwrap();
773        log.sync().unwrap();
774        assert_eq!(log.next_lsn(), 3);
775        let all = log.read_from(1).unwrap();
776        assert_eq!(all.len(), 2);
777        let (_, ops, _) = crate::wal::decode_ops_record(&all[1].1).unwrap();
778        assert_eq!(ops, vec![crate::Op::Put(Vec::new(), b"retry".to_vec())]);
779    }
780
781    /// Regression: a record whose *content* is corrupt (an embedded
782    /// command that fails to parse as JSON) is structurally complete — a
783    /// well-formed, checksummed, non-chained `WITH_COMMAND` frame — and
784    /// must survive reopen untouched. The chain-completeness recovery pass
785    /// must never fully decode a record's content just to decide whether a
786    /// trailing chain is open; doing so would misclassify this as "looks
787    /// like a torn write" and silently truncate a perfectly durable record
788    /// whose only problem is a corrupt payload one layer up.
789    #[test]
790    fn reopen_never_truncates_a_structurally_complete_record_with_corrupt_content() {
791        let dir = temp_dir();
792        {
793            let mut log = LogStore::open(&dir, 0, 1 << 30).unwrap();
794            let framed = crate::wal::encode_ops_record(0, 1, &[], Some(b"not-json"));
795            log.append_batch(&[(1, framed)]).unwrap();
796            log.sync().unwrap();
797        }
798        let log = LogStore::open(&dir, 0, 1 << 30).unwrap();
799        assert_eq!(
800            log.next_lsn(),
801            2,
802            "a structurally complete record must not be discarded"
803        );
804        let all = log.read_from(1).unwrap();
805        assert_eq!(
806            all.len(),
807            1,
808            "the corrupt-content record must survive reopen"
809        );
810        // Decoding it explicitly (as a real caller needing its content
811        // would) still correctly surfaces the content error — it is simply
812        // never used as a signal to truncate during recovery.
813        assert!(crate::wal::decode_ops_record(&all[0].1).is_err());
814    }
815
816    /// A real, decodable wal.rs-formatted record (one `Op::Put` carrying
817    /// `payload` as its value) — not raw bytes — since `read_segment`'s
818    /// chain-completeness pass now interprets every record's kind byte, the
819    /// same as production (`Store::append_log` never writes anything else).
820    fn record(lsn: u64, payload: &[u8]) -> (u64, Vec<u8>) {
821        let ops = vec![crate::Op::Put(Vec::new(), payload.to_vec())];
822        (lsn, crate::wal::encode_ops_record(0, lsn, &ops, None))
823    }
824
825    #[test]
826    fn append_sync_read_round_trip() {
827        let dir = temp_dir();
828        let mut log = LogStore::open(&dir, 0, 1 << 20).unwrap();
829        assert_eq!(log.next_lsn(), 1);
830        assert_eq!(log.durable_lsn(), 0);
831        log.append_batch(&[record(1, b"a"), record(2, b"bb"), record(3, b"ccc")])
832            .unwrap();
833        log.sync().unwrap();
834        assert_eq!(log.durable_lsn(), 3);
835        assert_eq!(log.next_lsn(), 4);
836
837        let all = log.read_from(1).unwrap();
838        assert_eq!(
839            all.iter().map(|(lsn, _)| *lsn).collect::<Vec<_>>(),
840            [1, 2, 3]
841        );
842        let tail = log.read_from(2).unwrap();
843        assert_eq!(tail.iter().map(|(lsn, _)| *lsn).collect::<Vec<_>>(), [2, 3]);
844        assert!(log.read_from(4).unwrap().is_empty());
845    }
846
847    /// A fresh WAL (no segments) opened beside a
848    /// materialization whose checkpoint is already ahead of it — e.g. a
849    /// store opened from a restored base backup with no accompanying WAL
850    /// directory — must continue the commit sequence from that checkpoint,
851    /// not restart at LSN 1 and collide with LSNs the materialization
852    /// already has.
853    #[test]
854    fn seed_next_lsn_if_empty_continues_past_a_restored_checkpoint() {
855        let dir = temp_dir();
856        let mut log = LogStore::open(&dir, 0, 1 << 20).unwrap();
857        assert_eq!(log.next_lsn(), 1);
858
859        log.seed_next_lsn_if_empty(51);
860        assert_eq!(
861            log.next_lsn(),
862            51,
863            "an empty log must adopt the materialization's checkpoint + 1"
864        );
865        log.append_batch(&[record(51, b"a")]).unwrap();
866        assert_eq!(log.next_lsn(), 52);
867
868        // Never seeds backwards past 1 for a genuinely fresh store (no
869        // prior checkpoint at all).
870        let fresh_dir = temp_dir();
871        let mut fresh = LogStore::open(&fresh_dir, 0, 1 << 20).unwrap();
872        fresh.seed_next_lsn_if_empty(1);
873        assert_eq!(fresh.next_lsn(), 1);
874
875        // A no-op once the log has real, recovered history — that `next_lsn`
876        // is authoritative and must never be overridden by a floor.
877        log.seed_next_lsn_if_empty(1_000);
878        assert_eq!(
879            log.next_lsn(),
880            52,
881            "seeding must not touch a log with real segments"
882        );
883    }
884
885    #[test]
886    fn rotates_segments_and_reads_across_them() {
887        let dir = temp_dir();
888        let mut log = LogStore::open(&dir, 0, 120).unwrap();
889        for lsn in 1..=6 {
890            log.append_batch(&[record(lsn, &[0xABu8; 40])]).unwrap();
891        }
892        log.sync().unwrap();
893        assert!(
894            log.segments().len() > 1,
895            "expected rotation, got {} segment(s)",
896            log.segments().len()
897        );
898        let all = log.read_from(1).unwrap();
899        assert_eq!(
900            all.iter().map(|(lsn, _)| *lsn).collect::<Vec<_>>(),
901            [1, 2, 3, 4, 5, 6]
902        );
903    }
904
905    #[test]
906    fn reopen_recovers_state() {
907        let dir = temp_dir();
908        {
909            let mut log = LogStore::open(&dir, 0, 1 << 20).unwrap();
910            log.append_batch(&[record(1, b"x"), record(2, b"y")])
911                .unwrap();
912            log.sync().unwrap();
913        }
914        let log = LogStore::open(&dir, 0, 1 << 20).unwrap();
915        assert_eq!(log.next_lsn(), 3);
916        assert_eq!(log.durable_lsn(), 2);
917        assert_eq!(log.read_from(1).unwrap().len(), 2);
918    }
919
920    #[test]
921    fn torn_tail_is_truncated_on_open() {
922        let dir = temp_dir();
923        {
924            let mut log = LogStore::open(&dir, 0, 1 << 20).unwrap();
925            log.append_batch(&[record(1, b"x"), record(2, b"y")])
926                .unwrap();
927            log.sync().unwrap();
928        }
929        // Simulate a crash mid-append: append a partial record to the active
930        // segment (a full 32-byte header claiming a payload that is not there).
931        let segment = dir.join(segment_file_name(1));
932        {
933            let mut file = OpenOptions::new().append(true).open(&segment).unwrap();
934            let framed = encode_wal_record(0, 3, b"a much longer payload");
935            file.write_all(&framed[..framed.len() - 5]).unwrap();
936        }
937        let before = std::fs::metadata(&segment).unwrap().len();
938        let log = LogStore::open(&dir, 0, 1 << 20).unwrap();
939        assert_eq!(log.next_lsn(), 3, "torn record must not advance the LSN");
940        assert_eq!(log.read_from(1).unwrap().len(), 2);
941        let after = std::fs::metadata(&segment).unwrap().len();
942        assert!(after < before, "torn tail must be truncated");
943    }
944
945    #[test]
946    fn sealed_torn_tail_is_corruption() {
947        let dir = temp_dir();
948        {
949            let mut log = LogStore::open(&dir, 0, 120).unwrap();
950            for lsn in 1..=4 {
951                log.append_batch(&[record(lsn, &[0xABu8; 40])]).unwrap();
952            }
953            log.sync().unwrap();
954        }
955        // Damage the first (sealed) segment's last record.
956        let first = dir.join(segment_file_name(1));
957        let mut bytes = std::fs::read(&first).unwrap();
958        let last = bytes.len() - 1;
959        bytes[last] ^= 0xFF;
960        std::fs::write(&first, &bytes).unwrap();
961        let error = LogStore::open(&dir, 0, 120).unwrap_err();
962        assert!(matches!(error, LogError::Corrupt { .. }), "got: {error}");
963    }
964
965    #[test]
966    fn out_of_order_append_is_rejected() {
967        let dir = temp_dir();
968        let mut log = LogStore::open(&dir, 0, 1 << 20).unwrap();
969        log.append_batch(&[record(1, b"a")]).unwrap();
970        let error = log.append_batch(&[record(3, b"c")]).unwrap_err();
971        assert!(
972            matches!(
973                error,
974                LogError::OutOfOrder {
975                    expected: 2,
976                    actual: 3
977                }
978            ),
979            "got: {error}"
980        );
981    }
982
983    #[test]
984    fn truncate_before_removes_only_sealed_segments() {
985        let dir = temp_dir();
986        let mut log = LogStore::open(&dir, 0, 120).unwrap();
987        for lsn in 1..=6 {
988            log.append_batch(&[record(lsn, &[0xABu8; 40])]).unwrap();
989        }
990        log.sync().unwrap();
991        let before = log.segments().len();
992        assert!(before > 1);
993        let active_first = log.segments().last().unwrap().first_lsn;
994        let removed = log.truncate_before(u64::MAX).unwrap();
995        assert_eq!(removed, before - 1, "all sealed segments must go");
996        assert_eq!(log.segments().len(), 1);
997        assert_eq!(log.segments()[0].first_lsn, active_first);
998        // The active segment and its records survive.
999        assert!(!log.read_from(active_first).unwrap().is_empty());
1000    }
1001
1002    #[test]
1003    fn rejects_foreign_shard_segment() {
1004        let dir = temp_dir();
1005        {
1006            let mut log = LogStore::open(&dir, 7, 1 << 20).unwrap();
1007            log.append_batch(&[record(1, b"a")]).unwrap();
1008            log.sync().unwrap();
1009        }
1010        let error = LogStore::open(&dir, 0, 1 << 20).unwrap_err();
1011        assert!(matches!(error, LogError::Corrupt { .. }), "got: {error}");
1012    }
1013}