1use crate::epoch::Epoch;
9use crate::manifest::TtlPolicy;
10use crate::rowid::RowId;
11use crate::schema::{ColumnDef, Schema};
12use crate::{MongrelError, Result};
13use crc::{Crc, CRC_32_ISCSI};
14use serde::{Deserialize, Serialize};
15use std::fs::{File, OpenOptions};
16use std::io::{BufReader, BufWriter, Read, Write};
17use std::path::{Path, PathBuf};
18use zeroize::Zeroizing;
19
20fn unix_nanos_now() -> u64 {
21 std::time::SystemTime::now()
22 .duration_since(std::time::UNIX_EPOCH)
23 .unwrap_or_default()
24 .as_nanos() as u64
25}
26
27pub const WAL_MAGIC: [u8; 8] = *b"MONGRWAL";
28const WAL_VERSION: u16 = 3;
29const HEADER_LEN: u64 = 8 + 2 + 4 + 8; const ENC_PLAINTEXT: u8 = 0;
32const ENC_AES_GCM: u8 = 1;
33
34pub const SYSTEM_TXN_ID: u64 = 0;
37
38const CRC32C: Crc<u32> = Crc::<u32>::new(&CRC_32_ISCSI);
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct Record {
46 pub seq: Epoch,
47 pub txn_id: u64,
48 pub op: Op,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct AddedRun {
55 pub table_id: u64,
56 pub run_id: u128,
57 pub row_count: u64,
58 pub level: u8,
59 pub min_row_id: u64,
60 pub max_row_id: u64,
61 pub content_hash: [u8; 32],
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
69pub enum DdlOp {
70 CreateTable {
71 table_id: u64,
72 name: String,
73 schema_json: Vec<u8>,
74 },
75 DropTable {
76 table_id: u64,
77 },
78 AlterTable {
81 table_id: u64,
82 column_json: Vec<u8>,
83 },
84 RenameTable {
90 table_id: u64,
91 new_name: String,
92 },
93 SetTtl {
95 table_id: u64,
96 policy_json: Vec<u8>,
97 },
98 SetMaterializedView {
101 name: String,
102 definition_json: Vec<u8>,
103 },
104 SetSecurityCatalog {
106 security_json: Vec<u8>,
107 },
108}
109
110impl DdlOp {
111 pub fn encode_schema(schema: &Schema) -> Result<Vec<u8>> {
113 serde_json::to_vec(schema).map_err(|e| MongrelError::Other(format!("schema json: {e}")))
114 }
115
116 pub fn decode_schema(bytes: &[u8]) -> Result<Schema> {
118 serde_json::from_slice(bytes).map_err(|e| MongrelError::Other(format!("schema json: {e}")))
119 }
120
121 pub fn encode_column(column: &ColumnDef) -> Result<Vec<u8>> {
122 serde_json::to_vec(column).map_err(|e| MongrelError::Other(format!("column json: {e}")))
123 }
124
125 pub fn decode_column(bytes: &[u8]) -> Result<ColumnDef> {
126 serde_json::from_slice(bytes).map_err(|e| MongrelError::Other(format!("column json: {e}")))
127 }
128
129 pub fn encode_ttl(policy: Option<TtlPolicy>) -> Result<Vec<u8>> {
130 serde_json::to_vec(&policy).map_err(|e| MongrelError::Other(format!("TTL json: {e}")))
131 }
132
133 pub fn decode_ttl(bytes: &[u8]) -> Result<Option<TtlPolicy>> {
134 serde_json::from_slice(bytes).map_err(|e| MongrelError::Other(format!("TTL json: {e}")))
135 }
136
137 pub fn encode_materialized_view(
138 definition: &crate::catalog::MaterializedViewEntry,
139 ) -> Result<Vec<u8>> {
140 serde_json::to_vec(definition)
141 .map_err(|e| MongrelError::Other(format!("materialized view json: {e}")))
142 }
143
144 pub fn decode_materialized_view(bytes: &[u8]) -> Result<crate::catalog::MaterializedViewEntry> {
145 serde_json::from_slice(bytes)
146 .map_err(|e| MongrelError::Other(format!("materialized view json: {e}")))
147 }
148
149 pub fn encode_security(security: &crate::security::SecurityCatalog) -> Result<Vec<u8>> {
150 serde_json::to_vec(security)
151 .map_err(|e| MongrelError::Other(format!("security catalog json: {e}")))
152 }
153
154 pub fn decode_security(bytes: &[u8]) -> Result<crate::security::SecurityCatalog> {
155 serde_json::from_slice(bytes)
156 .map_err(|e| MongrelError::Other(format!("security catalog json: {e}")))
157 }
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub enum Op {
162 Put {
163 table_id: u64,
164 rows: Vec<u8>,
165 },
166 Delete {
167 table_id: u64,
168 row_ids: Vec<RowId>,
169 },
170 TruncateTable {
171 table_id: u64,
172 },
173 ExternalTableState {
177 name: String,
178 state: Vec<u8>,
179 },
180 Flush {
184 table_id: u64,
185 flushed_epoch: u64,
186 },
187 TxnCommit {
190 epoch: u64,
191 added_runs: Vec<AddedRun>,
192 },
193 TxnAbort,
195 Ddl(DdlOp),
196 BeforeImage {
200 table_id: u64,
201 row_id: RowId,
202 row: Vec<u8>,
203 },
204 CommitTimestamp {
207 unix_nanos: u64,
208 },
209}
210
211impl Record {
212 pub fn new(seq: Epoch, txn_id: u64, op: Op) -> Self {
213 Self { seq, txn_id, op }
214 }
215}
216
217pub struct Wal {
220 file: BufWriter<File>,
221 path: PathBuf,
222 next_seq: u64,
224 unflushed_bytes: u64,
225 sync_byte_threshold: u64,
227 cipher: Option<Box<dyn crate::encryption::Cipher>>,
230 segment_no: u64,
239 frame_seq: u64,
245}
246
247impl Wal {
248 pub fn create(path: impl AsRef<Path>, epoch_created: Epoch) -> Result<Self> {
250 Self::create_with_cipher(path, epoch_created, None, 0)
251 }
252
253 pub fn create_with_cipher(
257 path: impl AsRef<Path>,
258 epoch_created: Epoch,
259 cipher: Option<Box<dyn crate::encryption::Cipher>>,
260 segment_no: u64,
261 ) -> Result<Self> {
262 let path = path.as_ref().to_path_buf();
263 let file = OpenOptions::new()
264 .create(true)
265 .read(true)
266 .write(true)
267 .truncate(true)
268 .open(&path)?;
269 let mut wal = Self {
270 file: BufWriter::with_capacity(1 << 20, file),
271 path,
272 next_seq: epoch_created.0 + 1,
273 unflushed_bytes: 0,
274 sync_byte_threshold: 64 * 1024,
275 cipher,
276 segment_no,
277 frame_seq: 0,
278 };
279 wal.write_header(epoch_created)?;
280 Ok(wal)
281 }
282
283 pub fn append_txn(&mut self, txn_id: u64, op: Op) -> Result<Epoch> {
290 let seq = Epoch(self.next_seq);
291 self.next_seq += 1;
292 self.append_record(&Record::new(seq, txn_id, op))?;
293 Ok(seq)
294 }
295
296 pub fn append_system(&mut self, op: Op) -> Result<Epoch> {
298 self.append_txn(SYSTEM_TXN_ID, op)
299 }
300
301 fn append_record(&mut self, record: &Record) -> Result<()> {
302 let payload = bincode::serialize(record)?;
303
304 let frame_payload = if let Some(cipher) = &self.cipher {
307 if self.frame_seq > u32::MAX as u64 {
312 return Err(MongrelError::Full(
313 "wal segment frame counter exhausted (2^32); rotate the segment".into(),
314 ));
315 }
316 let nonce = self.frame_nonce();
317 let ciphertext = cipher.encrypt_page(&nonce, &payload)?;
318 self.frame_seq += 1;
319 let mut combined = Vec::with_capacity(12 + ciphertext.len());
320 combined.extend_from_slice(&nonce);
321 combined.extend_from_slice(&ciphertext);
322 combined
323 } else {
324 payload
325 };
326
327 let len = frame_payload.len();
328 if len > u32::MAX as usize {
329 return Err(MongrelError::InvalidArgument(format!(
330 "wal payload too large: {len} bytes"
331 )));
332 }
333 let mut digest = CRC32C.digest();
335 digest.update(&record.seq.0.to_le_bytes());
336 digest.update(&record.txn_id.to_le_bytes());
337 digest.update(&frame_payload);
338 let crc_val = digest.finalize();
339
340 self.file.write_all(&(len as u32).to_le_bytes())?;
341 self.file.write_all(&crc_val.to_le_bytes())?;
342 self.file.write_all(&record.seq.0.to_le_bytes())?;
343 self.file.write_all(&record.txn_id.to_le_bytes())?;
344 self.file.write_all(&frame_payload)?;
345 self.unflushed_bytes += 4 + 4 + 8 + 8 + len as u64;
346 if self.sync_byte_threshold > 0 && self.unflushed_bytes >= self.sync_byte_threshold {
347 self.sync()?;
348 }
349 Ok(())
350 }
351
352 fn frame_nonce(&self) -> [u8; 12] {
358 frame_nonce_for(self.segment_no, self.frame_seq as u32)
359 }
360
361 pub fn sync(&mut self) -> Result<()> {
363 self.file.flush()?;
364 self.file.get_ref().sync_all()?;
365 self.unflushed_bytes = 0;
366 Ok(())
367 }
368
369 #[inline]
371 pub fn unflushed_bytes(&self) -> u64 {
372 self.unflushed_bytes
373 }
374
375 #[inline]
378 pub fn next_seq_val(&self) -> u64 {
379 self.next_seq
380 }
381
382 pub fn set_sync_byte_threshold(&mut self, threshold: u64) {
387 self.sync_byte_threshold = threshold;
388 }
389
390 pub fn path(&self) -> &Path {
391 &self.path
392 }
393
394 fn write_header(&mut self, epoch_created: Epoch) -> Result<()> {
395 let enc_flag = if self.cipher.is_some() {
396 ENC_AES_GCM
397 } else {
398 ENC_PLAINTEXT
399 };
400 self.file.write_all(&WAL_MAGIC)?;
401 self.file.write_all(&WAL_VERSION.to_le_bytes())?;
402 self.file.write_all(&[enc_flag, 0, 0, 0])?; self.file.write_all(&epoch_created.0.to_le_bytes())?;
404 self.unflushed_bytes = 0;
405 Ok(())
406 }
407}
408
409impl Drop for Wal {
410 fn drop(&mut self) {
411 let _ = self.file.flush();
412 }
413}
414
415pub struct WalReader {
418 inner: BufReader<File>,
419 pos: u64,
420 encrypted: bool,
422 cipher: Option<Box<dyn crate::encryption::Cipher>>,
424}
425
426impl WalReader {
427 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
428 Self::open_with_cipher(path, None)
429 }
430
431 pub fn open_with_cipher(
433 path: impl AsRef<Path>,
434 cipher: Option<Box<dyn crate::encryption::Cipher>>,
435 ) -> Result<Self> {
436 let mut file = File::open(path.as_ref())?;
437 let mut magic = [0u8; 8];
438 file.read_exact(&mut magic)?;
439 if magic != WAL_MAGIC {
440 return Err(MongrelError::MagicMismatch {
441 what: "wal",
442 expected: WAL_MAGIC,
443 got: magic,
444 });
445 }
446 let mut version_buf = [0u8; 2];
447 file.read_exact(&mut version_buf)?;
448 let version = u16::from_le_bytes(version_buf);
449 if version != WAL_VERSION {
450 return Err(MongrelError::InvalidArgument(format!(
451 "unsupported wal version {version}"
452 )));
453 }
454 let mut reserved = [0u8; 4];
455 file.read_exact(&mut reserved)?;
456 let encrypted = reserved[0] == ENC_AES_GCM;
457 let mut epoch_buf = [0u8; 8];
458 file.read_exact(&mut epoch_buf)?;
459 let _epoch_created = Epoch(u64::from_le_bytes(epoch_buf));
460 let pos = HEADER_LEN;
461 if encrypted && cipher.is_none() {
462 return Err(MongrelError::Decryption(
463 "WAL is encrypted but no passphrase or key was provided. \
464 Use Table::open_encrypted or Table::open_with_key."
465 .into(),
466 ));
467 }
468 Ok(Self {
469 inner: BufReader::new(file),
470 pos,
471 encrypted,
472 cipher,
473 })
474 }
475
476 pub fn next_record(&mut self) -> Result<Option<Record>> {
479 let mut len_buf = [0u8; 4];
480 match self.inner.read_exact(&mut len_buf) {
481 Ok(()) => {}
482 Err(ref e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
483 Err(e) => return Err(e.into()),
484 }
485 let len = u32::from_le_bytes(len_buf) as usize;
486 if len == 0 {
487 return Ok(None);
488 }
489 const MAX_RECORD_LEN: usize = 64 * 1024 * 1024;
492 if len > MAX_RECORD_LEN {
493 return Err(MongrelError::TornWrite { offset: self.pos });
494 }
495
496 let record_start = self.pos;
497 let mut rest = vec![0u8; 4 + 8 + 8 + len];
498 match self.inner.read_exact(&mut rest) {
499 Ok(()) => {}
500 Err(ref e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
501 return Err(MongrelError::TornWrite {
502 offset: record_start,
503 });
504 }
505 Err(e) => return Err(e.into()),
506 }
507 let crc_val = u32::from_le_bytes([rest[0], rest[1], rest[2], rest[3]]);
508 let seq = u64::from_le_bytes([
509 rest[4], rest[5], rest[6], rest[7], rest[8], rest[9], rest[10], rest[11],
510 ]);
511 let txn_id = u64::from_le_bytes([
512 rest[12], rest[13], rest[14], rest[15], rest[16], rest[17], rest[18], rest[19],
513 ]);
514 let payload = &rest[20..];
515
516 let mut digest = CRC32C.digest();
517 digest.update(&seq.to_le_bytes());
518 digest.update(&txn_id.to_le_bytes());
519 digest.update(payload);
520 if digest.finalize() != crc_val {
521 return Err(MongrelError::CorruptWal {
522 offset: record_start,
523 reason: "crc mismatch".into(),
524 });
525 }
526
527 let plaintext = if self.encrypted {
529 let Some(cipher) = &self.cipher else {
530 return Err(MongrelError::Decryption(
531 "WAL is encrypted but no cipher was provided".into(),
532 ));
533 };
534 if payload.len() < 28 {
535 return Err(MongrelError::CorruptWal {
537 offset: record_start,
538 reason: "encrypted frame too short".into(),
539 });
540 }
541 let nonce: [u8; 12] = payload[..12].try_into().unwrap();
542 let ciphertext = &payload[12..];
543 cipher.decrypt_page(&nonce, ciphertext).map_err(|e| {
544 MongrelError::Decryption(format!(
545 "WAL frame decryption failed — wrong passphrase or key? ({e})"
546 ))
547 })?
548 } else {
549 payload.to_vec()
550 };
551
552 let record: Record = bincode::deserialize(&plaintext)?;
559 self.pos += 4 + 4 + 8 + 8 + len as u64;
560 Ok(Some(record))
561 }
562
563 pub fn replay(&mut self) -> Result<Vec<Record>> {
569 let mut out = Vec::new();
570 loop {
571 match self.next_record() {
572 Ok(Some(rec)) => out.push(rec),
573 Ok(None) => break,
574 Err(MongrelError::TornWrite { offset }) => {
575 if self.valid_frame_follows()? {
578 return Err(MongrelError::CorruptWal {
579 offset,
580 reason: "interior torn frame followed by a valid frame".into(),
581 });
582 }
583 break;
584 }
585 Err(MongrelError::CorruptWal { offset, .. }) => {
586 if self.valid_frame_follows()? {
589 return Err(MongrelError::CorruptWal {
590 offset,
591 reason: "interior corruption: valid frame follows a CRC mismatch"
592 .into(),
593 });
594 }
595 break;
596 }
597 Err(e) => return Err(e),
598 }
599 }
600 Ok(out)
601 }
602
603 fn valid_frame_follows(&mut self) -> Result<bool> {
608 match self.next_record() {
609 Ok(Some(_)) => Ok(true),
610 Ok(None) => Ok(false),
611 Err(_) => Ok(false),
612 }
613 }
614
615 pub fn current_offset(&self) -> u64 {
618 self.pos
619 }
620}
621
622pub fn replay(path: impl AsRef<Path>) -> Result<Vec<Record>> {
625 WalReader::open(path)?.replay()
626}
627
628pub fn replay_with_cipher(
630 path: impl AsRef<Path>,
631 cipher: Option<Box<dyn crate::encryption::Cipher>>,
632) -> Result<Vec<Record>> {
633 WalReader::open_with_cipher(path, cipher)?.replay()
634}
635
636pub fn frame_nonce_for(segment_no: u64, frame: u32) -> [u8; 12] {
641 let mut n = [0u8; 12];
642 n[..8].copy_from_slice(&segment_no.to_be_bytes());
643 n[8..].copy_from_slice(&frame.to_le_bytes());
644 n
645}
646
647pub struct SharedWal {
653 wal_dir: PathBuf,
654 active: Wal,
655 active_segment_no: u64,
657 durable_seq: u64,
660 wal_dek: Option<Zeroizing<[u8; 32]>>,
663 group_sync_count: u64,
667}
668
669impl SharedWal {
670 fn segment_path(wal_dir: &Path, segment_no: u64) -> PathBuf {
672 wal_dir.join(format!("seg-{segment_no:06}.wal"))
673 }
674
675 #[cfg(feature = "encryption")]
677 fn cipher_from_dek(dek: &Zeroizing<[u8; 32]>) -> Result<Box<dyn crate::encryption::Cipher>> {
678 Ok(Box::new(crate::encryption::AesCipher::new(&dek[..])?))
679 }
680
681 pub fn create(root: &Path, epoch_created: Epoch) -> Result<Self> {
683 Self::create_with_dek(root, epoch_created, None)
684 }
685
686 pub fn create_with_dek(
688 root: &Path,
689 epoch_created: Epoch,
690 wal_dek: Option<Zeroizing<[u8; 32]>>,
691 ) -> Result<Self> {
692 let wal_dir = root.join("_wal");
693 std::fs::create_dir_all(&wal_dir)?;
694 let cipher = match &wal_dek {
695 #[cfg(feature = "encryption")]
696 Some(dk) => Some(Self::cipher_from_dek(dk)?),
697 #[cfg(not(feature = "encryption"))]
698 Some(_) => {
699 return Err(MongrelError::Encryption(
700 "encryption feature disabled but a WAL DEK was supplied".into(),
701 ))
702 }
703 None => None,
704 };
705 let active =
706 Wal::create_with_cipher(Self::segment_path(&wal_dir, 0), epoch_created, cipher, 0)?;
707 Ok(Self {
708 wal_dir,
709 active,
710 active_segment_no: 0,
711 durable_seq: epoch_created.0,
712 wal_dek,
713 group_sync_count: 0,
714 })
715 }
716
717 pub fn open(
722 root: &Path,
723 epoch_created: Epoch,
724 wal_dek: Option<Zeroizing<[u8; 32]>>,
725 ) -> Result<Self> {
726 let wal_dir = root.join("_wal");
727 std::fs::create_dir_all(&wal_dir)?;
728 let next_segment_no = list_segment_numbers(&wal_dir)?
729 .into_iter()
730 .max()
731 .map(|m| m + 1)
732 .unwrap_or(0);
733 let cipher = match &wal_dek {
734 #[cfg(feature = "encryption")]
735 Some(dk) => Some(Self::cipher_from_dek(dk)?),
736 #[cfg(not(feature = "encryption"))]
737 Some(_) => {
738 return Err(MongrelError::Encryption(
739 "encryption feature disabled but a WAL DEK was supplied".into(),
740 ))
741 }
742 None => None,
743 };
744 let mut active = Wal::create_with_cipher(
745 Self::segment_path(&wal_dir, next_segment_no),
746 epoch_created,
747 cipher,
748 next_segment_no,
749 )?;
750 active.sync()?;
753 Ok(Self {
754 wal_dir,
755 active,
756 active_segment_no: next_segment_no,
757 durable_seq: epoch_created.0,
758 wal_dek,
759 group_sync_count: 0,
760 })
761 }
762
763 #[allow(dead_code)]
765 pub fn wal_dir(&self) -> &Path {
766 &self.wal_dir
767 }
768
769 pub fn append(&mut self, txn_id: u64, _table_id: u64, op: Op) -> Result<u64> {
771 Ok(self.active.append_txn(txn_id, op)?.0)
772 }
773
774 pub fn append_commit(&mut self, txn_id: u64, epoch: Epoch, added: &[AddedRun]) -> Result<u64> {
776 self.append_commit_at(txn_id, epoch, added, unix_nanos_now())
777 }
778
779 pub fn append_commit_at(
780 &mut self,
781 txn_id: u64,
782 epoch: Epoch,
783 added: &[AddedRun],
784 unix_nanos: u64,
785 ) -> Result<u64> {
786 self.active
787 .append_txn(txn_id, Op::CommitTimestamp { unix_nanos })?;
788 Ok(self
789 .active
790 .append_txn(
791 txn_id,
792 Op::TxnCommit {
793 epoch: epoch.0,
794 added_runs: added.to_vec(),
795 },
796 )?
797 .0)
798 }
799
800 pub fn append_abort(&mut self, txn_id: u64) -> Result<()> {
802 self.active.append_txn(txn_id, Op::TxnAbort)?;
803 Ok(())
804 }
805
806 pub fn append_system(&mut self, op: Op) -> Result<u64> {
808 Ok(self.active.append_system(op)?.0)
809 }
810
811 pub fn group_sync(&mut self) -> Result<u64> {
815 self.active.sync()?;
816 self.group_sync_count += 1;
817 let highest = self.active.next_seq_val().saturating_sub(1);
818 if highest > self.durable_seq {
819 self.durable_seq = highest;
820 }
821 Ok(self.durable_seq)
822 }
823
824 pub fn group_sync_count(&self) -> u64 {
826 self.group_sync_count
827 }
828
829 pub fn durable_seq(&self) -> u64 {
831 self.durable_seq
832 }
833
834 pub fn rotate(&mut self, segment_no: u64) -> Result<()> {
837 let cipher = match &self.wal_dek {
838 #[cfg(feature = "encryption")]
839 Some(dk) => Some(Self::cipher_from_dek(dk)?),
840 _ => None,
841 };
842 let path = Self::segment_path(&self.wal_dir, segment_no);
843 let epoch = Epoch(self.durable_seq);
844 let wal = Wal::create_with_cipher(path, epoch, cipher, segment_no)?;
845 self.active = wal;
846 self.active_segment_no = segment_no;
847 Ok(())
848 }
849
850 pub fn active_segment_no(&self) -> u64 {
852 self.active_segment_no
853 }
854
855 pub fn gc_segments(&mut self, min_retained_seq: u64) -> Result<usize> {
865 self.gc_segments_retain_recent(min_retained_seq, 0)
866 }
867
868 pub fn gc_segments_retain_recent(
872 &mut self,
873 min_retained_seq: u64,
874 retain_recent: usize,
875 ) -> Result<usize> {
876 let mut segments = list_segment_numbers(&self.wal_dir)?;
877 segments.sort_unstable();
878 let retained: Vec<u64> = segments
879 .iter()
880 .rev()
881 .filter(|segment| **segment != self.active_segment_no)
882 .take(retain_recent)
883 .copied()
884 .collect();
885 let mut reaped = 0;
886 for n in segments {
887 if n == self.active_segment_no || retained.contains(&n) {
888 continue; }
890 let path = Self::segment_path(&self.wal_dir, n);
891 let reapable = if min_retained_seq == u64::MAX {
894 true
895 } else {
896 let recs = match &self.wal_dek {
901 #[cfg(feature = "encryption")]
902 Some(dk) => {
903 let cipher = Self::cipher_from_dek(dk)?;
904 replay_with_cipher(&path, Some(cipher))
905 }
906 _ => replay(&path),
907 };
908 match recs {
909 Ok(recs) => recs.iter().map(|r| r.seq.0).max().unwrap_or(0) < min_retained_seq,
910 Err(_) => true,
911 }
912 };
913 if reapable {
914 std::fs::remove_file(&path)?;
915 reaped += 1;
916 }
917 }
918 if reaped > 0 {
919 if let Ok(d) = std::fs::File::open(&self.wal_dir) {
920 let _ = d.sync_all();
921 }
922 }
923 Ok(reaped)
924 }
925
926 pub fn verify_segments(&self) -> Vec<(u64, String)> {
934 let mut bad = Vec::new();
935 let Ok(segments) = list_segment_numbers(&self.wal_dir) else {
936 return bad;
937 };
938 for n in segments {
939 let path = Self::segment_path(&self.wal_dir, n);
940 let res = match &self.wal_dek {
943 #[cfg(feature = "encryption")]
944 Some(dk) => match Self::cipher_from_dek(dk) {
945 Ok(cipher) => WalReader::open_with_cipher(&path, Some(cipher)),
946 Err(e) => Err(e),
947 },
948 _ => WalReader::open_with_cipher(&path, None),
949 };
950 if let Err(e) = res {
951 bad.push((n, format!("{e}")));
952 }
953 }
954 bad
955 }
956
957 pub fn replay(root: &Path) -> Result<Vec<Record>> {
960 Self::replay_with_dek(root, None)
961 }
962
963 pub fn replay_with_dek(
965 root: &Path,
966 wal_dek: Option<&Zeroizing<[u8; 32]>>,
967 ) -> Result<Vec<Record>> {
968 let wal_dir = root.join("_wal");
969 let mut segments = list_segment_numbers(&wal_dir)?;
970 segments.sort_unstable();
971 let mut out = Vec::new();
972 for n in segments {
973 let path = Self::segment_path(&wal_dir, n);
974 let recs = match wal_dek {
977 #[cfg(feature = "encryption")]
978 Some(dk) => {
979 let cipher = Self::cipher_from_dek(dk)?;
980 replay_with_cipher(&path, Some(cipher))?
981 }
982 _ => replay(&path)?,
983 };
984 out.extend(recs);
985 }
986 Ok(out)
987 }
988}
989
990fn list_segment_numbers(wal_dir: &Path) -> Result<Vec<u64>> {
992 let mut segments = Vec::new();
993 if let Ok(rd) = std::fs::read_dir(wal_dir) {
994 for entry in rd.flatten() {
995 let fname = entry.file_name();
996 let Some(s) = fname.to_str() else {
997 continue;
998 };
999 let Some(stripped) = s.strip_prefix("seg-") else {
1000 continue;
1001 };
1002 let Some(stripped) = stripped.strip_suffix(".wal") else {
1003 continue;
1004 };
1005 if let Ok(n) = stripped.parse::<u64>() {
1006 segments.push(n);
1007 }
1008 }
1009 }
1010 Ok(segments)
1011}
1012
1013#[cfg(test)]
1014mod shared_wal_tests {
1015 use super::*;
1016 use tempfile::tempdir;
1017
1018 #[test]
1019 fn shared_wal_interleaves_two_tables_one_fd() {
1020 let dir = tempdir().unwrap();
1021 let mut w = SharedWal::create(dir.path(), Epoch(0)).unwrap();
1022 w.append(
1023 1,
1024 10,
1025 Op::Put {
1026 table_id: 10,
1027 rows: vec![1],
1028 },
1029 )
1030 .unwrap();
1031 w.append(
1032 2,
1033 20,
1034 Op::Put {
1035 table_id: 20,
1036 rows: vec![2],
1037 },
1038 )
1039 .unwrap();
1040 w.append_commit(1, Epoch(1), &[]).unwrap();
1041 w.append_commit(2, Epoch(2), &[]).unwrap();
1042 let d = w.group_sync().unwrap();
1043 assert!(d >= 4);
1044 let recs = SharedWal::replay(dir.path()).unwrap();
1045 assert_eq!(
1046 recs.iter()
1047 .filter(|r| matches!(r.op, Op::Put { .. }))
1048 .count(),
1049 2
1050 );
1051 assert_eq!(
1052 recs.iter()
1053 .filter(|r| matches!(r.op, Op::TxnCommit { .. }))
1054 .count(),
1055 2
1056 );
1057 }
1058
1059 #[test]
1060 fn shared_wal_gc_retains_recent_rotated_segments() {
1061 let dir = tempdir().unwrap();
1062 let mut wal = SharedWal::create(dir.path(), Epoch(0)).unwrap();
1063 for segment in 0..4u64 {
1064 wal.append_commit(segment + 1, Epoch(segment + 1), &[])
1065 .unwrap();
1066 wal.group_sync().unwrap();
1067 if segment < 3 {
1068 wal.rotate(segment + 1).unwrap();
1069 }
1070 }
1071 assert_eq!(wal.gc_segments_retain_recent(u64::MAX, 2).unwrap(), 1);
1072 let count = std::fs::read_dir(dir.path().join("_wal"))
1073 .unwrap()
1074 .flatten()
1075 .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "wal"))
1076 .count();
1077 assert_eq!(count, 3, "active plus two retained segments");
1078 }
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083 use super::*;
1084 use tempfile::tempdir;
1085
1086 #[test]
1087 fn append_then_replay_roundtrips() {
1088 let dir = tempdir().unwrap();
1089 let path = dir.path().join("seg-000000.wal");
1090 let mut wal = Wal::create(&path, Epoch(100)).unwrap();
1091 let s1 = wal
1092 .append_txn(
1093 7,
1094 Op::Put {
1095 table_id: 1,
1096 rows: vec![1, 2, 3],
1097 },
1098 )
1099 .unwrap();
1100 let s2 = wal
1101 .append_txn(
1102 7,
1103 Op::Delete {
1104 table_id: 1,
1105 row_ids: vec![RowId(7)],
1106 },
1107 )
1108 .unwrap();
1109 assert_eq!(s1, Epoch(101));
1110 assert_eq!(s2, Epoch(102));
1111 wal.sync().unwrap();
1112
1113 let records = replay(&path).unwrap();
1114 assert_eq!(records.len(), 2);
1115 assert_eq!(records[0].seq, Epoch(101));
1116 assert_eq!(records[0].txn_id, 7);
1117 match &records[0].op {
1118 Op::Put { table_id, rows } => {
1119 assert_eq!(*table_id, 1);
1120 assert_eq!(rows, &vec![1, 2, 3]);
1121 }
1122 other => panic!("unexpected op {other:?}"),
1123 }
1124 match &records[1].op {
1125 Op::Delete { row_ids, .. } => {
1126 assert_eq!(*row_ids, vec![RowId(7)]);
1127 }
1128 other => panic!("unexpected op {other:?}"),
1129 }
1130 }
1131
1132 #[test]
1133 fn record_roundtrips_with_txn_id_and_commit_marker() {
1134 let dir = tempdir().unwrap();
1135 let path = dir.path().join("seg-000000.wal");
1136 let mut w = Wal::create(&path, Epoch(0)).unwrap();
1137 w.append_txn(
1138 7,
1139 Op::Put {
1140 table_id: 3,
1141 rows: vec![1, 2, 3],
1142 },
1143 )
1144 .unwrap();
1145 w.append_txn(
1146 7,
1147 Op::TxnCommit {
1148 epoch: 11,
1149 added_runs: vec![],
1150 },
1151 )
1152 .unwrap();
1153 w.sync().unwrap();
1154 let recs = replay(&path).unwrap();
1155 assert_eq!(recs[0].txn_id, 7);
1156 assert!(matches!(recs[0].op, Op::Put { table_id: 3, .. }));
1157 assert!(matches!(recs[1].op, Op::TxnCommit { epoch: 11, .. }));
1158 let mut w2 = Wal::create(&path, Epoch(0)).unwrap();
1160 w2.append_system(Op::Flush {
1161 table_id: 3,
1162 flushed_epoch: 11,
1163 })
1164 .unwrap();
1165 w2.sync().unwrap();
1166 let recs = replay(&path).unwrap();
1167 assert_eq!(recs[0].txn_id, SYSTEM_TXN_ID);
1168 assert!(matches!(recs[0].op, Op::Flush { .. }));
1169 }
1170
1171 #[test]
1172 fn torn_write_is_detected() {
1173 let dir = tempdir().unwrap();
1174 let path = dir.path().join("seg-000001.wal");
1175 let mut wal = Wal::create(&path, Epoch(0)).unwrap();
1176 wal.append_txn(
1177 1,
1178 Op::Put {
1179 table_id: 1,
1180 rows: vec![0; 10],
1181 },
1182 )
1183 .unwrap();
1184 wal.sync().unwrap();
1185 drop(wal);
1186
1187 let mut f = OpenOptions::new().append(true).open(&path).unwrap();
1189 f.write_all(&64u32.to_le_bytes()).unwrap();
1191 f.write_all(&[0u8; 7]).unwrap();
1192 f.sync_all().unwrap();
1193 drop(f);
1194
1195 let mut reader = WalReader::open(&path).unwrap();
1196 assert!(reader.next_record().unwrap().is_some());
1198 let err = reader.next_record().unwrap_err();
1200 assert!(matches!(err, MongrelError::TornWrite { .. }), "got {err:?}");
1201 }
1202
1203 #[test]
1204 fn crc_corruption_is_detected() {
1205 let dir = tempdir().unwrap();
1206 let path = dir.path().join("seg-000002.wal");
1207 let mut wal = Wal::create(&path, Epoch(0)).unwrap();
1208 wal.append_txn(
1209 1,
1210 Op::Put {
1211 table_id: 9,
1212 rows: vec![1, 2, 3, 4],
1213 },
1214 )
1215 .unwrap();
1216 wal.sync().unwrap();
1217 drop(wal);
1218
1219 let mut bytes = std::fs::read(&path).unwrap();
1221 let last = bytes.len() - 1;
1222 bytes[last] ^= 0xFF;
1223 std::fs::write(&path, bytes).unwrap();
1224
1225 let err = WalReader::open(&path).unwrap().next_record().unwrap_err();
1226 assert!(
1227 matches!(err, MongrelError::CorruptWal { .. }),
1228 "got {err:?}"
1229 );
1230 }
1231
1232 #[test]
1233 fn trailing_torn_is_eof_but_interior_corruption_errors() {
1234 let dir = tempdir().unwrap();
1235
1236 let path_a = dir.path().join("seg-torn.wal");
1239 let mut wal = Wal::create(&path_a, Epoch(0)).unwrap();
1240 wal.append_txn(
1241 1,
1242 Op::Put {
1243 table_id: 1,
1244 rows: vec![1],
1245 },
1246 )
1247 .unwrap();
1248 wal.append_txn(
1249 1,
1250 Op::Put {
1251 table_id: 1,
1252 rows: vec![2],
1253 },
1254 )
1255 .unwrap();
1256 wal.sync().unwrap();
1257 drop(wal);
1258 let mut f = OpenOptions::new().append(true).open(&path_a).unwrap();
1260 f.write_all(&64u32.to_le_bytes()).unwrap();
1261 f.write_all(&[0u8; 7]).unwrap();
1262 f.sync_all().unwrap();
1263 drop(f);
1264 let recs = replay(&path_a).unwrap();
1265 assert_eq!(recs.len(), 2, "torn trailing frame must truncate cleanly");
1266
1267 let path_b = dir.path().join("seg-interior.wal");
1270 let mut wal = Wal::create(&path_b, Epoch(0)).unwrap();
1271 wal.append_txn(
1272 1,
1273 Op::Put {
1274 table_id: 1,
1275 rows: vec![10, 20, 30],
1276 },
1277 )
1278 .unwrap();
1279 wal.append_txn(
1280 1,
1281 Op::Put {
1282 table_id: 1,
1283 rows: vec![40],
1284 },
1285 )
1286 .unwrap();
1287 wal.sync().unwrap();
1288 drop(wal);
1289 let mut bytes = std::fs::read(&path_b).unwrap();
1292 let first_payload_byte = HEADER_LEN as usize + 4 + 4 + 8 + 8; bytes[first_payload_byte] ^= 0xFF;
1294 std::fs::write(&path_b, bytes).unwrap();
1295 let err = replay(&path_b).unwrap_err();
1296 assert!(
1297 matches!(err, MongrelError::CorruptWal { .. }),
1298 "interior corruption must error, got {err:?}"
1299 );
1300
1301 let path_c = dir.path().join("seg-badtail.wal");
1304 let mut wal = Wal::create(&path_c, Epoch(0)).unwrap();
1305 wal.append_txn(
1306 1,
1307 Op::Put {
1308 table_id: 1,
1309 rows: vec![5],
1310 },
1311 )
1312 .unwrap();
1313 wal.sync().unwrap();
1314 drop(wal);
1315 let mut bytes = std::fs::read(&path_c).unwrap();
1316 let last = bytes.len() - 1;
1317 bytes[last] ^= 0xFF;
1318 std::fs::write(&path_c, bytes).unwrap();
1319 let recs = replay(&path_c).unwrap();
1320 assert_eq!(
1321 recs.len(),
1322 0,
1323 "trailing corrupt frame with no valid follower is a torn tail"
1324 );
1325 }
1326
1327 #[test]
1328 fn byte_threshold_auto_syncs() {
1329 let dir = tempdir().unwrap();
1330 let path = dir.path().join("seg-000003.wal");
1331 let mut wal = Wal::create(&path, Epoch(0)).unwrap();
1332 wal.sync_byte_threshold = 1; wal.append_txn(
1334 1,
1335 Op::Put {
1336 table_id: 1,
1337 rows: vec![0; 5],
1338 },
1339 )
1340 .unwrap();
1341 assert_eq!(
1342 wal.unflushed_bytes(),
1343 0,
1344 "threshold should have auto-synced"
1345 );
1346 }
1347
1348 #[cfg(feature = "encryption")]
1349 #[test]
1350 fn wal_nonce_is_segment_deterministic() {
1351 assert_ne!(frame_nonce_for(5, 0), frame_nonce_for(6, 0));
1354 assert_ne!(frame_nonce_for(5, 0), frame_nonce_for(5, 1));
1355 assert_eq!(frame_nonce_for(5, 0), frame_nonce_for(5, 0));
1358 }
1359}