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