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 Flush {
124 table_id: u64,
125 flushed_epoch: u64,
126 },
127 TxnCommit {
130 epoch: u64,
131 added_runs: Vec<AddedRun>,
132 },
133 TxnAbort,
135 Ddl(DdlOp),
136}
137
138impl Record {
139 pub fn new(seq: Epoch, txn_id: u64, op: Op) -> Self {
140 Self { seq, txn_id, op }
141 }
142}
143
144pub struct Wal {
147 file: BufWriter<File>,
148 path: PathBuf,
149 next_seq: u64,
151 unflushed_bytes: u64,
152 sync_byte_threshold: u64,
154 cipher: Option<Box<dyn crate::encryption::Cipher>>,
157 segment_no: u64,
166 frame_seq: u64,
172}
173
174impl Wal {
175 pub fn create(path: impl AsRef<Path>, epoch_created: Epoch) -> Result<Self> {
177 Self::create_with_cipher(path, epoch_created, None, 0)
178 }
179
180 pub fn create_with_cipher(
184 path: impl AsRef<Path>,
185 epoch_created: Epoch,
186 cipher: Option<Box<dyn crate::encryption::Cipher>>,
187 segment_no: u64,
188 ) -> Result<Self> {
189 let path = path.as_ref().to_path_buf();
190 let file = OpenOptions::new()
191 .create(true)
192 .read(true)
193 .write(true)
194 .truncate(true)
195 .open(&path)?;
196 let mut wal = Self {
197 file: BufWriter::with_capacity(1 << 20, file),
198 path,
199 next_seq: epoch_created.0 + 1,
200 unflushed_bytes: 0,
201 sync_byte_threshold: 64 * 1024,
202 cipher,
203 segment_no,
204 frame_seq: 0,
205 };
206 wal.write_header(epoch_created)?;
207 Ok(wal)
208 }
209
210 pub fn append_txn(&mut self, txn_id: u64, op: Op) -> Result<Epoch> {
217 let seq = Epoch(self.next_seq);
218 self.next_seq += 1;
219 self.append_record(&Record::new(seq, txn_id, op))?;
220 Ok(seq)
221 }
222
223 pub fn append_system(&mut self, op: Op) -> Result<Epoch> {
225 self.append_txn(SYSTEM_TXN_ID, op)
226 }
227
228 fn append_record(&mut self, record: &Record) -> Result<()> {
229 let payload = bincode::serialize(record)?;
230
231 let frame_payload = if let Some(cipher) = &self.cipher {
234 if self.frame_seq > u32::MAX as u64 {
239 return Err(MongrelError::Full(
240 "wal segment frame counter exhausted (2^32); rotate the segment".into(),
241 ));
242 }
243 let nonce = self.frame_nonce();
244 let ciphertext = cipher.encrypt_page(&nonce, &payload)?;
245 self.frame_seq += 1;
246 let mut combined = Vec::with_capacity(12 + ciphertext.len());
247 combined.extend_from_slice(&nonce);
248 combined.extend_from_slice(&ciphertext);
249 combined
250 } else {
251 payload
252 };
253
254 let len = frame_payload.len();
255 if len > u32::MAX as usize {
256 return Err(MongrelError::InvalidArgument(format!(
257 "wal payload too large: {len} bytes"
258 )));
259 }
260 let mut digest = CRC32C.digest();
262 digest.update(&record.seq.0.to_le_bytes());
263 digest.update(&record.txn_id.to_le_bytes());
264 digest.update(&frame_payload);
265 let crc_val = digest.finalize();
266
267 self.file.write_all(&(len as u32).to_le_bytes())?;
268 self.file.write_all(&crc_val.to_le_bytes())?;
269 self.file.write_all(&record.seq.0.to_le_bytes())?;
270 self.file.write_all(&record.txn_id.to_le_bytes())?;
271 self.file.write_all(&frame_payload)?;
272 self.unflushed_bytes += 4 + 4 + 8 + 8 + len as u64;
273 if self.sync_byte_threshold > 0 && self.unflushed_bytes >= self.sync_byte_threshold {
274 self.sync()?;
275 }
276 Ok(())
277 }
278
279 fn frame_nonce(&self) -> [u8; 12] {
285 frame_nonce_for(self.segment_no, self.frame_seq as u32)
286 }
287
288 pub fn sync(&mut self) -> Result<()> {
290 self.file.flush()?;
291 self.file.get_ref().sync_all()?;
292 self.unflushed_bytes = 0;
293 Ok(())
294 }
295
296 #[inline]
298 pub fn unflushed_bytes(&self) -> u64 {
299 self.unflushed_bytes
300 }
301
302 #[inline]
305 pub fn next_seq_val(&self) -> u64 {
306 self.next_seq
307 }
308
309 pub fn set_sync_byte_threshold(&mut self, threshold: u64) {
314 self.sync_byte_threshold = threshold;
315 }
316
317 pub fn path(&self) -> &Path {
318 &self.path
319 }
320
321 fn write_header(&mut self, epoch_created: Epoch) -> Result<()> {
322 let enc_flag = if self.cipher.is_some() {
323 ENC_AES_GCM
324 } else {
325 ENC_PLAINTEXT
326 };
327 self.file.write_all(&WAL_MAGIC)?;
328 self.file.write_all(&WAL_VERSION.to_le_bytes())?;
329 self.file.write_all(&[enc_flag, 0, 0, 0])?; self.file.write_all(&epoch_created.0.to_le_bytes())?;
331 self.unflushed_bytes = 0;
332 Ok(())
333 }
334}
335
336impl Drop for Wal {
337 fn drop(&mut self) {
338 let _ = self.file.flush();
339 }
340}
341
342pub struct WalReader {
345 inner: BufReader<File>,
346 pos: u64,
347 encrypted: bool,
349 cipher: Option<Box<dyn crate::encryption::Cipher>>,
351}
352
353impl WalReader {
354 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
355 Self::open_with_cipher(path, None)
356 }
357
358 pub fn open_with_cipher(
360 path: impl AsRef<Path>,
361 cipher: Option<Box<dyn crate::encryption::Cipher>>,
362 ) -> Result<Self> {
363 let mut file = File::open(path.as_ref())?;
364 let mut magic = [0u8; 8];
365 file.read_exact(&mut magic)?;
366 if magic != WAL_MAGIC {
367 return Err(MongrelError::MagicMismatch {
368 what: "wal",
369 expected: WAL_MAGIC,
370 got: magic,
371 });
372 }
373 let mut version_buf = [0u8; 2];
374 file.read_exact(&mut version_buf)?;
375 let version = u16::from_le_bytes(version_buf);
376 if version != WAL_VERSION {
377 return Err(MongrelError::InvalidArgument(format!(
378 "unsupported wal version {version}"
379 )));
380 }
381 let mut reserved = [0u8; 4];
382 file.read_exact(&mut reserved)?;
383 let encrypted = reserved[0] == ENC_AES_GCM;
384 let mut epoch_buf = [0u8; 8];
385 file.read_exact(&mut epoch_buf)?;
386 let _epoch_created = Epoch(u64::from_le_bytes(epoch_buf));
387 let pos = HEADER_LEN;
388 if encrypted && cipher.is_none() {
389 return Err(MongrelError::Decryption(
390 "WAL is encrypted but no passphrase or key was provided. \
391 Use Table::open_encrypted or Table::open_with_key."
392 .into(),
393 ));
394 }
395 Ok(Self {
396 inner: BufReader::new(file),
397 pos,
398 encrypted,
399 cipher,
400 })
401 }
402
403 pub fn next_record(&mut self) -> Result<Option<Record>> {
406 let mut len_buf = [0u8; 4];
407 match self.inner.read_exact(&mut len_buf) {
408 Ok(()) => {}
409 Err(ref e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
410 Err(e) => return Err(e.into()),
411 }
412 let len = u32::from_le_bytes(len_buf) as usize;
413 if len == 0 {
414 return Ok(None);
415 }
416 const MAX_RECORD_LEN: usize = 64 * 1024 * 1024;
419 if len > MAX_RECORD_LEN {
420 return Err(MongrelError::TornWrite { offset: self.pos });
421 }
422
423 let record_start = self.pos;
424 let mut rest = vec![0u8; 4 + 8 + 8 + len];
425 match self.inner.read_exact(&mut rest) {
426 Ok(()) => {}
427 Err(ref e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
428 return Err(MongrelError::TornWrite {
429 offset: record_start,
430 });
431 }
432 Err(e) => return Err(e.into()),
433 }
434 let crc_val = u32::from_le_bytes([rest[0], rest[1], rest[2], rest[3]]);
435 let seq = u64::from_le_bytes([
436 rest[4], rest[5], rest[6], rest[7], rest[8], rest[9], rest[10], rest[11],
437 ]);
438 let txn_id = u64::from_le_bytes([
439 rest[12], rest[13], rest[14], rest[15], rest[16], rest[17], rest[18], rest[19],
440 ]);
441 let payload = &rest[20..];
442
443 let mut digest = CRC32C.digest();
444 digest.update(&seq.to_le_bytes());
445 digest.update(&txn_id.to_le_bytes());
446 digest.update(payload);
447 if digest.finalize() != crc_val {
448 return Err(MongrelError::CorruptWal {
449 offset: record_start,
450 reason: "crc mismatch".into(),
451 });
452 }
453
454 let plaintext = if self.encrypted {
456 let Some(cipher) = &self.cipher else {
457 return Err(MongrelError::Decryption(
458 "WAL is encrypted but no cipher was provided".into(),
459 ));
460 };
461 if payload.len() < 28 {
462 return Err(MongrelError::CorruptWal {
464 offset: record_start,
465 reason: "encrypted frame too short".into(),
466 });
467 }
468 let nonce: [u8; 12] = payload[..12].try_into().unwrap();
469 let ciphertext = &payload[12..];
470 cipher.decrypt_page(&nonce, ciphertext).map_err(|e| {
471 MongrelError::Decryption(format!(
472 "WAL frame decryption failed — wrong passphrase or key? ({e})"
473 ))
474 })?
475 } else {
476 payload.to_vec()
477 };
478
479 let record: Record = bincode::deserialize(&plaintext)?;
486 self.pos += 4 + 4 + 8 + 8 + len as u64;
487 Ok(Some(record))
488 }
489
490 pub fn replay(&mut self) -> Result<Vec<Record>> {
496 let mut out = Vec::new();
497 loop {
498 match self.next_record() {
499 Ok(Some(rec)) => out.push(rec),
500 Ok(None) => break,
501 Err(MongrelError::TornWrite { offset }) => {
502 if self.valid_frame_follows()? {
505 return Err(MongrelError::CorruptWal {
506 offset,
507 reason: "interior torn frame followed by a valid frame".into(),
508 });
509 }
510 break;
511 }
512 Err(MongrelError::CorruptWal { offset, .. }) => {
513 if self.valid_frame_follows()? {
516 return Err(MongrelError::CorruptWal {
517 offset,
518 reason: "interior corruption: valid frame follows a CRC mismatch"
519 .into(),
520 });
521 }
522 break;
523 }
524 Err(e) => return Err(e),
525 }
526 }
527 Ok(out)
528 }
529
530 fn valid_frame_follows(&mut self) -> Result<bool> {
535 match self.next_record() {
536 Ok(Some(_)) => Ok(true),
537 Ok(None) => Ok(false),
538 Err(_) => Ok(false),
539 }
540 }
541
542 pub fn current_offset(&self) -> u64 {
545 self.pos
546 }
547}
548
549pub fn replay(path: impl AsRef<Path>) -> Result<Vec<Record>> {
552 WalReader::open(path)?.replay()
553}
554
555pub fn replay_with_cipher(
557 path: impl AsRef<Path>,
558 cipher: Option<Box<dyn crate::encryption::Cipher>>,
559) -> Result<Vec<Record>> {
560 WalReader::open_with_cipher(path, cipher)?.replay()
561}
562
563pub fn frame_nonce_for(segment_no: u64, frame: u32) -> [u8; 12] {
568 let mut n = [0u8; 12];
569 n[..8].copy_from_slice(&segment_no.to_be_bytes());
570 n[8..].copy_from_slice(&frame.to_le_bytes());
571 n
572}
573
574pub struct SharedWal {
580 wal_dir: PathBuf,
581 active: Wal,
582 active_segment_no: u64,
584 durable_seq: u64,
587 wal_dek: Option<Zeroizing<[u8; 32]>>,
590 group_sync_count: u64,
594}
595
596impl SharedWal {
597 fn segment_path(wal_dir: &Path, segment_no: u64) -> PathBuf {
599 wal_dir.join(format!("seg-{segment_no:06}.wal"))
600 }
601
602 #[cfg(feature = "encryption")]
604 fn cipher_from_dek(dek: &Zeroizing<[u8; 32]>) -> Result<Box<dyn crate::encryption::Cipher>> {
605 Ok(Box::new(crate::encryption::AesCipher::new(&dek[..])?))
606 }
607
608 pub fn create(root: &Path, epoch_created: Epoch) -> Result<Self> {
610 Self::create_with_dek(root, epoch_created, None)
611 }
612
613 pub fn create_with_dek(
615 root: &Path,
616 epoch_created: Epoch,
617 wal_dek: Option<Zeroizing<[u8; 32]>>,
618 ) -> Result<Self> {
619 let wal_dir = root.join("_wal");
620 std::fs::create_dir_all(&wal_dir)?;
621 let cipher = match &wal_dek {
622 #[cfg(feature = "encryption")]
623 Some(dk) => Some(Self::cipher_from_dek(dk)?),
624 #[cfg(not(feature = "encryption"))]
625 Some(_) => {
626 return Err(MongrelError::Encryption(
627 "encryption feature disabled but a WAL DEK was supplied".into(),
628 ))
629 }
630 None => None,
631 };
632 let active =
633 Wal::create_with_cipher(Self::segment_path(&wal_dir, 0), epoch_created, cipher, 0)?;
634 Ok(Self {
635 wal_dir,
636 active,
637 active_segment_no: 0,
638 durable_seq: epoch_created.0,
639 wal_dek,
640 group_sync_count: 0,
641 })
642 }
643
644 pub fn open(
649 root: &Path,
650 epoch_created: Epoch,
651 wal_dek: Option<Zeroizing<[u8; 32]>>,
652 ) -> Result<Self> {
653 let wal_dir = root.join("_wal");
654 std::fs::create_dir_all(&wal_dir)?;
655 let next_segment_no = list_segment_numbers(&wal_dir)?
656 .into_iter()
657 .max()
658 .map(|m| m + 1)
659 .unwrap_or(0);
660 let cipher = match &wal_dek {
661 #[cfg(feature = "encryption")]
662 Some(dk) => Some(Self::cipher_from_dek(dk)?),
663 #[cfg(not(feature = "encryption"))]
664 Some(_) => {
665 return Err(MongrelError::Encryption(
666 "encryption feature disabled but a WAL DEK was supplied".into(),
667 ))
668 }
669 None => None,
670 };
671 let mut active = Wal::create_with_cipher(
672 Self::segment_path(&wal_dir, next_segment_no),
673 epoch_created,
674 cipher,
675 next_segment_no,
676 )?;
677 active.sync()?;
680 Ok(Self {
681 wal_dir,
682 active,
683 active_segment_no: next_segment_no,
684 durable_seq: epoch_created.0,
685 wal_dek,
686 group_sync_count: 0,
687 })
688 }
689
690 #[allow(dead_code)]
692 pub fn wal_dir(&self) -> &Path {
693 &self.wal_dir
694 }
695
696 pub fn append(&mut self, txn_id: u64, _table_id: u64, op: Op) -> Result<u64> {
698 Ok(self.active.append_txn(txn_id, op)?.0)
699 }
700
701 pub fn append_commit(&mut self, txn_id: u64, epoch: Epoch, added: &[AddedRun]) -> Result<u64> {
703 Ok(self
704 .active
705 .append_txn(
706 txn_id,
707 Op::TxnCommit {
708 epoch: epoch.0,
709 added_runs: added.to_vec(),
710 },
711 )?
712 .0)
713 }
714
715 pub fn append_abort(&mut self, txn_id: u64) -> Result<()> {
717 self.active.append_txn(txn_id, Op::TxnAbort)?;
718 Ok(())
719 }
720
721 pub fn append_system(&mut self, op: Op) -> Result<u64> {
723 Ok(self.active.append_system(op)?.0)
724 }
725
726 pub fn group_sync(&mut self) -> Result<u64> {
730 self.active.sync()?;
731 self.group_sync_count += 1;
732 let highest = self.active.next_seq_val().saturating_sub(1);
733 if highest > self.durable_seq {
734 self.durable_seq = highest;
735 }
736 Ok(self.durable_seq)
737 }
738
739 pub fn group_sync_count(&self) -> u64 {
741 self.group_sync_count
742 }
743
744 pub fn durable_seq(&self) -> u64 {
746 self.durable_seq
747 }
748
749 pub fn rotate(&mut self, segment_no: u64) -> Result<()> {
752 let cipher = match &self.wal_dek {
753 #[cfg(feature = "encryption")]
754 Some(dk) => Some(Self::cipher_from_dek(dk)?),
755 _ => None,
756 };
757 let path = Self::segment_path(&self.wal_dir, segment_no);
758 let epoch = Epoch(self.durable_seq);
759 let wal = Wal::create_with_cipher(path, epoch, cipher, segment_no)?;
760 self.active = wal;
761 self.active_segment_no = segment_no;
762 Ok(())
763 }
764
765 pub fn active_segment_no(&self) -> u64 {
767 self.active_segment_no
768 }
769
770 pub fn gc_segments(&mut self, min_retained_seq: u64) -> Result<usize> {
780 let mut segments = list_segment_numbers(&self.wal_dir)?;
781 segments.sort_unstable();
782 let mut reaped = 0;
783 for n in segments {
784 if n == self.active_segment_no {
785 continue; }
787 let path = Self::segment_path(&self.wal_dir, n);
788 let reapable = if min_retained_seq == u64::MAX {
791 true
792 } else {
793 let recs = match &self.wal_dek {
798 #[cfg(feature = "encryption")]
799 Some(dk) => {
800 let cipher = Self::cipher_from_dek(dk)?;
801 replay_with_cipher(&path, Some(cipher))
802 }
803 _ => replay(&path),
804 };
805 match recs {
806 Ok(recs) => recs.iter().map(|r| r.seq.0).max().unwrap_or(0) < min_retained_seq,
807 Err(_) => true,
808 }
809 };
810 if reapable {
811 std::fs::remove_file(&path)?;
812 reaped += 1;
813 }
814 }
815 if reaped > 0 {
816 if let Ok(d) = std::fs::File::open(&self.wal_dir) {
817 let _ = d.sync_all();
818 }
819 }
820 Ok(reaped)
821 }
822
823 pub fn verify_segments(&self) -> Vec<(u64, String)> {
831 let mut bad = Vec::new();
832 let Ok(segments) = list_segment_numbers(&self.wal_dir) else {
833 return bad;
834 };
835 for n in segments {
836 let path = Self::segment_path(&self.wal_dir, n);
837 let res = match &self.wal_dek {
840 #[cfg(feature = "encryption")]
841 Some(dk) => match Self::cipher_from_dek(dk) {
842 Ok(cipher) => WalReader::open_with_cipher(&path, Some(cipher)),
843 Err(e) => Err(e),
844 },
845 _ => WalReader::open_with_cipher(&path, None),
846 };
847 if let Err(e) = res {
848 bad.push((n, format!("{e}")));
849 }
850 }
851 bad
852 }
853
854 pub fn replay(root: &Path) -> Result<Vec<Record>> {
857 Self::replay_with_dek(root, None)
858 }
859
860 pub fn replay_with_dek(
862 root: &Path,
863 wal_dek: Option<&Zeroizing<[u8; 32]>>,
864 ) -> Result<Vec<Record>> {
865 let wal_dir = root.join("_wal");
866 let mut segments = list_segment_numbers(&wal_dir)?;
867 segments.sort_unstable();
868 let mut out = Vec::new();
869 for n in segments {
870 let path = Self::segment_path(&wal_dir, n);
871 let recs = match wal_dek {
874 #[cfg(feature = "encryption")]
875 Some(dk) => {
876 let cipher = Self::cipher_from_dek(dk)?;
877 replay_with_cipher(&path, Some(cipher))?
878 }
879 _ => replay(&path)?,
880 };
881 out.extend(recs);
882 }
883 Ok(out)
884 }
885}
886
887fn list_segment_numbers(wal_dir: &Path) -> Result<Vec<u64>> {
889 let mut segments = Vec::new();
890 if let Ok(rd) = std::fs::read_dir(wal_dir) {
891 for entry in rd.flatten() {
892 let fname = entry.file_name();
893 let Some(s) = fname.to_str() else {
894 continue;
895 };
896 let Some(stripped) = s.strip_prefix("seg-") else {
897 continue;
898 };
899 let Some(stripped) = stripped.strip_suffix(".wal") else {
900 continue;
901 };
902 if let Ok(n) = stripped.parse::<u64>() {
903 segments.push(n);
904 }
905 }
906 }
907 Ok(segments)
908}
909
910#[cfg(test)]
911mod shared_wal_tests {
912 use super::*;
913 use tempfile::tempdir;
914
915 #[test]
916 fn shared_wal_interleaves_two_tables_one_fd() {
917 let dir = tempdir().unwrap();
918 let mut w = SharedWal::create(dir.path(), Epoch(0)).unwrap();
919 w.append(
920 1,
921 10,
922 Op::Put {
923 table_id: 10,
924 rows: vec![1],
925 },
926 )
927 .unwrap();
928 w.append(
929 2,
930 20,
931 Op::Put {
932 table_id: 20,
933 rows: vec![2],
934 },
935 )
936 .unwrap();
937 w.append_commit(1, Epoch(1), &[]).unwrap();
938 w.append_commit(2, Epoch(2), &[]).unwrap();
939 let d = w.group_sync().unwrap();
940 assert!(d >= 4);
941 let recs = SharedWal::replay(dir.path()).unwrap();
942 assert_eq!(
943 recs.iter()
944 .filter(|r| matches!(r.op, Op::Put { .. }))
945 .count(),
946 2
947 );
948 assert_eq!(
949 recs.iter()
950 .filter(|r| matches!(r.op, Op::TxnCommit { .. }))
951 .count(),
952 2
953 );
954 }
955}
956
957#[cfg(test)]
958mod tests {
959 use super::*;
960 use tempfile::tempdir;
961
962 #[test]
963 fn append_then_replay_roundtrips() {
964 let dir = tempdir().unwrap();
965 let path = dir.path().join("seg-000000.wal");
966 let mut wal = Wal::create(&path, Epoch(100)).unwrap();
967 let s1 = wal
968 .append_txn(
969 7,
970 Op::Put {
971 table_id: 1,
972 rows: vec![1, 2, 3],
973 },
974 )
975 .unwrap();
976 let s2 = wal
977 .append_txn(
978 7,
979 Op::Delete {
980 table_id: 1,
981 row_ids: vec![RowId(7)],
982 },
983 )
984 .unwrap();
985 assert_eq!(s1, Epoch(101));
986 assert_eq!(s2, Epoch(102));
987 wal.sync().unwrap();
988
989 let records = replay(&path).unwrap();
990 assert_eq!(records.len(), 2);
991 assert_eq!(records[0].seq, Epoch(101));
992 assert_eq!(records[0].txn_id, 7);
993 match &records[0].op {
994 Op::Put { table_id, rows } => {
995 assert_eq!(*table_id, 1);
996 assert_eq!(rows, &vec![1, 2, 3]);
997 }
998 other => panic!("unexpected op {other:?}"),
999 }
1000 match &records[1].op {
1001 Op::Delete { row_ids, .. } => {
1002 assert_eq!(*row_ids, vec![RowId(7)]);
1003 }
1004 other => panic!("unexpected op {other:?}"),
1005 }
1006 }
1007
1008 #[test]
1009 fn record_roundtrips_with_txn_id_and_commit_marker() {
1010 let dir = tempdir().unwrap();
1011 let path = dir.path().join("seg-000000.wal");
1012 let mut w = Wal::create(&path, Epoch(0)).unwrap();
1013 w.append_txn(
1014 7,
1015 Op::Put {
1016 table_id: 3,
1017 rows: vec![1, 2, 3],
1018 },
1019 )
1020 .unwrap();
1021 w.append_txn(
1022 7,
1023 Op::TxnCommit {
1024 epoch: 11,
1025 added_runs: vec![],
1026 },
1027 )
1028 .unwrap();
1029 w.sync().unwrap();
1030 let recs = replay(&path).unwrap();
1031 assert_eq!(recs[0].txn_id, 7);
1032 assert!(matches!(recs[0].op, Op::Put { table_id: 3, .. }));
1033 assert!(matches!(recs[1].op, Op::TxnCommit { epoch: 11, .. }));
1034 let mut w2 = Wal::create(&path, Epoch(0)).unwrap();
1036 w2.append_system(Op::Flush {
1037 table_id: 3,
1038 flushed_epoch: 11,
1039 })
1040 .unwrap();
1041 w2.sync().unwrap();
1042 let recs = replay(&path).unwrap();
1043 assert_eq!(recs[0].txn_id, SYSTEM_TXN_ID);
1044 assert!(matches!(recs[0].op, Op::Flush { .. }));
1045 }
1046
1047 #[test]
1048 fn torn_write_is_detected() {
1049 let dir = tempdir().unwrap();
1050 let path = dir.path().join("seg-000001.wal");
1051 let mut wal = Wal::create(&path, Epoch(0)).unwrap();
1052 wal.append_txn(
1053 1,
1054 Op::Put {
1055 table_id: 1,
1056 rows: vec![0; 10],
1057 },
1058 )
1059 .unwrap();
1060 wal.sync().unwrap();
1061 drop(wal);
1062
1063 let mut f = OpenOptions::new().append(true).open(&path).unwrap();
1065 f.write_all(&64u32.to_le_bytes()).unwrap();
1067 f.write_all(&[0u8; 7]).unwrap();
1068 f.sync_all().unwrap();
1069 drop(f);
1070
1071 let mut reader = WalReader::open(&path).unwrap();
1072 assert!(reader.next_record().unwrap().is_some());
1074 let err = reader.next_record().unwrap_err();
1076 assert!(matches!(err, MongrelError::TornWrite { .. }), "got {err:?}");
1077 }
1078
1079 #[test]
1080 fn crc_corruption_is_detected() {
1081 let dir = tempdir().unwrap();
1082 let path = dir.path().join("seg-000002.wal");
1083 let mut wal = Wal::create(&path, Epoch(0)).unwrap();
1084 wal.append_txn(
1085 1,
1086 Op::Put {
1087 table_id: 9,
1088 rows: vec![1, 2, 3, 4],
1089 },
1090 )
1091 .unwrap();
1092 wal.sync().unwrap();
1093 drop(wal);
1094
1095 let mut bytes = std::fs::read(&path).unwrap();
1097 let last = bytes.len() - 1;
1098 bytes[last] ^= 0xFF;
1099 std::fs::write(&path, bytes).unwrap();
1100
1101 let err = WalReader::open(&path).unwrap().next_record().unwrap_err();
1102 assert!(
1103 matches!(err, MongrelError::CorruptWal { .. }),
1104 "got {err:?}"
1105 );
1106 }
1107
1108 #[test]
1109 fn trailing_torn_is_eof_but_interior_corruption_errors() {
1110 let dir = tempdir().unwrap();
1111
1112 let path_a = dir.path().join("seg-torn.wal");
1115 let mut wal = Wal::create(&path_a, Epoch(0)).unwrap();
1116 wal.append_txn(
1117 1,
1118 Op::Put {
1119 table_id: 1,
1120 rows: vec![1],
1121 },
1122 )
1123 .unwrap();
1124 wal.append_txn(
1125 1,
1126 Op::Put {
1127 table_id: 1,
1128 rows: vec![2],
1129 },
1130 )
1131 .unwrap();
1132 wal.sync().unwrap();
1133 drop(wal);
1134 let mut f = OpenOptions::new().append(true).open(&path_a).unwrap();
1136 f.write_all(&64u32.to_le_bytes()).unwrap();
1137 f.write_all(&[0u8; 7]).unwrap();
1138 f.sync_all().unwrap();
1139 drop(f);
1140 let recs = replay(&path_a).unwrap();
1141 assert_eq!(recs.len(), 2, "torn trailing frame must truncate cleanly");
1142
1143 let path_b = dir.path().join("seg-interior.wal");
1146 let mut wal = Wal::create(&path_b, Epoch(0)).unwrap();
1147 wal.append_txn(
1148 1,
1149 Op::Put {
1150 table_id: 1,
1151 rows: vec![10, 20, 30],
1152 },
1153 )
1154 .unwrap();
1155 wal.append_txn(
1156 1,
1157 Op::Put {
1158 table_id: 1,
1159 rows: vec![40],
1160 },
1161 )
1162 .unwrap();
1163 wal.sync().unwrap();
1164 drop(wal);
1165 let mut bytes = std::fs::read(&path_b).unwrap();
1168 let first_payload_byte = HEADER_LEN as usize + 4 + 4 + 8 + 8; bytes[first_payload_byte] ^= 0xFF;
1170 std::fs::write(&path_b, bytes).unwrap();
1171 let err = replay(&path_b).unwrap_err();
1172 assert!(
1173 matches!(err, MongrelError::CorruptWal { .. }),
1174 "interior corruption must error, got {err:?}"
1175 );
1176
1177 let path_c = dir.path().join("seg-badtail.wal");
1180 let mut wal = Wal::create(&path_c, Epoch(0)).unwrap();
1181 wal.append_txn(
1182 1,
1183 Op::Put {
1184 table_id: 1,
1185 rows: vec![5],
1186 },
1187 )
1188 .unwrap();
1189 wal.sync().unwrap();
1190 drop(wal);
1191 let mut bytes = std::fs::read(&path_c).unwrap();
1192 let last = bytes.len() - 1;
1193 bytes[last] ^= 0xFF;
1194 std::fs::write(&path_c, bytes).unwrap();
1195 let recs = replay(&path_c).unwrap();
1196 assert_eq!(
1197 recs.len(),
1198 0,
1199 "trailing corrupt frame with no valid follower is a torn tail"
1200 );
1201 }
1202
1203 #[test]
1204 fn byte_threshold_auto_syncs() {
1205 let dir = tempdir().unwrap();
1206 let path = dir.path().join("seg-000003.wal");
1207 let mut wal = Wal::create(&path, Epoch(0)).unwrap();
1208 wal.sync_byte_threshold = 1; wal.append_txn(
1210 1,
1211 Op::Put {
1212 table_id: 1,
1213 rows: vec![0; 5],
1214 },
1215 )
1216 .unwrap();
1217 assert_eq!(
1218 wal.unflushed_bytes(),
1219 0,
1220 "threshold should have auto-synced"
1221 );
1222 }
1223
1224 #[cfg(feature = "encryption")]
1225 #[test]
1226 fn wal_nonce_is_segment_deterministic() {
1227 assert_ne!(frame_nonce_for(5, 0), frame_nonce_for(6, 0));
1230 assert_ne!(frame_nonce_for(5, 0), frame_nonce_for(5, 1));
1231 assert_eq!(frame_nonce_for(5, 0), frame_nonce_for(5, 0));
1234 }
1235}