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