Skip to main content

mongreldb_core/
wal.rs

1//! Append-only, group-commit, torn-write-safe WAL.
2//!
3//! Sub-ms writes come from the fact that [`Wal::append`] only copies bytes into
4//! the OS file buffer (and an in-process [`BufWriter`]); it does **not** fsync.
5//! A timer- or threshold-driven [`Wal::sync`] does the `flush() + sync_all()`
6//! and bumps the epoch.
7
8use crate::epoch::Epoch;
9use crate::manifest::TtlPolicy;
10use crate::rowid::RowId;
11use crate::schema::{ColumnDef, Schema};
12use crate::{MongrelError, Result};
13use crc::{Crc, CRC_32_ISCSI};
14use serde::{Deserialize, Serialize};
15use std::fs::{File, OpenOptions};
16use std::io::{BufReader, BufWriter, Read, Write};
17use std::path::{Path, PathBuf};
18use zeroize::Zeroizing;
19
20fn unix_nanos_now() -> u64 {
21    std::time::SystemTime::now()
22        .duration_since(std::time::UNIX_EPOCH)
23        .unwrap_or_default()
24        .as_nanos() as u64
25}
26
27pub const WAL_MAGIC: [u8; 8] = *b"MONGRWAL";
28const WAL_VERSION: u16 = 3;
29const HEADER_LEN: u64 = 8 + 2 + 4 + 8; // magic + version + reserved(incl enc_flag) + epoch_created
30/// Encryption flag stored in reserved[0] of the WAL header.
31const ENC_PLAINTEXT: u8 = 0;
32const ENC_AES_GCM: u8 = 1;
33
34/// `txn_id` reserved for system records (`Flush`) that are not part of any
35/// client transaction.
36pub const SYSTEM_TXN_ID: u64 = 0;
37
38const CRC32C: Crc<u32> = Crc::<u32>::new(&CRC_32_ISCSI);
39
40/// One mutation. `Put.rows` is a self-describing Arrow IPC stream (or, for tiny
41/// single-row writes, a compact row batch — both are opaque bytes to the WAL).
42/// `txn_id` groups records into a transaction; the group is sealed by a
43/// [`Op::TxnCommit`] carrying the same `txn_id`.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct Record {
46    pub seq: Epoch,
47    pub txn_id: u64,
48    pub op: Op,
49}
50
51/// A sorted run made durable as part of a transaction's commit (spec §7.1).
52/// Recovery links these into the table's run list at the commit epoch.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct AddedRun {
55    pub table_id: u64,
56    pub run_id: u128,
57    pub row_count: u64,
58    pub level: u8,
59    pub min_row_id: u64,
60    pub max_row_id: u64,
61    pub content_hash: [u8; 32],
62}
63
64/// A schema change logged through the WAL (spec §7.1; full DDL wiring in P2.7).
65/// The schema/column payload is carried as JSON bytes because `Schema`'s
66/// internally-tagged `TypeId` is not representable under the WAL's bincode frame
67/// encoding.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub enum DdlOp {
70    CreateTable {
71        table_id: u64,
72        name: String,
73        schema_json: Vec<u8>,
74    },
75    DropTable {
76        table_id: u64,
77    },
78    /// Replace one existing column definition with the JSON-encoded
79    /// [`ColumnDef`] produced by native ALTER COLUMN validation.
80    AlterTable {
81        table_id: u64,
82        column_json: Vec<u8>,
83    },
84    /// Rename a live table. The catalog entry's name changes; the `table_id`,
85    /// schema, on-disk layout, and in-memory table object are all untouched
86    /// (the table is keyed by `table_id`, not name). Recovery applies this by
87    /// rewriting the catalog entry's name; it is idempotent when the
88    /// checkpoint already carries `new_name`.
89    RenameTable {
90        table_id: u64,
91        new_name: String,
92    },
93    /// Replace or clear a table's manifest-backed TTL policy.
94    SetTtl {
95        table_id: u64,
96        policy_json: Vec<u8>,
97    },
98    /// Create or update a persistent materialized-view definition. Appended at
99    /// the end of the enum to preserve earlier bincode discriminants.
100    SetMaterializedView {
101        name: String,
102        definition_json: Vec<u8>,
103    },
104    /// Full persistent RLS/masking catalog replacement.
105    SetSecurityCatalog {
106        security_json: Vec<u8>,
107    },
108}
109
110impl DdlOp {
111    /// Encode a schema for [`DdlOp::CreateTable`].
112    pub fn encode_schema(schema: &Schema) -> Result<Vec<u8>> {
113        serde_json::to_vec(schema).map_err(|e| MongrelError::Other(format!("schema json: {e}")))
114    }
115
116    /// Decode a schema carried by [`DdlOp::CreateTable`].
117    pub fn decode_schema(bytes: &[u8]) -> Result<Schema> {
118        serde_json::from_slice(bytes).map_err(|e| MongrelError::Other(format!("schema json: {e}")))
119    }
120
121    pub fn encode_column(column: &ColumnDef) -> Result<Vec<u8>> {
122        serde_json::to_vec(column).map_err(|e| MongrelError::Other(format!("column json: {e}")))
123    }
124
125    pub fn decode_column(bytes: &[u8]) -> Result<ColumnDef> {
126        serde_json::from_slice(bytes).map_err(|e| MongrelError::Other(format!("column json: {e}")))
127    }
128
129    pub fn encode_ttl(policy: Option<TtlPolicy>) -> Result<Vec<u8>> {
130        serde_json::to_vec(&policy).map_err(|e| MongrelError::Other(format!("TTL json: {e}")))
131    }
132
133    pub fn decode_ttl(bytes: &[u8]) -> Result<Option<TtlPolicy>> {
134        serde_json::from_slice(bytes).map_err(|e| MongrelError::Other(format!("TTL json: {e}")))
135    }
136
137    pub fn encode_materialized_view(
138        definition: &crate::catalog::MaterializedViewEntry,
139    ) -> Result<Vec<u8>> {
140        serde_json::to_vec(definition)
141            .map_err(|e| MongrelError::Other(format!("materialized view json: {e}")))
142    }
143
144    pub fn decode_materialized_view(bytes: &[u8]) -> Result<crate::catalog::MaterializedViewEntry> {
145        serde_json::from_slice(bytes)
146            .map_err(|e| MongrelError::Other(format!("materialized view json: {e}")))
147    }
148
149    pub fn encode_security(security: &crate::security::SecurityCatalog) -> Result<Vec<u8>> {
150        serde_json::to_vec(security)
151            .map_err(|e| MongrelError::Other(format!("security catalog json: {e}")))
152    }
153
154    pub fn decode_security(bytes: &[u8]) -> Result<crate::security::SecurityCatalog> {
155        serde_json::from_slice(bytes)
156            .map_err(|e| MongrelError::Other(format!("security catalog json: {e}")))
157    }
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub enum Op {
162    Put {
163        table_id: u64,
164        rows: Vec<u8>,
165    },
166    Delete {
167        table_id: u64,
168        row_ids: Vec<RowId>,
169    },
170    TruncateTable {
171        table_id: u64,
172    },
173    /// Durable module-owned state for an external table. The payload is opaque
174    /// to the core; recovery writes the last committed payloads back under
175    /// `_vtab/<name>/state.json`.
176    ExternalTableState {
177        name: String,
178        state: Vec<u8>,
179    },
180    /// System marker (txn_id == [`SYSTEM_TXN_ID`]): everything up to
181    /// `flushed_epoch` for `table_id` is durable in a sorted run, so recovery
182    /// may skip replaying older records for that table.
183    Flush {
184        table_id: u64,
185        flushed_epoch: u64,
186    },
187    /// Seals a transaction: every earlier record with the same `txn_id` is
188    /// committed and becomes visible at `epoch`.
189    TxnCommit {
190        epoch: u64,
191        added_runs: Vec<AddedRun>,
192    },
193    /// Aborts a transaction; its staged records are discarded on recovery.
194    TxnAbort,
195    Ddl(DdlOp),
196    /// Row image captured immediately before a Delete in the same committed
197    /// transaction. Recovery ignores it; durable CDC uses it for delete/update
198    /// deltas. Appended last to preserve all earlier bincode discriminants.
199    BeforeImage {
200        table_id: u64,
201        row_id: RowId,
202        row: Vec<u8>,
203    },
204    /// Wall-clock UTC nanoseconds recorded immediately before TxnCommit. PITR
205    /// uses this durable ledger to map timestamp cutoffs to commit epochs.
206    CommitTimestamp {
207        unix_nanos: u64,
208    },
209}
210
211impl Record {
212    pub fn new(seq: Epoch, txn_id: u64, op: Op) -> Self {
213        Self { seq, txn_id, op }
214    }
215}
216
217/// Group-commit WAL writer. Append is O(buffer copy) and never fsyncs; callers
218/// (or a timer) drive [`Wal::sync`].
219pub struct Wal {
220    file: BufWriter<File>,
221    path: PathBuf,
222    /// Next sequence number to assign; equals `last_assigned.0 + 1`.
223    next_seq: u64,
224    unflushed_bytes: u64,
225    /// `sync()` automatically once this many bytes are buffered (0 = manual).
226    sync_byte_threshold: u64,
227    /// Optional AEAD cipher for frame-level encryption. When present, each
228    /// frame's payload is encrypted before writing.
229    cipher: Option<Box<dyn crate::encryption::Cipher>>,
230    /// Persisted segment number for this WAL segment. Forms the high 8 bytes
231    /// (big-endian) of the 12-byte AES-GCM nonce; the low 4 are the per-segment
232    /// frame counter. The WAL DEK is constant across all segments, so cross-
233    /// segment nonce uniqueness rests entirely on this number, which is drawn
234    /// from the catalog's monotonic `next_segment_no` (spec §7.1, review fix
235    /// #23). Determinism makes a reopened segment — which truncates and rewrites
236    /// the active file — reuse the SAME nonces it would have used pre-crash,
237    /// which is safe because the old frames are gone (overwritten).
238    segment_no: u64,
239    /// Per-segment frame counter. Occupies the low 4 bytes of the nonce, so
240    /// `append_record` refuses to write past `u32::MAX` frames in one segment
241    /// (that would truncate the counter and reuse a nonce under the DEK).
242    /// Segments rotate at flush long before this, so it is unreachable in
243    /// practice — but enforced rather than assumed.
244    frame_seq: u64,
245}
246
247impl Wal {
248    /// Create a new WAL segment, truncating any existing file at `path`.
249    pub fn create(path: impl AsRef<Path>, epoch_created: Epoch) -> Result<Self> {
250        Self::create_with_cipher(path, epoch_created, None, 0)
251    }
252
253    /// Create a new WAL segment with optional frame-level encryption. The
254    /// persisted `segment_no` namespaces AES-GCM nonces across segments under
255    /// the constant WAL DEK (spec §7.1, review fix #23).
256    pub fn create_with_cipher(
257        path: impl AsRef<Path>,
258        epoch_created: Epoch,
259        cipher: Option<Box<dyn crate::encryption::Cipher>>,
260        segment_no: u64,
261    ) -> Result<Self> {
262        let path = path.as_ref().to_path_buf();
263        let file = OpenOptions::new()
264            .create(true)
265            .read(true)
266            .write(true)
267            .truncate(true)
268            .open(&path)?;
269        let mut wal = Self {
270            file: BufWriter::with_capacity(1 << 20, file),
271            path,
272            next_seq: epoch_created.0 + 1,
273            unflushed_bytes: 0,
274            sync_byte_threshold: 64 * 1024,
275            cipher,
276            segment_no,
277            frame_seq: 0,
278        };
279        wal.write_header(epoch_created)?;
280        Ok(wal)
281    }
282
283    /// Append a record belonging to transaction `txn_id`. Assigns the next
284    /// monotonic sequence (the first record after a WAL created at `E` gets
285    /// `E + 1`), writes it, and returns the assigned sequence. Does NOT fsync —
286    /// call [`Wal::sync`] (or rely on the byte threshold). The WAL sequence is
287    /// independent of the row commit epoch; the engine tracks commit epochs
288    /// separately.
289    pub fn append_txn(&mut self, txn_id: u64, op: Op) -> Result<Epoch> {
290        let seq = Epoch(self.next_seq);
291        self.next_seq += 1;
292        self.append_record(&Record::new(seq, txn_id, op))?;
293        Ok(seq)
294    }
295
296    /// Append a system record (txn_id == [`SYSTEM_TXN_ID`]), e.g. `Flush`.
297    pub fn append_system(&mut self, op: Op) -> Result<Epoch> {
298        self.append_txn(SYSTEM_TXN_ID, op)
299    }
300
301    fn append_record(&mut self, record: &Record) -> Result<()> {
302        let payload = bincode::serialize(record)?;
303
304        // Encrypt the payload if a cipher is present. The nonce is prepended
305        // to the ciphertext so the reader can extract it from a single read.
306        let frame_payload = if let Some(cipher) = &self.cipher {
307            // The frame counter occupies the low 4 bytes of the nonce. Refuse to
308            // wrap it — a wrapped counter would reuse a nonce under the constant
309            // WAL DEK (catastrophic for AES-GCM). Unreachable in practice
310            // (segments rotate at flush), but enforced.
311            if self.frame_seq > u32::MAX as u64 {
312                return Err(MongrelError::Full(
313                    "wal segment frame counter exhausted (2^32); rotate the segment".into(),
314                ));
315            }
316            let nonce = self.frame_nonce();
317            let ciphertext = cipher.encrypt_page(&nonce, &payload)?;
318            self.frame_seq += 1;
319            let mut combined = Vec::with_capacity(12 + ciphertext.len());
320            combined.extend_from_slice(&nonce);
321            combined.extend_from_slice(&ciphertext);
322            combined
323        } else {
324            payload
325        };
326
327        let len = frame_payload.len();
328        if len > u32::MAX as usize {
329            return Err(MongrelError::InvalidArgument(format!(
330                "wal payload too large: {len} bytes"
331            )));
332        }
333        // CRC covers seq + txn_id + (encrypted) payload.
334        let mut digest = CRC32C.digest();
335        digest.update(&record.seq.0.to_le_bytes());
336        digest.update(&record.txn_id.to_le_bytes());
337        digest.update(&frame_payload);
338        let crc_val = digest.finalize();
339
340        self.file.write_all(&(len as u32).to_le_bytes())?;
341        self.file.write_all(&crc_val.to_le_bytes())?;
342        self.file.write_all(&record.seq.0.to_le_bytes())?;
343        self.file.write_all(&record.txn_id.to_le_bytes())?;
344        self.file.write_all(&frame_payload)?;
345        self.unflushed_bytes += 4 + 4 + 8 + 8 + len as u64;
346        if self.sync_byte_threshold > 0 && self.unflushed_bytes >= self.sync_byte_threshold {
347            self.sync()?;
348        }
349        Ok(())
350    }
351
352    /// Build the 12-byte AES-GCM nonce for the current frame:
353    /// `[segment_no: 8B BE][frame_seq: 4B LE]`. `segment_no` is persisted and
354    /// monotonic across segments; the counter is unique within a segment, so
355    /// nonces never repeat under the constant WAL DEK — provided
356    /// `frame_seq <= u32::MAX`, which `append_record` enforces before calling.
357    fn frame_nonce(&self) -> [u8; 12] {
358        frame_nonce_for(self.segment_no, self.frame_seq as u32)
359    }
360
361    /// Flush the buffer and fsync the file. This is the durability point.
362    pub fn sync(&mut self) -> Result<()> {
363        self.file.flush()?;
364        self.file.get_ref().sync_all()?;
365        self.unflushed_bytes = 0;
366        Ok(())
367    }
368
369    /// Pending bytes not yet fsynced.
370    #[inline]
371    pub fn unflushed_bytes(&self) -> u64 {
372        self.unflushed_bytes
373    }
374
375    /// The next sequence number this writer will assign (i.e. last assigned + 1).
376    /// Exposed so a shared-WAL group-sync can report the durable high-water mark.
377    #[inline]
378    pub fn next_seq_val(&self) -> u64 {
379        self.next_seq
380    }
381
382    /// Tune the auto-sync threshold (bytes of buffered WAL before an automatic
383    /// `fsync`). `0` disables auto-sync entirely (manual [`Wal::sync`] only) —
384    /// useful for latency benchmarks and for grouping many writes under one
385    /// explicit commit.
386    pub fn set_sync_byte_threshold(&mut self, threshold: u64) {
387        self.sync_byte_threshold = threshold;
388    }
389
390    pub fn path(&self) -> &Path {
391        &self.path
392    }
393
394    fn write_header(&mut self, epoch_created: Epoch) -> Result<()> {
395        let enc_flag = if self.cipher.is_some() {
396            ENC_AES_GCM
397        } else {
398            ENC_PLAINTEXT
399        };
400        self.file.write_all(&WAL_MAGIC)?;
401        self.file.write_all(&WAL_VERSION.to_le_bytes())?;
402        self.file.write_all(&[enc_flag, 0, 0, 0])?; // enc_flag + 3 reserved
403        self.file.write_all(&epoch_created.0.to_le_bytes())?;
404        self.unflushed_bytes = 0;
405        Ok(())
406    }
407}
408
409impl Drop for Wal {
410    fn drop(&mut self) {
411        let _ = self.file.flush();
412    }
413}
414
415/// Streaming reader used by recovery. Stops at the first torn record
416/// (`REC_LEN == 0`) or CRC mismatch, returning the cleanly-committed prefix.
417pub struct WalReader {
418    inner: BufReader<File>,
419    pos: u64,
420    /// True if frames are encrypted (enc_flag in header).
421    encrypted: bool,
422    /// Optional cipher for decryption.
423    cipher: Option<Box<dyn crate::encryption::Cipher>>,
424}
425
426impl WalReader {
427    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
428        Self::open_with_cipher(path, None)
429    }
430
431    /// Open a WAL segment for reading, optionally with a decryption cipher.
432    pub fn open_with_cipher(
433        path: impl AsRef<Path>,
434        cipher: Option<Box<dyn crate::encryption::Cipher>>,
435    ) -> Result<Self> {
436        let mut file = File::open(path.as_ref())?;
437        let mut magic = [0u8; 8];
438        file.read_exact(&mut magic)?;
439        if magic != WAL_MAGIC {
440            return Err(MongrelError::MagicMismatch {
441                what: "wal",
442                expected: WAL_MAGIC,
443                got: magic,
444            });
445        }
446        let mut version_buf = [0u8; 2];
447        file.read_exact(&mut version_buf)?;
448        let version = u16::from_le_bytes(version_buf);
449        if version != WAL_VERSION {
450            return Err(MongrelError::InvalidArgument(format!(
451                "unsupported wal version {version}"
452            )));
453        }
454        let mut reserved = [0u8; 4];
455        file.read_exact(&mut reserved)?;
456        let encrypted = reserved[0] == ENC_AES_GCM;
457        let mut epoch_buf = [0u8; 8];
458        file.read_exact(&mut epoch_buf)?;
459        let _epoch_created = Epoch(u64::from_le_bytes(epoch_buf));
460        let pos = HEADER_LEN;
461        if encrypted && cipher.is_none() {
462            return Err(MongrelError::Decryption(
463                "WAL is encrypted but no passphrase or key was provided. \
464                 Use Table::open_encrypted or Table::open_with_key."
465                    .into(),
466            ));
467        }
468        Ok(Self {
469            inner: BufReader::new(file),
470            pos,
471            encrypted,
472            cipher,
473        })
474    }
475
476    /// Read the next record. Returns `Ok(None)` at a clean end-of-records
477    /// (zero-length marker or EOF), and `Err(TornWrite)` for a partial record.
478    pub fn next_record(&mut self) -> Result<Option<Record>> {
479        let mut len_buf = [0u8; 4];
480        match self.inner.read_exact(&mut len_buf) {
481            Ok(()) => {}
482            Err(ref e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
483            Err(e) => return Err(e.into()),
484        }
485        let len = u32::from_le_bytes(len_buf) as usize;
486        if len == 0 {
487            return Ok(None);
488        }
489        // A runaway length (torn header or garbage) would trigger a huge
490        // allocation; treat anything past the cap as a torn write.
491        const MAX_RECORD_LEN: usize = 64 * 1024 * 1024;
492        if len > MAX_RECORD_LEN {
493            return Err(MongrelError::TornWrite { offset: self.pos });
494        }
495
496        let record_start = self.pos;
497        let mut rest = vec![0u8; 4 + 8 + 8 + len];
498        match self.inner.read_exact(&mut rest) {
499            Ok(()) => {}
500            Err(ref e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
501                return Err(MongrelError::TornWrite {
502                    offset: record_start,
503                });
504            }
505            Err(e) => return Err(e.into()),
506        }
507        let crc_val = u32::from_le_bytes([rest[0], rest[1], rest[2], rest[3]]);
508        let seq = u64::from_le_bytes([
509            rest[4], rest[5], rest[6], rest[7], rest[8], rest[9], rest[10], rest[11],
510        ]);
511        let txn_id = u64::from_le_bytes([
512            rest[12], rest[13], rest[14], rest[15], rest[16], rest[17], rest[18], rest[19],
513        ]);
514        let payload = &rest[20..];
515
516        let mut digest = CRC32C.digest();
517        digest.update(&seq.to_le_bytes());
518        digest.update(&txn_id.to_le_bytes());
519        digest.update(payload);
520        if digest.finalize() != crc_val {
521            return Err(MongrelError::CorruptWal {
522                offset: record_start,
523                reason: "crc mismatch".into(),
524            });
525        }
526
527        // Decrypt if encrypted.
528        let plaintext = if self.encrypted {
529            let Some(cipher) = &self.cipher else {
530                return Err(MongrelError::Decryption(
531                    "WAL is encrypted but no cipher was provided".into(),
532                ));
533            };
534            if payload.len() < 28 {
535                // 12 (nonce) + 16 (min GCM tag) minimum
536                return Err(MongrelError::CorruptWal {
537                    offset: record_start,
538                    reason: "encrypted frame too short".into(),
539                });
540            }
541            let nonce: [u8; 12] = payload[..12].try_into().unwrap();
542            let ciphertext = &payload[12..];
543            cipher.decrypt_page(&nonce, ciphertext).map_err(|e| {
544                MongrelError::Decryption(format!(
545                    "WAL frame decryption failed — wrong passphrase or key? ({e})"
546                ))
547            })?
548        } else {
549            payload.to_vec()
550        };
551
552        // Trust the deserialized `seq`, not the outer frame `seq`: the outer one
553        // is covered only by an unkeyed CRC32C (recomputable by anyone with
554        // write access), whereas the inner `seq` rides inside the record — under
555        // the CRC for plaintext frames and under AES-GCM authentication for
556        // encrypted ones. They are written equal, so this changes nothing for
557        // honest data while denying a tamperer the ability to renumber a frame.
558        let record: Record = bincode::deserialize(&plaintext)?;
559        self.pos += 4 + 4 + 8 + 8 + len as u64;
560        Ok(Some(record))
561    }
562
563    /// Replay all cleanly-committed records. A torn tail (crash mid-append or a
564    /// partially-flushed last frame) is treated as end-of-log and truncated —
565    /// the valid prefix is returned. A CRC failure or short read that is
566    /// followed by a well-formed frame is treated as **interior corruption**
567    /// and surfaces as [`MongrelError::CorruptWal`] (spec §8.4, review fix #22).
568    pub fn replay(&mut self) -> Result<Vec<Record>> {
569        let mut out = Vec::new();
570        loop {
571            match self.next_record() {
572                Ok(Some(rec)) => out.push(rec),
573                Ok(None) => break,
574                Err(MongrelError::TornWrite { offset }) => {
575                    // Partial trailing frame: clean EOF unless a valid frame
576                    // follows it (which would mean the torn frame is interior).
577                    if self.valid_frame_follows()? {
578                        return Err(MongrelError::CorruptWal {
579                            offset,
580                            reason: "interior torn frame followed by a valid frame".into(),
581                        });
582                    }
583                    break;
584                }
585                Err(MongrelError::CorruptWal { offset, .. }) => {
586                    // CRC mismatch: torn tail if nothing valid follows, else
587                    // interior corruption.
588                    if self.valid_frame_follows()? {
589                        return Err(MongrelError::CorruptWal {
590                            offset,
591                            reason: "interior corruption: valid frame follows a CRC mismatch"
592                                .into(),
593                        });
594                    }
595                    break;
596                }
597                Err(e) => return Err(e),
598            }
599        }
600        Ok(out)
601    }
602
603    /// Probe whether a well-formed frame remains at the current read position.
604    /// Used by [`Self::replay`] to disambiguate a trailing torn frame from
605    /// interior corruption. The reader state is left positioned after the
606    /// probed frame; `replay` stops after calling this regardless.
607    fn valid_frame_follows(&mut self) -> Result<bool> {
608        match self.next_record() {
609            Ok(Some(_)) => Ok(true),
610            Ok(None) => Ok(false),
611            Err(_) => Ok(false),
612        }
613    }
614
615    /// Position the write cursor at end of file (for a reopen-and-append path,
616    /// to be implemented alongside segment rotation).
617    pub fn current_offset(&self) -> u64 {
618        self.pos
619    }
620}
621
622/// Replay every record from a WAL file, stopping at the first torn/corrupt one.
623/// Convenience wrapper around [`WalReader`].
624pub fn replay(path: impl AsRef<Path>) -> Result<Vec<Record>> {
625    WalReader::open(path)?.replay()
626}
627
628/// Replay with an optional decryption cipher (for encrypted WAL segments).
629pub fn replay_with_cipher(
630    path: impl AsRef<Path>,
631    cipher: Option<Box<dyn crate::encryption::Cipher>>,
632) -> Result<Vec<Record>> {
633    WalReader::open_with_cipher(path, cipher)?.replay()
634}
635
636/// Build the deterministic 12-byte AES-GCM nonce for `(segment_no, frame)`:
637/// `[segment_no: 8B BE][frame: 4B LE]`. The high 8 bytes are unique per
638/// segment (persisted monotonic counter) and the low 4 are unique per frame
639/// within a segment, so the pair never collides under the constant WAL DEK.
640pub fn frame_nonce_for(segment_no: u64, frame: u32) -> [u8; 12] {
641    let mut n = [0u8; 12];
642    n[..8].copy_from_slice(&segment_no.to_be_bytes());
643    n[8..].copy_from_slice(&frame.to_le_bytes());
644    n
645}
646
647/// A WAL shared across all tables of a `Database`, multiplexing many tables'
648/// records onto one fd (spec §7.2). Owns the active `Wal` segment plus the list
649/// of rotated segments under `<root>/_wal/`. Appends are buffered; a single
650/// [`SharedWal::group_sync`] is the durability point for every concurrent
651/// writer that appended since the last sync.
652pub struct SharedWal {
653    wal_dir: PathBuf,
654    active: Wal,
655    /// Monotonic segment number of the active segment (namespaces nonces).
656    active_segment_no: u64,
657    /// Highest sequence number reported durable by the last successful
658    /// `group_sync`. P3's group-commit publishes only commits at or below this.
659    durable_seq: u64,
660    /// WAL DEK (constant across segments). None for plaintext. Kept so a
661    /// `rotate` can rebuild the per-segment cipher under the same key.
662    wal_dek: Option<Zeroizing<[u8; 32]>>,
663    /// Count of actual fsyncs issued via [`Self::group_sync`]. With real group
664    /// commit this is far below the commit count (one leader fsync serves many
665    /// followers). Diagnostic / test-facing.
666    group_sync_count: u64,
667}
668
669impl SharedWal {
670    /// Segment filename for a given number.
671    fn segment_path(wal_dir: &Path, segment_no: u64) -> PathBuf {
672        wal_dir.join(format!("seg-{segment_no:06}.wal"))
673    }
674
675    /// Build a per-segment frame cipher from the WAL DEK (encryption feature).
676    #[cfg(feature = "encryption")]
677    fn cipher_from_dek(dek: &Zeroizing<[u8; 32]>) -> Result<Box<dyn crate::encryption::Cipher>> {
678        Ok(Box::new(crate::encryption::AesCipher::new(&dek[..])?))
679    }
680
681    /// Create a fresh shared WAL at `<root>/_wal/` starting at `epoch_created`.
682    pub fn create(root: &Path, epoch_created: Epoch) -> Result<Self> {
683        Self::create_with_dek(root, epoch_created, None)
684    }
685
686    /// Create with optional frame-level encryption (WAL DEK).
687    pub fn create_with_dek(
688        root: &Path,
689        epoch_created: Epoch,
690        wal_dek: Option<Zeroizing<[u8; 32]>>,
691    ) -> Result<Self> {
692        let wal_dir = root.join("_wal");
693        std::fs::create_dir_all(&wal_dir)?;
694        let cipher = match &wal_dek {
695            #[cfg(feature = "encryption")]
696            Some(dk) => Some(Self::cipher_from_dek(dk)?),
697            #[cfg(not(feature = "encryption"))]
698            Some(_) => {
699                return Err(MongrelError::Encryption(
700                    "encryption feature disabled but a WAL DEK was supplied".into(),
701                ))
702            }
703            None => None,
704        };
705        let active =
706            Wal::create_with_cipher(Self::segment_path(&wal_dir, 0), epoch_created, cipher, 0)?;
707        Ok(Self {
708            wal_dir,
709            active,
710            active_segment_no: 0,
711            durable_seq: epoch_created.0,
712            wal_dek,
713            group_sync_count: 0,
714        })
715    }
716
717    /// Open an existing shared WAL for append, preserving prior segments (which
718    /// `replay` reads for recovery). A fresh active segment numbered one past
719    /// the highest existing is created — old segments are NOT truncated (review
720    /// fix #6), so a crash mid-recovery can re-replay them safely.
721    pub fn open(
722        root: &Path,
723        epoch_created: Epoch,
724        wal_dek: Option<Zeroizing<[u8; 32]>>,
725    ) -> Result<Self> {
726        let wal_dir = root.join("_wal");
727        std::fs::create_dir_all(&wal_dir)?;
728        let next_segment_no = list_segment_numbers(&wal_dir)?
729            .into_iter()
730            .max()
731            .map(|m| m + 1)
732            .unwrap_or(0);
733        let cipher = match &wal_dek {
734            #[cfg(feature = "encryption")]
735            Some(dk) => Some(Self::cipher_from_dek(dk)?),
736            #[cfg(not(feature = "encryption"))]
737            Some(_) => {
738                return Err(MongrelError::Encryption(
739                    "encryption feature disabled but a WAL DEK was supplied".into(),
740                ))
741            }
742            None => None,
743        };
744        let mut active = Wal::create_with_cipher(
745            Self::segment_path(&wal_dir, next_segment_no),
746            epoch_created,
747            cipher,
748            next_segment_no,
749        )?;
750        // Flush + fsync the fresh segment header so the recovery replay (which
751        // reads every segment) never sees a half-written file.
752        active.sync()?;
753        Ok(Self {
754            wal_dir,
755            active,
756            active_segment_no: next_segment_no,
757            durable_seq: epoch_created.0,
758            wal_dek,
759            group_sync_count: 0,
760        })
761    }
762
763    /// The active segment's wal_dir (test/diagnostic).
764    #[allow(dead_code)]
765    pub fn wal_dir(&self) -> &Path {
766        &self.wal_dir
767    }
768
769    /// Append a record for `(txn_id, table_id)`. Does not fsync.
770    pub fn append(&mut self, txn_id: u64, _table_id: u64, op: Op) -> Result<u64> {
771        Ok(self.active.append_txn(txn_id, op)?.0)
772    }
773
774    /// Append a `TxnCommit` marker sealing `txn_id` at `epoch`.
775    pub fn append_commit(&mut self, txn_id: u64, epoch: Epoch, added: &[AddedRun]) -> Result<u64> {
776        self.append_commit_at(txn_id, epoch, added, unix_nanos_now())
777    }
778
779    pub fn append_commit_at(
780        &mut self,
781        txn_id: u64,
782        epoch: Epoch,
783        added: &[AddedRun],
784        unix_nanos: u64,
785    ) -> Result<u64> {
786        self.active
787            .append_txn(txn_id, Op::CommitTimestamp { unix_nanos })?;
788        Ok(self
789            .active
790            .append_txn(
791                txn_id,
792                Op::TxnCommit {
793                    epoch: epoch.0,
794                    added_runs: added.to_vec(),
795                },
796            )?
797            .0)
798    }
799
800    /// Append a `TxnAbort` marker for `txn_id`.
801    pub fn append_abort(&mut self, txn_id: u64) -> Result<()> {
802        self.active.append_txn(txn_id, Op::TxnAbort)?;
803        Ok(())
804    }
805
806    /// Append a system record (txn_id == 0), e.g. `Flush`.
807    pub fn append_system(&mut self, op: Op) -> Result<u64> {
808        Ok(self.active.append_system(op)?.0)
809    }
810
811    /// Flush + fsync the active segment and return the highest durable sequence
812    /// number. This is the single durability point for every concurrent
813    /// appender since the last `group_sync`.
814    pub fn group_sync(&mut self) -> Result<u64> {
815        self.active.sync()?;
816        self.group_sync_count += 1;
817        let highest = self.active.next_seq_val().saturating_sub(1);
818        if highest > self.durable_seq {
819            self.durable_seq = highest;
820        }
821        Ok(self.durable_seq)
822    }
823
824    /// Number of fsyncs issued so far (test/diagnostic — see [`group_sync`]).
825    pub fn group_sync_count(&self) -> u64 {
826        self.group_sync_count
827    }
828
829    /// The highest sequence number reported durable by the last `group_sync`.
830    pub fn durable_seq(&self) -> u64 {
831        self.durable_seq
832    }
833
834    /// Rotate to a fresh segment numbered `segment_no` (which namespaces nonces
835    /// under the constant WAL DEK). The current segment must already be synced.
836    pub fn rotate(&mut self, segment_no: u64) -> Result<()> {
837        let cipher = match &self.wal_dek {
838            #[cfg(feature = "encryption")]
839            Some(dk) => Some(Self::cipher_from_dek(dk)?),
840            _ => None,
841        };
842        let path = Self::segment_path(&self.wal_dir, segment_no);
843        let epoch = Epoch(self.durable_seq);
844        let wal = Wal::create_with_cipher(path, epoch, cipher, segment_no)?;
845        self.active = wal;
846        self.active_segment_no = segment_no;
847        Ok(())
848    }
849
850    /// The active segment number.
851    pub fn active_segment_no(&self) -> u64 {
852        self.active_segment_no
853    }
854
855    /// Delete rotated (non-active) WAL segments whose records are all below
856    /// `min_retained_seq` — i.e. every record in them is already durable in a
857    /// run and not needed by any in-flight or committed-not-flushed txn (spec
858    /// §6.4/§16). The active segment is **never** deleted. Returns the count of
859    /// segment files reaped.
860    ///
861    /// `open()` mints a fresh active segment on every reopen without truncating
862    /// the prior ones (so a crash mid-recovery can re-replay), which means old
863    /// segments accumulate; this is what reaps them once their data is durable.
864    pub fn gc_segments(&mut self, min_retained_seq: u64) -> Result<usize> {
865        self.gc_segments_retain_recent(min_retained_seq, 0)
866    }
867
868    /// [`Self::gc_segments`] while retaining the newest `retain_recent`
869    /// rotated segments for replication followers. The active segment is
870    /// retained separately and does not count toward this limit.
871    pub fn gc_segments_retain_recent(
872        &mut self,
873        min_retained_seq: u64,
874        retain_recent: usize,
875    ) -> Result<usize> {
876        let mut segments = list_segment_numbers(&self.wal_dir)?;
877        segments.sort_unstable();
878        let retained: Vec<u64> = segments
879            .iter()
880            .rev()
881            .filter(|segment| **segment != self.active_segment_no)
882            .take(retain_recent)
883            .copied()
884            .collect();
885        let mut reaped = 0;
886        for n in segments {
887            if n == self.active_segment_no || retained.contains(&n) {
888                continue; // never delete the segment we're appending to
889            }
890            let path = Self::segment_path(&self.wal_dir, n);
891            // Fast path: an infinite floor means every non-active segment is
892            // reapable, so skip the (cold-but-not-free) full replay.
893            let reapable = if min_retained_seq == u64::MAX {
894                true
895            } else {
896                // A segment is reapable when its highest seq is below the
897                // retention floor. A torn/corrupt OLD segment that won't replay
898                // is treated as reapable: we are GCing it anyway and its records
899                // are by construction already durable in runs.
900                let recs = match &self.wal_dek {
901                    #[cfg(feature = "encryption")]
902                    Some(dk) => {
903                        let cipher = Self::cipher_from_dek(dk)?;
904                        replay_with_cipher(&path, Some(cipher))
905                    }
906                    _ => replay(&path),
907                };
908                match recs {
909                    Ok(recs) => recs.iter().map(|r| r.seq.0).max().unwrap_or(0) < min_retained_seq,
910                    Err(_) => true,
911                }
912            };
913            if reapable {
914                std::fs::remove_file(&path)?;
915                reaped += 1;
916            }
917        }
918        if reaped > 0 {
919            if let Ok(d) = std::fs::File::open(&self.wal_dir) {
920                let _ = d.sync_all();
921            }
922        }
923        Ok(reaped)
924    }
925
926    /// Verify the on-disk integrity of every WAL segment (spec §16): each
927    /// `seg-NNNNNN.wal` file under `<root>/_wal/` must open — its header magic
928    /// and version must parse, and for an encrypted WAL the frame cipher must
929    /// be derivable from the WAL DEK. A segment that fails to open is corrupt
930    /// or truncated and would break recovery. Returns one `(segment_no, error)`
931    /// pair per failing segment. The active (in-memory) segment is trusted by
932    /// construction and re-checked from disk like the others.
933    pub fn verify_segments(&self) -> Vec<(u64, String)> {
934        let mut bad = Vec::new();
935        let Ok(segments) = list_segment_numbers(&self.wal_dir) else {
936            return bad;
937        };
938        for n in segments {
939            let path = Self::segment_path(&self.wal_dir, n);
940            // The frame cipher is constant across segments under the WAL DEK;
941            // rebuild it per open (cheap key schedule, few segments).
942            let res = match &self.wal_dek {
943                #[cfg(feature = "encryption")]
944                Some(dk) => match Self::cipher_from_dek(dk) {
945                    Ok(cipher) => WalReader::open_with_cipher(&path, Some(cipher)),
946                    Err(e) => Err(e),
947                },
948                _ => WalReader::open_with_cipher(&path, None),
949            };
950            if let Err(e) = res {
951                bad.push((n, format!("{e}")));
952            }
953        }
954        bad
955    }
956
957    /// Replay every record across all segments in `<root>/_wal/`, in segment
958    /// order, applying the torn-tail-vs-interior-corruption rule per segment.
959    pub fn replay(root: &Path) -> Result<Vec<Record>> {
960        Self::replay_with_dek(root, None)
961    }
962
963    /// Replay with an optional WAL DEK (for encrypted segments).
964    pub fn replay_with_dek(
965        root: &Path,
966        wal_dek: Option<&Zeroizing<[u8; 32]>>,
967    ) -> Result<Vec<Record>> {
968        let wal_dir = root.join("_wal");
969        let mut segments = list_segment_numbers(&wal_dir)?;
970        segments.sort_unstable();
971        let mut out = Vec::new();
972        for n in segments {
973            let path = Self::segment_path(&wal_dir, n);
974            // Replay each segment independently: a torn tail in any segment
975            // truncates only that segment's prefix (interior corruption errors).
976            let recs = match wal_dek {
977                #[cfg(feature = "encryption")]
978                Some(dk) => {
979                    let cipher = Self::cipher_from_dek(dk)?;
980                    replay_with_cipher(&path, Some(cipher))?
981                }
982                _ => replay(&path)?,
983            };
984            out.extend(recs);
985        }
986        Ok(out)
987    }
988}
989
990/// List the segment numbers present under `wal_dir` (unsorted).
991fn list_segment_numbers(wal_dir: &Path) -> Result<Vec<u64>> {
992    let mut segments = Vec::new();
993    if let Ok(rd) = std::fs::read_dir(wal_dir) {
994        for entry in rd.flatten() {
995            let fname = entry.file_name();
996            let Some(s) = fname.to_str() else {
997                continue;
998            };
999            let Some(stripped) = s.strip_prefix("seg-") else {
1000                continue;
1001            };
1002            let Some(stripped) = stripped.strip_suffix(".wal") else {
1003                continue;
1004            };
1005            if let Ok(n) = stripped.parse::<u64>() {
1006                segments.push(n);
1007            }
1008        }
1009    }
1010    Ok(segments)
1011}
1012
1013#[cfg(test)]
1014mod shared_wal_tests {
1015    use super::*;
1016    use tempfile::tempdir;
1017
1018    #[test]
1019    fn shared_wal_interleaves_two_tables_one_fd() {
1020        let dir = tempdir().unwrap();
1021        let mut w = SharedWal::create(dir.path(), Epoch(0)).unwrap();
1022        w.append(
1023            1,
1024            10,
1025            Op::Put {
1026                table_id: 10,
1027                rows: vec![1],
1028            },
1029        )
1030        .unwrap();
1031        w.append(
1032            2,
1033            20,
1034            Op::Put {
1035                table_id: 20,
1036                rows: vec![2],
1037            },
1038        )
1039        .unwrap();
1040        w.append_commit(1, Epoch(1), &[]).unwrap();
1041        w.append_commit(2, Epoch(2), &[]).unwrap();
1042        let d = w.group_sync().unwrap();
1043        assert!(d >= 4);
1044        let recs = SharedWal::replay(dir.path()).unwrap();
1045        assert_eq!(
1046            recs.iter()
1047                .filter(|r| matches!(r.op, Op::Put { .. }))
1048                .count(),
1049            2
1050        );
1051        assert_eq!(
1052            recs.iter()
1053                .filter(|r| matches!(r.op, Op::TxnCommit { .. }))
1054                .count(),
1055            2
1056        );
1057    }
1058
1059    #[test]
1060    fn shared_wal_gc_retains_recent_rotated_segments() {
1061        let dir = tempdir().unwrap();
1062        let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
1063        for segment in 0..4u64 {
1064            wal.append_commit(segment + 1, Epoch(segment + 1), &[])
1065                .unwrap();
1066            wal.group_sync().unwrap();
1067            if segment < 3 {
1068                wal.rotate(segment + 1).unwrap();
1069            }
1070        }
1071        assert_eq!(wal.gc_segments_retain_recent(u64::MAX, 2).unwrap(), 1);
1072        let count = std::fs::read_dir(dir.path().join("_wal"))
1073            .unwrap()
1074            .flatten()
1075            .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "wal"))
1076            .count();
1077        assert_eq!(count, 3, "active plus two retained segments");
1078    }
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083    use super::*;
1084    use tempfile::tempdir;
1085
1086    #[test]
1087    fn append_then_replay_roundtrips() {
1088        let dir = tempdir().unwrap();
1089        let path = dir.path().join("seg-000000.wal");
1090        let mut wal = Wal::create(&path, Epoch(100)).unwrap();
1091        let s1 = wal
1092            .append_txn(
1093                7,
1094                Op::Put {
1095                    table_id: 1,
1096                    rows: vec![1, 2, 3],
1097                },
1098            )
1099            .unwrap();
1100        let s2 = wal
1101            .append_txn(
1102                7,
1103                Op::Delete {
1104                    table_id: 1,
1105                    row_ids: vec![RowId(7)],
1106                },
1107            )
1108            .unwrap();
1109        assert_eq!(s1, Epoch(101));
1110        assert_eq!(s2, Epoch(102));
1111        wal.sync().unwrap();
1112
1113        let records = replay(&path).unwrap();
1114        assert_eq!(records.len(), 2);
1115        assert_eq!(records[0].seq, Epoch(101));
1116        assert_eq!(records[0].txn_id, 7);
1117        match &records[0].op {
1118            Op::Put { table_id, rows } => {
1119                assert_eq!(*table_id, 1);
1120                assert_eq!(rows, &vec![1, 2, 3]);
1121            }
1122            other => panic!("unexpected op {other:?}"),
1123        }
1124        match &records[1].op {
1125            Op::Delete { row_ids, .. } => {
1126                assert_eq!(*row_ids, vec![RowId(7)]);
1127            }
1128            other => panic!("unexpected op {other:?}"),
1129        }
1130    }
1131
1132    #[test]
1133    fn record_roundtrips_with_txn_id_and_commit_marker() {
1134        let dir = tempdir().unwrap();
1135        let path = dir.path().join("seg-000000.wal");
1136        let mut w = Wal::create(&path, Epoch(0)).unwrap();
1137        w.append_txn(
1138            7,
1139            Op::Put {
1140                table_id: 3,
1141                rows: vec![1, 2, 3],
1142            },
1143        )
1144        .unwrap();
1145        w.append_txn(
1146            7,
1147            Op::TxnCommit {
1148                epoch: 11,
1149                added_runs: vec![],
1150            },
1151        )
1152        .unwrap();
1153        w.sync().unwrap();
1154        let recs = replay(&path).unwrap();
1155        assert_eq!(recs[0].txn_id, 7);
1156        assert!(matches!(recs[0].op, Op::Put { table_id: 3, .. }));
1157        assert!(matches!(recs[1].op, Op::TxnCommit { epoch: 11, .. }));
1158        // system records carry the reserved id
1159        let mut w2 = Wal::create(&path, Epoch(0)).unwrap();
1160        w2.append_system(Op::Flush {
1161            table_id: 3,
1162            flushed_epoch: 11,
1163        })
1164        .unwrap();
1165        w2.sync().unwrap();
1166        let recs = replay(&path).unwrap();
1167        assert_eq!(recs[0].txn_id, SYSTEM_TXN_ID);
1168        assert!(matches!(recs[0].op, Op::Flush { .. }));
1169    }
1170
1171    #[test]
1172    fn torn_write_is_detected() {
1173        let dir = tempdir().unwrap();
1174        let path = dir.path().join("seg-000001.wal");
1175        let mut wal = Wal::create(&path, Epoch(0)).unwrap();
1176        wal.append_txn(
1177            1,
1178            Op::Put {
1179                table_id: 1,
1180                rows: vec![0; 10],
1181            },
1182        )
1183        .unwrap();
1184        wal.sync().unwrap();
1185        drop(wal);
1186
1187        // Append a garbage partial record (simulate a crash mid-write).
1188        let mut f = OpenOptions::new().append(true).open(&path).unwrap();
1189        // REC_LEN claims 64 bytes but we only write a handful.
1190        f.write_all(&64u32.to_le_bytes()).unwrap();
1191        f.write_all(&[0u8; 7]).unwrap();
1192        f.sync_all().unwrap();
1193        drop(f);
1194
1195        let mut reader = WalReader::open(&path).unwrap();
1196        // The first real record reads fine.
1197        assert!(reader.next_record().unwrap().is_some());
1198        // The partial record surfaces as a torn write.
1199        let err = reader.next_record().unwrap_err();
1200        assert!(matches!(err, MongrelError::TornWrite { .. }), "got {err:?}");
1201    }
1202
1203    #[test]
1204    fn crc_corruption_is_detected() {
1205        let dir = tempdir().unwrap();
1206        let path = dir.path().join("seg-000002.wal");
1207        let mut wal = Wal::create(&path, Epoch(0)).unwrap();
1208        wal.append_txn(
1209            1,
1210            Op::Put {
1211                table_id: 9,
1212                rows: vec![1, 2, 3, 4],
1213            },
1214        )
1215        .unwrap();
1216        wal.sync().unwrap();
1217        drop(wal);
1218
1219        // Flip a payload byte well past the header.
1220        let mut bytes = std::fs::read(&path).unwrap();
1221        let last = bytes.len() - 1;
1222        bytes[last] ^= 0xFF;
1223        std::fs::write(&path, bytes).unwrap();
1224
1225        let err = WalReader::open(&path).unwrap().next_record().unwrap_err();
1226        assert!(
1227            matches!(err, MongrelError::CorruptWal { .. }),
1228            "got {err:?}"
1229        );
1230    }
1231
1232    #[test]
1233    fn trailing_torn_is_eof_but_interior_corruption_errors() {
1234        let dir = tempdir().unwrap();
1235
1236        // (a) good records then a half-written trailing frame -> replay returns
1237        //     the good prefix (torn tail = clean EOF).
1238        let path_a = dir.path().join("seg-torn.wal");
1239        let mut wal = Wal::create(&path_a, Epoch(0)).unwrap();
1240        wal.append_txn(
1241            1,
1242            Op::Put {
1243                table_id: 1,
1244                rows: vec![1],
1245            },
1246        )
1247        .unwrap();
1248        wal.append_txn(
1249            1,
1250            Op::Put {
1251                table_id: 1,
1252                rows: vec![2],
1253            },
1254        )
1255        .unwrap();
1256        wal.sync().unwrap();
1257        drop(wal);
1258        // Append a partial trailing frame (claims 64 bytes, only 7 written).
1259        let mut f = OpenOptions::new().append(true).open(&path_a).unwrap();
1260        f.write_all(&64u32.to_le_bytes()).unwrap();
1261        f.write_all(&[0u8; 7]).unwrap();
1262        f.sync_all().unwrap();
1263        drop(f);
1264        let recs = replay(&path_a).unwrap();
1265        assert_eq!(recs.len(), 2, "torn trailing frame must truncate cleanly");
1266
1267        // (b) corrupt an INTERIOR frame's CRC and append a valid frame after ->
1268        //     replay errors (interior corruption, not a torn tail).
1269        let path_b = dir.path().join("seg-interior.wal");
1270        let mut wal = Wal::create(&path_b, Epoch(0)).unwrap();
1271        wal.append_txn(
1272            1,
1273            Op::Put {
1274                table_id: 1,
1275                rows: vec![10, 20, 30],
1276            },
1277        )
1278        .unwrap();
1279        wal.append_txn(
1280            1,
1281            Op::Put {
1282                table_id: 1,
1283                rows: vec![40],
1284            },
1285        )
1286        .unwrap();
1287        wal.sync().unwrap();
1288        drop(wal);
1289        // Flip a payload byte of the FIRST frame (interior), leaving the second
1290        // frame intact so a valid frame follows the corrupt one.
1291        let mut bytes = std::fs::read(&path_b).unwrap();
1292        let first_payload_byte = HEADER_LEN as usize + 4 + 4 + 8 + 8; // past len+crc+seq+txn_id
1293        bytes[first_payload_byte] ^= 0xFF;
1294        std::fs::write(&path_b, bytes).unwrap();
1295        let err = replay(&path_b).unwrap_err();
1296        assert!(
1297            matches!(err, MongrelError::CorruptWal { .. }),
1298            "interior corruption must error, got {err:?}"
1299        );
1300
1301        // (c) a trailing frame whose CRC is bad (last frame, nothing valid
1302        //     after) is a torn tail -> clean truncation, no error.
1303        let path_c = dir.path().join("seg-badtail.wal");
1304        let mut wal = Wal::create(&path_c, Epoch(0)).unwrap();
1305        wal.append_txn(
1306            1,
1307            Op::Put {
1308                table_id: 1,
1309                rows: vec![5],
1310            },
1311        )
1312        .unwrap();
1313        wal.sync().unwrap();
1314        drop(wal);
1315        let mut bytes = std::fs::read(&path_c).unwrap();
1316        let last = bytes.len() - 1;
1317        bytes[last] ^= 0xFF;
1318        std::fs::write(&path_c, bytes).unwrap();
1319        let recs = replay(&path_c).unwrap();
1320        assert_eq!(
1321            recs.len(),
1322            0,
1323            "trailing corrupt frame with no valid follower is a torn tail"
1324        );
1325    }
1326
1327    #[test]
1328    fn byte_threshold_auto_syncs() {
1329        let dir = tempdir().unwrap();
1330        let path = dir.path().join("seg-000003.wal");
1331        let mut wal = Wal::create(&path, Epoch(0)).unwrap();
1332        wal.sync_byte_threshold = 1; // sync after every record
1333        wal.append_txn(
1334            1,
1335            Op::Put {
1336                table_id: 1,
1337                rows: vec![0; 5],
1338            },
1339        )
1340        .unwrap();
1341        assert_eq!(
1342            wal.unflushed_bytes(),
1343            0,
1344            "threshold should have auto-synced"
1345        );
1346    }
1347
1348    #[cfg(feature = "encryption")]
1349    #[test]
1350    fn wal_nonce_is_segment_deterministic() {
1351        // Two segments with different segment_no must never share a frame nonce
1352        // base, and frames within a segment never collide.
1353        assert_ne!(frame_nonce_for(5, 0), frame_nonce_for(6, 0));
1354        assert_ne!(frame_nonce_for(5, 0), frame_nonce_for(5, 1));
1355        // Deterministic: same inputs → same nonce (reopened segment reuses its
1356        // own nonces safely because the old frames were overwritten).
1357        assert_eq!(frame_nonce_for(5, 0), frame_nonce_for(5, 0));
1358    }
1359}