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