Skip to main content

mongreldb_core/
wal.rs

1//! Append-only, group-commit, torn-write-safe WAL.
2//!
3//! Sub-ms writes come from the fact that [`Wal::append`] only copies bytes into
4//! the OS file buffer (and an in-process [`BufWriter`]); it does **not** fsync.
5//! A timer- or threshold-driven [`Wal::sync`] does the `flush() + sync_all()`
6//! and bumps the epoch.
7
8use crate::epoch::Epoch;
9use crate::manifest::TtlPolicy;
10use crate::rowid::RowId;
11use crate::schema::{ColumnDef, Schema};
12use crate::{MongrelError, Result};
13use crc::{Crc, CRC_32_ISCSI};
14#[cfg(feature = "encryption")]
15use hmac::{Hmac, Mac};
16use serde::{Deserialize, Serialize};
17use sha2::{Digest as _, Sha256};
18use std::fs::{File, OpenOptions};
19use std::io::{BufReader, BufWriter, Read, Write};
20use std::path::{Path, PathBuf};
21use std::sync::Arc;
22use zeroize::Zeroizing;
23
24fn unix_nanos_now() -> u64 {
25    std::time::SystemTime::now()
26        .duration_since(std::time::UNIX_EPOCH)
27        .unwrap_or_default()
28        .as_nanos() as u64
29}
30
31pub const WAL_MAGIC: [u8; 8] = *b"MONGRWAL";
32const LEGACY_WAL_VERSION: u16 = 3;
33const WAL_VERSION: u16 = 4;
34const LEGACY_HEADER_LEN: u64 = 8 + 2 + 4 + 8;
35const HEADER_LEN: u64 = LEGACY_HEADER_LEN + 8 + 32;
36const WAL_FRAME_AAD_DOMAIN: &[u8] = b"mongreldb/wal-frame/v4";
37const WAL_HEAD_MAGIC: [u8; 8] = *b"MONGWHED";
38const WAL_HEAD_VERSION: u16 = 1;
39const WAL_HEAD_FILENAME: &str = "wal-head-v1";
40const WAL_HEAD_AUTH_DOMAIN: &[u8] = b"mongreldb/wal-head/v1";
41const WAL_HEAD_BODY_LEN: usize = 72;
42const WAL_HEAD_LEN: usize = WAL_HEAD_BODY_LEN + 32;
43const MAX_RECOVERY_WAL_BYTES: u64 = 512 * 1024 * 1024;
44const MAX_RECOVERY_WAL_RECORDS: usize = 1_000_000;
45/// Encryption flag stored in reserved[0] of the WAL header.
46const ENC_PLAINTEXT: u8 = 0;
47const ENC_AES_GCM: u8 = 1;
48
49#[derive(Clone, Copy, Debug)]
50struct WalHead {
51    segment_no: u64,
52    durable_len: u64,
53    open_generation: u64,
54    prefix_hash: [u8; 32],
55}
56
57/// `txn_id` reserved for system records (`Flush`) that are not part of any
58/// client transaction.
59pub const SYSTEM_TXN_ID: u64 = 0;
60
61const CRC32C: Crc<u32> = Crc::<u32>::new(&CRC_32_ISCSI);
62
63/// One mutation. `Put.rows` is a self-describing Arrow IPC stream (or, for tiny
64/// single-row writes, a compact row batch — both are opaque bytes to the WAL).
65/// `txn_id` groups records into a transaction; the group is sealed by a
66/// [`Op::TxnCommit`] carrying the same `txn_id`.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct Record {
69    pub seq: Epoch,
70    pub txn_id: u64,
71    pub op: Op,
72}
73
74/// A sorted run made durable as part of a transaction's commit (spec §7.1).
75/// Recovery links these into the table's run list at the commit epoch.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct AddedRun {
78    pub table_id: u64,
79    pub run_id: u128,
80    pub row_count: u64,
81    pub level: u8,
82    pub min_row_id: u64,
83    pub max_row_id: u64,
84    pub content_hash: [u8; 32],
85}
86
87/// A schema change logged through the WAL (spec §7.1; full DDL wiring in P2.7).
88/// The schema/column payload is carried as JSON bytes because `Schema`'s
89/// internally-tagged `TypeId` is not representable under the WAL's bincode frame
90/// encoding.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub enum DdlOp {
93    CreateTable {
94        table_id: u64,
95        name: String,
96        schema_json: Vec<u8>,
97    },
98    DropTable {
99        table_id: u64,
100    },
101    /// Replace one existing column definition with the JSON-encoded
102    /// [`ColumnDef`] produced by native ALTER COLUMN validation.
103    AlterTable {
104        table_id: u64,
105        column_json: Vec<u8>,
106    },
107    /// Rename a live table. The catalog entry's name changes; the `table_id`,
108    /// schema, on-disk layout, and in-memory table object are all untouched
109    /// (the table is keyed by `table_id`, not name). Recovery applies this by
110    /// rewriting the catalog entry's name; it is idempotent when the
111    /// checkpoint already carries `new_name`.
112    RenameTable {
113        table_id: u64,
114        new_name: String,
115    },
116    /// Replace or clear a table's manifest-backed TTL policy.
117    SetTtl {
118        table_id: u64,
119        policy_json: Vec<u8>,
120    },
121    /// Create or update a persistent materialized-view definition. Appended at
122    /// the end of the enum to preserve earlier bincode discriminants.
123    SetMaterializedView {
124        name: String,
125        definition_json: Vec<u8>,
126    },
127    /// Full persistent RLS/masking catalog replacement.
128    SetSecurityCatalog {
129        security_json: Vec<u8>,
130    },
131    /// Create a hidden CTAS build table. Appended to preserve prior bincode
132    /// discriminants.
133    CreateBuildingTable {
134        table_id: u64,
135        build_name: String,
136        intended_name: String,
137        query_id: String,
138        created_at_unix_nanos: u64,
139        schema_json: Vec<u8>,
140    },
141    /// Atomically publish a hidden CTAS build under its intended live name.
142    PublishBuildingTable {
143        table_id: u64,
144        new_name: String,
145    },
146    /// Create a hidden replacement build while its original table stays live.
147    /// Appended to preserve prior bincode discriminants.
148    CreateRebuildingTable {
149        table_id: u64,
150        build_name: String,
151        intended_name: String,
152        query_id: String,
153        created_at_unix_nanos: u64,
154        replaces_table_id: u64,
155        schema_json: Vec<u8>,
156    },
157    /// Atomically replace a live table with its completed hidden build.
158    ReplaceBuildingTable {
159        table_id: u64,
160        replaced_table_id: u64,
161        new_name: String,
162    },
163    /// Persist SQLite-compatible application metadata. Appended to preserve
164    /// all earlier bincode discriminants.
165    SetSqlPragma {
166        key: String,
167        value: i64,
168    },
169    /// Exact post-commit catalog image. Appended after operation-specific DDL
170    /// so recovery, PITR, and logical replication preserve all derived catalog
171    /// mutations. Appended last to preserve prior bincode discriminants.
172    CatalogSnapshot {
173        catalog_json: Vec<u8>,
174    },
175    /// Remove connector state when an external-table generation is created or
176    /// dropped. Appended last to preserve prior bincode discriminants.
177    ResetExternalTableState {
178        name: String,
179        generation_epoch: u64,
180    },
181}
182
183impl DdlOp {
184    /// Encode a schema for [`DdlOp::CreateTable`].
185    pub fn encode_schema(schema: &Schema) -> Result<Vec<u8>> {
186        serde_json::to_vec(schema).map_err(|e| MongrelError::Other(format!("schema json: {e}")))
187    }
188
189    /// Decode a schema carried by [`DdlOp::CreateTable`].
190    pub fn decode_schema(bytes: &[u8]) -> Result<Schema> {
191        serde_json::from_slice(bytes).map_err(|e| MongrelError::Other(format!("schema json: {e}")))
192    }
193
194    pub fn encode_column(column: &ColumnDef) -> Result<Vec<u8>> {
195        serde_json::to_vec(column).map_err(|e| MongrelError::Other(format!("column json: {e}")))
196    }
197
198    pub fn decode_column(bytes: &[u8]) -> Result<ColumnDef> {
199        serde_json::from_slice(bytes).map_err(|e| MongrelError::Other(format!("column json: {e}")))
200    }
201
202    pub fn encode_ttl(policy: Option<TtlPolicy>) -> Result<Vec<u8>> {
203        serde_json::to_vec(&policy).map_err(|e| MongrelError::Other(format!("TTL json: {e}")))
204    }
205
206    pub fn decode_ttl(bytes: &[u8]) -> Result<Option<TtlPolicy>> {
207        serde_json::from_slice(bytes).map_err(|e| MongrelError::Other(format!("TTL json: {e}")))
208    }
209
210    pub fn encode_materialized_view(
211        definition: &crate::catalog::MaterializedViewEntry,
212    ) -> Result<Vec<u8>> {
213        serde_json::to_vec(definition)
214            .map_err(|e| MongrelError::Other(format!("materialized view json: {e}")))
215    }
216
217    pub fn decode_materialized_view(bytes: &[u8]) -> Result<crate::catalog::MaterializedViewEntry> {
218        serde_json::from_slice(bytes)
219            .map_err(|e| MongrelError::Other(format!("materialized view json: {e}")))
220    }
221
222    pub fn encode_catalog(catalog: &crate::catalog::Catalog) -> Result<Vec<u8>> {
223        crate::catalog::encode(catalog)
224    }
225
226    pub fn decode_catalog(bytes: &[u8]) -> Result<crate::catalog::Catalog> {
227        crate::catalog::decode(bytes)
228    }
229
230    pub fn encode_security(security: &crate::security::SecurityCatalog) -> Result<Vec<u8>> {
231        serde_json::to_vec(security)
232            .map_err(|e| MongrelError::Other(format!("security catalog json: {e}")))
233    }
234
235    pub fn decode_security(bytes: &[u8]) -> Result<crate::security::SecurityCatalog> {
236        serde_json::from_slice(bytes)
237            .map_err(|e| MongrelError::Other(format!("security catalog json: {e}")))
238    }
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub enum Op {
243    Put {
244        table_id: u64,
245        rows: Vec<u8>,
246    },
247    Delete {
248        table_id: u64,
249        row_ids: Vec<RowId>,
250    },
251    TruncateTable {
252        table_id: u64,
253    },
254    /// Durable module-owned state for an external table. The payload is opaque
255    /// to the core; recovery writes the last committed payloads back under
256    /// `_vtab/<name>/state.json`.
257    ExternalTableState {
258        name: String,
259        state: Vec<u8>,
260    },
261    /// System marker (txn_id == [`SYSTEM_TXN_ID`]): everything up to
262    /// `flushed_epoch` for `table_id` is durable in a sorted run, so recovery
263    /// may skip replaying older records for that table.
264    Flush {
265        table_id: u64,
266        flushed_epoch: u64,
267    },
268    /// Seals a transaction: every earlier record with the same `txn_id` is
269    /// committed and becomes visible at `epoch`.
270    TxnCommit {
271        epoch: u64,
272        added_runs: Vec<AddedRun>,
273    },
274    /// Aborts a transaction; its staged records are discarded on recovery.
275    TxnAbort,
276    Ddl(DdlOp),
277    /// Row image captured immediately before a Delete in the same committed
278    /// transaction. Recovery ignores it; durable CDC uses it for delete/update
279    /// deltas. Appended last to preserve all earlier bincode discriminants.
280    BeforeImage {
281        table_id: u64,
282        row_id: RowId,
283        row: Vec<u8>,
284    },
285    /// Wall-clock UTC nanoseconds recorded immediately before TxnCommit. PITR
286    /// uses this durable ledger to map timestamp cutoffs to commit epochs.
287    CommitTimestamp {
288        unix_nanos: u64,
289    },
290    /// Logical rows for a transaction whose normal recovery path links an
291    /// immutable spilled run. Ordinary recovery ignores this duplicate
292    /// payload; PITR and CDC use it after the source run has been compacted or
293    /// garbage-collected. Chunks stay below the WAL reader frame limit.
294    SpilledRows {
295        table_id: u64,
296        rows: Vec<u8>,
297    },
298}
299
300impl Record {
301    pub fn new(seq: Epoch, txn_id: u64, op: Op) -> Self {
302        Self { seq, txn_id, op }
303    }
304}
305
306/// Group-commit WAL writer. Append is O(buffer copy) and never fsyncs; callers
307/// (or a timer) drive [`Wal::sync`].
308pub struct Wal {
309    file: BufWriter<File>,
310    path: PathBuf,
311    /// Next sequence number to assign; equals `last_assigned.0 + 1`.
312    next_seq: u64,
313    unflushed_bytes: u64,
314    /// `sync()` automatically once this many bytes are buffered (0 = manual).
315    sync_byte_threshold: u64,
316    /// Optional AEAD cipher for frame-level encryption. When present, each
317    /// frame's payload is encrypted before writing.
318    cipher: Option<Box<dyn crate::encryption::Cipher>>,
319    /// Persisted segment number for this WAL segment. Forms the high 8 bytes
320    /// (big-endian) of the 12-byte AES-GCM nonce; the low 4 are the per-segment
321    /// frame counter. The WAL DEK is constant across all segments, so cross-
322    /// segment nonce uniqueness rests entirely on this number, which is drawn
323    /// from the canonical monotonically increasing segment sequence. Existing
324    /// segment paths are never truncated or reused.
325    segment_no: u64,
326    /// Per-segment frame counter. Occupies the low 4 bytes of the nonce, so
327    /// `append_record` refuses to write past `u32::MAX` frames in one segment
328    /// (that would truncate the counter and reuse a nonce under the DEK).
329    /// Segments rotate at flush long before this, so it is unreachable in
330    /// practice — but enforced rather than assumed.
331    frame_seq: u64,
332    previous_segment_hash: [u8; 32],
333    header_binding: [u8; 32],
334}
335
336impl Wal {
337    /// Create a new WAL segment. Existing paths are never replaced.
338    pub fn create(path: impl AsRef<Path>, epoch_created: Epoch) -> Result<Self> {
339        let path = path.as_ref();
340        let segment_no = segment_number_from_path(path).unwrap_or(0);
341        Self::create_with_cipher(path, epoch_created, None, segment_no)
342    }
343
344    /// Create a new WAL segment with optional frame-level encryption. The
345    /// persisted `segment_no` namespaces AES-GCM nonces across segments under
346    /// the constant WAL DEK (spec §7.1, review fix #23).
347    pub fn create_with_cipher(
348        path: impl AsRef<Path>,
349        epoch_created: Epoch,
350        cipher: Option<Box<dyn crate::encryption::Cipher>>,
351        segment_no: u64,
352    ) -> Result<Self> {
353        Self::create_chained(path, epoch_created, cipher, segment_no, [0; 32])
354    }
355
356    fn create_chained(
357        path: impl AsRef<Path>,
358        epoch_created: Epoch,
359        cipher: Option<Box<dyn crate::encryption::Cipher>>,
360        segment_no: u64,
361        previous_segment_hash: [u8; 32],
362    ) -> Result<Self> {
363        let path = path.as_ref().to_path_buf();
364        let file = OpenOptions::new()
365            .create_new(true)
366            .read(true)
367            .write(true)
368            .open(&path)?;
369        let wal = Self::create_chained_from_file(
370            file,
371            path,
372            epoch_created,
373            cipher,
374            segment_no,
375            previous_segment_hash,
376        )?;
377        if let Some(parent) = wal.path.parent() {
378            crate::durable_file::sync_directory(parent)?;
379        }
380        Ok(wal)
381    }
382
383    fn create_chained_in(
384        wal_root: &crate::durable_file::DurableRoot,
385        segment_no: u64,
386        epoch_created: Epoch,
387        cipher: Option<Box<dyn crate::encryption::Cipher>>,
388        previous_segment_hash: [u8; 32],
389    ) -> Result<Self> {
390        let name = segment_filename(segment_no);
391        let file = wal_root.create_regular_new(&name)?;
392        let wal = Self::create_chained_from_file(
393            file,
394            wal_root.canonical_path().join(&name),
395            epoch_created,
396            cipher,
397            segment_no,
398            previous_segment_hash,
399        )?;
400        wal_root.sync_entry_parent(&name)?;
401        Ok(wal)
402    }
403
404    fn create_chained_from_file(
405        file: File,
406        path: PathBuf,
407        epoch_created: Epoch,
408        cipher: Option<Box<dyn crate::encryption::Cipher>>,
409        segment_no: u64,
410        previous_segment_hash: [u8; 32],
411    ) -> Result<Self> {
412        let next_seq = epoch_created
413            .0
414            .checked_add(1)
415            .ok_or_else(|| MongrelError::Full("WAL sequence namespace exhausted".into()))?;
416        let mut wal = Self {
417            file: BufWriter::with_capacity(1 << 20, file),
418            path,
419            next_seq,
420            unflushed_bytes: 0,
421            sync_byte_threshold: 64 * 1024,
422            cipher,
423            segment_no,
424            frame_seq: 0,
425            previous_segment_hash,
426            header_binding: [0; 32],
427        };
428        wal.write_header(epoch_created)?;
429        // A WAL segment is an authoritative commit prerequisite. Persist both
430        // its header and its directory entry before any caller can append a
431        // transaction that later reports a durable commit.
432        wal.sync()?;
433        Ok(wal)
434    }
435
436    /// Append a record belonging to transaction `txn_id`. Assigns the next
437    /// monotonic sequence (the first record after a WAL created at `E` gets
438    /// `E + 1`), writes it, and returns the assigned sequence. Does NOT fsync —
439    /// call [`Wal::sync`] (or rely on the byte threshold). The WAL sequence is
440    /// independent of the row commit epoch; the engine tracks commit epochs
441    /// separately.
442    pub fn append_txn(&mut self, txn_id: u64, op: Op) -> Result<Epoch> {
443        let seq = Epoch(self.next_seq);
444        let next_seq = self
445            .next_seq
446            .checked_add(1)
447            .ok_or_else(|| MongrelError::Full("WAL sequence namespace exhausted".into()))?;
448        self.append_record(&Record::new(seq, txn_id, op))?;
449        self.next_seq = next_seq;
450        Ok(seq)
451    }
452
453    /// Append a system record (txn_id == [`SYSTEM_TXN_ID`]), e.g. `Flush`.
454    pub fn append_system(&mut self, op: Op) -> Result<Epoch> {
455        self.append_txn(SYSTEM_TXN_ID, op)
456    }
457
458    fn append_record(&mut self, record: &Record) -> Result<()> {
459        let payload = bincode::serialize(record)?;
460
461        // Encrypt the payload if a cipher is present. The nonce is prepended
462        // to the ciphertext so the reader can extract it from a single read.
463        let frame_payload = if let Some(cipher) = &self.cipher {
464            // The frame counter occupies the low 4 bytes of the nonce. Refuse to
465            // wrap it — a wrapped counter would reuse a nonce under the constant
466            // WAL DEK (catastrophic for AES-GCM). Unreachable in practice
467            // (segments rotate at flush), but enforced.
468            if self.frame_seq > u32::MAX as u64 {
469                return Err(MongrelError::Full(
470                    "wal segment frame counter exhausted (2^32); rotate the segment".into(),
471                ));
472            }
473            let nonce = self.frame_nonce();
474            let ciphertext_len = payload.len().checked_add(16).ok_or_else(|| {
475                MongrelError::InvalidArgument("wal payload length overflow".into())
476            })?;
477            let aad = wal_frame_aad(
478                &self.header_binding,
479                self.segment_no,
480                self.frame_seq as u32,
481                record.seq.0,
482                record.txn_id,
483                ciphertext_len as u64,
484            );
485            let ciphertext = cipher.encrypt_page_with_aad(&nonce, &payload, &aad)?;
486            self.frame_seq += 1;
487            ciphertext
488        } else {
489            payload
490        };
491
492        let len = frame_payload.len();
493        if len > u32::MAX as usize {
494            return Err(MongrelError::InvalidArgument(format!(
495                "wal payload too large: {len} bytes"
496            )));
497        }
498        // CRC covers seq + txn_id + (encrypted) payload.
499        let mut digest = CRC32C.digest();
500        digest.update(&record.seq.0.to_le_bytes());
501        digest.update(&record.txn_id.to_le_bytes());
502        digest.update(&frame_payload);
503        let crc_val = digest.finalize();
504
505        self.file.write_all(&(len as u32).to_le_bytes())?;
506        self.file.write_all(&crc_val.to_le_bytes())?;
507        self.file.write_all(&record.seq.0.to_le_bytes())?;
508        self.file.write_all(&record.txn_id.to_le_bytes())?;
509        self.file.write_all(&frame_payload)?;
510        self.unflushed_bytes += 4 + 4 + 8 + 8 + len as u64;
511        if self.sync_byte_threshold > 0 && self.unflushed_bytes >= self.sync_byte_threshold {
512            self.sync()?;
513        }
514        Ok(())
515    }
516
517    /// Build the 12-byte AES-GCM nonce for the current frame:
518    /// `[segment_no: 8B BE][frame_seq: 4B LE]`. `segment_no` is persisted and
519    /// monotonic across segments; the counter is unique within a segment, so
520    /// nonces never repeat under the constant WAL DEK — provided
521    /// `frame_seq <= u32::MAX`, which `append_record` enforces before calling.
522    fn frame_nonce(&self) -> [u8; 12] {
523        frame_nonce_for(self.segment_no, self.frame_seq as u32)
524    }
525
526    /// Flush the buffer and fsync the file. This is the durability point.
527    pub fn sync(&mut self) -> Result<()> {
528        self.file.flush()?;
529        self.file.get_ref().sync_all()?;
530        self.unflushed_bytes = 0;
531        Ok(())
532    }
533
534    /// Pending bytes not yet fsynced.
535    #[inline]
536    pub fn unflushed_bytes(&self) -> u64 {
537        self.unflushed_bytes
538    }
539
540    /// The next sequence number this writer will assign (i.e. last assigned + 1).
541    /// Exposed so a shared-WAL group-sync can report the durable high-water mark.
542    #[inline]
543    pub fn next_seq_val(&self) -> u64 {
544        self.next_seq
545    }
546
547    /// Tune the auto-sync threshold (bytes of buffered WAL before an automatic
548    /// `fsync`). `0` disables auto-sync entirely (manual [`Wal::sync`] only) —
549    /// useful for latency benchmarks and for grouping many writes under one
550    /// explicit commit.
551    pub fn set_sync_byte_threshold(&mut self, threshold: u64) {
552        self.sync_byte_threshold = threshold;
553    }
554
555    pub fn path(&self) -> &Path {
556        &self.path
557    }
558
559    /// Publish a fully synced staging segment under its final no-replace name.
560    /// The old segment remains authoritative until this succeeds.
561    pub(crate) fn publish_as(mut self, destination: PathBuf) -> Result<Self> {
562        self.sync()?;
563        crate::durable_file::rename(&self.path, &destination)?;
564        self.path = destination;
565        Ok(self)
566    }
567
568    fn write_header(&mut self, epoch_created: Epoch) -> Result<()> {
569        let enc_flag = if self.cipher.is_some() {
570            ENC_AES_GCM
571        } else {
572            ENC_PLAINTEXT
573        };
574        let header = encode_wal_header(
575            enc_flag,
576            epoch_created.0,
577            self.segment_no,
578            &self.previous_segment_hash,
579        );
580        self.header_binding = Sha256::digest(&header).into();
581        self.file.write_all(&header)?;
582        self.unflushed_bytes = 0;
583        Ok(())
584    }
585}
586
587impl Drop for Wal {
588    fn drop(&mut self) {
589        let _ = self.file.flush();
590    }
591}
592
593fn encode_wal_header(
594    encryption: u8,
595    epoch_created: u64,
596    segment_no: u64,
597    previous_segment_hash: &[u8; 32],
598) -> Vec<u8> {
599    let mut header = Vec::with_capacity(HEADER_LEN as usize);
600    header.extend_from_slice(&WAL_MAGIC);
601    header.extend_from_slice(&WAL_VERSION.to_le_bytes());
602    header.extend_from_slice(&[encryption, 0, 0, 0]);
603    header.extend_from_slice(&epoch_created.to_le_bytes());
604    header.extend_from_slice(&segment_no.to_le_bytes());
605    header.extend_from_slice(previous_segment_hash);
606    header
607}
608
609fn wal_frame_aad(
610    header_binding: &[u8; 32],
611    segment_no: u64,
612    frame_seq: u32,
613    record_seq: u64,
614    txn_id: u64,
615    ciphertext_len: u64,
616) -> Vec<u8> {
617    let mut aad = Vec::with_capacity(WAL_FRAME_AAD_DOMAIN.len() + 68);
618    aad.extend_from_slice(WAL_FRAME_AAD_DOMAIN);
619    aad.extend_from_slice(header_binding);
620    aad.extend_from_slice(&segment_no.to_le_bytes());
621    aad.extend_from_slice(&frame_seq.to_le_bytes());
622    aad.extend_from_slice(&record_seq.to_le_bytes());
623    aad.extend_from_slice(&txn_id.to_le_bytes());
624    aad.extend_from_slice(&ciphertext_len.to_le_bytes());
625    aad
626}
627
628fn wal_head_body(head: &WalHead, encrypted: bool) -> [u8; WAL_HEAD_BODY_LEN] {
629    let mut body = [0_u8; WAL_HEAD_BODY_LEN];
630    body[..8].copy_from_slice(&WAL_HEAD_MAGIC);
631    body[8..10].copy_from_slice(&WAL_HEAD_VERSION.to_le_bytes());
632    body[10] = if encrypted {
633        ENC_AES_GCM
634    } else {
635        ENC_PLAINTEXT
636    };
637    body[16..24].copy_from_slice(&head.segment_no.to_le_bytes());
638    body[24..32].copy_from_slice(&head.durable_len.to_le_bytes());
639    body[32..40].copy_from_slice(&head.open_generation.to_le_bytes());
640    body[40..72].copy_from_slice(&head.prefix_hash);
641    body
642}
643
644fn wal_head_auth(
645    body: &[u8; WAL_HEAD_BODY_LEN],
646    wal_dek: Option<&Zeroizing<[u8; 32]>>,
647) -> Result<[u8; 32]> {
648    if let Some(key) = wal_dek {
649        #[cfg(feature = "encryption")]
650        {
651            let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(&key[..])
652                .map_err(|error| MongrelError::Encryption(error.to_string()))?;
653            mac.update(WAL_HEAD_AUTH_DOMAIN);
654            mac.update(body);
655            return Ok(mac.finalize().into_bytes().into());
656        }
657        #[cfg(not(feature = "encryption"))]
658        {
659            let _ = key;
660            return Err(MongrelError::Encryption(
661                "encryption feature disabled but a WAL DEK was supplied".into(),
662            ));
663        }
664    }
665    let mut hash = Sha256::new();
666    hash.update(WAL_HEAD_AUTH_DOMAIN);
667    hash.update(body);
668    Ok(hash.finalize().into())
669}
670
671fn encode_wal_head(
672    head: &WalHead,
673    wal_dek: Option<&Zeroizing<[u8; 32]>>,
674) -> Result<[u8; WAL_HEAD_LEN]> {
675    let body = wal_head_body(head, wal_dek.is_some());
676    let auth = wal_head_auth(&body, wal_dek)?;
677    let mut encoded = [0_u8; WAL_HEAD_LEN];
678    encoded[..WAL_HEAD_BODY_LEN].copy_from_slice(&body);
679    encoded[WAL_HEAD_BODY_LEN..].copy_from_slice(&auth);
680    Ok(encoded)
681}
682
683fn decode_wal_head(encoded: &[u8], wal_dek: Option<&Zeroizing<[u8; 32]>>) -> Result<WalHead> {
684    if encoded.len() != WAL_HEAD_LEN {
685        return Err(MongrelError::CorruptWal {
686            offset: 0,
687            reason: format!(
688                "WAL head is {} bytes, expected {WAL_HEAD_LEN}",
689                encoded.len()
690            ),
691        });
692    }
693    let body: &[u8; WAL_HEAD_BODY_LEN] =
694        encoded[..WAL_HEAD_BODY_LEN]
695            .try_into()
696            .map_err(|_| MongrelError::CorruptWal {
697                offset: 0,
698                reason: "invalid WAL head body".into(),
699            })?;
700    if body[..8] != WAL_HEAD_MAGIC {
701        return Err(MongrelError::CorruptWal {
702            offset: 0,
703            reason: "invalid WAL head magic".into(),
704        });
705    }
706    let version = u16::from_le_bytes([body[8], body[9]]);
707    if version != WAL_HEAD_VERSION {
708        return Err(MongrelError::CorruptWal {
709            offset: 8,
710            reason: format!("unsupported WAL head version {version}"),
711        });
712    }
713    let expected_mode = if wal_dek.is_some() {
714        ENC_AES_GCM
715    } else {
716        ENC_PLAINTEXT
717    };
718    if body[10] != expected_mode || body[11..16] != [0; 5] {
719        return Err(MongrelError::CorruptWal {
720            offset: 10,
721            reason: "WAL head authentication mode or reserved bytes differ".into(),
722        });
723    }
724    let expected_auth = wal_head_auth(body, wal_dek)?;
725    if encoded[WAL_HEAD_BODY_LEN..] != expected_auth {
726        return Err(MongrelError::CorruptWal {
727            offset: WAL_HEAD_BODY_LEN as u64,
728            reason: "WAL head authentication failed".into(),
729        });
730    }
731    let mut prefix_hash = [0_u8; 32];
732    prefix_hash.copy_from_slice(&body[40..72]);
733    let mut segment_no = [0_u8; 8];
734    segment_no.copy_from_slice(&body[16..24]);
735    let mut durable_len = [0_u8; 8];
736    durable_len.copy_from_slice(&body[24..32]);
737    let mut open_generation = [0_u8; 8];
738    open_generation.copy_from_slice(&body[32..40]);
739    Ok(WalHead {
740        segment_no: u64::from_le_bytes(segment_no),
741        durable_len: u64::from_le_bytes(durable_len),
742        open_generation: u64::from_le_bytes(open_generation),
743        prefix_hash,
744    })
745}
746
747fn hash_file_prefix(file: File, length: u64) -> Result<[u8; 32]> {
748    if file.metadata()?.len() < length {
749        return Err(MongrelError::CorruptWal {
750            offset: length,
751            reason: "WAL file is shorter than its durable head".into(),
752        });
753    }
754    let mut reader = BufReader::new(file).take(length);
755    let mut hash = Sha256::new();
756    std::io::copy(&mut reader, &mut HashWriter(&mut hash))?;
757    Ok(hash.finalize().into())
758}
759
760struct HashWriter<'a>(&'a mut Sha256);
761
762impl Write for HashWriter<'_> {
763    fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
764        self.0.update(bytes);
765        Ok(bytes.len())
766    }
767
768    fn flush(&mut self) -> std::io::Result<()> {
769        Ok(())
770    }
771}
772
773fn hash_segment(wal_root: &crate::durable_file::DurableRoot, segment_no: u64) -> Result<[u8; 32]> {
774    let name = segment_filename(segment_no);
775    let file = wal_root.open_regular(&name)?;
776    let length = file.metadata()?.len();
777    hash_file_prefix(file, length)
778}
779
780fn read_wal_head(
781    root: &crate::durable_file::DurableRoot,
782    wal_dek: Option<&Zeroizing<[u8; 32]>>,
783) -> Result<Option<WalHead>> {
784    let relative = Path::new("_meta").join(WAL_HEAD_FILENAME);
785    match root.entry_exists(&relative) {
786        Ok(true) => {}
787        Ok(false) => return Ok(None),
788        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
789        Err(error) => return Err(error.into()),
790    }
791    let mut file = root.open_regular(&relative)?;
792    let length = file.metadata()?.len();
793    if length != WAL_HEAD_LEN as u64 {
794        return Err(MongrelError::CorruptWal {
795            offset: 0,
796            reason: format!("WAL head is {length} bytes, expected {WAL_HEAD_LEN}"),
797        });
798    }
799    let mut encoded = [0_u8; WAL_HEAD_LEN];
800    file.read_exact(&mut encoded)?;
801    decode_wal_head(&encoded, wal_dek).map(Some)
802}
803
804fn write_wal_head(
805    root: &crate::durable_file::DurableRoot,
806    wal_root: &crate::durable_file::DurableRoot,
807    segment_no: u64,
808    open_generation: u64,
809    wal_dek: Option<&Zeroizing<[u8; 32]>>,
810) -> Result<WalHead> {
811    let name = segment_filename(segment_no);
812    let file = wal_root.open_regular(&name)?;
813    let durable_len = file.metadata()?.len();
814    let head = WalHead {
815        segment_no,
816        durable_len,
817        open_generation,
818        prefix_hash: hash_file_prefix(file, durable_len)?,
819    };
820    root.create_directory_all("_meta")?;
821    root.write_atomic(
822        Path::new("_meta").join(WAL_HEAD_FILENAME),
823        &encode_wal_head(&head, wal_dek)?,
824    )?;
825    Ok(head)
826}
827
828/// Streaming reader used by recovery. Only a physically short final frame is
829/// an admissible crash-torn tail; CRC, authentication, and decode failures are
830/// corruption.
831pub struct WalReader {
832    inner: BufReader<File>,
833    pos: u64,
834    file_len: u64,
835    /// True if frames are encrypted (enc_flag in header).
836    encrypted: bool,
837    /// Optional cipher for decryption.
838    cipher: Option<Box<dyn crate::encryption::Cipher>>,
839    version: u16,
840    segment_no: u64,
841    previous_segment_hash: [u8; 32],
842    header_binding: [u8; 32],
843    frame_seq: u64,
844}
845
846impl WalReader {
847    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
848        Self::open_with_cipher(path, None)
849    }
850
851    /// Open a WAL segment for reading, optionally with a decryption cipher.
852    pub fn open_with_cipher(
853        path: impl AsRef<Path>,
854        cipher: Option<Box<dyn crate::encryption::Cipher>>,
855    ) -> Result<Self> {
856        Self::open_with_cipher_expected(path, cipher, None)
857    }
858
859    fn open_with_cipher_expected(
860        path: impl AsRef<Path>,
861        cipher: Option<Box<dyn crate::encryption::Cipher>>,
862        expected_segment_no: Option<u64>,
863    ) -> Result<Self> {
864        let path = path.as_ref();
865        let expected_segment_no = expected_segment_no.or_else(|| segment_number_from_path(path));
866        let file = crate::durable_file::open_regular_nofollow(path)?;
867        Self::open_file_with_cipher_expected(file, cipher, expected_segment_no)
868    }
869
870    fn open_file_with_cipher_expected(
871        mut file: File,
872        cipher: Option<Box<dyn crate::encryption::Cipher>>,
873        expected_segment_no: Option<u64>,
874    ) -> Result<Self> {
875        let file_len = file.metadata()?.len();
876        let mut magic = [0u8; 8];
877        file.read_exact(&mut magic)?;
878        if magic != WAL_MAGIC {
879            return Err(MongrelError::MagicMismatch {
880                what: "wal",
881                expected: WAL_MAGIC,
882                got: magic,
883            });
884        }
885        let mut version_buf = [0u8; 2];
886        file.read_exact(&mut version_buf)?;
887        let version = u16::from_le_bytes(version_buf);
888        if version != LEGACY_WAL_VERSION && version != WAL_VERSION {
889            return Err(MongrelError::InvalidArgument(format!(
890                "unsupported wal version {version}"
891            )));
892        }
893        let mut reserved = [0u8; 4];
894        file.read_exact(&mut reserved)?;
895        if !matches!(reserved[0], ENC_PLAINTEXT | ENC_AES_GCM) || reserved[1..] != [0, 0, 0] {
896            return Err(MongrelError::CorruptWal {
897                offset: 10,
898                reason: "invalid WAL header flags".into(),
899            });
900        }
901        let encrypted = reserved[0] == ENC_AES_GCM;
902        let mut epoch_buf = [0u8; 8];
903        file.read_exact(&mut epoch_buf)?;
904        let _epoch_created = Epoch(u64::from_le_bytes(epoch_buf));
905        let mut previous_segment_hash = [0_u8; 32];
906        let segment_no = if version == WAL_VERSION {
907            let mut segment = [0_u8; 8];
908            file.read_exact(&mut segment)?;
909            file.read_exact(&mut previous_segment_hash)?;
910            u64::from_le_bytes(segment)
911        } else {
912            expected_segment_no.unwrap_or(0)
913        };
914        if expected_segment_no.is_some_and(|expected| expected != segment_no) {
915            return Err(MongrelError::CorruptWal {
916                offset: 0,
917                reason: format!(
918                    "WAL header segment {segment_no} does not match filename segment {}",
919                    expected_segment_no.unwrap()
920                ),
921            });
922        }
923        let pos = if version == WAL_VERSION {
924            HEADER_LEN
925        } else {
926            LEGACY_HEADER_LEN
927        };
928        let header_binding = if version == WAL_VERSION {
929            Sha256::digest(encode_wal_header(
930                reserved[0],
931                _epoch_created.0,
932                segment_no,
933                &previous_segment_hash,
934            ))
935            .into()
936        } else {
937            [0; 32]
938        };
939        if encrypted && cipher.is_none() {
940            return Err(MongrelError::Decryption(
941                "WAL is encrypted but no passphrase or key was provided. \
942                 Use Table::open_encrypted or Table::open_with_key."
943                    .into(),
944            ));
945        }
946        Ok(Self {
947            inner: BufReader::new(file),
948            pos,
949            file_len,
950            encrypted,
951            cipher,
952            version,
953            segment_no,
954            previous_segment_hash,
955            header_binding,
956            frame_seq: 0,
957        })
958    }
959
960    /// Read the next record. Returns `Ok(None)` at a clean end-of-records
961    /// (zero-length marker or EOF), and `Err(TornWrite)` for a partial record.
962    pub fn next_record(&mut self) -> Result<Option<Record>> {
963        if self.pos == self.file_len {
964            return Ok(None);
965        }
966        if self.file_len.saturating_sub(self.pos) < 4 {
967            return Err(MongrelError::TornWrite { offset: self.pos });
968        }
969        let mut len_buf = [0u8; 4];
970        match self.inner.read_exact(&mut len_buf) {
971            Ok(()) => {}
972            Err(ref e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
973                return Err(MongrelError::TornWrite { offset: self.pos });
974            }
975            Err(e) => return Err(e.into()),
976        }
977        let len = u32::from_le_bytes(len_buf) as usize;
978        if len == 0 {
979            return Err(MongrelError::CorruptWal {
980                offset: self.pos,
981                reason: "zero-length WAL frames are not valid".into(),
982            });
983        }
984        // A runaway length (torn header or garbage) would trigger a huge
985        // allocation; treat anything past the cap as a torn write.
986        const MAX_RECORD_LEN: usize = 64 * 1024 * 1024;
987        if len > MAX_RECORD_LEN {
988            return Err(MongrelError::CorruptWal {
989                offset: self.pos,
990                reason: format!("WAL frame length {len} exceeds limit"),
991            });
992        }
993
994        let record_start = self.pos;
995        let mut rest = vec![0u8; 4 + 8 + 8 + len];
996        match self.inner.read_exact(&mut rest) {
997            Ok(()) => {}
998            Err(ref e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
999                return Err(MongrelError::TornWrite {
1000                    offset: record_start,
1001                });
1002            }
1003            Err(e) => return Err(e.into()),
1004        }
1005        let crc_val = u32::from_le_bytes([rest[0], rest[1], rest[2], rest[3]]);
1006        let seq = u64::from_le_bytes([
1007            rest[4], rest[5], rest[6], rest[7], rest[8], rest[9], rest[10], rest[11],
1008        ]);
1009        let txn_id = u64::from_le_bytes([
1010            rest[12], rest[13], rest[14], rest[15], rest[16], rest[17], rest[18], rest[19],
1011        ]);
1012        let payload = &rest[20..];
1013
1014        let mut digest = CRC32C.digest();
1015        digest.update(&seq.to_le_bytes());
1016        digest.update(&txn_id.to_le_bytes());
1017        digest.update(payload);
1018        if digest.finalize() != crc_val {
1019            return Err(MongrelError::CorruptWal {
1020                offset: record_start,
1021                reason: "crc mismatch".into(),
1022            });
1023        }
1024
1025        // Decrypt if encrypted.
1026        let plaintext = if self.encrypted {
1027            let Some(cipher) = &self.cipher else {
1028                return Err(MongrelError::Decryption(
1029                    "WAL is encrypted but no cipher was provided".into(),
1030                ));
1031            };
1032            let minimum = if self.version == LEGACY_WAL_VERSION {
1033                28
1034            } else {
1035                16
1036            };
1037            if payload.len() < minimum {
1038                return Err(MongrelError::CorruptWal {
1039                    offset: record_start,
1040                    reason: "encrypted frame too short".into(),
1041                });
1042            }
1043            if self.frame_seq > u32::MAX as u64 {
1044                return Err(MongrelError::CorruptWal {
1045                    offset: record_start,
1046                    reason: "WAL frame counter exceeds u32".into(),
1047                });
1048            }
1049            let expected_nonce = frame_nonce_for(self.segment_no, self.frame_seq as u32);
1050            let (ciphertext, aad) = if self.version == LEGACY_WAL_VERSION {
1051                if payload[..12] != expected_nonce {
1052                    return Err(MongrelError::CorruptWal {
1053                        offset: record_start,
1054                        reason: "encrypted WAL nonce does not match frame position".into(),
1055                    });
1056                }
1057                (&payload[12..], Vec::new())
1058            } else {
1059                (
1060                    payload,
1061                    wal_frame_aad(
1062                        &self.header_binding,
1063                        self.segment_no,
1064                        self.frame_seq as u32,
1065                        seq,
1066                        txn_id,
1067                        payload.len() as u64,
1068                    ),
1069                )
1070            };
1071            cipher
1072                .decrypt_page_with_aad(&expected_nonce, ciphertext, &aad)
1073                .map_err(|e| {
1074                    MongrelError::Decryption(format!(
1075                        "WAL frame decryption failed — wrong passphrase or key? ({e})"
1076                    ))
1077                })?
1078        } else {
1079            payload.to_vec()
1080        };
1081
1082        let record: Record =
1083            bincode::deserialize(&plaintext).map_err(|error| MongrelError::CorruptWal {
1084                offset: record_start,
1085                reason: format!("WAL record decode failed: {error}"),
1086            })?;
1087        if record.seq.0 != seq || record.txn_id != txn_id {
1088            return Err(MongrelError::CorruptWal {
1089                offset: record_start,
1090                reason: "WAL outer and authenticated record identity differ".into(),
1091            });
1092        }
1093        self.frame_seq += 1;
1094        self.pos += 4 + 4 + 8 + 8 + len as u64;
1095        Ok(Some(record))
1096    }
1097
1098    fn constrain_to_durable_len(&mut self, durable_len: u64) -> Result<()> {
1099        if durable_len < self.pos || durable_len > self.file_len {
1100            return Err(MongrelError::CorruptWal {
1101                offset: durable_len,
1102                reason: format!(
1103                    "WAL durable length {durable_len} is outside [{}, {}]",
1104                    self.pos, self.file_len
1105                ),
1106            });
1107        }
1108        self.file_len = durable_len;
1109        Ok(())
1110    }
1111
1112    /// Replay all cleanly-committed records. A torn tail (crash mid-append or a
1113    /// partially-flushed last frame) is treated as end-of-log and truncated —
1114    /// the valid prefix is returned. A CRC failure or short read that is
1115    /// followed by a well-formed frame is treated as **interior corruption**
1116    /// and surfaces as [`MongrelError::CorruptWal`] (spec §8.4, review fix #22).
1117    pub fn replay(&mut self) -> Result<Vec<Record>> {
1118        self.replay_with_tail_policy(true)
1119    }
1120
1121    fn replay_strict(&mut self) -> Result<Vec<Record>> {
1122        self.replay_with_tail_policy(false)
1123    }
1124
1125    fn replay_bounded(&mut self, max_records: usize, allow_torn_tail: bool) -> Result<Vec<Record>> {
1126        let mut out = Vec::new();
1127        loop {
1128            match self.next_record() {
1129                Ok(Some(record)) => {
1130                    if out.len() >= max_records {
1131                        return Err(MongrelError::ResourceLimitExceeded {
1132                            resource: "WAL recovery records",
1133                            requested: max_records.saturating_add(1),
1134                            limit: max_records,
1135                        });
1136                    }
1137                    out.push(record);
1138                }
1139                Ok(None) => break,
1140                Err(MongrelError::TornWrite { offset }) => {
1141                    if !allow_torn_tail {
1142                        return Err(MongrelError::CorruptWal {
1143                            offset,
1144                            reason: "torn tail in a non-final WAL segment".into(),
1145                        });
1146                    }
1147                    if self.valid_frame_follows()? {
1148                        return Err(MongrelError::CorruptWal {
1149                            offset,
1150                            reason: "interior torn frame followed by a valid frame".into(),
1151                        });
1152                    }
1153                    break;
1154                }
1155                Err(error @ MongrelError::CorruptWal { .. }) => return Err(error),
1156                Err(error) => return Err(error),
1157            }
1158        }
1159        Ok(out)
1160    }
1161
1162    fn replay_with_tail_policy(&mut self, allow_torn_tail: bool) -> Result<Vec<Record>> {
1163        let mut out = Vec::new();
1164        loop {
1165            match self.next_record() {
1166                Ok(Some(rec)) => out.push(rec),
1167                Ok(None) => break,
1168                Err(MongrelError::TornWrite { offset }) => {
1169                    if !allow_torn_tail {
1170                        return Err(MongrelError::CorruptWal {
1171                            offset,
1172                            reason: "torn tail in a non-final WAL segment".into(),
1173                        });
1174                    }
1175                    // Partial trailing frame: clean EOF unless a valid frame
1176                    // follows it (which would mean the torn frame is interior).
1177                    if self.valid_frame_follows()? {
1178                        return Err(MongrelError::CorruptWal {
1179                            offset,
1180                            reason: "interior torn frame followed by a valid frame".into(),
1181                        });
1182                    }
1183                    break;
1184                }
1185                Err(error @ MongrelError::CorruptWal { .. }) => return Err(error),
1186                Err(e) => return Err(e),
1187            }
1188        }
1189        Ok(out)
1190    }
1191
1192    /// Replay cooperatively with a hard record bound.
1193    pub fn replay_controlled(
1194        &mut self,
1195        control: &crate::ExecutionControl,
1196        max_records: usize,
1197    ) -> Result<Vec<Record>> {
1198        self.replay_controlled_with_tail_policy(control, max_records, true)
1199    }
1200
1201    fn replay_controlled_strict(
1202        &mut self,
1203        control: &crate::ExecutionControl,
1204        max_records: usize,
1205    ) -> Result<Vec<Record>> {
1206        self.replay_controlled_with_tail_policy(control, max_records, false)
1207    }
1208
1209    fn replay_controlled_with_tail_policy(
1210        &mut self,
1211        control: &crate::ExecutionControl,
1212        max_records: usize,
1213        allow_torn_tail: bool,
1214    ) -> Result<Vec<Record>> {
1215        let mut out = Vec::new();
1216        loop {
1217            if out.len() % 256 == 0 {
1218                control.checkpoint()?;
1219            }
1220            match self.next_record() {
1221                Ok(Some(record)) => {
1222                    if out.len() >= max_records {
1223                        return Err(MongrelError::ResourceLimitExceeded {
1224                            resource: "controlled WAL replay records",
1225                            requested: max_records.saturating_add(1),
1226                            limit: max_records,
1227                        });
1228                    }
1229                    out.push(record);
1230                }
1231                Ok(None) => break,
1232                Err(MongrelError::TornWrite { offset }) => {
1233                    if !allow_torn_tail {
1234                        return Err(MongrelError::CorruptWal {
1235                            offset,
1236                            reason: "torn tail in a non-final WAL segment".into(),
1237                        });
1238                    }
1239                    if self.valid_frame_follows()? {
1240                        return Err(MongrelError::CorruptWal {
1241                            offset,
1242                            reason: "interior torn frame followed by a valid frame".into(),
1243                        });
1244                    }
1245                    break;
1246                }
1247                Err(error @ MongrelError::CorruptWal { .. }) => return Err(error),
1248                Err(error) => return Err(error),
1249            }
1250        }
1251        control.checkpoint()?;
1252        Ok(out)
1253    }
1254
1255    /// Probe whether a well-formed frame remains at the current read position.
1256    /// Used by [`Self::replay`] to disambiguate a trailing torn frame from
1257    /// interior corruption. The reader state is left positioned after the
1258    /// probed frame; `replay` stops after calling this regardless.
1259    fn valid_frame_follows(&mut self) -> Result<bool> {
1260        match self.next_record() {
1261            Ok(Some(_)) => Ok(true),
1262            Ok(None) => Ok(false),
1263            Err(_) => Ok(false),
1264        }
1265    }
1266
1267    /// Position the write cursor at end of file (for a reopen-and-append path,
1268    /// to be implemented alongside segment rotation).
1269    pub fn current_offset(&self) -> u64 {
1270        self.pos
1271    }
1272}
1273
1274/// Replay every record from a WAL file, stopping at the first torn/corrupt one.
1275/// Convenience wrapper around [`WalReader`].
1276pub fn replay(path: impl AsRef<Path>) -> Result<Vec<Record>> {
1277    WalReader::open(path)?.replay()
1278}
1279
1280/// Replay with an optional decryption cipher (for encrypted WAL segments).
1281pub fn replay_with_cipher(
1282    path: impl AsRef<Path>,
1283    cipher: Option<Box<dyn crate::encryption::Cipher>>,
1284) -> Result<Vec<Record>> {
1285    WalReader::open_with_cipher(path, cipher)?.replay()
1286}
1287
1288/// Build the deterministic 12-byte AES-GCM nonce for `(segment_no, frame)`:
1289/// `[segment_no: 8B BE][frame: 4B LE]`. The high 8 bytes are unique per
1290/// segment (persisted monotonic counter) and the low 4 are unique per frame
1291/// within a segment, so the pair never collides under the constant WAL DEK.
1292pub fn frame_nonce_for(segment_no: u64, frame: u32) -> [u8; 12] {
1293    let mut n = [0u8; 12];
1294    n[..8].copy_from_slice(&segment_no.to_be_bytes());
1295    n[8..].copy_from_slice(&frame.to_le_bytes());
1296    n
1297}
1298
1299/// A WAL shared across all tables of a `Database`, multiplexing many tables'
1300/// records onto one fd (spec §7.2). Owns the active `Wal` segment plus the list
1301/// of rotated segments under `<root>/_wal/`. Appends are buffered; a single
1302/// [`SharedWal::group_sync`] is the durability point for every concurrent
1303/// writer that appended since the last sync.
1304pub struct SharedWal {
1305    root: Arc<crate::durable_file::DurableRoot>,
1306    wal_root: Arc<crate::durable_file::DurableRoot>,
1307    wal_dir: PathBuf,
1308    active: Wal,
1309    /// Monotonic segment number of the active segment (namespaces nonces).
1310    active_segment_no: u64,
1311    /// Highest sequence number reported durable by the last successful
1312    /// `group_sync`. P3's group-commit publishes only commits at or below this.
1313    durable_seq: u64,
1314    /// Highest database-open generation sealed into the durable WAL head.
1315    open_generation: u64,
1316    /// WAL DEK (constant across segments). None for plaintext. Kept so a
1317    /// `rotate` can rebuild the per-segment cipher under the same key.
1318    wal_dek: Option<Zeroizing<[u8; 32]>>,
1319    /// Count of actual fsyncs issued via [`Self::group_sync`]. With real group
1320    /// commit this is far below the commit count (one leader fsync serves many
1321    /// followers). Diagnostic / test-facing.
1322    group_sync_count: u64,
1323}
1324
1325#[derive(Default)]
1326struct ReplayTxnState {
1327    timestamp_seen: bool,
1328    terminal: bool,
1329}
1330
1331struct WalLayout {
1332    segments: Vec<u64>,
1333    head: Option<WalHead>,
1334}
1335
1336fn reader_for_segment(
1337    wal_root: &crate::durable_file::DurableRoot,
1338    segment_no: u64,
1339    wal_dek: Option<&Zeroizing<[u8; 32]>>,
1340) -> Result<WalReader> {
1341    let cipher = match wal_dek {
1342        #[cfg(feature = "encryption")]
1343        Some(key) => Some(SharedWal::cipher_from_dek(key)?),
1344        #[cfg(not(feature = "encryption"))]
1345        Some(_) => {
1346            return Err(MongrelError::Encryption(
1347                "encryption feature disabled but a WAL DEK was supplied".into(),
1348            ));
1349        }
1350        None => None,
1351    };
1352    let file = wal_root.open_regular(segment_filename(segment_no))?;
1353    WalReader::open_file_with_cipher_expected(file, cipher, Some(segment_no))
1354}
1355
1356fn inspect_wal_layout(
1357    root: &crate::durable_file::DurableRoot,
1358    wal_root: &crate::durable_file::DurableRoot,
1359    wal_dek: Option<&Zeroizing<[u8; 32]>>,
1360) -> Result<WalLayout> {
1361    let segments = list_segment_numbers(wal_root)?;
1362    let head = read_wal_head(root, wal_dek)?;
1363    let mut saw_v4 = false;
1364    for (index, segment_no) in segments.iter().copied().enumerate() {
1365        let reader = reader_for_segment(wal_root, segment_no, wal_dek)?;
1366        if reader.encrypted != wal_dek.is_some() {
1367            return Err(MongrelError::CorruptWal {
1368                offset: 10,
1369                reason: format!(
1370                    "WAL segment {segment_no} encryption mode differs from the database"
1371                ),
1372            });
1373        }
1374        if reader.version == WAL_VERSION {
1375            if index != 0 {
1376                let previous_no = segments[index - 1];
1377                let expected = hash_segment(wal_root, previous_no)?;
1378                if reader.previous_segment_hash != expected {
1379                    return Err(MongrelError::CorruptWal {
1380                        offset: 30,
1381                        reason: format!(
1382                            "WAL segment {segment_no} does not authenticate segment {previous_no}"
1383                        ),
1384                    });
1385                }
1386            }
1387            saw_v4 = true;
1388        } else if saw_v4 {
1389            return Err(MongrelError::CorruptWal {
1390                offset: 8,
1391                reason: format!("legacy WAL segment {segment_no} follows a v4 segment"),
1392            });
1393        }
1394    }
1395
1396    if saw_v4 && head.is_none() {
1397        return Err(MongrelError::CorruptWal {
1398            offset: 0,
1399            reason: "v4 WAL segments require a durable WAL head".into(),
1400        });
1401    }
1402    if !saw_v4 && head.is_some() {
1403        return Err(MongrelError::CorruptWal {
1404            offset: 0,
1405            reason: "a durable WAL head exists without any v4 segment".into(),
1406        });
1407    }
1408    if let Some(head) = head {
1409        let final_segment = segments
1410            .last()
1411            .copied()
1412            .ok_or_else(|| MongrelError::CorruptWal {
1413                offset: 0,
1414                reason: "durable WAL head references an empty WAL directory".into(),
1415            })?;
1416        if head.segment_no != final_segment {
1417            return Err(MongrelError::CorruptWal {
1418                offset: head.segment_no,
1419                reason: format!(
1420                    "durable WAL head references segment {}, newest retained segment is {final_segment}",
1421                    head.segment_no
1422                ),
1423            });
1424        }
1425        let file = wal_root.open_regular(segment_filename(head.segment_no))?;
1426        let actual_len = file.metadata()?.len();
1427        if head.durable_len > actual_len {
1428            return Err(MongrelError::CorruptWal {
1429                offset: head.durable_len,
1430                reason: format!(
1431                    "durable WAL head length {} exceeds segment length {actual_len}",
1432                    head.durable_len
1433                ),
1434            });
1435        }
1436        if hash_file_prefix(file, head.durable_len)? != head.prefix_hash {
1437            return Err(MongrelError::CorruptWal {
1438                offset: head.durable_len,
1439                reason: "durable WAL prefix hash differs from WAL head".into(),
1440            });
1441        }
1442    }
1443    Ok(WalLayout { segments, head })
1444}
1445
1446fn remove_unpublished_header_only_segment(
1447    root: &crate::durable_file::DurableRoot,
1448    wal_root: &crate::durable_file::DurableRoot,
1449    wal_dek: Option<&Zeroizing<[u8; 32]>>,
1450) -> Result<bool> {
1451    let Some(head) = read_wal_head(root, wal_dek)? else {
1452        return Ok(false);
1453    };
1454    let segments = list_segment_numbers(wal_root)?;
1455    let Some(newest) = segments.last().copied() else {
1456        return Ok(false);
1457    };
1458    let Some(orphan_no) = head.segment_no.checked_add(1) else {
1459        return Ok(false);
1460    };
1461    if newest != orphan_no || !segments.contains(&head.segment_no) {
1462        return Ok(false);
1463    }
1464    let reader = reader_for_segment(wal_root, orphan_no, wal_dek)?;
1465    if reader.version != WAL_VERSION
1466        || reader.file_len != HEADER_LEN
1467        || reader.previous_segment_hash != hash_segment(wal_root, head.segment_no)?
1468    {
1469        return Ok(false);
1470    }
1471    wal_root.remove_file(segment_filename(orphan_no))?;
1472    Ok(true)
1473}
1474
1475fn replay_wal_layout(
1476    wal_root: &crate::durable_file::DurableRoot,
1477    layout: &WalLayout,
1478    wal_dek: Option<&Zeroizing<[u8; 32]>>,
1479) -> Result<Vec<Record>> {
1480    let total_bytes = layout
1481        .segments
1482        .iter()
1483        .try_fold(0_u64, |total, segment_no| {
1484            let bytes = if layout
1485                .head
1486                .is_some_and(|head| head.segment_no == *segment_no)
1487            {
1488                layout.head.unwrap().durable_len
1489            } else {
1490                wal_root
1491                    .open_regular(segment_filename(*segment_no))?
1492                    .metadata()?
1493                    .len()
1494            };
1495            total
1496                .checked_add(bytes)
1497                .ok_or(MongrelError::ResourceLimitExceeded {
1498                    resource: "WAL recovery bytes",
1499                    requested: usize::MAX,
1500                    limit: MAX_RECOVERY_WAL_BYTES as usize,
1501                })
1502        })?;
1503    if total_bytes > MAX_RECOVERY_WAL_BYTES {
1504        return Err(MongrelError::ResourceLimitExceeded {
1505            resource: "WAL recovery bytes",
1506            requested: usize::try_from(total_bytes).unwrap_or(usize::MAX),
1507            limit: MAX_RECOVERY_WAL_BYTES as usize,
1508        });
1509    }
1510    let mut records = Vec::new();
1511    let final_segment = layout.segments.last().copied();
1512    for segment_no in layout.segments.iter().copied() {
1513        let mut reader = reader_for_segment(wal_root, segment_no, wal_dek)?;
1514        if layout
1515            .head
1516            .is_some_and(|head| head.segment_no == segment_no)
1517        {
1518            reader.constrain_to_durable_len(layout.head.unwrap().durable_len)?;
1519        }
1520        let remaining = MAX_RECOVERY_WAL_RECORDS.saturating_sub(records.len());
1521        let segment_records = reader.replay_bounded(
1522            remaining,
1523            Some(segment_no) == final_segment && layout.head.is_none(),
1524        )?;
1525        records.extend(segment_records);
1526    }
1527    validate_shared_transaction_framing(&records)?;
1528    Ok(records)
1529}
1530
1531pub(crate) fn validate_shared_transaction_framing(records: &[Record]) -> Result<()> {
1532    let mut transactions = std::collections::HashMap::<u64, ReplayTxnState>::new();
1533    let mut commit_epochs = std::collections::HashMap::<u64, u64>::new();
1534    let mut previous_sequence: Option<u64> = None;
1535    let mut previous_commit_epoch: Option<u64> = None;
1536    for record in records {
1537        if let Some(previous) = previous_sequence {
1538            let expected = previous
1539                .checked_add(1)
1540                .ok_or_else(|| MongrelError::CorruptWal {
1541                    offset: record.seq.0,
1542                    reason: "WAL sequence overflows after u64::MAX".into(),
1543                })?;
1544            if record.seq.0 != expected {
1545                return Err(MongrelError::CorruptWal {
1546                    offset: record.seq.0,
1547                    reason: format!("WAL sequence {} does not follow {previous}", record.seq.0),
1548                });
1549            }
1550        }
1551        previous_sequence = Some(record.seq.0);
1552        if record.txn_id == SYSTEM_TXN_ID {
1553            if !matches!(record.op, Op::Flush { .. }) {
1554                return Err(MongrelError::CorruptWal {
1555                    offset: record.seq.0,
1556                    reason: "non-system operation uses reserved transaction id 0".into(),
1557                });
1558            }
1559            continue;
1560        }
1561        let state = transactions.entry(record.txn_id).or_default();
1562        if state.terminal {
1563            return Err(MongrelError::CorruptWal {
1564                offset: record.seq.0,
1565                reason: format!(
1566                    "transaction {} has records after its terminal marker",
1567                    record.txn_id
1568                ),
1569            });
1570        }
1571        match record.op {
1572            Op::CommitTimestamp { .. } => {
1573                if state.timestamp_seen {
1574                    return Err(MongrelError::CorruptWal {
1575                        offset: record.seq.0,
1576                        reason: format!(
1577                            "transaction {} has duplicate commit timestamps",
1578                            record.txn_id
1579                        ),
1580                    });
1581                }
1582                state.timestamp_seen = true;
1583            }
1584            Op::TxnCommit { epoch, .. } => {
1585                if epoch == 0 {
1586                    return Err(MongrelError::CorruptWal {
1587                        offset: record.seq.0,
1588                        reason: format!("transaction {} commits at epoch 0", record.txn_id),
1589                    });
1590                }
1591                if let Some(previous) = commit_epochs.insert(epoch, record.txn_id) {
1592                    return Err(MongrelError::CorruptWal {
1593                        offset: record.seq.0,
1594                        reason: format!(
1595                            "transactions {previous} and {} share commit epoch {epoch}",
1596                            record.txn_id
1597                        ),
1598                    });
1599                }
1600                if previous_commit_epoch.is_some_and(|previous| epoch <= previous) {
1601                    return Err(MongrelError::CorruptWal {
1602                        offset: record.seq.0,
1603                        reason: format!(
1604                            "commit epoch {epoch} does not advance beyond {}",
1605                            previous_commit_epoch.unwrap_or(0)
1606                        ),
1607                    });
1608                }
1609                previous_commit_epoch = Some(epoch);
1610                state.terminal = true;
1611            }
1612            Op::TxnAbort => state.terminal = true,
1613            Op::Flush { .. } => {
1614                return Err(MongrelError::CorruptWal {
1615                    offset: record.seq.0,
1616                    reason: format!(
1617                        "transaction {} contains a system flush record",
1618                        record.txn_id
1619                    ),
1620                });
1621            }
1622            _ => {}
1623        }
1624    }
1625    Ok(())
1626}
1627
1628impl SharedWal {
1629    /// Build a per-segment frame cipher from the WAL DEK (encryption feature).
1630    #[cfg(feature = "encryption")]
1631    fn cipher_from_dek(dek: &Zeroizing<[u8; 32]>) -> Result<Box<dyn crate::encryption::Cipher>> {
1632        Ok(Box::new(crate::encryption::AesCipher::new(&dek[..])?))
1633    }
1634
1635    /// Create a fresh shared WAL at `<root>/_wal/` starting at `epoch_created`.
1636    pub fn create(root: &Path, epoch_created: Epoch) -> Result<Self> {
1637        Self::create_with_dek(root, epoch_created, None)
1638    }
1639
1640    /// Create with optional frame-level encryption (WAL DEK).
1641    pub fn create_with_dek(
1642        root: &Path,
1643        epoch_created: Epoch,
1644        wal_dek: Option<Zeroizing<[u8; 32]>>,
1645    ) -> Result<Self> {
1646        let root = Arc::new(crate::durable_file::DurableRoot::open(root)?);
1647        Self::create_with_durable_root(root, epoch_created, wal_dek)
1648    }
1649
1650    pub(crate) fn create_with_durable_root(
1651        root: Arc<crate::durable_file::DurableRoot>,
1652        epoch_created: Epoch,
1653        wal_dek: Option<Zeroizing<[u8; 32]>>,
1654    ) -> Result<Self> {
1655        let wal_root = Arc::new(root.create_directory_all_pinned("_wal")?);
1656        let wal_dir = wal_root.io_path()?;
1657        if !list_segment_numbers(&wal_root)?.is_empty() {
1658            return Err(MongrelError::CorruptWal {
1659                offset: 0,
1660                reason: "refuses to create a shared WAL over existing segments".into(),
1661            });
1662        }
1663        let cipher = match &wal_dek {
1664            #[cfg(feature = "encryption")]
1665            Some(dk) => Some(Self::cipher_from_dek(dk)?),
1666            #[cfg(not(feature = "encryption"))]
1667            Some(_) => {
1668                return Err(MongrelError::Encryption(
1669                    "encryption feature disabled but a WAL DEK was supplied".into(),
1670                ))
1671            }
1672            None => None,
1673        };
1674        let active = Wal::create_chained_in(&wal_root, 0, epoch_created, cipher, [0; 32])?;
1675        if let Err(error) = write_wal_head(&root, &wal_root, 0, 0, wal_dek.as_ref()) {
1676            drop(active);
1677            let _ = wal_root.remove_file(segment_filename(0));
1678            return Err(error);
1679        }
1680        Ok(Self {
1681            root,
1682            wal_root,
1683            wal_dir,
1684            active,
1685            active_segment_no: 0,
1686            durable_seq: epoch_created.0,
1687            open_generation: 0,
1688            wal_dek,
1689            group_sync_count: 0,
1690        })
1691    }
1692
1693    /// Open an existing shared WAL for append, preserving prior segments (which
1694    /// `replay` reads for recovery). A fresh active segment numbered one past
1695    /// the highest existing is created — old segments are NOT truncated (review
1696    /// fix #6), so a crash mid-recovery can re-replay them safely.
1697    pub fn open(
1698        root: &Path,
1699        epoch_created: Epoch,
1700        wal_dek: Option<Zeroizing<[u8; 32]>>,
1701    ) -> Result<Self> {
1702        let root = Arc::new(crate::durable_file::DurableRoot::open(root)?);
1703        Self::open_durable_root(root, epoch_created, wal_dek)
1704    }
1705
1706    pub(crate) fn open_durable_root(
1707        root: Arc<crate::durable_file::DurableRoot>,
1708        epoch_created: Epoch,
1709        wal_dek: Option<Zeroizing<[u8; 32]>>,
1710    ) -> Result<Self> {
1711        Self::open_durable_root_validated(root, epoch_created, wal_dek, None)
1712    }
1713
1714    pub(crate) fn open_durable_root_validated(
1715        root: Arc<crate::durable_file::DurableRoot>,
1716        epoch_created: Epoch,
1717        wal_dek: Option<Zeroizing<[u8; 32]>>,
1718        expected_records: Option<&[Record]>,
1719    ) -> Result<Self> {
1720        let wal_root =
1721            Arc::new(
1722                root.open_directory("_wal")
1723                    .map_err(|error| MongrelError::CorruptWal {
1724                        offset: 0,
1725                        reason: format!(
1726                            "existing database WAL directory cannot be opened: {error}"
1727                        ),
1728                    })?,
1729            );
1730        let wal_dir = wal_root.io_path()?;
1731        if let Some(expected) = expected_records {
1732            let layout = inspect_wal_layout(&root, &wal_root, wal_dek.as_ref())?;
1733            let actual = replay_wal_layout(&wal_root, &layout, wal_dek.as_ref())?;
1734            if bincode::serialize(&actual)? != bincode::serialize(expected)? {
1735                return Err(MongrelError::CorruptWal {
1736                    offset: 0,
1737                    reason: "WAL changed after recovery planning".into(),
1738                });
1739            }
1740        }
1741        remove_unpublished_header_only_segment(&root, &wal_root, wal_dek.as_ref())?;
1742        let layout = inspect_wal_layout(&root, &wal_root, wal_dek.as_ref())?;
1743        let final_segment =
1744            layout
1745                .segments
1746                .last()
1747                .copied()
1748                .ok_or_else(|| MongrelError::CorruptWal {
1749                    offset: 0,
1750                    reason: "existing database has no WAL segments".into(),
1751                })?;
1752        let records = replay_wal_layout(&wal_root, &layout, wal_dek.as_ref())?;
1753        let open_generation = layout
1754            .head
1755            .map(|head| head.open_generation)
1756            .unwrap_or_else(|| {
1757                records
1758                    .iter()
1759                    .filter(|record| record.txn_id != SYSTEM_TXN_ID)
1760                    .map(|record| record.txn_id >> 32)
1761                    .max()
1762                    .unwrap_or(0)
1763            });
1764
1765        // Bytes beyond the authenticated head were never published durable.
1766        // A legacy final segment may likewise end in a true short torn frame.
1767        // Remove that suffix before the file becomes an immutable chain link.
1768        let durable_len = if let Some(head) = layout.head {
1769            head.durable_len
1770        } else {
1771            let mut reader = reader_for_segment(&wal_root, final_segment, wal_dek.as_ref())?;
1772            reader.replay()?;
1773            reader.current_offset()
1774        };
1775        let final_name = segment_filename(final_segment);
1776        let final_file = wal_root.open_regular_read_write(&final_name)?;
1777        if final_file.metadata()?.len() != durable_len {
1778            final_file.set_len(durable_len)?;
1779            final_file.sync_all()?;
1780        }
1781        let previous_segment_hash = hash_segment(&wal_root, final_segment)?;
1782        let next_segment_no = final_segment
1783            .checked_add(1)
1784            .ok_or_else(|| MongrelError::Full("WAL segment namespace exhausted".into()))?;
1785        let durable_seq = records
1786            .last()
1787            .map(|record| record.seq.0)
1788            .unwrap_or(epoch_created.0)
1789            .max(epoch_created.0);
1790        let cipher = match &wal_dek {
1791            #[cfg(feature = "encryption")]
1792            Some(dk) => Some(Self::cipher_from_dek(dk)?),
1793            #[cfg(not(feature = "encryption"))]
1794            Some(_) => {
1795                return Err(MongrelError::Encryption(
1796                    "encryption feature disabled but a WAL DEK was supplied".into(),
1797                ))
1798            }
1799            None => None,
1800        };
1801        let mut active = Wal::create_chained_in(
1802            &wal_root,
1803            next_segment_no,
1804            epoch_created,
1805            cipher,
1806            previous_segment_hash,
1807        )?;
1808        active.next_seq = durable_seq
1809            .checked_add(1)
1810            .ok_or_else(|| MongrelError::Full("WAL sequence namespace exhausted".into()))?;
1811        if let Err(error) = write_wal_head(
1812            &root,
1813            &wal_root,
1814            next_segment_no,
1815            open_generation,
1816            wal_dek.as_ref(),
1817        ) {
1818            drop(active);
1819            let _ = wal_root.remove_file(segment_filename(next_segment_no));
1820            return Err(error);
1821        }
1822        Ok(Self {
1823            root,
1824            wal_root,
1825            wal_dir,
1826            active,
1827            active_segment_no: next_segment_no,
1828            durable_seq,
1829            open_generation,
1830            wal_dek,
1831            group_sync_count: 0,
1832        })
1833    }
1834
1835    /// The active segment's wal_dir (test/diagnostic).
1836    #[allow(dead_code)]
1837    pub fn wal_dir(&self) -> &Path {
1838        &self.wal_dir
1839    }
1840
1841    /// Append a record for `(txn_id, table_id)`. Does not fsync.
1842    pub fn append(&mut self, txn_id: u64, _table_id: u64, op: Op) -> Result<u64> {
1843        Ok(self.active.append_txn(txn_id, op)?.0)
1844    }
1845
1846    /// Append a `TxnCommit` marker sealing `txn_id` at `epoch`.
1847    pub fn append_commit(&mut self, txn_id: u64, epoch: Epoch, added: &[AddedRun]) -> Result<u64> {
1848        self.append_commit_at(txn_id, epoch, added, unix_nanos_now())
1849    }
1850
1851    pub fn append_commit_at(
1852        &mut self,
1853        txn_id: u64,
1854        epoch: Epoch,
1855        added: &[AddedRun],
1856        unix_nanos: u64,
1857    ) -> Result<u64> {
1858        self.active
1859            .append_txn(txn_id, Op::CommitTimestamp { unix_nanos })?;
1860        Ok(self
1861            .active
1862            .append_txn(
1863                txn_id,
1864                Op::TxnCommit {
1865                    epoch: epoch.0,
1866                    added_runs: added.to_vec(),
1867                },
1868            )?
1869            .0)
1870    }
1871
1872    /// Append a `TxnAbort` marker for `txn_id`.
1873    pub fn append_abort(&mut self, txn_id: u64) -> Result<()> {
1874        self.active.append_txn(txn_id, Op::TxnAbort)?;
1875        Ok(())
1876    }
1877
1878    /// Append a system record (txn_id == 0), e.g. `Flush`.
1879    pub fn append_system(&mut self, op: Op) -> Result<u64> {
1880        Ok(self.active.append_system(op)?.0)
1881    }
1882
1883    /// Flush + fsync the active segment and return the highest durable sequence
1884    /// number. This is the single durability point for every concurrent
1885    /// appender since the last `group_sync`.
1886    pub fn group_sync(&mut self) -> Result<u64> {
1887        self.active.sync()?;
1888        write_wal_head(
1889            &self.root,
1890            &self.wal_root,
1891            self.active_segment_no,
1892            self.open_generation,
1893            self.wal_dek.as_ref(),
1894        )?;
1895        self.group_sync_count += 1;
1896        let highest = self.active.next_seq_val().saturating_sub(1);
1897        if highest > self.durable_seq {
1898            self.durable_seq = highest;
1899        }
1900        Ok(self.durable_seq)
1901    }
1902
1903    /// Number of fsyncs issued so far (test/diagnostic — see [`group_sync`]).
1904    pub fn group_sync_count(&self) -> u64 {
1905        self.group_sync_count
1906    }
1907
1908    /// The highest sequence number reported durable by the last `group_sync`.
1909    pub fn durable_seq(&self) -> u64 {
1910        self.durable_seq
1911    }
1912
1913    pub(crate) fn seal_open_generation(&mut self, generation: u64) -> Result<()> {
1914        if generation < self.open_generation {
1915            return Err(MongrelError::CorruptWal {
1916                offset: generation,
1917                reason: format!(
1918                    "open generation {generation} precedes WAL head generation {}",
1919                    self.open_generation
1920                ),
1921            });
1922        }
1923        self.active.sync()?;
1924        let previous = self.open_generation;
1925        self.open_generation = generation;
1926        if let Err(error) = write_wal_head(
1927            &self.root,
1928            &self.wal_root,
1929            self.active_segment_no,
1930            self.open_generation,
1931            self.wal_dek.as_ref(),
1932        ) {
1933            self.open_generation = previous;
1934            return Err(error);
1935        }
1936        Ok(())
1937    }
1938
1939    /// Rotate to a fresh segment numbered `segment_no` (which namespaces nonces
1940    /// under the constant WAL DEK). The current segment must already be synced.
1941    pub fn rotate(&mut self, segment_no: u64) -> Result<()> {
1942        let expected = self
1943            .active_segment_no
1944            .checked_add(1)
1945            .ok_or_else(|| MongrelError::Full("WAL segment namespace exhausted".into()))?;
1946        if segment_no != expected {
1947            return Err(MongrelError::InvalidArgument(format!(
1948                "WAL rotation segment {segment_no} does not immediately follow {}",
1949                self.active_segment_no
1950            )));
1951        }
1952        self.active.sync()?;
1953        write_wal_head(
1954            &self.root,
1955            &self.wal_root,
1956            self.active_segment_no,
1957            self.open_generation,
1958            self.wal_dek.as_ref(),
1959        )?;
1960        let highest = self.active.next_seq_val().saturating_sub(1);
1961        self.durable_seq = self.durable_seq.max(highest);
1962        let previous_segment_hash = hash_segment(&self.wal_root, self.active_segment_no)?;
1963        let cipher = match &self.wal_dek {
1964            #[cfg(feature = "encryption")]
1965            Some(dk) => Some(Self::cipher_from_dek(dk)?),
1966            _ => None,
1967        };
1968        let epoch = Epoch(self.durable_seq);
1969        let wal = Wal::create_chained_in(
1970            &self.wal_root,
1971            segment_no,
1972            epoch,
1973            cipher,
1974            previous_segment_hash,
1975        )?;
1976        if let Err(error) = write_wal_head(
1977            &self.root,
1978            &self.wal_root,
1979            segment_no,
1980            self.open_generation,
1981            self.wal_dek.as_ref(),
1982        ) {
1983            drop(wal);
1984            let _ = self.wal_root.remove_file(segment_filename(segment_no));
1985            return Err(error);
1986        }
1987        self.active = wal;
1988        self.active_segment_no = segment_no;
1989        Ok(())
1990    }
1991
1992    /// The active segment number.
1993    pub fn active_segment_no(&self) -> u64 {
1994        self.active_segment_no
1995    }
1996
1997    /// After every table is checkpointed into runs, publish a fresh durable
1998    /// active segment before deleting any older segment. The active file handle
1999    /// is never unlinked while it can receive later commits.
2000    pub(crate) fn reset_after_checkpoint(&mut self) -> Result<usize> {
2001        self.group_sync()?;
2002        let next_segment_no = list_segment_numbers(&self.wal_root)?
2003            .into_iter()
2004            .max()
2005            .ok_or_else(|| MongrelError::CorruptWal {
2006                offset: 0,
2007                reason: "active WAL segment disappeared before checkpoint reset".into(),
2008            })?
2009            .checked_add(1)
2010            .ok_or_else(|| MongrelError::Full("WAL segment namespace exhausted".into()))?;
2011        self.rotate(next_segment_no)?;
2012        // Keep this explicit even though segment creation currently syncs its
2013        // header. Checkpoint safety must not depend on that constructor detail.
2014        self.group_sync()?;
2015        self.gc_segments_retain_recent(u64::MAX, 0)
2016    }
2017
2018    /// Delete rotated (non-active) WAL segments whose records are all below
2019    /// `min_retained_seq` — i.e. every record in them is already durable in a
2020    /// run and not needed by any in-flight or committed-not-flushed txn (spec
2021    /// §6.4/§16). The active segment is **never** deleted. Returns the count of
2022    /// segment files reaped.
2023    ///
2024    /// `open()` mints a fresh active segment on every reopen without truncating
2025    /// the prior ones (so a crash mid-recovery can re-replay), which means old
2026    /// segments accumulate; this is what reaps them once their data is durable.
2027    pub fn gc_segments(&mut self, min_retained_seq: u64) -> Result<usize> {
2028        self.gc_segments_retain_recent(min_retained_seq, 0)
2029    }
2030
2031    pub(crate) fn has_gc_segments_retain_recent(&self, retain_recent: usize) -> Result<bool> {
2032        let rotated = list_segment_numbers(&self.wal_root)?
2033            .into_iter()
2034            .filter(|segment| *segment != self.active_segment_no)
2035            .count();
2036        Ok(rotated > retain_recent)
2037    }
2038
2039    /// [`Self::gc_segments`] while retaining the newest `retain_recent`
2040    /// rotated segments for replication followers. The active segment is
2041    /// retained separately and does not count toward this limit.
2042    pub fn gc_segments_retain_recent(
2043        &mut self,
2044        min_retained_seq: u64,
2045        retain_recent: usize,
2046    ) -> Result<usize> {
2047        self.group_sync()?;
2048        let layout = inspect_wal_layout(&self.root, &self.wal_root, self.wal_dek.as_ref())?;
2049        let readable = replay_wal_layout(&self.wal_root, &layout, self.wal_dek.as_ref())?;
2050        let segments = &layout.segments;
2051        let retained: Vec<u64> = segments
2052            .iter()
2053            .rev()
2054            .filter(|segment| **segment != self.active_segment_no)
2055            .take(retain_recent)
2056            .copied()
2057            .collect();
2058        let mut candidates = Vec::new();
2059        let mut prefix_open = true;
2060        for n in segments.iter().copied() {
2061            let mut reader = reader_for_segment(&self.wal_root, n, self.wal_dek.as_ref())?;
2062            if layout.head.is_some_and(|head| head.segment_no == n) {
2063                reader.constrain_to_durable_len(layout.head.unwrap().durable_len)?;
2064            }
2065            let records = reader.replay_strict()?;
2066            let below_floor = min_retained_seq == u64::MAX
2067                || records.iter().map(|record| record.seq.0).max().unwrap_or(0) < min_retained_seq;
2068            let reapable =
2069                prefix_open && n != self.active_segment_no && !retained.contains(&n) && below_floor;
2070            if reapable {
2071                candidates.push((n, records));
2072            } else {
2073                prefix_open = false;
2074            }
2075        }
2076
2077        let commits = readable
2078            .iter()
2079            .filter_map(|record| match record.op {
2080                Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
2081                _ => None,
2082            })
2083            .collect::<std::collections::HashMap<_, _>>();
2084        let removed_floor = candidates
2085            .iter()
2086            .flat_map(|(_, records)| records)
2087            .filter_map(|record| commits.get(&record.txn_id).copied())
2088            .max();
2089        if let Some(epoch) = removed_floor {
2090            crate::replication::advance_replication_wal_floor_durable(&self.root, epoch)?;
2091        }
2092        for (segment_no, _) in &candidates {
2093            self.wal_root.remove_file(segment_filename(*segment_no))?;
2094        }
2095        let reaped = candidates.len();
2096        Ok(reaped)
2097    }
2098
2099    /// Verify the on-disk integrity of every WAL segment (spec §16): each
2100    /// `seg-NNNNNN.wal` file under `<root>/_wal/` must open — its header magic
2101    /// and version must parse, and for an encrypted WAL the frame cipher must
2102    /// be derivable from the WAL DEK. A segment that fails to open is corrupt
2103    /// or truncated and would break recovery. Returns one `(segment_no, error)`
2104    /// pair per failing segment. The active (in-memory) segment is trusted by
2105    /// construction and re-checked from disk like the others.
2106    pub fn verify_segments(&self) -> Vec<(u64, String)> {
2107        let result = inspect_wal_layout(&self.root, &self.wal_root, self.wal_dek.as_ref())
2108            .and_then(|layout| replay_wal_layout(&self.wal_root, &layout, self.wal_dek.as_ref()));
2109        match result {
2110            Ok(_) => Vec::new(),
2111            Err(error) => vec![(u64::MAX, error.to_string())],
2112        }
2113    }
2114
2115    /// Replay every record across all segments in `<root>/_wal/`, in segment
2116    /// order. Only the newest segment may end in a crash-torn frame; every
2117    /// older segment is immutable and therefore must validate completely.
2118    pub fn replay(root: &Path) -> Result<Vec<Record>> {
2119        Self::replay_with_dek(root, None)
2120    }
2121
2122    /// Replay with an optional WAL DEK (for encrypted segments).
2123    pub fn replay_with_dek(
2124        root: &Path,
2125        wal_dek: Option<&Zeroizing<[u8; 32]>>,
2126    ) -> Result<Vec<Record>> {
2127        let root = crate::durable_file::DurableRoot::open(root)?;
2128        Self::replay_durable_with_dek(&root, wal_dek)
2129    }
2130
2131    pub(crate) fn replay_durable_with_dek(
2132        root: &crate::durable_file::DurableRoot,
2133        wal_dek: Option<&Zeroizing<[u8; 32]>>,
2134    ) -> Result<Vec<Record>> {
2135        let wal_root = root.open_directory("_wal")?;
2136        let layout = inspect_wal_layout(root, &wal_root, wal_dek)?;
2137        replay_wal_layout(&wal_root, &layout, wal_dek)
2138    }
2139
2140    pub(crate) fn durable_open_generation(
2141        root: &crate::durable_file::DurableRoot,
2142        wal_dek: Option<&Zeroizing<[u8; 32]>>,
2143    ) -> Result<Option<u64>> {
2144        let wal_root = root.open_directory("_wal")?;
2145        let layout = inspect_wal_layout(root, &wal_root, wal_dek)?;
2146        Ok(layout.head.map(|head| head.open_generation))
2147    }
2148
2149    /// Replay all segments cooperatively with hard total record and on-disk
2150    /// byte bounds.
2151    pub fn replay_with_dek_controlled(
2152        root: &Path,
2153        wal_dek: Option<&Zeroizing<[u8; 32]>>,
2154        control: &crate::ExecutionControl,
2155        max_records: usize,
2156        max_bytes: usize,
2157    ) -> Result<Vec<Record>> {
2158        let root = crate::durable_file::DurableRoot::open(root)?;
2159        let wal_root = root.open_directory("_wal")?;
2160        let layout = inspect_wal_layout(&root, &wal_root, wal_dek)?;
2161        let total_bytes = layout.segments.iter().try_fold(0_usize, |total, segment| {
2162            let bytes = if layout.head.is_some_and(|head| head.segment_no == *segment) {
2163                usize::try_from(layout.head.unwrap().durable_len).unwrap_or(usize::MAX)
2164            } else {
2165                usize::try_from(
2166                    wal_root
2167                        .open_regular(segment_filename(*segment))?
2168                        .metadata()?
2169                        .len(),
2170                )
2171                .unwrap_or(usize::MAX)
2172            };
2173            Ok::<_, MongrelError>(total.saturating_add(bytes))
2174        })?;
2175        if total_bytes > max_bytes {
2176            return Err(MongrelError::ResourceLimitExceeded {
2177                resource: "controlled WAL replay bytes",
2178                requested: total_bytes,
2179                limit: max_bytes,
2180            });
2181        }
2182        let mut out = Vec::new();
2183        let final_segment = layout.segments.last().copied();
2184        for n in layout.segments.iter().copied() {
2185            control.checkpoint()?;
2186            let remaining = max_records.saturating_sub(out.len());
2187            let mut reader = reader_for_segment(&wal_root, n, wal_dek)?;
2188            if layout.head.is_some_and(|head| head.segment_no == n) {
2189                reader.constrain_to_durable_len(layout.head.unwrap().durable_len)?;
2190            }
2191            let records = if Some(n) == final_segment && layout.head.is_none() {
2192                reader.replay_controlled(control, remaining)?
2193            } else {
2194                reader.replay_controlled_strict(control, remaining)?
2195            };
2196            out.extend(records);
2197        }
2198        validate_shared_transaction_framing(&out)?;
2199        Ok(out)
2200    }
2201}
2202
2203fn segment_filename(segment_no: u64) -> String {
2204    format!("seg-{segment_no:06}.wal")
2205}
2206
2207fn segment_number_from_path(path: &Path) -> Option<u64> {
2208    path.file_name()
2209        .and_then(|name| name.to_str())
2210        .and_then(|name| name.strip_prefix("seg-"))
2211        .and_then(|name| name.strip_suffix(".wal"))
2212        .and_then(|number| number.parse().ok())
2213}
2214
2215/// List and validate the retained canonical segment sequence under `wal_dir`.
2216/// Garbage files are ignored, but every `seg-*` entry is authoritative and
2217/// therefore must be a regular file with its one canonical name. GC may remove
2218/// a prefix; gaps inside the retained suffix are corruption.
2219fn list_segment_numbers(wal_root: &crate::durable_file::DurableRoot) -> Result<Vec<u64>> {
2220    let mut segments = Vec::new();
2221    for fname in wal_root.list_regular_files(".")? {
2222        let s = fname.to_str().ok_or_else(|| MongrelError::CorruptWal {
2223            offset: 0,
2224            reason: "WAL directory contains a non-UTF-8 entry".into(),
2225        })?;
2226        if !s.starts_with("seg-") {
2227            continue;
2228        }
2229        let number = s
2230            .strip_prefix("seg-")
2231            .and_then(|value| value.strip_suffix(".wal"))
2232            .and_then(|value| value.parse::<u64>().ok())
2233            .ok_or_else(|| MongrelError::CorruptWal {
2234                offset: 0,
2235                reason: format!("malformed WAL segment filename {s:?}"),
2236            })?;
2237        if s != segment_filename(number) {
2238            return Err(MongrelError::CorruptWal {
2239                offset: 0,
2240                reason: format!("non-canonical WAL segment filename {s:?}"),
2241            });
2242        }
2243        segments.push(number);
2244    }
2245    segments.sort_unstable();
2246    for pair in segments.windows(2) {
2247        let expected = pair[0]
2248            .checked_add(1)
2249            .ok_or_else(|| MongrelError::CorruptWal {
2250                offset: pair[0],
2251                reason: "WAL segment namespace overflows after u64::MAX".into(),
2252            })?;
2253        if pair[1] != expected {
2254            return Err(MongrelError::CorruptWal {
2255                offset: pair[1],
2256                reason: format!(
2257                    "WAL segment {} does not immediately follow {}",
2258                    pair[1], pair[0]
2259                ),
2260            });
2261        }
2262    }
2263    Ok(segments)
2264}
2265
2266#[cfg(test)]
2267mod shared_wal_tests {
2268    use super::*;
2269    use tempfile::tempdir;
2270
2271    fn write_legacy_segment(path: &Path, records: &[Record]) {
2272        let mut bytes = Vec::new();
2273        bytes.extend_from_slice(&WAL_MAGIC);
2274        bytes.extend_from_slice(&LEGACY_WAL_VERSION.to_le_bytes());
2275        bytes.extend_from_slice(&[ENC_PLAINTEXT, 0, 0, 0]);
2276        bytes.extend_from_slice(&0_u64.to_le_bytes());
2277        for record in records {
2278            let payload = bincode::serialize(record).unwrap();
2279            let mut digest = CRC32C.digest();
2280            digest.update(&record.seq.0.to_le_bytes());
2281            digest.update(&record.txn_id.to_le_bytes());
2282            digest.update(&payload);
2283            bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes());
2284            bytes.extend_from_slice(&digest.finalize().to_le_bytes());
2285            bytes.extend_from_slice(&record.seq.0.to_le_bytes());
2286            bytes.extend_from_slice(&record.txn_id.to_le_bytes());
2287            bytes.extend_from_slice(&payload);
2288        }
2289        std::fs::write(path, bytes).unwrap();
2290    }
2291
2292    #[test]
2293    fn shared_wal_interleaves_two_tables_one_fd() {
2294        let dir = tempdir().unwrap();
2295        let mut w = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2296        w.append(
2297            1,
2298            10,
2299            Op::Put {
2300                table_id: 10,
2301                rows: vec![1],
2302            },
2303        )
2304        .unwrap();
2305        w.append(
2306            2,
2307            20,
2308            Op::Put {
2309                table_id: 20,
2310                rows: vec![2],
2311            },
2312        )
2313        .unwrap();
2314        w.append_commit(1, Epoch(1), &[]).unwrap();
2315        w.append_commit(2, Epoch(2), &[]).unwrap();
2316        let d = w.group_sync().unwrap();
2317        assert!(d >= 4);
2318        let recs = SharedWal::replay(dir.path()).unwrap();
2319        assert_eq!(
2320            recs.iter()
2321                .filter(|r| matches!(r.op, Op::Put { .. }))
2322                .count(),
2323            2
2324        );
2325        assert_eq!(
2326            recs.iter()
2327                .filter(|r| matches!(r.op, Op::TxnCommit { .. }))
2328                .count(),
2329            2
2330        );
2331    }
2332
2333    #[test]
2334    fn controlled_shared_replay_rejects_aggregate_wal_bytes() {
2335        let dir = tempdir().unwrap();
2336        let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2337        wal.append_commit(1, Epoch(1), &[]).unwrap();
2338        wal.group_sync().unwrap();
2339        let control = crate::ExecutionControl::new(None);
2340
2341        let error =
2342            SharedWal::replay_with_dek_controlled(dir.path(), None, &control, usize::MAX, 1)
2343                .unwrap_err();
2344        assert!(matches!(
2345            error,
2346            MongrelError::ResourceLimitExceeded {
2347                resource: "controlled WAL replay bytes",
2348                ..
2349            }
2350        ));
2351    }
2352
2353    #[test]
2354    fn shared_wal_gc_retains_recent_rotated_segments() {
2355        let dir = tempdir().unwrap();
2356        let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2357        for segment in 0..4u64 {
2358            wal.append_commit(segment + 1, Epoch(segment + 1), &[])
2359                .unwrap();
2360            wal.group_sync().unwrap();
2361            if segment < 3 {
2362                wal.rotate(segment + 1).unwrap();
2363            }
2364        }
2365        assert_eq!(wal.gc_segments_retain_recent(u64::MAX, 2).unwrap(), 1);
2366        let count = std::fs::read_dir(dir.path().join("_wal"))
2367            .unwrap()
2368            .flatten()
2369            .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "wal"))
2370            .count();
2371        assert_eq!(count, 3, "active plus two retained segments");
2372    }
2373
2374    #[test]
2375    fn shared_replay_rejects_torn_tail_in_rotated_segment() {
2376        let dir = tempdir().unwrap();
2377        let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2378        wal.append_commit(1, Epoch(1), &[]).unwrap();
2379        wal.group_sync().unwrap();
2380        wal.rotate(1).unwrap();
2381        wal.append_commit(2, Epoch(2), &[]).unwrap();
2382        wal.group_sync().unwrap();
2383        drop(wal);
2384
2385        let old = dir.path().join("_wal/seg-000000.wal");
2386        let mut file = OpenOptions::new().append(true).open(old).unwrap();
2387        file.write_all(&[1, 2, 3]).unwrap();
2388        file.sync_all().unwrap();
2389
2390        assert!(matches!(
2391            SharedWal::replay(dir.path()),
2392            Err(MongrelError::CorruptWal { .. })
2393        ));
2394    }
2395
2396    #[test]
2397    fn shared_replay_rejects_records_after_commit() {
2398        let dir = tempdir().unwrap();
2399        let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2400        wal.append_commit(7, Epoch(1), &[]).unwrap();
2401        wal.append(
2402            7,
2403            1,
2404            Op::Delete {
2405                table_id: 1,
2406                row_ids: vec![RowId(9)],
2407            },
2408        )
2409        .unwrap();
2410        wal.group_sync().unwrap();
2411        drop(wal);
2412
2413        assert!(matches!(
2414            SharedWal::replay(dir.path()),
2415            Err(MongrelError::CorruptWal { .. })
2416        ));
2417    }
2418
2419    #[test]
2420    fn rotate_without_explicit_group_sync_keeps_sequence_adjacent() {
2421        let dir = tempdir().unwrap();
2422        let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2423        assert_eq!(
2424            wal.append_system(Op::Flush {
2425                table_id: 1,
2426                flushed_epoch: 1,
2427            })
2428            .unwrap(),
2429            1
2430        );
2431        wal.rotate(1).unwrap();
2432        assert_eq!(
2433            wal.append_system(Op::Flush {
2434                table_id: 1,
2435                flushed_epoch: 2,
2436            })
2437            .unwrap(),
2438            2
2439        );
2440        wal.group_sync().unwrap();
2441        let records = SharedWal::replay(dir.path()).unwrap();
2442        assert_eq!(
2443            records
2444                .iter()
2445                .map(|record| record.seq.0)
2446                .collect::<Vec<_>>(),
2447            vec![1, 2]
2448        );
2449    }
2450
2451    #[test]
2452    fn open_recovers_exact_header_only_segment_crash_window() {
2453        let dir = tempdir().unwrap();
2454        let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2455        wal.append_commit(1, Epoch(1), &[]).unwrap();
2456        wal.group_sync().unwrap();
2457        drop(wal);
2458
2459        let root = crate::durable_file::DurableRoot::open(dir.path()).unwrap();
2460        let wal_root = root.open_directory("_wal").unwrap();
2461        let previous_hash = hash_segment(&wal_root, 0).unwrap();
2462        drop(Wal::create_chained_in(&wal_root, 1, Epoch(1), None, previous_hash).unwrap());
2463        assert_eq!(read_wal_head(&root, None).unwrap().unwrap().segment_no, 0);
2464
2465        let reopened = SharedWal::open(dir.path(), Epoch(1), None).unwrap();
2466        assert_eq!(reopened.active_segment_no(), 1);
2467        assert_eq!(list_segment_numbers(&wal_root).unwrap(), vec![0, 1]);
2468        assert_eq!(read_wal_head(&root, None).unwrap().unwrap().segment_no, 1);
2469    }
2470
2471    #[test]
2472    fn replay_rejects_duplicate_sequence_and_backward_commit_epoch() {
2473        let duplicate = tempdir().unwrap();
2474        std::fs::create_dir(duplicate.path().join("_wal")).unwrap();
2475        write_legacy_segment(
2476            &duplicate.path().join("_wal/seg-000000.wal"),
2477            &[Record::new(
2478                Epoch(7),
2479                SYSTEM_TXN_ID,
2480                Op::Flush {
2481                    table_id: 1,
2482                    flushed_epoch: 1,
2483                },
2484            )],
2485        );
2486        write_legacy_segment(
2487            &duplicate.path().join("_wal/seg-000001.wal"),
2488            &[Record::new(
2489                Epoch(7),
2490                SYSTEM_TXN_ID,
2491                Op::Flush {
2492                    table_id: 1,
2493                    flushed_epoch: 2,
2494                },
2495            )],
2496        );
2497        assert!(matches!(
2498            SharedWal::replay(duplicate.path()),
2499            Err(MongrelError::CorruptWal { .. })
2500        ));
2501
2502        let epochs = tempdir().unwrap();
2503        std::fs::create_dir(epochs.path().join("_wal")).unwrap();
2504        write_legacy_segment(
2505            &epochs.path().join("_wal/seg-000000.wal"),
2506            &[
2507                Record::new(Epoch(1), 1, Op::CommitTimestamp { unix_nanos: 1 }),
2508                Record::new(
2509                    Epoch(2),
2510                    1,
2511                    Op::TxnCommit {
2512                        epoch: 2,
2513                        added_runs: Vec::new(),
2514                    },
2515                ),
2516                Record::new(Epoch(3), 2, Op::CommitTimestamp { unix_nanos: 2 }),
2517                Record::new(
2518                    Epoch(4),
2519                    2,
2520                    Op::TxnCommit {
2521                        epoch: 1,
2522                        added_runs: Vec::new(),
2523                    },
2524                ),
2525            ],
2526        );
2527        assert!(matches!(
2528            SharedWal::replay(epochs.path()),
2529            Err(MongrelError::CorruptWal { .. })
2530        ));
2531    }
2532
2533    #[test]
2534    fn legacy_v3_replays_then_migrates_to_chained_v4() {
2535        let dir = tempdir().unwrap();
2536        std::fs::create_dir(dir.path().join("_wal")).unwrap();
2537        write_legacy_segment(
2538            &dir.path().join("_wal/seg-000000.wal"),
2539            &[
2540                Record::new(Epoch(1), 1, Op::CommitTimestamp { unix_nanos: 1 }),
2541                Record::new(
2542                    Epoch(2),
2543                    1,
2544                    Op::TxnCommit {
2545                        epoch: 1,
2546                        added_runs: Vec::new(),
2547                    },
2548                ),
2549            ],
2550        );
2551        assert_eq!(SharedWal::replay(dir.path()).unwrap().len(), 2);
2552        let wal = SharedWal::open(dir.path(), Epoch(1), None).unwrap();
2553        assert_eq!(wal.active_segment_no(), 1);
2554        drop(wal);
2555        assert_eq!(SharedWal::replay(dir.path()).unwrap().len(), 2);
2556        let root = crate::durable_file::DurableRoot::open(dir.path()).unwrap();
2557        assert_eq!(read_wal_head(&root, None).unwrap().unwrap().segment_no, 1);
2558    }
2559
2560    #[test]
2561    fn head_and_strict_segment_names_detect_deletion_gaps_and_aliases() {
2562        let deleted = tempdir().unwrap();
2563        let wal = SharedWal::create(deleted.path(), Epoch(0)).unwrap();
2564        drop(wal);
2565        std::fs::remove_file(deleted.path().join("_wal/seg-000000.wal")).unwrap();
2566        assert!(SharedWal::replay(deleted.path()).is_err());
2567
2568        let alias = tempdir().unwrap();
2569        let wal = SharedWal::create(alias.path(), Epoch(0)).unwrap();
2570        drop(wal);
2571        std::fs::rename(
2572            alias.path().join("_wal/seg-000000.wal"),
2573            alias.path().join("_wal/seg-0.wal"),
2574        )
2575        .unwrap();
2576        assert!(SharedWal::replay(alias.path()).is_err());
2577
2578        let gap = tempdir().unwrap();
2579        let mut wal = SharedWal::create(gap.path(), Epoch(0)).unwrap();
2580        wal.rotate(1).unwrap();
2581        drop(wal);
2582        std::fs::rename(
2583            gap.path().join("_wal/seg-000001.wal"),
2584            gap.path().join("_wal/seg-000002.wal"),
2585        )
2586        .unwrap();
2587        assert!(SharedWal::replay(gap.path()).is_err());
2588    }
2589
2590    #[test]
2591    fn verify_and_gc_fail_closed_on_segment_corruption() {
2592        let dir = tempdir().unwrap();
2593        let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2594        wal.append_commit(1, Epoch(1), &[]).unwrap();
2595        wal.group_sync().unwrap();
2596        wal.rotate(1).unwrap();
2597        wal.append_commit(2, Epoch(2), &[]).unwrap();
2598        wal.group_sync().unwrap();
2599
2600        let old = dir.path().join("_wal/seg-000000.wal");
2601        let mut bytes = std::fs::read(&old).unwrap();
2602        let last = bytes.len() - 1;
2603        bytes[last] ^= 0x40;
2604        std::fs::write(&old, bytes).unwrap();
2605        assert!(!wal.verify_segments().is_empty());
2606        assert!(wal.gc_segments(u64::MAX).is_err());
2607        assert!(old.exists());
2608    }
2609}
2610
2611#[cfg(test)]
2612mod tests {
2613    use super::*;
2614    use tempfile::tempdir;
2615
2616    fn frame_ranges(bytes: &[u8]) -> Vec<std::ops::Range<usize>> {
2617        let mut ranges = Vec::new();
2618        let mut offset = HEADER_LEN as usize;
2619        while offset < bytes.len() {
2620            let mut length = [0_u8; 4];
2621            length.copy_from_slice(&bytes[offset..offset + 4]);
2622            let end = offset + 24 + u32::from_le_bytes(length) as usize;
2623            ranges.push(offset..end);
2624            offset = end;
2625        }
2626        ranges
2627    }
2628
2629    fn recompute_frame_crc(bytes: &mut [u8], start: usize) {
2630        let mut length = [0_u8; 4];
2631        length.copy_from_slice(&bytes[start..start + 4]);
2632        let length = u32::from_le_bytes(length) as usize;
2633        let seq = &bytes[start + 8..start + 16];
2634        let txn_id = &bytes[start + 16..start + 24];
2635        let payload = &bytes[start + 24..start + 24 + length];
2636        let mut digest = CRC32C.digest();
2637        digest.update(seq);
2638        digest.update(txn_id);
2639        digest.update(payload);
2640        bytes[start + 4..start + 8].copy_from_slice(&digest.finalize().to_le_bytes());
2641    }
2642
2643    #[test]
2644    fn append_then_replay_roundtrips() {
2645        let dir = tempdir().unwrap();
2646        let path = dir.path().join("seg-000000.wal");
2647        let mut wal = Wal::create(&path, Epoch(100)).unwrap();
2648        let s1 = wal
2649            .append_txn(
2650                7,
2651                Op::Put {
2652                    table_id: 1,
2653                    rows: vec![1, 2, 3],
2654                },
2655            )
2656            .unwrap();
2657        let s2 = wal
2658            .append_txn(
2659                7,
2660                Op::Delete {
2661                    table_id: 1,
2662                    row_ids: vec![RowId(7)],
2663                },
2664            )
2665            .unwrap();
2666        assert_eq!(s1, Epoch(101));
2667        assert_eq!(s2, Epoch(102));
2668        wal.sync().unwrap();
2669
2670        let records = replay(&path).unwrap();
2671        assert_eq!(records.len(), 2);
2672        assert_eq!(records[0].seq, Epoch(101));
2673        assert_eq!(records[0].txn_id, 7);
2674        match &records[0].op {
2675            Op::Put { table_id, rows } => {
2676                assert_eq!(*table_id, 1);
2677                assert_eq!(rows, &vec![1, 2, 3]);
2678            }
2679            other => panic!("unexpected op {other:?}"),
2680        }
2681        match &records[1].op {
2682            Op::Delete { row_ids, .. } => {
2683                assert_eq!(*row_ids, vec![RowId(7)]);
2684            }
2685            other => panic!("unexpected op {other:?}"),
2686        }
2687    }
2688
2689    #[test]
2690    fn record_roundtrips_with_txn_id_and_commit_marker() {
2691        let dir = tempdir().unwrap();
2692        let path = dir.path().join("seg-000000.wal");
2693        let mut w = Wal::create(&path, Epoch(0)).unwrap();
2694        w.append_txn(
2695            7,
2696            Op::Put {
2697                table_id: 3,
2698                rows: vec![1, 2, 3],
2699            },
2700        )
2701        .unwrap();
2702        w.append_txn(
2703            7,
2704            Op::TxnCommit {
2705                epoch: 11,
2706                added_runs: vec![],
2707            },
2708        )
2709        .unwrap();
2710        w.sync().unwrap();
2711        let recs = replay(&path).unwrap();
2712        assert_eq!(recs[0].txn_id, 7);
2713        assert!(matches!(recs[0].op, Op::Put { table_id: 3, .. }));
2714        assert!(matches!(recs[1].op, Op::TxnCommit { epoch: 11, .. }));
2715        // system records carry the reserved id
2716        let system_path = dir.path().join("seg-000001.wal");
2717        let mut w2 = Wal::create(&system_path, Epoch(0)).unwrap();
2718        w2.append_system(Op::Flush {
2719            table_id: 3,
2720            flushed_epoch: 11,
2721        })
2722        .unwrap();
2723        w2.sync().unwrap();
2724        let recs = replay(&system_path).unwrap();
2725        assert_eq!(recs[0].txn_id, SYSTEM_TXN_ID);
2726        assert!(matches!(recs[0].op, Op::Flush { .. }));
2727    }
2728
2729    #[test]
2730    fn catalog_snapshot_and_external_reset_roundtrip() {
2731        let dir = tempdir().unwrap();
2732        let path = dir.path().join("seg-catalog.wal");
2733        let mut wal = Wal::create(&path, Epoch(0)).unwrap();
2734        wal.append_txn(
2735            9,
2736            Op::Ddl(DdlOp::CatalogSnapshot {
2737                catalog_json: br#"{"db_epoch":7}"#.to_vec(),
2738            }),
2739        )
2740        .unwrap();
2741        wal.append_txn(
2742            9,
2743            Op::Ddl(DdlOp::ResetExternalTableState {
2744                name: "ext".into(),
2745                generation_epoch: 7,
2746            }),
2747        )
2748        .unwrap();
2749        wal.sync().unwrap();
2750
2751        let records = replay(&path).unwrap();
2752        assert!(matches!(
2753            &records[0].op,
2754            Op::Ddl(DdlOp::CatalogSnapshot { catalog_json })
2755                if catalog_json == br#"{"db_epoch":7}"#
2756        ));
2757        assert!(matches!(
2758            &records[1].op,
2759            Op::Ddl(DdlOp::ResetExternalTableState {
2760                name,
2761                generation_epoch: 7,
2762            }) if name == "ext"
2763        ));
2764    }
2765
2766    #[test]
2767    fn torn_write_is_detected() {
2768        let dir = tempdir().unwrap();
2769        let path = dir.path().join("seg-000001.wal");
2770        let mut wal = Wal::create(&path, Epoch(0)).unwrap();
2771        wal.append_txn(
2772            1,
2773            Op::Put {
2774                table_id: 1,
2775                rows: vec![0; 10],
2776            },
2777        )
2778        .unwrap();
2779        wal.sync().unwrap();
2780        drop(wal);
2781
2782        // Append a garbage partial record (simulate a crash mid-write).
2783        let mut f = OpenOptions::new().append(true).open(&path).unwrap();
2784        // REC_LEN claims 64 bytes but we only write a handful.
2785        f.write_all(&64u32.to_le_bytes()).unwrap();
2786        f.write_all(&[0u8; 7]).unwrap();
2787        f.sync_all().unwrap();
2788        drop(f);
2789
2790        let mut reader = WalReader::open(&path).unwrap();
2791        // The first real record reads fine.
2792        assert!(reader.next_record().unwrap().is_some());
2793        // The partial record surfaces as a torn write.
2794        let err = reader.next_record().unwrap_err();
2795        assert!(matches!(err, MongrelError::TornWrite { .. }), "got {err:?}");
2796    }
2797
2798    #[test]
2799    fn crc_corruption_is_detected() {
2800        let dir = tempdir().unwrap();
2801        let path = dir.path().join("seg-000002.wal");
2802        let mut wal = Wal::create(&path, Epoch(0)).unwrap();
2803        wal.append_txn(
2804            1,
2805            Op::Put {
2806                table_id: 9,
2807                rows: vec![1, 2, 3, 4],
2808            },
2809        )
2810        .unwrap();
2811        wal.sync().unwrap();
2812        drop(wal);
2813
2814        // Flip a payload byte well past the header.
2815        let mut bytes = std::fs::read(&path).unwrap();
2816        let last = bytes.len() - 1;
2817        bytes[last] ^= 0xFF;
2818        std::fs::write(&path, bytes).unwrap();
2819
2820        let err = WalReader::open(&path).unwrap().next_record().unwrap_err();
2821        assert!(
2822            matches!(err, MongrelError::CorruptWal { .. }),
2823            "got {err:?}"
2824        );
2825    }
2826
2827    #[test]
2828    fn trailing_torn_is_eof_but_interior_corruption_errors() {
2829        let dir = tempdir().unwrap();
2830
2831        // (a) good records then a half-written trailing frame -> replay returns
2832        //     the good prefix (torn tail = clean EOF).
2833        let path_a = dir.path().join("seg-torn.wal");
2834        let mut wal = Wal::create(&path_a, Epoch(0)).unwrap();
2835        wal.append_txn(
2836            1,
2837            Op::Put {
2838                table_id: 1,
2839                rows: vec![1],
2840            },
2841        )
2842        .unwrap();
2843        wal.append_txn(
2844            1,
2845            Op::Put {
2846                table_id: 1,
2847                rows: vec![2],
2848            },
2849        )
2850        .unwrap();
2851        wal.sync().unwrap();
2852        drop(wal);
2853        // Append a partial trailing frame (claims 64 bytes, only 7 written).
2854        let mut f = OpenOptions::new().append(true).open(&path_a).unwrap();
2855        f.write_all(&64u32.to_le_bytes()).unwrap();
2856        f.write_all(&[0u8; 7]).unwrap();
2857        f.sync_all().unwrap();
2858        drop(f);
2859        let recs = replay(&path_a).unwrap();
2860        assert_eq!(recs.len(), 2, "torn trailing frame must truncate cleanly");
2861
2862        // (b) corrupt an INTERIOR frame's CRC and append a valid frame after ->
2863        //     replay errors (interior corruption, not a torn tail).
2864        let path_b = dir.path().join("seg-interior.wal");
2865        let mut wal = Wal::create(&path_b, Epoch(0)).unwrap();
2866        wal.append_txn(
2867            1,
2868            Op::Put {
2869                table_id: 1,
2870                rows: vec![10, 20, 30],
2871            },
2872        )
2873        .unwrap();
2874        wal.append_txn(
2875            1,
2876            Op::Put {
2877                table_id: 1,
2878                rows: vec![40],
2879            },
2880        )
2881        .unwrap();
2882        wal.sync().unwrap();
2883        drop(wal);
2884        // Flip a payload byte of the FIRST frame (interior), leaving the second
2885        // frame intact so a valid frame follows the corrupt one.
2886        let mut bytes = std::fs::read(&path_b).unwrap();
2887        let first_payload_byte = HEADER_LEN as usize + 4 + 4 + 8 + 8; // past len+crc+seq+txn_id
2888        bytes[first_payload_byte] ^= 0xFF;
2889        std::fs::write(&path_b, bytes).unwrap();
2890        let err = replay(&path_b).unwrap_err();
2891        assert!(
2892            matches!(err, MongrelError::CorruptWal { .. }),
2893            "interior corruption must error, got {err:?}"
2894        );
2895
2896        // (c) a complete trailing frame with a bad CRC is corruption. Only a
2897        //     physically short final frame is an admissible crash-torn tail.
2898        let path_c = dir.path().join("seg-badtail.wal");
2899        let mut wal = Wal::create(&path_c, Epoch(0)).unwrap();
2900        wal.append_txn(
2901            1,
2902            Op::Put {
2903                table_id: 1,
2904                rows: vec![5],
2905            },
2906        )
2907        .unwrap();
2908        wal.sync().unwrap();
2909        drop(wal);
2910        let mut bytes = std::fs::read(&path_c).unwrap();
2911        let last = bytes.len() - 1;
2912        bytes[last] ^= 0xFF;
2913        std::fs::write(&path_c, bytes).unwrap();
2914        assert!(matches!(
2915            replay(&path_c),
2916            Err(MongrelError::CorruptWal { .. })
2917        ));
2918    }
2919
2920    #[test]
2921    fn byte_threshold_auto_syncs() {
2922        let dir = tempdir().unwrap();
2923        let path = dir.path().join("seg-000003.wal");
2924        let mut wal = Wal::create(&path, Epoch(0)).unwrap();
2925        wal.sync_byte_threshold = 1; // sync after every record
2926        wal.append_txn(
2927            1,
2928            Op::Put {
2929                table_id: 1,
2930                rows: vec![0; 5],
2931            },
2932        )
2933        .unwrap();
2934        assert_eq!(
2935            wal.unflushed_bytes(),
2936            0,
2937            "threshold should have auto-synced"
2938        );
2939    }
2940
2941    #[test]
2942    fn create_never_replaces_an_existing_segment() {
2943        let dir = tempdir().unwrap();
2944        let path = dir.path().join("seg-000000.wal");
2945        drop(Wal::create(&path, Epoch(0)).unwrap());
2946        let before = std::fs::read(&path).unwrap();
2947        assert!(matches!(
2948            Wal::create(&path, Epoch(0)),
2949            Err(MongrelError::Io(error)) if error.kind() == std::io::ErrorKind::AlreadyExists
2950        ));
2951        assert_eq!(std::fs::read(path).unwrap(), before);
2952    }
2953
2954    #[test]
2955    fn zero_length_frame_never_hides_a_wal_suffix() {
2956        let dir = tempdir().unwrap();
2957        let path = dir.path().join("seg-000000.wal");
2958        let mut wal = Wal::create(&path, Epoch(0)).unwrap();
2959        wal.append_system(Op::Flush {
2960            table_id: 1,
2961            flushed_epoch: 1,
2962        })
2963        .unwrap();
2964        wal.sync().unwrap();
2965        drop(wal);
2966        let original = std::fs::read(&path).unwrap();
2967        let mut bytes = original[..HEADER_LEN as usize].to_vec();
2968        bytes.extend_from_slice(&0_u32.to_le_bytes());
2969        bytes.extend_from_slice(&original[HEADER_LEN as usize..]);
2970        std::fs::write(&path, bytes).unwrap();
2971        assert!(matches!(
2972            replay(&path),
2973            Err(MongrelError::CorruptWal { .. })
2974        ));
2975    }
2976
2977    #[test]
2978    fn reader_rejects_outer_inner_record_identity_mismatch() {
2979        let dir = tempdir().unwrap();
2980        let path = dir.path().join("seg-000000.wal");
2981        let mut wal = Wal::create(&path, Epoch(0)).unwrap();
2982        wal.append_system(Op::Flush {
2983            table_id: 1,
2984            flushed_epoch: 1,
2985        })
2986        .unwrap();
2987        wal.sync().unwrap();
2988        drop(wal);
2989
2990        let mut bytes = std::fs::read(&path).unwrap();
2991        let start = HEADER_LEN as usize;
2992        let mut length = [0_u8; 4];
2993        length.copy_from_slice(&bytes[start..start + 4]);
2994        let length = u32::from_le_bytes(length) as usize;
2995        let payload = &bytes[start + 24..start + 24 + length];
2996        let mut record: Record = bincode::deserialize(payload).unwrap();
2997        record.seq = Epoch(99);
2998        let replacement = bincode::serialize(&record).unwrap();
2999        assert_eq!(replacement.len(), length);
3000        bytes[start + 24..start + 24 + length].copy_from_slice(&replacement);
3001        recompute_frame_crc(&mut bytes, start);
3002        std::fs::write(&path, bytes).unwrap();
3003
3004        assert!(matches!(
3005            WalReader::open(&path).unwrap().next_record(),
3006            Err(MongrelError::CorruptWal { .. })
3007        ));
3008    }
3009
3010    #[cfg(feature = "encryption")]
3011    #[test]
3012    fn encrypted_frames_reject_reorder_replay_deletion_and_cross_segment_move() {
3013        fn cipher(key: &[u8; 32]) -> Box<dyn crate::encryption::Cipher> {
3014            Box::new(crate::encryption::AesCipher::new(key).unwrap())
3015        }
3016
3017        let dir = tempdir().unwrap();
3018        let key = [0x5a; 32];
3019        let path = dir.path().join("seg-000009.wal");
3020        let mut wal = Wal::create_with_cipher(&path, Epoch(0), Some(cipher(&key)), 9).unwrap();
3021        for table_id in [1, 2] {
3022            wal.append_system(Op::Flush {
3023                table_id,
3024                flushed_epoch: table_id,
3025            })
3026            .unwrap();
3027        }
3028        wal.sync().unwrap();
3029        drop(wal);
3030        let original = std::fs::read(&path).unwrap();
3031        let ranges = frame_ranges(&original);
3032        assert_eq!(ranges.len(), 2);
3033
3034        let mut reordered = original[..HEADER_LEN as usize].to_vec();
3035        reordered.extend_from_slice(&original[ranges[1].clone()]);
3036        reordered.extend_from_slice(&original[ranges[0].clone()]);
3037        std::fs::write(&path, reordered).unwrap();
3038        assert!(replay_with_cipher(&path, Some(cipher(&key))).is_err());
3039
3040        let mut replayed = original[..HEADER_LEN as usize].to_vec();
3041        replayed.extend_from_slice(&original[ranges[0].clone()]);
3042        replayed.extend_from_slice(&original[ranges[0].clone()]);
3043        replayed.extend_from_slice(&original[ranges[1].clone()]);
3044        std::fs::write(&path, replayed).unwrap();
3045        assert!(replay_with_cipher(&path, Some(cipher(&key))).is_err());
3046
3047        let mut deleted = original[..HEADER_LEN as usize].to_vec();
3048        deleted.extend_from_slice(&original[ranges[1].clone()]);
3049        std::fs::write(&path, deleted).unwrap();
3050        assert!(replay_with_cipher(&path, Some(cipher(&key))).is_err());
3051
3052        let mut outer_tampered = original.clone();
3053        outer_tampered[ranges[0].start + 8] ^= 0x01;
3054        recompute_frame_crc(&mut outer_tampered, ranges[0].start);
3055        std::fs::write(&path, outer_tampered).unwrap();
3056        assert!(replay_with_cipher(&path, Some(cipher(&key))).is_err());
3057
3058        let other = dir.path().join("seg-000010.wal");
3059        let mut wal = Wal::create_with_cipher(&other, Epoch(0), Some(cipher(&key)), 10).unwrap();
3060        wal.append_system(Op::Flush {
3061            table_id: 3,
3062            flushed_epoch: 3,
3063        })
3064        .unwrap();
3065        wal.sync().unwrap();
3066        drop(wal);
3067        let mut moved = std::fs::read(&other).unwrap();
3068        moved.truncate(HEADER_LEN as usize);
3069        moved.extend_from_slice(&original[ranges[0].clone()]);
3070        std::fs::write(&other, moved).unwrap();
3071        assert!(replay_with_cipher(&other, Some(cipher(&key))).is_err());
3072    }
3073
3074    #[cfg(feature = "encryption")]
3075    #[test]
3076    fn wal_nonce_is_segment_deterministic() {
3077        // Two segments with different segment_no must never share a frame nonce
3078        // base, and frames within a segment never collide.
3079        assert_ne!(frame_nonce_for(5, 0), frame_nonce_for(6, 0));
3080        assert_ne!(frame_nonce_for(5, 0), frame_nonce_for(5, 1));
3081        // Deterministic: identical positions produce identical nonces. Segment
3082        // paths are create-new and never reused.
3083        assert_eq!(frame_nonce_for(5, 0), frame_nonce_for(5, 0));
3084    }
3085}