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 LEGACY_WAL_VERSION: u16 = 3;
33const WAL_VERSION: u16 = 4;
34const LEGACY_HEADER_LEN: u64 = 8 + 2 + 4 + 8;
35const HEADER_LEN: u64 = LEGACY_HEADER_LEN + 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 != LEGACY_WAL_VERSION && version != WAL_VERSION {
889 return Err(MongrelError::InvalidArgument(format!(
890 "unsupported wal version {version}"
891 )));
892 }
893 let mut reserved = [0u8; 4];
894 file.read_exact(&mut reserved)?;
895 if !matches!(reserved[0], ENC_PLAINTEXT | ENC_AES_GCM) || reserved[1..] != [0, 0, 0] {
896 return Err(MongrelError::CorruptWal {
897 offset: 10,
898 reason: "invalid WAL header flags".into(),
899 });
900 }
901 let encrypted = reserved[0] == ENC_AES_GCM;
902 let mut epoch_buf = [0u8; 8];
903 file.read_exact(&mut epoch_buf)?;
904 let _epoch_created = Epoch(u64::from_le_bytes(epoch_buf));
905 let mut previous_segment_hash = [0_u8; 32];
906 let segment_no = if version == WAL_VERSION {
907 let mut segment = [0_u8; 8];
908 file.read_exact(&mut segment)?;
909 file.read_exact(&mut previous_segment_hash)?;
910 u64::from_le_bytes(segment)
911 } else {
912 expected_segment_no.unwrap_or(0)
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 = if version == WAL_VERSION {
924 HEADER_LEN
925 } else {
926 LEGACY_HEADER_LEN
927 };
928 let header_binding = if version == WAL_VERSION {
929 Sha256::digest(encode_wal_header(
930 reserved[0],
931 _epoch_created.0,
932 segment_no,
933 &previous_segment_hash,
934 ))
935 .into()
936 } else {
937 [0; 32]
938 };
939 if encrypted && cipher.is_none() {
940 return Err(MongrelError::Decryption(
941 "WAL is encrypted but no passphrase or key was provided. \
942 Use Table::open_encrypted or Table::open_with_key."
943 .into(),
944 ));
945 }
946 Ok(Self {
947 inner: BufReader::new(file),
948 pos,
949 file_len,
950 encrypted,
951 cipher,
952 version,
953 segment_no,
954 previous_segment_hash,
955 header_binding,
956 frame_seq: 0,
957 })
958 }
959
960 pub fn next_record(&mut self) -> Result<Option<Record>> {
963 if self.pos == self.file_len {
964 return Ok(None);
965 }
966 if self.file_len.saturating_sub(self.pos) < 4 {
967 return Err(MongrelError::TornWrite { offset: self.pos });
968 }
969 let mut len_buf = [0u8; 4];
970 match self.inner.read_exact(&mut len_buf) {
971 Ok(()) => {}
972 Err(ref e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
973 return Err(MongrelError::TornWrite { offset: self.pos });
974 }
975 Err(e) => return Err(e.into()),
976 }
977 let len = u32::from_le_bytes(len_buf) as usize;
978 if len == 0 {
979 return Err(MongrelError::CorruptWal {
980 offset: self.pos,
981 reason: "zero-length WAL frames are not valid".into(),
982 });
983 }
984 const MAX_RECORD_LEN: usize = 64 * 1024 * 1024;
987 if len > MAX_RECORD_LEN {
988 return Err(MongrelError::CorruptWal {
989 offset: self.pos,
990 reason: format!("WAL frame length {len} exceeds limit"),
991 });
992 }
993
994 let record_start = self.pos;
995 let mut rest = vec![0u8; 4 + 8 + 8 + len];
996 match self.inner.read_exact(&mut rest) {
997 Ok(()) => {}
998 Err(ref e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
999 return Err(MongrelError::TornWrite {
1000 offset: record_start,
1001 });
1002 }
1003 Err(e) => return Err(e.into()),
1004 }
1005 let crc_val = u32::from_le_bytes([rest[0], rest[1], rest[2], rest[3]]);
1006 let seq = u64::from_le_bytes([
1007 rest[4], rest[5], rest[6], rest[7], rest[8], rest[9], rest[10], rest[11],
1008 ]);
1009 let txn_id = u64::from_le_bytes([
1010 rest[12], rest[13], rest[14], rest[15], rest[16], rest[17], rest[18], rest[19],
1011 ]);
1012 let payload = &rest[20..];
1013
1014 let mut digest = CRC32C.digest();
1015 digest.update(&seq.to_le_bytes());
1016 digest.update(&txn_id.to_le_bytes());
1017 digest.update(payload);
1018 if digest.finalize() != crc_val {
1019 return Err(MongrelError::CorruptWal {
1020 offset: record_start,
1021 reason: "crc mismatch".into(),
1022 });
1023 }
1024
1025 let plaintext = if self.encrypted {
1027 let Some(cipher) = &self.cipher else {
1028 return Err(MongrelError::Decryption(
1029 "WAL is encrypted but no cipher was provided".into(),
1030 ));
1031 };
1032 let minimum = if self.version == LEGACY_WAL_VERSION {
1033 28
1034 } else {
1035 16
1036 };
1037 if payload.len() < minimum {
1038 return Err(MongrelError::CorruptWal {
1039 offset: record_start,
1040 reason: "encrypted frame too short".into(),
1041 });
1042 }
1043 if self.frame_seq > u32::MAX as u64 {
1044 return Err(MongrelError::CorruptWal {
1045 offset: record_start,
1046 reason: "WAL frame counter exceeds u32".into(),
1047 });
1048 }
1049 let expected_nonce = frame_nonce_for(self.segment_no, self.frame_seq as u32);
1050 let (ciphertext, aad) = if self.version == LEGACY_WAL_VERSION {
1051 if payload[..12] != expected_nonce {
1052 return Err(MongrelError::CorruptWal {
1053 offset: record_start,
1054 reason: "encrypted WAL nonce does not match frame position".into(),
1055 });
1056 }
1057 (&payload[12..], Vec::new())
1058 } else {
1059 (
1060 payload,
1061 wal_frame_aad(
1062 &self.header_binding,
1063 self.segment_no,
1064 self.frame_seq as u32,
1065 seq,
1066 txn_id,
1067 payload.len() as u64,
1068 ),
1069 )
1070 };
1071 cipher
1072 .decrypt_page_with_aad(&expected_nonce, ciphertext, &aad)
1073 .map_err(|e| {
1074 MongrelError::Decryption(format!(
1075 "WAL frame decryption failed — wrong passphrase or key? ({e})"
1076 ))
1077 })?
1078 } else {
1079 payload.to_vec()
1080 };
1081
1082 let record: Record =
1083 bincode::deserialize(&plaintext).map_err(|error| MongrelError::CorruptWal {
1084 offset: record_start,
1085 reason: format!("WAL record decode failed: {error}"),
1086 })?;
1087 if record.seq.0 != seq || record.txn_id != txn_id {
1088 return Err(MongrelError::CorruptWal {
1089 offset: record_start,
1090 reason: "WAL outer and authenticated record identity differ".into(),
1091 });
1092 }
1093 self.frame_seq += 1;
1094 self.pos += 4 + 4 + 8 + 8 + len as u64;
1095 Ok(Some(record))
1096 }
1097
1098 fn constrain_to_durable_len(&mut self, durable_len: u64) -> Result<()> {
1099 if durable_len < self.pos || durable_len > self.file_len {
1100 return Err(MongrelError::CorruptWal {
1101 offset: durable_len,
1102 reason: format!(
1103 "WAL durable length {durable_len} is outside [{}, {}]",
1104 self.pos, self.file_len
1105 ),
1106 });
1107 }
1108 self.file_len = durable_len;
1109 Ok(())
1110 }
1111
1112 pub fn replay(&mut self) -> Result<Vec<Record>> {
1118 self.replay_with_tail_policy(true)
1119 }
1120
1121 fn replay_strict(&mut self) -> Result<Vec<Record>> {
1122 self.replay_with_tail_policy(false)
1123 }
1124
1125 fn replay_bounded(&mut self, max_records: usize, allow_torn_tail: bool) -> Result<Vec<Record>> {
1126 let mut out = Vec::new();
1127 loop {
1128 match self.next_record() {
1129 Ok(Some(record)) => {
1130 if out.len() >= max_records {
1131 return Err(MongrelError::ResourceLimitExceeded {
1132 resource: "WAL recovery records",
1133 requested: max_records.saturating_add(1),
1134 limit: max_records,
1135 });
1136 }
1137 out.push(record);
1138 }
1139 Ok(None) => break,
1140 Err(MongrelError::TornWrite { offset }) => {
1141 if !allow_torn_tail {
1142 return Err(MongrelError::CorruptWal {
1143 offset,
1144 reason: "torn tail in a non-final WAL segment".into(),
1145 });
1146 }
1147 if self.valid_frame_follows()? {
1148 return Err(MongrelError::CorruptWal {
1149 offset,
1150 reason: "interior torn frame followed by a valid frame".into(),
1151 });
1152 }
1153 break;
1154 }
1155 Err(error @ MongrelError::CorruptWal { .. }) => return Err(error),
1156 Err(error) => return Err(error),
1157 }
1158 }
1159 Ok(out)
1160 }
1161
1162 fn replay_with_tail_policy(&mut self, allow_torn_tail: bool) -> Result<Vec<Record>> {
1163 let mut out = Vec::new();
1164 loop {
1165 match self.next_record() {
1166 Ok(Some(rec)) => out.push(rec),
1167 Ok(None) => break,
1168 Err(MongrelError::TornWrite { offset }) => {
1169 if !allow_torn_tail {
1170 return Err(MongrelError::CorruptWal {
1171 offset,
1172 reason: "torn tail in a non-final WAL segment".into(),
1173 });
1174 }
1175 if self.valid_frame_follows()? {
1178 return Err(MongrelError::CorruptWal {
1179 offset,
1180 reason: "interior torn frame followed by a valid frame".into(),
1181 });
1182 }
1183 break;
1184 }
1185 Err(error @ MongrelError::CorruptWal { .. }) => return Err(error),
1186 Err(e) => return Err(e),
1187 }
1188 }
1189 Ok(out)
1190 }
1191
1192 pub fn replay_controlled(
1194 &mut self,
1195 control: &crate::ExecutionControl,
1196 max_records: usize,
1197 ) -> Result<Vec<Record>> {
1198 self.replay_controlled_with_tail_policy(control, max_records, true)
1199 }
1200
1201 fn replay_controlled_strict(
1202 &mut self,
1203 control: &crate::ExecutionControl,
1204 max_records: usize,
1205 ) -> Result<Vec<Record>> {
1206 self.replay_controlled_with_tail_policy(control, max_records, false)
1207 }
1208
1209 fn replay_controlled_with_tail_policy(
1210 &mut self,
1211 control: &crate::ExecutionControl,
1212 max_records: usize,
1213 allow_torn_tail: bool,
1214 ) -> Result<Vec<Record>> {
1215 let mut out = Vec::new();
1216 loop {
1217 if out.len() % 256 == 0 {
1218 control.checkpoint()?;
1219 }
1220 match self.next_record() {
1221 Ok(Some(record)) => {
1222 if out.len() >= max_records {
1223 return Err(MongrelError::ResourceLimitExceeded {
1224 resource: "controlled WAL replay records",
1225 requested: max_records.saturating_add(1),
1226 limit: max_records,
1227 });
1228 }
1229 out.push(record);
1230 }
1231 Ok(None) => break,
1232 Err(MongrelError::TornWrite { offset }) => {
1233 if !allow_torn_tail {
1234 return Err(MongrelError::CorruptWal {
1235 offset,
1236 reason: "torn tail in a non-final WAL segment".into(),
1237 });
1238 }
1239 if self.valid_frame_follows()? {
1240 return Err(MongrelError::CorruptWal {
1241 offset,
1242 reason: "interior torn frame followed by a valid frame".into(),
1243 });
1244 }
1245 break;
1246 }
1247 Err(error @ MongrelError::CorruptWal { .. }) => return Err(error),
1248 Err(error) => return Err(error),
1249 }
1250 }
1251 control.checkpoint()?;
1252 Ok(out)
1253 }
1254
1255 fn valid_frame_follows(&mut self) -> Result<bool> {
1260 match self.next_record() {
1261 Ok(Some(_)) => Ok(true),
1262 Ok(None) => Ok(false),
1263 Err(_) => Ok(false),
1264 }
1265 }
1266
1267 pub fn current_offset(&self) -> u64 {
1270 self.pos
1271 }
1272}
1273
1274pub fn replay(path: impl AsRef<Path>) -> Result<Vec<Record>> {
1277 WalReader::open(path)?.replay()
1278}
1279
1280pub fn replay_with_cipher(
1282 path: impl AsRef<Path>,
1283 cipher: Option<Box<dyn crate::encryption::Cipher>>,
1284) -> Result<Vec<Record>> {
1285 WalReader::open_with_cipher(path, cipher)?.replay()
1286}
1287
1288pub fn frame_nonce_for(segment_no: u64, frame: u32) -> [u8; 12] {
1293 let mut n = [0u8; 12];
1294 n[..8].copy_from_slice(&segment_no.to_be_bytes());
1295 n[8..].copy_from_slice(&frame.to_le_bytes());
1296 n
1297}
1298
1299pub struct SharedWal {
1305 root: Arc<crate::durable_file::DurableRoot>,
1306 wal_root: Arc<crate::durable_file::DurableRoot>,
1307 wal_dir: PathBuf,
1308 active: Wal,
1309 active_segment_no: u64,
1311 durable_seq: u64,
1314 open_generation: u64,
1316 wal_dek: Option<Zeroizing<[u8; 32]>>,
1319 group_sync_count: u64,
1323}
1324
1325#[derive(Default)]
1326struct ReplayTxnState {
1327 timestamp_seen: bool,
1328 terminal: bool,
1329}
1330
1331struct WalLayout {
1332 segments: Vec<u64>,
1333 head: Option<WalHead>,
1334}
1335
1336fn reader_for_segment(
1337 wal_root: &crate::durable_file::DurableRoot,
1338 segment_no: u64,
1339 wal_dek: Option<&Zeroizing<[u8; 32]>>,
1340) -> Result<WalReader> {
1341 let cipher = match wal_dek {
1342 #[cfg(feature = "encryption")]
1343 Some(key) => Some(SharedWal::cipher_from_dek(key)?),
1344 #[cfg(not(feature = "encryption"))]
1345 Some(_) => {
1346 return Err(MongrelError::Encryption(
1347 "encryption feature disabled but a WAL DEK was supplied".into(),
1348 ));
1349 }
1350 None => None,
1351 };
1352 let file = wal_root.open_regular(segment_filename(segment_no))?;
1353 WalReader::open_file_with_cipher_expected(file, cipher, Some(segment_no))
1354}
1355
1356fn inspect_wal_layout(
1357 root: &crate::durable_file::DurableRoot,
1358 wal_root: &crate::durable_file::DurableRoot,
1359 wal_dek: Option<&Zeroizing<[u8; 32]>>,
1360) -> Result<WalLayout> {
1361 let segments = list_segment_numbers(wal_root)?;
1362 let head = read_wal_head(root, wal_dek)?;
1363 let mut saw_v4 = false;
1364 for (index, segment_no) in segments.iter().copied().enumerate() {
1365 let reader = reader_for_segment(wal_root, segment_no, wal_dek)?;
1366 if reader.encrypted != wal_dek.is_some() {
1367 return Err(MongrelError::CorruptWal {
1368 offset: 10,
1369 reason: format!(
1370 "WAL segment {segment_no} encryption mode differs from the database"
1371 ),
1372 });
1373 }
1374 if reader.version == WAL_VERSION {
1375 if index != 0 {
1376 let previous_no = segments[index - 1];
1377 let expected = hash_segment(wal_root, previous_no)?;
1378 if reader.previous_segment_hash != expected {
1379 return Err(MongrelError::CorruptWal {
1380 offset: 30,
1381 reason: format!(
1382 "WAL segment {segment_no} does not authenticate segment {previous_no}"
1383 ),
1384 });
1385 }
1386 }
1387 saw_v4 = true;
1388 } else if saw_v4 {
1389 return Err(MongrelError::CorruptWal {
1390 offset: 8,
1391 reason: format!("legacy WAL segment {segment_no} follows a v4 segment"),
1392 });
1393 }
1394 }
1395
1396 if saw_v4 && head.is_none() {
1397 return Err(MongrelError::CorruptWal {
1398 offset: 0,
1399 reason: "v4 WAL segments require a durable WAL head".into(),
1400 });
1401 }
1402 if !saw_v4 && head.is_some() {
1403 return Err(MongrelError::CorruptWal {
1404 offset: 0,
1405 reason: "a durable WAL head exists without any v4 segment".into(),
1406 });
1407 }
1408 if let Some(head) = head {
1409 let final_segment = segments
1410 .last()
1411 .copied()
1412 .ok_or_else(|| MongrelError::CorruptWal {
1413 offset: 0,
1414 reason: "durable WAL head references an empty WAL directory".into(),
1415 })?;
1416 if head.segment_no != final_segment {
1417 return Err(MongrelError::CorruptWal {
1418 offset: head.segment_no,
1419 reason: format!(
1420 "durable WAL head references segment {}, newest retained segment is {final_segment}",
1421 head.segment_no
1422 ),
1423 });
1424 }
1425 let file = wal_root.open_regular(segment_filename(head.segment_no))?;
1426 let actual_len = file.metadata()?.len();
1427 if head.durable_len > actual_len {
1428 return Err(MongrelError::CorruptWal {
1429 offset: head.durable_len,
1430 reason: format!(
1431 "durable WAL head length {} exceeds segment length {actual_len}",
1432 head.durable_len
1433 ),
1434 });
1435 }
1436 if hash_file_prefix(file, head.durable_len)? != head.prefix_hash {
1437 return Err(MongrelError::CorruptWal {
1438 offset: head.durable_len,
1439 reason: "durable WAL prefix hash differs from WAL head".into(),
1440 });
1441 }
1442 }
1443 Ok(WalLayout { segments, head })
1444}
1445
1446fn remove_unpublished_header_only_segment(
1447 root: &crate::durable_file::DurableRoot,
1448 wal_root: &crate::durable_file::DurableRoot,
1449 wal_dek: Option<&Zeroizing<[u8; 32]>>,
1450) -> Result<bool> {
1451 let Some(head) = read_wal_head(root, wal_dek)? else {
1452 return Ok(false);
1453 };
1454 let segments = list_segment_numbers(wal_root)?;
1455 let Some(newest) = segments.last().copied() else {
1456 return Ok(false);
1457 };
1458 let Some(orphan_no) = head.segment_no.checked_add(1) else {
1459 return Ok(false);
1460 };
1461 if newest != orphan_no || !segments.contains(&head.segment_no) {
1462 return Ok(false);
1463 }
1464 let reader = reader_for_segment(wal_root, orphan_no, wal_dek)?;
1465 if reader.version != WAL_VERSION
1466 || reader.file_len != HEADER_LEN
1467 || reader.previous_segment_hash != hash_segment(wal_root, head.segment_no)?
1468 {
1469 return Ok(false);
1470 }
1471 wal_root.remove_file(segment_filename(orphan_no))?;
1472 Ok(true)
1473}
1474
1475fn replay_wal_layout(
1476 wal_root: &crate::durable_file::DurableRoot,
1477 layout: &WalLayout,
1478 wal_dek: Option<&Zeroizing<[u8; 32]>>,
1479) -> Result<Vec<Record>> {
1480 let total_bytes = layout
1481 .segments
1482 .iter()
1483 .try_fold(0_u64, |total, segment_no| {
1484 let bytes = if layout
1485 .head
1486 .is_some_and(|head| head.segment_no == *segment_no)
1487 {
1488 layout.head.unwrap().durable_len
1489 } else {
1490 wal_root
1491 .open_regular(segment_filename(*segment_no))?
1492 .metadata()?
1493 .len()
1494 };
1495 total
1496 .checked_add(bytes)
1497 .ok_or(MongrelError::ResourceLimitExceeded {
1498 resource: "WAL recovery bytes",
1499 requested: usize::MAX,
1500 limit: MAX_RECOVERY_WAL_BYTES as usize,
1501 })
1502 })?;
1503 if total_bytes > MAX_RECOVERY_WAL_BYTES {
1504 return Err(MongrelError::ResourceLimitExceeded {
1505 resource: "WAL recovery bytes",
1506 requested: usize::try_from(total_bytes).unwrap_or(usize::MAX),
1507 limit: MAX_RECOVERY_WAL_BYTES as usize,
1508 });
1509 }
1510 let mut records = Vec::new();
1511 let final_segment = layout.segments.last().copied();
1512 for segment_no in layout.segments.iter().copied() {
1513 let mut reader = reader_for_segment(wal_root, segment_no, wal_dek)?;
1514 if layout
1515 .head
1516 .is_some_and(|head| head.segment_no == segment_no)
1517 {
1518 reader.constrain_to_durable_len(layout.head.unwrap().durable_len)?;
1519 }
1520 let remaining = MAX_RECOVERY_WAL_RECORDS.saturating_sub(records.len());
1521 let segment_records = reader.replay_bounded(
1522 remaining,
1523 Some(segment_no) == final_segment && layout.head.is_none(),
1524 )?;
1525 records.extend(segment_records);
1526 }
1527 validate_shared_transaction_framing(&records)?;
1528 Ok(records)
1529}
1530
1531pub(crate) fn validate_shared_transaction_framing(records: &[Record]) -> Result<()> {
1532 let mut transactions = std::collections::HashMap::<u64, ReplayTxnState>::new();
1533 let mut commit_epochs = std::collections::HashMap::<u64, u64>::new();
1534 let mut previous_sequence: Option<u64> = None;
1535 let mut previous_commit_epoch: Option<u64> = None;
1536 for record in records {
1537 if let Some(previous) = previous_sequence {
1538 let expected = previous
1539 .checked_add(1)
1540 .ok_or_else(|| MongrelError::CorruptWal {
1541 offset: record.seq.0,
1542 reason: "WAL sequence overflows after u64::MAX".into(),
1543 })?;
1544 if record.seq.0 != expected {
1545 return Err(MongrelError::CorruptWal {
1546 offset: record.seq.0,
1547 reason: format!("WAL sequence {} does not follow {previous}", record.seq.0),
1548 });
1549 }
1550 }
1551 previous_sequence = Some(record.seq.0);
1552 if record.txn_id == SYSTEM_TXN_ID {
1553 if !matches!(record.op, Op::Flush { .. }) {
1554 return Err(MongrelError::CorruptWal {
1555 offset: record.seq.0,
1556 reason: "non-system operation uses reserved transaction id 0".into(),
1557 });
1558 }
1559 continue;
1560 }
1561 let state = transactions.entry(record.txn_id).or_default();
1562 if state.terminal {
1563 return Err(MongrelError::CorruptWal {
1564 offset: record.seq.0,
1565 reason: format!(
1566 "transaction {} has records after its terminal marker",
1567 record.txn_id
1568 ),
1569 });
1570 }
1571 match record.op {
1572 Op::CommitTimestamp { .. } => {
1573 if state.timestamp_seen {
1574 return Err(MongrelError::CorruptWal {
1575 offset: record.seq.0,
1576 reason: format!(
1577 "transaction {} has duplicate commit timestamps",
1578 record.txn_id
1579 ),
1580 });
1581 }
1582 state.timestamp_seen = true;
1583 }
1584 Op::TxnCommit { epoch, .. } => {
1585 if epoch == 0 {
1586 return Err(MongrelError::CorruptWal {
1587 offset: record.seq.0,
1588 reason: format!("transaction {} commits at epoch 0", record.txn_id),
1589 });
1590 }
1591 if let Some(previous) = commit_epochs.insert(epoch, record.txn_id) {
1592 return Err(MongrelError::CorruptWal {
1593 offset: record.seq.0,
1594 reason: format!(
1595 "transactions {previous} and {} share commit epoch {epoch}",
1596 record.txn_id
1597 ),
1598 });
1599 }
1600 if previous_commit_epoch.is_some_and(|previous| epoch <= previous) {
1601 return Err(MongrelError::CorruptWal {
1602 offset: record.seq.0,
1603 reason: format!(
1604 "commit epoch {epoch} does not advance beyond {}",
1605 previous_commit_epoch.unwrap_or(0)
1606 ),
1607 });
1608 }
1609 previous_commit_epoch = Some(epoch);
1610 state.terminal = true;
1611 }
1612 Op::TxnAbort => state.terminal = true,
1613 Op::Flush { .. } => {
1614 return Err(MongrelError::CorruptWal {
1615 offset: record.seq.0,
1616 reason: format!(
1617 "transaction {} contains a system flush record",
1618 record.txn_id
1619 ),
1620 });
1621 }
1622 _ => {}
1623 }
1624 }
1625 Ok(())
1626}
1627
1628impl SharedWal {
1629 #[cfg(feature = "encryption")]
1631 fn cipher_from_dek(dek: &Zeroizing<[u8; 32]>) -> Result<Box<dyn crate::encryption::Cipher>> {
1632 Ok(Box::new(crate::encryption::AesCipher::new(&dek[..])?))
1633 }
1634
1635 pub fn create(root: &Path, epoch_created: Epoch) -> Result<Self> {
1637 Self::create_with_dek(root, epoch_created, None)
1638 }
1639
1640 pub fn create_with_dek(
1642 root: &Path,
1643 epoch_created: Epoch,
1644 wal_dek: Option<Zeroizing<[u8; 32]>>,
1645 ) -> Result<Self> {
1646 let root = Arc::new(crate::durable_file::DurableRoot::open(root)?);
1647 Self::create_with_durable_root(root, epoch_created, wal_dek)
1648 }
1649
1650 pub(crate) fn create_with_durable_root(
1651 root: Arc<crate::durable_file::DurableRoot>,
1652 epoch_created: Epoch,
1653 wal_dek: Option<Zeroizing<[u8; 32]>>,
1654 ) -> Result<Self> {
1655 let wal_root = Arc::new(root.create_directory_all_pinned("_wal")?);
1656 let wal_dir = wal_root.io_path()?;
1657 if !list_segment_numbers(&wal_root)?.is_empty() {
1658 return Err(MongrelError::CorruptWal {
1659 offset: 0,
1660 reason: "refuses to create a shared WAL over existing segments".into(),
1661 });
1662 }
1663 let cipher = match &wal_dek {
1664 #[cfg(feature = "encryption")]
1665 Some(dk) => Some(Self::cipher_from_dek(dk)?),
1666 #[cfg(not(feature = "encryption"))]
1667 Some(_) => {
1668 return Err(MongrelError::Encryption(
1669 "encryption feature disabled but a WAL DEK was supplied".into(),
1670 ))
1671 }
1672 None => None,
1673 };
1674 let active = Wal::create_chained_in(&wal_root, 0, epoch_created, cipher, [0; 32])?;
1675 if let Err(error) = write_wal_head(&root, &wal_root, 0, 0, wal_dek.as_ref()) {
1676 drop(active);
1677 let _ = wal_root.remove_file(segment_filename(0));
1678 return Err(error);
1679 }
1680 Ok(Self {
1681 root,
1682 wal_root,
1683 wal_dir,
1684 active,
1685 active_segment_no: 0,
1686 durable_seq: epoch_created.0,
1687 open_generation: 0,
1688 wal_dek,
1689 group_sync_count: 0,
1690 })
1691 }
1692
1693 pub fn open(
1698 root: &Path,
1699 epoch_created: Epoch,
1700 wal_dek: Option<Zeroizing<[u8; 32]>>,
1701 ) -> Result<Self> {
1702 let root = Arc::new(crate::durable_file::DurableRoot::open(root)?);
1703 Self::open_durable_root(root, epoch_created, wal_dek)
1704 }
1705
1706 pub(crate) fn open_durable_root(
1707 root: Arc<crate::durable_file::DurableRoot>,
1708 epoch_created: Epoch,
1709 wal_dek: Option<Zeroizing<[u8; 32]>>,
1710 ) -> Result<Self> {
1711 Self::open_durable_root_validated(root, epoch_created, wal_dek, None)
1712 }
1713
1714 pub(crate) fn open_durable_root_validated(
1715 root: Arc<crate::durable_file::DurableRoot>,
1716 epoch_created: Epoch,
1717 wal_dek: Option<Zeroizing<[u8; 32]>>,
1718 expected_records: Option<&[Record]>,
1719 ) -> Result<Self> {
1720 let wal_root =
1721 Arc::new(
1722 root.open_directory("_wal")
1723 .map_err(|error| MongrelError::CorruptWal {
1724 offset: 0,
1725 reason: format!(
1726 "existing database WAL directory cannot be opened: {error}"
1727 ),
1728 })?,
1729 );
1730 let wal_dir = wal_root.io_path()?;
1731 if let Some(expected) = expected_records {
1732 let layout = inspect_wal_layout(&root, &wal_root, wal_dek.as_ref())?;
1733 let actual = replay_wal_layout(&wal_root, &layout, wal_dek.as_ref())?;
1734 if bincode::serialize(&actual)? != bincode::serialize(expected)? {
1735 return Err(MongrelError::CorruptWal {
1736 offset: 0,
1737 reason: "WAL changed after recovery planning".into(),
1738 });
1739 }
1740 }
1741 remove_unpublished_header_only_segment(&root, &wal_root, wal_dek.as_ref())?;
1742 let layout = inspect_wal_layout(&root, &wal_root, wal_dek.as_ref())?;
1743 let final_segment =
1744 layout
1745 .segments
1746 .last()
1747 .copied()
1748 .ok_or_else(|| MongrelError::CorruptWal {
1749 offset: 0,
1750 reason: "existing database has no WAL segments".into(),
1751 })?;
1752 let records = replay_wal_layout(&wal_root, &layout, wal_dek.as_ref())?;
1753 let open_generation = layout
1754 .head
1755 .map(|head| head.open_generation)
1756 .unwrap_or_else(|| {
1757 records
1758 .iter()
1759 .filter(|record| record.txn_id != SYSTEM_TXN_ID)
1760 .map(|record| record.txn_id >> 32)
1761 .max()
1762 .unwrap_or(0)
1763 });
1764
1765 let durable_len = if let Some(head) = layout.head {
1769 head.durable_len
1770 } else {
1771 let mut reader = reader_for_segment(&wal_root, final_segment, wal_dek.as_ref())?;
1772 reader.replay()?;
1773 reader.current_offset()
1774 };
1775 let final_name = segment_filename(final_segment);
1776 let final_file = wal_root.open_regular_read_write(&final_name)?;
1777 if final_file.metadata()?.len() != durable_len {
1778 final_file.set_len(durable_len)?;
1779 final_file.sync_all()?;
1780 }
1781 let previous_segment_hash = hash_segment(&wal_root, final_segment)?;
1782 let next_segment_no = final_segment
1783 .checked_add(1)
1784 .ok_or_else(|| MongrelError::Full("WAL segment namespace exhausted".into()))?;
1785 let durable_seq = records
1786 .last()
1787 .map(|record| record.seq.0)
1788 .unwrap_or(epoch_created.0)
1789 .max(epoch_created.0);
1790 let cipher = match &wal_dek {
1791 #[cfg(feature = "encryption")]
1792 Some(dk) => Some(Self::cipher_from_dek(dk)?),
1793 #[cfg(not(feature = "encryption"))]
1794 Some(_) => {
1795 return Err(MongrelError::Encryption(
1796 "encryption feature disabled but a WAL DEK was supplied".into(),
1797 ))
1798 }
1799 None => None,
1800 };
1801 let mut active = Wal::create_chained_in(
1802 &wal_root,
1803 next_segment_no,
1804 epoch_created,
1805 cipher,
1806 previous_segment_hash,
1807 )?;
1808 active.next_seq = durable_seq
1809 .checked_add(1)
1810 .ok_or_else(|| MongrelError::Full("WAL sequence namespace exhausted".into()))?;
1811 if let Err(error) = write_wal_head(
1812 &root,
1813 &wal_root,
1814 next_segment_no,
1815 open_generation,
1816 wal_dek.as_ref(),
1817 ) {
1818 drop(active);
1819 let _ = wal_root.remove_file(segment_filename(next_segment_no));
1820 return Err(error);
1821 }
1822 Ok(Self {
1823 root,
1824 wal_root,
1825 wal_dir,
1826 active,
1827 active_segment_no: next_segment_no,
1828 durable_seq,
1829 open_generation,
1830 wal_dek,
1831 group_sync_count: 0,
1832 })
1833 }
1834
1835 #[allow(dead_code)]
1837 pub fn wal_dir(&self) -> &Path {
1838 &self.wal_dir
1839 }
1840
1841 pub fn append(&mut self, txn_id: u64, _table_id: u64, op: Op) -> Result<u64> {
1843 Ok(self.active.append_txn(txn_id, op)?.0)
1844 }
1845
1846 pub fn append_commit(&mut self, txn_id: u64, epoch: Epoch, added: &[AddedRun]) -> Result<u64> {
1848 self.append_commit_at(txn_id, epoch, added, unix_nanos_now())
1849 }
1850
1851 pub fn append_commit_at(
1852 &mut self,
1853 txn_id: u64,
1854 epoch: Epoch,
1855 added: &[AddedRun],
1856 unix_nanos: u64,
1857 ) -> Result<u64> {
1858 self.active
1859 .append_txn(txn_id, Op::CommitTimestamp { unix_nanos })?;
1860 Ok(self
1861 .active
1862 .append_txn(
1863 txn_id,
1864 Op::TxnCommit {
1865 epoch: epoch.0,
1866 added_runs: added.to_vec(),
1867 },
1868 )?
1869 .0)
1870 }
1871
1872 pub fn append_abort(&mut self, txn_id: u64) -> Result<()> {
1874 self.active.append_txn(txn_id, Op::TxnAbort)?;
1875 Ok(())
1876 }
1877
1878 pub fn append_system(&mut self, op: Op) -> Result<u64> {
1880 Ok(self.active.append_system(op)?.0)
1881 }
1882
1883 pub fn group_sync(&mut self) -> Result<u64> {
1887 self.active.sync()?;
1888 write_wal_head(
1889 &self.root,
1890 &self.wal_root,
1891 self.active_segment_no,
1892 self.open_generation,
1893 self.wal_dek.as_ref(),
1894 )?;
1895 self.group_sync_count += 1;
1896 let highest = self.active.next_seq_val().saturating_sub(1);
1897 if highest > self.durable_seq {
1898 self.durable_seq = highest;
1899 }
1900 Ok(self.durable_seq)
1901 }
1902
1903 pub fn group_sync_count(&self) -> u64 {
1905 self.group_sync_count
1906 }
1907
1908 pub fn durable_seq(&self) -> u64 {
1910 self.durable_seq
1911 }
1912
1913 pub(crate) fn seal_open_generation(&mut self, generation: u64) -> Result<()> {
1914 if generation < self.open_generation {
1915 return Err(MongrelError::CorruptWal {
1916 offset: generation,
1917 reason: format!(
1918 "open generation {generation} precedes WAL head generation {}",
1919 self.open_generation
1920 ),
1921 });
1922 }
1923 self.active.sync()?;
1924 let previous = self.open_generation;
1925 self.open_generation = generation;
1926 if let Err(error) = write_wal_head(
1927 &self.root,
1928 &self.wal_root,
1929 self.active_segment_no,
1930 self.open_generation,
1931 self.wal_dek.as_ref(),
1932 ) {
1933 self.open_generation = previous;
1934 return Err(error);
1935 }
1936 Ok(())
1937 }
1938
1939 pub fn rotate(&mut self, segment_no: u64) -> Result<()> {
1942 let expected = self
1943 .active_segment_no
1944 .checked_add(1)
1945 .ok_or_else(|| MongrelError::Full("WAL segment namespace exhausted".into()))?;
1946 if segment_no != expected {
1947 return Err(MongrelError::InvalidArgument(format!(
1948 "WAL rotation segment {segment_no} does not immediately follow {}",
1949 self.active_segment_no
1950 )));
1951 }
1952 self.active.sync()?;
1953 write_wal_head(
1954 &self.root,
1955 &self.wal_root,
1956 self.active_segment_no,
1957 self.open_generation,
1958 self.wal_dek.as_ref(),
1959 )?;
1960 let highest = self.active.next_seq_val().saturating_sub(1);
1961 self.durable_seq = self.durable_seq.max(highest);
1962 let previous_segment_hash = hash_segment(&self.wal_root, self.active_segment_no)?;
1963 let cipher = match &self.wal_dek {
1964 #[cfg(feature = "encryption")]
1965 Some(dk) => Some(Self::cipher_from_dek(dk)?),
1966 _ => None,
1967 };
1968 let epoch = Epoch(self.durable_seq);
1969 let wal = Wal::create_chained_in(
1970 &self.wal_root,
1971 segment_no,
1972 epoch,
1973 cipher,
1974 previous_segment_hash,
1975 )?;
1976 if let Err(error) = write_wal_head(
1977 &self.root,
1978 &self.wal_root,
1979 segment_no,
1980 self.open_generation,
1981 self.wal_dek.as_ref(),
1982 ) {
1983 drop(wal);
1984 let _ = self.wal_root.remove_file(segment_filename(segment_no));
1985 return Err(error);
1986 }
1987 self.active = wal;
1988 self.active_segment_no = segment_no;
1989 Ok(())
1990 }
1991
1992 pub fn active_segment_no(&self) -> u64 {
1994 self.active_segment_no
1995 }
1996
1997 pub(crate) fn reset_after_checkpoint(&mut self) -> Result<usize> {
2001 self.group_sync()?;
2002 let next_segment_no = list_segment_numbers(&self.wal_root)?
2003 .into_iter()
2004 .max()
2005 .ok_or_else(|| MongrelError::CorruptWal {
2006 offset: 0,
2007 reason: "active WAL segment disappeared before checkpoint reset".into(),
2008 })?
2009 .checked_add(1)
2010 .ok_or_else(|| MongrelError::Full("WAL segment namespace exhausted".into()))?;
2011 self.rotate(next_segment_no)?;
2012 self.group_sync()?;
2015 self.gc_segments_retain_recent(u64::MAX, 0)
2016 }
2017
2018 pub fn gc_segments(&mut self, min_retained_seq: u64) -> Result<usize> {
2028 self.gc_segments_retain_recent(min_retained_seq, 0)
2029 }
2030
2031 pub(crate) fn has_gc_segments_retain_recent(&self, retain_recent: usize) -> Result<bool> {
2032 let rotated = list_segment_numbers(&self.wal_root)?
2033 .into_iter()
2034 .filter(|segment| *segment != self.active_segment_no)
2035 .count();
2036 Ok(rotated > retain_recent)
2037 }
2038
2039 pub fn gc_segments_retain_recent(
2043 &mut self,
2044 min_retained_seq: u64,
2045 retain_recent: usize,
2046 ) -> Result<usize> {
2047 self.group_sync()?;
2048 let layout = inspect_wal_layout(&self.root, &self.wal_root, self.wal_dek.as_ref())?;
2049 let readable = replay_wal_layout(&self.wal_root, &layout, self.wal_dek.as_ref())?;
2050 let segments = &layout.segments;
2051 let retained: Vec<u64> = segments
2052 .iter()
2053 .rev()
2054 .filter(|segment| **segment != self.active_segment_no)
2055 .take(retain_recent)
2056 .copied()
2057 .collect();
2058 let mut candidates = Vec::new();
2059 let mut prefix_open = true;
2060 for n in segments.iter().copied() {
2061 let mut reader = reader_for_segment(&self.wal_root, n, self.wal_dek.as_ref())?;
2062 if layout.head.is_some_and(|head| head.segment_no == n) {
2063 reader.constrain_to_durable_len(layout.head.unwrap().durable_len)?;
2064 }
2065 let records = reader.replay_strict()?;
2066 let below_floor = min_retained_seq == u64::MAX
2067 || records.iter().map(|record| record.seq.0).max().unwrap_or(0) < min_retained_seq;
2068 let reapable =
2069 prefix_open && n != self.active_segment_no && !retained.contains(&n) && below_floor;
2070 if reapable {
2071 candidates.push((n, records));
2072 } else {
2073 prefix_open = false;
2074 }
2075 }
2076
2077 let commits = readable
2078 .iter()
2079 .filter_map(|record| match record.op {
2080 Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
2081 _ => None,
2082 })
2083 .collect::<std::collections::HashMap<_, _>>();
2084 let removed_floor = candidates
2085 .iter()
2086 .flat_map(|(_, records)| records)
2087 .filter_map(|record| commits.get(&record.txn_id).copied())
2088 .max();
2089 if let Some(epoch) = removed_floor {
2090 crate::replication::advance_replication_wal_floor_durable(&self.root, epoch)?;
2091 }
2092 for (segment_no, _) in &candidates {
2093 self.wal_root.remove_file(segment_filename(*segment_no))?;
2094 }
2095 let reaped = candidates.len();
2096 Ok(reaped)
2097 }
2098
2099 pub fn verify_segments(&self) -> Vec<(u64, String)> {
2107 let result = inspect_wal_layout(&self.root, &self.wal_root, self.wal_dek.as_ref())
2108 .and_then(|layout| replay_wal_layout(&self.wal_root, &layout, self.wal_dek.as_ref()));
2109 match result {
2110 Ok(_) => Vec::new(),
2111 Err(error) => vec![(u64::MAX, error.to_string())],
2112 }
2113 }
2114
2115 pub fn replay(root: &Path) -> Result<Vec<Record>> {
2119 Self::replay_with_dek(root, None)
2120 }
2121
2122 pub fn replay_with_dek(
2124 root: &Path,
2125 wal_dek: Option<&Zeroizing<[u8; 32]>>,
2126 ) -> Result<Vec<Record>> {
2127 let root = crate::durable_file::DurableRoot::open(root)?;
2128 Self::replay_durable_with_dek(&root, wal_dek)
2129 }
2130
2131 pub(crate) fn replay_durable_with_dek(
2132 root: &crate::durable_file::DurableRoot,
2133 wal_dek: Option<&Zeroizing<[u8; 32]>>,
2134 ) -> Result<Vec<Record>> {
2135 let wal_root = root.open_directory("_wal")?;
2136 let layout = inspect_wal_layout(root, &wal_root, wal_dek)?;
2137 replay_wal_layout(&wal_root, &layout, wal_dek)
2138 }
2139
2140 pub(crate) fn durable_open_generation(
2141 root: &crate::durable_file::DurableRoot,
2142 wal_dek: Option<&Zeroizing<[u8; 32]>>,
2143 ) -> Result<Option<u64>> {
2144 let wal_root = root.open_directory("_wal")?;
2145 let layout = inspect_wal_layout(root, &wal_root, wal_dek)?;
2146 Ok(layout.head.map(|head| head.open_generation))
2147 }
2148
2149 pub fn replay_with_dek_controlled(
2152 root: &Path,
2153 wal_dek: Option<&Zeroizing<[u8; 32]>>,
2154 control: &crate::ExecutionControl,
2155 max_records: usize,
2156 max_bytes: usize,
2157 ) -> Result<Vec<Record>> {
2158 let root = crate::durable_file::DurableRoot::open(root)?;
2159 let wal_root = root.open_directory("_wal")?;
2160 let layout = inspect_wal_layout(&root, &wal_root, wal_dek)?;
2161 let total_bytes = layout.segments.iter().try_fold(0_usize, |total, segment| {
2162 let bytes = if layout.head.is_some_and(|head| head.segment_no == *segment) {
2163 usize::try_from(layout.head.unwrap().durable_len).unwrap_or(usize::MAX)
2164 } else {
2165 usize::try_from(
2166 wal_root
2167 .open_regular(segment_filename(*segment))?
2168 .metadata()?
2169 .len(),
2170 )
2171 .unwrap_or(usize::MAX)
2172 };
2173 Ok::<_, MongrelError>(total.saturating_add(bytes))
2174 })?;
2175 if total_bytes > max_bytes {
2176 return Err(MongrelError::ResourceLimitExceeded {
2177 resource: "controlled WAL replay bytes",
2178 requested: total_bytes,
2179 limit: max_bytes,
2180 });
2181 }
2182 let mut out = Vec::new();
2183 let final_segment = layout.segments.last().copied();
2184 for n in layout.segments.iter().copied() {
2185 control.checkpoint()?;
2186 let remaining = max_records.saturating_sub(out.len());
2187 let mut reader = reader_for_segment(&wal_root, n, wal_dek)?;
2188 if layout.head.is_some_and(|head| head.segment_no == n) {
2189 reader.constrain_to_durable_len(layout.head.unwrap().durable_len)?;
2190 }
2191 let records = if Some(n) == final_segment && layout.head.is_none() {
2192 reader.replay_controlled(control, remaining)?
2193 } else {
2194 reader.replay_controlled_strict(control, remaining)?
2195 };
2196 out.extend(records);
2197 }
2198 validate_shared_transaction_framing(&out)?;
2199 Ok(out)
2200 }
2201}
2202
2203fn segment_filename(segment_no: u64) -> String {
2204 format!("seg-{segment_no:06}.wal")
2205}
2206
2207fn segment_number_from_path(path: &Path) -> Option<u64> {
2208 path.file_name()
2209 .and_then(|name| name.to_str())
2210 .and_then(|name| name.strip_prefix("seg-"))
2211 .and_then(|name| name.strip_suffix(".wal"))
2212 .and_then(|number| number.parse().ok())
2213}
2214
2215fn list_segment_numbers(wal_root: &crate::durable_file::DurableRoot) -> Result<Vec<u64>> {
2220 let mut segments = Vec::new();
2221 for fname in wal_root.list_regular_files(".")? {
2222 let s = fname.to_str().ok_or_else(|| MongrelError::CorruptWal {
2223 offset: 0,
2224 reason: "WAL directory contains a non-UTF-8 entry".into(),
2225 })?;
2226 if !s.starts_with("seg-") {
2227 continue;
2228 }
2229 let number = s
2230 .strip_prefix("seg-")
2231 .and_then(|value| value.strip_suffix(".wal"))
2232 .and_then(|value| value.parse::<u64>().ok())
2233 .ok_or_else(|| MongrelError::CorruptWal {
2234 offset: 0,
2235 reason: format!("malformed WAL segment filename {s:?}"),
2236 })?;
2237 if s != segment_filename(number) {
2238 return Err(MongrelError::CorruptWal {
2239 offset: 0,
2240 reason: format!("non-canonical WAL segment filename {s:?}"),
2241 });
2242 }
2243 segments.push(number);
2244 }
2245 segments.sort_unstable();
2246 for pair in segments.windows(2) {
2247 let expected = pair[0]
2248 .checked_add(1)
2249 .ok_or_else(|| MongrelError::CorruptWal {
2250 offset: pair[0],
2251 reason: "WAL segment namespace overflows after u64::MAX".into(),
2252 })?;
2253 if pair[1] != expected {
2254 return Err(MongrelError::CorruptWal {
2255 offset: pair[1],
2256 reason: format!(
2257 "WAL segment {} does not immediately follow {}",
2258 pair[1], pair[0]
2259 ),
2260 });
2261 }
2262 }
2263 Ok(segments)
2264}
2265
2266#[cfg(test)]
2267mod shared_wal_tests {
2268 use super::*;
2269 use tempfile::tempdir;
2270
2271 fn write_legacy_segment(path: &Path, records: &[Record]) {
2272 let mut bytes = Vec::new();
2273 bytes.extend_from_slice(&WAL_MAGIC);
2274 bytes.extend_from_slice(&LEGACY_WAL_VERSION.to_le_bytes());
2275 bytes.extend_from_slice(&[ENC_PLAINTEXT, 0, 0, 0]);
2276 bytes.extend_from_slice(&0_u64.to_le_bytes());
2277 for record in records {
2278 let payload = bincode::serialize(record).unwrap();
2279 let mut digest = CRC32C.digest();
2280 digest.update(&record.seq.0.to_le_bytes());
2281 digest.update(&record.txn_id.to_le_bytes());
2282 digest.update(&payload);
2283 bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes());
2284 bytes.extend_from_slice(&digest.finalize().to_le_bytes());
2285 bytes.extend_from_slice(&record.seq.0.to_le_bytes());
2286 bytes.extend_from_slice(&record.txn_id.to_le_bytes());
2287 bytes.extend_from_slice(&payload);
2288 }
2289 std::fs::write(path, bytes).unwrap();
2290 }
2291
2292 #[test]
2293 fn shared_wal_interleaves_two_tables_one_fd() {
2294 let dir = tempdir().unwrap();
2295 let mut w = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2296 w.append(
2297 1,
2298 10,
2299 Op::Put {
2300 table_id: 10,
2301 rows: vec![1],
2302 },
2303 )
2304 .unwrap();
2305 w.append(
2306 2,
2307 20,
2308 Op::Put {
2309 table_id: 20,
2310 rows: vec![2],
2311 },
2312 )
2313 .unwrap();
2314 w.append_commit(1, Epoch(1), &[]).unwrap();
2315 w.append_commit(2, Epoch(2), &[]).unwrap();
2316 let d = w.group_sync().unwrap();
2317 assert!(d >= 4);
2318 let recs = SharedWal::replay(dir.path()).unwrap();
2319 assert_eq!(
2320 recs.iter()
2321 .filter(|r| matches!(r.op, Op::Put { .. }))
2322 .count(),
2323 2
2324 );
2325 assert_eq!(
2326 recs.iter()
2327 .filter(|r| matches!(r.op, Op::TxnCommit { .. }))
2328 .count(),
2329 2
2330 );
2331 }
2332
2333 #[test]
2334 fn controlled_shared_replay_rejects_aggregate_wal_bytes() {
2335 let dir = tempdir().unwrap();
2336 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2337 wal.append_commit(1, Epoch(1), &[]).unwrap();
2338 wal.group_sync().unwrap();
2339 let control = crate::ExecutionControl::new(None);
2340
2341 let error =
2342 SharedWal::replay_with_dek_controlled(dir.path(), None, &control, usize::MAX, 1)
2343 .unwrap_err();
2344 assert!(matches!(
2345 error,
2346 MongrelError::ResourceLimitExceeded {
2347 resource: "controlled WAL replay bytes",
2348 ..
2349 }
2350 ));
2351 }
2352
2353 #[test]
2354 fn shared_wal_gc_retains_recent_rotated_segments() {
2355 let dir = tempdir().unwrap();
2356 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2357 for segment in 0..4u64 {
2358 wal.append_commit(segment + 1, Epoch(segment + 1), &[])
2359 .unwrap();
2360 wal.group_sync().unwrap();
2361 if segment < 3 {
2362 wal.rotate(segment + 1).unwrap();
2363 }
2364 }
2365 assert_eq!(wal.gc_segments_retain_recent(u64::MAX, 2).unwrap(), 1);
2366 let count = std::fs::read_dir(dir.path().join("_wal"))
2367 .unwrap()
2368 .flatten()
2369 .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "wal"))
2370 .count();
2371 assert_eq!(count, 3, "active plus two retained segments");
2372 }
2373
2374 #[test]
2375 fn shared_replay_rejects_torn_tail_in_rotated_segment() {
2376 let dir = tempdir().unwrap();
2377 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2378 wal.append_commit(1, Epoch(1), &[]).unwrap();
2379 wal.group_sync().unwrap();
2380 wal.rotate(1).unwrap();
2381 wal.append_commit(2, Epoch(2), &[]).unwrap();
2382 wal.group_sync().unwrap();
2383 drop(wal);
2384
2385 let old = dir.path().join("_wal/seg-000000.wal");
2386 let mut file = OpenOptions::new().append(true).open(old).unwrap();
2387 file.write_all(&[1, 2, 3]).unwrap();
2388 file.sync_all().unwrap();
2389
2390 assert!(matches!(
2391 SharedWal::replay(dir.path()),
2392 Err(MongrelError::CorruptWal { .. })
2393 ));
2394 }
2395
2396 #[test]
2397 fn shared_replay_rejects_records_after_commit() {
2398 let dir = tempdir().unwrap();
2399 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2400 wal.append_commit(7, Epoch(1), &[]).unwrap();
2401 wal.append(
2402 7,
2403 1,
2404 Op::Delete {
2405 table_id: 1,
2406 row_ids: vec![RowId(9)],
2407 },
2408 )
2409 .unwrap();
2410 wal.group_sync().unwrap();
2411 drop(wal);
2412
2413 assert!(matches!(
2414 SharedWal::replay(dir.path()),
2415 Err(MongrelError::CorruptWal { .. })
2416 ));
2417 }
2418
2419 #[test]
2420 fn rotate_without_explicit_group_sync_keeps_sequence_adjacent() {
2421 let dir = tempdir().unwrap();
2422 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2423 assert_eq!(
2424 wal.append_system(Op::Flush {
2425 table_id: 1,
2426 flushed_epoch: 1,
2427 })
2428 .unwrap(),
2429 1
2430 );
2431 wal.rotate(1).unwrap();
2432 assert_eq!(
2433 wal.append_system(Op::Flush {
2434 table_id: 1,
2435 flushed_epoch: 2,
2436 })
2437 .unwrap(),
2438 2
2439 );
2440 wal.group_sync().unwrap();
2441 let records = SharedWal::replay(dir.path()).unwrap();
2442 assert_eq!(
2443 records
2444 .iter()
2445 .map(|record| record.seq.0)
2446 .collect::<Vec<_>>(),
2447 vec![1, 2]
2448 );
2449 }
2450
2451 #[test]
2452 fn open_recovers_exact_header_only_segment_crash_window() {
2453 let dir = tempdir().unwrap();
2454 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2455 wal.append_commit(1, Epoch(1), &[]).unwrap();
2456 wal.group_sync().unwrap();
2457 drop(wal);
2458
2459 let root = crate::durable_file::DurableRoot::open(dir.path()).unwrap();
2460 let wal_root = root.open_directory("_wal").unwrap();
2461 let previous_hash = hash_segment(&wal_root, 0).unwrap();
2462 drop(Wal::create_chained_in(&wal_root, 1, Epoch(1), None, previous_hash).unwrap());
2463 assert_eq!(read_wal_head(&root, None).unwrap().unwrap().segment_no, 0);
2464
2465 let reopened = SharedWal::open(dir.path(), Epoch(1), None).unwrap();
2466 assert_eq!(reopened.active_segment_no(), 1);
2467 assert_eq!(list_segment_numbers(&wal_root).unwrap(), vec![0, 1]);
2468 assert_eq!(read_wal_head(&root, None).unwrap().unwrap().segment_no, 1);
2469 }
2470
2471 #[test]
2472 fn replay_rejects_duplicate_sequence_and_backward_commit_epoch() {
2473 let duplicate = tempdir().unwrap();
2474 std::fs::create_dir(duplicate.path().join("_wal")).unwrap();
2475 write_legacy_segment(
2476 &duplicate.path().join("_wal/seg-000000.wal"),
2477 &[Record::new(
2478 Epoch(7),
2479 SYSTEM_TXN_ID,
2480 Op::Flush {
2481 table_id: 1,
2482 flushed_epoch: 1,
2483 },
2484 )],
2485 );
2486 write_legacy_segment(
2487 &duplicate.path().join("_wal/seg-000001.wal"),
2488 &[Record::new(
2489 Epoch(7),
2490 SYSTEM_TXN_ID,
2491 Op::Flush {
2492 table_id: 1,
2493 flushed_epoch: 2,
2494 },
2495 )],
2496 );
2497 assert!(matches!(
2498 SharedWal::replay(duplicate.path()),
2499 Err(MongrelError::CorruptWal { .. })
2500 ));
2501
2502 let epochs = tempdir().unwrap();
2503 std::fs::create_dir(epochs.path().join("_wal")).unwrap();
2504 write_legacy_segment(
2505 &epochs.path().join("_wal/seg-000000.wal"),
2506 &[
2507 Record::new(Epoch(1), 1, Op::CommitTimestamp { unix_nanos: 1 }),
2508 Record::new(
2509 Epoch(2),
2510 1,
2511 Op::TxnCommit {
2512 epoch: 2,
2513 added_runs: Vec::new(),
2514 },
2515 ),
2516 Record::new(Epoch(3), 2, Op::CommitTimestamp { unix_nanos: 2 }),
2517 Record::new(
2518 Epoch(4),
2519 2,
2520 Op::TxnCommit {
2521 epoch: 1,
2522 added_runs: Vec::new(),
2523 },
2524 ),
2525 ],
2526 );
2527 assert!(matches!(
2528 SharedWal::replay(epochs.path()),
2529 Err(MongrelError::CorruptWal { .. })
2530 ));
2531 }
2532
2533 #[test]
2534 fn legacy_v3_replays_then_migrates_to_chained_v4() {
2535 let dir = tempdir().unwrap();
2536 std::fs::create_dir(dir.path().join("_wal")).unwrap();
2537 write_legacy_segment(
2538 &dir.path().join("_wal/seg-000000.wal"),
2539 &[
2540 Record::new(Epoch(1), 1, Op::CommitTimestamp { unix_nanos: 1 }),
2541 Record::new(
2542 Epoch(2),
2543 1,
2544 Op::TxnCommit {
2545 epoch: 1,
2546 added_runs: Vec::new(),
2547 },
2548 ),
2549 ],
2550 );
2551 assert_eq!(SharedWal::replay(dir.path()).unwrap().len(), 2);
2552 let wal = SharedWal::open(dir.path(), Epoch(1), None).unwrap();
2553 assert_eq!(wal.active_segment_no(), 1);
2554 drop(wal);
2555 assert_eq!(SharedWal::replay(dir.path()).unwrap().len(), 2);
2556 let root = crate::durable_file::DurableRoot::open(dir.path()).unwrap();
2557 assert_eq!(read_wal_head(&root, None).unwrap().unwrap().segment_no, 1);
2558 }
2559
2560 #[test]
2561 fn head_and_strict_segment_names_detect_deletion_gaps_and_aliases() {
2562 let deleted = tempdir().unwrap();
2563 let wal = SharedWal::create(deleted.path(), Epoch(0)).unwrap();
2564 drop(wal);
2565 std::fs::remove_file(deleted.path().join("_wal/seg-000000.wal")).unwrap();
2566 assert!(SharedWal::replay(deleted.path()).is_err());
2567
2568 let alias = tempdir().unwrap();
2569 let wal = SharedWal::create(alias.path(), Epoch(0)).unwrap();
2570 drop(wal);
2571 std::fs::rename(
2572 alias.path().join("_wal/seg-000000.wal"),
2573 alias.path().join("_wal/seg-0.wal"),
2574 )
2575 .unwrap();
2576 assert!(SharedWal::replay(alias.path()).is_err());
2577
2578 let gap = tempdir().unwrap();
2579 let mut wal = SharedWal::create(gap.path(), Epoch(0)).unwrap();
2580 wal.rotate(1).unwrap();
2581 drop(wal);
2582 std::fs::rename(
2583 gap.path().join("_wal/seg-000001.wal"),
2584 gap.path().join("_wal/seg-000002.wal"),
2585 )
2586 .unwrap();
2587 assert!(SharedWal::replay(gap.path()).is_err());
2588 }
2589
2590 #[test]
2591 fn verify_and_gc_fail_closed_on_segment_corruption() {
2592 let dir = tempdir().unwrap();
2593 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
2594 wal.append_commit(1, Epoch(1), &[]).unwrap();
2595 wal.group_sync().unwrap();
2596 wal.rotate(1).unwrap();
2597 wal.append_commit(2, Epoch(2), &[]).unwrap();
2598 wal.group_sync().unwrap();
2599
2600 let old = dir.path().join("_wal/seg-000000.wal");
2601 let mut bytes = std::fs::read(&old).unwrap();
2602 let last = bytes.len() - 1;
2603 bytes[last] ^= 0x40;
2604 std::fs::write(&old, bytes).unwrap();
2605 assert!(!wal.verify_segments().is_empty());
2606 assert!(wal.gc_segments(u64::MAX).is_err());
2607 assert!(old.exists());
2608 }
2609}
2610
2611#[cfg(test)]
2612mod tests {
2613 use super::*;
2614 use tempfile::tempdir;
2615
2616 fn frame_ranges(bytes: &[u8]) -> Vec<std::ops::Range<usize>> {
2617 let mut ranges = Vec::new();
2618 let mut offset = HEADER_LEN as usize;
2619 while offset < bytes.len() {
2620 let mut length = [0_u8; 4];
2621 length.copy_from_slice(&bytes[offset..offset + 4]);
2622 let end = offset + 24 + u32::from_le_bytes(length) as usize;
2623 ranges.push(offset..end);
2624 offset = end;
2625 }
2626 ranges
2627 }
2628
2629 fn recompute_frame_crc(bytes: &mut [u8], start: usize) {
2630 let mut length = [0_u8; 4];
2631 length.copy_from_slice(&bytes[start..start + 4]);
2632 let length = u32::from_le_bytes(length) as usize;
2633 let seq = &bytes[start + 8..start + 16];
2634 let txn_id = &bytes[start + 16..start + 24];
2635 let payload = &bytes[start + 24..start + 24 + length];
2636 let mut digest = CRC32C.digest();
2637 digest.update(seq);
2638 digest.update(txn_id);
2639 digest.update(payload);
2640 bytes[start + 4..start + 8].copy_from_slice(&digest.finalize().to_le_bytes());
2641 }
2642
2643 #[test]
2644 fn append_then_replay_roundtrips() {
2645 let dir = tempdir().unwrap();
2646 let path = dir.path().join("seg-000000.wal");
2647 let mut wal = Wal::create(&path, Epoch(100)).unwrap();
2648 let s1 = wal
2649 .append_txn(
2650 7,
2651 Op::Put {
2652 table_id: 1,
2653 rows: vec![1, 2, 3],
2654 },
2655 )
2656 .unwrap();
2657 let s2 = wal
2658 .append_txn(
2659 7,
2660 Op::Delete {
2661 table_id: 1,
2662 row_ids: vec![RowId(7)],
2663 },
2664 )
2665 .unwrap();
2666 assert_eq!(s1, Epoch(101));
2667 assert_eq!(s2, Epoch(102));
2668 wal.sync().unwrap();
2669
2670 let records = replay(&path).unwrap();
2671 assert_eq!(records.len(), 2);
2672 assert_eq!(records[0].seq, Epoch(101));
2673 assert_eq!(records[0].txn_id, 7);
2674 match &records[0].op {
2675 Op::Put { table_id, rows } => {
2676 assert_eq!(*table_id, 1);
2677 assert_eq!(rows, &vec![1, 2, 3]);
2678 }
2679 other => panic!("unexpected op {other:?}"),
2680 }
2681 match &records[1].op {
2682 Op::Delete { row_ids, .. } => {
2683 assert_eq!(*row_ids, vec![RowId(7)]);
2684 }
2685 other => panic!("unexpected op {other:?}"),
2686 }
2687 }
2688
2689 #[test]
2690 fn record_roundtrips_with_txn_id_and_commit_marker() {
2691 let dir = tempdir().unwrap();
2692 let path = dir.path().join("seg-000000.wal");
2693 let mut w = Wal::create(&path, Epoch(0)).unwrap();
2694 w.append_txn(
2695 7,
2696 Op::Put {
2697 table_id: 3,
2698 rows: vec![1, 2, 3],
2699 },
2700 )
2701 .unwrap();
2702 w.append_txn(
2703 7,
2704 Op::TxnCommit {
2705 epoch: 11,
2706 added_runs: vec![],
2707 },
2708 )
2709 .unwrap();
2710 w.sync().unwrap();
2711 let recs = replay(&path).unwrap();
2712 assert_eq!(recs[0].txn_id, 7);
2713 assert!(matches!(recs[0].op, Op::Put { table_id: 3, .. }));
2714 assert!(matches!(recs[1].op, Op::TxnCommit { epoch: 11, .. }));
2715 let system_path = dir.path().join("seg-000001.wal");
2717 let mut w2 = Wal::create(&system_path, Epoch(0)).unwrap();
2718 w2.append_system(Op::Flush {
2719 table_id: 3,
2720 flushed_epoch: 11,
2721 })
2722 .unwrap();
2723 w2.sync().unwrap();
2724 let recs = replay(&system_path).unwrap();
2725 assert_eq!(recs[0].txn_id, SYSTEM_TXN_ID);
2726 assert!(matches!(recs[0].op, Op::Flush { .. }));
2727 }
2728
2729 #[test]
2730 fn catalog_snapshot_and_external_reset_roundtrip() {
2731 let dir = tempdir().unwrap();
2732 let path = dir.path().join("seg-catalog.wal");
2733 let mut wal = Wal::create(&path, Epoch(0)).unwrap();
2734 wal.append_txn(
2735 9,
2736 Op::Ddl(DdlOp::CatalogSnapshot {
2737 catalog_json: br#"{"db_epoch":7}"#.to_vec(),
2738 }),
2739 )
2740 .unwrap();
2741 wal.append_txn(
2742 9,
2743 Op::Ddl(DdlOp::ResetExternalTableState {
2744 name: "ext".into(),
2745 generation_epoch: 7,
2746 }),
2747 )
2748 .unwrap();
2749 wal.sync().unwrap();
2750
2751 let records = replay(&path).unwrap();
2752 assert!(matches!(
2753 &records[0].op,
2754 Op::Ddl(DdlOp::CatalogSnapshot { catalog_json })
2755 if catalog_json == br#"{"db_epoch":7}"#
2756 ));
2757 assert!(matches!(
2758 &records[1].op,
2759 Op::Ddl(DdlOp::ResetExternalTableState {
2760 name,
2761 generation_epoch: 7,
2762 }) if name == "ext"
2763 ));
2764 }
2765
2766 #[test]
2767 fn torn_write_is_detected() {
2768 let dir = tempdir().unwrap();
2769 let path = dir.path().join("seg-000001.wal");
2770 let mut wal = Wal::create(&path, Epoch(0)).unwrap();
2771 wal.append_txn(
2772 1,
2773 Op::Put {
2774 table_id: 1,
2775 rows: vec![0; 10],
2776 },
2777 )
2778 .unwrap();
2779 wal.sync().unwrap();
2780 drop(wal);
2781
2782 let mut f = OpenOptions::new().append(true).open(&path).unwrap();
2784 f.write_all(&64u32.to_le_bytes()).unwrap();
2786 f.write_all(&[0u8; 7]).unwrap();
2787 f.sync_all().unwrap();
2788 drop(f);
2789
2790 let mut reader = WalReader::open(&path).unwrap();
2791 assert!(reader.next_record().unwrap().is_some());
2793 let err = reader.next_record().unwrap_err();
2795 assert!(matches!(err, MongrelError::TornWrite { .. }), "got {err:?}");
2796 }
2797
2798 #[test]
2799 fn crc_corruption_is_detected() {
2800 let dir = tempdir().unwrap();
2801 let path = dir.path().join("seg-000002.wal");
2802 let mut wal = Wal::create(&path, Epoch(0)).unwrap();
2803 wal.append_txn(
2804 1,
2805 Op::Put {
2806 table_id: 9,
2807 rows: vec![1, 2, 3, 4],
2808 },
2809 )
2810 .unwrap();
2811 wal.sync().unwrap();
2812 drop(wal);
2813
2814 let mut bytes = std::fs::read(&path).unwrap();
2816 let last = bytes.len() - 1;
2817 bytes[last] ^= 0xFF;
2818 std::fs::write(&path, bytes).unwrap();
2819
2820 let err = WalReader::open(&path).unwrap().next_record().unwrap_err();
2821 assert!(
2822 matches!(err, MongrelError::CorruptWal { .. }),
2823 "got {err:?}"
2824 );
2825 }
2826
2827 #[test]
2828 fn trailing_torn_is_eof_but_interior_corruption_errors() {
2829 let dir = tempdir().unwrap();
2830
2831 let path_a = dir.path().join("seg-torn.wal");
2834 let mut wal = Wal::create(&path_a, Epoch(0)).unwrap();
2835 wal.append_txn(
2836 1,
2837 Op::Put {
2838 table_id: 1,
2839 rows: vec![1],
2840 },
2841 )
2842 .unwrap();
2843 wal.append_txn(
2844 1,
2845 Op::Put {
2846 table_id: 1,
2847 rows: vec![2],
2848 },
2849 )
2850 .unwrap();
2851 wal.sync().unwrap();
2852 drop(wal);
2853 let mut f = OpenOptions::new().append(true).open(&path_a).unwrap();
2855 f.write_all(&64u32.to_le_bytes()).unwrap();
2856 f.write_all(&[0u8; 7]).unwrap();
2857 f.sync_all().unwrap();
2858 drop(f);
2859 let recs = replay(&path_a).unwrap();
2860 assert_eq!(recs.len(), 2, "torn trailing frame must truncate cleanly");
2861
2862 let path_b = dir.path().join("seg-interior.wal");
2865 let mut wal = Wal::create(&path_b, Epoch(0)).unwrap();
2866 wal.append_txn(
2867 1,
2868 Op::Put {
2869 table_id: 1,
2870 rows: vec![10, 20, 30],
2871 },
2872 )
2873 .unwrap();
2874 wal.append_txn(
2875 1,
2876 Op::Put {
2877 table_id: 1,
2878 rows: vec![40],
2879 },
2880 )
2881 .unwrap();
2882 wal.sync().unwrap();
2883 drop(wal);
2884 let mut bytes = std::fs::read(&path_b).unwrap();
2887 let first_payload_byte = HEADER_LEN as usize + 4 + 4 + 8 + 8; bytes[first_payload_byte] ^= 0xFF;
2889 std::fs::write(&path_b, bytes).unwrap();
2890 let err = replay(&path_b).unwrap_err();
2891 assert!(
2892 matches!(err, MongrelError::CorruptWal { .. }),
2893 "interior corruption must error, got {err:?}"
2894 );
2895
2896 let path_c = dir.path().join("seg-badtail.wal");
2899 let mut wal = Wal::create(&path_c, Epoch(0)).unwrap();
2900 wal.append_txn(
2901 1,
2902 Op::Put {
2903 table_id: 1,
2904 rows: vec![5],
2905 },
2906 )
2907 .unwrap();
2908 wal.sync().unwrap();
2909 drop(wal);
2910 let mut bytes = std::fs::read(&path_c).unwrap();
2911 let last = bytes.len() - 1;
2912 bytes[last] ^= 0xFF;
2913 std::fs::write(&path_c, bytes).unwrap();
2914 assert!(matches!(
2915 replay(&path_c),
2916 Err(MongrelError::CorruptWal { .. })
2917 ));
2918 }
2919
2920 #[test]
2921 fn byte_threshold_auto_syncs() {
2922 let dir = tempdir().unwrap();
2923 let path = dir.path().join("seg-000003.wal");
2924 let mut wal = Wal::create(&path, Epoch(0)).unwrap();
2925 wal.sync_byte_threshold = 1; wal.append_txn(
2927 1,
2928 Op::Put {
2929 table_id: 1,
2930 rows: vec![0; 5],
2931 },
2932 )
2933 .unwrap();
2934 assert_eq!(
2935 wal.unflushed_bytes(),
2936 0,
2937 "threshold should have auto-synced"
2938 );
2939 }
2940
2941 #[test]
2942 fn create_never_replaces_an_existing_segment() {
2943 let dir = tempdir().unwrap();
2944 let path = dir.path().join("seg-000000.wal");
2945 drop(Wal::create(&path, Epoch(0)).unwrap());
2946 let before = std::fs::read(&path).unwrap();
2947 assert!(matches!(
2948 Wal::create(&path, Epoch(0)),
2949 Err(MongrelError::Io(error)) if error.kind() == std::io::ErrorKind::AlreadyExists
2950 ));
2951 assert_eq!(std::fs::read(path).unwrap(), before);
2952 }
2953
2954 #[test]
2955 fn zero_length_frame_never_hides_a_wal_suffix() {
2956 let dir = tempdir().unwrap();
2957 let path = dir.path().join("seg-000000.wal");
2958 let mut wal = Wal::create(&path, Epoch(0)).unwrap();
2959 wal.append_system(Op::Flush {
2960 table_id: 1,
2961 flushed_epoch: 1,
2962 })
2963 .unwrap();
2964 wal.sync().unwrap();
2965 drop(wal);
2966 let original = std::fs::read(&path).unwrap();
2967 let mut bytes = original[..HEADER_LEN as usize].to_vec();
2968 bytes.extend_from_slice(&0_u32.to_le_bytes());
2969 bytes.extend_from_slice(&original[HEADER_LEN as usize..]);
2970 std::fs::write(&path, bytes).unwrap();
2971 assert!(matches!(
2972 replay(&path),
2973 Err(MongrelError::CorruptWal { .. })
2974 ));
2975 }
2976
2977 #[test]
2978 fn reader_rejects_outer_inner_record_identity_mismatch() {
2979 let dir = tempdir().unwrap();
2980 let path = dir.path().join("seg-000000.wal");
2981 let mut wal = Wal::create(&path, Epoch(0)).unwrap();
2982 wal.append_system(Op::Flush {
2983 table_id: 1,
2984 flushed_epoch: 1,
2985 })
2986 .unwrap();
2987 wal.sync().unwrap();
2988 drop(wal);
2989
2990 let mut bytes = std::fs::read(&path).unwrap();
2991 let start = HEADER_LEN as usize;
2992 let mut length = [0_u8; 4];
2993 length.copy_from_slice(&bytes[start..start + 4]);
2994 let length = u32::from_le_bytes(length) as usize;
2995 let payload = &bytes[start + 24..start + 24 + length];
2996 let mut record: Record = bincode::deserialize(payload).unwrap();
2997 record.seq = Epoch(99);
2998 let replacement = bincode::serialize(&record).unwrap();
2999 assert_eq!(replacement.len(), length);
3000 bytes[start + 24..start + 24 + length].copy_from_slice(&replacement);
3001 recompute_frame_crc(&mut bytes, start);
3002 std::fs::write(&path, bytes).unwrap();
3003
3004 assert!(matches!(
3005 WalReader::open(&path).unwrap().next_record(),
3006 Err(MongrelError::CorruptWal { .. })
3007 ));
3008 }
3009
3010 #[cfg(feature = "encryption")]
3011 #[test]
3012 fn encrypted_frames_reject_reorder_replay_deletion_and_cross_segment_move() {
3013 fn cipher(key: &[u8; 32]) -> Box<dyn crate::encryption::Cipher> {
3014 Box::new(crate::encryption::AesCipher::new(key).unwrap())
3015 }
3016
3017 let dir = tempdir().unwrap();
3018 let key = [0x5a; 32];
3019 let path = dir.path().join("seg-000009.wal");
3020 let mut wal = Wal::create_with_cipher(&path, Epoch(0), Some(cipher(&key)), 9).unwrap();
3021 for table_id in [1, 2] {
3022 wal.append_system(Op::Flush {
3023 table_id,
3024 flushed_epoch: table_id,
3025 })
3026 .unwrap();
3027 }
3028 wal.sync().unwrap();
3029 drop(wal);
3030 let original = std::fs::read(&path).unwrap();
3031 let ranges = frame_ranges(&original);
3032 assert_eq!(ranges.len(), 2);
3033
3034 let mut reordered = original[..HEADER_LEN as usize].to_vec();
3035 reordered.extend_from_slice(&original[ranges[1].clone()]);
3036 reordered.extend_from_slice(&original[ranges[0].clone()]);
3037 std::fs::write(&path, reordered).unwrap();
3038 assert!(replay_with_cipher(&path, Some(cipher(&key))).is_err());
3039
3040 let mut replayed = original[..HEADER_LEN as usize].to_vec();
3041 replayed.extend_from_slice(&original[ranges[0].clone()]);
3042 replayed.extend_from_slice(&original[ranges[0].clone()]);
3043 replayed.extend_from_slice(&original[ranges[1].clone()]);
3044 std::fs::write(&path, replayed).unwrap();
3045 assert!(replay_with_cipher(&path, Some(cipher(&key))).is_err());
3046
3047 let mut deleted = original[..HEADER_LEN as usize].to_vec();
3048 deleted.extend_from_slice(&original[ranges[1].clone()]);
3049 std::fs::write(&path, deleted).unwrap();
3050 assert!(replay_with_cipher(&path, Some(cipher(&key))).is_err());
3051
3052 let mut outer_tampered = original.clone();
3053 outer_tampered[ranges[0].start + 8] ^= 0x01;
3054 recompute_frame_crc(&mut outer_tampered, ranges[0].start);
3055 std::fs::write(&path, outer_tampered).unwrap();
3056 assert!(replay_with_cipher(&path, Some(cipher(&key))).is_err());
3057
3058 let other = dir.path().join("seg-000010.wal");
3059 let mut wal = Wal::create_with_cipher(&other, Epoch(0), Some(cipher(&key)), 10).unwrap();
3060 wal.append_system(Op::Flush {
3061 table_id: 3,
3062 flushed_epoch: 3,
3063 })
3064 .unwrap();
3065 wal.sync().unwrap();
3066 drop(wal);
3067 let mut moved = std::fs::read(&other).unwrap();
3068 moved.truncate(HEADER_LEN as usize);
3069 moved.extend_from_slice(&original[ranges[0].clone()]);
3070 std::fs::write(&other, moved).unwrap();
3071 assert!(replay_with_cipher(&other, Some(cipher(&key))).is_err());
3072 }
3073
3074 #[cfg(feature = "encryption")]
3075 #[test]
3076 fn wal_nonce_is_segment_deterministic() {
3077 assert_ne!(frame_nonce_for(5, 0), frame_nonce_for(6, 0));
3080 assert_ne!(frame_nonce_for(5, 0), frame_nonce_for(5, 1));
3081 assert_eq!(frame_nonce_for(5, 0), frame_nonce_for(5, 0));
3084 }
3085}