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