1use crate::epoch::Epoch;
9use crate::manifest::TtlPolicy;
10use crate::rowid::RowId;
11use crate::schema::{ColumnDef, Schema};
12use crate::{MongrelError, Result};
13use crc::{Crc, CRC_32_ISCSI};
14#[cfg(feature = "encryption")]
15use hmac::{Hmac, Mac};
16use serde::{Deserialize, Serialize};
17use sha2::{Digest as _, Sha256};
18use std::fs::{File, OpenOptions};
19use std::io::{BufReader, BufWriter, Read, Write};
20use std::path::{Path, PathBuf};
21use std::sync::Arc;
22use zeroize::Zeroizing;
23
24fn unix_nanos_now() -> u64 {
25 std::time::SystemTime::now()
26 .duration_since(std::time::UNIX_EPOCH)
27 .unwrap_or_default()
28 .as_nanos() as u64
29}
30
31pub const WAL_MAGIC: [u8; 8] = *b"MONGRWAL";
32const WAL_VERSION: u16 = 4;
35const HEADER_LEN: u64 = 8 + 2 + 4 + 8 + 8 + 32;
36const WAL_FRAME_AAD_DOMAIN: &[u8] = b"mongreldb/wal-frame/v4";
37const WAL_HEAD_MAGIC: [u8; 8] = *b"MONGWHED";
38const WAL_HEAD_VERSION: u16 = 1;
39const WAL_HEAD_FILENAME: &str = "wal-head-v1";
40const WAL_HEAD_AUTH_DOMAIN: &[u8] = b"mongreldb/wal-head/v1";
41const WAL_HEAD_BODY_LEN: usize = 72;
42const WAL_HEAD_LEN: usize = WAL_HEAD_BODY_LEN + 32;
43const MAX_RECOVERY_WAL_BYTES: u64 = 512 * 1024 * 1024;
44const MAX_RECOVERY_WAL_RECORDS: usize = 1_000_000;
45const ENC_PLAINTEXT: u8 = 0;
47const ENC_AES_GCM: u8 = 1;
48
49#[derive(Clone, Copy, Debug)]
50struct WalHead {
51 segment_no: u64,
52 durable_len: u64,
53 open_generation: u64,
54 prefix_hash: [u8; 32],
55}
56
57pub const SYSTEM_TXN_ID: u64 = 0;
60
61const CRC32C: Crc<u32> = Crc::<u32>::new(&CRC_32_ISCSI);
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct Record {
69 pub seq: Epoch,
70 pub txn_id: u64,
71 pub op: Op,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct AddedRun {
78 pub table_id: u64,
79 pub run_id: u128,
80 pub row_count: u64,
81 pub level: u8,
82 pub min_row_id: u64,
83 pub max_row_id: u64,
84 pub content_hash: [u8; 32],
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
92pub enum DdlOp {
93 CreateTable {
94 table_id: u64,
95 name: String,
96 schema_json: Vec<u8>,
97 },
98 DropTable {
99 table_id: u64,
100 },
101 AlterTable {
104 table_id: u64,
105 column_json: Vec<u8>,
106 },
107 RenameTable {
113 table_id: u64,
114 new_name: String,
115 },
116 SetTtl {
118 table_id: u64,
119 policy_json: Vec<u8>,
120 },
121 SetMaterializedView {
124 name: String,
125 definition_json: Vec<u8>,
126 },
127 SetSecurityCatalog {
129 security_json: Vec<u8>,
130 },
131 CreateBuildingTable {
134 table_id: u64,
135 build_name: String,
136 intended_name: String,
137 query_id: String,
138 created_at_unix_nanos: u64,
139 schema_json: Vec<u8>,
140 },
141 PublishBuildingTable {
143 table_id: u64,
144 new_name: String,
145 },
146 CreateRebuildingTable {
149 table_id: u64,
150 build_name: String,
151 intended_name: String,
152 query_id: String,
153 created_at_unix_nanos: u64,
154 replaces_table_id: u64,
155 schema_json: Vec<u8>,
156 },
157 ReplaceBuildingTable {
159 table_id: u64,
160 replaced_table_id: u64,
161 new_name: String,
162 },
163 SetSqlPragma {
166 key: String,
167 value: i64,
168 },
169 CatalogSnapshot {
173 catalog_json: Vec<u8>,
174 },
175 ResetExternalTableState {
178 name: String,
179 generation_epoch: u64,
180 },
181}
182
183impl DdlOp {
184 pub fn encode_schema(schema: &Schema) -> Result<Vec<u8>> {
186 serde_json::to_vec(schema).map_err(|e| MongrelError::Other(format!("schema json: {e}")))
187 }
188
189 pub fn decode_schema(bytes: &[u8]) -> Result<Schema> {
191 serde_json::from_slice(bytes).map_err(|e| MongrelError::Other(format!("schema json: {e}")))
192 }
193
194 pub fn encode_column(column: &ColumnDef) -> Result<Vec<u8>> {
195 serde_json::to_vec(column).map_err(|e| MongrelError::Other(format!("column json: {e}")))
196 }
197
198 pub fn decode_column(bytes: &[u8]) -> Result<ColumnDef> {
199 serde_json::from_slice(bytes).map_err(|e| MongrelError::Other(format!("column json: {e}")))
200 }
201
202 pub fn encode_ttl(policy: Option<TtlPolicy>) -> Result<Vec<u8>> {
203 serde_json::to_vec(&policy).map_err(|e| MongrelError::Other(format!("TTL json: {e}")))
204 }
205
206 pub fn decode_ttl(bytes: &[u8]) -> Result<Option<TtlPolicy>> {
207 serde_json::from_slice(bytes).map_err(|e| MongrelError::Other(format!("TTL json: {e}")))
208 }
209
210 pub fn encode_materialized_view(
211 definition: &crate::catalog::MaterializedViewEntry,
212 ) -> Result<Vec<u8>> {
213 serde_json::to_vec(definition)
214 .map_err(|e| MongrelError::Other(format!("materialized view json: {e}")))
215 }
216
217 pub fn decode_materialized_view(bytes: &[u8]) -> Result<crate::catalog::MaterializedViewEntry> {
218 serde_json::from_slice(bytes)
219 .map_err(|e| MongrelError::Other(format!("materialized view json: {e}")))
220 }
221
222 pub fn encode_catalog(catalog: &crate::catalog::Catalog) -> Result<Vec<u8>> {
223 crate::catalog::encode(catalog)
224 }
225
226 pub fn decode_catalog(bytes: &[u8]) -> Result<crate::catalog::Catalog> {
227 crate::catalog::decode(bytes)
228 }
229
230 pub fn encode_security(security: &crate::security::SecurityCatalog) -> Result<Vec<u8>> {
231 serde_json::to_vec(security)
232 .map_err(|e| MongrelError::Other(format!("security catalog json: {e}")))
233 }
234
235 pub fn decode_security(bytes: &[u8]) -> Result<crate::security::SecurityCatalog> {
236 serde_json::from_slice(bytes)
237 .map_err(|e| MongrelError::Other(format!("security catalog json: {e}")))
238 }
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub enum Op {
243 Put {
244 table_id: u64,
245 rows: Vec<u8>,
246 },
247 Delete {
248 table_id: u64,
249 row_ids: Vec<RowId>,
250 },
251 TruncateTable {
252 table_id: u64,
253 },
254 ExternalTableState {
258 name: String,
259 state: Vec<u8>,
260 },
261 Flush {
265 table_id: u64,
266 flushed_epoch: u64,
267 },
268 TxnCommit {
271 epoch: u64,
272 added_runs: Vec<AddedRun>,
273 },
274 TxnAbort,
276 Ddl(DdlOp),
277 BeforeImage {
281 table_id: u64,
282 row_id: RowId,
283 row: Vec<u8>,
284 },
285 CommitTimestamp {
288 unix_nanos: u64,
289 },
290 SpilledRows {
295 table_id: u64,
296 rows: Vec<u8>,
297 },
298}
299
300impl Record {
301 pub fn new(seq: Epoch, txn_id: u64, op: Op) -> Self {
302 Self { seq, txn_id, op }
303 }
304}
305
306pub struct Wal {
309 file: BufWriter<File>,
310 path: PathBuf,
311 next_seq: u64,
313 unflushed_bytes: u64,
314 sync_byte_threshold: u64,
316 cipher: Option<Box<dyn crate::encryption::Cipher>>,
319 segment_no: u64,
326 frame_seq: u64,
332 previous_segment_hash: [u8; 32],
333 header_binding: [u8; 32],
334}
335
336impl Wal {
337 pub fn create(path: impl AsRef<Path>, epoch_created: Epoch) -> Result<Self> {
339 let path = path.as_ref();
340 let segment_no = segment_number_from_path(path).unwrap_or(0);
341 Self::create_with_cipher(path, epoch_created, None, segment_no)
342 }
343
344 pub fn create_with_cipher(
348 path: impl AsRef<Path>,
349 epoch_created: Epoch,
350 cipher: Option<Box<dyn crate::encryption::Cipher>>,
351 segment_no: u64,
352 ) -> Result<Self> {
353 Self::create_chained(path, epoch_created, cipher, segment_no, [0; 32])
354 }
355
356 fn create_chained(
357 path: impl AsRef<Path>,
358 epoch_created: Epoch,
359 cipher: Option<Box<dyn crate::encryption::Cipher>>,
360 segment_no: u64,
361 previous_segment_hash: [u8; 32],
362 ) -> Result<Self> {
363 let path = path.as_ref().to_path_buf();
364 let file = OpenOptions::new()
365 .create_new(true)
366 .read(true)
367 .write(true)
368 .open(&path)?;
369 let wal = Self::create_chained_from_file(
370 file,
371 path,
372 epoch_created,
373 cipher,
374 segment_no,
375 previous_segment_hash,
376 )?;
377 if let Some(parent) = wal.path.parent() {
378 crate::durable_file::sync_directory(parent)?;
379 }
380 Ok(wal)
381 }
382
383 fn create_chained_in(
384 wal_root: &crate::durable_file::DurableRoot,
385 segment_no: u64,
386 epoch_created: Epoch,
387 cipher: Option<Box<dyn crate::encryption::Cipher>>,
388 previous_segment_hash: [u8; 32],
389 ) -> Result<Self> {
390 let name = segment_filename(segment_no);
391 let file = wal_root.create_regular_new(&name)?;
392 let wal = Self::create_chained_from_file(
393 file,
394 wal_root.canonical_path().join(&name),
395 epoch_created,
396 cipher,
397 segment_no,
398 previous_segment_hash,
399 )?;
400 wal_root.sync_entry_parent(&name)?;
401 Ok(wal)
402 }
403
404 fn create_chained_from_file(
405 file: File,
406 path: PathBuf,
407 epoch_created: Epoch,
408 cipher: Option<Box<dyn crate::encryption::Cipher>>,
409 segment_no: u64,
410 previous_segment_hash: [u8; 32],
411 ) -> Result<Self> {
412 let next_seq = epoch_created
413 .0
414 .checked_add(1)
415 .ok_or_else(|| MongrelError::Full("WAL sequence namespace exhausted".into()))?;
416 let mut wal = Self {
417 file: BufWriter::with_capacity(1 << 20, file),
418 path,
419 next_seq,
420 unflushed_bytes: 0,
421 sync_byte_threshold: 64 * 1024,
422 cipher,
423 segment_no,
424 frame_seq: 0,
425 previous_segment_hash,
426 header_binding: [0; 32],
427 };
428 wal.write_header(epoch_created)?;
429 wal.sync()?;
433 Ok(wal)
434 }
435
436 pub fn append_txn(&mut self, txn_id: u64, op: Op) -> Result<Epoch> {
443 let seq = Epoch(self.next_seq);
444 let next_seq = self
445 .next_seq
446 .checked_add(1)
447 .ok_or_else(|| MongrelError::Full("WAL sequence namespace exhausted".into()))?;
448 self.append_record(&Record::new(seq, txn_id, op))?;
449 self.next_seq = next_seq;
450 Ok(seq)
451 }
452
453 pub fn append_system(&mut self, op: Op) -> Result<Epoch> {
455 self.append_txn(SYSTEM_TXN_ID, op)
456 }
457
458 fn append_record(&mut self, record: &Record) -> Result<()> {
459 let payload = bincode::serialize(record)?;
460
461 let frame_payload = if let Some(cipher) = &self.cipher {
464 if self.frame_seq > u32::MAX as u64 {
469 return Err(MongrelError::Full(
470 "wal segment frame counter exhausted (2^32); rotate the segment".into(),
471 ));
472 }
473 let nonce = self.frame_nonce();
474 let ciphertext_len = payload.len().checked_add(16).ok_or_else(|| {
475 MongrelError::InvalidArgument("wal payload length overflow".into())
476 })?;
477 let aad = wal_frame_aad(
478 &self.header_binding,
479 self.segment_no,
480 self.frame_seq as u32,
481 record.seq.0,
482 record.txn_id,
483 ciphertext_len as u64,
484 );
485 let ciphertext = cipher.encrypt_page_with_aad(&nonce, &payload, &aad)?;
486 self.frame_seq += 1;
487 ciphertext
488 } else {
489 payload
490 };
491
492 let len = frame_payload.len();
493 if len > u32::MAX as usize {
494 return Err(MongrelError::InvalidArgument(format!(
495 "wal payload too large: {len} bytes"
496 )));
497 }
498 let mut digest = CRC32C.digest();
500 digest.update(&record.seq.0.to_le_bytes());
501 digest.update(&record.txn_id.to_le_bytes());
502 digest.update(&frame_payload);
503 let crc_val = digest.finalize();
504
505 self.file.write_all(&(len as u32).to_le_bytes())?;
506 self.file.write_all(&crc_val.to_le_bytes())?;
507 self.file.write_all(&record.seq.0.to_le_bytes())?;
508 self.file.write_all(&record.txn_id.to_le_bytes())?;
509 self.file.write_all(&frame_payload)?;
510 self.unflushed_bytes += 4 + 4 + 8 + 8 + len as u64;
511 if self.sync_byte_threshold > 0 && self.unflushed_bytes >= self.sync_byte_threshold {
512 self.sync()?;
513 }
514 Ok(())
515 }
516
517 fn frame_nonce(&self) -> [u8; 12] {
523 frame_nonce_for(self.segment_no, self.frame_seq as u32)
524 }
525
526 pub fn sync(&mut self) -> Result<()> {
528 self.file.flush()?;
529 self.file.get_ref().sync_all()?;
530 self.unflushed_bytes = 0;
531 Ok(())
532 }
533
534 #[inline]
536 pub fn unflushed_bytes(&self) -> u64 {
537 self.unflushed_bytes
538 }
539
540 #[inline]
543 pub fn next_seq_val(&self) -> u64 {
544 self.next_seq
545 }
546
547 pub fn set_sync_byte_threshold(&mut self, threshold: u64) {
552 self.sync_byte_threshold = threshold;
553 }
554
555 pub fn path(&self) -> &Path {
556 &self.path
557 }
558
559 pub(crate) fn publish_as(mut self, destination: PathBuf) -> Result<Self> {
562 self.sync()?;
563 crate::durable_file::rename(&self.path, &destination)?;
564 self.path = destination;
565 Ok(self)
566 }
567
568 fn write_header(&mut self, epoch_created: Epoch) -> Result<()> {
569 let enc_flag = if self.cipher.is_some() {
570 ENC_AES_GCM
571 } else {
572 ENC_PLAINTEXT
573 };
574 let header = encode_wal_header(
575 enc_flag,
576 epoch_created.0,
577 self.segment_no,
578 &self.previous_segment_hash,
579 );
580 self.header_binding = Sha256::digest(&header).into();
581 self.file.write_all(&header)?;
582 self.unflushed_bytes = 0;
583 Ok(())
584 }
585}
586
587impl Drop for Wal {
588 fn drop(&mut self) {
589 let _ = self.file.flush();
590 }
591}
592
593fn encode_wal_header(
594 encryption: u8,
595 epoch_created: u64,
596 segment_no: u64,
597 previous_segment_hash: &[u8; 32],
598) -> Vec<u8> {
599 let mut header = Vec::with_capacity(HEADER_LEN as usize);
600 header.extend_from_slice(&WAL_MAGIC);
601 header.extend_from_slice(&WAL_VERSION.to_le_bytes());
602 header.extend_from_slice(&[encryption, 0, 0, 0]);
603 header.extend_from_slice(&epoch_created.to_le_bytes());
604 header.extend_from_slice(&segment_no.to_le_bytes());
605 header.extend_from_slice(previous_segment_hash);
606 header
607}
608
609fn wal_frame_aad(
610 header_binding: &[u8; 32],
611 segment_no: u64,
612 frame_seq: u32,
613 record_seq: u64,
614 txn_id: u64,
615 ciphertext_len: u64,
616) -> Vec<u8> {
617 let mut aad = Vec::with_capacity(WAL_FRAME_AAD_DOMAIN.len() + 68);
618 aad.extend_from_slice(WAL_FRAME_AAD_DOMAIN);
619 aad.extend_from_slice(header_binding);
620 aad.extend_from_slice(&segment_no.to_le_bytes());
621 aad.extend_from_slice(&frame_seq.to_le_bytes());
622 aad.extend_from_slice(&record_seq.to_le_bytes());
623 aad.extend_from_slice(&txn_id.to_le_bytes());
624 aad.extend_from_slice(&ciphertext_len.to_le_bytes());
625 aad
626}
627
628fn wal_head_body(head: &WalHead, encrypted: bool) -> [u8; WAL_HEAD_BODY_LEN] {
629 let mut body = [0_u8; WAL_HEAD_BODY_LEN];
630 body[..8].copy_from_slice(&WAL_HEAD_MAGIC);
631 body[8..10].copy_from_slice(&WAL_HEAD_VERSION.to_le_bytes());
632 body[10] = if encrypted {
633 ENC_AES_GCM
634 } else {
635 ENC_PLAINTEXT
636 };
637 body[16..24].copy_from_slice(&head.segment_no.to_le_bytes());
638 body[24..32].copy_from_slice(&head.durable_len.to_le_bytes());
639 body[32..40].copy_from_slice(&head.open_generation.to_le_bytes());
640 body[40..72].copy_from_slice(&head.prefix_hash);
641 body
642}
643
644fn wal_head_auth(
645 body: &[u8; WAL_HEAD_BODY_LEN],
646 wal_dek: Option<&Zeroizing<[u8; 32]>>,
647) -> Result<[u8; 32]> {
648 if let Some(key) = wal_dek {
649 #[cfg(feature = "encryption")]
650 {
651 let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(&key[..])
652 .map_err(|error| MongrelError::Encryption(error.to_string()))?;
653 mac.update(WAL_HEAD_AUTH_DOMAIN);
654 mac.update(body);
655 return Ok(mac.finalize().into_bytes().into());
656 }
657 #[cfg(not(feature = "encryption"))]
658 {
659 let _ = key;
660 return Err(MongrelError::Encryption(
661 "encryption feature disabled but a WAL DEK was supplied".into(),
662 ));
663 }
664 }
665 let mut hash = Sha256::new();
666 hash.update(WAL_HEAD_AUTH_DOMAIN);
667 hash.update(body);
668 Ok(hash.finalize().into())
669}
670
671fn encode_wal_head(
672 head: &WalHead,
673 wal_dek: Option<&Zeroizing<[u8; 32]>>,
674) -> Result<[u8; WAL_HEAD_LEN]> {
675 let body = wal_head_body(head, wal_dek.is_some());
676 let auth = wal_head_auth(&body, wal_dek)?;
677 let mut encoded = [0_u8; WAL_HEAD_LEN];
678 encoded[..WAL_HEAD_BODY_LEN].copy_from_slice(&body);
679 encoded[WAL_HEAD_BODY_LEN..].copy_from_slice(&auth);
680 Ok(encoded)
681}
682
683fn decode_wal_head(encoded: &[u8], wal_dek: Option<&Zeroizing<[u8; 32]>>) -> Result<WalHead> {
684 if encoded.len() != WAL_HEAD_LEN {
685 return Err(MongrelError::CorruptWal {
686 offset: 0,
687 reason: format!(
688 "WAL head is {} bytes, expected {WAL_HEAD_LEN}",
689 encoded.len()
690 ),
691 });
692 }
693 let body: &[u8; WAL_HEAD_BODY_LEN] =
694 encoded[..WAL_HEAD_BODY_LEN]
695 .try_into()
696 .map_err(|_| MongrelError::CorruptWal {
697 offset: 0,
698 reason: "invalid WAL head body".into(),
699 })?;
700 if body[..8] != WAL_HEAD_MAGIC {
701 return Err(MongrelError::CorruptWal {
702 offset: 0,
703 reason: "invalid WAL head magic".into(),
704 });
705 }
706 let version = u16::from_le_bytes([body[8], body[9]]);
707 if version != WAL_HEAD_VERSION {
708 return Err(MongrelError::CorruptWal {
709 offset: 8,
710 reason: format!("unsupported WAL head version {version}"),
711 });
712 }
713 let expected_mode = if wal_dek.is_some() {
714 ENC_AES_GCM
715 } else {
716 ENC_PLAINTEXT
717 };
718 if body[10] != expected_mode || body[11..16] != [0; 5] {
719 return Err(MongrelError::CorruptWal {
720 offset: 10,
721 reason: "WAL head authentication mode or reserved bytes differ".into(),
722 });
723 }
724 let expected_auth = wal_head_auth(body, wal_dek)?;
725 if encoded[WAL_HEAD_BODY_LEN..] != expected_auth {
726 return Err(MongrelError::CorruptWal {
727 offset: WAL_HEAD_BODY_LEN as u64,
728 reason: "WAL head authentication failed".into(),
729 });
730 }
731 let mut prefix_hash = [0_u8; 32];
732 prefix_hash.copy_from_slice(&body[40..72]);
733 let mut segment_no = [0_u8; 8];
734 segment_no.copy_from_slice(&body[16..24]);
735 let mut durable_len = [0_u8; 8];
736 durable_len.copy_from_slice(&body[24..32]);
737 let mut open_generation = [0_u8; 8];
738 open_generation.copy_from_slice(&body[32..40]);
739 Ok(WalHead {
740 segment_no: u64::from_le_bytes(segment_no),
741 durable_len: u64::from_le_bytes(durable_len),
742 open_generation: u64::from_le_bytes(open_generation),
743 prefix_hash,
744 })
745}
746
747fn hash_file_prefix(file: File, length: u64) -> Result<[u8; 32]> {
748 if file.metadata()?.len() < length {
749 return Err(MongrelError::CorruptWal {
750 offset: length,
751 reason: "WAL file is shorter than its durable head".into(),
752 });
753 }
754 let mut reader = BufReader::new(file).take(length);
755 let mut hash = Sha256::new();
756 std::io::copy(&mut reader, &mut HashWriter(&mut hash))?;
757 Ok(hash.finalize().into())
758}
759
760struct HashWriter<'a>(&'a mut Sha256);
761
762impl Write for HashWriter<'_> {
763 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
764 self.0.update(bytes);
765 Ok(bytes.len())
766 }
767
768 fn flush(&mut self) -> std::io::Result<()> {
769 Ok(())
770 }
771}
772
773fn hash_segment(wal_root: &crate::durable_file::DurableRoot, segment_no: u64) -> Result<[u8; 32]> {
774 let name = segment_filename(segment_no);
775 let file = wal_root.open_regular(&name)?;
776 let length = file.metadata()?.len();
777 hash_file_prefix(file, length)
778}
779
780fn read_wal_head(
781 root: &crate::durable_file::DurableRoot,
782 wal_dek: Option<&Zeroizing<[u8; 32]>>,
783) -> Result<Option<WalHead>> {
784 let relative = Path::new("_meta").join(WAL_HEAD_FILENAME);
785 match root.entry_exists(&relative) {
786 Ok(true) => {}
787 Ok(false) => return Ok(None),
788 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
789 Err(error) => return Err(error.into()),
790 }
791 let mut file = root.open_regular(&relative)?;
792 let length = file.metadata()?.len();
793 if length != WAL_HEAD_LEN as u64 {
794 return Err(MongrelError::CorruptWal {
795 offset: 0,
796 reason: format!("WAL head is {length} bytes, expected {WAL_HEAD_LEN}"),
797 });
798 }
799 let mut encoded = [0_u8; WAL_HEAD_LEN];
800 file.read_exact(&mut encoded)?;
801 decode_wal_head(&encoded, wal_dek).map(Some)
802}
803
804fn write_wal_head(
805 root: &crate::durable_file::DurableRoot,
806 wal_root: &crate::durable_file::DurableRoot,
807 segment_no: u64,
808 open_generation: u64,
809 wal_dek: Option<&Zeroizing<[u8; 32]>>,
810) -> Result<WalHead> {
811 let name = segment_filename(segment_no);
812 let file = wal_root.open_regular(&name)?;
813 let durable_len = file.metadata()?.len();
814 let head = WalHead {
815 segment_no,
816 durable_len,
817 open_generation,
818 prefix_hash: hash_file_prefix(file, durable_len)?,
819 };
820 root.create_directory_all("_meta")?;
821 root.write_atomic(
822 Path::new("_meta").join(WAL_HEAD_FILENAME),
823 &encode_wal_head(&head, wal_dek)?,
824 )?;
825 Ok(head)
826}
827
828pub struct WalReader {
832 inner: BufReader<File>,
833 pos: u64,
834 file_len: u64,
835 encrypted: bool,
837 cipher: Option<Box<dyn crate::encryption::Cipher>>,
839 version: u16,
840 segment_no: u64,
841 previous_segment_hash: [u8; 32],
842 header_binding: [u8; 32],
843 frame_seq: u64,
844}
845
846impl WalReader {
847 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
848 Self::open_with_cipher(path, None)
849 }
850
851 pub fn open_with_cipher(
853 path: impl AsRef<Path>,
854 cipher: Option<Box<dyn crate::encryption::Cipher>>,
855 ) -> Result<Self> {
856 Self::open_with_cipher_expected(path, cipher, None)
857 }
858
859 fn open_with_cipher_expected(
860 path: impl AsRef<Path>,
861 cipher: Option<Box<dyn crate::encryption::Cipher>>,
862 expected_segment_no: Option<u64>,
863 ) -> Result<Self> {
864 let path = path.as_ref();
865 let expected_segment_no = expected_segment_no.or_else(|| segment_number_from_path(path));
866 let file = crate::durable_file::open_regular_nofollow(path)?;
867 Self::open_file_with_cipher_expected(file, cipher, expected_segment_no)
868 }
869
870 fn open_file_with_cipher_expected(
871 mut file: File,
872 cipher: Option<Box<dyn crate::encryption::Cipher>>,
873 expected_segment_no: Option<u64>,
874 ) -> Result<Self> {
875 let file_len = file.metadata()?.len();
876 let mut magic = [0u8; 8];
877 file.read_exact(&mut magic)?;
878 if magic != WAL_MAGIC {
879 return Err(MongrelError::MagicMismatch {
880 what: "wal",
881 expected: WAL_MAGIC,
882 got: magic,
883 });
884 }
885 let mut version_buf = [0u8; 2];
886 file.read_exact(&mut version_buf)?;
887 let version = u16::from_le_bytes(version_buf);
888 if version != WAL_VERSION {
889 return Err(MongrelError::UnsupportedStorageVersion {
890 component: "wal",
891 found: version,
892 supported: WAL_VERSION,
893 });
894 }
895 let mut reserved = [0u8; 4];
896 file.read_exact(&mut reserved)?;
897 if !matches!(reserved[0], ENC_PLAINTEXT | ENC_AES_GCM) || reserved[1..] != [0, 0, 0] {
898 return Err(MongrelError::CorruptWal {
899 offset: 10,
900 reason: "invalid WAL header flags".into(),
901 });
902 }
903 let encrypted = reserved[0] == ENC_AES_GCM;
904 let mut epoch_buf = [0u8; 8];
905 file.read_exact(&mut epoch_buf)?;
906 let _epoch_created = Epoch(u64::from_le_bytes(epoch_buf));
907 let mut previous_segment_hash = [0_u8; 32];
908 let segment_no = {
909 let mut segment = [0_u8; 8];
910 file.read_exact(&mut segment)?;
911 file.read_exact(&mut previous_segment_hash)?;
912 u64::from_le_bytes(segment)
913 };
914 if expected_segment_no.is_some_and(|expected| expected != segment_no) {
915 return Err(MongrelError::CorruptWal {
916 offset: 0,
917 reason: format!(
918 "WAL header segment {segment_no} does not match filename segment {}",
919 expected_segment_no.unwrap()
920 ),
921 });
922 }
923 let pos = HEADER_LEN;
924 let header_binding = Sha256::digest(encode_wal_header(
925 reserved[0],
926 _epoch_created.0,
927 segment_no,
928 &previous_segment_hash,
929 ))
930 .into();
931 if encrypted && cipher.is_none() {
932 return Err(MongrelError::Decryption(
933 "WAL is encrypted but no passphrase or key was provided. \
934 Use Table::open_encrypted or Table::open_with_key."
935 .into(),
936 ));
937 }
938 Ok(Self {
939 inner: BufReader::new(file),
940 pos,
941 file_len,
942 encrypted,
943 cipher,
944 version,
945 segment_no,
946 previous_segment_hash,
947 header_binding,
948 frame_seq: 0,
949 })
950 }
951
952 pub fn next_record(&mut self) -> Result<Option<Record>> {
955 if self.pos == self.file_len {
956 return Ok(None);
957 }
958 if self.file_len.saturating_sub(self.pos) < 4 {
959 return Err(MongrelError::TornWrite { offset: self.pos });
960 }
961 let mut len_buf = [0u8; 4];
962 match self.inner.read_exact(&mut len_buf) {
963 Ok(()) => {}
964 Err(ref e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
965 return Err(MongrelError::TornWrite { offset: self.pos });
966 }
967 Err(e) => return Err(e.into()),
968 }
969 let len = u32::from_le_bytes(len_buf) as usize;
970 if len == 0 {
971 return Err(MongrelError::CorruptWal {
972 offset: self.pos,
973 reason: "zero-length WAL frames are not valid".into(),
974 });
975 }
976 const MAX_RECORD_LEN: usize = 64 * 1024 * 1024;
979 if len > MAX_RECORD_LEN {
980 return Err(MongrelError::CorruptWal {
981 offset: self.pos,
982 reason: format!("WAL frame length {len} exceeds limit"),
983 });
984 }
985
986 let record_start = self.pos;
987 let mut rest = vec![0u8; 4 + 8 + 8 + len];
988 match self.inner.read_exact(&mut rest) {
989 Ok(()) => {}
990 Err(ref e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
991 return Err(MongrelError::TornWrite {
992 offset: record_start,
993 });
994 }
995 Err(e) => return Err(e.into()),
996 }
997 let crc_val = u32::from_le_bytes([rest[0], rest[1], rest[2], rest[3]]);
998 let seq = u64::from_le_bytes([
999 rest[4], rest[5], rest[6], rest[7], rest[8], rest[9], rest[10], rest[11],
1000 ]);
1001 let txn_id = u64::from_le_bytes([
1002 rest[12], rest[13], rest[14], rest[15], rest[16], rest[17], rest[18], rest[19],
1003 ]);
1004 let payload = &rest[20..];
1005
1006 let mut digest = CRC32C.digest();
1007 digest.update(&seq.to_le_bytes());
1008 digest.update(&txn_id.to_le_bytes());
1009 digest.update(payload);
1010 if digest.finalize() != crc_val {
1011 return Err(MongrelError::CorruptWal {
1012 offset: record_start,
1013 reason: "crc mismatch".into(),
1014 });
1015 }
1016
1017 let plaintext = if self.encrypted {
1019 let Some(cipher) = &self.cipher else {
1020 return Err(MongrelError::Decryption(
1021 "WAL is encrypted but no cipher was provided".into(),
1022 ));
1023 };
1024 let minimum = 16;
1025 if payload.len() < minimum {
1026 return Err(MongrelError::CorruptWal {
1027 offset: record_start,
1028 reason: "encrypted frame too short".into(),
1029 });
1030 }
1031 if self.frame_seq > u32::MAX as u64 {
1032 return Err(MongrelError::CorruptWal {
1033 offset: record_start,
1034 reason: "WAL frame counter exceeds u32".into(),
1035 });
1036 }
1037 let expected_nonce = frame_nonce_for(self.segment_no, self.frame_seq as u32);
1038 let aad = wal_frame_aad(
1039 &self.header_binding,
1040 self.segment_no,
1041 self.frame_seq as u32,
1042 seq,
1043 txn_id,
1044 payload.len() as u64,
1045 );
1046 cipher
1047 .decrypt_page_with_aad(&expected_nonce, payload, &aad)
1048 .map_err(|e| {
1049 MongrelError::Decryption(format!(
1050 "WAL frame decryption failed — wrong passphrase or key? ({e})"
1051 ))
1052 })?
1053 } else {
1054 payload.to_vec()
1055 };
1056
1057 let record: Record =
1058 bincode::deserialize(&plaintext).map_err(|error| MongrelError::CorruptWal {
1059 offset: record_start,
1060 reason: format!("WAL record decode failed: {error}"),
1061 })?;
1062 if record.seq.0 != seq || record.txn_id != txn_id {
1063 return Err(MongrelError::CorruptWal {
1064 offset: record_start,
1065 reason: "WAL outer and authenticated record identity differ".into(),
1066 });
1067 }
1068 self.frame_seq += 1;
1069 self.pos += 4 + 4 + 8 + 8 + len as u64;
1070 Ok(Some(record))
1071 }
1072
1073 fn constrain_to_durable_len(&mut self, durable_len: u64) -> Result<()> {
1074 if durable_len < self.pos || durable_len > self.file_len {
1075 return Err(MongrelError::CorruptWal {
1076 offset: durable_len,
1077 reason: format!(
1078 "WAL durable length {durable_len} is outside [{}, {}]",
1079 self.pos, self.file_len
1080 ),
1081 });
1082 }
1083 self.file_len = durable_len;
1084 Ok(())
1085 }
1086
1087 pub fn replay(&mut self) -> Result<Vec<Record>> {
1093 self.replay_with_tail_policy(true)
1094 }
1095
1096 fn replay_strict(&mut self) -> Result<Vec<Record>> {
1097 self.replay_with_tail_policy(false)
1098 }
1099
1100 fn replay_bounded(&mut self, max_records: usize, allow_torn_tail: bool) -> Result<Vec<Record>> {
1101 let mut out = Vec::new();
1102 loop {
1103 match self.next_record() {
1104 Ok(Some(record)) => {
1105 if out.len() >= max_records {
1106 return Err(MongrelError::ResourceLimitExceeded {
1107 resource: "WAL recovery records",
1108 requested: max_records.saturating_add(1),
1109 limit: max_records,
1110 });
1111 }
1112 out.push(record);
1113 }
1114 Ok(None) => break,
1115 Err(MongrelError::TornWrite { offset }) => {
1116 if !allow_torn_tail {
1117 return Err(MongrelError::CorruptWal {
1118 offset,
1119 reason: "torn tail in a non-final WAL segment".into(),
1120 });
1121 }
1122 if self.valid_frame_follows()? {
1123 return Err(MongrelError::CorruptWal {
1124 offset,
1125 reason: "interior torn frame followed by a valid frame".into(),
1126 });
1127 }
1128 break;
1129 }
1130 Err(error @ MongrelError::CorruptWal { .. }) => return Err(error),
1131 Err(error) => return Err(error),
1132 }
1133 }
1134 Ok(out)
1135 }
1136
1137 fn replay_with_tail_policy(&mut self, allow_torn_tail: bool) -> Result<Vec<Record>> {
1138 let mut out = Vec::new();
1139 loop {
1140 match self.next_record() {
1141 Ok(Some(rec)) => out.push(rec),
1142 Ok(None) => break,
1143 Err(MongrelError::TornWrite { offset }) => {
1144 if !allow_torn_tail {
1145 return Err(MongrelError::CorruptWal {
1146 offset,
1147 reason: "torn tail in a non-final WAL segment".into(),
1148 });
1149 }
1150 if self.valid_frame_follows()? {
1153 return Err(MongrelError::CorruptWal {
1154 offset,
1155 reason: "interior torn frame followed by a valid frame".into(),
1156 });
1157 }
1158 break;
1159 }
1160 Err(error @ MongrelError::CorruptWal { .. }) => return Err(error),
1161 Err(e) => return Err(e),
1162 }
1163 }
1164 Ok(out)
1165 }
1166
1167 pub fn replay_controlled(
1169 &mut self,
1170 control: &crate::ExecutionControl,
1171 max_records: usize,
1172 ) -> Result<Vec<Record>> {
1173 self.replay_controlled_with_tail_policy(control, max_records, true)
1174 }
1175
1176 fn replay_controlled_strict(
1177 &mut self,
1178 control: &crate::ExecutionControl,
1179 max_records: usize,
1180 ) -> Result<Vec<Record>> {
1181 self.replay_controlled_with_tail_policy(control, max_records, false)
1182 }
1183
1184 fn replay_controlled_with_tail_policy(
1185 &mut self,
1186 control: &crate::ExecutionControl,
1187 max_records: usize,
1188 allow_torn_tail: bool,
1189 ) -> Result<Vec<Record>> {
1190 let mut out = Vec::new();
1191 loop {
1192 if out.len() % 256 == 0 {
1193 control.checkpoint()?;
1194 }
1195 match self.next_record() {
1196 Ok(Some(record)) => {
1197 if out.len() >= max_records {
1198 return Err(MongrelError::ResourceLimitExceeded {
1199 resource: "controlled WAL replay records",
1200 requested: max_records.saturating_add(1),
1201 limit: max_records,
1202 });
1203 }
1204 out.push(record);
1205 }
1206 Ok(None) => break,
1207 Err(MongrelError::TornWrite { offset }) => {
1208 if !allow_torn_tail {
1209 return Err(MongrelError::CorruptWal {
1210 offset,
1211 reason: "torn tail in a non-final WAL segment".into(),
1212 });
1213 }
1214 if self.valid_frame_follows()? {
1215 return Err(MongrelError::CorruptWal {
1216 offset,
1217 reason: "interior torn frame followed by a valid frame".into(),
1218 });
1219 }
1220 break;
1221 }
1222 Err(error @ MongrelError::CorruptWal { .. }) => return Err(error),
1223 Err(error) => return Err(error),
1224 }
1225 }
1226 control.checkpoint()?;
1227 Ok(out)
1228 }
1229
1230 fn valid_frame_follows(&mut self) -> Result<bool> {
1235 match self.next_record() {
1236 Ok(Some(_)) => Ok(true),
1237 Ok(None) => Ok(false),
1238 Err(_) => Ok(false),
1239 }
1240 }
1241
1242 pub fn current_offset(&self) -> u64 {
1245 self.pos
1246 }
1247}
1248
1249pub fn replay(path: impl AsRef<Path>) -> Result<Vec<Record>> {
1252 WalReader::open(path)?.replay()
1253}
1254
1255pub fn replay_with_cipher(
1257 path: impl AsRef<Path>,
1258 cipher: Option<Box<dyn crate::encryption::Cipher>>,
1259) -> Result<Vec<Record>> {
1260 WalReader::open_with_cipher(path, cipher)?.replay()
1261}
1262
1263pub fn frame_nonce_for(segment_no: u64, frame: u32) -> [u8; 12] {
1268 let mut n = [0u8; 12];
1269 n[..8].copy_from_slice(&segment_no.to_be_bytes());
1270 n[8..].copy_from_slice(&frame.to_le_bytes());
1271 n
1272}
1273
1274pub struct SharedWal {
1280 root: Arc<crate::durable_file::DurableRoot>,
1281 wal_root: Arc<crate::durable_file::DurableRoot>,
1282 wal_dir: PathBuf,
1283 active: Wal,
1284 active_segment_no: u64,
1286 durable_seq: u64,
1289 open_generation: u64,
1291 wal_dek: Option<Zeroizing<[u8; 32]>>,
1294 group_sync_count: u64,
1298}
1299
1300#[derive(Default)]
1301struct ReplayTxnState {
1302 timestamp_seen: bool,
1303 terminal: bool,
1304}
1305
1306struct WalLayout {
1307 segments: Vec<u64>,
1308 head: Option<WalHead>,
1309}
1310
1311fn reader_for_segment(
1312 wal_root: &crate::durable_file::DurableRoot,
1313 segment_no: u64,
1314 wal_dek: Option<&Zeroizing<[u8; 32]>>,
1315) -> Result<WalReader> {
1316 let cipher = match wal_dek {
1317 #[cfg(feature = "encryption")]
1318 Some(key) => Some(SharedWal::cipher_from_dek(key)?),
1319 #[cfg(not(feature = "encryption"))]
1320 Some(_) => {
1321 return Err(MongrelError::Encryption(
1322 "encryption feature disabled but a WAL DEK was supplied".into(),
1323 ));
1324 }
1325 None => None,
1326 };
1327 let file = wal_root.open_regular(segment_filename(segment_no))?;
1328 WalReader::open_file_with_cipher_expected(file, cipher, Some(segment_no))
1329}
1330
1331fn inspect_wal_layout(
1332 root: &crate::durable_file::DurableRoot,
1333 wal_root: &crate::durable_file::DurableRoot,
1334 wal_dek: Option<&Zeroizing<[u8; 32]>>,
1335) -> Result<WalLayout> {
1336 let segments = list_segment_numbers(wal_root)?;
1337 let head = read_wal_head(root, wal_dek)?;
1338 for (index, segment_no) in segments.iter().copied().enumerate() {
1339 let reader = reader_for_segment(wal_root, segment_no, wal_dek)?;
1340 if reader.encrypted != wal_dek.is_some() {
1341 return Err(MongrelError::CorruptWal {
1342 offset: 10,
1343 reason: format!(
1344 "WAL segment {segment_no} encryption mode differs from the database"
1345 ),
1346 });
1347 }
1348 if index != 0 {
1349 let previous_no = segments[index - 1];
1350 let expected = hash_segment(wal_root, previous_no)?;
1351 if reader.previous_segment_hash != expected {
1352 return Err(MongrelError::CorruptWal {
1353 offset: 30,
1354 reason: format!(
1355 "WAL segment {segment_no} does not authenticate segment {previous_no}"
1356 ),
1357 });
1358 }
1359 }
1360 }
1361
1362 if !segments.is_empty() && head.is_none() {
1363 return Err(MongrelError::CorruptWal {
1364 offset: 0,
1365 reason: "WAL segments exist without a durable WAL head".into(),
1366 });
1367 }
1368 if segments.is_empty() && head.is_some() {
1369 return Err(MongrelError::CorruptWal {
1370 offset: 0,
1371 reason: "a durable WAL head exists without any WAL segment".into(),
1372 });
1373 }
1374 if let Some(head) = head {
1375 let final_segment = segments
1376 .last()
1377 .copied()
1378 .ok_or_else(|| MongrelError::CorruptWal {
1379 offset: 0,
1380 reason: "durable WAL head references an empty WAL directory".into(),
1381 })?;
1382 if head.segment_no != final_segment {
1383 return Err(MongrelError::CorruptWal {
1384 offset: head.segment_no,
1385 reason: format!(
1386 "durable WAL head references segment {}, newest retained segment is {final_segment}",
1387 head.segment_no
1388 ),
1389 });
1390 }
1391 let file = wal_root.open_regular(segment_filename(head.segment_no))?;
1392 let actual_len = file.metadata()?.len();
1393 if head.durable_len > actual_len {
1394 return Err(MongrelError::CorruptWal {
1395 offset: head.durable_len,
1396 reason: format!(
1397 "durable WAL head length {} exceeds segment length {actual_len}",
1398 head.durable_len
1399 ),
1400 });
1401 }
1402 if hash_file_prefix(file, head.durable_len)? != head.prefix_hash {
1403 return Err(MongrelError::CorruptWal {
1404 offset: head.durable_len,
1405 reason: "durable WAL prefix hash differs from WAL head".into(),
1406 });
1407 }
1408 }
1409 Ok(WalLayout { segments, head })
1410}
1411
1412fn remove_unpublished_header_only_segment(
1413 root: &crate::durable_file::DurableRoot,
1414 wal_root: &crate::durable_file::DurableRoot,
1415 wal_dek: Option<&Zeroizing<[u8; 32]>>,
1416) -> Result<bool> {
1417 let Some(head) = read_wal_head(root, wal_dek)? else {
1418 return Ok(false);
1419 };
1420 let segments = list_segment_numbers(wal_root)?;
1421 let Some(newest) = segments.last().copied() else {
1422 return Ok(false);
1423 };
1424 let Some(orphan_no) = head.segment_no.checked_add(1) else {
1425 return Ok(false);
1426 };
1427 if newest != orphan_no || !segments.contains(&head.segment_no) {
1428 return Ok(false);
1429 }
1430 let reader = reader_for_segment(wal_root, orphan_no, wal_dek)?;
1431 if reader.version != WAL_VERSION
1432 || reader.file_len != HEADER_LEN
1433 || reader.previous_segment_hash != hash_segment(wal_root, head.segment_no)?
1434 {
1435 return Ok(false);
1436 }
1437 wal_root.remove_file(segment_filename(orphan_no))?;
1438 Ok(true)
1439}
1440
1441struct ReplayedSegment {
1445 segment_no: u64,
1446 records: Vec<Record>,
1447}
1448
1449fn validate_v4_sequence_continuity(segments: &[ReplayedSegment]) -> Result<()> {
1454 let mut previous: Option<(u64, u64)> = None;
1455 for segment in segments {
1456 for record in &segment.records {
1457 if let Some((previous_seq, previous_segment)) = previous {
1458 let expected =
1459 previous_seq
1460 .checked_add(1)
1461 .ok_or_else(|| MongrelError::CorruptWal {
1462 offset: record.seq.0,
1463 reason: "WAL sequence overflows after u64::MAX".into(),
1464 })?;
1465 if record.seq.0 != expected {
1466 let reason = if segment.segment_no == previous_segment {
1467 format!(
1468 "WAL segment {} sequence {} does not follow {previous_seq}",
1469 segment.segment_no, record.seq.0
1470 )
1471 } else {
1472 format!(
1473 "WAL segment {} begins with sequence {}, expected {expected} after segment {previous_segment}",
1474 segment.segment_no, record.seq.0
1475 )
1476 };
1477 return Err(MongrelError::CorruptWal {
1478 offset: record.seq.0,
1479 reason,
1480 });
1481 }
1482 }
1483 previous = Some((record.seq.0, segment.segment_no));
1484 }
1485 }
1486 Ok(())
1487}
1488
1489fn replay_wal_layout(
1490 wal_root: &crate::durable_file::DurableRoot,
1491 layout: &WalLayout,
1492 wal_dek: Option<&Zeroizing<[u8; 32]>>,
1493) -> Result<Vec<Record>> {
1494 let total_bytes = layout
1495 .segments
1496 .iter()
1497 .try_fold(0_u64, |total, segment_no| {
1498 let bytes = if layout
1499 .head
1500 .is_some_and(|head| head.segment_no == *segment_no)
1501 {
1502 layout.head.unwrap().durable_len
1503 } else {
1504 wal_root
1505 .open_regular(segment_filename(*segment_no))?
1506 .metadata()?
1507 .len()
1508 };
1509 total
1510 .checked_add(bytes)
1511 .ok_or(MongrelError::ResourceLimitExceeded {
1512 resource: "WAL recovery bytes",
1513 requested: usize::MAX,
1514 limit: MAX_RECOVERY_WAL_BYTES as usize,
1515 })
1516 })?;
1517 if total_bytes > MAX_RECOVERY_WAL_BYTES {
1518 return Err(MongrelError::ResourceLimitExceeded {
1519 resource: "WAL recovery bytes",
1520 requested: usize::try_from(total_bytes).unwrap_or(usize::MAX),
1521 limit: MAX_RECOVERY_WAL_BYTES as usize,
1522 });
1523 }
1524 let mut segments = Vec::with_capacity(layout.segments.len());
1525 let mut total_records = 0_usize;
1526 for segment_no in layout.segments.iter().copied() {
1527 let mut reader = reader_for_segment(wal_root, segment_no, wal_dek)?;
1528 if layout
1529 .head
1530 .is_some_and(|head| head.segment_no == segment_no)
1531 {
1532 reader.constrain_to_durable_len(layout.head.unwrap().durable_len)?;
1533 }
1534 let remaining = MAX_RECOVERY_WAL_RECORDS.saturating_sub(total_records);
1535 let records = reader.replay_bounded(remaining, false)?;
1539 total_records += records.len();
1540 segments.push(ReplayedSegment {
1541 segment_no,
1542 records,
1543 });
1544 }
1545 validate_v4_sequence_continuity(&segments)?;
1546 let records: Vec<Record> = segments
1547 .into_iter()
1548 .flat_map(|segment| segment.records)
1549 .collect();
1550 validate_shared_transaction_framing(&records)?;
1551 Ok(records)
1552}
1553
1554pub(crate) fn validate_shared_transaction_framing(records: &[Record]) -> Result<()> {
1559 let mut transactions = std::collections::HashMap::<u64, ReplayTxnState>::new();
1560 let mut commit_epochs = std::collections::HashMap::<u64, u64>::new();
1561 let mut previous_commit_epoch: Option<u64> = None;
1562 for record in records {
1563 if record.txn_id == SYSTEM_TXN_ID {
1564 if !matches!(record.op, Op::Flush { .. }) {
1565 return Err(MongrelError::CorruptWal {
1566 offset: record.seq.0,
1567 reason: "non-system operation uses reserved transaction id 0".into(),
1568 });
1569 }
1570 continue;
1571 }
1572 let state = transactions.entry(record.txn_id).or_default();
1573 if state.terminal {
1574 return Err(MongrelError::CorruptWal {
1575 offset: record.seq.0,
1576 reason: format!(
1577 "transaction {} has records after its terminal marker",
1578 record.txn_id
1579 ),
1580 });
1581 }
1582 match record.op {
1583 Op::CommitTimestamp { .. } => {
1584 if state.timestamp_seen {
1585 return Err(MongrelError::CorruptWal {
1586 offset: record.seq.0,
1587 reason: format!(
1588 "transaction {} has duplicate commit timestamps",
1589 record.txn_id
1590 ),
1591 });
1592 }
1593 state.timestamp_seen = true;
1594 }
1595 Op::TxnCommit { epoch, .. } => {
1596 if epoch == 0 {
1597 return Err(MongrelError::CorruptWal {
1598 offset: record.seq.0,
1599 reason: format!("transaction {} commits at epoch 0", record.txn_id),
1600 });
1601 }
1602 if let Some(previous) = commit_epochs.insert(epoch, record.txn_id) {
1603 return Err(MongrelError::CorruptWal {
1604 offset: record.seq.0,
1605 reason: format!(
1606 "transactions {previous} and {} share commit epoch {epoch}",
1607 record.txn_id
1608 ),
1609 });
1610 }
1611 if previous_commit_epoch.is_some_and(|previous| epoch <= previous) {
1612 return Err(MongrelError::CorruptWal {
1613 offset: record.seq.0,
1614 reason: format!(
1615 "commit epoch {epoch} does not advance beyond {}",
1616 previous_commit_epoch.unwrap_or(0)
1617 ),
1618 });
1619 }
1620 previous_commit_epoch = Some(epoch);
1621 state.terminal = true;
1622 }
1623 Op::TxnAbort => state.terminal = true,
1624 Op::Flush { .. } => {
1625 return Err(MongrelError::CorruptWal {
1626 offset: record.seq.0,
1627 reason: format!(
1628 "transaction {} contains a system flush record",
1629 record.txn_id
1630 ),
1631 });
1632 }
1633 _ => {}
1634 }
1635 }
1636 Ok(())
1637}
1638
1639impl SharedWal {
1640 #[cfg(feature = "encryption")]
1642 fn cipher_from_dek(dek: &Zeroizing<[u8; 32]>) -> Result<Box<dyn crate::encryption::Cipher>> {
1643 Ok(Box::new(crate::encryption::AesCipher::new(&dek[..])?))
1644 }
1645
1646 pub fn create(root: &Path, epoch_created: Epoch) -> Result<Self> {
1648 Self::create_with_dek(root, epoch_created, None)
1649 }
1650
1651 pub fn create_with_dek(
1653 root: &Path,
1654 epoch_created: Epoch,
1655 wal_dek: Option<Zeroizing<[u8; 32]>>,
1656 ) -> Result<Self> {
1657 let root = Arc::new(crate::durable_file::DurableRoot::open(root)?);
1658 Self::create_with_durable_root(root, epoch_created, wal_dek)
1659 }
1660
1661 pub(crate) fn create_with_durable_root(
1662 root: Arc<crate::durable_file::DurableRoot>,
1663 epoch_created: Epoch,
1664 wal_dek: Option<Zeroizing<[u8; 32]>>,
1665 ) -> Result<Self> {
1666 let wal_root = Arc::new(root.create_directory_all_pinned("_wal")?);
1667 let wal_dir = wal_root.io_path()?;
1668 if !list_segment_numbers(&wal_root)?.is_empty() {
1669 return Err(MongrelError::CorruptWal {
1670 offset: 0,
1671 reason: "refuses to create a shared WAL over existing segments".into(),
1672 });
1673 }
1674 let cipher = match &wal_dek {
1675 #[cfg(feature = "encryption")]
1676 Some(dk) => Some(Self::cipher_from_dek(dk)?),
1677 #[cfg(not(feature = "encryption"))]
1678 Some(_) => {
1679 return Err(MongrelError::Encryption(
1680 "encryption feature disabled but a WAL DEK was supplied".into(),
1681 ))
1682 }
1683 None => None,
1684 };
1685 let active = Wal::create_chained_in(&wal_root, 0, epoch_created, cipher, [0; 32])?;
1686 if let Err(error) = write_wal_head(&root, &wal_root, 0, 0, wal_dek.as_ref()) {
1687 drop(active);
1688 let _ = wal_root.remove_file(segment_filename(0));
1689 return Err(error);
1690 }
1691 Ok(Self {
1692 root,
1693 wal_root,
1694 wal_dir,
1695 active,
1696 active_segment_no: 0,
1697 durable_seq: epoch_created.0,
1698 open_generation: 0,
1699 wal_dek,
1700 group_sync_count: 0,
1701 })
1702 }
1703
1704 pub fn open(
1709 root: &Path,
1710 epoch_created: Epoch,
1711 wal_dek: Option<Zeroizing<[u8; 32]>>,
1712 ) -> Result<Self> {
1713 let root = Arc::new(crate::durable_file::DurableRoot::open(root)?);
1714 Self::open_durable_root(root, epoch_created, wal_dek)
1715 }
1716
1717 pub(crate) fn open_durable_root(
1718 root: Arc<crate::durable_file::DurableRoot>,
1719 epoch_created: Epoch,
1720 wal_dek: Option<Zeroizing<[u8; 32]>>,
1721 ) -> Result<Self> {
1722 Self::open_durable_root_validated(root, epoch_created, wal_dek, None)
1723 }
1724
1725 pub(crate) fn open_durable_root_validated(
1726 root: Arc<crate::durable_file::DurableRoot>,
1727 epoch_created: Epoch,
1728 wal_dek: Option<Zeroizing<[u8; 32]>>,
1729 expected_records: Option<&[Record]>,
1730 ) -> Result<Self> {
1731 let wal_root =
1732 Arc::new(
1733 root.open_directory("_wal")
1734 .map_err(|error| MongrelError::CorruptWal {
1735 offset: 0,
1736 reason: format!(
1737 "existing database WAL directory cannot be opened: {error}"
1738 ),
1739 })?,
1740 );
1741 let wal_dir = wal_root.io_path()?;
1742 if let Some(expected) = expected_records {
1743 let layout = inspect_wal_layout(&root, &wal_root, wal_dek.as_ref())?;
1744 let actual = replay_wal_layout(&wal_root, &layout, wal_dek.as_ref())?;
1745 if bincode::serialize(&actual)? != bincode::serialize(expected)? {
1746 return Err(MongrelError::CorruptWal {
1747 offset: 0,
1748 reason: "WAL changed after recovery planning".into(),
1749 });
1750 }
1751 }
1752 remove_unpublished_header_only_segment(&root, &wal_root, wal_dek.as_ref())?;
1753 let layout = inspect_wal_layout(&root, &wal_root, wal_dek.as_ref())?;
1754 let final_segment =
1755 layout
1756 .segments
1757 .last()
1758 .copied()
1759 .ok_or_else(|| MongrelError::CorruptWal {
1760 offset: 0,
1761 reason: "existing database has no WAL segments".into(),
1762 })?;
1763 let records = replay_wal_layout(&wal_root, &layout, wal_dek.as_ref())?;
1764 let open_generation = layout
1765 .head
1766 .map(|head| head.open_generation)
1767 .unwrap_or_else(|| {
1768 records
1769 .iter()
1770 .filter(|record| record.txn_id != SYSTEM_TXN_ID)
1771 .map(|record| record.txn_id >> 32)
1772 .max()
1773 .unwrap_or(0)
1774 });
1775
1776 let durable_len = layout
1780 .head
1781 .ok_or_else(|| MongrelError::CorruptWal {
1782 offset: 0,
1783 reason: "validated WAL layout has no durable WAL head".into(),
1784 })?
1785 .durable_len;
1786 let final_name = segment_filename(final_segment);
1787 let final_file = wal_root.open_regular_read_write(&final_name)?;
1788 if final_file.metadata()?.len() != durable_len {
1789 final_file.set_len(durable_len)?;
1790 final_file.sync_all()?;
1791 }
1792 let previous_segment_hash = hash_segment(&wal_root, final_segment)?;
1793 let next_segment_no = final_segment
1794 .checked_add(1)
1795 .ok_or_else(|| MongrelError::Full("WAL segment namespace exhausted".into()))?;
1796 let durable_seq = records
1799 .last()
1800 .map(|record| record.seq.0)
1801 .unwrap_or(epoch_created.0);
1802 let cipher = match &wal_dek {
1803 #[cfg(feature = "encryption")]
1804 Some(dk) => Some(Self::cipher_from_dek(dk)?),
1805 #[cfg(not(feature = "encryption"))]
1806 Some(_) => {
1807 return Err(MongrelError::Encryption(
1808 "encryption feature disabled but a WAL DEK was supplied".into(),
1809 ))
1810 }
1811 None => None,
1812 };
1813 let mut active = Wal::create_chained_in(
1814 &wal_root,
1815 next_segment_no,
1816 epoch_created,
1817 cipher,
1818 previous_segment_hash,
1819 )?;
1820 active.next_seq = durable_seq
1821 .checked_add(1)
1822 .ok_or_else(|| MongrelError::Full("WAL sequence namespace exhausted".into()))?;
1823 if let Err(error) = write_wal_head(
1824 &root,
1825 &wal_root,
1826 next_segment_no,
1827 open_generation,
1828 wal_dek.as_ref(),
1829 ) {
1830 drop(active);
1831 let _ = wal_root.remove_file(segment_filename(next_segment_no));
1832 return Err(error);
1833 }
1834 Ok(Self {
1835 root,
1836 wal_root,
1837 wal_dir,
1838 active,
1839 active_segment_no: next_segment_no,
1840 durable_seq,
1841 open_generation,
1842 wal_dek,
1843 group_sync_count: 0,
1844 })
1845 }
1846
1847 #[allow(dead_code)]
1849 pub fn wal_dir(&self) -> &Path {
1850 &self.wal_dir
1851 }
1852
1853 pub fn append(&mut self, txn_id: u64, _table_id: u64, op: Op) -> Result<u64> {
1855 Ok(self.active.append_txn(txn_id, op)?.0)
1856 }
1857
1858 pub fn append_commit(&mut self, txn_id: u64, epoch: Epoch, added: &[AddedRun]) -> Result<u64> {
1860 self.append_commit_at(txn_id, epoch, added, unix_nanos_now())
1861 }
1862
1863 pub fn append_commit_at(
1864 &mut self,
1865 txn_id: u64,
1866 epoch: Epoch,
1867 added: &[AddedRun],
1868 unix_nanos: u64,
1869 ) -> Result<u64> {
1870 self.active
1871 .append_txn(txn_id, Op::CommitTimestamp { unix_nanos })?;
1872 Ok(self
1873 .active
1874 .append_txn(
1875 txn_id,
1876 Op::TxnCommit {
1877 epoch: epoch.0,
1878 added_runs: added.to_vec(),
1879 },
1880 )?
1881 .0)
1882 }
1883
1884 pub fn append_abort(&mut self, txn_id: u64) -> Result<()> {
1886 self.active.append_txn(txn_id, Op::TxnAbort)?;
1887 Ok(())
1888 }
1889
1890 pub fn append_system(&mut self, op: Op) -> Result<u64> {
1892 Ok(self.active.append_system(op)?.0)
1893 }
1894
1895 pub fn group_sync(&mut self) -> Result<u64> {
1899 self.active.sync()?;
1900 write_wal_head(
1901 &self.root,
1902 &self.wal_root,
1903 self.active_segment_no,
1904 self.open_generation,
1905 self.wal_dek.as_ref(),
1906 )?;
1907 self.group_sync_count += 1;
1908 let highest = self.active.next_seq_val().saturating_sub(1);
1909 if highest > self.durable_seq {
1910 self.durable_seq = highest;
1911 }
1912 Ok(self.durable_seq)
1913 }
1914
1915 pub fn group_sync_count(&self) -> u64 {
1917 self.group_sync_count
1918 }
1919
1920 pub fn durable_seq(&self) -> u64 {
1922 self.durable_seq
1923 }
1924
1925 pub(crate) fn seal_open_generation(&mut self, generation: u64) -> Result<()> {
1926 if generation < self.open_generation {
1927 return Err(MongrelError::CorruptWal {
1928 offset: generation,
1929 reason: format!(
1930 "open generation {generation} precedes WAL head generation {}",
1931 self.open_generation
1932 ),
1933 });
1934 }
1935 self.active.sync()?;
1936 let previous = self.open_generation;
1937 self.open_generation = generation;
1938 if let Err(error) = write_wal_head(
1939 &self.root,
1940 &self.wal_root,
1941 self.active_segment_no,
1942 self.open_generation,
1943 self.wal_dek.as_ref(),
1944 ) {
1945 self.open_generation = previous;
1946 return Err(error);
1947 }
1948 Ok(())
1949 }
1950
1951 pub fn rotate(&mut self, segment_no: u64) -> Result<()> {
1954 let expected = self
1955 .active_segment_no
1956 .checked_add(1)
1957 .ok_or_else(|| MongrelError::Full("WAL segment namespace exhausted".into()))?;
1958 if segment_no != expected {
1959 return Err(MongrelError::InvalidArgument(format!(
1960 "WAL rotation segment {segment_no} does not immediately follow {}",
1961 self.active_segment_no
1962 )));
1963 }
1964 self.active.sync()?;
1965 write_wal_head(
1966 &self.root,
1967 &self.wal_root,
1968 self.active_segment_no,
1969 self.open_generation,
1970 self.wal_dek.as_ref(),
1971 )?;
1972 let highest = self.active.next_seq_val().saturating_sub(1);
1973 self.durable_seq = self.durable_seq.max(highest);
1974 let previous_segment_hash = hash_segment(&self.wal_root, self.active_segment_no)?;
1975 let cipher = match &self.wal_dek {
1976 #[cfg(feature = "encryption")]
1977 Some(dk) => Some(Self::cipher_from_dek(dk)?),
1978 _ => None,
1979 };
1980 let epoch = Epoch(self.durable_seq);
1981 let wal = Wal::create_chained_in(
1982 &self.wal_root,
1983 segment_no,
1984 epoch,
1985 cipher,
1986 previous_segment_hash,
1987 )?;
1988 if let Err(error) = write_wal_head(
1989 &self.root,
1990 &self.wal_root,
1991 segment_no,
1992 self.open_generation,
1993 self.wal_dek.as_ref(),
1994 ) {
1995 drop(wal);
1996 let _ = self.wal_root.remove_file(segment_filename(segment_no));
1997 return Err(error);
1998 }
1999 self.active = wal;
2000 self.active_segment_no = segment_no;
2001 Ok(())
2002 }
2003
2004 pub fn active_segment_no(&self) -> u64 {
2006 self.active_segment_no
2007 }
2008
2009 pub(crate) fn reset_after_checkpoint(&mut self) -> Result<usize> {
2013 self.group_sync()?;
2014 let next_segment_no = list_segment_numbers(&self.wal_root)?
2015 .into_iter()
2016 .max()
2017 .ok_or_else(|| MongrelError::CorruptWal {
2018 offset: 0,
2019 reason: "active WAL segment disappeared before checkpoint reset".into(),
2020 })?
2021 .checked_add(1)
2022 .ok_or_else(|| MongrelError::Full("WAL segment namespace exhausted".into()))?;
2023 self.rotate(next_segment_no)?;
2024 self.group_sync()?;
2027 self.gc_segments_retain_recent(u64::MAX, 0)
2028 }
2029
2030 pub fn gc_segments(&mut self, min_retained_seq: u64) -> Result<usize> {
2040 self.gc_segments_retain_recent(min_retained_seq, 0)
2041 }
2042
2043 pub(crate) fn has_gc_segments_retain_recent(&self, retain_recent: usize) -> Result<bool> {
2044 let rotated = list_segment_numbers(&self.wal_root)?
2045 .into_iter()
2046 .filter(|segment| *segment != self.active_segment_no)
2047 .count();
2048 Ok(rotated > retain_recent)
2049 }
2050
2051 pub fn gc_segments_retain_recent(
2055 &mut self,
2056 min_retained_seq: u64,
2057 retain_recent: usize,
2058 ) -> Result<usize> {
2059 self.group_sync()?;
2060 let layout = inspect_wal_layout(&self.root, &self.wal_root, self.wal_dek.as_ref())?;
2061 let readable = replay_wal_layout(&self.wal_root, &layout, self.wal_dek.as_ref())?;
2062 let segments = &layout.segments;
2063 let retained: Vec<u64> = segments
2064 .iter()
2065 .rev()
2066 .filter(|segment| **segment != self.active_segment_no)
2067 .take(retain_recent)
2068 .copied()
2069 .collect();
2070 let mut candidates = Vec::new();
2071 let mut prefix_open = true;
2072 for n in segments.iter().copied() {
2073 let mut reader = reader_for_segment(&self.wal_root, n, self.wal_dek.as_ref())?;
2074 if layout.head.is_some_and(|head| head.segment_no == n) {
2075 reader.constrain_to_durable_len(layout.head.unwrap().durable_len)?;
2076 }
2077 let records = reader.replay_strict()?;
2078 let below_floor = min_retained_seq == u64::MAX
2079 || records.iter().map(|record| record.seq.0).max().unwrap_or(0) < min_retained_seq;
2080 let reapable =
2081 prefix_open && n != self.active_segment_no && !retained.contains(&n) && below_floor;
2082 if reapable {
2083 candidates.push((n, records));
2084 } else {
2085 prefix_open = false;
2086 }
2087 }
2088
2089 let commits = readable
2090 .iter()
2091 .filter_map(|record| match record.op {
2092 Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
2093 _ => None,
2094 })
2095 .collect::<std::collections::HashMap<_, _>>();
2096 let removed_floor = candidates
2097 .iter()
2098 .flat_map(|(_, records)| records)
2099 .filter_map(|record| commits.get(&record.txn_id).copied())
2100 .max();
2101 if let Some(epoch) = removed_floor {
2102 crate::replication::advance_replication_wal_floor_durable(&self.root, epoch)?;
2103 }
2104 for (segment_no, _) in &candidates {
2105 self.wal_root.remove_file(segment_filename(*segment_no))?;
2106 }
2107 let reaped = candidates.len();
2108 Ok(reaped)
2109 }
2110
2111 pub fn verify_segments(&self) -> Vec<(u64, String)> {
2119 let result = inspect_wal_layout(&self.root, &self.wal_root, self.wal_dek.as_ref())
2120 .and_then(|layout| replay_wal_layout(&self.wal_root, &layout, self.wal_dek.as_ref()));
2121 match result {
2122 Ok(_) => Vec::new(),
2123 Err(error) => vec![(u64::MAX, error.to_string())],
2124 }
2125 }
2126
2127 pub fn replay(root: &Path) -> Result<Vec<Record>> {
2131 Self::replay_with_dek(root, None)
2132 }
2133
2134 pub fn replay_with_dek(
2136 root: &Path,
2137 wal_dek: Option<&Zeroizing<[u8; 32]>>,
2138 ) -> Result<Vec<Record>> {
2139 let root = crate::durable_file::DurableRoot::open(root)?;
2140 Self::replay_durable_with_dek(&root, wal_dek)
2141 }
2142
2143 pub(crate) fn replay_durable_with_dek(
2144 root: &crate::durable_file::DurableRoot,
2145 wal_dek: Option<&Zeroizing<[u8; 32]>>,
2146 ) -> Result<Vec<Record>> {
2147 let wal_root = root.open_directory("_wal")?;
2148 let layout = inspect_wal_layout(root, &wal_root, wal_dek)?;
2149 replay_wal_layout(&wal_root, &layout, wal_dek)
2150 }
2151
2152 pub(crate) fn durable_open_generation(
2153 root: &crate::durable_file::DurableRoot,
2154 wal_dek: Option<&Zeroizing<[u8; 32]>>,
2155 ) -> Result<Option<u64>> {
2156 let wal_root = root.open_directory("_wal")?;
2157 let layout = inspect_wal_layout(root, &wal_root, wal_dek)?;
2158 Ok(layout.head.map(|head| head.open_generation))
2159 }
2160
2161 pub fn replay_with_dek_controlled(
2164 root: &Path,
2165 wal_dek: Option<&Zeroizing<[u8; 32]>>,
2166 control: &crate::ExecutionControl,
2167 max_records: usize,
2168 max_bytes: usize,
2169 ) -> Result<Vec<Record>> {
2170 let root = crate::durable_file::DurableRoot::open(root)?;
2171 let wal_root = root.open_directory("_wal")?;
2172 let layout = inspect_wal_layout(&root, &wal_root, wal_dek)?;
2173 let total_bytes = layout.segments.iter().try_fold(0_usize, |total, segment| {
2174 let bytes = if layout.head.is_some_and(|head| head.segment_no == *segment) {
2175 usize::try_from(layout.head.unwrap().durable_len).unwrap_or(usize::MAX)
2176 } else {
2177 usize::try_from(
2178 wal_root
2179 .open_regular(segment_filename(*segment))?
2180 .metadata()?
2181 .len(),
2182 )
2183 .unwrap_or(usize::MAX)
2184 };
2185 Ok::<_, MongrelError>(total.saturating_add(bytes))
2186 })?;
2187 if total_bytes > max_bytes {
2188 return Err(MongrelError::ResourceLimitExceeded {
2189 resource: "controlled WAL replay bytes",
2190 requested: total_bytes,
2191 limit: max_bytes,
2192 });
2193 }
2194 let mut segments = Vec::with_capacity(layout.segments.len());
2195 let mut total_records = 0_usize;
2196 for n in layout.segments.iter().copied() {
2197 control.checkpoint()?;
2198 let remaining = max_records.saturating_sub(total_records);
2199 let mut reader = reader_for_segment(&wal_root, n, wal_dek)?;
2200 if layout.head.is_some_and(|head| head.segment_no == n) {
2201 reader.constrain_to_durable_len(layout.head.unwrap().durable_len)?;
2202 }
2203 let records = reader.replay_controlled_strict(control, remaining)?;
2207 total_records += records.len();
2208 segments.push(ReplayedSegment {
2209 segment_no: n,
2210 records,
2211 });
2212 }
2213 validate_v4_sequence_continuity(&segments)?;
2214 let out: Vec<Record> = segments
2215 .into_iter()
2216 .flat_map(|segment| segment.records)
2217 .collect();
2218 validate_shared_transaction_framing(&out)?;
2219 Ok(out)
2220 }
2221}
2222
2223fn segment_filename(segment_no: u64) -> String {
2224 format!("seg-{segment_no:06}.wal")
2225}
2226
2227fn segment_number_from_path(path: &Path) -> Option<u64> {
2228 path.file_name()
2229 .and_then(|name| name.to_str())
2230 .and_then(|name| name.strip_prefix("seg-"))
2231 .and_then(|name| name.strip_suffix(".wal"))
2232 .and_then(|number| number.parse().ok())
2233}
2234
2235fn list_segment_numbers(wal_root: &crate::durable_file::DurableRoot) -> Result<Vec<u64>> {
2240 let mut segments = Vec::new();
2241 for fname in wal_root.list_regular_files(".")? {
2242 let s = fname.to_str().ok_or_else(|| MongrelError::CorruptWal {
2243 offset: 0,
2244 reason: "WAL directory contains a non-UTF-8 entry".into(),
2245 })?;
2246 if !s.starts_with("seg-") {
2247 continue;
2248 }
2249 let number = s
2250 .strip_prefix("seg-")
2251 .and_then(|value| value.strip_suffix(".wal"))
2252 .and_then(|value| value.parse::<u64>().ok())
2253 .ok_or_else(|| MongrelError::CorruptWal {
2254 offset: 0,
2255 reason: format!("malformed WAL segment filename {s:?}"),
2256 })?;
2257 if s != segment_filename(number) {
2258 return Err(MongrelError::CorruptWal {
2259 offset: 0,
2260 reason: format!("non-canonical WAL segment filename {s:?}"),
2261 });
2262 }
2263 segments.push(number);
2264 }
2265 segments.sort_unstable();
2266 for pair in segments.windows(2) {
2267 let expected = pair[0]
2268 .checked_add(1)
2269 .ok_or_else(|| MongrelError::CorruptWal {
2270 offset: pair[0],
2271 reason: "WAL segment namespace overflows after u64::MAX".into(),
2272 })?;
2273 if pair[1] != expected {
2274 return Err(MongrelError::CorruptWal {
2275 offset: pair[1],
2276 reason: format!(
2277 "WAL segment {} does not immediately follow {}",
2278 pair[1], pair[0]
2279 ),
2280 });
2281 }
2282 }
2283 Ok(segments)
2284}
2285
2286#[cfg(test)]
2287mod shared_wal_tests {
2288 use super::*;
2289 use tempfile::tempdir;
2290
2291 fn write_v3_segment(path: &Path) {
2294 let mut bytes = Vec::new();
2295 bytes.extend_from_slice(&WAL_MAGIC);
2296 bytes.extend_from_slice(&3_u16.to_le_bytes());
2297 bytes.extend_from_slice(&[ENC_PLAINTEXT, 0, 0, 0]);
2298 bytes.extend_from_slice(&0_u64.to_le_bytes());
2299 std::fs::write(path, bytes).unwrap();
2300 }
2301
2302 #[test]
2303 fn v3_segments_are_rejected_as_unsupported_not_corrupt() {
2304 let dir = tempdir().unwrap();
2305 std::fs::create_dir(dir.path().join("_wal")).unwrap();
2306 write_v3_segment(&dir.path().join("_wal/seg-000000.wal"));
2307
2308 let error = SharedWal::replay(dir.path()).unwrap_err();
2309 assert!(
2310 matches!(
2311 error,
2312 MongrelError::UnsupportedStorageVersion {
2313 component: "wal",
2314 found: 3,
2315 supported: 4,
2316 }
2317 ),
2318 "expected UnsupportedStorageVersion, got {error:?}"
2319 );
2320
2321 let dir = tempdir().unwrap();
2322 std::fs::create_dir(dir.path().join("_wal")).unwrap();
2323 write_v3_segment(&dir.path().join("_wal/seg-000000.wal"));
2324 let error = SharedWal::open(dir.path(), Epoch(1), None)
2325 .err()
2326 .expect("v3 WAL must be rejected");
2327 assert!(matches!(
2328 error,
2329 MongrelError::UnsupportedStorageVersion {
2330 component: "wal",
2331 found: 3,
2332 supported: 4,
2333 }
2334 ));
2335 }
2336
2337 #[test]
2338 fn shared_wal_interleaves_two_tables_one_fd() {
2339 let dir = tempdir().unwrap();
2340 let mut w = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2341 w.append(
2342 1,
2343 10,
2344 Op::Put {
2345 table_id: 10,
2346 rows: vec![1],
2347 },
2348 )
2349 .unwrap();
2350 w.append(
2351 2,
2352 20,
2353 Op::Put {
2354 table_id: 20,
2355 rows: vec![2],
2356 },
2357 )
2358 .unwrap();
2359 w.append_commit(1, Epoch(1), &[]).unwrap();
2360 w.append_commit(2, Epoch(2), &[]).unwrap();
2361 let d = w.group_sync().unwrap();
2362 assert!(d >= 4);
2363 let recs = SharedWal::replay(dir.path()).unwrap();
2364 assert_eq!(
2365 recs.iter()
2366 .filter(|r| matches!(r.op, Op::Put { .. }))
2367 .count(),
2368 2
2369 );
2370 assert_eq!(
2371 recs.iter()
2372 .filter(|r| matches!(r.op, Op::TxnCommit { .. }))
2373 .count(),
2374 2
2375 );
2376 }
2377
2378 #[test]
2379 fn controlled_shared_replay_rejects_aggregate_wal_bytes() {
2380 let dir = tempdir().unwrap();
2381 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2382 wal.append_commit(1, Epoch(1), &[]).unwrap();
2383 wal.group_sync().unwrap();
2384 let control = crate::ExecutionControl::new(None);
2385
2386 let error =
2387 SharedWal::replay_with_dek_controlled(dir.path(), None, &control, usize::MAX, 1)
2388 .unwrap_err();
2389 assert!(matches!(
2390 error,
2391 MongrelError::ResourceLimitExceeded {
2392 resource: "controlled WAL replay bytes",
2393 ..
2394 }
2395 ));
2396 }
2397
2398 #[test]
2399 fn shared_wal_gc_retains_recent_rotated_segments() {
2400 let dir = tempdir().unwrap();
2401 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2402 for segment in 0..4u64 {
2403 wal.append_commit(segment + 1, Epoch(segment + 1), &[])
2404 .unwrap();
2405 wal.group_sync().unwrap();
2406 if segment < 3 {
2407 wal.rotate(segment + 1).unwrap();
2408 }
2409 }
2410 assert_eq!(wal.gc_segments_retain_recent(u64::MAX, 2).unwrap(), 1);
2411 let count = std::fs::read_dir(dir.path().join("_wal"))
2412 .unwrap()
2413 .flatten()
2414 .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "wal"))
2415 .count();
2416 assert_eq!(count, 3, "active plus two retained segments");
2417 }
2418
2419 #[test]
2420 fn shared_replay_rejects_torn_tail_in_rotated_segment() {
2421 let dir = tempdir().unwrap();
2422 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2423 wal.append_commit(1, Epoch(1), &[]).unwrap();
2424 wal.group_sync().unwrap();
2425 wal.rotate(1).unwrap();
2426 wal.append_commit(2, Epoch(2), &[]).unwrap();
2427 wal.group_sync().unwrap();
2428 drop(wal);
2429
2430 let old = dir.path().join("_wal/seg-000000.wal");
2431 let mut file = OpenOptions::new().append(true).open(old).unwrap();
2432 file.write_all(&[1, 2, 3]).unwrap();
2433 file.sync_all().unwrap();
2434
2435 assert!(matches!(
2436 SharedWal::replay(dir.path()),
2437 Err(MongrelError::CorruptWal { .. })
2438 ));
2439 }
2440
2441 #[test]
2442 fn shared_replay_rejects_records_after_commit() {
2443 let dir = tempdir().unwrap();
2444 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2445 wal.append_commit(7, Epoch(1), &[]).unwrap();
2446 wal.append(
2447 7,
2448 1,
2449 Op::Delete {
2450 table_id: 1,
2451 row_ids: vec![RowId(9)],
2452 },
2453 )
2454 .unwrap();
2455 wal.group_sync().unwrap();
2456 drop(wal);
2457
2458 assert!(matches!(
2459 SharedWal::replay(dir.path()),
2460 Err(MongrelError::CorruptWal { .. })
2461 ));
2462 }
2463
2464 #[test]
2465 fn rotate_without_explicit_group_sync_keeps_sequence_adjacent() {
2466 let dir = tempdir().unwrap();
2467 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2468 assert_eq!(
2469 wal.append_system(Op::Flush {
2470 table_id: 1,
2471 flushed_epoch: 1,
2472 })
2473 .unwrap(),
2474 1
2475 );
2476 wal.rotate(1).unwrap();
2477 assert_eq!(
2478 wal.append_system(Op::Flush {
2479 table_id: 1,
2480 flushed_epoch: 2,
2481 })
2482 .unwrap(),
2483 2
2484 );
2485 wal.group_sync().unwrap();
2486 let records = SharedWal::replay(dir.path()).unwrap();
2487 assert_eq!(
2488 records
2489 .iter()
2490 .map(|record| record.seq.0)
2491 .collect::<Vec<_>>(),
2492 vec![1, 2]
2493 );
2494 }
2495
2496 #[test]
2497 fn open_recovers_exact_header_only_segment_crash_window() {
2498 let dir = tempdir().unwrap();
2499 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2500 wal.append_commit(1, Epoch(1), &[]).unwrap();
2501 wal.group_sync().unwrap();
2502 drop(wal);
2503
2504 let root = crate::durable_file::DurableRoot::open(dir.path()).unwrap();
2505 let wal_root = root.open_directory("_wal").unwrap();
2506 let previous_hash = hash_segment(&wal_root, 0).unwrap();
2507 drop(Wal::create_chained_in(&wal_root, 1, Epoch(1), None, previous_hash).unwrap());
2508 assert_eq!(read_wal_head(&root, None).unwrap().unwrap().segment_no, 0);
2509
2510 let reopened = SharedWal::open(dir.path(), Epoch(1), None).unwrap();
2511 assert_eq!(reopened.active_segment_no(), 1);
2512 assert_eq!(list_segment_numbers(&wal_root).unwrap(), vec![0, 1]);
2513 assert_eq!(read_wal_head(&root, None).unwrap().unwrap().segment_no, 1);
2514 }
2515
2516 #[test]
2517 fn replay_rejects_sequence_reset_across_segments() {
2518 let dir = tempdir().unwrap();
2519 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2520 wal.append_commit(1, Epoch(1), &[]).unwrap();
2521 wal.group_sync().unwrap();
2522 wal.rotate(1).unwrap();
2523 wal.active.next_seq = 1;
2526 wal.append_commit(2, Epoch(2), &[]).unwrap();
2527 wal.group_sync().unwrap();
2528 drop(wal);
2529
2530 let error = SharedWal::replay(dir.path()).unwrap_err();
2531 assert!(
2532 matches!(error, MongrelError::CorruptWal { .. }),
2533 "expected CorruptWal, got {error:?}"
2534 );
2535 }
2536
2537 #[test]
2538 fn replay_rejects_backward_commit_epoch() {
2539 let dir = tempdir().unwrap();
2540 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2541 wal.append_commit(1, Epoch(2), &[]).unwrap();
2542 wal.append_commit(2, Epoch(1), &[]).unwrap();
2543 wal.group_sync().unwrap();
2544 drop(wal);
2545
2546 assert!(matches!(
2547 SharedWal::replay(dir.path()),
2548 Err(MongrelError::CorruptWal { .. })
2549 ));
2550 }
2551
2552 #[test]
2553 fn multiple_sessions_continue_one_global_sequence() {
2554 let dir = tempdir().unwrap();
2555 for session in 0..3_u64 {
2556 let mut wal = SharedWal::open(dir.path(), Epoch(session * 2), None)
2557 .unwrap_or_else(|_| SharedWal::create(dir.path(), Epoch(session * 2)).unwrap());
2558 assert_eq!(wal.active_segment_no(), session);
2559 for i in 0..2_u64 {
2560 let txn = session * 2 + i + 1;
2561 wal.append_commit(txn, Epoch(txn), &[]).unwrap();
2562 }
2563 wal.group_sync().unwrap();
2564 drop(wal);
2565 }
2566
2567 let records = SharedWal::replay(dir.path()).unwrap();
2568 let sequences: Vec<u64> = records.iter().map(|record| record.seq.0).collect();
2569 assert_eq!(sequences, (1..=12).collect::<Vec<u64>>());
2572
2573 let mut wal = SharedWal::open(dir.path(), Epoch(6), None).unwrap();
2576 assert_eq!(wal.active_segment_no(), 3);
2577 wal.append_commit(7, Epoch(7), &[]).unwrap();
2578 wal.group_sync().unwrap();
2579 drop(wal);
2580
2581 let records = SharedWal::replay(dir.path()).unwrap();
2582 let sequences: Vec<u64> = records.iter().map(|record| record.seq.0).collect();
2583 assert_eq!(
2584 sequences,
2585 (1..=sequences.len() as u64).collect::<Vec<u64>>(),
2586 "records stay globally contiguous across sessions"
2587 );
2588 }
2589
2590 #[test]
2591 fn head_and_strict_segment_names_detect_deletion_gaps_and_aliases() {
2592 let deleted = tempdir().unwrap();
2593 let wal = SharedWal::create(deleted.path(), Epoch(0)).unwrap();
2594 drop(wal);
2595 std::fs::remove_file(deleted.path().join("_wal/seg-000000.wal")).unwrap();
2596 assert!(SharedWal::replay(deleted.path()).is_err());
2597
2598 let alias = tempdir().unwrap();
2599 let wal = SharedWal::create(alias.path(), Epoch(0)).unwrap();
2600 drop(wal);
2601 std::fs::rename(
2602 alias.path().join("_wal/seg-000000.wal"),
2603 alias.path().join("_wal/seg-0.wal"),
2604 )
2605 .unwrap();
2606 assert!(SharedWal::replay(alias.path()).is_err());
2607
2608 let gap = tempdir().unwrap();
2609 let mut wal = SharedWal::create(gap.path(), Epoch(0)).unwrap();
2610 wal.rotate(1).unwrap();
2611 drop(wal);
2612 std::fs::rename(
2613 gap.path().join("_wal/seg-000001.wal"),
2614 gap.path().join("_wal/seg-000002.wal"),
2615 )
2616 .unwrap();
2617 assert!(SharedWal::replay(gap.path()).is_err());
2618 }
2619
2620 #[test]
2621 fn verify_and_gc_fail_closed_on_segment_corruption() {
2622 let dir = tempdir().unwrap();
2623 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2624 wal.append_commit(1, Epoch(1), &[]).unwrap();
2625 wal.group_sync().unwrap();
2626 wal.rotate(1).unwrap();
2627 wal.append_commit(2, Epoch(2), &[]).unwrap();
2628 wal.group_sync().unwrap();
2629
2630 let old = dir.path().join("_wal/seg-000000.wal");
2631 let mut bytes = std::fs::read(&old).unwrap();
2632 let last = bytes.len() - 1;
2633 bytes[last] ^= 0x40;
2634 std::fs::write(&old, bytes).unwrap();
2635 assert!(!wal.verify_segments().is_empty());
2636 assert!(wal.gc_segments(u64::MAX).is_err());
2637 assert!(old.exists());
2638 }
2639
2640 #[test]
2641 fn rotation_within_session_keeps_sequence_contiguous() {
2642 let dir = tempdir().unwrap();
2643 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2644 wal.append_commit(1, Epoch(1), &[]).unwrap();
2645 wal.rotate(1).unwrap();
2646 wal.append_commit(2, Epoch(2), &[]).unwrap();
2647 wal.rotate(2).unwrap();
2648 wal.append_commit(3, Epoch(3), &[]).unwrap();
2649 wal.group_sync().unwrap();
2650 drop(wal);
2651
2652 let records = SharedWal::replay(dir.path()).unwrap();
2653 let sequences: Vec<u64> = records.iter().map(|record| record.seq.0).collect();
2654 assert_eq!(
2655 sequences,
2656 (1..=sequences.len() as u64).collect::<Vec<u64>>()
2657 );
2658 assert_eq!(sequences.len(), 6, "two records per commit");
2659 }
2660
2661 #[test]
2662 fn open_truncates_garbage_beyond_the_authenticated_durable_prefix() {
2663 let dir = tempdir().unwrap();
2664 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2665 wal.append_commit(1, Epoch(1), &[]).unwrap();
2666 wal.group_sync().unwrap();
2667 drop(wal);
2668
2669 let seg = dir.path().join("_wal/seg-000000.wal");
2670 let published_len = std::fs::metadata(&seg).unwrap().len();
2671 let mut file = OpenOptions::new().append(true).open(&seg).unwrap();
2672 file.write_all(&[0xde, 0xad, 0xbe, 0xef, 1, 2, 3, 4, 5])
2673 .unwrap();
2674 file.sync_all().unwrap();
2675 drop(file);
2676
2677 let wal = SharedWal::open(dir.path(), Epoch(1), None).unwrap();
2680 drop(wal);
2681 assert_eq!(std::fs::metadata(&seg).unwrap().len(), published_len);
2682 let records = SharedWal::replay(dir.path()).unwrap();
2683 assert_eq!(records.len(), 2);
2684 assert!(records.iter().all(|record| record.seq.0 <= 2));
2685 }
2686
2687 #[test]
2688 fn open_rejects_damage_inside_the_authenticated_prefix() {
2689 let dir = tempdir().unwrap();
2690 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2691 wal.append_commit(1, Epoch(1), &[]).unwrap();
2692 wal.group_sync().unwrap();
2693 wal.rotate(1).unwrap();
2694 wal.append_commit(2, Epoch(2), &[]).unwrap();
2695 wal.group_sync().unwrap();
2696 drop(wal);
2697
2698 let seg = dir.path().join("_wal/seg-000000.wal");
2701 let mut bytes = std::fs::read(&seg).unwrap();
2702 bytes[(HEADER_LEN + 12) as usize] ^= 0x01;
2703 std::fs::write(&seg, bytes).unwrap();
2704
2705 assert!(matches!(
2706 SharedWal::replay(dir.path()),
2707 Err(MongrelError::CorruptWal { .. })
2708 ));
2709 assert!(SharedWal::open(dir.path(), Epoch(2), None).is_err());
2710 }
2711
2712 #[cfg(feature = "encryption")]
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 #[cfg(feature = "encryption")]
3150 #[test]
3151 fn encrypted_frames_reject_reorder_replay_deletion_and_cross_segment_move() {
3152 fn cipher(key: &[u8; 32]) -> Box<dyn crate::encryption::Cipher> {
3153 Box::new(crate::encryption::AesCipher::new(key).unwrap())
3154 }
3155
3156 let dir = tempdir().unwrap();
3157 let key = [0x5a; 32];
3158 let path = dir.path().join("seg-000009.wal");
3159 let mut wal = Wal::create_with_cipher(&path, Epoch(0), Some(cipher(&key)), 9).unwrap();
3160 for table_id in [1, 2] {
3161 wal.append_system(Op::Flush {
3162 table_id,
3163 flushed_epoch: table_id,
3164 })
3165 .unwrap();
3166 }
3167 wal.sync().unwrap();
3168 drop(wal);
3169 let original = std::fs::read(&path).unwrap();
3170 let ranges = frame_ranges(&original);
3171 assert_eq!(ranges.len(), 2);
3172
3173 let mut reordered = original[..HEADER_LEN as usize].to_vec();
3174 reordered.extend_from_slice(&original[ranges[1].clone()]);
3175 reordered.extend_from_slice(&original[ranges[0].clone()]);
3176 std::fs::write(&path, reordered).unwrap();
3177 assert!(replay_with_cipher(&path, Some(cipher(&key))).is_err());
3178
3179 let mut replayed = original[..HEADER_LEN as usize].to_vec();
3180 replayed.extend_from_slice(&original[ranges[0].clone()]);
3181 replayed.extend_from_slice(&original[ranges[0].clone()]);
3182 replayed.extend_from_slice(&original[ranges[1].clone()]);
3183 std::fs::write(&path, replayed).unwrap();
3184 assert!(replay_with_cipher(&path, Some(cipher(&key))).is_err());
3185
3186 let mut deleted = original[..HEADER_LEN as usize].to_vec();
3187 deleted.extend_from_slice(&original[ranges[1].clone()]);
3188 std::fs::write(&path, deleted).unwrap();
3189 assert!(replay_with_cipher(&path, Some(cipher(&key))).is_err());
3190
3191 let mut outer_tampered = original.clone();
3192 outer_tampered[ranges[0].start + 8] ^= 0x01;
3193 recompute_frame_crc(&mut outer_tampered, ranges[0].start);
3194 std::fs::write(&path, outer_tampered).unwrap();
3195 assert!(replay_with_cipher(&path, Some(cipher(&key))).is_err());
3196
3197 let other = dir.path().join("seg-000010.wal");
3198 let mut wal = Wal::create_with_cipher(&other, Epoch(0), Some(cipher(&key)), 10).unwrap();
3199 wal.append_system(Op::Flush {
3200 table_id: 3,
3201 flushed_epoch: 3,
3202 })
3203 .unwrap();
3204 wal.sync().unwrap();
3205 drop(wal);
3206 let mut moved = std::fs::read(&other).unwrap();
3207 moved.truncate(HEADER_LEN as usize);
3208 moved.extend_from_slice(&original[ranges[0].clone()]);
3209 std::fs::write(&other, moved).unwrap();
3210 assert!(replay_with_cipher(&other, Some(cipher(&key))).is_err());
3211 }
3212
3213 #[cfg(feature = "encryption")]
3214 #[test]
3215 fn wal_nonce_is_segment_deterministic() {
3216 assert_ne!(frame_nonce_for(5, 0), frame_nonce_for(6, 0));
3219 assert_ne!(frame_nonce_for(5, 0), frame_nonce_for(5, 1));
3220 assert_eq!(frame_nonce_for(5, 0), frame_nonce_for(5, 0));
3223 }
3224}