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