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 drop(reader);
1433 wal_root.remove_file(segment_filename(orphan_no))?;
1434 Ok(true)
1435}
1436
1437struct ReplayedSegment {
1441 segment_no: u64,
1442 records: Vec<Record>,
1443}
1444
1445fn validate_v4_sequence_continuity(segments: &[ReplayedSegment]) -> Result<()> {
1450 let mut previous: Option<(u64, u64)> = None;
1451 for segment in segments {
1452 for record in &segment.records {
1453 if let Some((previous_seq, previous_segment)) = previous {
1454 let expected =
1455 previous_seq
1456 .checked_add(1)
1457 .ok_or_else(|| MongrelError::CorruptWal {
1458 offset: record.seq.0,
1459 reason: "WAL sequence overflows after u64::MAX".into(),
1460 })?;
1461 if record.seq.0 != expected {
1462 let reason = if segment.segment_no == previous_segment {
1463 format!(
1464 "WAL segment {} sequence {} does not follow {previous_seq}",
1465 segment.segment_no, record.seq.0
1466 )
1467 } else {
1468 format!(
1469 "WAL segment {} begins with sequence {}, expected {expected} after segment {previous_segment}",
1470 segment.segment_no, record.seq.0
1471 )
1472 };
1473 return Err(MongrelError::CorruptWal {
1474 offset: record.seq.0,
1475 reason,
1476 });
1477 }
1478 }
1479 previous = Some((record.seq.0, segment.segment_no));
1480 }
1481 }
1482 Ok(())
1483}
1484
1485fn replay_wal_layout(
1486 wal_root: &crate::durable_file::DurableRoot,
1487 layout: &WalLayout,
1488 wal_dek: Option<&Zeroizing<[u8; 32]>>,
1489) -> Result<Vec<Record>> {
1490 let total_bytes = layout
1491 .segments
1492 .iter()
1493 .try_fold(0_u64, |total, segment_no| {
1494 let bytes = if layout
1495 .head
1496 .is_some_and(|head| head.segment_no == *segment_no)
1497 {
1498 layout.head.unwrap().durable_len
1499 } else {
1500 wal_root
1501 .open_regular(segment_filename(*segment_no))?
1502 .metadata()?
1503 .len()
1504 };
1505 total
1506 .checked_add(bytes)
1507 .ok_or(MongrelError::ResourceLimitExceeded {
1508 resource: "WAL recovery bytes",
1509 requested: usize::MAX,
1510 limit: MAX_RECOVERY_WAL_BYTES as usize,
1511 })
1512 })?;
1513 if total_bytes > MAX_RECOVERY_WAL_BYTES {
1514 return Err(MongrelError::ResourceLimitExceeded {
1515 resource: "WAL recovery bytes",
1516 requested: usize::try_from(total_bytes).unwrap_or(usize::MAX),
1517 limit: MAX_RECOVERY_WAL_BYTES as usize,
1518 });
1519 }
1520 let mut segments = Vec::with_capacity(layout.segments.len());
1521 let mut total_records = 0_usize;
1522 for segment_no in layout.segments.iter().copied() {
1523 let mut reader = reader_for_segment(wal_root, segment_no, wal_dek)?;
1524 if layout
1525 .head
1526 .is_some_and(|head| head.segment_no == segment_no)
1527 {
1528 reader.constrain_to_durable_len(layout.head.unwrap().durable_len)?;
1529 }
1530 let remaining = MAX_RECOVERY_WAL_RECORDS.saturating_sub(total_records);
1531 let records = reader.replay_bounded(remaining, false)?;
1535 total_records += records.len();
1536 segments.push(ReplayedSegment {
1537 segment_no,
1538 records,
1539 });
1540 }
1541 validate_v4_sequence_continuity(&segments)?;
1542 let records: Vec<Record> = segments
1543 .into_iter()
1544 .flat_map(|segment| segment.records)
1545 .collect();
1546 validate_shared_transaction_framing(&records)?;
1547 Ok(records)
1548}
1549
1550pub(crate) fn validate_shared_transaction_framing(records: &[Record]) -> Result<()> {
1555 let mut transactions = std::collections::HashMap::<u64, ReplayTxnState>::new();
1556 let mut commit_epochs = std::collections::HashMap::<u64, u64>::new();
1557 let mut previous_commit_epoch: Option<u64> = None;
1558 for record in records {
1559 if record.txn_id == SYSTEM_TXN_ID {
1560 if !matches!(record.op, Op::Flush { .. }) {
1561 return Err(MongrelError::CorruptWal {
1562 offset: record.seq.0,
1563 reason: "non-system operation uses reserved transaction id 0".into(),
1564 });
1565 }
1566 continue;
1567 }
1568 let state = transactions.entry(record.txn_id).or_default();
1569 if state.terminal {
1570 return Err(MongrelError::CorruptWal {
1571 offset: record.seq.0,
1572 reason: format!(
1573 "transaction {} has records after its terminal marker",
1574 record.txn_id
1575 ),
1576 });
1577 }
1578 match record.op {
1579 Op::CommitTimestamp { .. } => {
1580 if state.timestamp_seen {
1581 return Err(MongrelError::CorruptWal {
1582 offset: record.seq.0,
1583 reason: format!(
1584 "transaction {} has duplicate commit timestamps",
1585 record.txn_id
1586 ),
1587 });
1588 }
1589 state.timestamp_seen = true;
1590 }
1591 Op::TxnCommit { epoch, .. } => {
1592 if epoch == 0 {
1593 return Err(MongrelError::CorruptWal {
1594 offset: record.seq.0,
1595 reason: format!("transaction {} commits at epoch 0", record.txn_id),
1596 });
1597 }
1598 if let Some(previous) = commit_epochs.insert(epoch, record.txn_id) {
1599 return Err(MongrelError::CorruptWal {
1600 offset: record.seq.0,
1601 reason: format!(
1602 "transactions {previous} and {} share commit epoch {epoch}",
1603 record.txn_id
1604 ),
1605 });
1606 }
1607 if previous_commit_epoch.is_some_and(|previous| epoch <= previous) {
1608 return Err(MongrelError::CorruptWal {
1609 offset: record.seq.0,
1610 reason: format!(
1611 "commit epoch {epoch} does not advance beyond {}",
1612 previous_commit_epoch.unwrap_or(0)
1613 ),
1614 });
1615 }
1616 previous_commit_epoch = Some(epoch);
1617 state.terminal = true;
1618 }
1619 Op::TxnAbort => state.terminal = true,
1620 Op::Flush { .. } => {
1621 return Err(MongrelError::CorruptWal {
1622 offset: record.seq.0,
1623 reason: format!(
1624 "transaction {} contains a system flush record",
1625 record.txn_id
1626 ),
1627 });
1628 }
1629 _ => {}
1630 }
1631 }
1632 Ok(())
1633}
1634
1635impl SharedWal {
1636 fn cipher_from_dek(dek: &Zeroizing<[u8; 32]>) -> Result<Box<dyn crate::encryption::Cipher>> {
1638 Ok(Box::new(crate::encryption::AesCipher::new(&dek[..])?))
1639 }
1640
1641 pub fn create(root: &Path, epoch_created: Epoch) -> Result<Self> {
1643 Self::create_with_dek(root, epoch_created, None)
1644 }
1645
1646 pub fn create_with_dek(
1648 root: &Path,
1649 epoch_created: Epoch,
1650 wal_dek: Option<Zeroizing<[u8; 32]>>,
1651 ) -> Result<Self> {
1652 let root = Arc::new(crate::durable_file::DurableRoot::open(root)?);
1653 Self::create_with_durable_root(root, epoch_created, wal_dek)
1654 }
1655
1656 pub(crate) fn create_with_durable_root(
1657 root: Arc<crate::durable_file::DurableRoot>,
1658 epoch_created: Epoch,
1659 wal_dek: Option<Zeroizing<[u8; 32]>>,
1660 ) -> Result<Self> {
1661 let wal_root = Arc::new(root.create_directory_all_pinned("_wal")?);
1662 let wal_dir = wal_root.io_path()?;
1663 if !list_segment_numbers(&wal_root)?.is_empty() {
1664 return Err(MongrelError::CorruptWal {
1665 offset: 0,
1666 reason: "refuses to create a shared WAL over existing segments".into(),
1667 });
1668 }
1669 let cipher = match &wal_dek {
1670 Some(dk) => Some(Self::cipher_from_dek(dk)?),
1671 None => None,
1672 };
1673 let active = Wal::create_chained_in(&wal_root, 0, epoch_created, cipher, [0; 32])?;
1674 if let Err(error) = write_wal_head(&root, &wal_root, 0, 0, wal_dek.as_ref()) {
1675 drop(active);
1676 let _ = wal_root.remove_file(segment_filename(0));
1677 return Err(error);
1678 }
1679 Ok(Self {
1680 root,
1681 wal_root,
1682 wal_dir,
1683 active,
1684 active_segment_no: 0,
1685 durable_seq: epoch_created.0,
1686 open_generation: 0,
1687 wal_dek,
1688 group_sync_count: 0,
1689 })
1690 }
1691
1692 pub fn open(
1697 root: &Path,
1698 epoch_created: Epoch,
1699 wal_dek: Option<Zeroizing<[u8; 32]>>,
1700 ) -> Result<Self> {
1701 let root = Arc::new(crate::durable_file::DurableRoot::open(root)?);
1702 Self::open_durable_root(root, epoch_created, wal_dek)
1703 }
1704
1705 pub(crate) fn open_durable_root(
1706 root: Arc<crate::durable_file::DurableRoot>,
1707 epoch_created: Epoch,
1708 wal_dek: Option<Zeroizing<[u8; 32]>>,
1709 ) -> Result<Self> {
1710 Self::open_durable_root_validated(root, epoch_created, wal_dek, None)
1711 }
1712
1713 pub(crate) fn open_durable_root_validated(
1714 root: Arc<crate::durable_file::DurableRoot>,
1715 epoch_created: Epoch,
1716 wal_dek: Option<Zeroizing<[u8; 32]>>,
1717 expected_records: Option<&[Record]>,
1718 ) -> Result<Self> {
1719 let wal_root =
1720 Arc::new(
1721 root.open_directory("_wal")
1722 .map_err(|error| MongrelError::CorruptWal {
1723 offset: 0,
1724 reason: format!(
1725 "existing database WAL directory cannot be opened: {error}"
1726 ),
1727 })?,
1728 );
1729 let wal_dir = wal_root.io_path()?;
1730 if let Some(expected) = expected_records {
1731 let layout = inspect_wal_layout(&root, &wal_root, wal_dek.as_ref())?;
1732 let actual = replay_wal_layout(&wal_root, &layout, wal_dek.as_ref())?;
1733 if bincode::serialize(&actual)? != bincode::serialize(expected)? {
1734 return Err(MongrelError::CorruptWal {
1735 offset: 0,
1736 reason: "WAL changed after recovery planning".into(),
1737 });
1738 }
1739 }
1740 remove_unpublished_header_only_segment(&root, &wal_root, wal_dek.as_ref())?;
1741 let layout = inspect_wal_layout(&root, &wal_root, wal_dek.as_ref())?;
1742 let final_segment =
1743 layout
1744 .segments
1745 .last()
1746 .copied()
1747 .ok_or_else(|| MongrelError::CorruptWal {
1748 offset: 0,
1749 reason: "existing database has no WAL segments".into(),
1750 })?;
1751 let records = replay_wal_layout(&wal_root, &layout, wal_dek.as_ref())?;
1752 let open_generation = layout
1753 .head
1754 .map(|head| head.open_generation)
1755 .unwrap_or_else(|| {
1756 records
1757 .iter()
1758 .filter(|record| record.txn_id != SYSTEM_TXN_ID)
1759 .map(|record| record.txn_id >> 32)
1760 .max()
1761 .unwrap_or(0)
1762 });
1763
1764 let durable_len = layout
1768 .head
1769 .ok_or_else(|| MongrelError::CorruptWal {
1770 offset: 0,
1771 reason: "validated WAL layout has no durable WAL head".into(),
1772 })?
1773 .durable_len;
1774 let final_name = segment_filename(final_segment);
1775 let final_file = wal_root.open_regular_read_write(&final_name)?;
1776 if final_file.metadata()?.len() != durable_len {
1777 final_file.set_len(durable_len)?;
1778 final_file.sync_all()?;
1779 }
1780 let previous_segment_hash = hash_segment(&wal_root, final_segment)?;
1781 let next_segment_no = final_segment
1782 .checked_add(1)
1783 .ok_or_else(|| MongrelError::Full("WAL segment namespace exhausted".into()))?;
1784 let durable_seq = records
1787 .last()
1788 .map(|record| record.seq.0)
1789 .unwrap_or(epoch_created.0);
1790 let cipher = match &wal_dek {
1791 Some(dk) => Some(Self::cipher_from_dek(dk)?),
1792 None => None,
1793 };
1794 let mut active = Wal::create_chained_in(
1795 &wal_root,
1796 next_segment_no,
1797 epoch_created,
1798 cipher,
1799 previous_segment_hash,
1800 )?;
1801 active.next_seq = durable_seq
1802 .checked_add(1)
1803 .ok_or_else(|| MongrelError::Full("WAL sequence namespace exhausted".into()))?;
1804 if let Err(error) = write_wal_head(
1805 &root,
1806 &wal_root,
1807 next_segment_no,
1808 open_generation,
1809 wal_dek.as_ref(),
1810 ) {
1811 drop(active);
1812 let _ = wal_root.remove_file(segment_filename(next_segment_no));
1813 return Err(error);
1814 }
1815 Ok(Self {
1816 root,
1817 wal_root,
1818 wal_dir,
1819 active,
1820 active_segment_no: next_segment_no,
1821 durable_seq,
1822 open_generation,
1823 wal_dek,
1824 group_sync_count: 0,
1825 })
1826 }
1827
1828 #[allow(dead_code)]
1830 pub fn wal_dir(&self) -> &Path {
1831 &self.wal_dir
1832 }
1833
1834 pub fn append(&mut self, txn_id: u64, _table_id: u64, op: Op) -> Result<u64> {
1838 mongreldb_fault::inject("wal.append.before").map_err(crate::commit_log::fault_as_io)?;
1839 let seq = self.active.append_txn(txn_id, op)?.0;
1840 mongreldb_fault::inject("wal.append.after").map_err(crate::commit_log::fault_as_io)?;
1841 Ok(seq)
1842 }
1843
1844 pub fn append_commit(&mut self, txn_id: u64, epoch: Epoch, added: &[AddedRun]) -> Result<u64> {
1846 self.append_commit_at(txn_id, epoch, added, unix_nanos_now())
1847 }
1848
1849 pub fn append_commit_at(
1850 &mut self,
1851 txn_id: u64,
1852 epoch: Epoch,
1853 added: &[AddedRun],
1854 unix_nanos: u64,
1855 ) -> Result<u64> {
1856 self.active
1857 .append_txn(txn_id, Op::CommitTimestamp { unix_nanos })?;
1858 Ok(self
1859 .active
1860 .append_txn(
1861 txn_id,
1862 Op::TxnCommit {
1863 epoch: epoch.0,
1864 added_runs: added.to_vec(),
1865 },
1866 )?
1867 .0)
1868 }
1869
1870 pub fn append_abort(&mut self, txn_id: u64) -> Result<()> {
1872 self.active.append_txn(txn_id, Op::TxnAbort)?;
1873 Ok(())
1874 }
1875
1876 pub fn append_system(&mut self, op: Op) -> Result<u64> {
1878 Ok(self.active.append_system(op)?.0)
1879 }
1880
1881 pub fn group_sync(&mut self) -> Result<u64> {
1886 mongreldb_fault::inject("wal.fsync.before").map_err(crate::commit_log::fault_as_io)?;
1887 self.active.sync()?;
1888 mongreldb_fault::inject("wal.fsync.after").map_err(crate::commit_log::fault_as_io)?;
1889 write_wal_head(
1890 &self.root,
1891 &self.wal_root,
1892 self.active_segment_no,
1893 self.open_generation,
1894 self.wal_dek.as_ref(),
1895 )?;
1896 self.group_sync_count += 1;
1897 let highest = self.active.next_seq_val().saturating_sub(1);
1898 if highest > self.durable_seq {
1899 self.durable_seq = highest;
1900 }
1901 Ok(self.durable_seq)
1902 }
1903
1904 pub fn group_sync_count(&self) -> u64 {
1906 self.group_sync_count
1907 }
1908
1909 pub fn durable_seq(&self) -> u64 {
1911 self.durable_seq
1912 }
1913
1914 pub(crate) fn seal_open_generation(&mut self, generation: u64) -> Result<()> {
1915 if generation < self.open_generation {
1916 return Err(MongrelError::CorruptWal {
1917 offset: generation,
1918 reason: format!(
1919 "open generation {generation} precedes WAL head generation {}",
1920 self.open_generation
1921 ),
1922 });
1923 }
1924 self.active.sync()?;
1925 let previous = self.open_generation;
1926 self.open_generation = generation;
1927 if let Err(error) = write_wal_head(
1928 &self.root,
1929 &self.wal_root,
1930 self.active_segment_no,
1931 self.open_generation,
1932 self.wal_dek.as_ref(),
1933 ) {
1934 self.open_generation = previous;
1935 return Err(error);
1936 }
1937 Ok(())
1938 }
1939
1940 pub fn rotate(&mut self, segment_no: u64) -> Result<()> {
1943 let expected = self
1944 .active_segment_no
1945 .checked_add(1)
1946 .ok_or_else(|| MongrelError::Full("WAL segment namespace exhausted".into()))?;
1947 if segment_no != expected {
1948 return Err(MongrelError::InvalidArgument(format!(
1949 "WAL rotation segment {segment_no} does not immediately follow {}",
1950 self.active_segment_no
1951 )));
1952 }
1953 self.active.sync()?;
1954 write_wal_head(
1955 &self.root,
1956 &self.wal_root,
1957 self.active_segment_no,
1958 self.open_generation,
1959 self.wal_dek.as_ref(),
1960 )?;
1961 let highest = self.active.next_seq_val().saturating_sub(1);
1962 self.durable_seq = self.durable_seq.max(highest);
1963 let previous_segment_hash = hash_segment(&self.wal_root, self.active_segment_no)?;
1964 let cipher = match &self.wal_dek {
1965 Some(dk) => Some(Self::cipher_from_dek(dk)?),
1966 _ => None,
1967 };
1968 let epoch = Epoch(self.durable_seq);
1969 let wal = Wal::create_chained_in(
1970 &self.wal_root,
1971 segment_no,
1972 epoch,
1973 cipher,
1974 previous_segment_hash,
1975 )?;
1976 if let Err(error) = write_wal_head(
1977 &self.root,
1978 &self.wal_root,
1979 segment_no,
1980 self.open_generation,
1981 self.wal_dek.as_ref(),
1982 ) {
1983 drop(wal);
1984 let _ = self.wal_root.remove_file(segment_filename(segment_no));
1985 return Err(error);
1986 }
1987 self.active = wal;
1988 self.active_segment_no = segment_no;
1989 Ok(())
1990 }
1991
1992 pub fn active_segment_no(&self) -> u64 {
1994 self.active_segment_no
1995 }
1996
1997 pub(crate) fn reset_after_checkpoint(&mut self) -> Result<usize> {
2001 self.group_sync()?;
2002 let next_segment_no = list_segment_numbers(&self.wal_root)?
2003 .into_iter()
2004 .max()
2005 .ok_or_else(|| MongrelError::CorruptWal {
2006 offset: 0,
2007 reason: "active WAL segment disappeared before checkpoint reset".into(),
2008 })?
2009 .checked_add(1)
2010 .ok_or_else(|| MongrelError::Full("WAL segment namespace exhausted".into()))?;
2011 self.rotate(next_segment_no)?;
2012 self.group_sync()?;
2015 self.gc_segments_retain_recent(u64::MAX, 0)
2016 }
2017
2018 pub fn gc_segments(&mut self, min_retained_seq: u64) -> Result<usize> {
2028 self.gc_segments_retain_recent(min_retained_seq, 0)
2029 }
2030
2031 pub(crate) fn has_gc_segments_retain_recent(&self, retain_recent: usize) -> Result<bool> {
2032 let rotated = list_segment_numbers(&self.wal_root)?
2033 .into_iter()
2034 .filter(|segment| *segment != self.active_segment_no)
2035 .count();
2036 Ok(rotated > retain_recent)
2037 }
2038
2039 pub fn gc_segments_retain_recent(
2043 &mut self,
2044 min_retained_seq: u64,
2045 retain_recent: usize,
2046 ) -> Result<usize> {
2047 self.group_sync()?;
2048 let layout = inspect_wal_layout(&self.root, &self.wal_root, self.wal_dek.as_ref())?;
2049 let readable = replay_wal_layout(&self.wal_root, &layout, self.wal_dek.as_ref())?;
2050 let segments = &layout.segments;
2051 let retained: Vec<u64> = segments
2052 .iter()
2053 .rev()
2054 .filter(|segment| **segment != self.active_segment_no)
2055 .take(retain_recent)
2056 .copied()
2057 .collect();
2058 let mut candidates = Vec::new();
2059 let mut prefix_open = true;
2060 for n in segments.iter().copied() {
2061 let mut reader = reader_for_segment(&self.wal_root, n, self.wal_dek.as_ref())?;
2062 if layout.head.is_some_and(|head| head.segment_no == n) {
2063 reader.constrain_to_durable_len(layout.head.unwrap().durable_len)?;
2064 }
2065 let records = reader.replay_strict()?;
2066 let below_floor = min_retained_seq == u64::MAX
2067 || records.iter().map(|record| record.seq.0).max().unwrap_or(0) < min_retained_seq;
2068 let reapable =
2069 prefix_open && n != self.active_segment_no && !retained.contains(&n) && below_floor;
2070 if reapable {
2071 candidates.push((n, records));
2072 } else {
2073 prefix_open = false;
2074 }
2075 }
2076
2077 let commits = readable
2078 .iter()
2079 .filter_map(|record| match record.op {
2080 Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
2081 _ => None,
2082 })
2083 .collect::<std::collections::HashMap<_, _>>();
2084 let removed_floor = candidates
2085 .iter()
2086 .flat_map(|(_, records)| records)
2087 .filter_map(|record| commits.get(&record.txn_id).copied())
2088 .max();
2089 if let Some(epoch) = removed_floor {
2090 crate::replication::advance_replication_wal_floor_durable(&self.root, epoch)?;
2091 }
2092 for (segment_no, _) in &candidates {
2093 self.wal_root.remove_file(segment_filename(*segment_no))?;
2094 }
2095 let reaped = candidates.len();
2096 Ok(reaped)
2097 }
2098
2099 pub fn verify_segments(&self) -> Vec<(u64, String)> {
2107 let result = inspect_wal_layout(&self.root, &self.wal_root, self.wal_dek.as_ref())
2108 .and_then(|layout| replay_wal_layout(&self.wal_root, &layout, self.wal_dek.as_ref()));
2109 match result {
2110 Ok(_) => Vec::new(),
2111 Err(error) => vec![(u64::MAX, error.to_string())],
2112 }
2113 }
2114
2115 pub fn replay(root: &Path) -> Result<Vec<Record>> {
2119 Self::replay_with_dek(root, None)
2120 }
2121
2122 pub fn replay_with_dek(
2124 root: &Path,
2125 wal_dek: Option<&Zeroizing<[u8; 32]>>,
2126 ) -> Result<Vec<Record>> {
2127 let root = crate::durable_file::DurableRoot::open(root)?;
2128 Self::replay_durable_with_dek(&root, wal_dek)
2129 }
2130
2131 pub(crate) fn replay_durable_with_dek(
2132 root: &crate::durable_file::DurableRoot,
2133 wal_dek: Option<&Zeroizing<[u8; 32]>>,
2134 ) -> Result<Vec<Record>> {
2135 let wal_root = root.open_directory("_wal")?;
2136 let layout = inspect_wal_layout(root, &wal_root, wal_dek)?;
2137 replay_wal_layout(&wal_root, &layout, wal_dek)
2138 }
2139
2140 pub(crate) fn durable_open_generation(
2141 root: &crate::durable_file::DurableRoot,
2142 wal_dek: Option<&Zeroizing<[u8; 32]>>,
2143 ) -> Result<Option<u64>> {
2144 let wal_root = root.open_directory("_wal")?;
2145 let layout = inspect_wal_layout(root, &wal_root, wal_dek)?;
2146 Ok(layout.head.map(|head| head.open_generation))
2147 }
2148
2149 pub fn replay_with_dek_controlled(
2152 root: &Path,
2153 wal_dek: Option<&Zeroizing<[u8; 32]>>,
2154 control: &crate::ExecutionControl,
2155 max_records: usize,
2156 max_bytes: usize,
2157 ) -> Result<Vec<Record>> {
2158 let root = crate::durable_file::DurableRoot::open(root)?;
2159 let wal_root = root.open_directory("_wal")?;
2160 let layout = inspect_wal_layout(&root, &wal_root, wal_dek)?;
2161 let total_bytes = layout.segments.iter().try_fold(0_usize, |total, segment| {
2162 let bytes = if layout.head.is_some_and(|head| head.segment_no == *segment) {
2163 usize::try_from(layout.head.unwrap().durable_len).unwrap_or(usize::MAX)
2164 } else {
2165 usize::try_from(
2166 wal_root
2167 .open_regular(segment_filename(*segment))?
2168 .metadata()?
2169 .len(),
2170 )
2171 .unwrap_or(usize::MAX)
2172 };
2173 Ok::<_, MongrelError>(total.saturating_add(bytes))
2174 })?;
2175 if total_bytes > max_bytes {
2176 return Err(MongrelError::ResourceLimitExceeded {
2177 resource: "controlled WAL replay bytes",
2178 requested: total_bytes,
2179 limit: max_bytes,
2180 });
2181 }
2182 let mut segments = Vec::with_capacity(layout.segments.len());
2183 let mut total_records = 0_usize;
2184 for n in layout.segments.iter().copied() {
2185 control.checkpoint()?;
2186 let remaining = max_records.saturating_sub(total_records);
2187 let mut reader = reader_for_segment(&wal_root, n, wal_dek)?;
2188 if layout.head.is_some_and(|head| head.segment_no == n) {
2189 reader.constrain_to_durable_len(layout.head.unwrap().durable_len)?;
2190 }
2191 let records = reader.replay_controlled_strict(control, remaining)?;
2195 total_records += records.len();
2196 segments.push(ReplayedSegment {
2197 segment_no: n,
2198 records,
2199 });
2200 }
2201 validate_v4_sequence_continuity(&segments)?;
2202 let out: Vec<Record> = segments
2203 .into_iter()
2204 .flat_map(|segment| segment.records)
2205 .collect();
2206 validate_shared_transaction_framing(&out)?;
2207 Ok(out)
2208 }
2209}
2210
2211fn segment_filename(segment_no: u64) -> String {
2212 format!("seg-{segment_no:06}.wal")
2213}
2214
2215fn segment_number_from_path(path: &Path) -> Option<u64> {
2216 path.file_name()
2217 .and_then(|name| name.to_str())
2218 .and_then(|name| name.strip_prefix("seg-"))
2219 .and_then(|name| name.strip_suffix(".wal"))
2220 .and_then(|number| number.parse().ok())
2221}
2222
2223fn list_segment_numbers(wal_root: &crate::durable_file::DurableRoot) -> Result<Vec<u64>> {
2228 let mut segments = Vec::new();
2229 for fname in wal_root.list_regular_files(".")? {
2230 let s = fname.to_str().ok_or_else(|| MongrelError::CorruptWal {
2231 offset: 0,
2232 reason: "WAL directory contains a non-UTF-8 entry".into(),
2233 })?;
2234 if !s.starts_with("seg-") {
2235 continue;
2236 }
2237 let number = s
2238 .strip_prefix("seg-")
2239 .and_then(|value| value.strip_suffix(".wal"))
2240 .and_then(|value| value.parse::<u64>().ok())
2241 .ok_or_else(|| MongrelError::CorruptWal {
2242 offset: 0,
2243 reason: format!("malformed WAL segment filename {s:?}"),
2244 })?;
2245 if s != segment_filename(number) {
2246 return Err(MongrelError::CorruptWal {
2247 offset: 0,
2248 reason: format!("non-canonical WAL segment filename {s:?}"),
2249 });
2250 }
2251 segments.push(number);
2252 }
2253 segments.sort_unstable();
2254 for pair in segments.windows(2) {
2255 let expected = pair[0]
2256 .checked_add(1)
2257 .ok_or_else(|| MongrelError::CorruptWal {
2258 offset: pair[0],
2259 reason: "WAL segment namespace overflows after u64::MAX".into(),
2260 })?;
2261 if pair[1] != expected {
2262 return Err(MongrelError::CorruptWal {
2263 offset: pair[1],
2264 reason: format!(
2265 "WAL segment {} does not immediately follow {}",
2266 pair[1], pair[0]
2267 ),
2268 });
2269 }
2270 }
2271 Ok(segments)
2272}
2273
2274#[cfg(test)]
2275mod shared_wal_tests {
2276 use super::*;
2277 use tempfile::tempdir;
2278
2279 fn write_v3_segment(path: &Path) {
2282 let mut bytes = Vec::new();
2283 bytes.extend_from_slice(&WAL_MAGIC);
2284 bytes.extend_from_slice(&3_u16.to_le_bytes());
2285 bytes.extend_from_slice(&[ENC_PLAINTEXT, 0, 0, 0]);
2286 bytes.extend_from_slice(&0_u64.to_le_bytes());
2287 std::fs::write(path, bytes).unwrap();
2288 }
2289
2290 #[test]
2291 fn v3_segments_are_rejected_as_unsupported_not_corrupt() {
2292 let dir = tempdir().unwrap();
2293 std::fs::create_dir(dir.path().join("_wal")).unwrap();
2294 write_v3_segment(&dir.path().join("_wal/seg-000000.wal"));
2295
2296 let error = SharedWal::replay(dir.path()).unwrap_err();
2297 assert!(
2298 matches!(
2299 error,
2300 MongrelError::UnsupportedStorageVersion {
2301 component: "wal",
2302 found: 3,
2303 supported: 4,
2304 }
2305 ),
2306 "expected UnsupportedStorageVersion, got {error:?}"
2307 );
2308
2309 let dir = tempdir().unwrap();
2310 std::fs::create_dir(dir.path().join("_wal")).unwrap();
2311 write_v3_segment(&dir.path().join("_wal/seg-000000.wal"));
2312 let error = SharedWal::open(dir.path(), Epoch(1), None)
2313 .err()
2314 .expect("v3 WAL must be rejected");
2315 assert!(matches!(
2316 error,
2317 MongrelError::UnsupportedStorageVersion {
2318 component: "wal",
2319 found: 3,
2320 supported: 4,
2321 }
2322 ));
2323 }
2324
2325 #[test]
2326 fn shared_wal_interleaves_two_tables_one_fd() {
2327 let dir = tempdir().unwrap();
2328 let mut w = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2329 w.append(
2330 1,
2331 10,
2332 Op::Put {
2333 table_id: 10,
2334 rows: vec![1],
2335 },
2336 )
2337 .unwrap();
2338 w.append(
2339 2,
2340 20,
2341 Op::Put {
2342 table_id: 20,
2343 rows: vec![2],
2344 },
2345 )
2346 .unwrap();
2347 w.append_commit(1, Epoch(1), &[]).unwrap();
2348 w.append_commit(2, Epoch(2), &[]).unwrap();
2349 let d = w.group_sync().unwrap();
2350 assert!(d >= 4);
2351 let recs = SharedWal::replay(dir.path()).unwrap();
2352 assert_eq!(
2353 recs.iter()
2354 .filter(|r| matches!(r.op, Op::Put { .. }))
2355 .count(),
2356 2
2357 );
2358 assert_eq!(
2359 recs.iter()
2360 .filter(|r| matches!(r.op, Op::TxnCommit { .. }))
2361 .count(),
2362 2
2363 );
2364 }
2365
2366 #[test]
2367 fn controlled_shared_replay_rejects_aggregate_wal_bytes() {
2368 let dir = tempdir().unwrap();
2369 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2370 wal.append_commit(1, Epoch(1), &[]).unwrap();
2371 wal.group_sync().unwrap();
2372 let control = crate::ExecutionControl::new(None);
2373
2374 let error =
2375 SharedWal::replay_with_dek_controlled(dir.path(), None, &control, usize::MAX, 1)
2376 .unwrap_err();
2377 assert!(matches!(
2378 error,
2379 MongrelError::ResourceLimitExceeded {
2380 resource: "controlled WAL replay bytes",
2381 ..
2382 }
2383 ));
2384 }
2385
2386 #[test]
2387 fn shared_wal_gc_retains_recent_rotated_segments() {
2388 let dir = tempdir().unwrap();
2389 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2390 for segment in 0..4u64 {
2391 wal.append_commit(segment + 1, Epoch(segment + 1), &[])
2392 .unwrap();
2393 wal.group_sync().unwrap();
2394 if segment < 3 {
2395 wal.rotate(segment + 1).unwrap();
2396 }
2397 }
2398 assert_eq!(wal.gc_segments_retain_recent(u64::MAX, 2).unwrap(), 1);
2399 let count = std::fs::read_dir(dir.path().join("_wal"))
2400 .unwrap()
2401 .flatten()
2402 .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "wal"))
2403 .count();
2404 assert_eq!(count, 3, "active plus two retained segments");
2405 }
2406
2407 #[test]
2408 fn shared_replay_rejects_torn_tail_in_rotated_segment() {
2409 let dir = tempdir().unwrap();
2410 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2411 wal.append_commit(1, Epoch(1), &[]).unwrap();
2412 wal.group_sync().unwrap();
2413 wal.rotate(1).unwrap();
2414 wal.append_commit(2, Epoch(2), &[]).unwrap();
2415 wal.group_sync().unwrap();
2416 drop(wal);
2417
2418 let old = dir.path().join("_wal/seg-000000.wal");
2419 let mut file = OpenOptions::new().append(true).open(old).unwrap();
2420 file.write_all(&[1, 2, 3]).unwrap();
2421 file.sync_all().unwrap();
2422
2423 assert!(matches!(
2424 SharedWal::replay(dir.path()),
2425 Err(MongrelError::CorruptWal { .. })
2426 ));
2427 }
2428
2429 #[test]
2430 fn shared_replay_rejects_records_after_commit() {
2431 let dir = tempdir().unwrap();
2432 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2433 wal.append_commit(7, Epoch(1), &[]).unwrap();
2434 wal.append(
2435 7,
2436 1,
2437 Op::Delete {
2438 table_id: 1,
2439 row_ids: vec![RowId(9)],
2440 },
2441 )
2442 .unwrap();
2443 wal.group_sync().unwrap();
2444 drop(wal);
2445
2446 assert!(matches!(
2447 SharedWal::replay(dir.path()),
2448 Err(MongrelError::CorruptWal { .. })
2449 ));
2450 }
2451
2452 #[test]
2453 fn rotate_without_explicit_group_sync_keeps_sequence_adjacent() {
2454 let dir = tempdir().unwrap();
2455 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2456 assert_eq!(
2457 wal.append_system(Op::Flush {
2458 table_id: 1,
2459 flushed_epoch: 1,
2460 })
2461 .unwrap(),
2462 1
2463 );
2464 wal.rotate(1).unwrap();
2465 assert_eq!(
2466 wal.append_system(Op::Flush {
2467 table_id: 1,
2468 flushed_epoch: 2,
2469 })
2470 .unwrap(),
2471 2
2472 );
2473 wal.group_sync().unwrap();
2474 let records = SharedWal::replay(dir.path()).unwrap();
2475 assert_eq!(
2476 records
2477 .iter()
2478 .map(|record| record.seq.0)
2479 .collect::<Vec<_>>(),
2480 vec![1, 2]
2481 );
2482 }
2483
2484 #[test]
2485 fn open_recovers_exact_header_only_segment_crash_window() {
2486 let dir = tempdir().unwrap();
2487 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2488 wal.append_commit(1, Epoch(1), &[]).unwrap();
2489 wal.group_sync().unwrap();
2490 drop(wal);
2491
2492 let root = crate::durable_file::DurableRoot::open(dir.path()).unwrap();
2493 let wal_root = root.open_directory("_wal").unwrap();
2494 let previous_hash = hash_segment(&wal_root, 0).unwrap();
2495 drop(Wal::create_chained_in(&wal_root, 1, Epoch(1), None, previous_hash).unwrap());
2496 assert_eq!(read_wal_head(&root, None).unwrap().unwrap().segment_no, 0);
2497
2498 let reopened = SharedWal::open(dir.path(), Epoch(1), None).unwrap();
2499 assert_eq!(reopened.active_segment_no(), 1);
2500 assert_eq!(list_segment_numbers(&wal_root).unwrap(), vec![0, 1]);
2501 assert_eq!(read_wal_head(&root, None).unwrap().unwrap().segment_no, 1);
2502 }
2503
2504 #[test]
2505 fn replay_rejects_sequence_reset_across_segments() {
2506 let dir = tempdir().unwrap();
2507 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2508 wal.append_commit(1, Epoch(1), &[]).unwrap();
2509 wal.group_sync().unwrap();
2510 wal.rotate(1).unwrap();
2511 wal.active.next_seq = 1;
2514 wal.append_commit(2, Epoch(2), &[]).unwrap();
2515 wal.group_sync().unwrap();
2516 drop(wal);
2517
2518 let error = SharedWal::replay(dir.path()).unwrap_err();
2519 assert!(
2520 matches!(error, MongrelError::CorruptWal { .. }),
2521 "expected CorruptWal, got {error:?}"
2522 );
2523 }
2524
2525 #[test]
2526 fn replay_rejects_backward_commit_epoch() {
2527 let dir = tempdir().unwrap();
2528 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2529 wal.append_commit(1, Epoch(2), &[]).unwrap();
2530 wal.append_commit(2, Epoch(1), &[]).unwrap();
2531 wal.group_sync().unwrap();
2532 drop(wal);
2533
2534 assert!(matches!(
2535 SharedWal::replay(dir.path()),
2536 Err(MongrelError::CorruptWal { .. })
2537 ));
2538 }
2539
2540 #[test]
2541 fn multiple_sessions_continue_one_global_sequence() {
2542 let dir = tempdir().unwrap();
2543 for session in 0..3_u64 {
2544 let mut wal = SharedWal::open(dir.path(), Epoch(session * 2), None)
2545 .unwrap_or_else(|_| SharedWal::create(dir.path(), Epoch(session * 2)).unwrap());
2546 assert_eq!(wal.active_segment_no(), session);
2547 for i in 0..2_u64 {
2548 let txn = session * 2 + i + 1;
2549 wal.append_commit(txn, Epoch(txn), &[]).unwrap();
2550 }
2551 wal.group_sync().unwrap();
2552 drop(wal);
2553 }
2554
2555 let records = SharedWal::replay(dir.path()).unwrap();
2556 let sequences: Vec<u64> = records.iter().map(|record| record.seq.0).collect();
2557 assert_eq!(sequences, (1..=12).collect::<Vec<u64>>());
2560
2561 let mut wal = SharedWal::open(dir.path(), Epoch(6), None).unwrap();
2564 assert_eq!(wal.active_segment_no(), 3);
2565 wal.append_commit(7, Epoch(7), &[]).unwrap();
2566 wal.group_sync().unwrap();
2567 drop(wal);
2568
2569 let records = SharedWal::replay(dir.path()).unwrap();
2570 let sequences: Vec<u64> = records.iter().map(|record| record.seq.0).collect();
2571 assert_eq!(
2572 sequences,
2573 (1..=sequences.len() as u64).collect::<Vec<u64>>(),
2574 "records stay globally contiguous across sessions"
2575 );
2576 }
2577
2578 #[test]
2579 fn head_and_strict_segment_names_detect_deletion_gaps_and_aliases() {
2580 let deleted = tempdir().unwrap();
2581 let wal = SharedWal::create(deleted.path(), Epoch(0)).unwrap();
2582 drop(wal);
2583 std::fs::remove_file(deleted.path().join("_wal/seg-000000.wal")).unwrap();
2584 assert!(SharedWal::replay(deleted.path()).is_err());
2585
2586 let alias = tempdir().unwrap();
2587 let wal = SharedWal::create(alias.path(), Epoch(0)).unwrap();
2588 drop(wal);
2589 std::fs::rename(
2590 alias.path().join("_wal/seg-000000.wal"),
2591 alias.path().join("_wal/seg-0.wal"),
2592 )
2593 .unwrap();
2594 assert!(SharedWal::replay(alias.path()).is_err());
2595
2596 let gap = tempdir().unwrap();
2597 let mut wal = SharedWal::create(gap.path(), Epoch(0)).unwrap();
2598 wal.rotate(1).unwrap();
2599 drop(wal);
2600 std::fs::rename(
2601 gap.path().join("_wal/seg-000001.wal"),
2602 gap.path().join("_wal/seg-000002.wal"),
2603 )
2604 .unwrap();
2605 assert!(SharedWal::replay(gap.path()).is_err());
2606 }
2607
2608 #[test]
2609 fn verify_and_gc_fail_closed_on_segment_corruption() {
2610 let dir = tempdir().unwrap();
2611 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2612 wal.append_commit(1, Epoch(1), &[]).unwrap();
2613 wal.group_sync().unwrap();
2614 wal.rotate(1).unwrap();
2615 wal.append_commit(2, Epoch(2), &[]).unwrap();
2616 wal.group_sync().unwrap();
2617
2618 let old = dir.path().join("_wal/seg-000000.wal");
2619 let mut bytes = std::fs::read(&old).unwrap();
2620 let last = bytes.len() - 1;
2621 bytes[last] ^= 0x40;
2622 std::fs::write(&old, bytes).unwrap();
2623 assert!(!wal.verify_segments().is_empty());
2624 assert!(wal.gc_segments(u64::MAX).is_err());
2625 assert!(old.exists());
2626 }
2627
2628 #[test]
2629 fn rotation_within_session_keeps_sequence_contiguous() {
2630 let dir = tempdir().unwrap();
2631 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2632 wal.append_commit(1, Epoch(1), &[]).unwrap();
2633 wal.rotate(1).unwrap();
2634 wal.append_commit(2, Epoch(2), &[]).unwrap();
2635 wal.rotate(2).unwrap();
2636 wal.append_commit(3, Epoch(3), &[]).unwrap();
2637 wal.group_sync().unwrap();
2638 drop(wal);
2639
2640 let records = SharedWal::replay(dir.path()).unwrap();
2641 let sequences: Vec<u64> = records.iter().map(|record| record.seq.0).collect();
2642 assert_eq!(
2643 sequences,
2644 (1..=sequences.len() as u64).collect::<Vec<u64>>()
2645 );
2646 assert_eq!(sequences.len(), 6, "two records per commit");
2647 }
2648
2649 #[test]
2650 fn open_truncates_garbage_beyond_the_authenticated_durable_prefix() {
2651 let dir = tempdir().unwrap();
2652 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2653 wal.append_commit(1, Epoch(1), &[]).unwrap();
2654 wal.group_sync().unwrap();
2655 drop(wal);
2656
2657 let seg = dir.path().join("_wal/seg-000000.wal");
2658 let published_len = std::fs::metadata(&seg).unwrap().len();
2659 let mut file = OpenOptions::new().append(true).open(&seg).unwrap();
2660 file.write_all(&[0xde, 0xad, 0xbe, 0xef, 1, 2, 3, 4, 5])
2661 .unwrap();
2662 file.sync_all().unwrap();
2663 drop(file);
2664
2665 let wal = SharedWal::open(dir.path(), Epoch(1), None).unwrap();
2668 drop(wal);
2669 assert_eq!(std::fs::metadata(&seg).unwrap().len(), published_len);
2670 let records = SharedWal::replay(dir.path()).unwrap();
2671 assert_eq!(records.len(), 2);
2672 assert!(records.iter().all(|record| record.seq.0 <= 2));
2673 }
2674
2675 #[test]
2676 fn open_rejects_damage_inside_the_authenticated_prefix() {
2677 let dir = tempdir().unwrap();
2678 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2679 wal.append_commit(1, Epoch(1), &[]).unwrap();
2680 wal.group_sync().unwrap();
2681 wal.rotate(1).unwrap();
2682 wal.append_commit(2, Epoch(2), &[]).unwrap();
2683 wal.group_sync().unwrap();
2684 drop(wal);
2685
2686 let seg = dir.path().join("_wal/seg-000000.wal");
2689 let mut bytes = std::fs::read(&seg).unwrap();
2690 bytes[(HEADER_LEN + 12) as usize] ^= 0x01;
2691 std::fs::write(&seg, bytes).unwrap();
2692
2693 assert!(matches!(
2694 SharedWal::replay(dir.path()),
2695 Err(MongrelError::CorruptWal { .. })
2696 ));
2697 assert!(SharedWal::open(dir.path(), Epoch(2), None).is_err());
2698 }
2699
2700 #[test]
2701 fn encrypted_sessions_continue_one_global_sequence() {
2702 let dek = || Zeroizing::new([7_u8; 32]);
2703 let dir = tempdir().unwrap();
2704 for session in 0..3_u64 {
2705 let mut wal = SharedWal::open(dir.path(), Epoch(session * 2), Some(dek()))
2706 .unwrap_or_else(|_| {
2707 SharedWal::create_with_dek(dir.path(), Epoch(session * 2), Some(dek())).unwrap()
2708 });
2709 for i in 0..2_u64 {
2710 let txn = session * 2 + i + 1;
2711 wal.append_commit(txn, Epoch(txn), &[]).unwrap();
2712 }
2713 wal.group_sync().unwrap();
2714 drop(wal);
2715 }
2716
2717 let records = SharedWal::replay_with_dek(dir.path(), Some(&dek())).unwrap();
2718 let sequences: Vec<u64> = records.iter().map(|record| record.seq.0).collect();
2719 assert_eq!(
2720 sequences,
2721 (1..=sequences.len() as u64).collect::<Vec<u64>>()
2722 );
2723
2724 let seg = dir.path().join("_wal/seg-000002.wal");
2726 let published_len = std::fs::metadata(&seg).unwrap().len();
2727 let mut file = OpenOptions::new().append(true).open(&seg).unwrap();
2728 file.write_all(&[9, 9, 9, 9, 9, 9, 9]).unwrap();
2729 file.sync_all().unwrap();
2730 drop(file);
2731 let wal = SharedWal::open(dir.path(), Epoch(6), Some(dek())).unwrap();
2732 drop(wal);
2733 assert_eq!(std::fs::metadata(&seg).unwrap().len(), published_len);
2734 }
2735}
2736
2737#[cfg(test)]
2738mod tests {
2739 use super::*;
2740 use tempfile::tempdir;
2741
2742 fn frame_ranges(bytes: &[u8]) -> Vec<std::ops::Range<usize>> {
2743 let mut ranges = Vec::new();
2744 let mut offset = HEADER_LEN as usize;
2745 while offset < bytes.len() {
2746 let mut length = [0_u8; 4];
2747 length.copy_from_slice(&bytes[offset..offset + 4]);
2748 let end = offset + 24 + u32::from_le_bytes(length) as usize;
2749 ranges.push(offset..end);
2750 offset = end;
2751 }
2752 ranges
2753 }
2754
2755 fn recompute_frame_crc(bytes: &mut [u8], start: usize) {
2756 let mut length = [0_u8; 4];
2757 length.copy_from_slice(&bytes[start..start + 4]);
2758 let length = u32::from_le_bytes(length) as usize;
2759 let seq = &bytes[start + 8..start + 16];
2760 let txn_id = &bytes[start + 16..start + 24];
2761 let payload = &bytes[start + 24..start + 24 + length];
2762 let mut digest = CRC32C.digest();
2763 digest.update(seq);
2764 digest.update(txn_id);
2765 digest.update(payload);
2766 bytes[start + 4..start + 8].copy_from_slice(&digest.finalize().to_le_bytes());
2767 }
2768
2769 #[test]
2770 fn append_then_replay_roundtrips() {
2771 let dir = tempdir().unwrap();
2772 let path = dir.path().join("seg-000000.wal");
2773 let mut wal = Wal::create(&path, Epoch(100)).unwrap();
2774 let s1 = wal
2775 .append_txn(
2776 7,
2777 Op::Put {
2778 table_id: 1,
2779 rows: vec![1, 2, 3],
2780 },
2781 )
2782 .unwrap();
2783 let s2 = wal
2784 .append_txn(
2785 7,
2786 Op::Delete {
2787 table_id: 1,
2788 row_ids: vec![RowId(7)],
2789 },
2790 )
2791 .unwrap();
2792 assert_eq!(s1, Epoch(101));
2793 assert_eq!(s2, Epoch(102));
2794 wal.sync().unwrap();
2795
2796 let records = replay(&path).unwrap();
2797 assert_eq!(records.len(), 2);
2798 assert_eq!(records[0].seq, Epoch(101));
2799 assert_eq!(records[0].txn_id, 7);
2800 match &records[0].op {
2801 Op::Put { table_id, rows } => {
2802 assert_eq!(*table_id, 1);
2803 assert_eq!(rows, &vec![1, 2, 3]);
2804 }
2805 other => panic!("unexpected op {other:?}"),
2806 }
2807 match &records[1].op {
2808 Op::Delete { row_ids, .. } => {
2809 assert_eq!(*row_ids, vec![RowId(7)]);
2810 }
2811 other => panic!("unexpected op {other:?}"),
2812 }
2813 }
2814
2815 #[test]
2816 fn record_roundtrips_with_txn_id_and_commit_marker() {
2817 let dir = tempdir().unwrap();
2818 let path = dir.path().join("seg-000000.wal");
2819 let mut w = Wal::create(&path, Epoch(0)).unwrap();
2820 w.append_txn(
2821 7,
2822 Op::Put {
2823 table_id: 3,
2824 rows: vec![1, 2, 3],
2825 },
2826 )
2827 .unwrap();
2828 w.append_txn(
2829 7,
2830 Op::TxnCommit {
2831 epoch: 11,
2832 added_runs: vec![],
2833 },
2834 )
2835 .unwrap();
2836 w.sync().unwrap();
2837 let recs = replay(&path).unwrap();
2838 assert_eq!(recs[0].txn_id, 7);
2839 assert!(matches!(recs[0].op, Op::Put { table_id: 3, .. }));
2840 assert!(matches!(recs[1].op, Op::TxnCommit { epoch: 11, .. }));
2841 let system_path = dir.path().join("seg-000001.wal");
2843 let mut w2 = Wal::create(&system_path, Epoch(0)).unwrap();
2844 w2.append_system(Op::Flush {
2845 table_id: 3,
2846 flushed_epoch: 11,
2847 })
2848 .unwrap();
2849 w2.sync().unwrap();
2850 let recs = replay(&system_path).unwrap();
2851 assert_eq!(recs[0].txn_id, SYSTEM_TXN_ID);
2852 assert!(matches!(recs[0].op, Op::Flush { .. }));
2853 }
2854
2855 #[test]
2856 fn catalog_snapshot_and_external_reset_roundtrip() {
2857 let dir = tempdir().unwrap();
2858 let path = dir.path().join("seg-catalog.wal");
2859 let mut wal = Wal::create(&path, Epoch(0)).unwrap();
2860 wal.append_txn(
2861 9,
2862 Op::Ddl(DdlOp::CatalogSnapshot {
2863 catalog_json: br#"{"db_epoch":7}"#.to_vec(),
2864 }),
2865 )
2866 .unwrap();
2867 wal.append_txn(
2868 9,
2869 Op::Ddl(DdlOp::ResetExternalTableState {
2870 name: "ext".into(),
2871 generation_epoch: 7,
2872 }),
2873 )
2874 .unwrap();
2875 wal.sync().unwrap();
2876
2877 let records = replay(&path).unwrap();
2878 assert!(matches!(
2879 &records[0].op,
2880 Op::Ddl(DdlOp::CatalogSnapshot { catalog_json })
2881 if catalog_json == br#"{"db_epoch":7}"#
2882 ));
2883 assert!(matches!(
2884 &records[1].op,
2885 Op::Ddl(DdlOp::ResetExternalTableState {
2886 name,
2887 generation_epoch: 7,
2888 }) if name == "ext"
2889 ));
2890 }
2891
2892 #[test]
2893 fn torn_write_is_detected() {
2894 let dir = tempdir().unwrap();
2895 let path = dir.path().join("seg-000001.wal");
2896 let mut wal = Wal::create(&path, Epoch(0)).unwrap();
2897 wal.append_txn(
2898 1,
2899 Op::Put {
2900 table_id: 1,
2901 rows: vec![0; 10],
2902 },
2903 )
2904 .unwrap();
2905 wal.sync().unwrap();
2906 drop(wal);
2907
2908 let mut f = OpenOptions::new().append(true).open(&path).unwrap();
2910 f.write_all(&64u32.to_le_bytes()).unwrap();
2912 f.write_all(&[0u8; 7]).unwrap();
2913 f.sync_all().unwrap();
2914 drop(f);
2915
2916 let mut reader = WalReader::open(&path).unwrap();
2917 assert!(reader.next_record().unwrap().is_some());
2919 let err = reader.next_record().unwrap_err();
2921 assert!(matches!(err, MongrelError::TornWrite { .. }), "got {err:?}");
2922 }
2923
2924 #[test]
2925 fn crc_corruption_is_detected() {
2926 let dir = tempdir().unwrap();
2927 let path = dir.path().join("seg-000002.wal");
2928 let mut wal = Wal::create(&path, Epoch(0)).unwrap();
2929 wal.append_txn(
2930 1,
2931 Op::Put {
2932 table_id: 9,
2933 rows: vec![1, 2, 3, 4],
2934 },
2935 )
2936 .unwrap();
2937 wal.sync().unwrap();
2938 drop(wal);
2939
2940 let mut bytes = std::fs::read(&path).unwrap();
2942 let last = bytes.len() - 1;
2943 bytes[last] ^= 0xFF;
2944 std::fs::write(&path, bytes).unwrap();
2945
2946 let err = WalReader::open(&path).unwrap().next_record().unwrap_err();
2947 assert!(
2948 matches!(err, MongrelError::CorruptWal { .. }),
2949 "got {err:?}"
2950 );
2951 }
2952
2953 #[test]
2954 fn trailing_torn_is_eof_but_interior_corruption_errors() {
2955 let dir = tempdir().unwrap();
2956
2957 let path_a = dir.path().join("seg-torn.wal");
2960 let mut wal = Wal::create(&path_a, Epoch(0)).unwrap();
2961 wal.append_txn(
2962 1,
2963 Op::Put {
2964 table_id: 1,
2965 rows: vec![1],
2966 },
2967 )
2968 .unwrap();
2969 wal.append_txn(
2970 1,
2971 Op::Put {
2972 table_id: 1,
2973 rows: vec![2],
2974 },
2975 )
2976 .unwrap();
2977 wal.sync().unwrap();
2978 drop(wal);
2979 let mut f = OpenOptions::new().append(true).open(&path_a).unwrap();
2981 f.write_all(&64u32.to_le_bytes()).unwrap();
2982 f.write_all(&[0u8; 7]).unwrap();
2983 f.sync_all().unwrap();
2984 drop(f);
2985 let recs = replay(&path_a).unwrap();
2986 assert_eq!(recs.len(), 2, "torn trailing frame must truncate cleanly");
2987
2988 let path_b = dir.path().join("seg-interior.wal");
2991 let mut wal = Wal::create(&path_b, Epoch(0)).unwrap();
2992 wal.append_txn(
2993 1,
2994 Op::Put {
2995 table_id: 1,
2996 rows: vec![10, 20, 30],
2997 },
2998 )
2999 .unwrap();
3000 wal.append_txn(
3001 1,
3002 Op::Put {
3003 table_id: 1,
3004 rows: vec![40],
3005 },
3006 )
3007 .unwrap();
3008 wal.sync().unwrap();
3009 drop(wal);
3010 let mut bytes = std::fs::read(&path_b).unwrap();
3013 let first_payload_byte = HEADER_LEN as usize + 4 + 4 + 8 + 8; bytes[first_payload_byte] ^= 0xFF;
3015 std::fs::write(&path_b, bytes).unwrap();
3016 let err = replay(&path_b).unwrap_err();
3017 assert!(
3018 matches!(err, MongrelError::CorruptWal { .. }),
3019 "interior corruption must error, got {err:?}"
3020 );
3021
3022 let path_c = dir.path().join("seg-badtail.wal");
3025 let mut wal = Wal::create(&path_c, Epoch(0)).unwrap();
3026 wal.append_txn(
3027 1,
3028 Op::Put {
3029 table_id: 1,
3030 rows: vec![5],
3031 },
3032 )
3033 .unwrap();
3034 wal.sync().unwrap();
3035 drop(wal);
3036 let mut bytes = std::fs::read(&path_c).unwrap();
3037 let last = bytes.len() - 1;
3038 bytes[last] ^= 0xFF;
3039 std::fs::write(&path_c, bytes).unwrap();
3040 assert!(matches!(
3041 replay(&path_c),
3042 Err(MongrelError::CorruptWal { .. })
3043 ));
3044 }
3045
3046 #[test]
3047 fn byte_threshold_auto_syncs() {
3048 let dir = tempdir().unwrap();
3049 let path = dir.path().join("seg-000003.wal");
3050 let mut wal = Wal::create(&path, Epoch(0)).unwrap();
3051 wal.sync_byte_threshold = 1; wal.append_txn(
3053 1,
3054 Op::Put {
3055 table_id: 1,
3056 rows: vec![0; 5],
3057 },
3058 )
3059 .unwrap();
3060 assert_eq!(
3061 wal.unflushed_bytes(),
3062 0,
3063 "threshold should have auto-synced"
3064 );
3065 }
3066
3067 #[test]
3068 fn create_never_replaces_an_existing_segment() {
3069 let dir = tempdir().unwrap();
3070 let path = dir.path().join("seg-000000.wal");
3071 drop(Wal::create(&path, Epoch(0)).unwrap());
3072 let before = std::fs::read(&path).unwrap();
3073 assert!(matches!(
3074 Wal::create(&path, Epoch(0)),
3075 Err(MongrelError::Io(error)) if error.kind() == std::io::ErrorKind::AlreadyExists
3076 ));
3077 assert_eq!(std::fs::read(path).unwrap(), before);
3078 }
3079
3080 #[test]
3081 fn zero_length_frame_never_hides_a_wal_suffix() {
3082 let dir = tempdir().unwrap();
3083 let path = dir.path().join("seg-000000.wal");
3084 let mut wal = Wal::create(&path, Epoch(0)).unwrap();
3085 wal.append_system(Op::Flush {
3086 table_id: 1,
3087 flushed_epoch: 1,
3088 })
3089 .unwrap();
3090 wal.sync().unwrap();
3091 drop(wal);
3092 let original = std::fs::read(&path).unwrap();
3093 let mut bytes = original[..HEADER_LEN as usize].to_vec();
3094 bytes.extend_from_slice(&0_u32.to_le_bytes());
3095 bytes.extend_from_slice(&original[HEADER_LEN as usize..]);
3096 std::fs::write(&path, bytes).unwrap();
3097 assert!(matches!(
3098 replay(&path),
3099 Err(MongrelError::CorruptWal { .. })
3100 ));
3101 }
3102
3103 #[test]
3104 fn reader_rejects_outer_inner_record_identity_mismatch() {
3105 let dir = tempdir().unwrap();
3106 let path = dir.path().join("seg-000000.wal");
3107 let mut wal = Wal::create(&path, Epoch(0)).unwrap();
3108 wal.append_system(Op::Flush {
3109 table_id: 1,
3110 flushed_epoch: 1,
3111 })
3112 .unwrap();
3113 wal.sync().unwrap();
3114 drop(wal);
3115
3116 let mut bytes = std::fs::read(&path).unwrap();
3117 let start = HEADER_LEN as usize;
3118 let mut length = [0_u8; 4];
3119 length.copy_from_slice(&bytes[start..start + 4]);
3120 let length = u32::from_le_bytes(length) as usize;
3121 let payload = &bytes[start + 24..start + 24 + length];
3122 let mut record: Record = bincode::deserialize(payload).unwrap();
3123 record.seq = Epoch(99);
3124 let replacement = bincode::serialize(&record).unwrap();
3125 assert_eq!(replacement.len(), length);
3126 bytes[start + 24..start + 24 + length].copy_from_slice(&replacement);
3127 recompute_frame_crc(&mut bytes, start);
3128 std::fs::write(&path, bytes).unwrap();
3129
3130 assert!(matches!(
3131 WalReader::open(&path).unwrap().next_record(),
3132 Err(MongrelError::CorruptWal { .. })
3133 ));
3134 }
3135
3136 #[test]
3137 fn encrypted_frames_reject_reorder_replay_deletion_and_cross_segment_move() {
3138 fn cipher(key: &[u8; 32]) -> Box<dyn crate::encryption::Cipher> {
3139 Box::new(crate::encryption::AesCipher::new(key).unwrap())
3140 }
3141
3142 let dir = tempdir().unwrap();
3143 let key = [0x5a; 32];
3144 let path = dir.path().join("seg-000009.wal");
3145 let mut wal = Wal::create_with_cipher(&path, Epoch(0), Some(cipher(&key)), 9).unwrap();
3146 for table_id in [1, 2] {
3147 wal.append_system(Op::Flush {
3148 table_id,
3149 flushed_epoch: table_id,
3150 })
3151 .unwrap();
3152 }
3153 wal.sync().unwrap();
3154 drop(wal);
3155 let original = std::fs::read(&path).unwrap();
3156 let ranges = frame_ranges(&original);
3157 assert_eq!(ranges.len(), 2);
3158
3159 let mut reordered = original[..HEADER_LEN as usize].to_vec();
3160 reordered.extend_from_slice(&original[ranges[1].clone()]);
3161 reordered.extend_from_slice(&original[ranges[0].clone()]);
3162 std::fs::write(&path, reordered).unwrap();
3163 assert!(replay_with_cipher(&path, Some(cipher(&key))).is_err());
3164
3165 let mut replayed = original[..HEADER_LEN as usize].to_vec();
3166 replayed.extend_from_slice(&original[ranges[0].clone()]);
3167 replayed.extend_from_slice(&original[ranges[0].clone()]);
3168 replayed.extend_from_slice(&original[ranges[1].clone()]);
3169 std::fs::write(&path, replayed).unwrap();
3170 assert!(replay_with_cipher(&path, Some(cipher(&key))).is_err());
3171
3172 let mut deleted = original[..HEADER_LEN as usize].to_vec();
3173 deleted.extend_from_slice(&original[ranges[1].clone()]);
3174 std::fs::write(&path, deleted).unwrap();
3175 assert!(replay_with_cipher(&path, Some(cipher(&key))).is_err());
3176
3177 let mut outer_tampered = original.clone();
3178 outer_tampered[ranges[0].start + 8] ^= 0x01;
3179 recompute_frame_crc(&mut outer_tampered, ranges[0].start);
3180 std::fs::write(&path, outer_tampered).unwrap();
3181 assert!(replay_with_cipher(&path, Some(cipher(&key))).is_err());
3182
3183 let other = dir.path().join("seg-000010.wal");
3184 let mut wal = Wal::create_with_cipher(&other, Epoch(0), Some(cipher(&key)), 10).unwrap();
3185 wal.append_system(Op::Flush {
3186 table_id: 3,
3187 flushed_epoch: 3,
3188 })
3189 .unwrap();
3190 wal.sync().unwrap();
3191 drop(wal);
3192 let mut moved = std::fs::read(&other).unwrap();
3193 moved.truncate(HEADER_LEN as usize);
3194 moved.extend_from_slice(&original[ranges[0].clone()]);
3195 std::fs::write(&other, moved).unwrap();
3196 assert!(replay_with_cipher(&other, Some(cipher(&key))).is_err());
3197 }
3198
3199 #[test]
3200 fn wal_nonce_is_segment_deterministic() {
3201 assert_ne!(frame_nonce_for(5, 0), frame_nonce_for(6, 0));
3204 assert_ne!(frame_nonce_for(5, 0), frame_nonce_for(5, 1));
3205 assert_eq!(frame_nonce_for(5, 0), frame_nonce_for(5, 0));
3208 }
3209}