1use crate::epoch::Epoch;
9use crate::rowid::RowId;
10use crate::schema::{ColumnDef, Schema};
11use crate::{MongrelError, Result};
12use crc::{Crc, CRC_32_ISCSI};
13use serde::{Deserialize, Serialize};
14use std::fs::{File, OpenOptions};
15use std::io::{BufReader, BufWriter, Read, Write};
16use std::path::{Path, PathBuf};
17use zeroize::Zeroizing;
18
19pub const WAL_MAGIC: [u8; 8] = *b"MONGRWAL";
20const WAL_VERSION: u16 = 3;
21const HEADER_LEN: u64 = 8 + 2 + 4 + 8; const ENC_PLAINTEXT: u8 = 0;
24const ENC_AES_GCM: u8 = 1;
25
26pub const SYSTEM_TXN_ID: u64 = 0;
29
30const CRC32C: Crc<u32> = Crc::<u32>::new(&CRC_32_ISCSI);
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct Record {
38 pub seq: Epoch,
39 pub txn_id: u64,
40 pub op: Op,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct AddedRun {
47 pub table_id: u64,
48 pub run_id: u128,
49 pub row_count: u64,
50 pub level: u8,
51 pub min_row_id: u64,
52 pub max_row_id: u64,
53 pub content_hash: [u8; 32],
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
61pub enum DdlOp {
62 CreateTable {
63 table_id: u64,
64 name: String,
65 schema_json: Vec<u8>,
66 },
67 DropTable {
68 table_id: u64,
69 },
70 AlterTable {
73 table_id: u64,
74 column_json: Vec<u8>,
75 },
76 RenameTable {
82 table_id: u64,
83 new_name: String,
84 },
85}
86
87impl DdlOp {
88 pub fn encode_schema(schema: &Schema) -> Result<Vec<u8>> {
90 serde_json::to_vec(schema).map_err(|e| MongrelError::Other(format!("schema json: {e}")))
91 }
92
93 pub fn decode_schema(bytes: &[u8]) -> Result<Schema> {
95 serde_json::from_slice(bytes).map_err(|e| MongrelError::Other(format!("schema json: {e}")))
96 }
97
98 pub fn encode_column(column: &ColumnDef) -> Result<Vec<u8>> {
99 serde_json::to_vec(column).map_err(|e| MongrelError::Other(format!("column json: {e}")))
100 }
101
102 pub fn decode_column(bytes: &[u8]) -> Result<ColumnDef> {
103 serde_json::from_slice(bytes).map_err(|e| MongrelError::Other(format!("column json: {e}")))
104 }
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub enum Op {
109 Put {
110 table_id: u64,
111 rows: Vec<u8>,
112 },
113 Delete {
114 table_id: u64,
115 row_ids: Vec<RowId>,
116 },
117 TruncateTable {
118 table_id: u64,
119 },
120 ExternalTableState {
124 name: String,
125 state: Vec<u8>,
126 },
127 Flush {
131 table_id: u64,
132 flushed_epoch: u64,
133 },
134 TxnCommit {
137 epoch: u64,
138 added_runs: Vec<AddedRun>,
139 },
140 TxnAbort,
142 Ddl(DdlOp),
143}
144
145impl Record {
146 pub fn new(seq: Epoch, txn_id: u64, op: Op) -> Self {
147 Self { seq, txn_id, op }
148 }
149}
150
151pub struct Wal {
154 file: BufWriter<File>,
155 path: PathBuf,
156 next_seq: u64,
158 unflushed_bytes: u64,
159 sync_byte_threshold: u64,
161 cipher: Option<Box<dyn crate::encryption::Cipher>>,
164 segment_no: u64,
173 frame_seq: u64,
179}
180
181impl Wal {
182 pub fn create(path: impl AsRef<Path>, epoch_created: Epoch) -> Result<Self> {
184 Self::create_with_cipher(path, epoch_created, None, 0)
185 }
186
187 pub fn create_with_cipher(
191 path: impl AsRef<Path>,
192 epoch_created: Epoch,
193 cipher: Option<Box<dyn crate::encryption::Cipher>>,
194 segment_no: u64,
195 ) -> Result<Self> {
196 let path = path.as_ref().to_path_buf();
197 let file = OpenOptions::new()
198 .create(true)
199 .read(true)
200 .write(true)
201 .truncate(true)
202 .open(&path)?;
203 let mut wal = Self {
204 file: BufWriter::with_capacity(1 << 20, file),
205 path,
206 next_seq: epoch_created.0 + 1,
207 unflushed_bytes: 0,
208 sync_byte_threshold: 64 * 1024,
209 cipher,
210 segment_no,
211 frame_seq: 0,
212 };
213 wal.write_header(epoch_created)?;
214 Ok(wal)
215 }
216
217 pub fn append_txn(&mut self, txn_id: u64, op: Op) -> Result<Epoch> {
224 let seq = Epoch(self.next_seq);
225 self.next_seq += 1;
226 self.append_record(&Record::new(seq, txn_id, op))?;
227 Ok(seq)
228 }
229
230 pub fn append_system(&mut self, op: Op) -> Result<Epoch> {
232 self.append_txn(SYSTEM_TXN_ID, op)
233 }
234
235 fn append_record(&mut self, record: &Record) -> Result<()> {
236 let payload = bincode::serialize(record)?;
237
238 let frame_payload = if let Some(cipher) = &self.cipher {
241 if self.frame_seq > u32::MAX as u64 {
246 return Err(MongrelError::Full(
247 "wal segment frame counter exhausted (2^32); rotate the segment".into(),
248 ));
249 }
250 let nonce = self.frame_nonce();
251 let ciphertext = cipher.encrypt_page(&nonce, &payload)?;
252 self.frame_seq += 1;
253 let mut combined = Vec::with_capacity(12 + ciphertext.len());
254 combined.extend_from_slice(&nonce);
255 combined.extend_from_slice(&ciphertext);
256 combined
257 } else {
258 payload
259 };
260
261 let len = frame_payload.len();
262 if len > u32::MAX as usize {
263 return Err(MongrelError::InvalidArgument(format!(
264 "wal payload too large: {len} bytes"
265 )));
266 }
267 let mut digest = CRC32C.digest();
269 digest.update(&record.seq.0.to_le_bytes());
270 digest.update(&record.txn_id.to_le_bytes());
271 digest.update(&frame_payload);
272 let crc_val = digest.finalize();
273
274 self.file.write_all(&(len as u32).to_le_bytes())?;
275 self.file.write_all(&crc_val.to_le_bytes())?;
276 self.file.write_all(&record.seq.0.to_le_bytes())?;
277 self.file.write_all(&record.txn_id.to_le_bytes())?;
278 self.file.write_all(&frame_payload)?;
279 self.unflushed_bytes += 4 + 4 + 8 + 8 + len as u64;
280 if self.sync_byte_threshold > 0 && self.unflushed_bytes >= self.sync_byte_threshold {
281 self.sync()?;
282 }
283 Ok(())
284 }
285
286 fn frame_nonce(&self) -> [u8; 12] {
292 frame_nonce_for(self.segment_no, self.frame_seq as u32)
293 }
294
295 pub fn sync(&mut self) -> Result<()> {
297 self.file.flush()?;
298 self.file.get_ref().sync_all()?;
299 self.unflushed_bytes = 0;
300 Ok(())
301 }
302
303 #[inline]
305 pub fn unflushed_bytes(&self) -> u64 {
306 self.unflushed_bytes
307 }
308
309 #[inline]
312 pub fn next_seq_val(&self) -> u64 {
313 self.next_seq
314 }
315
316 pub fn set_sync_byte_threshold(&mut self, threshold: u64) {
321 self.sync_byte_threshold = threshold;
322 }
323
324 pub fn path(&self) -> &Path {
325 &self.path
326 }
327
328 fn write_header(&mut self, epoch_created: Epoch) -> Result<()> {
329 let enc_flag = if self.cipher.is_some() {
330 ENC_AES_GCM
331 } else {
332 ENC_PLAINTEXT
333 };
334 self.file.write_all(&WAL_MAGIC)?;
335 self.file.write_all(&WAL_VERSION.to_le_bytes())?;
336 self.file.write_all(&[enc_flag, 0, 0, 0])?; self.file.write_all(&epoch_created.0.to_le_bytes())?;
338 self.unflushed_bytes = 0;
339 Ok(())
340 }
341}
342
343impl Drop for Wal {
344 fn drop(&mut self) {
345 let _ = self.file.flush();
346 }
347}
348
349pub struct WalReader {
352 inner: BufReader<File>,
353 pos: u64,
354 encrypted: bool,
356 cipher: Option<Box<dyn crate::encryption::Cipher>>,
358}
359
360impl WalReader {
361 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
362 Self::open_with_cipher(path, None)
363 }
364
365 pub fn open_with_cipher(
367 path: impl AsRef<Path>,
368 cipher: Option<Box<dyn crate::encryption::Cipher>>,
369 ) -> Result<Self> {
370 let mut file = File::open(path.as_ref())?;
371 let mut magic = [0u8; 8];
372 file.read_exact(&mut magic)?;
373 if magic != WAL_MAGIC {
374 return Err(MongrelError::MagicMismatch {
375 what: "wal",
376 expected: WAL_MAGIC,
377 got: magic,
378 });
379 }
380 let mut version_buf = [0u8; 2];
381 file.read_exact(&mut version_buf)?;
382 let version = u16::from_le_bytes(version_buf);
383 if version != WAL_VERSION {
384 return Err(MongrelError::InvalidArgument(format!(
385 "unsupported wal version {version}"
386 )));
387 }
388 let mut reserved = [0u8; 4];
389 file.read_exact(&mut reserved)?;
390 let encrypted = reserved[0] == ENC_AES_GCM;
391 let mut epoch_buf = [0u8; 8];
392 file.read_exact(&mut epoch_buf)?;
393 let _epoch_created = Epoch(u64::from_le_bytes(epoch_buf));
394 let pos = HEADER_LEN;
395 if encrypted && cipher.is_none() {
396 return Err(MongrelError::Decryption(
397 "WAL is encrypted but no passphrase or key was provided. \
398 Use Table::open_encrypted or Table::open_with_key."
399 .into(),
400 ));
401 }
402 Ok(Self {
403 inner: BufReader::new(file),
404 pos,
405 encrypted,
406 cipher,
407 })
408 }
409
410 pub fn next_record(&mut self) -> Result<Option<Record>> {
413 let mut len_buf = [0u8; 4];
414 match self.inner.read_exact(&mut len_buf) {
415 Ok(()) => {}
416 Err(ref e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
417 Err(e) => return Err(e.into()),
418 }
419 let len = u32::from_le_bytes(len_buf) as usize;
420 if len == 0 {
421 return Ok(None);
422 }
423 const MAX_RECORD_LEN: usize = 64 * 1024 * 1024;
426 if len > MAX_RECORD_LEN {
427 return Err(MongrelError::TornWrite { offset: self.pos });
428 }
429
430 let record_start = self.pos;
431 let mut rest = vec![0u8; 4 + 8 + 8 + len];
432 match self.inner.read_exact(&mut rest) {
433 Ok(()) => {}
434 Err(ref e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
435 return Err(MongrelError::TornWrite {
436 offset: record_start,
437 });
438 }
439 Err(e) => return Err(e.into()),
440 }
441 let crc_val = u32::from_le_bytes([rest[0], rest[1], rest[2], rest[3]]);
442 let seq = u64::from_le_bytes([
443 rest[4], rest[5], rest[6], rest[7], rest[8], rest[9], rest[10], rest[11],
444 ]);
445 let txn_id = u64::from_le_bytes([
446 rest[12], rest[13], rest[14], rest[15], rest[16], rest[17], rest[18], rest[19],
447 ]);
448 let payload = &rest[20..];
449
450 let mut digest = CRC32C.digest();
451 digest.update(&seq.to_le_bytes());
452 digest.update(&txn_id.to_le_bytes());
453 digest.update(payload);
454 if digest.finalize() != crc_val {
455 return Err(MongrelError::CorruptWal {
456 offset: record_start,
457 reason: "crc mismatch".into(),
458 });
459 }
460
461 let plaintext = if self.encrypted {
463 let Some(cipher) = &self.cipher else {
464 return Err(MongrelError::Decryption(
465 "WAL is encrypted but no cipher was provided".into(),
466 ));
467 };
468 if payload.len() < 28 {
469 return Err(MongrelError::CorruptWal {
471 offset: record_start,
472 reason: "encrypted frame too short".into(),
473 });
474 }
475 let nonce: [u8; 12] = payload[..12].try_into().unwrap();
476 let ciphertext = &payload[12..];
477 cipher.decrypt_page(&nonce, ciphertext).map_err(|e| {
478 MongrelError::Decryption(format!(
479 "WAL frame decryption failed — wrong passphrase or key? ({e})"
480 ))
481 })?
482 } else {
483 payload.to_vec()
484 };
485
486 let record: Record = bincode::deserialize(&plaintext)?;
493 self.pos += 4 + 4 + 8 + 8 + len as u64;
494 Ok(Some(record))
495 }
496
497 pub fn replay(&mut self) -> Result<Vec<Record>> {
503 let mut out = Vec::new();
504 loop {
505 match self.next_record() {
506 Ok(Some(rec)) => out.push(rec),
507 Ok(None) => break,
508 Err(MongrelError::TornWrite { offset }) => {
509 if self.valid_frame_follows()? {
512 return Err(MongrelError::CorruptWal {
513 offset,
514 reason: "interior torn frame followed by a valid frame".into(),
515 });
516 }
517 break;
518 }
519 Err(MongrelError::CorruptWal { offset, .. }) => {
520 if self.valid_frame_follows()? {
523 return Err(MongrelError::CorruptWal {
524 offset,
525 reason: "interior corruption: valid frame follows a CRC mismatch"
526 .into(),
527 });
528 }
529 break;
530 }
531 Err(e) => return Err(e),
532 }
533 }
534 Ok(out)
535 }
536
537 fn valid_frame_follows(&mut self) -> Result<bool> {
542 match self.next_record() {
543 Ok(Some(_)) => Ok(true),
544 Ok(None) => Ok(false),
545 Err(_) => Ok(false),
546 }
547 }
548
549 pub fn current_offset(&self) -> u64 {
552 self.pos
553 }
554}
555
556pub fn replay(path: impl AsRef<Path>) -> Result<Vec<Record>> {
559 WalReader::open(path)?.replay()
560}
561
562pub fn replay_with_cipher(
564 path: impl AsRef<Path>,
565 cipher: Option<Box<dyn crate::encryption::Cipher>>,
566) -> Result<Vec<Record>> {
567 WalReader::open_with_cipher(path, cipher)?.replay()
568}
569
570pub fn frame_nonce_for(segment_no: u64, frame: u32) -> [u8; 12] {
575 let mut n = [0u8; 12];
576 n[..8].copy_from_slice(&segment_no.to_be_bytes());
577 n[8..].copy_from_slice(&frame.to_le_bytes());
578 n
579}
580
581pub struct SharedWal {
587 wal_dir: PathBuf,
588 active: Wal,
589 active_segment_no: u64,
591 durable_seq: u64,
594 wal_dek: Option<Zeroizing<[u8; 32]>>,
597 group_sync_count: u64,
601}
602
603impl SharedWal {
604 fn segment_path(wal_dir: &Path, segment_no: u64) -> PathBuf {
606 wal_dir.join(format!("seg-{segment_no:06}.wal"))
607 }
608
609 #[cfg(feature = "encryption")]
611 fn cipher_from_dek(dek: &Zeroizing<[u8; 32]>) -> Result<Box<dyn crate::encryption::Cipher>> {
612 Ok(Box::new(crate::encryption::AesCipher::new(&dek[..])?))
613 }
614
615 pub fn create(root: &Path, epoch_created: Epoch) -> Result<Self> {
617 Self::create_with_dek(root, epoch_created, None)
618 }
619
620 pub fn create_with_dek(
622 root: &Path,
623 epoch_created: Epoch,
624 wal_dek: Option<Zeroizing<[u8; 32]>>,
625 ) -> Result<Self> {
626 let wal_dir = root.join("_wal");
627 std::fs::create_dir_all(&wal_dir)?;
628 let cipher = match &wal_dek {
629 #[cfg(feature = "encryption")]
630 Some(dk) => Some(Self::cipher_from_dek(dk)?),
631 #[cfg(not(feature = "encryption"))]
632 Some(_) => {
633 return Err(MongrelError::Encryption(
634 "encryption feature disabled but a WAL DEK was supplied".into(),
635 ))
636 }
637 None => None,
638 };
639 let active =
640 Wal::create_with_cipher(Self::segment_path(&wal_dir, 0), epoch_created, cipher, 0)?;
641 Ok(Self {
642 wal_dir,
643 active,
644 active_segment_no: 0,
645 durable_seq: epoch_created.0,
646 wal_dek,
647 group_sync_count: 0,
648 })
649 }
650
651 pub fn open(
656 root: &Path,
657 epoch_created: Epoch,
658 wal_dek: Option<Zeroizing<[u8; 32]>>,
659 ) -> Result<Self> {
660 let wal_dir = root.join("_wal");
661 std::fs::create_dir_all(&wal_dir)?;
662 let next_segment_no = list_segment_numbers(&wal_dir)?
663 .into_iter()
664 .max()
665 .map(|m| m + 1)
666 .unwrap_or(0);
667 let cipher = match &wal_dek {
668 #[cfg(feature = "encryption")]
669 Some(dk) => Some(Self::cipher_from_dek(dk)?),
670 #[cfg(not(feature = "encryption"))]
671 Some(_) => {
672 return Err(MongrelError::Encryption(
673 "encryption feature disabled but a WAL DEK was supplied".into(),
674 ))
675 }
676 None => None,
677 };
678 let mut active = Wal::create_with_cipher(
679 Self::segment_path(&wal_dir, next_segment_no),
680 epoch_created,
681 cipher,
682 next_segment_no,
683 )?;
684 active.sync()?;
687 Ok(Self {
688 wal_dir,
689 active,
690 active_segment_no: next_segment_no,
691 durable_seq: epoch_created.0,
692 wal_dek,
693 group_sync_count: 0,
694 })
695 }
696
697 #[allow(dead_code)]
699 pub fn wal_dir(&self) -> &Path {
700 &self.wal_dir
701 }
702
703 pub fn append(&mut self, txn_id: u64, _table_id: u64, op: Op) -> Result<u64> {
705 Ok(self.active.append_txn(txn_id, op)?.0)
706 }
707
708 pub fn append_commit(&mut self, txn_id: u64, epoch: Epoch, added: &[AddedRun]) -> Result<u64> {
710 Ok(self
711 .active
712 .append_txn(
713 txn_id,
714 Op::TxnCommit {
715 epoch: epoch.0,
716 added_runs: added.to_vec(),
717 },
718 )?
719 .0)
720 }
721
722 pub fn append_abort(&mut self, txn_id: u64) -> Result<()> {
724 self.active.append_txn(txn_id, Op::TxnAbort)?;
725 Ok(())
726 }
727
728 pub fn append_system(&mut self, op: Op) -> Result<u64> {
730 Ok(self.active.append_system(op)?.0)
731 }
732
733 pub fn group_sync(&mut self) -> Result<u64> {
737 self.active.sync()?;
738 self.group_sync_count += 1;
739 let highest = self.active.next_seq_val().saturating_sub(1);
740 if highest > self.durable_seq {
741 self.durable_seq = highest;
742 }
743 Ok(self.durable_seq)
744 }
745
746 pub fn group_sync_count(&self) -> u64 {
748 self.group_sync_count
749 }
750
751 pub fn durable_seq(&self) -> u64 {
753 self.durable_seq
754 }
755
756 pub fn rotate(&mut self, segment_no: u64) -> Result<()> {
759 let cipher = match &self.wal_dek {
760 #[cfg(feature = "encryption")]
761 Some(dk) => Some(Self::cipher_from_dek(dk)?),
762 _ => None,
763 };
764 let path = Self::segment_path(&self.wal_dir, segment_no);
765 let epoch = Epoch(self.durable_seq);
766 let wal = Wal::create_with_cipher(path, epoch, cipher, segment_no)?;
767 self.active = wal;
768 self.active_segment_no = segment_no;
769 Ok(())
770 }
771
772 pub fn active_segment_no(&self) -> u64 {
774 self.active_segment_no
775 }
776
777 pub fn gc_segments(&mut self, min_retained_seq: u64) -> Result<usize> {
787 let mut segments = list_segment_numbers(&self.wal_dir)?;
788 segments.sort_unstable();
789 let mut reaped = 0;
790 for n in segments {
791 if n == self.active_segment_no {
792 continue; }
794 let path = Self::segment_path(&self.wal_dir, n);
795 let reapable = if min_retained_seq == u64::MAX {
798 true
799 } else {
800 let recs = match &self.wal_dek {
805 #[cfg(feature = "encryption")]
806 Some(dk) => {
807 let cipher = Self::cipher_from_dek(dk)?;
808 replay_with_cipher(&path, Some(cipher))
809 }
810 _ => replay(&path),
811 };
812 match recs {
813 Ok(recs) => recs.iter().map(|r| r.seq.0).max().unwrap_or(0) < min_retained_seq,
814 Err(_) => true,
815 }
816 };
817 if reapable {
818 std::fs::remove_file(&path)?;
819 reaped += 1;
820 }
821 }
822 if reaped > 0 {
823 if let Ok(d) = std::fs::File::open(&self.wal_dir) {
824 let _ = d.sync_all();
825 }
826 }
827 Ok(reaped)
828 }
829
830 pub fn verify_segments(&self) -> Vec<(u64, String)> {
838 let mut bad = Vec::new();
839 let Ok(segments) = list_segment_numbers(&self.wal_dir) else {
840 return bad;
841 };
842 for n in segments {
843 let path = Self::segment_path(&self.wal_dir, n);
844 let res = match &self.wal_dek {
847 #[cfg(feature = "encryption")]
848 Some(dk) => match Self::cipher_from_dek(dk) {
849 Ok(cipher) => WalReader::open_with_cipher(&path, Some(cipher)),
850 Err(e) => Err(e),
851 },
852 _ => WalReader::open_with_cipher(&path, None),
853 };
854 if let Err(e) = res {
855 bad.push((n, format!("{e}")));
856 }
857 }
858 bad
859 }
860
861 pub fn replay(root: &Path) -> Result<Vec<Record>> {
864 Self::replay_with_dek(root, None)
865 }
866
867 pub fn replay_with_dek(
869 root: &Path,
870 wal_dek: Option<&Zeroizing<[u8; 32]>>,
871 ) -> Result<Vec<Record>> {
872 let wal_dir = root.join("_wal");
873 let mut segments = list_segment_numbers(&wal_dir)?;
874 segments.sort_unstable();
875 let mut out = Vec::new();
876 for n in segments {
877 let path = Self::segment_path(&wal_dir, n);
878 let recs = match wal_dek {
881 #[cfg(feature = "encryption")]
882 Some(dk) => {
883 let cipher = Self::cipher_from_dek(dk)?;
884 replay_with_cipher(&path, Some(cipher))?
885 }
886 _ => replay(&path)?,
887 };
888 out.extend(recs);
889 }
890 Ok(out)
891 }
892}
893
894fn list_segment_numbers(wal_dir: &Path) -> Result<Vec<u64>> {
896 let mut segments = Vec::new();
897 if let Ok(rd) = std::fs::read_dir(wal_dir) {
898 for entry in rd.flatten() {
899 let fname = entry.file_name();
900 let Some(s) = fname.to_str() else {
901 continue;
902 };
903 let Some(stripped) = s.strip_prefix("seg-") else {
904 continue;
905 };
906 let Some(stripped) = stripped.strip_suffix(".wal") else {
907 continue;
908 };
909 if let Ok(n) = stripped.parse::<u64>() {
910 segments.push(n);
911 }
912 }
913 }
914 Ok(segments)
915}
916
917#[cfg(test)]
918mod shared_wal_tests {
919 use super::*;
920 use tempfile::tempdir;
921
922 #[test]
923 fn shared_wal_interleaves_two_tables_one_fd() {
924 let dir = tempdir().unwrap();
925 let mut w = SharedWal::create(dir.path(), Epoch(0)).unwrap();
926 w.append(
927 1,
928 10,
929 Op::Put {
930 table_id: 10,
931 rows: vec![1],
932 },
933 )
934 .unwrap();
935 w.append(
936 2,
937 20,
938 Op::Put {
939 table_id: 20,
940 rows: vec![2],
941 },
942 )
943 .unwrap();
944 w.append_commit(1, Epoch(1), &[]).unwrap();
945 w.append_commit(2, Epoch(2), &[]).unwrap();
946 let d = w.group_sync().unwrap();
947 assert!(d >= 4);
948 let recs = SharedWal::replay(dir.path()).unwrap();
949 assert_eq!(
950 recs.iter()
951 .filter(|r| matches!(r.op, Op::Put { .. }))
952 .count(),
953 2
954 );
955 assert_eq!(
956 recs.iter()
957 .filter(|r| matches!(r.op, Op::TxnCommit { .. }))
958 .count(),
959 2
960 );
961 }
962}
963
964#[cfg(test)]
965mod tests {
966 use super::*;
967 use tempfile::tempdir;
968
969 #[test]
970 fn append_then_replay_roundtrips() {
971 let dir = tempdir().unwrap();
972 let path = dir.path().join("seg-000000.wal");
973 let mut wal = Wal::create(&path, Epoch(100)).unwrap();
974 let s1 = wal
975 .append_txn(
976 7,
977 Op::Put {
978 table_id: 1,
979 rows: vec![1, 2, 3],
980 },
981 )
982 .unwrap();
983 let s2 = wal
984 .append_txn(
985 7,
986 Op::Delete {
987 table_id: 1,
988 row_ids: vec![RowId(7)],
989 },
990 )
991 .unwrap();
992 assert_eq!(s1, Epoch(101));
993 assert_eq!(s2, Epoch(102));
994 wal.sync().unwrap();
995
996 let records = replay(&path).unwrap();
997 assert_eq!(records.len(), 2);
998 assert_eq!(records[0].seq, Epoch(101));
999 assert_eq!(records[0].txn_id, 7);
1000 match &records[0].op {
1001 Op::Put { table_id, rows } => {
1002 assert_eq!(*table_id, 1);
1003 assert_eq!(rows, &vec![1, 2, 3]);
1004 }
1005 other => panic!("unexpected op {other:?}"),
1006 }
1007 match &records[1].op {
1008 Op::Delete { row_ids, .. } => {
1009 assert_eq!(*row_ids, vec![RowId(7)]);
1010 }
1011 other => panic!("unexpected op {other:?}"),
1012 }
1013 }
1014
1015 #[test]
1016 fn record_roundtrips_with_txn_id_and_commit_marker() {
1017 let dir = tempdir().unwrap();
1018 let path = dir.path().join("seg-000000.wal");
1019 let mut w = Wal::create(&path, Epoch(0)).unwrap();
1020 w.append_txn(
1021 7,
1022 Op::Put {
1023 table_id: 3,
1024 rows: vec![1, 2, 3],
1025 },
1026 )
1027 .unwrap();
1028 w.append_txn(
1029 7,
1030 Op::TxnCommit {
1031 epoch: 11,
1032 added_runs: vec![],
1033 },
1034 )
1035 .unwrap();
1036 w.sync().unwrap();
1037 let recs = replay(&path).unwrap();
1038 assert_eq!(recs[0].txn_id, 7);
1039 assert!(matches!(recs[0].op, Op::Put { table_id: 3, .. }));
1040 assert!(matches!(recs[1].op, Op::TxnCommit { epoch: 11, .. }));
1041 let mut w2 = Wal::create(&path, Epoch(0)).unwrap();
1043 w2.append_system(Op::Flush {
1044 table_id: 3,
1045 flushed_epoch: 11,
1046 })
1047 .unwrap();
1048 w2.sync().unwrap();
1049 let recs = replay(&path).unwrap();
1050 assert_eq!(recs[0].txn_id, SYSTEM_TXN_ID);
1051 assert!(matches!(recs[0].op, Op::Flush { .. }));
1052 }
1053
1054 #[test]
1055 fn torn_write_is_detected() {
1056 let dir = tempdir().unwrap();
1057 let path = dir.path().join("seg-000001.wal");
1058 let mut wal = Wal::create(&path, Epoch(0)).unwrap();
1059 wal.append_txn(
1060 1,
1061 Op::Put {
1062 table_id: 1,
1063 rows: vec![0; 10],
1064 },
1065 )
1066 .unwrap();
1067 wal.sync().unwrap();
1068 drop(wal);
1069
1070 let mut f = OpenOptions::new().append(true).open(&path).unwrap();
1072 f.write_all(&64u32.to_le_bytes()).unwrap();
1074 f.write_all(&[0u8; 7]).unwrap();
1075 f.sync_all().unwrap();
1076 drop(f);
1077
1078 let mut reader = WalReader::open(&path).unwrap();
1079 assert!(reader.next_record().unwrap().is_some());
1081 let err = reader.next_record().unwrap_err();
1083 assert!(matches!(err, MongrelError::TornWrite { .. }), "got {err:?}");
1084 }
1085
1086 #[test]
1087 fn crc_corruption_is_detected() {
1088 let dir = tempdir().unwrap();
1089 let path = dir.path().join("seg-000002.wal");
1090 let mut wal = Wal::create(&path, Epoch(0)).unwrap();
1091 wal.append_txn(
1092 1,
1093 Op::Put {
1094 table_id: 9,
1095 rows: vec![1, 2, 3, 4],
1096 },
1097 )
1098 .unwrap();
1099 wal.sync().unwrap();
1100 drop(wal);
1101
1102 let mut bytes = std::fs::read(&path).unwrap();
1104 let last = bytes.len() - 1;
1105 bytes[last] ^= 0xFF;
1106 std::fs::write(&path, bytes).unwrap();
1107
1108 let err = WalReader::open(&path).unwrap().next_record().unwrap_err();
1109 assert!(
1110 matches!(err, MongrelError::CorruptWal { .. }),
1111 "got {err:?}"
1112 );
1113 }
1114
1115 #[test]
1116 fn trailing_torn_is_eof_but_interior_corruption_errors() {
1117 let dir = tempdir().unwrap();
1118
1119 let path_a = dir.path().join("seg-torn.wal");
1122 let mut wal = Wal::create(&path_a, Epoch(0)).unwrap();
1123 wal.append_txn(
1124 1,
1125 Op::Put {
1126 table_id: 1,
1127 rows: vec![1],
1128 },
1129 )
1130 .unwrap();
1131 wal.append_txn(
1132 1,
1133 Op::Put {
1134 table_id: 1,
1135 rows: vec![2],
1136 },
1137 )
1138 .unwrap();
1139 wal.sync().unwrap();
1140 drop(wal);
1141 let mut f = OpenOptions::new().append(true).open(&path_a).unwrap();
1143 f.write_all(&64u32.to_le_bytes()).unwrap();
1144 f.write_all(&[0u8; 7]).unwrap();
1145 f.sync_all().unwrap();
1146 drop(f);
1147 let recs = replay(&path_a).unwrap();
1148 assert_eq!(recs.len(), 2, "torn trailing frame must truncate cleanly");
1149
1150 let path_b = dir.path().join("seg-interior.wal");
1153 let mut wal = Wal::create(&path_b, Epoch(0)).unwrap();
1154 wal.append_txn(
1155 1,
1156 Op::Put {
1157 table_id: 1,
1158 rows: vec![10, 20, 30],
1159 },
1160 )
1161 .unwrap();
1162 wal.append_txn(
1163 1,
1164 Op::Put {
1165 table_id: 1,
1166 rows: vec![40],
1167 },
1168 )
1169 .unwrap();
1170 wal.sync().unwrap();
1171 drop(wal);
1172 let mut bytes = std::fs::read(&path_b).unwrap();
1175 let first_payload_byte = HEADER_LEN as usize + 4 + 4 + 8 + 8; bytes[first_payload_byte] ^= 0xFF;
1177 std::fs::write(&path_b, bytes).unwrap();
1178 let err = replay(&path_b).unwrap_err();
1179 assert!(
1180 matches!(err, MongrelError::CorruptWal { .. }),
1181 "interior corruption must error, got {err:?}"
1182 );
1183
1184 let path_c = dir.path().join("seg-badtail.wal");
1187 let mut wal = Wal::create(&path_c, Epoch(0)).unwrap();
1188 wal.append_txn(
1189 1,
1190 Op::Put {
1191 table_id: 1,
1192 rows: vec![5],
1193 },
1194 )
1195 .unwrap();
1196 wal.sync().unwrap();
1197 drop(wal);
1198 let mut bytes = std::fs::read(&path_c).unwrap();
1199 let last = bytes.len() - 1;
1200 bytes[last] ^= 0xFF;
1201 std::fs::write(&path_c, bytes).unwrap();
1202 let recs = replay(&path_c).unwrap();
1203 assert_eq!(
1204 recs.len(),
1205 0,
1206 "trailing corrupt frame with no valid follower is a torn tail"
1207 );
1208 }
1209
1210 #[test]
1211 fn byte_threshold_auto_syncs() {
1212 let dir = tempdir().unwrap();
1213 let path = dir.path().join("seg-000003.wal");
1214 let mut wal = Wal::create(&path, Epoch(0)).unwrap();
1215 wal.sync_byte_threshold = 1; wal.append_txn(
1217 1,
1218 Op::Put {
1219 table_id: 1,
1220 rows: vec![0; 5],
1221 },
1222 )
1223 .unwrap();
1224 assert_eq!(
1225 wal.unflushed_bytes(),
1226 0,
1227 "threshold should have auto-synced"
1228 );
1229 }
1230
1231 #[cfg(feature = "encryption")]
1232 #[test]
1233 fn wal_nonce_is_segment_deterministic() {
1234 assert_ne!(frame_nonce_for(5, 0), frame_nonce_for(6, 0));
1237 assert_ne!(frame_nonce_for(5, 0), frame_nonce_for(5, 1));
1238 assert_eq!(frame_nonce_for(5, 0), frame_nonce_for(5, 0));
1241 }
1242}