1use 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";
32pub(crate) const WAL_VERSION: u16 = 4;
35const HEADER_LEN: u64 = 8 + 2 + 4 + 8 + 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;
45const 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
57pub const SYSTEM_TXN_ID: u64 = 0;
60
61const CRC32C: Crc<u32> = Crc::<u32>::new(&CRC_32_ISCSI);
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct Record {
69 pub seq: Epoch,
70 pub txn_id: u64,
71 pub op: Op,
72}
73
74#[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#[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 AlterTable {
104 table_id: u64,
105 column_json: Vec<u8>,
106 },
107 RenameTable {
113 table_id: u64,
114 new_name: String,
115 },
116 SetTtl {
118 table_id: u64,
119 policy_json: Vec<u8>,
120 },
121 SetMaterializedView {
124 name: String,
125 definition_json: Vec<u8>,
126 },
127 SetSecurityCatalog {
129 security_json: Vec<u8>,
130 },
131 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 PublishBuildingTable {
143 table_id: u64,
144 new_name: String,
145 },
146 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 ReplaceBuildingTable {
159 table_id: u64,
160 replaced_table_id: u64,
161 new_name: String,
162 },
163 SetSqlPragma {
166 key: String,
167 value: i64,
168 },
169 CatalogSnapshot {
173 catalog_json: Vec<u8>,
174 },
175 ResetExternalTableState {
178 name: String,
179 generation_epoch: u64,
180 },
181 Command {
186 payload: Vec<u8>,
187 },
188}
189
190impl DdlOp {
191 pub fn encode_schema(schema: &Schema) -> Result<Vec<u8>> {
193 serde_json::to_vec(schema).map_err(|e| MongrelError::Other(format!("schema json: {e}")))
194 }
195
196 pub fn decode_schema(bytes: &[u8]) -> Result<Schema> {
198 serde_json::from_slice(bytes).map_err(|e| MongrelError::Other(format!("schema json: {e}")))
199 }
200
201 pub fn encode_column(column: &ColumnDef) -> Result<Vec<u8>> {
202 serde_json::to_vec(column).map_err(|e| MongrelError::Other(format!("column json: {e}")))
203 }
204
205 pub fn decode_column(bytes: &[u8]) -> Result<ColumnDef> {
206 serde_json::from_slice(bytes).map_err(|e| MongrelError::Other(format!("column json: {e}")))
207 }
208
209 pub fn encode_ttl(policy: Option<TtlPolicy>) -> Result<Vec<u8>> {
210 serde_json::to_vec(&policy).map_err(|e| MongrelError::Other(format!("TTL json: {e}")))
211 }
212
213 pub fn decode_ttl(bytes: &[u8]) -> Result<Option<TtlPolicy>> {
214 serde_json::from_slice(bytes).map_err(|e| MongrelError::Other(format!("TTL json: {e}")))
215 }
216
217 pub fn encode_materialized_view(
218 definition: &crate::catalog::MaterializedViewEntry,
219 ) -> Result<Vec<u8>> {
220 serde_json::to_vec(definition)
221 .map_err(|e| MongrelError::Other(format!("materialized view json: {e}")))
222 }
223
224 pub fn decode_materialized_view(bytes: &[u8]) -> Result<crate::catalog::MaterializedViewEntry> {
225 serde_json::from_slice(bytes)
226 .map_err(|e| MongrelError::Other(format!("materialized view json: {e}")))
227 }
228
229 pub fn encode_catalog(catalog: &crate::catalog::Catalog) -> Result<Vec<u8>> {
230 crate::catalog::encode(catalog)
231 }
232
233 pub fn decode_catalog(bytes: &[u8]) -> Result<crate::catalog::Catalog> {
234 crate::catalog::decode(bytes)
235 }
236
237 pub fn encode_security(security: &crate::security::SecurityCatalog) -> Result<Vec<u8>> {
238 serde_json::to_vec(security)
239 .map_err(|e| MongrelError::Other(format!("security catalog json: {e}")))
240 }
241
242 pub fn decode_security(bytes: &[u8]) -> Result<crate::security::SecurityCatalog> {
243 serde_json::from_slice(bytes)
244 .map_err(|e| MongrelError::Other(format!("security catalog json: {e}")))
245 }
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize)]
249pub enum Op {
250 Put {
251 table_id: u64,
252 rows: Vec<u8>,
253 },
254 Delete {
255 table_id: u64,
256 row_ids: Vec<RowId>,
257 },
258 TruncateTable {
259 table_id: u64,
260 },
261 ExternalTableState {
265 name: String,
266 state: Vec<u8>,
267 },
268 Flush {
272 table_id: u64,
273 flushed_epoch: u64,
274 },
275 TxnCommit {
278 epoch: u64,
279 added_runs: Vec<AddedRun>,
280 },
281 TxnAbort,
283 Ddl(DdlOp),
284 BeforeImage {
288 table_id: u64,
289 row_id: RowId,
290 row: Vec<u8>,
291 },
292 CommitTimestamp {
299 unix_nanos: u64,
300 },
301 SpilledRows {
306 table_id: u64,
307 rows: Vec<u8>,
308 },
309}
310
311impl Record {
312 pub fn new(seq: Epoch, txn_id: u64, op: Op) -> Self {
313 Self { seq, txn_id, op }
314 }
315}
316
317pub struct Wal {
320 file: BufWriter<File>,
321 path: PathBuf,
322 next_seq: u64,
324 unflushed_bytes: u64,
325 sync_byte_threshold: u64,
327 cipher: Option<Box<dyn crate::encryption::Cipher>>,
330 segment_no: u64,
337 frame_seq: u64,
343 previous_segment_hash: [u8; 32],
344 header_binding: [u8; 32],
345}
346
347impl Wal {
348 pub fn create(path: impl AsRef<Path>, epoch_created: Epoch) -> Result<Self> {
350 let path = path.as_ref();
351 let segment_no = segment_number_from_path(path).unwrap_or(0);
352 Self::create_with_cipher(path, epoch_created, None, segment_no)
353 }
354
355 pub fn create_with_cipher(
359 path: impl AsRef<Path>,
360 epoch_created: Epoch,
361 cipher: Option<Box<dyn crate::encryption::Cipher>>,
362 segment_no: u64,
363 ) -> Result<Self> {
364 Self::create_chained(path, epoch_created, cipher, segment_no, [0; 32])
365 }
366
367 fn create_chained(
368 path: impl AsRef<Path>,
369 epoch_created: Epoch,
370 cipher: Option<Box<dyn crate::encryption::Cipher>>,
371 segment_no: u64,
372 previous_segment_hash: [u8; 32],
373 ) -> Result<Self> {
374 let path = path.as_ref().to_path_buf();
375 let file = OpenOptions::new()
376 .create_new(true)
377 .read(true)
378 .write(true)
379 .open(&path)?;
380 let wal = Self::create_chained_from_file(
381 file,
382 path,
383 epoch_created,
384 cipher,
385 segment_no,
386 previous_segment_hash,
387 )?;
388 if let Some(parent) = wal.path.parent() {
389 crate::durable_file::sync_directory(parent)?;
390 }
391 Ok(wal)
392 }
393
394 fn create_chained_in(
395 wal_root: &crate::durable_file::DurableRoot,
396 segment_no: u64,
397 epoch_created: Epoch,
398 cipher: Option<Box<dyn crate::encryption::Cipher>>,
399 previous_segment_hash: [u8; 32],
400 ) -> Result<Self> {
401 let name = segment_filename(segment_no);
402 let file = wal_root.create_regular_new(&name)?;
403 let wal = Self::create_chained_from_file(
404 file,
405 wal_root.canonical_path().join(&name),
406 epoch_created,
407 cipher,
408 segment_no,
409 previous_segment_hash,
410 )?;
411 wal_root.sync_entry_parent(&name)?;
412 Ok(wal)
413 }
414
415 fn create_chained_from_file(
416 file: File,
417 path: PathBuf,
418 epoch_created: Epoch,
419 cipher: Option<Box<dyn crate::encryption::Cipher>>,
420 segment_no: u64,
421 previous_segment_hash: [u8; 32],
422 ) -> Result<Self> {
423 let next_seq = epoch_created
424 .0
425 .checked_add(1)
426 .ok_or_else(|| MongrelError::Full("WAL sequence namespace exhausted".into()))?;
427 let mut wal = Self {
428 file: BufWriter::with_capacity(1 << 20, file),
429 path,
430 next_seq,
431 unflushed_bytes: 0,
432 sync_byte_threshold: 64 * 1024,
433 cipher,
434 segment_no,
435 frame_seq: 0,
436 previous_segment_hash,
437 header_binding: [0; 32],
438 };
439 wal.write_header(epoch_created)?;
440 wal.sync()?;
444 Ok(wal)
445 }
446
447 pub fn append_txn(&mut self, txn_id: u64, op: Op) -> Result<Epoch> {
454 let seq = Epoch(self.next_seq);
455 let next_seq = self
456 .next_seq
457 .checked_add(1)
458 .ok_or_else(|| MongrelError::Full("WAL sequence namespace exhausted".into()))?;
459 self.append_record(&Record::new(seq, txn_id, op))?;
460 self.next_seq = next_seq;
461 Ok(seq)
462 }
463
464 pub fn append_system(&mut self, op: Op) -> Result<Epoch> {
466 self.append_txn(SYSTEM_TXN_ID, op)
467 }
468
469 fn append_record(&mut self, record: &Record) -> Result<()> {
470 let payload = bincode::serialize(record)?;
471
472 let frame_payload = if let Some(cipher) = &self.cipher {
475 if self.frame_seq > u32::MAX as u64 {
480 return Err(MongrelError::Full(
481 "wal segment frame counter exhausted (2^32); rotate the segment".into(),
482 ));
483 }
484 let nonce = self.frame_nonce();
485 let ciphertext_len = payload.len().checked_add(16).ok_or_else(|| {
486 MongrelError::InvalidArgument("wal payload length overflow".into())
487 })?;
488 let aad = wal_frame_aad(
489 &self.header_binding,
490 self.segment_no,
491 self.frame_seq as u32,
492 record.seq.0,
493 record.txn_id,
494 ciphertext_len as u64,
495 );
496 let ciphertext = cipher.encrypt_page_with_aad(&nonce, &payload, &aad)?;
497 self.frame_seq += 1;
498 ciphertext
499 } else {
500 payload
501 };
502
503 let len = frame_payload.len();
504 if len > u32::MAX as usize {
505 return Err(MongrelError::InvalidArgument(format!(
506 "wal payload too large: {len} bytes"
507 )));
508 }
509 let mut digest = CRC32C.digest();
511 digest.update(&record.seq.0.to_le_bytes());
512 digest.update(&record.txn_id.to_le_bytes());
513 digest.update(&frame_payload);
514 let crc_val = digest.finalize();
515
516 self.file.write_all(&(len as u32).to_le_bytes())?;
517 self.file.write_all(&crc_val.to_le_bytes())?;
518 self.file.write_all(&record.seq.0.to_le_bytes())?;
519 self.file.write_all(&record.txn_id.to_le_bytes())?;
520 self.file.write_all(&frame_payload)?;
521 self.unflushed_bytes += 4 + 4 + 8 + 8 + len as u64;
522 if self.sync_byte_threshold > 0 && self.unflushed_bytes >= self.sync_byte_threshold {
523 self.sync()?;
524 }
525 Ok(())
526 }
527
528 fn frame_nonce(&self) -> [u8; 12] {
534 frame_nonce_for(self.segment_no, self.frame_seq as u32)
535 }
536
537 pub fn sync(&mut self) -> Result<()> {
539 self.file.flush()?;
540 self.file.get_ref().sync_all()?;
541 self.unflushed_bytes = 0;
542 Ok(())
543 }
544
545 #[inline]
547 pub fn unflushed_bytes(&self) -> u64 {
548 self.unflushed_bytes
549 }
550
551 #[inline]
554 pub fn next_seq_val(&self) -> u64 {
555 self.next_seq
556 }
557
558 pub fn set_sync_byte_threshold(&mut self, threshold: u64) {
563 self.sync_byte_threshold = threshold;
564 }
565
566 pub fn path(&self) -> &Path {
567 &self.path
568 }
569
570 pub(crate) fn publish_as(mut self, destination: PathBuf) -> Result<Self> {
573 self.sync()?;
574 crate::durable_file::rename(&self.path, &destination)?;
575 self.path = destination;
576 Ok(self)
577 }
578
579 fn write_header(&mut self, epoch_created: Epoch) -> Result<()> {
580 let enc_flag = if self.cipher.is_some() {
581 ENC_AES_GCM
582 } else {
583 ENC_PLAINTEXT
584 };
585 let header = encode_wal_header(
586 enc_flag,
587 epoch_created.0,
588 self.segment_no,
589 &self.previous_segment_hash,
590 );
591 self.header_binding = Sha256::digest(&header).into();
592 self.file.write_all(&header)?;
593 self.unflushed_bytes = 0;
594 Ok(())
595 }
596}
597
598impl Drop for Wal {
599 fn drop(&mut self) {
600 let _ = self.file.flush();
601 }
602}
603
604fn encode_wal_header(
605 encryption: u8,
606 epoch_created: u64,
607 segment_no: u64,
608 previous_segment_hash: &[u8; 32],
609) -> Vec<u8> {
610 let mut header = Vec::with_capacity(HEADER_LEN as usize);
611 header.extend_from_slice(&WAL_MAGIC);
612 header.extend_from_slice(&WAL_VERSION.to_le_bytes());
613 header.extend_from_slice(&[encryption, 0, 0, 0]);
614 header.extend_from_slice(&epoch_created.to_le_bytes());
615 header.extend_from_slice(&segment_no.to_le_bytes());
616 header.extend_from_slice(previous_segment_hash);
617 header
618}
619
620fn wal_frame_aad(
621 header_binding: &[u8; 32],
622 segment_no: u64,
623 frame_seq: u32,
624 record_seq: u64,
625 txn_id: u64,
626 ciphertext_len: u64,
627) -> Vec<u8> {
628 let mut aad = Vec::with_capacity(WAL_FRAME_AAD_DOMAIN.len() + 68);
629 aad.extend_from_slice(WAL_FRAME_AAD_DOMAIN);
630 aad.extend_from_slice(header_binding);
631 aad.extend_from_slice(&segment_no.to_le_bytes());
632 aad.extend_from_slice(&frame_seq.to_le_bytes());
633 aad.extend_from_slice(&record_seq.to_le_bytes());
634 aad.extend_from_slice(&txn_id.to_le_bytes());
635 aad.extend_from_slice(&ciphertext_len.to_le_bytes());
636 aad
637}
638
639fn wal_head_body(head: &WalHead, encrypted: bool) -> [u8; WAL_HEAD_BODY_LEN] {
640 let mut body = [0_u8; WAL_HEAD_BODY_LEN];
641 body[..8].copy_from_slice(&WAL_HEAD_MAGIC);
642 body[8..10].copy_from_slice(&WAL_HEAD_VERSION.to_le_bytes());
643 body[10] = if encrypted {
644 ENC_AES_GCM
645 } else {
646 ENC_PLAINTEXT
647 };
648 body[16..24].copy_from_slice(&head.segment_no.to_le_bytes());
649 body[24..32].copy_from_slice(&head.durable_len.to_le_bytes());
650 body[32..40].copy_from_slice(&head.open_generation.to_le_bytes());
651 body[40..72].copy_from_slice(&head.prefix_hash);
652 body
653}
654
655fn wal_head_auth(
656 body: &[u8; WAL_HEAD_BODY_LEN],
657 wal_dek: Option<&Zeroizing<[u8; 32]>>,
658) -> Result<[u8; 32]> {
659 if let Some(key) = wal_dek {
660 #[cfg(feature = "encryption")]
661 {
662 let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(&key[..])
663 .map_err(|error| MongrelError::Encryption(error.to_string()))?;
664 mac.update(WAL_HEAD_AUTH_DOMAIN);
665 mac.update(body);
666 return Ok(mac.finalize().into_bytes().into());
667 }
668 #[cfg(not(feature = "encryption"))]
669 {
670 let _ = key;
671 return Err(MongrelError::Encryption(
672 "encryption feature disabled but a WAL DEK was supplied".into(),
673 ));
674 }
675 }
676 let mut hash = Sha256::new();
677 hash.update(WAL_HEAD_AUTH_DOMAIN);
678 hash.update(body);
679 Ok(hash.finalize().into())
680}
681
682fn encode_wal_head(
683 head: &WalHead,
684 wal_dek: Option<&Zeroizing<[u8; 32]>>,
685) -> Result<[u8; WAL_HEAD_LEN]> {
686 let body = wal_head_body(head, wal_dek.is_some());
687 let auth = wal_head_auth(&body, wal_dek)?;
688 let mut encoded = [0_u8; WAL_HEAD_LEN];
689 encoded[..WAL_HEAD_BODY_LEN].copy_from_slice(&body);
690 encoded[WAL_HEAD_BODY_LEN..].copy_from_slice(&auth);
691 Ok(encoded)
692}
693
694fn decode_wal_head(encoded: &[u8], wal_dek: Option<&Zeroizing<[u8; 32]>>) -> Result<WalHead> {
695 if encoded.len() != WAL_HEAD_LEN {
696 return Err(MongrelError::CorruptWal {
697 offset: 0,
698 reason: format!(
699 "WAL head is {} bytes, expected {WAL_HEAD_LEN}",
700 encoded.len()
701 ),
702 });
703 }
704 let body: &[u8; WAL_HEAD_BODY_LEN] =
705 encoded[..WAL_HEAD_BODY_LEN]
706 .try_into()
707 .map_err(|_| MongrelError::CorruptWal {
708 offset: 0,
709 reason: "invalid WAL head body".into(),
710 })?;
711 if body[..8] != WAL_HEAD_MAGIC {
712 return Err(MongrelError::CorruptWal {
713 offset: 0,
714 reason: "invalid WAL head magic".into(),
715 });
716 }
717 let version = u16::from_le_bytes([body[8], body[9]]);
718 if version != WAL_HEAD_VERSION {
719 return Err(MongrelError::CorruptWal {
720 offset: 8,
721 reason: format!("unsupported WAL head version {version}"),
722 });
723 }
724 let expected_mode = if wal_dek.is_some() {
725 ENC_AES_GCM
726 } else {
727 ENC_PLAINTEXT
728 };
729 if body[10] != expected_mode || body[11..16] != [0; 5] {
730 return Err(MongrelError::CorruptWal {
731 offset: 10,
732 reason: "WAL head authentication mode or reserved bytes differ".into(),
733 });
734 }
735 let expected_auth = wal_head_auth(body, wal_dek)?;
736 if encoded[WAL_HEAD_BODY_LEN..] != expected_auth {
737 return Err(MongrelError::CorruptWal {
738 offset: WAL_HEAD_BODY_LEN as u64,
739 reason: "WAL head authentication failed".into(),
740 });
741 }
742 let mut prefix_hash = [0_u8; 32];
743 prefix_hash.copy_from_slice(&body[40..72]);
744 let mut segment_no = [0_u8; 8];
745 segment_no.copy_from_slice(&body[16..24]);
746 let mut durable_len = [0_u8; 8];
747 durable_len.copy_from_slice(&body[24..32]);
748 let mut open_generation = [0_u8; 8];
749 open_generation.copy_from_slice(&body[32..40]);
750 Ok(WalHead {
751 segment_no: u64::from_le_bytes(segment_no),
752 durable_len: u64::from_le_bytes(durable_len),
753 open_generation: u64::from_le_bytes(open_generation),
754 prefix_hash,
755 })
756}
757
758fn hash_file_prefix(file: File, length: u64) -> Result<[u8; 32]> {
759 if file.metadata()?.len() < length {
760 return Err(MongrelError::CorruptWal {
761 offset: length,
762 reason: "WAL file is shorter than its durable head".into(),
763 });
764 }
765 let mut reader = BufReader::new(file).take(length);
766 let mut hash = Sha256::new();
767 std::io::copy(&mut reader, &mut HashWriter(&mut hash))?;
768 Ok(hash.finalize().into())
769}
770
771struct HashWriter<'a>(&'a mut Sha256);
772
773impl Write for HashWriter<'_> {
774 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
775 self.0.update(bytes);
776 Ok(bytes.len())
777 }
778
779 fn flush(&mut self) -> std::io::Result<()> {
780 Ok(())
781 }
782}
783
784fn hash_segment(wal_root: &crate::durable_file::DurableRoot, segment_no: u64) -> Result<[u8; 32]> {
785 let name = segment_filename(segment_no);
786 let file = wal_root.open_regular(&name)?;
787 let length = file.metadata()?.len();
788 hash_file_prefix(file, length)
789}
790
791fn read_wal_head(
792 root: &crate::durable_file::DurableRoot,
793 wal_dek: Option<&Zeroizing<[u8; 32]>>,
794) -> Result<Option<WalHead>> {
795 let relative = Path::new("_meta").join(WAL_HEAD_FILENAME);
796 match root.entry_exists(&relative) {
797 Ok(true) => {}
798 Ok(false) => return Ok(None),
799 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
800 Err(error) => return Err(error.into()),
801 }
802 let mut file = root.open_regular(&relative)?;
803 let length = file.metadata()?.len();
804 if length != WAL_HEAD_LEN as u64 {
805 return Err(MongrelError::CorruptWal {
806 offset: 0,
807 reason: format!("WAL head is {length} bytes, expected {WAL_HEAD_LEN}"),
808 });
809 }
810 let mut encoded = [0_u8; WAL_HEAD_LEN];
811 file.read_exact(&mut encoded)?;
812 decode_wal_head(&encoded, wal_dek).map(Some)
813}
814
815fn write_wal_head(
816 root: &crate::durable_file::DurableRoot,
817 wal_root: &crate::durable_file::DurableRoot,
818 segment_no: u64,
819 open_generation: u64,
820 wal_dek: Option<&Zeroizing<[u8; 32]>>,
821) -> Result<WalHead> {
822 let name = segment_filename(segment_no);
823 let file = wal_root.open_regular(&name)?;
824 let durable_len = file.metadata()?.len();
825 let head = WalHead {
826 segment_no,
827 durable_len,
828 open_generation,
829 prefix_hash: hash_file_prefix(file, durable_len)?,
830 };
831 root.create_directory_all("_meta")?;
832 root.write_atomic(
833 Path::new("_meta").join(WAL_HEAD_FILENAME),
834 &encode_wal_head(&head, wal_dek)?,
835 )?;
836 Ok(head)
837}
838
839pub struct WalReader {
843 inner: BufReader<File>,
844 pos: u64,
845 file_len: u64,
846 encrypted: bool,
848 cipher: Option<Box<dyn crate::encryption::Cipher>>,
850 version: u16,
851 segment_no: u64,
852 previous_segment_hash: [u8; 32],
853 header_binding: [u8; 32],
854 frame_seq: u64,
855}
856
857impl WalReader {
858 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
859 Self::open_with_cipher(path, None)
860 }
861
862 pub fn open_with_cipher(
864 path: impl AsRef<Path>,
865 cipher: Option<Box<dyn crate::encryption::Cipher>>,
866 ) -> Result<Self> {
867 Self::open_with_cipher_expected(path, cipher, None)
868 }
869
870 fn open_with_cipher_expected(
871 path: impl AsRef<Path>,
872 cipher: Option<Box<dyn crate::encryption::Cipher>>,
873 expected_segment_no: Option<u64>,
874 ) -> Result<Self> {
875 let path = path.as_ref();
876 let expected_segment_no = expected_segment_no.or_else(|| segment_number_from_path(path));
877 let file = crate::durable_file::open_regular_nofollow(path)?;
878 Self::open_file_with_cipher_expected(file, cipher, expected_segment_no)
879 }
880
881 fn open_file_with_cipher_expected(
882 mut file: File,
883 cipher: Option<Box<dyn crate::encryption::Cipher>>,
884 expected_segment_no: Option<u64>,
885 ) -> Result<Self> {
886 let file_len = file.metadata()?.len();
887 let mut magic = [0u8; 8];
888 file.read_exact(&mut magic)?;
889 if magic != WAL_MAGIC {
890 return Err(MongrelError::MagicMismatch {
891 what: "wal",
892 expected: WAL_MAGIC,
893 got: magic,
894 });
895 }
896 let mut version_buf = [0u8; 2];
897 file.read_exact(&mut version_buf)?;
898 let version = u16::from_le_bytes(version_buf);
899 if version != WAL_VERSION {
900 return Err(MongrelError::UnsupportedStorageVersion {
901 component: "wal",
902 found: version,
903 supported: WAL_VERSION,
904 });
905 }
906 let mut reserved = [0u8; 4];
907 file.read_exact(&mut reserved)?;
908 if !matches!(reserved[0], ENC_PLAINTEXT | ENC_AES_GCM) || reserved[1..] != [0, 0, 0] {
909 return Err(MongrelError::CorruptWal {
910 offset: 10,
911 reason: "invalid WAL header flags".into(),
912 });
913 }
914 let encrypted = reserved[0] == ENC_AES_GCM;
915 let mut epoch_buf = [0u8; 8];
916 file.read_exact(&mut epoch_buf)?;
917 let _epoch_created = Epoch(u64::from_le_bytes(epoch_buf));
918 let mut previous_segment_hash = [0_u8; 32];
919 let segment_no = {
920 let mut segment = [0_u8; 8];
921 file.read_exact(&mut segment)?;
922 file.read_exact(&mut previous_segment_hash)?;
923 u64::from_le_bytes(segment)
924 };
925 if expected_segment_no.is_some_and(|expected| expected != segment_no) {
926 return Err(MongrelError::CorruptWal {
927 offset: 0,
928 reason: format!(
929 "WAL header segment {segment_no} does not match filename segment {}",
930 expected_segment_no.unwrap()
931 ),
932 });
933 }
934 let pos = HEADER_LEN;
935 let header_binding = Sha256::digest(encode_wal_header(
936 reserved[0],
937 _epoch_created.0,
938 segment_no,
939 &previous_segment_hash,
940 ))
941 .into();
942 if encrypted && cipher.is_none() {
943 return Err(MongrelError::Decryption(
944 "WAL is encrypted but no passphrase or key was provided. \
945 Use Table::open_encrypted or Table::open_with_key."
946 .into(),
947 ));
948 }
949 Ok(Self {
950 inner: BufReader::new(file),
951 pos,
952 file_len,
953 encrypted,
954 cipher,
955 version,
956 segment_no,
957 previous_segment_hash,
958 header_binding,
959 frame_seq: 0,
960 })
961 }
962
963 pub fn next_record(&mut self) -> Result<Option<Record>> {
966 if self.pos == self.file_len {
967 return Ok(None);
968 }
969 if self.file_len.saturating_sub(self.pos) < 4 {
970 return Err(MongrelError::TornWrite { offset: self.pos });
971 }
972 let mut len_buf = [0u8; 4];
973 match self.inner.read_exact(&mut len_buf) {
974 Ok(()) => {}
975 Err(ref e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
976 return Err(MongrelError::TornWrite { offset: self.pos });
977 }
978 Err(e) => return Err(e.into()),
979 }
980 let len = u32::from_le_bytes(len_buf) as usize;
981 if len == 0 {
982 return Err(MongrelError::CorruptWal {
983 offset: self.pos,
984 reason: "zero-length WAL frames are not valid".into(),
985 });
986 }
987 const MAX_RECORD_LEN: usize = 64 * 1024 * 1024;
990 if len > MAX_RECORD_LEN {
991 return Err(MongrelError::CorruptWal {
992 offset: self.pos,
993 reason: format!("WAL frame length {len} exceeds limit"),
994 });
995 }
996
997 let record_start = self.pos;
998 let mut rest = vec![0u8; 4 + 8 + 8 + len];
999 match self.inner.read_exact(&mut rest) {
1000 Ok(()) => {}
1001 Err(ref e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
1002 return Err(MongrelError::TornWrite {
1003 offset: record_start,
1004 });
1005 }
1006 Err(e) => return Err(e.into()),
1007 }
1008 let crc_val = u32::from_le_bytes([rest[0], rest[1], rest[2], rest[3]]);
1009 let seq = u64::from_le_bytes([
1010 rest[4], rest[5], rest[6], rest[7], rest[8], rest[9], rest[10], rest[11],
1011 ]);
1012 let txn_id = u64::from_le_bytes([
1013 rest[12], rest[13], rest[14], rest[15], rest[16], rest[17], rest[18], rest[19],
1014 ]);
1015 let payload = &rest[20..];
1016
1017 let mut digest = CRC32C.digest();
1018 digest.update(&seq.to_le_bytes());
1019 digest.update(&txn_id.to_le_bytes());
1020 digest.update(payload);
1021 if digest.finalize() != crc_val {
1022 return Err(MongrelError::CorruptWal {
1023 offset: record_start,
1024 reason: "crc mismatch".into(),
1025 });
1026 }
1027
1028 let plaintext = if self.encrypted {
1030 let Some(cipher) = &self.cipher else {
1031 return Err(MongrelError::Decryption(
1032 "WAL is encrypted but no cipher was provided".into(),
1033 ));
1034 };
1035 let minimum = 16;
1036 if payload.len() < minimum {
1037 return Err(MongrelError::CorruptWal {
1038 offset: record_start,
1039 reason: "encrypted frame too short".into(),
1040 });
1041 }
1042 if self.frame_seq > u32::MAX as u64 {
1043 return Err(MongrelError::CorruptWal {
1044 offset: record_start,
1045 reason: "WAL frame counter exceeds u32".into(),
1046 });
1047 }
1048 let expected_nonce = frame_nonce_for(self.segment_no, self.frame_seq as u32);
1049 let aad = wal_frame_aad(
1050 &self.header_binding,
1051 self.segment_no,
1052 self.frame_seq as u32,
1053 seq,
1054 txn_id,
1055 payload.len() as u64,
1056 );
1057 cipher
1058 .decrypt_page_with_aad(&expected_nonce, payload, &aad)
1059 .map_err(|e| {
1060 MongrelError::Decryption(format!(
1061 "WAL frame decryption failed — wrong passphrase or key? ({e})"
1062 ))
1063 })?
1064 } else {
1065 payload.to_vec()
1066 };
1067
1068 let record: Record =
1069 bincode::deserialize(&plaintext).map_err(|error| MongrelError::CorruptWal {
1070 offset: record_start,
1071 reason: format!("WAL record decode failed: {error}"),
1072 })?;
1073 if record.seq.0 != seq || record.txn_id != txn_id {
1074 return Err(MongrelError::CorruptWal {
1075 offset: record_start,
1076 reason: "WAL outer and authenticated record identity differ".into(),
1077 });
1078 }
1079 self.frame_seq += 1;
1080 self.pos += 4 + 4 + 8 + 8 + len as u64;
1081 Ok(Some(record))
1082 }
1083
1084 fn constrain_to_durable_len(&mut self, durable_len: u64) -> Result<()> {
1085 if durable_len < self.pos || durable_len > self.file_len {
1086 return Err(MongrelError::CorruptWal {
1087 offset: durable_len,
1088 reason: format!(
1089 "WAL durable length {durable_len} is outside [{}, {}]",
1090 self.pos, self.file_len
1091 ),
1092 });
1093 }
1094 self.file_len = durable_len;
1095 Ok(())
1096 }
1097
1098 pub fn replay(&mut self) -> Result<Vec<Record>> {
1104 self.replay_with_tail_policy(true)
1105 }
1106
1107 fn replay_strict(&mut self) -> Result<Vec<Record>> {
1108 self.replay_with_tail_policy(false)
1109 }
1110
1111 fn replay_bounded(&mut self, max_records: usize, allow_torn_tail: bool) -> Result<Vec<Record>> {
1112 let mut out = Vec::new();
1113 loop {
1114 match self.next_record() {
1115 Ok(Some(record)) => {
1116 if out.len() >= max_records {
1117 return Err(MongrelError::ResourceLimitExceeded {
1118 resource: "WAL recovery records",
1119 requested: max_records.saturating_add(1),
1120 limit: max_records,
1121 });
1122 }
1123 out.push(record);
1124 }
1125 Ok(None) => break,
1126 Err(MongrelError::TornWrite { offset }) => {
1127 if !allow_torn_tail {
1128 return Err(MongrelError::CorruptWal {
1129 offset,
1130 reason: "torn tail in a non-final WAL segment".into(),
1131 });
1132 }
1133 if self.valid_frame_follows()? {
1134 return Err(MongrelError::CorruptWal {
1135 offset,
1136 reason: "interior torn frame followed by a valid frame".into(),
1137 });
1138 }
1139 break;
1140 }
1141 Err(error @ MongrelError::CorruptWal { .. }) => return Err(error),
1142 Err(error) => return Err(error),
1143 }
1144 }
1145 Ok(out)
1146 }
1147
1148 fn replay_with_tail_policy(&mut self, allow_torn_tail: bool) -> Result<Vec<Record>> {
1149 let mut out = Vec::new();
1150 loop {
1151 match self.next_record() {
1152 Ok(Some(rec)) => out.push(rec),
1153 Ok(None) => break,
1154 Err(MongrelError::TornWrite { offset }) => {
1155 if !allow_torn_tail {
1156 return Err(MongrelError::CorruptWal {
1157 offset,
1158 reason: "torn tail in a non-final WAL segment".into(),
1159 });
1160 }
1161 if self.valid_frame_follows()? {
1164 return Err(MongrelError::CorruptWal {
1165 offset,
1166 reason: "interior torn frame followed by a valid frame".into(),
1167 });
1168 }
1169 break;
1170 }
1171 Err(error @ MongrelError::CorruptWal { .. }) => return Err(error),
1172 Err(e) => return Err(e),
1173 }
1174 }
1175 Ok(out)
1176 }
1177
1178 pub fn replay_controlled(
1180 &mut self,
1181 control: &crate::ExecutionControl,
1182 max_records: usize,
1183 ) -> Result<Vec<Record>> {
1184 self.replay_controlled_with_tail_policy(control, max_records, true)
1185 }
1186
1187 fn replay_controlled_strict(
1188 &mut self,
1189 control: &crate::ExecutionControl,
1190 max_records: usize,
1191 ) -> Result<Vec<Record>> {
1192 self.replay_controlled_with_tail_policy(control, max_records, false)
1193 }
1194
1195 fn replay_controlled_with_tail_policy(
1196 &mut self,
1197 control: &crate::ExecutionControl,
1198 max_records: usize,
1199 allow_torn_tail: bool,
1200 ) -> Result<Vec<Record>> {
1201 let mut out = Vec::new();
1202 loop {
1203 if out.len() % 256 == 0 {
1204 control.checkpoint()?;
1205 }
1206 match self.next_record() {
1207 Ok(Some(record)) => {
1208 if out.len() >= max_records {
1209 return Err(MongrelError::ResourceLimitExceeded {
1210 resource: "controlled WAL replay records",
1211 requested: max_records.saturating_add(1),
1212 limit: max_records,
1213 });
1214 }
1215 out.push(record);
1216 }
1217 Ok(None) => break,
1218 Err(MongrelError::TornWrite { offset }) => {
1219 if !allow_torn_tail {
1220 return Err(MongrelError::CorruptWal {
1221 offset,
1222 reason: "torn tail in a non-final WAL segment".into(),
1223 });
1224 }
1225 if self.valid_frame_follows()? {
1226 return Err(MongrelError::CorruptWal {
1227 offset,
1228 reason: "interior torn frame followed by a valid frame".into(),
1229 });
1230 }
1231 break;
1232 }
1233 Err(error @ MongrelError::CorruptWal { .. }) => return Err(error),
1234 Err(error) => return Err(error),
1235 }
1236 }
1237 control.checkpoint()?;
1238 Ok(out)
1239 }
1240
1241 fn valid_frame_follows(&mut self) -> Result<bool> {
1246 match self.next_record() {
1247 Ok(Some(_)) => Ok(true),
1248 Ok(None) => Ok(false),
1249 Err(_) => Ok(false),
1250 }
1251 }
1252
1253 pub fn current_offset(&self) -> u64 {
1256 self.pos
1257 }
1258}
1259
1260pub fn replay(path: impl AsRef<Path>) -> Result<Vec<Record>> {
1263 WalReader::open(path)?.replay()
1264}
1265
1266pub fn replay_with_cipher(
1268 path: impl AsRef<Path>,
1269 cipher: Option<Box<dyn crate::encryption::Cipher>>,
1270) -> Result<Vec<Record>> {
1271 WalReader::open_with_cipher(path, cipher)?.replay()
1272}
1273
1274pub fn frame_nonce_for(segment_no: u64, frame: u32) -> [u8; 12] {
1279 let mut n = [0u8; 12];
1280 n[..8].copy_from_slice(&segment_no.to_be_bytes());
1281 n[8..].copy_from_slice(&frame.to_le_bytes());
1282 n
1283}
1284
1285pub struct SharedWal {
1291 root: Arc<crate::durable_file::DurableRoot>,
1292 wal_root: Arc<crate::durable_file::DurableRoot>,
1293 wal_dir: PathBuf,
1294 active: Wal,
1295 active_segment_no: u64,
1297 durable_seq: u64,
1300 open_generation: u64,
1302 wal_dek: Option<Zeroizing<[u8; 32]>>,
1305 group_sync_count: u64,
1309}
1310
1311#[derive(Default)]
1312struct ReplayTxnState {
1313 timestamp_seen: bool,
1314 terminal: bool,
1315}
1316
1317struct WalLayout {
1318 segments: Vec<u64>,
1319 head: Option<WalHead>,
1320}
1321
1322fn reader_for_segment(
1323 wal_root: &crate::durable_file::DurableRoot,
1324 segment_no: u64,
1325 wal_dek: Option<&Zeroizing<[u8; 32]>>,
1326) -> Result<WalReader> {
1327 let cipher = match wal_dek {
1328 #[cfg(feature = "encryption")]
1329 Some(key) => Some(SharedWal::cipher_from_dek(key)?),
1330 #[cfg(not(feature = "encryption"))]
1331 Some(_) => {
1332 return Err(MongrelError::Encryption(
1333 "encryption feature disabled but a WAL DEK was supplied".into(),
1334 ));
1335 }
1336 None => None,
1337 };
1338 let file = wal_root.open_regular(segment_filename(segment_no))?;
1339 WalReader::open_file_with_cipher_expected(file, cipher, Some(segment_no))
1340}
1341
1342fn inspect_wal_layout(
1343 root: &crate::durable_file::DurableRoot,
1344 wal_root: &crate::durable_file::DurableRoot,
1345 wal_dek: Option<&Zeroizing<[u8; 32]>>,
1346) -> Result<WalLayout> {
1347 let segments = list_segment_numbers(wal_root)?;
1348 let head = read_wal_head(root, wal_dek)?;
1349 for (index, segment_no) in segments.iter().copied().enumerate() {
1350 let reader = reader_for_segment(wal_root, segment_no, wal_dek)?;
1351 if reader.encrypted != wal_dek.is_some() {
1352 return Err(MongrelError::CorruptWal {
1353 offset: 10,
1354 reason: format!(
1355 "WAL segment {segment_no} encryption mode differs from the database"
1356 ),
1357 });
1358 }
1359 if index != 0 {
1360 let previous_no = segments[index - 1];
1361 let expected = hash_segment(wal_root, previous_no)?;
1362 if reader.previous_segment_hash != expected {
1363 return Err(MongrelError::CorruptWal {
1364 offset: 30,
1365 reason: format!(
1366 "WAL segment {segment_no} does not authenticate segment {previous_no}"
1367 ),
1368 });
1369 }
1370 }
1371 }
1372
1373 if !segments.is_empty() && head.is_none() {
1374 return Err(MongrelError::CorruptWal {
1375 offset: 0,
1376 reason: "WAL segments exist without a durable WAL head".into(),
1377 });
1378 }
1379 if segments.is_empty() && head.is_some() {
1380 return Err(MongrelError::CorruptWal {
1381 offset: 0,
1382 reason: "a durable WAL head exists without any WAL segment".into(),
1383 });
1384 }
1385 if let Some(head) = head {
1386 let final_segment = segments
1387 .last()
1388 .copied()
1389 .ok_or_else(|| MongrelError::CorruptWal {
1390 offset: 0,
1391 reason: "durable WAL head references an empty WAL directory".into(),
1392 })?;
1393 if head.segment_no != final_segment {
1394 return Err(MongrelError::CorruptWal {
1395 offset: head.segment_no,
1396 reason: format!(
1397 "durable WAL head references segment {}, newest retained segment is {final_segment}",
1398 head.segment_no
1399 ),
1400 });
1401 }
1402 let file = wal_root.open_regular(segment_filename(head.segment_no))?;
1403 let actual_len = file.metadata()?.len();
1404 if head.durable_len > actual_len {
1405 return Err(MongrelError::CorruptWal {
1406 offset: head.durable_len,
1407 reason: format!(
1408 "durable WAL head length {} exceeds segment length {actual_len}",
1409 head.durable_len
1410 ),
1411 });
1412 }
1413 if hash_file_prefix(file, head.durable_len)? != head.prefix_hash {
1414 return Err(MongrelError::CorruptWal {
1415 offset: head.durable_len,
1416 reason: "durable WAL prefix hash differs from WAL head".into(),
1417 });
1418 }
1419 }
1420 Ok(WalLayout { segments, head })
1421}
1422
1423fn remove_unpublished_header_only_segment(
1424 root: &crate::durable_file::DurableRoot,
1425 wal_root: &crate::durable_file::DurableRoot,
1426 wal_dek: Option<&Zeroizing<[u8; 32]>>,
1427) -> Result<bool> {
1428 let Some(head) = read_wal_head(root, wal_dek)? else {
1429 return Ok(false);
1430 };
1431 let segments = list_segment_numbers(wal_root)?;
1432 let Some(newest) = segments.last().copied() else {
1433 return Ok(false);
1434 };
1435 let Some(orphan_no) = head.segment_no.checked_add(1) else {
1436 return Ok(false);
1437 };
1438 if newest != orphan_no || !segments.contains(&head.segment_no) {
1439 return Ok(false);
1440 }
1441 let reader = reader_for_segment(wal_root, orphan_no, wal_dek)?;
1442 if reader.version != WAL_VERSION
1443 || reader.file_len != HEADER_LEN
1444 || reader.previous_segment_hash != hash_segment(wal_root, head.segment_no)?
1445 {
1446 return Ok(false);
1447 }
1448 wal_root.remove_file(segment_filename(orphan_no))?;
1449 Ok(true)
1450}
1451
1452struct ReplayedSegment {
1456 segment_no: u64,
1457 records: Vec<Record>,
1458}
1459
1460fn validate_v4_sequence_continuity(segments: &[ReplayedSegment]) -> Result<()> {
1465 let mut previous: Option<(u64, u64)> = None;
1466 for segment in segments {
1467 for record in &segment.records {
1468 if let Some((previous_seq, previous_segment)) = previous {
1469 let expected =
1470 previous_seq
1471 .checked_add(1)
1472 .ok_or_else(|| MongrelError::CorruptWal {
1473 offset: record.seq.0,
1474 reason: "WAL sequence overflows after u64::MAX".into(),
1475 })?;
1476 if record.seq.0 != expected {
1477 let reason = if segment.segment_no == previous_segment {
1478 format!(
1479 "WAL segment {} sequence {} does not follow {previous_seq}",
1480 segment.segment_no, record.seq.0
1481 )
1482 } else {
1483 format!(
1484 "WAL segment {} begins with sequence {}, expected {expected} after segment {previous_segment}",
1485 segment.segment_no, record.seq.0
1486 )
1487 };
1488 return Err(MongrelError::CorruptWal {
1489 offset: record.seq.0,
1490 reason,
1491 });
1492 }
1493 }
1494 previous = Some((record.seq.0, segment.segment_no));
1495 }
1496 }
1497 Ok(())
1498}
1499
1500fn replay_wal_layout(
1501 wal_root: &crate::durable_file::DurableRoot,
1502 layout: &WalLayout,
1503 wal_dek: Option<&Zeroizing<[u8; 32]>>,
1504) -> Result<Vec<Record>> {
1505 let total_bytes = layout
1506 .segments
1507 .iter()
1508 .try_fold(0_u64, |total, segment_no| {
1509 let bytes = if layout
1510 .head
1511 .is_some_and(|head| head.segment_no == *segment_no)
1512 {
1513 layout.head.unwrap().durable_len
1514 } else {
1515 wal_root
1516 .open_regular(segment_filename(*segment_no))?
1517 .metadata()?
1518 .len()
1519 };
1520 total
1521 .checked_add(bytes)
1522 .ok_or(MongrelError::ResourceLimitExceeded {
1523 resource: "WAL recovery bytes",
1524 requested: usize::MAX,
1525 limit: MAX_RECOVERY_WAL_BYTES as usize,
1526 })
1527 })?;
1528 if total_bytes > MAX_RECOVERY_WAL_BYTES {
1529 return Err(MongrelError::ResourceLimitExceeded {
1530 resource: "WAL recovery bytes",
1531 requested: usize::try_from(total_bytes).unwrap_or(usize::MAX),
1532 limit: MAX_RECOVERY_WAL_BYTES as usize,
1533 });
1534 }
1535 let mut segments = Vec::with_capacity(layout.segments.len());
1536 let mut total_records = 0_usize;
1537 for segment_no in layout.segments.iter().copied() {
1538 let mut reader = reader_for_segment(wal_root, segment_no, wal_dek)?;
1539 if layout
1540 .head
1541 .is_some_and(|head| head.segment_no == segment_no)
1542 {
1543 reader.constrain_to_durable_len(layout.head.unwrap().durable_len)?;
1544 }
1545 let remaining = MAX_RECOVERY_WAL_RECORDS.saturating_sub(total_records);
1546 let records = reader.replay_bounded(remaining, false)?;
1550 total_records += records.len();
1551 segments.push(ReplayedSegment {
1552 segment_no,
1553 records,
1554 });
1555 }
1556 validate_v4_sequence_continuity(&segments)?;
1557 let records: Vec<Record> = segments
1558 .into_iter()
1559 .flat_map(|segment| segment.records)
1560 .collect();
1561 validate_shared_transaction_framing(&records)?;
1562 Ok(records)
1563}
1564
1565pub(crate) fn validate_shared_transaction_framing(records: &[Record]) -> Result<()> {
1570 let mut transactions = std::collections::HashMap::<u64, ReplayTxnState>::new();
1571 let mut commit_epochs = std::collections::HashMap::<u64, u64>::new();
1572 let mut previous_commit_epoch: Option<u64> = None;
1573 for record in records {
1574 if record.txn_id == SYSTEM_TXN_ID {
1575 if !matches!(record.op, Op::Flush { .. }) {
1576 return Err(MongrelError::CorruptWal {
1577 offset: record.seq.0,
1578 reason: "non-system operation uses reserved transaction id 0".into(),
1579 });
1580 }
1581 continue;
1582 }
1583 let state = transactions.entry(record.txn_id).or_default();
1584 if state.terminal {
1585 return Err(MongrelError::CorruptWal {
1586 offset: record.seq.0,
1587 reason: format!(
1588 "transaction {} has records after its terminal marker",
1589 record.txn_id
1590 ),
1591 });
1592 }
1593 match record.op {
1594 Op::CommitTimestamp { .. } => {
1595 if state.timestamp_seen {
1596 return Err(MongrelError::CorruptWal {
1597 offset: record.seq.0,
1598 reason: format!(
1599 "transaction {} has duplicate commit timestamps",
1600 record.txn_id
1601 ),
1602 });
1603 }
1604 state.timestamp_seen = true;
1605 }
1606 Op::TxnCommit { epoch, .. } => {
1607 if epoch == 0 {
1608 return Err(MongrelError::CorruptWal {
1609 offset: record.seq.0,
1610 reason: format!("transaction {} commits at epoch 0", record.txn_id),
1611 });
1612 }
1613 if let Some(previous) = commit_epochs.insert(epoch, record.txn_id) {
1614 return Err(MongrelError::CorruptWal {
1615 offset: record.seq.0,
1616 reason: format!(
1617 "transactions {previous} and {} share commit epoch {epoch}",
1618 record.txn_id
1619 ),
1620 });
1621 }
1622 if previous_commit_epoch.is_some_and(|previous| epoch <= previous) {
1623 return Err(MongrelError::CorruptWal {
1624 offset: record.seq.0,
1625 reason: format!(
1626 "commit epoch {epoch} does not advance beyond {}",
1627 previous_commit_epoch.unwrap_or(0)
1628 ),
1629 });
1630 }
1631 previous_commit_epoch = Some(epoch);
1632 state.terminal = true;
1633 }
1634 Op::TxnAbort => state.terminal = true,
1635 Op::Flush { .. } => {
1636 return Err(MongrelError::CorruptWal {
1637 offset: record.seq.0,
1638 reason: format!(
1639 "transaction {} contains a system flush record",
1640 record.txn_id
1641 ),
1642 });
1643 }
1644 _ => {}
1645 }
1646 }
1647 Ok(())
1648}
1649
1650impl SharedWal {
1651 #[cfg(feature = "encryption")]
1653 fn cipher_from_dek(dek: &Zeroizing<[u8; 32]>) -> Result<Box<dyn crate::encryption::Cipher>> {
1654 Ok(Box::new(crate::encryption::AesCipher::new(&dek[..])?))
1655 }
1656
1657 pub fn create(root: &Path, epoch_created: Epoch) -> Result<Self> {
1659 Self::create_with_dek(root, epoch_created, None)
1660 }
1661
1662 pub fn create_with_dek(
1664 root: &Path,
1665 epoch_created: Epoch,
1666 wal_dek: Option<Zeroizing<[u8; 32]>>,
1667 ) -> Result<Self> {
1668 let root = Arc::new(crate::durable_file::DurableRoot::open(root)?);
1669 Self::create_with_durable_root(root, epoch_created, wal_dek)
1670 }
1671
1672 pub(crate) fn create_with_durable_root(
1673 root: Arc<crate::durable_file::DurableRoot>,
1674 epoch_created: Epoch,
1675 wal_dek: Option<Zeroizing<[u8; 32]>>,
1676 ) -> Result<Self> {
1677 let wal_root = Arc::new(root.create_directory_all_pinned("_wal")?);
1678 let wal_dir = wal_root.io_path()?;
1679 if !list_segment_numbers(&wal_root)?.is_empty() {
1680 return Err(MongrelError::CorruptWal {
1681 offset: 0,
1682 reason: "refuses to create a shared WAL over existing segments".into(),
1683 });
1684 }
1685 let cipher = match &wal_dek {
1686 #[cfg(feature = "encryption")]
1687 Some(dk) => Some(Self::cipher_from_dek(dk)?),
1688 #[cfg(not(feature = "encryption"))]
1689 Some(_) => {
1690 return Err(MongrelError::Encryption(
1691 "encryption feature disabled but a WAL DEK was supplied".into(),
1692 ))
1693 }
1694 None => None,
1695 };
1696 let active = Wal::create_chained_in(&wal_root, 0, epoch_created, cipher, [0; 32])?;
1697 if let Err(error) = write_wal_head(&root, &wal_root, 0, 0, wal_dek.as_ref()) {
1698 drop(active);
1699 let _ = wal_root.remove_file(segment_filename(0));
1700 return Err(error);
1701 }
1702 Ok(Self {
1703 root,
1704 wal_root,
1705 wal_dir,
1706 active,
1707 active_segment_no: 0,
1708 durable_seq: epoch_created.0,
1709 open_generation: 0,
1710 wal_dek,
1711 group_sync_count: 0,
1712 })
1713 }
1714
1715 pub fn open(
1720 root: &Path,
1721 epoch_created: Epoch,
1722 wal_dek: Option<Zeroizing<[u8; 32]>>,
1723 ) -> Result<Self> {
1724 let root = Arc::new(crate::durable_file::DurableRoot::open(root)?);
1725 Self::open_durable_root(root, epoch_created, wal_dek)
1726 }
1727
1728 pub(crate) fn open_durable_root(
1729 root: Arc<crate::durable_file::DurableRoot>,
1730 epoch_created: Epoch,
1731 wal_dek: Option<Zeroizing<[u8; 32]>>,
1732 ) -> Result<Self> {
1733 Self::open_durable_root_validated(root, epoch_created, wal_dek, None)
1734 }
1735
1736 pub(crate) fn open_durable_root_validated(
1737 root: Arc<crate::durable_file::DurableRoot>,
1738 epoch_created: Epoch,
1739 wal_dek: Option<Zeroizing<[u8; 32]>>,
1740 expected_records: Option<&[Record]>,
1741 ) -> Result<Self> {
1742 let wal_root =
1743 Arc::new(
1744 root.open_directory("_wal")
1745 .map_err(|error| MongrelError::CorruptWal {
1746 offset: 0,
1747 reason: format!(
1748 "existing database WAL directory cannot be opened: {error}"
1749 ),
1750 })?,
1751 );
1752 let wal_dir = wal_root.io_path()?;
1753 if let Some(expected) = expected_records {
1754 let layout = inspect_wal_layout(&root, &wal_root, wal_dek.as_ref())?;
1755 let actual = replay_wal_layout(&wal_root, &layout, wal_dek.as_ref())?;
1756 if bincode::serialize(&actual)? != bincode::serialize(expected)? {
1757 return Err(MongrelError::CorruptWal {
1758 offset: 0,
1759 reason: "WAL changed after recovery planning".into(),
1760 });
1761 }
1762 }
1763 remove_unpublished_header_only_segment(&root, &wal_root, wal_dek.as_ref())?;
1764 let layout = inspect_wal_layout(&root, &wal_root, wal_dek.as_ref())?;
1765 let final_segment =
1766 layout
1767 .segments
1768 .last()
1769 .copied()
1770 .ok_or_else(|| MongrelError::CorruptWal {
1771 offset: 0,
1772 reason: "existing database has no WAL segments".into(),
1773 })?;
1774 let records = replay_wal_layout(&wal_root, &layout, wal_dek.as_ref())?;
1775 let open_generation = layout
1776 .head
1777 .map(|head| head.open_generation)
1778 .unwrap_or_else(|| {
1779 records
1780 .iter()
1781 .filter(|record| record.txn_id != SYSTEM_TXN_ID)
1782 .map(|record| record.txn_id >> 32)
1783 .max()
1784 .unwrap_or(0)
1785 });
1786
1787 let durable_len = layout
1791 .head
1792 .ok_or_else(|| MongrelError::CorruptWal {
1793 offset: 0,
1794 reason: "validated WAL layout has no durable WAL head".into(),
1795 })?
1796 .durable_len;
1797 let final_name = segment_filename(final_segment);
1798 let final_file = wal_root.open_regular_read_write(&final_name)?;
1799 if final_file.metadata()?.len() != durable_len {
1800 final_file.set_len(durable_len)?;
1801 final_file.sync_all()?;
1802 }
1803 let previous_segment_hash = hash_segment(&wal_root, final_segment)?;
1804 let next_segment_no = final_segment
1805 .checked_add(1)
1806 .ok_or_else(|| MongrelError::Full("WAL segment namespace exhausted".into()))?;
1807 let durable_seq = records
1810 .last()
1811 .map(|record| record.seq.0)
1812 .unwrap_or(epoch_created.0);
1813 let cipher = match &wal_dek {
1814 #[cfg(feature = "encryption")]
1815 Some(dk) => Some(Self::cipher_from_dek(dk)?),
1816 #[cfg(not(feature = "encryption"))]
1817 Some(_) => {
1818 return Err(MongrelError::Encryption(
1819 "encryption feature disabled but a WAL DEK was supplied".into(),
1820 ))
1821 }
1822 None => None,
1823 };
1824 let mut active = Wal::create_chained_in(
1825 &wal_root,
1826 next_segment_no,
1827 epoch_created,
1828 cipher,
1829 previous_segment_hash,
1830 )?;
1831 active.next_seq = durable_seq
1832 .checked_add(1)
1833 .ok_or_else(|| MongrelError::Full("WAL sequence namespace exhausted".into()))?;
1834 if let Err(error) = write_wal_head(
1835 &root,
1836 &wal_root,
1837 next_segment_no,
1838 open_generation,
1839 wal_dek.as_ref(),
1840 ) {
1841 drop(active);
1842 let _ = wal_root.remove_file(segment_filename(next_segment_no));
1843 return Err(error);
1844 }
1845 Ok(Self {
1846 root,
1847 wal_root,
1848 wal_dir,
1849 active,
1850 active_segment_no: next_segment_no,
1851 durable_seq,
1852 open_generation,
1853 wal_dek,
1854 group_sync_count: 0,
1855 })
1856 }
1857
1858 #[allow(dead_code)]
1860 pub fn wal_dir(&self) -> &Path {
1861 &self.wal_dir
1862 }
1863
1864 pub fn append(&mut self, txn_id: u64, _table_id: u64, op: Op) -> Result<u64> {
1868 mongreldb_fault::inject("wal.append.before").map_err(crate::commit_log::fault_as_io)?;
1869 let seq = self.active.append_txn(txn_id, op)?.0;
1870 mongreldb_fault::inject("wal.append.after").map_err(crate::commit_log::fault_as_io)?;
1871 Ok(seq)
1872 }
1873
1874 pub fn append_commit(&mut self, txn_id: u64, epoch: Epoch, added: &[AddedRun]) -> Result<u64> {
1876 self.append_commit_at(txn_id, epoch, added, unix_nanos_now())
1877 }
1878
1879 pub fn append_commit_at(
1880 &mut self,
1881 txn_id: u64,
1882 epoch: Epoch,
1883 added: &[AddedRun],
1884 unix_nanos: u64,
1885 ) -> Result<u64> {
1886 self.active
1887 .append_txn(txn_id, Op::CommitTimestamp { unix_nanos })?;
1888 Ok(self
1889 .active
1890 .append_txn(
1891 txn_id,
1892 Op::TxnCommit {
1893 epoch: epoch.0,
1894 added_runs: added.to_vec(),
1895 },
1896 )?
1897 .0)
1898 }
1899
1900 pub fn append_abort(&mut self, txn_id: u64) -> Result<()> {
1902 self.active.append_txn(txn_id, Op::TxnAbort)?;
1903 Ok(())
1904 }
1905
1906 pub fn append_system(&mut self, op: Op) -> Result<u64> {
1908 Ok(self.active.append_system(op)?.0)
1909 }
1910
1911 pub fn group_sync(&mut self) -> Result<u64> {
1916 mongreldb_fault::inject("wal.fsync.before").map_err(crate::commit_log::fault_as_io)?;
1917 self.active.sync()?;
1918 mongreldb_fault::inject("wal.fsync.after").map_err(crate::commit_log::fault_as_io)?;
1919 write_wal_head(
1920 &self.root,
1921 &self.wal_root,
1922 self.active_segment_no,
1923 self.open_generation,
1924 self.wal_dek.as_ref(),
1925 )?;
1926 self.group_sync_count += 1;
1927 let highest = self.active.next_seq_val().saturating_sub(1);
1928 if highest > self.durable_seq {
1929 self.durable_seq = highest;
1930 }
1931 Ok(self.durable_seq)
1932 }
1933
1934 pub fn group_sync_count(&self) -> u64 {
1936 self.group_sync_count
1937 }
1938
1939 pub fn durable_seq(&self) -> u64 {
1941 self.durable_seq
1942 }
1943
1944 pub(crate) fn seal_open_generation(&mut self, generation: u64) -> Result<()> {
1945 if generation < self.open_generation {
1946 return Err(MongrelError::CorruptWal {
1947 offset: generation,
1948 reason: format!(
1949 "open generation {generation} precedes WAL head generation {}",
1950 self.open_generation
1951 ),
1952 });
1953 }
1954 self.active.sync()?;
1955 let previous = self.open_generation;
1956 self.open_generation = generation;
1957 if let Err(error) = write_wal_head(
1958 &self.root,
1959 &self.wal_root,
1960 self.active_segment_no,
1961 self.open_generation,
1962 self.wal_dek.as_ref(),
1963 ) {
1964 self.open_generation = previous;
1965 return Err(error);
1966 }
1967 Ok(())
1968 }
1969
1970 pub fn rotate(&mut self, segment_no: u64) -> Result<()> {
1973 let expected = self
1974 .active_segment_no
1975 .checked_add(1)
1976 .ok_or_else(|| MongrelError::Full("WAL segment namespace exhausted".into()))?;
1977 if segment_no != expected {
1978 return Err(MongrelError::InvalidArgument(format!(
1979 "WAL rotation segment {segment_no} does not immediately follow {}",
1980 self.active_segment_no
1981 )));
1982 }
1983 self.active.sync()?;
1984 write_wal_head(
1985 &self.root,
1986 &self.wal_root,
1987 self.active_segment_no,
1988 self.open_generation,
1989 self.wal_dek.as_ref(),
1990 )?;
1991 let highest = self.active.next_seq_val().saturating_sub(1);
1992 self.durable_seq = self.durable_seq.max(highest);
1993 let previous_segment_hash = hash_segment(&self.wal_root, self.active_segment_no)?;
1994 let cipher = match &self.wal_dek {
1995 #[cfg(feature = "encryption")]
1996 Some(dk) => Some(Self::cipher_from_dek(dk)?),
1997 _ => None,
1998 };
1999 let epoch = Epoch(self.durable_seq);
2000 let wal = Wal::create_chained_in(
2001 &self.wal_root,
2002 segment_no,
2003 epoch,
2004 cipher,
2005 previous_segment_hash,
2006 )?;
2007 if let Err(error) = write_wal_head(
2008 &self.root,
2009 &self.wal_root,
2010 segment_no,
2011 self.open_generation,
2012 self.wal_dek.as_ref(),
2013 ) {
2014 drop(wal);
2015 let _ = self.wal_root.remove_file(segment_filename(segment_no));
2016 return Err(error);
2017 }
2018 self.active = wal;
2019 self.active_segment_no = segment_no;
2020 Ok(())
2021 }
2022
2023 pub fn active_segment_no(&self) -> u64 {
2025 self.active_segment_no
2026 }
2027
2028 pub(crate) fn reset_after_checkpoint(&mut self) -> Result<usize> {
2032 self.group_sync()?;
2033 let next_segment_no = list_segment_numbers(&self.wal_root)?
2034 .into_iter()
2035 .max()
2036 .ok_or_else(|| MongrelError::CorruptWal {
2037 offset: 0,
2038 reason: "active WAL segment disappeared before checkpoint reset".into(),
2039 })?
2040 .checked_add(1)
2041 .ok_or_else(|| MongrelError::Full("WAL segment namespace exhausted".into()))?;
2042 self.rotate(next_segment_no)?;
2043 self.group_sync()?;
2046 self.gc_segments_retain_recent(u64::MAX, 0)
2047 }
2048
2049 pub fn gc_segments(&mut self, min_retained_seq: u64) -> Result<usize> {
2059 self.gc_segments_retain_recent(min_retained_seq, 0)
2060 }
2061
2062 pub(crate) fn has_gc_segments_retain_recent(&self, retain_recent: usize) -> Result<bool> {
2063 let rotated = list_segment_numbers(&self.wal_root)?
2064 .into_iter()
2065 .filter(|segment| *segment != self.active_segment_no)
2066 .count();
2067 Ok(rotated > retain_recent)
2068 }
2069
2070 pub fn gc_segments_retain_recent(
2074 &mut self,
2075 min_retained_seq: u64,
2076 retain_recent: usize,
2077 ) -> Result<usize> {
2078 self.group_sync()?;
2079 let layout = inspect_wal_layout(&self.root, &self.wal_root, self.wal_dek.as_ref())?;
2080 let readable = replay_wal_layout(&self.wal_root, &layout, self.wal_dek.as_ref())?;
2081 let segments = &layout.segments;
2082 let retained: Vec<u64> = segments
2083 .iter()
2084 .rev()
2085 .filter(|segment| **segment != self.active_segment_no)
2086 .take(retain_recent)
2087 .copied()
2088 .collect();
2089 let mut candidates = Vec::new();
2090 let mut prefix_open = true;
2091 for n in segments.iter().copied() {
2092 let mut reader = reader_for_segment(&self.wal_root, n, self.wal_dek.as_ref())?;
2093 if layout.head.is_some_and(|head| head.segment_no == n) {
2094 reader.constrain_to_durable_len(layout.head.unwrap().durable_len)?;
2095 }
2096 let records = reader.replay_strict()?;
2097 let below_floor = min_retained_seq == u64::MAX
2098 || records.iter().map(|record| record.seq.0).max().unwrap_or(0) < min_retained_seq;
2099 let reapable =
2100 prefix_open && n != self.active_segment_no && !retained.contains(&n) && below_floor;
2101 if reapable {
2102 candidates.push((n, records));
2103 } else {
2104 prefix_open = false;
2105 }
2106 }
2107
2108 let commits = readable
2109 .iter()
2110 .filter_map(|record| match record.op {
2111 Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
2112 _ => None,
2113 })
2114 .collect::<std::collections::HashMap<_, _>>();
2115 let removed_floor = candidates
2116 .iter()
2117 .flat_map(|(_, records)| records)
2118 .filter_map(|record| commits.get(&record.txn_id).copied())
2119 .max();
2120 if let Some(epoch) = removed_floor {
2121 crate::replication::advance_replication_wal_floor_durable(&self.root, epoch)?;
2122 }
2123 for (segment_no, _) in &candidates {
2124 self.wal_root.remove_file(segment_filename(*segment_no))?;
2125 }
2126 let reaped = candidates.len();
2127 Ok(reaped)
2128 }
2129
2130 pub fn verify_segments(&self) -> Vec<(u64, String)> {
2138 let result = inspect_wal_layout(&self.root, &self.wal_root, self.wal_dek.as_ref())
2139 .and_then(|layout| replay_wal_layout(&self.wal_root, &layout, self.wal_dek.as_ref()));
2140 match result {
2141 Ok(_) => Vec::new(),
2142 Err(error) => vec![(u64::MAX, error.to_string())],
2143 }
2144 }
2145
2146 pub fn replay(root: &Path) -> Result<Vec<Record>> {
2150 Self::replay_with_dek(root, None)
2151 }
2152
2153 pub fn replay_with_dek(
2155 root: &Path,
2156 wal_dek: Option<&Zeroizing<[u8; 32]>>,
2157 ) -> Result<Vec<Record>> {
2158 let root = crate::durable_file::DurableRoot::open(root)?;
2159 Self::replay_durable_with_dek(&root, wal_dek)
2160 }
2161
2162 pub(crate) fn replay_durable_with_dek(
2163 root: &crate::durable_file::DurableRoot,
2164 wal_dek: Option<&Zeroizing<[u8; 32]>>,
2165 ) -> Result<Vec<Record>> {
2166 let wal_root = root.open_directory("_wal")?;
2167 let layout = inspect_wal_layout(root, &wal_root, wal_dek)?;
2168 replay_wal_layout(&wal_root, &layout, wal_dek)
2169 }
2170
2171 pub(crate) fn durable_open_generation(
2172 root: &crate::durable_file::DurableRoot,
2173 wal_dek: Option<&Zeroizing<[u8; 32]>>,
2174 ) -> Result<Option<u64>> {
2175 let wal_root = root.open_directory("_wal")?;
2176 let layout = inspect_wal_layout(root, &wal_root, wal_dek)?;
2177 Ok(layout.head.map(|head| head.open_generation))
2178 }
2179
2180 pub fn replay_with_dek_controlled(
2183 root: &Path,
2184 wal_dek: Option<&Zeroizing<[u8; 32]>>,
2185 control: &crate::ExecutionControl,
2186 max_records: usize,
2187 max_bytes: usize,
2188 ) -> Result<Vec<Record>> {
2189 let root = crate::durable_file::DurableRoot::open(root)?;
2190 let wal_root = root.open_directory("_wal")?;
2191 let layout = inspect_wal_layout(&root, &wal_root, wal_dek)?;
2192 let total_bytes = layout.segments.iter().try_fold(0_usize, |total, segment| {
2193 let bytes = if layout.head.is_some_and(|head| head.segment_no == *segment) {
2194 usize::try_from(layout.head.unwrap().durable_len).unwrap_or(usize::MAX)
2195 } else {
2196 usize::try_from(
2197 wal_root
2198 .open_regular(segment_filename(*segment))?
2199 .metadata()?
2200 .len(),
2201 )
2202 .unwrap_or(usize::MAX)
2203 };
2204 Ok::<_, MongrelError>(total.saturating_add(bytes))
2205 })?;
2206 if total_bytes > max_bytes {
2207 return Err(MongrelError::ResourceLimitExceeded {
2208 resource: "controlled WAL replay bytes",
2209 requested: total_bytes,
2210 limit: max_bytes,
2211 });
2212 }
2213 let mut segments = Vec::with_capacity(layout.segments.len());
2214 let mut total_records = 0_usize;
2215 for n in layout.segments.iter().copied() {
2216 control.checkpoint()?;
2217 let remaining = max_records.saturating_sub(total_records);
2218 let mut reader = reader_for_segment(&wal_root, n, wal_dek)?;
2219 if layout.head.is_some_and(|head| head.segment_no == n) {
2220 reader.constrain_to_durable_len(layout.head.unwrap().durable_len)?;
2221 }
2222 let records = reader.replay_controlled_strict(control, remaining)?;
2226 total_records += records.len();
2227 segments.push(ReplayedSegment {
2228 segment_no: n,
2229 records,
2230 });
2231 }
2232 validate_v4_sequence_continuity(&segments)?;
2233 let out: Vec<Record> = segments
2234 .into_iter()
2235 .flat_map(|segment| segment.records)
2236 .collect();
2237 validate_shared_transaction_framing(&out)?;
2238 Ok(out)
2239 }
2240}
2241
2242fn segment_filename(segment_no: u64) -> String {
2243 format!("seg-{segment_no:06}.wal")
2244}
2245
2246fn segment_number_from_path(path: &Path) -> Option<u64> {
2247 path.file_name()
2248 .and_then(|name| name.to_str())
2249 .and_then(|name| name.strip_prefix("seg-"))
2250 .and_then(|name| name.strip_suffix(".wal"))
2251 .and_then(|number| number.parse().ok())
2252}
2253
2254fn list_segment_numbers(wal_root: &crate::durable_file::DurableRoot) -> Result<Vec<u64>> {
2259 let mut segments = Vec::new();
2260 for fname in wal_root.list_regular_files(".")? {
2261 let s = fname.to_str().ok_or_else(|| MongrelError::CorruptWal {
2262 offset: 0,
2263 reason: "WAL directory contains a non-UTF-8 entry".into(),
2264 })?;
2265 if !s.starts_with("seg-") {
2266 continue;
2267 }
2268 let number = s
2269 .strip_prefix("seg-")
2270 .and_then(|value| value.strip_suffix(".wal"))
2271 .and_then(|value| value.parse::<u64>().ok())
2272 .ok_or_else(|| MongrelError::CorruptWal {
2273 offset: 0,
2274 reason: format!("malformed WAL segment filename {s:?}"),
2275 })?;
2276 if s != segment_filename(number) {
2277 return Err(MongrelError::CorruptWal {
2278 offset: 0,
2279 reason: format!("non-canonical WAL segment filename {s:?}"),
2280 });
2281 }
2282 segments.push(number);
2283 }
2284 segments.sort_unstable();
2285 for pair in segments.windows(2) {
2286 let expected = pair[0]
2287 .checked_add(1)
2288 .ok_or_else(|| MongrelError::CorruptWal {
2289 offset: pair[0],
2290 reason: "WAL segment namespace overflows after u64::MAX".into(),
2291 })?;
2292 if pair[1] != expected {
2293 return Err(MongrelError::CorruptWal {
2294 offset: pair[1],
2295 reason: format!(
2296 "WAL segment {} does not immediately follow {}",
2297 pair[1], pair[0]
2298 ),
2299 });
2300 }
2301 }
2302 Ok(segments)
2303}
2304
2305#[cfg(test)]
2306mod shared_wal_tests {
2307 use super::*;
2308 use tempfile::tempdir;
2309
2310 fn write_v3_segment(path: &Path) {
2313 let mut bytes = Vec::new();
2314 bytes.extend_from_slice(&WAL_MAGIC);
2315 bytes.extend_from_slice(&3_u16.to_le_bytes());
2316 bytes.extend_from_slice(&[ENC_PLAINTEXT, 0, 0, 0]);
2317 bytes.extend_from_slice(&0_u64.to_le_bytes());
2318 std::fs::write(path, bytes).unwrap();
2319 }
2320
2321 #[test]
2322 fn v3_segments_are_rejected_as_unsupported_not_corrupt() {
2323 let dir = tempdir().unwrap();
2324 std::fs::create_dir(dir.path().join("_wal")).unwrap();
2325 write_v3_segment(&dir.path().join("_wal/seg-000000.wal"));
2326
2327 let error = SharedWal::replay(dir.path()).unwrap_err();
2328 assert!(
2329 matches!(
2330 error,
2331 MongrelError::UnsupportedStorageVersion {
2332 component: "wal",
2333 found: 3,
2334 supported: 4,
2335 }
2336 ),
2337 "expected UnsupportedStorageVersion, got {error:?}"
2338 );
2339
2340 let dir = tempdir().unwrap();
2341 std::fs::create_dir(dir.path().join("_wal")).unwrap();
2342 write_v3_segment(&dir.path().join("_wal/seg-000000.wal"));
2343 let error = SharedWal::open(dir.path(), Epoch(1), None)
2344 .err()
2345 .expect("v3 WAL must be rejected");
2346 assert!(matches!(
2347 error,
2348 MongrelError::UnsupportedStorageVersion {
2349 component: "wal",
2350 found: 3,
2351 supported: 4,
2352 }
2353 ));
2354 }
2355
2356 #[test]
2357 fn shared_wal_interleaves_two_tables_one_fd() {
2358 let dir = tempdir().unwrap();
2359 let mut w = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2360 w.append(
2361 1,
2362 10,
2363 Op::Put {
2364 table_id: 10,
2365 rows: vec![1],
2366 },
2367 )
2368 .unwrap();
2369 w.append(
2370 2,
2371 20,
2372 Op::Put {
2373 table_id: 20,
2374 rows: vec![2],
2375 },
2376 )
2377 .unwrap();
2378 w.append_commit(1, Epoch(1), &[]).unwrap();
2379 w.append_commit(2, Epoch(2), &[]).unwrap();
2380 let d = w.group_sync().unwrap();
2381 assert!(d >= 4);
2382 let recs = SharedWal::replay(dir.path()).unwrap();
2383 assert_eq!(
2384 recs.iter()
2385 .filter(|r| matches!(r.op, Op::Put { .. }))
2386 .count(),
2387 2
2388 );
2389 assert_eq!(
2390 recs.iter()
2391 .filter(|r| matches!(r.op, Op::TxnCommit { .. }))
2392 .count(),
2393 2
2394 );
2395 }
2396
2397 #[test]
2398 fn controlled_shared_replay_rejects_aggregate_wal_bytes() {
2399 let dir = tempdir().unwrap();
2400 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2401 wal.append_commit(1, Epoch(1), &[]).unwrap();
2402 wal.group_sync().unwrap();
2403 let control = crate::ExecutionControl::new(None);
2404
2405 let error =
2406 SharedWal::replay_with_dek_controlled(dir.path(), None, &control, usize::MAX, 1)
2407 .unwrap_err();
2408 assert!(matches!(
2409 error,
2410 MongrelError::ResourceLimitExceeded {
2411 resource: "controlled WAL replay bytes",
2412 ..
2413 }
2414 ));
2415 }
2416
2417 #[test]
2418 fn shared_wal_gc_retains_recent_rotated_segments() {
2419 let dir = tempdir().unwrap();
2420 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2421 for segment in 0..4u64 {
2422 wal.append_commit(segment + 1, Epoch(segment + 1), &[])
2423 .unwrap();
2424 wal.group_sync().unwrap();
2425 if segment < 3 {
2426 wal.rotate(segment + 1).unwrap();
2427 }
2428 }
2429 assert_eq!(wal.gc_segments_retain_recent(u64::MAX, 2).unwrap(), 1);
2430 let count = std::fs::read_dir(dir.path().join("_wal"))
2431 .unwrap()
2432 .flatten()
2433 .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "wal"))
2434 .count();
2435 assert_eq!(count, 3, "active plus two retained segments");
2436 }
2437
2438 #[test]
2439 fn shared_replay_rejects_torn_tail_in_rotated_segment() {
2440 let dir = tempdir().unwrap();
2441 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2442 wal.append_commit(1, Epoch(1), &[]).unwrap();
2443 wal.group_sync().unwrap();
2444 wal.rotate(1).unwrap();
2445 wal.append_commit(2, Epoch(2), &[]).unwrap();
2446 wal.group_sync().unwrap();
2447 drop(wal);
2448
2449 let old = dir.path().join("_wal/seg-000000.wal");
2450 let mut file = OpenOptions::new().append(true).open(old).unwrap();
2451 file.write_all(&[1, 2, 3]).unwrap();
2452 file.sync_all().unwrap();
2453
2454 assert!(matches!(
2455 SharedWal::replay(dir.path()),
2456 Err(MongrelError::CorruptWal { .. })
2457 ));
2458 }
2459
2460 #[test]
2461 fn shared_replay_rejects_records_after_commit() {
2462 let dir = tempdir().unwrap();
2463 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2464 wal.append_commit(7, Epoch(1), &[]).unwrap();
2465 wal.append(
2466 7,
2467 1,
2468 Op::Delete {
2469 table_id: 1,
2470 row_ids: vec![RowId(9)],
2471 },
2472 )
2473 .unwrap();
2474 wal.group_sync().unwrap();
2475 drop(wal);
2476
2477 assert!(matches!(
2478 SharedWal::replay(dir.path()),
2479 Err(MongrelError::CorruptWal { .. })
2480 ));
2481 }
2482
2483 #[test]
2484 fn rotate_without_explicit_group_sync_keeps_sequence_adjacent() {
2485 let dir = tempdir().unwrap();
2486 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2487 assert_eq!(
2488 wal.append_system(Op::Flush {
2489 table_id: 1,
2490 flushed_epoch: 1,
2491 })
2492 .unwrap(),
2493 1
2494 );
2495 wal.rotate(1).unwrap();
2496 assert_eq!(
2497 wal.append_system(Op::Flush {
2498 table_id: 1,
2499 flushed_epoch: 2,
2500 })
2501 .unwrap(),
2502 2
2503 );
2504 wal.group_sync().unwrap();
2505 let records = SharedWal::replay(dir.path()).unwrap();
2506 assert_eq!(
2507 records
2508 .iter()
2509 .map(|record| record.seq.0)
2510 .collect::<Vec<_>>(),
2511 vec![1, 2]
2512 );
2513 }
2514
2515 #[test]
2516 fn open_recovers_exact_header_only_segment_crash_window() {
2517 let dir = tempdir().unwrap();
2518 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2519 wal.append_commit(1, Epoch(1), &[]).unwrap();
2520 wal.group_sync().unwrap();
2521 drop(wal);
2522
2523 let root = crate::durable_file::DurableRoot::open(dir.path()).unwrap();
2524 let wal_root = root.open_directory("_wal").unwrap();
2525 let previous_hash = hash_segment(&wal_root, 0).unwrap();
2526 drop(Wal::create_chained_in(&wal_root, 1, Epoch(1), None, previous_hash).unwrap());
2527 assert_eq!(read_wal_head(&root, None).unwrap().unwrap().segment_no, 0);
2528
2529 let reopened = SharedWal::open(dir.path(), Epoch(1), None).unwrap();
2530 assert_eq!(reopened.active_segment_no(), 1);
2531 assert_eq!(list_segment_numbers(&wal_root).unwrap(), vec![0, 1]);
2532 assert_eq!(read_wal_head(&root, None).unwrap().unwrap().segment_no, 1);
2533 }
2534
2535 #[test]
2536 fn replay_rejects_sequence_reset_across_segments() {
2537 let dir = tempdir().unwrap();
2538 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2539 wal.append_commit(1, Epoch(1), &[]).unwrap();
2540 wal.group_sync().unwrap();
2541 wal.rotate(1).unwrap();
2542 wal.active.next_seq = 1;
2545 wal.append_commit(2, Epoch(2), &[]).unwrap();
2546 wal.group_sync().unwrap();
2547 drop(wal);
2548
2549 let error = SharedWal::replay(dir.path()).unwrap_err();
2550 assert!(
2551 matches!(error, MongrelError::CorruptWal { .. }),
2552 "expected CorruptWal, got {error:?}"
2553 );
2554 }
2555
2556 #[test]
2557 fn replay_rejects_backward_commit_epoch() {
2558 let dir = tempdir().unwrap();
2559 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2560 wal.append_commit(1, Epoch(2), &[]).unwrap();
2561 wal.append_commit(2, Epoch(1), &[]).unwrap();
2562 wal.group_sync().unwrap();
2563 drop(wal);
2564
2565 assert!(matches!(
2566 SharedWal::replay(dir.path()),
2567 Err(MongrelError::CorruptWal { .. })
2568 ));
2569 }
2570
2571 #[test]
2572 fn multiple_sessions_continue_one_global_sequence() {
2573 let dir = tempdir().unwrap();
2574 for session in 0..3_u64 {
2575 let mut wal = SharedWal::open(dir.path(), Epoch(session * 2), None)
2576 .unwrap_or_else(|_| SharedWal::create(dir.path(), Epoch(session * 2)).unwrap());
2577 assert_eq!(wal.active_segment_no(), session);
2578 for i in 0..2_u64 {
2579 let txn = session * 2 + i + 1;
2580 wal.append_commit(txn, Epoch(txn), &[]).unwrap();
2581 }
2582 wal.group_sync().unwrap();
2583 drop(wal);
2584 }
2585
2586 let records = SharedWal::replay(dir.path()).unwrap();
2587 let sequences: Vec<u64> = records.iter().map(|record| record.seq.0).collect();
2588 assert_eq!(sequences, (1..=12).collect::<Vec<u64>>());
2591
2592 let mut wal = SharedWal::open(dir.path(), Epoch(6), None).unwrap();
2595 assert_eq!(wal.active_segment_no(), 3);
2596 wal.append_commit(7, Epoch(7), &[]).unwrap();
2597 wal.group_sync().unwrap();
2598 drop(wal);
2599
2600 let records = SharedWal::replay(dir.path()).unwrap();
2601 let sequences: Vec<u64> = records.iter().map(|record| record.seq.0).collect();
2602 assert_eq!(
2603 sequences,
2604 (1..=sequences.len() as u64).collect::<Vec<u64>>(),
2605 "records stay globally contiguous across sessions"
2606 );
2607 }
2608
2609 #[test]
2610 fn head_and_strict_segment_names_detect_deletion_gaps_and_aliases() {
2611 let deleted = tempdir().unwrap();
2612 let wal = SharedWal::create(deleted.path(), Epoch(0)).unwrap();
2613 drop(wal);
2614 std::fs::remove_file(deleted.path().join("_wal/seg-000000.wal")).unwrap();
2615 assert!(SharedWal::replay(deleted.path()).is_err());
2616
2617 let alias = tempdir().unwrap();
2618 let wal = SharedWal::create(alias.path(), Epoch(0)).unwrap();
2619 drop(wal);
2620 std::fs::rename(
2621 alias.path().join("_wal/seg-000000.wal"),
2622 alias.path().join("_wal/seg-0.wal"),
2623 )
2624 .unwrap();
2625 assert!(SharedWal::replay(alias.path()).is_err());
2626
2627 let gap = tempdir().unwrap();
2628 let mut wal = SharedWal::create(gap.path(), Epoch(0)).unwrap();
2629 wal.rotate(1).unwrap();
2630 drop(wal);
2631 std::fs::rename(
2632 gap.path().join("_wal/seg-000001.wal"),
2633 gap.path().join("_wal/seg-000002.wal"),
2634 )
2635 .unwrap();
2636 assert!(SharedWal::replay(gap.path()).is_err());
2637 }
2638
2639 #[test]
2640 fn verify_and_gc_fail_closed_on_segment_corruption() {
2641 let dir = tempdir().unwrap();
2642 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2643 wal.append_commit(1, Epoch(1), &[]).unwrap();
2644 wal.group_sync().unwrap();
2645 wal.rotate(1).unwrap();
2646 wal.append_commit(2, Epoch(2), &[]).unwrap();
2647 wal.group_sync().unwrap();
2648
2649 let old = dir.path().join("_wal/seg-000000.wal");
2650 let mut bytes = std::fs::read(&old).unwrap();
2651 let last = bytes.len() - 1;
2652 bytes[last] ^= 0x40;
2653 std::fs::write(&old, bytes).unwrap();
2654 assert!(!wal.verify_segments().is_empty());
2655 assert!(wal.gc_segments(u64::MAX).is_err());
2656 assert!(old.exists());
2657 }
2658
2659 #[test]
2660 fn rotation_within_session_keeps_sequence_contiguous() {
2661 let dir = tempdir().unwrap();
2662 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2663 wal.append_commit(1, Epoch(1), &[]).unwrap();
2664 wal.rotate(1).unwrap();
2665 wal.append_commit(2, Epoch(2), &[]).unwrap();
2666 wal.rotate(2).unwrap();
2667 wal.append_commit(3, Epoch(3), &[]).unwrap();
2668 wal.group_sync().unwrap();
2669 drop(wal);
2670
2671 let records = SharedWal::replay(dir.path()).unwrap();
2672 let sequences: Vec<u64> = records.iter().map(|record| record.seq.0).collect();
2673 assert_eq!(
2674 sequences,
2675 (1..=sequences.len() as u64).collect::<Vec<u64>>()
2676 );
2677 assert_eq!(sequences.len(), 6, "two records per commit");
2678 }
2679
2680 #[test]
2681 fn open_truncates_garbage_beyond_the_authenticated_durable_prefix() {
2682 let dir = tempdir().unwrap();
2683 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2684 wal.append_commit(1, Epoch(1), &[]).unwrap();
2685 wal.group_sync().unwrap();
2686 drop(wal);
2687
2688 let seg = dir.path().join("_wal/seg-000000.wal");
2689 let published_len = std::fs::metadata(&seg).unwrap().len();
2690 let mut file = OpenOptions::new().append(true).open(&seg).unwrap();
2691 file.write_all(&[0xde, 0xad, 0xbe, 0xef, 1, 2, 3, 4, 5])
2692 .unwrap();
2693 file.sync_all().unwrap();
2694 drop(file);
2695
2696 let wal = SharedWal::open(dir.path(), Epoch(1), None).unwrap();
2699 drop(wal);
2700 assert_eq!(std::fs::metadata(&seg).unwrap().len(), published_len);
2701 let records = SharedWal::replay(dir.path()).unwrap();
2702 assert_eq!(records.len(), 2);
2703 assert!(records.iter().all(|record| record.seq.0 <= 2));
2704 }
2705
2706 #[test]
2707 fn open_rejects_damage_inside_the_authenticated_prefix() {
2708 let dir = tempdir().unwrap();
2709 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2710 wal.append_commit(1, Epoch(1), &[]).unwrap();
2711 wal.group_sync().unwrap();
2712 wal.rotate(1).unwrap();
2713 wal.append_commit(2, Epoch(2), &[]).unwrap();
2714 wal.group_sync().unwrap();
2715 drop(wal);
2716
2717 let seg = dir.path().join("_wal/seg-000000.wal");
2720 let mut bytes = std::fs::read(&seg).unwrap();
2721 bytes[(HEADER_LEN + 12) as usize] ^= 0x01;
2722 std::fs::write(&seg, bytes).unwrap();
2723
2724 assert!(matches!(
2725 SharedWal::replay(dir.path()),
2726 Err(MongrelError::CorruptWal { .. })
2727 ));
2728 assert!(SharedWal::open(dir.path(), Epoch(2), None).is_err());
2729 }
2730
2731 #[cfg(feature = "encryption")]
2732 #[test]
2733 fn encrypted_sessions_continue_one_global_sequence() {
2734 let dek = || Zeroizing::new([7_u8; 32]);
2735 let dir = tempdir().unwrap();
2736 for session in 0..3_u64 {
2737 let mut wal = SharedWal::open(dir.path(), Epoch(session * 2), Some(dek()))
2738 .unwrap_or_else(|_| {
2739 SharedWal::create_with_dek(dir.path(), Epoch(session * 2), Some(dek())).unwrap()
2740 });
2741 for i in 0..2_u64 {
2742 let txn = session * 2 + i + 1;
2743 wal.append_commit(txn, Epoch(txn), &[]).unwrap();
2744 }
2745 wal.group_sync().unwrap();
2746 drop(wal);
2747 }
2748
2749 let records = SharedWal::replay_with_dek(dir.path(), Some(&dek())).unwrap();
2750 let sequences: Vec<u64> = records.iter().map(|record| record.seq.0).collect();
2751 assert_eq!(
2752 sequences,
2753 (1..=sequences.len() as u64).collect::<Vec<u64>>()
2754 );
2755
2756 let seg = dir.path().join("_wal/seg-000002.wal");
2758 let published_len = std::fs::metadata(&seg).unwrap().len();
2759 let mut file = OpenOptions::new().append(true).open(&seg).unwrap();
2760 file.write_all(&[9, 9, 9, 9, 9, 9, 9]).unwrap();
2761 file.sync_all().unwrap();
2762 drop(file);
2763 let wal = SharedWal::open(dir.path(), Epoch(6), Some(dek())).unwrap();
2764 drop(wal);
2765 assert_eq!(std::fs::metadata(&seg).unwrap().len(), published_len);
2766 }
2767}
2768
2769#[cfg(test)]
2770mod tests {
2771 use super::*;
2772 use tempfile::tempdir;
2773
2774 #[cfg(feature = "encryption")]
2775 fn frame_ranges(bytes: &[u8]) -> Vec<std::ops::Range<usize>> {
2776 let mut ranges = Vec::new();
2777 let mut offset = HEADER_LEN as usize;
2778 while offset < bytes.len() {
2779 let mut length = [0_u8; 4];
2780 length.copy_from_slice(&bytes[offset..offset + 4]);
2781 let end = offset + 24 + u32::from_le_bytes(length) as usize;
2782 ranges.push(offset..end);
2783 offset = end;
2784 }
2785 ranges
2786 }
2787
2788 fn recompute_frame_crc(bytes: &mut [u8], start: usize) {
2789 let mut length = [0_u8; 4];
2790 length.copy_from_slice(&bytes[start..start + 4]);
2791 let length = u32::from_le_bytes(length) as usize;
2792 let seq = &bytes[start + 8..start + 16];
2793 let txn_id = &bytes[start + 16..start + 24];
2794 let payload = &bytes[start + 24..start + 24 + length];
2795 let mut digest = CRC32C.digest();
2796 digest.update(seq);
2797 digest.update(txn_id);
2798 digest.update(payload);
2799 bytes[start + 4..start + 8].copy_from_slice(&digest.finalize().to_le_bytes());
2800 }
2801
2802 #[test]
2803 fn append_then_replay_roundtrips() {
2804 let dir = tempdir().unwrap();
2805 let path = dir.path().join("seg-000000.wal");
2806 let mut wal = Wal::create(&path, Epoch(100)).unwrap();
2807 let s1 = wal
2808 .append_txn(
2809 7,
2810 Op::Put {
2811 table_id: 1,
2812 rows: vec![1, 2, 3],
2813 },
2814 )
2815 .unwrap();
2816 let s2 = wal
2817 .append_txn(
2818 7,
2819 Op::Delete {
2820 table_id: 1,
2821 row_ids: vec![RowId(7)],
2822 },
2823 )
2824 .unwrap();
2825 assert_eq!(s1, Epoch(101));
2826 assert_eq!(s2, Epoch(102));
2827 wal.sync().unwrap();
2828
2829 let records = replay(&path).unwrap();
2830 assert_eq!(records.len(), 2);
2831 assert_eq!(records[0].seq, Epoch(101));
2832 assert_eq!(records[0].txn_id, 7);
2833 match &records[0].op {
2834 Op::Put { table_id, rows } => {
2835 assert_eq!(*table_id, 1);
2836 assert_eq!(rows, &vec![1, 2, 3]);
2837 }
2838 other => panic!("unexpected op {other:?}"),
2839 }
2840 match &records[1].op {
2841 Op::Delete { row_ids, .. } => {
2842 assert_eq!(*row_ids, vec![RowId(7)]);
2843 }
2844 other => panic!("unexpected op {other:?}"),
2845 }
2846 }
2847
2848 #[test]
2849 fn record_roundtrips_with_txn_id_and_commit_marker() {
2850 let dir = tempdir().unwrap();
2851 let path = dir.path().join("seg-000000.wal");
2852 let mut w = Wal::create(&path, Epoch(0)).unwrap();
2853 w.append_txn(
2854 7,
2855 Op::Put {
2856 table_id: 3,
2857 rows: vec![1, 2, 3],
2858 },
2859 )
2860 .unwrap();
2861 w.append_txn(
2862 7,
2863 Op::TxnCommit {
2864 epoch: 11,
2865 added_runs: vec![],
2866 },
2867 )
2868 .unwrap();
2869 w.sync().unwrap();
2870 let recs = replay(&path).unwrap();
2871 assert_eq!(recs[0].txn_id, 7);
2872 assert!(matches!(recs[0].op, Op::Put { table_id: 3, .. }));
2873 assert!(matches!(recs[1].op, Op::TxnCommit { epoch: 11, .. }));
2874 let system_path = dir.path().join("seg-000001.wal");
2876 let mut w2 = Wal::create(&system_path, Epoch(0)).unwrap();
2877 w2.append_system(Op::Flush {
2878 table_id: 3,
2879 flushed_epoch: 11,
2880 })
2881 .unwrap();
2882 w2.sync().unwrap();
2883 let recs = replay(&system_path).unwrap();
2884 assert_eq!(recs[0].txn_id, SYSTEM_TXN_ID);
2885 assert!(matches!(recs[0].op, Op::Flush { .. }));
2886 }
2887
2888 #[test]
2889 fn catalog_snapshot_and_external_reset_roundtrip() {
2890 let dir = tempdir().unwrap();
2891 let path = dir.path().join("seg-catalog.wal");
2892 let mut wal = Wal::create(&path, Epoch(0)).unwrap();
2893 wal.append_txn(
2894 9,
2895 Op::Ddl(DdlOp::CatalogSnapshot {
2896 catalog_json: br#"{"db_epoch":7}"#.to_vec(),
2897 }),
2898 )
2899 .unwrap();
2900 wal.append_txn(
2901 9,
2902 Op::Ddl(DdlOp::ResetExternalTableState {
2903 name: "ext".into(),
2904 generation_epoch: 7,
2905 }),
2906 )
2907 .unwrap();
2908 wal.sync().unwrap();
2909
2910 let records = replay(&path).unwrap();
2911 assert!(matches!(
2912 &records[0].op,
2913 Op::Ddl(DdlOp::CatalogSnapshot { catalog_json })
2914 if catalog_json == br#"{"db_epoch":7}"#
2915 ));
2916 assert!(matches!(
2917 &records[1].op,
2918 Op::Ddl(DdlOp::ResetExternalTableState {
2919 name,
2920 generation_epoch: 7,
2921 }) if name == "ext"
2922 ));
2923 }
2924
2925 #[test]
2926 fn torn_write_is_detected() {
2927 let dir = tempdir().unwrap();
2928 let path = dir.path().join("seg-000001.wal");
2929 let mut wal = Wal::create(&path, Epoch(0)).unwrap();
2930 wal.append_txn(
2931 1,
2932 Op::Put {
2933 table_id: 1,
2934 rows: vec![0; 10],
2935 },
2936 )
2937 .unwrap();
2938 wal.sync().unwrap();
2939 drop(wal);
2940
2941 let mut f = OpenOptions::new().append(true).open(&path).unwrap();
2943 f.write_all(&64u32.to_le_bytes()).unwrap();
2945 f.write_all(&[0u8; 7]).unwrap();
2946 f.sync_all().unwrap();
2947 drop(f);
2948
2949 let mut reader = WalReader::open(&path).unwrap();
2950 assert!(reader.next_record().unwrap().is_some());
2952 let err = reader.next_record().unwrap_err();
2954 assert!(matches!(err, MongrelError::TornWrite { .. }), "got {err:?}");
2955 }
2956
2957 #[test]
2958 fn crc_corruption_is_detected() {
2959 let dir = tempdir().unwrap();
2960 let path = dir.path().join("seg-000002.wal");
2961 let mut wal = Wal::create(&path, Epoch(0)).unwrap();
2962 wal.append_txn(
2963 1,
2964 Op::Put {
2965 table_id: 9,
2966 rows: vec![1, 2, 3, 4],
2967 },
2968 )
2969 .unwrap();
2970 wal.sync().unwrap();
2971 drop(wal);
2972
2973 let mut bytes = std::fs::read(&path).unwrap();
2975 let last = bytes.len() - 1;
2976 bytes[last] ^= 0xFF;
2977 std::fs::write(&path, bytes).unwrap();
2978
2979 let err = WalReader::open(&path).unwrap().next_record().unwrap_err();
2980 assert!(
2981 matches!(err, MongrelError::CorruptWal { .. }),
2982 "got {err:?}"
2983 );
2984 }
2985
2986 #[test]
2987 fn trailing_torn_is_eof_but_interior_corruption_errors() {
2988 let dir = tempdir().unwrap();
2989
2990 let path_a = dir.path().join("seg-torn.wal");
2993 let mut wal = Wal::create(&path_a, Epoch(0)).unwrap();
2994 wal.append_txn(
2995 1,
2996 Op::Put {
2997 table_id: 1,
2998 rows: vec![1],
2999 },
3000 )
3001 .unwrap();
3002 wal.append_txn(
3003 1,
3004 Op::Put {
3005 table_id: 1,
3006 rows: vec![2],
3007 },
3008 )
3009 .unwrap();
3010 wal.sync().unwrap();
3011 drop(wal);
3012 let mut f = OpenOptions::new().append(true).open(&path_a).unwrap();
3014 f.write_all(&64u32.to_le_bytes()).unwrap();
3015 f.write_all(&[0u8; 7]).unwrap();
3016 f.sync_all().unwrap();
3017 drop(f);
3018 let recs = replay(&path_a).unwrap();
3019 assert_eq!(recs.len(), 2, "torn trailing frame must truncate cleanly");
3020
3021 let path_b = dir.path().join("seg-interior.wal");
3024 let mut wal = Wal::create(&path_b, Epoch(0)).unwrap();
3025 wal.append_txn(
3026 1,
3027 Op::Put {
3028 table_id: 1,
3029 rows: vec![10, 20, 30],
3030 },
3031 )
3032 .unwrap();
3033 wal.append_txn(
3034 1,
3035 Op::Put {
3036 table_id: 1,
3037 rows: vec![40],
3038 },
3039 )
3040 .unwrap();
3041 wal.sync().unwrap();
3042 drop(wal);
3043 let mut bytes = std::fs::read(&path_b).unwrap();
3046 let first_payload_byte = HEADER_LEN as usize + 4 + 4 + 8 + 8; bytes[first_payload_byte] ^= 0xFF;
3048 std::fs::write(&path_b, bytes).unwrap();
3049 let err = replay(&path_b).unwrap_err();
3050 assert!(
3051 matches!(err, MongrelError::CorruptWal { .. }),
3052 "interior corruption must error, got {err:?}"
3053 );
3054
3055 let path_c = dir.path().join("seg-badtail.wal");
3058 let mut wal = Wal::create(&path_c, Epoch(0)).unwrap();
3059 wal.append_txn(
3060 1,
3061 Op::Put {
3062 table_id: 1,
3063 rows: vec![5],
3064 },
3065 )
3066 .unwrap();
3067 wal.sync().unwrap();
3068 drop(wal);
3069 let mut bytes = std::fs::read(&path_c).unwrap();
3070 let last = bytes.len() - 1;
3071 bytes[last] ^= 0xFF;
3072 std::fs::write(&path_c, bytes).unwrap();
3073 assert!(matches!(
3074 replay(&path_c),
3075 Err(MongrelError::CorruptWal { .. })
3076 ));
3077 }
3078
3079 #[test]
3080 fn byte_threshold_auto_syncs() {
3081 let dir = tempdir().unwrap();
3082 let path = dir.path().join("seg-000003.wal");
3083 let mut wal = Wal::create(&path, Epoch(0)).unwrap();
3084 wal.sync_byte_threshold = 1; wal.append_txn(
3086 1,
3087 Op::Put {
3088 table_id: 1,
3089 rows: vec![0; 5],
3090 },
3091 )
3092 .unwrap();
3093 assert_eq!(
3094 wal.unflushed_bytes(),
3095 0,
3096 "threshold should have auto-synced"
3097 );
3098 }
3099
3100 #[test]
3101 fn create_never_replaces_an_existing_segment() {
3102 let dir = tempdir().unwrap();
3103 let path = dir.path().join("seg-000000.wal");
3104 drop(Wal::create(&path, Epoch(0)).unwrap());
3105 let before = std::fs::read(&path).unwrap();
3106 assert!(matches!(
3107 Wal::create(&path, Epoch(0)),
3108 Err(MongrelError::Io(error)) if error.kind() == std::io::ErrorKind::AlreadyExists
3109 ));
3110 assert_eq!(std::fs::read(path).unwrap(), before);
3111 }
3112
3113 #[test]
3114 fn zero_length_frame_never_hides_a_wal_suffix() {
3115 let dir = tempdir().unwrap();
3116 let path = dir.path().join("seg-000000.wal");
3117 let mut wal = Wal::create(&path, Epoch(0)).unwrap();
3118 wal.append_system(Op::Flush {
3119 table_id: 1,
3120 flushed_epoch: 1,
3121 })
3122 .unwrap();
3123 wal.sync().unwrap();
3124 drop(wal);
3125 let original = std::fs::read(&path).unwrap();
3126 let mut bytes = original[..HEADER_LEN as usize].to_vec();
3127 bytes.extend_from_slice(&0_u32.to_le_bytes());
3128 bytes.extend_from_slice(&original[HEADER_LEN as usize..]);
3129 std::fs::write(&path, bytes).unwrap();
3130 assert!(matches!(
3131 replay(&path),
3132 Err(MongrelError::CorruptWal { .. })
3133 ));
3134 }
3135
3136 #[test]
3137 fn reader_rejects_outer_inner_record_identity_mismatch() {
3138 let dir = tempdir().unwrap();
3139 let path = dir.path().join("seg-000000.wal");
3140 let mut wal = Wal::create(&path, Epoch(0)).unwrap();
3141 wal.append_system(Op::Flush {
3142 table_id: 1,
3143 flushed_epoch: 1,
3144 })
3145 .unwrap();
3146 wal.sync().unwrap();
3147 drop(wal);
3148
3149 let mut bytes = std::fs::read(&path).unwrap();
3150 let start = HEADER_LEN as usize;
3151 let mut length = [0_u8; 4];
3152 length.copy_from_slice(&bytes[start..start + 4]);
3153 let length = u32::from_le_bytes(length) as usize;
3154 let payload = &bytes[start + 24..start + 24 + length];
3155 let mut record: Record = bincode::deserialize(payload).unwrap();
3156 record.seq = Epoch(99);
3157 let replacement = bincode::serialize(&record).unwrap();
3158 assert_eq!(replacement.len(), length);
3159 bytes[start + 24..start + 24 + length].copy_from_slice(&replacement);
3160 recompute_frame_crc(&mut bytes, start);
3161 std::fs::write(&path, bytes).unwrap();
3162
3163 assert!(matches!(
3164 WalReader::open(&path).unwrap().next_record(),
3165 Err(MongrelError::CorruptWal { .. })
3166 ));
3167 }
3168
3169 #[cfg(feature = "encryption")]
3170 #[test]
3171 fn encrypted_frames_reject_reorder_replay_deletion_and_cross_segment_move() {
3172 fn cipher(key: &[u8; 32]) -> Box<dyn crate::encryption::Cipher> {
3173 Box::new(crate::encryption::AesCipher::new(key).unwrap())
3174 }
3175
3176 let dir = tempdir().unwrap();
3177 let key = [0x5a; 32];
3178 let path = dir.path().join("seg-000009.wal");
3179 let mut wal = Wal::create_with_cipher(&path, Epoch(0), Some(cipher(&key)), 9).unwrap();
3180 for table_id in [1, 2] {
3181 wal.append_system(Op::Flush {
3182 table_id,
3183 flushed_epoch: table_id,
3184 })
3185 .unwrap();
3186 }
3187 wal.sync().unwrap();
3188 drop(wal);
3189 let original = std::fs::read(&path).unwrap();
3190 let ranges = frame_ranges(&original);
3191 assert_eq!(ranges.len(), 2);
3192
3193 let mut reordered = original[..HEADER_LEN as usize].to_vec();
3194 reordered.extend_from_slice(&original[ranges[1].clone()]);
3195 reordered.extend_from_slice(&original[ranges[0].clone()]);
3196 std::fs::write(&path, reordered).unwrap();
3197 assert!(replay_with_cipher(&path, Some(cipher(&key))).is_err());
3198
3199 let mut replayed = original[..HEADER_LEN as usize].to_vec();
3200 replayed.extend_from_slice(&original[ranges[0].clone()]);
3201 replayed.extend_from_slice(&original[ranges[0].clone()]);
3202 replayed.extend_from_slice(&original[ranges[1].clone()]);
3203 std::fs::write(&path, replayed).unwrap();
3204 assert!(replay_with_cipher(&path, Some(cipher(&key))).is_err());
3205
3206 let mut deleted = original[..HEADER_LEN as usize].to_vec();
3207 deleted.extend_from_slice(&original[ranges[1].clone()]);
3208 std::fs::write(&path, deleted).unwrap();
3209 assert!(replay_with_cipher(&path, Some(cipher(&key))).is_err());
3210
3211 let mut outer_tampered = original.clone();
3212 outer_tampered[ranges[0].start + 8] ^= 0x01;
3213 recompute_frame_crc(&mut outer_tampered, ranges[0].start);
3214 std::fs::write(&path, outer_tampered).unwrap();
3215 assert!(replay_with_cipher(&path, Some(cipher(&key))).is_err());
3216
3217 let other = dir.path().join("seg-000010.wal");
3218 let mut wal = Wal::create_with_cipher(&other, Epoch(0), Some(cipher(&key)), 10).unwrap();
3219 wal.append_system(Op::Flush {
3220 table_id: 3,
3221 flushed_epoch: 3,
3222 })
3223 .unwrap();
3224 wal.sync().unwrap();
3225 drop(wal);
3226 let mut moved = std::fs::read(&other).unwrap();
3227 moved.truncate(HEADER_LEN as usize);
3228 moved.extend_from_slice(&original[ranges[0].clone()]);
3229 std::fs::write(&other, moved).unwrap();
3230 assert!(replay_with_cipher(&other, Some(cipher(&key))).is_err());
3231 }
3232
3233 #[cfg(feature = "encryption")]
3234 #[test]
3235 fn wal_nonce_is_segment_deterministic() {
3236 assert_ne!(frame_nonce_for(5, 0), frame_nonce_for(6, 0));
3239 assert_ne!(frame_nonce_for(5, 0), frame_nonce_for(5, 1));
3240 assert_eq!(frame_nonce_for(5, 0), frame_nonce_for(5, 0));
3243 }
3244}