1use fsqlite_error::{FrankenError, Result};
17use fsqlite_types::cx::Cx;
18use fsqlite_types::flags::SyncFlags;
19use fsqlite_vfs::{SyncKind, VfsFile, VfsWriteCompletion};
20use tracing::{debug, error, warn};
21
22static PARANOID_DURABILITY: std::sync::LazyLock<bool> = std::sync::LazyLock::new(|| {
25 std::env::var("FRANKENSQLITE_PARANOID_DURABILITY")
26 .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
27});
28
29use crate::checksum::{
30 SqliteWalChecksum, WAL_FORMAT_VERSION, WAL_FRAME_HEADER_SIZE, WAL_HEADER_SIZE, WAL_MAGIC_LE,
31 WalChecksumTransform, WalFrameHeader, WalHeader, WalSalts, compute_wal_frame_checksum,
32 read_wal_header_checksum, wal_header_checksum, write_wal_frame_checksum,
33 write_wal_frame_checksum_fields, write_wal_frame_salts,
34};
35
36#[inline]
37fn log_replay_decision(
38 replay_cursor: &'static str,
39 frame_no: usize,
40 commit_boundary: usize,
41 decision_reason: &'static str,
42) {
43 debug!(
44 replay_cursor,
45 frame_no, commit_boundary, decision_reason, "WAL replay decision"
46 );
47}
48
49struct VfsWritePreflight<'a> {
50 completion: Option<&'a VfsWriteCompletion>,
51}
52
53impl<'a> VfsWritePreflight<'a> {
54 fn new(completion: Option<&'a VfsWriteCompletion>) -> Self {
55 Self { completion }
56 }
57
58 fn hand_off(&mut self) {
59 self.completion = None;
60 }
61}
62
63impl Drop for VfsWritePreflight<'_> {
64 fn drop(&mut self) {
65 if let Some(completion) = self.completion {
66 completion.complete_error();
67 }
68 }
69}
70
71#[derive(Debug, Clone, Copy)]
73pub struct WalAppendFrameRef<'a> {
74 pub page_number: u32,
76 pub page_data: &'a [u8],
78 pub db_size_if_commit: u32,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub struct WalGenerationIdentity {
90 pub checkpoint_seq: u32,
92 pub salts: WalSalts,
94}
95
96impl WalGenerationIdentity {
97 #[must_use]
99 pub const fn from_header(header: &WalHeader) -> Self {
100 Self {
101 checkpoint_seq: header.checkpoint_seq,
102 salts: header.salts,
103 }
104 }
105}
106
107#[inline]
108fn push_wal_frame_bytes(
109 frame_scratch: &mut Vec<u8>,
110 page_number: u32,
111 db_size_if_commit: u32,
112 salts: WalSalts,
113 page_data: &[u8],
114) {
115 frame_scratch.extend_from_slice(&page_number.to_be_bytes());
116 frame_scratch.extend_from_slice(&db_size_if_commit.to_be_bytes());
117 frame_scratch.extend_from_slice(&salts.salt1.to_be_bytes());
118 frame_scratch.extend_from_slice(&salts.salt2.to_be_bytes());
119 frame_scratch.extend_from_slice(&[0_u8; 8]);
120 frame_scratch.extend_from_slice(page_data);
121}
122
123pub struct WalFile<F: VfsFile> {
128 file: F,
129 page_size: usize,
130 big_endian_checksum: bool,
131 header: WalHeader,
132 running_checksum: SqliteWalChecksum,
134 frame_count: usize,
136 last_commit_frame: Option<usize>,
138 frame_scratch: Vec<u8>,
144 last_fsynced_frame_count: usize,
149}
150
151impl<F: VfsFile> WalFile<F> {
152 pub async fn refresh(&mut self, cx: &Cx) -> Result<()> {
158 let frame_size = self.frame_size();
159 let expected_size = u64::try_from(WAL_HEADER_SIZE)
160 .expect("WAL header size fits u64")
161 .saturating_add(
162 u64::try_from(self.frame_count)
163 .unwrap_or(u64::MAX)
164 .saturating_mul(u64::try_from(frame_size).unwrap_or(u64::MAX)),
165 );
166 let file_size = self.file.file_size(cx)?;
167
168 if file_size < expected_size {
172 log_replay_decision("refresh", 0, self.frame_count, "file_shrank_rebuild");
173 return self.rebuild_state_from_file(cx).await;
174 }
175
176 let mut header_buf = [0u8; WAL_HEADER_SIZE];
180 let header_read = self.file.read(cx, &mut header_buf, 0).await?;
181 if header_read < WAL_HEADER_SIZE {
182 log_replay_decision("refresh", 0, self.frame_count, "header_short_read_corrupt");
183 return Err(FrankenError::WalCorrupt {
184 detail: format!(
185 "WAL file too small for header during refresh: read {header_read}, need {WAL_HEADER_SIZE}"
186 ),
187 });
188 }
189
190 let disk_header = WalHeader::from_bytes(&header_buf)?;
191 let disk_big_endian = disk_header.big_endian_checksum();
192 let disk_header_checksum = read_wal_header_checksum(&header_buf)?;
193 let expected_header_checksum = wal_header_checksum(&header_buf, disk_big_endian)?;
194 if disk_header_checksum != expected_header_checksum {
195 log_replay_decision(
196 "refresh",
197 0,
198 self.frame_count,
199 "header_checksum_mismatch_corrupt",
200 );
201 return Err(FrankenError::WalCorrupt {
202 detail: "WAL header checksum mismatch during refresh".to_owned(),
203 });
204 }
205
206 if disk_header.magic != self.header.magic
208 || disk_header.format_version != self.header.format_version
209 || disk_header.page_size != self.header.page_size
210 || disk_header.checkpoint_seq != self.header.checkpoint_seq
211 || disk_header.salts != self.header.salts
212 {
213 log_replay_decision(
214 "refresh",
215 0,
216 self.frame_count,
217 "header_generation_changed_rebuild",
218 );
219 return self.rebuild_state_from_file(cx).await;
220 }
221
222 if file_size == expected_size {
223 return Ok(());
224 }
225
226 let frame_size_u64 = u64::try_from(frame_size).unwrap_or(u64::MAX);
236 let available_frames = usize::try_from(
237 file_size.saturating_sub(u64::try_from(WAL_HEADER_SIZE).unwrap_or(0)) / frame_size_u64,
238 )
239 .unwrap_or(usize::MAX);
240 if available_frames <= self.frame_count {
241 return Ok(());
242 }
243
244 let mut new_frame_count = self.frame_count;
245 let mut new_running_checksum = self.running_checksum;
246 let mut last_commit_count = self.frame_count;
247 let mut last_commit_checksum = self.running_checksum;
248
249 let mut frame_buf = vec![0u8; frame_size];
250 for frame_index in self.frame_count..available_frames {
251 let frame_no = frame_index.saturating_add(1);
252 let offset = self.frame_offset(frame_index);
253 let bytes_read = self.file.read(cx, &mut frame_buf, offset).await?;
254 if bytes_read < frame_size {
255 log_replay_decision(
256 "refresh_incremental",
257 frame_no,
258 last_commit_count,
259 "truncated_tail_stop",
260 );
261 break; }
263
264 let frame_header = WalFrameHeader::from_bytes(&frame_buf[..WAL_FRAME_HEADER_SIZE])?;
265 if frame_header.salts != self.header.salts {
266 log_replay_decision(
267 "refresh_incremental",
268 frame_no,
269 last_commit_count,
270 "salt_mismatch_stop",
271 );
272 break; }
274
275 let expected = compute_wal_frame_checksum(
276 &frame_buf,
277 self.page_size,
278 new_running_checksum,
279 self.big_endian_checksum,
280 )?;
281 if frame_header.checksum != expected {
282 log_replay_decision(
283 "refresh_incremental",
284 frame_no,
285 last_commit_count,
286 "checksum_mismatch_stop",
287 );
288 break; }
290
291 new_running_checksum = expected;
292 new_frame_count += 1;
293
294 if frame_header.is_commit() {
295 last_commit_count = new_frame_count;
296 last_commit_checksum = new_running_checksum;
297 log_replay_decision(
298 "refresh_incremental",
299 frame_no,
300 last_commit_count,
301 "accept_commit",
302 );
303 } else {
304 log_replay_decision(
305 "refresh_incremental",
306 frame_no,
307 last_commit_count,
308 "accept_non_commit",
309 );
310 }
311 }
312
313 self.frame_count = last_commit_count;
314 self.running_checksum = last_commit_checksum;
315 self.last_commit_frame = last_commit_count.checked_sub(1);
316
317 Ok(())
318 }
319
320 async fn rebuild_state_from_file(&mut self, cx: &Cx) -> Result<()> {
321 self.last_fsynced_frame_count = 0;
326
327 let mut header_buf = [0u8; WAL_HEADER_SIZE];
328 let header_read = self.file.read(cx, &mut header_buf, 0).await?;
329 if header_read < WAL_HEADER_SIZE {
330 log_replay_decision("rebuild", 0, self.frame_count, "header_short_read_corrupt");
331 return Err(FrankenError::WalCorrupt {
332 detail: format!(
333 "WAL file too small for header during rebuild: read {header_read}, need {WAL_HEADER_SIZE}"
334 ),
335 });
336 }
337
338 let header = WalHeader::from_bytes(&header_buf)?;
339 let page_size = usize::try_from(header.page_size).expect("WAL header page size fits usize");
340 let big_endian_checksum = header.big_endian_checksum();
341 let header_checksum = read_wal_header_checksum(&header_buf)?;
342 let expected_header_checksum = wal_header_checksum(&header_buf, big_endian_checksum)?;
343 if header_checksum != expected_header_checksum {
344 log_replay_decision(
345 "rebuild",
346 0,
347 self.frame_count,
348 "header_checksum_mismatch_corrupt",
349 );
350 return Err(FrankenError::WalCorrupt {
351 detail: "WAL header checksum mismatch during rebuild".to_owned(),
352 });
353 }
354
355 self.header = header;
356 self.page_size = page_size;
357 self.big_endian_checksum = big_endian_checksum;
358 self.running_checksum = header_checksum;
359 self.frame_count = 0;
360
361 let mut new_frame_count = 0;
362 let mut new_running_checksum = header_checksum;
363 let mut last_commit_count = 0;
364 let mut last_commit_checksum = header_checksum;
365
366 let frame_size = self.frame_size();
367 let file_size = self.file.file_size(cx)?;
368 let max_frames = usize::try_from(
369 file_size.saturating_sub(u64::try_from(WAL_HEADER_SIZE).unwrap_or(0))
370 / u64::try_from(frame_size).unwrap_or(1),
371 )
372 .unwrap_or(usize::MAX);
373
374 let mut frame_buf = vec![0u8; frame_size];
375 for frame_index in 0..max_frames {
376 let frame_no = frame_index.saturating_add(1);
377 let offset = self.frame_offset(frame_index);
378 let bytes_read = self.file.read(cx, &mut frame_buf, offset).await?;
379 if bytes_read < frame_size {
380 log_replay_decision(
381 "rebuild",
382 frame_no,
383 last_commit_count,
384 "truncated_tail_stop",
385 );
386 break;
387 }
388
389 let frame_header = WalFrameHeader::from_bytes(&frame_buf[..WAL_FRAME_HEADER_SIZE])?;
390 if frame_header.salts != self.header.salts {
391 log_replay_decision("rebuild", frame_no, last_commit_count, "salt_mismatch_stop");
392 break;
393 }
394
395 let expected = compute_wal_frame_checksum(
396 &frame_buf,
397 self.page_size,
398 new_running_checksum,
399 self.big_endian_checksum,
400 )?;
401 if frame_header.checksum != expected {
402 log_replay_decision(
403 "rebuild",
404 frame_no,
405 last_commit_count,
406 "checksum_mismatch_stop",
407 );
408 break;
409 }
410
411 new_running_checksum = expected;
412 new_frame_count += 1;
413
414 if frame_header.is_commit() {
415 last_commit_count = new_frame_count;
416 last_commit_checksum = new_running_checksum;
417 log_replay_decision("rebuild", frame_no, last_commit_count, "accept_commit");
418 } else {
419 log_replay_decision("rebuild", frame_no, last_commit_count, "accept_non_commit");
420 }
421 }
422
423 self.frame_count = last_commit_count;
424 self.running_checksum = last_commit_checksum;
425 self.last_commit_frame = last_commit_count.checked_sub(1);
426
427 Ok(())
428 }
429
430 #[must_use]
432 pub fn frame_size(&self) -> usize {
433 WAL_FRAME_HEADER_SIZE + self.page_size
434 }
435
436 #[allow(clippy::cast_possible_truncation)]
438 pub(crate) fn frame_offset(&self, index: usize) -> u64 {
439 let header_size = WAL_HEADER_SIZE as u64;
442 let idx = index as u64;
443 let frame_sz = self.frame_size() as u64;
444 header_size.saturating_add(idx.saturating_mul(frame_sz))
445 }
446
447 #[must_use]
449 pub fn frame_count(&self) -> usize {
450 self.frame_count
451 }
452
453 #[must_use]
455 pub fn header(&self) -> &WalHeader {
456 &self.header
457 }
458
459 #[must_use]
461 pub fn generation_identity(&self) -> WalGenerationIdentity {
462 WalGenerationIdentity::from_header(&self.header)
463 }
464
465 #[must_use]
467 pub fn page_size(&self) -> usize {
468 self.page_size
469 }
470
471 #[must_use]
473 pub fn big_endian_checksum(&self) -> bool {
474 self.big_endian_checksum
475 }
476
477 #[must_use]
479 pub fn running_checksum(&self) -> SqliteWalChecksum {
480 self.running_checksum
481 }
482
483 #[cfg(test)]
484 #[must_use]
485 fn frame_scratch_len(&self) -> usize {
486 self.frame_scratch.len()
487 }
488
489 #[cfg(test)]
490 #[must_use]
491 fn frame_scratch_capacity(&self) -> usize {
492 self.frame_scratch.capacity()
493 }
494
495 #[cfg(test)]
496 #[must_use]
497 fn frame_scratch_ptr(&self) -> *const u8 {
498 self.frame_scratch.as_ptr()
499 }
500
501 pub async fn create(
506 cx: &Cx,
507 mut file: F,
508 page_size: u32,
509 checkpoint_seq: u32,
510 salts: WalSalts,
511 ) -> Result<Self> {
512 let header = WalHeader {
513 magic: WAL_MAGIC_LE,
514 format_version: WAL_FORMAT_VERSION,
515 page_size,
516 checkpoint_seq,
517 salts,
518 checksum: SqliteWalChecksum::default(), };
520 let header_bytes = header.to_bytes()?;
521 file.write(cx, &header_bytes, 0).await?;
522 file.truncate(
523 cx,
524 u64::try_from(WAL_HEADER_SIZE).expect("header size fits u64"),
525 )?;
526 file.sync(cx, SyncFlags::NORMAL)?;
534
535 let running_checksum = read_wal_header_checksum(&header_bytes)?;
536
537 debug!(
538 page_size,
539 checkpoint_seq,
540 salt1 = header.salts.salt1,
541 salt2 = header.salts.salt2,
542 "WAL file created"
543 );
544 crate::metrics::GLOBAL_WAL_METRICS.set_wal_frames_current(0);
545
546 Ok(Self {
547 file,
548 page_size: usize::try_from(page_size).expect("page size fits usize"),
549 big_endian_checksum: false,
550 header,
551 running_checksum,
552 frame_count: 0,
553 last_commit_frame: None,
554 frame_scratch: Vec::new(),
555 last_fsynced_frame_count: 0,
556 })
557 }
558
559 #[allow(clippy::too_many_lines)]
563 pub async fn open(cx: &Cx, file: F) -> Result<Self> {
564 let mut header_buf = [0u8; WAL_HEADER_SIZE];
566 let bytes_read = file.read(cx, &mut header_buf, 0).await?;
567 if bytes_read < WAL_HEADER_SIZE {
568 log_replay_decision("startup_open", 0, 0, "header_short_read_corrupt");
569 return Err(FrankenError::WalCorrupt {
570 detail: format!(
571 "WAL file too small for header: read {bytes_read}, need {WAL_HEADER_SIZE}"
572 ),
573 });
574 }
575 let header = WalHeader::from_bytes(&header_buf)?;
576 let page_size = usize::try_from(header.page_size).expect("WAL header page size fits usize");
577 let big_endian_checksum = header.big_endian_checksum();
578 let frame_size = WAL_FRAME_HEADER_SIZE + page_size;
579
580 let header_checksum = read_wal_header_checksum(&header_buf)?;
582 let expected_checksum =
583 crate::checksum::wal_header_checksum(&header_buf, big_endian_checksum)?;
584 if header_checksum != expected_checksum {
585 error!("WAL header checksum mismatch — file may be corrupt");
586 log_replay_decision("startup_open", 0, 0, "header_checksum_mismatch_corrupt");
587 return Err(FrankenError::WalCorrupt {
588 detail: "WAL header checksum mismatch".to_owned(),
589 });
590 }
591
592 let file_size = file.file_size(cx)?;
594 let data_bytes =
595 file_size.saturating_sub(u64::try_from(WAL_HEADER_SIZE).expect("header size fits u64"));
596 let max_frames = usize::try_from(data_bytes / u64::try_from(frame_size).unwrap_or(1))
597 .unwrap_or(usize::MAX);
598
599 let mut running_checksum = header_checksum;
600 let mut valid_frames = 0_usize;
601 let mut last_commit_frames = 0_usize;
602 let mut last_commit_checksum = header_checksum;
603 let mut frame_buf = vec![0u8; frame_size];
604
605 for frame_index in 0..max_frames {
606 let frame_no = frame_index.saturating_add(1);
607 let header_size = WAL_HEADER_SIZE as u64;
612 let idx = frame_index as u64;
613 let frame_sz = frame_size as u64;
614 let file_offset = header_size.saturating_add(idx.saturating_mul(frame_sz));
615
616 let bytes_read = file.read(cx, &mut frame_buf, file_offset).await?;
617 if bytes_read < frame_size {
618 log_replay_decision(
619 "startup_open",
620 frame_no,
621 last_commit_frames,
622 "truncated_tail_stop",
623 );
624 break; }
626
627 let frame_header = WalFrameHeader::from_bytes(&frame_buf[..WAL_FRAME_HEADER_SIZE])?;
629 if frame_header.salts != header.salts {
630 warn!(frame_index, "WAL frame salt mismatch — chain terminated");
631 log_replay_decision(
632 "startup_open",
633 frame_no,
634 last_commit_frames,
635 "salt_mismatch_stop",
636 );
637 break; }
639
640 let expected = compute_wal_frame_checksum(
642 &frame_buf,
643 page_size,
644 running_checksum,
645 big_endian_checksum,
646 )?;
647 if frame_header.checksum != expected {
648 warn!(
649 frame_index,
650 "WAL frame checksum mismatch — chain terminated"
651 );
652 log_replay_decision(
653 "startup_open",
654 frame_no,
655 last_commit_frames,
656 "checksum_mismatch_stop",
657 );
658 break; }
660
661 running_checksum = expected;
662 valid_frames += 1;
663
664 if frame_header.is_commit() {
665 last_commit_frames = valid_frames;
666 last_commit_checksum = running_checksum;
667 log_replay_decision(
668 "startup_open",
669 frame_no,
670 last_commit_frames,
671 "accept_commit",
672 );
673 } else {
674 log_replay_decision(
675 "startup_open",
676 frame_no,
677 last_commit_frames,
678 "accept_non_commit",
679 );
680 }
681 }
682
683 debug!(
684 page_size,
685 big_endian_checksum,
686 checkpoint_seq = header.checkpoint_seq,
687 valid_frames = last_commit_frames,
688 "WAL file opened"
689 );
690 crate::metrics::GLOBAL_WAL_METRICS
691 .set_wal_frames_current(u64::try_from(last_commit_frames).unwrap_or(u64::MAX));
692
693 Ok(Self {
694 file,
695 page_size,
696 big_endian_checksum,
697 header,
698 running_checksum: last_commit_checksum,
699 frame_count: last_commit_frames,
700 last_commit_frame: last_commit_frames.checked_sub(1),
701 frame_scratch: Vec::new(),
702 last_fsynced_frame_count: last_commit_frames,
703 })
704 }
705
706 pub fn advance_state_after_write(
712 &mut self,
713 frames_written: usize,
714 new_running_checksum: SqliteWalChecksum,
715 ) -> Result<()> {
716 let new_count = self
717 .frame_count
718 .checked_add(frames_written)
719 .ok_or(FrankenError::DatabaseFull)?;
720
721 if new_count > usize::try_from(u32::MAX).unwrap_or(usize::MAX) {
722 return Err(FrankenError::DatabaseFull);
723 }
724
725 self.frame_count = new_count;
726 self.running_checksum = new_running_checksum;
727 crate::metrics::GLOBAL_WAL_METRICS
728 .set_wal_frames_current(u64::try_from(self.frame_count).unwrap_or(u64::MAX));
729 Ok(())
730 }
731
732 pub async fn append_frame(
739 &mut self,
740 cx: &Cx,
741 page_number: u32,
742 page_data: &[u8],
743 db_size_if_commit: u32,
744 ) -> Result<()> {
745 if self.frame_count >= usize::try_from(u32::MAX).unwrap_or(usize::MAX) {
746 return Err(FrankenError::DatabaseFull);
747 }
748
749 if page_data.len() != self.page_size {
750 return Err(FrankenError::WalCorrupt {
751 detail: format!(
752 "page data size mismatch: expected {}, got {}",
753 self.page_size,
754 page_data.len()
755 ),
756 });
757 }
758
759 let frame_size = self.frame_size();
761 let page_size = self.page_size;
762 let salts = self.header.salts;
763 let running_checksum = self.running_checksum;
764 let big_endian_checksum = self.big_endian_checksum;
765 let offset = self.frame_offset(self.frame_count);
766
767 let mut frame_scratch = std::mem::take(&mut self.frame_scratch);
768 frame_scratch.clear();
769 if frame_scratch.capacity() < frame_size {
770 frame_scratch.reserve(frame_size - frame_scratch.capacity());
771 }
772 let append_result = async {
773 push_wal_frame_bytes(
774 &mut frame_scratch,
775 page_number,
776 db_size_if_commit,
777 salts,
778 page_data,
779 );
780 let frame = &mut frame_scratch[..frame_size];
781
782 let new_checksum =
784 write_wal_frame_checksum(frame, page_size, running_checksum, big_endian_checksum)?;
785
786 self.file.write(cx, frame, offset).await?;
787 Ok::<_, FrankenError>(new_checksum)
788 }
789 .await;
790 self.frame_scratch = frame_scratch;
791 let new_checksum = append_result?;
792
793 self.running_checksum = new_checksum;
794 self.frame_count += 1;
795 if db_size_if_commit != 0 {
796 self.last_commit_frame = Some(self.frame_count - 1);
797 }
798 crate::metrics::GLOBAL_WAL_METRICS
799 .set_wal_frames_current(u64::try_from(self.frame_count).unwrap_or(u64::MAX));
800
801 let bytes_written = u64::try_from(frame_size).unwrap_or(u64::MAX);
802 let span = tracing::span!(
803 tracing::Level::DEBUG,
804 "wal_write",
805 frame_count = self.frame_count,
806 bytes_written = bytes_written,
807 page_number = page_number,
808 is_commit = db_size_if_commit > 0,
809 );
810 let _guard = span.enter();
811
812 debug!(
813 frame_index = self.frame_count - 1,
814 page_number,
815 is_commit = db_size_if_commit > 0,
816 "WAL frame appended"
817 );
818
819 crate::metrics::GLOBAL_WAL_METRICS.record_frame_write(bytes_written);
820
821 Ok(())
822 }
823
824 pub fn prepare_frame_bytes(&self, frames: &[WalAppendFrameRef<'_>]) -> Result<Vec<u8>> {
831 let mut frame_buf = Vec::new();
832 let mut checksum_transforms = Vec::new();
833 let _ = self.prepare_frame_bytes_with_transforms_into(
834 frames.len(),
835 frames.iter().copied(),
836 &mut frame_buf,
837 &mut checksum_transforms,
838 )?;
839 Ok(frame_buf)
840 }
841
842 pub fn prepare_frame_bytes_with_transforms_into<'a, I>(
849 &self,
850 frame_count: usize,
851 frames: I,
852 frame_buf: &mut Vec<u8>,
853 checksum_transforms: &mut Vec<WalChecksumTransform>,
854 ) -> Result<Option<usize>>
855 where
856 I: IntoIterator<Item = WalAppendFrameRef<'a>>,
857 {
858 frame_buf.clear();
859 checksum_transforms.clear();
860 if frame_count == 0 {
861 return Ok(None);
862 }
863
864 let frame_size = self.frame_size();
865 let total_bytes = frame_count
866 .checked_mul(frame_size)
867 .ok_or(FrankenError::DatabaseFull)?;
868 frame_buf.resize(total_bytes, 0);
869 if checksum_transforms.capacity() < frame_count {
870 checksum_transforms.reserve(frame_count - checksum_transforms.capacity());
871 }
872
873 let mut observed_frame_count = 0usize;
874 let mut last_commit_offset = None;
875 for (idx, frame) in frames.into_iter().enumerate() {
876 if idx >= frame_count {
877 return Err(FrankenError::WalCorrupt {
878 detail: format!(
879 "prepared batch frame count mismatch: expected {frame_count}, got more than declared"
880 ),
881 });
882 }
883 if frame.page_data.len() != self.page_size {
884 return Err(FrankenError::WalCorrupt {
885 detail: format!(
886 "page data size mismatch in batch frame {idx}: expected {}, got {}",
887 self.page_size,
888 frame.page_data.len()
889 ),
890 });
891 }
892
893 let buf_offset = idx
894 .checked_mul(frame_size)
895 .ok_or(FrankenError::DatabaseFull)?;
896 let frame_slice = &mut frame_buf[buf_offset..buf_offset + frame_size];
897
898 frame_slice[..4].copy_from_slice(&frame.page_number.to_be_bytes());
899 frame_slice[4..8].copy_from_slice(&frame.db_size_if_commit.to_be_bytes());
900 write_wal_frame_salts(&mut frame_slice[..WAL_FRAME_HEADER_SIZE], self.header.salts)?;
901 frame_slice[WAL_FRAME_HEADER_SIZE..].copy_from_slice(frame.page_data);
902 checksum_transforms.push(WalChecksumTransform::for_wal_frame(
903 frame_slice,
904 self.page_size,
905 self.big_endian_checksum,
906 )?);
907 if frame.db_size_if_commit != 0 {
908 last_commit_offset = Some(idx);
909 }
910 observed_frame_count = idx.saturating_add(1);
911 }
912
913 if observed_frame_count != frame_count {
914 return Err(FrankenError::WalCorrupt {
915 detail: format!(
916 "prepared batch frame count mismatch: expected {frame_count}, got {observed_frame_count}"
917 ),
918 });
919 }
920
921 Ok(last_commit_offset)
922 }
923
924 pub async fn prepared_append_window_still_current(
931 &self,
932 cx: &Cx,
933 generation: WalGenerationIdentity,
934 start_frame_index: usize,
935 ) -> Result<bool> {
936 let expected_size = u64::try_from(WAL_HEADER_SIZE)
937 .expect("WAL header size fits u64")
938 .saturating_add(
939 u64::try_from(start_frame_index)
940 .unwrap_or(u64::MAX)
941 .saturating_mul(u64::try_from(self.frame_size()).unwrap_or(u64::MAX)),
942 );
943 if self.file.file_size(cx)? != expected_size {
944 return Ok(false);
945 }
946
947 let mut header_buf = [0u8; WAL_HEADER_SIZE];
948 let header_read = self.file.read(cx, &mut header_buf, 0).await?;
949 if header_read < WAL_HEADER_SIZE {
950 return Err(FrankenError::WalCorrupt {
951 detail: format!(
952 "WAL file too small for header during prepared append validation: read {header_read}, need {WAL_HEADER_SIZE}"
953 ),
954 });
955 }
956
957 let disk_header = WalHeader::from_bytes(&header_buf)?;
958 Ok(WalGenerationIdentity::from_header(&disk_header) == generation)
959 }
960
961 pub fn finalize_prepared_frame_bytes(
968 &self,
969 prepared_frame_bytes: &mut [u8],
970 frame_transforms: &[WalChecksumTransform],
971 ) -> Result<SqliteWalChecksum> {
972 let frame_count = frame_transforms.len();
973 if frame_count == 0 {
974 return Ok(self.running_checksum);
975 }
976
977 let frame_size = self.frame_size();
978 let expected_bytes = frame_count
979 .checked_mul(frame_size)
980 .ok_or(FrankenError::DatabaseFull)?;
981 if prepared_frame_bytes.len() != expected_bytes {
982 return Err(FrankenError::WalCorrupt {
983 detail: format!(
984 "prepared batch byte length mismatch: expected {expected_bytes}, got {}",
985 prepared_frame_bytes.len()
986 ),
987 });
988 }
989
990 let mut running_checksum = self.running_checksum;
991 for (frame_slice, frame_transform) in prepared_frame_bytes
992 .chunks_exact_mut(frame_size)
993 .zip(frame_transforms.iter())
994 {
995 write_wal_frame_salts(&mut frame_slice[..WAL_FRAME_HEADER_SIZE], self.header.salts)?;
996 running_checksum = frame_transform.apply(running_checksum);
997 write_wal_frame_checksum_fields(frame_slice, running_checksum)?;
998 }
999
1000 Ok(running_checksum)
1001 }
1002
1003 pub async fn append_finalized_prepared_frame_bytes(
1006 &mut self,
1007 cx: &Cx,
1008 prepared_frame_bytes: &[u8],
1009 frame_count: usize,
1010 final_running_checksum: SqliteWalChecksum,
1011 last_commit_offset: Option<usize>,
1012 ) -> Result<()> {
1013 self.append_finalized_prepared_frame_bytes_with_completion(
1014 cx,
1015 prepared_frame_bytes,
1016 frame_count,
1017 final_running_checksum,
1018 last_commit_offset,
1019 None,
1020 )
1021 .await
1022 }
1023
1024 pub async fn append_finalized_prepared_frame_bytes_tracked(
1029 &mut self,
1030 cx: &Cx,
1031 prepared_frame_bytes: &[u8],
1032 frame_count: usize,
1033 final_running_checksum: SqliteWalChecksum,
1034 last_commit_offset: Option<usize>,
1035 completion: VfsWriteCompletion,
1036 ) -> Result<()> {
1037 self.append_finalized_prepared_frame_bytes_with_completion(
1038 cx,
1039 prepared_frame_bytes,
1040 frame_count,
1041 final_running_checksum,
1042 last_commit_offset,
1043 Some(&completion),
1044 )
1045 .await
1046 }
1047
1048 async fn append_finalized_prepared_frame_bytes_with_completion(
1049 &mut self,
1050 cx: &Cx,
1051 prepared_frame_bytes: &[u8],
1052 frame_count: usize,
1053 final_running_checksum: SqliteWalChecksum,
1054 last_commit_offset: Option<usize>,
1055 completion: Option<&VfsWriteCompletion>,
1056 ) -> Result<()> {
1057 let mut preflight = VfsWritePreflight::new(completion);
1058 if frame_count == 0 {
1059 if let Some(completion) = completion {
1060 completion.complete_success();
1061 }
1062 preflight.hand_off();
1063 return Ok(());
1064 }
1065
1066 let new_count = self
1067 .frame_count
1068 .checked_add(frame_count)
1069 .ok_or(FrankenError::DatabaseFull)?;
1070 if new_count > usize::try_from(u32::MAX).unwrap_or(usize::MAX) {
1071 return Err(FrankenError::DatabaseFull);
1072 }
1073
1074 let frame_size = self.frame_size();
1075 let expected_bytes = frame_count
1076 .checked_mul(frame_size)
1077 .ok_or(FrankenError::DatabaseFull)?;
1078 if prepared_frame_bytes.len() != expected_bytes {
1079 return Err(FrankenError::WalCorrupt {
1080 detail: format!(
1081 "prepared batch byte length mismatch: expected {expected_bytes}, got {}",
1082 prepared_frame_bytes.len()
1083 ),
1084 });
1085 }
1086
1087 let start_frame_index = self.frame_count;
1088 let offset = self.frame_offset(start_frame_index);
1089
1090 #[cfg(any(test, feature = "fault-injection"))]
1091 crate::fault_hooks::maybe_inject_crash_at(
1092 crate::fault_hooks::CrashBoundary::BeforeWalFrameAppend,
1093 &format!("start_frame={start_frame_index} frame_count={frame_count}"),
1094 )?;
1095
1096 preflight.hand_off();
1097 if let Some(completion) = completion {
1098 self.file
1099 .write_tracked(cx, prepared_frame_bytes, offset, completion.clone())
1100 .await?;
1101 } else {
1102 self.file.write(cx, prepared_frame_bytes, offset).await?;
1103 }
1104 self.advance_state_after_write(frame_count, final_running_checksum)?;
1105 if let Some(last_commit_offset) = last_commit_offset {
1106 self.last_commit_frame = Some(start_frame_index + last_commit_offset);
1107 }
1108
1109 #[cfg(any(test, feature = "fault-injection"))]
1110 crate::fault_hooks::maybe_inject_crash_at(
1111 crate::fault_hooks::CrashBoundary::AfterWalFrameAppendBeforeFsync,
1112 &format!(
1113 "end_frame={} frames_written={frame_count}",
1114 self.frame_count
1115 ),
1116 )?;
1117
1118 let bytes_per_frame = u64::try_from(frame_size).unwrap_or(u64::MAX);
1119 let bytes_written = u64::try_from(expected_bytes).unwrap_or(u64::MAX);
1120 let span = tracing::span!(
1121 tracing::Level::DEBUG,
1122 "wal_batch_write",
1123 start_frame_index = start_frame_index,
1124 frames_written = frame_count,
1125 bytes_written = bytes_written,
1126 );
1127 let _guard = span.enter();
1128
1129 debug!(
1130 end_frame_count = self.frame_count,
1131 frames_written = frame_count,
1132 "WAL frames appended in batch"
1133 );
1134
1135 for _ in 0..frame_count {
1136 crate::metrics::GLOBAL_WAL_METRICS.record_frame_write(bytes_per_frame);
1137 }
1138
1139 Ok(())
1140 }
1141
1142 pub async fn append_prepared_frame_bytes(
1149 &mut self,
1150 cx: &Cx,
1151 prepared_frame_bytes: &mut [u8],
1152 frame_transforms: &[WalChecksumTransform],
1153 ) -> Result<()> {
1154 self.append_prepared_frame_bytes_with_completion(
1155 cx,
1156 prepared_frame_bytes,
1157 frame_transforms,
1158 None,
1159 )
1160 .await
1161 }
1162
1163 pub async fn append_prepared_frame_bytes_tracked(
1165 &mut self,
1166 cx: &Cx,
1167 prepared_frame_bytes: &mut [u8],
1168 frame_transforms: &[WalChecksumTransform],
1169 completion: VfsWriteCompletion,
1170 ) -> Result<()> {
1171 self.append_prepared_frame_bytes_with_completion(
1172 cx,
1173 prepared_frame_bytes,
1174 frame_transforms,
1175 Some(&completion),
1176 )
1177 .await
1178 }
1179
1180 async fn append_prepared_frame_bytes_with_completion(
1181 &mut self,
1182 cx: &Cx,
1183 prepared_frame_bytes: &mut [u8],
1184 frame_transforms: &[WalChecksumTransform],
1185 completion: Option<&VfsWriteCompletion>,
1186 ) -> Result<()> {
1187 let mut preflight = VfsWritePreflight::new(completion);
1188 let frame_count = frame_transforms.len();
1189 if frame_count == 0 {
1190 if let Some(completion) = completion {
1191 completion.complete_success();
1192 }
1193 preflight.hand_off();
1194 return Ok(());
1195 }
1196
1197 let new_count = self
1198 .frame_count
1199 .checked_add(frame_count)
1200 .ok_or(FrankenError::DatabaseFull)?;
1201 if new_count > usize::try_from(u32::MAX).unwrap_or(usize::MAX) {
1202 return Err(FrankenError::DatabaseFull);
1203 }
1204
1205 let frame_size = self.frame_size();
1206 let running_checksum =
1207 self.finalize_prepared_frame_bytes(prepared_frame_bytes, frame_transforms)?;
1208 let last_commit_offset = prepared_frame_bytes
1209 .chunks_exact(frame_size)
1210 .enumerate()
1211 .rev()
1212 .find_map(|(offset, frame_slice)| {
1213 let db_size_if_commit = u32::from_be_bytes([
1214 frame_slice[4],
1215 frame_slice[5],
1216 frame_slice[6],
1217 frame_slice[7],
1218 ]);
1219 (db_size_if_commit != 0).then_some(offset)
1220 });
1221 preflight.hand_off();
1222 self.append_finalized_prepared_frame_bytes_with_completion(
1223 cx,
1224 prepared_frame_bytes,
1225 frame_count,
1226 running_checksum,
1227 last_commit_offset,
1228 completion,
1229 )
1230 .await
1231 }
1232
1233 pub async fn append_frames(&mut self, cx: &Cx, frames: &[WalAppendFrameRef<'_>]) -> Result<()> {
1239 self.append_frame_iter(cx, frames.len(), frames.iter().copied())
1240 .await
1241 }
1242
1243 pub async fn append_frames_tracked(
1245 &mut self,
1246 cx: &Cx,
1247 frames: &[WalAppendFrameRef<'_>],
1248 completion: VfsWriteCompletion,
1249 ) -> Result<()> {
1250 self.append_frame_iter_tracked(cx, frames.len(), frames.iter().copied(), completion)
1251 .await
1252 }
1253
1254 pub(crate) async fn append_frame_iter<'a, I>(
1257 &mut self,
1258 cx: &Cx,
1259 frame_count: usize,
1260 frames: I,
1261 ) -> Result<()>
1262 where
1263 I: IntoIterator<Item = WalAppendFrameRef<'a>>,
1264 {
1265 self.append_frame_iter_with_completion(cx, frame_count, frames, None)
1266 .await
1267 }
1268
1269 pub(crate) async fn append_frame_iter_tracked<'a, I>(
1270 &mut self,
1271 cx: &Cx,
1272 frame_count: usize,
1273 frames: I,
1274 completion: VfsWriteCompletion,
1275 ) -> Result<()>
1276 where
1277 I: IntoIterator<Item = WalAppendFrameRef<'a>>,
1278 {
1279 self.append_frame_iter_with_completion(cx, frame_count, frames, Some(&completion))
1280 .await
1281 }
1282
1283 async fn append_frame_iter_with_completion<'a, I>(
1284 &mut self,
1285 cx: &Cx,
1286 frame_count: usize,
1287 frames: I,
1288 completion: Option<&VfsWriteCompletion>,
1289 ) -> Result<()>
1290 where
1291 I: IntoIterator<Item = WalAppendFrameRef<'a>>,
1292 {
1293 let mut preflight = VfsWritePreflight::new(completion);
1294 if frame_count == 0 {
1295 if let Some(completion) = completion {
1296 completion.complete_success();
1297 }
1298 preflight.hand_off();
1299 return Ok(());
1300 }
1301
1302 #[cfg(any(test, feature = "fault-injection"))]
1303 crate::fault_hooks::maybe_inject_append_busy(self.frame_count, frame_count)?;
1304
1305 let frame_size = self.frame_size();
1306 let total_bytes = frame_count
1307 .checked_mul(frame_size)
1308 .ok_or(FrankenError::DatabaseFull)?;
1309 let page_size = self.page_size;
1310 let salts = self.header.salts;
1311 let big_endian_checksum = self.big_endian_checksum;
1312 #[cfg(any(test, feature = "fault-injection"))]
1313 let frame_count_before = self.frame_count;
1314
1315 let mut frame_scratch = std::mem::take(&mut self.frame_scratch);
1316 frame_scratch.clear();
1317 if frame_scratch.capacity() < total_bytes {
1318 frame_scratch.reserve(total_bytes - frame_scratch.capacity());
1319 }
1320 let append_result = async {
1325 let mut running_checksum = self.running_checksum;
1326 let mut last_commit_offset: Option<usize> = None;
1327 let mut observed_frame_count = 0usize;
1328
1329 for (idx, frame) in frames.into_iter().enumerate() {
1330 if idx >= frame_count {
1331 return Err(FrankenError::WalCorrupt {
1332 detail: format!(
1333 "append batch frame count mismatch: expected {frame_count}, got more than declared"
1334 ),
1335 });
1336 }
1337 if frame.page_data.len() != page_size {
1338 return Err(FrankenError::WalCorrupt {
1339 detail: format!(
1340 "page data size mismatch in batch frame {idx}: expected {page_size}, got {}",
1341 frame.page_data.len()
1342 ),
1343 });
1344 }
1345
1346 let buf_offset = idx
1347 .checked_mul(frame_size)
1348 .ok_or(FrankenError::DatabaseFull)?;
1349
1350 push_wal_frame_bytes(
1352 &mut frame_scratch,
1353 frame.page_number,
1354 frame.db_size_if_commit,
1355 salts,
1356 frame.page_data,
1357 );
1358 let frame_slice = &mut frame_scratch[buf_offset..buf_offset + frame_size];
1359
1360 running_checksum = write_wal_frame_checksum(
1362 frame_slice,
1363 page_size,
1364 running_checksum,
1365 big_endian_checksum,
1366 )?;
1367
1368 if frame.db_size_if_commit != 0 {
1369 last_commit_offset = Some(idx);
1370 }
1371 observed_frame_count = idx + 1;
1372 }
1373
1374 if observed_frame_count != frame_count {
1375 return Err(FrankenError::WalCorrupt {
1376 detail: format!(
1377 "append batch frame count mismatch: expected {frame_count}, got {observed_frame_count}"
1378 ),
1379 });
1380 }
1381
1382 preflight.hand_off();
1383 self.append_finalized_prepared_frame_bytes_with_completion(
1384 cx,
1385 &frame_scratch,
1386 frame_count,
1387 running_checksum,
1388 last_commit_offset,
1389 completion,
1390 )
1391 .await
1392 }
1393 .await;
1394 self.frame_scratch = frame_scratch;
1395
1396 #[cfg(any(test, feature = "fault-injection"))]
1397 if append_result.is_ok() {
1398 crate::fault_hooks::maybe_inject_after_append(frame_count_before, frame_count)?;
1399 }
1400
1401 append_result
1402 }
1403
1404 pub async fn read_frame(
1406 &self,
1407 cx: &Cx,
1408 frame_index: usize,
1409 ) -> Result<(WalFrameHeader, Vec<u8>)> {
1410 let frame_size = self.frame_size();
1411 let mut buf = vec![0u8; frame_size];
1412 let header = self.read_frame_into(cx, frame_index, &mut buf).await?;
1413 let page_data = buf[WAL_FRAME_HEADER_SIZE..].to_vec();
1414 Ok((header, page_data))
1415 }
1416
1417 pub async fn read_frame_into(
1423 &self,
1424 cx: &Cx,
1425 frame_index: usize,
1426 buf: &mut [u8],
1427 ) -> Result<WalFrameHeader> {
1428 if frame_index >= self.frame_count {
1429 return Err(FrankenError::WalCorrupt {
1430 detail: format!(
1431 "frame index {frame_index} out of range (count: {})",
1432 self.frame_count
1433 ),
1434 });
1435 }
1436
1437 let frame_size = self.frame_size();
1438 if buf.len() < frame_size {
1439 return Err(FrankenError::Internal(format!(
1440 "read_frame_into buffer too small: got {}, need {}",
1441 buf.len(),
1442 frame_size
1443 )));
1444 }
1445
1446 let offset = self.frame_offset(frame_index);
1447 let bytes_read = self.file.read(cx, &mut buf[..frame_size], offset).await?;
1448 if bytes_read < frame_size {
1449 return Err(FrankenError::WalCorrupt {
1450 detail: format!(
1451 "short read at frame {frame_index}: got {bytes_read}, need {frame_size}"
1452 ),
1453 });
1454 }
1455
1456 WalFrameHeader::from_bytes(&buf[..WAL_FRAME_HEADER_SIZE])
1457 }
1458
1459 pub async fn read_frame_header(&self, cx: &Cx, frame_index: usize) -> Result<WalFrameHeader> {
1461 if frame_index >= self.frame_count {
1462 return Err(FrankenError::WalCorrupt {
1463 detail: format!(
1464 "frame index {frame_index} out of range (count: {})",
1465 self.frame_count
1466 ),
1467 });
1468 }
1469
1470 let mut header_buf = [0u8; WAL_FRAME_HEADER_SIZE];
1471 let offset = self.frame_offset(frame_index);
1472 let bytes_read = self.file.read(cx, &mut header_buf, offset).await?;
1473 if bytes_read < WAL_FRAME_HEADER_SIZE {
1474 return Err(FrankenError::WalCorrupt {
1475 detail: format!("short header read at frame {frame_index}: got {bytes_read}"),
1476 });
1477 }
1478
1479 WalFrameHeader::from_bytes(&header_buf)
1480 }
1481
1482 pub fn last_commit_frame(&mut self, cx: &Cx) -> Result<Option<usize>> {
1484 let _ = cx;
1485 Ok(self.last_commit_frame)
1486 }
1487
1488 pub fn sync(&mut self, cx: &Cx, flags: SyncFlags) -> Result<()> {
1491 #[cfg(any(test, feature = "fault-injection"))]
1492 crate::fault_hooks::maybe_inject_sync_failure(self.frame_count, flags)?;
1493
1494 self.file.sync(cx, flags)?;
1495 self.last_fsynced_frame_count = self.frame_count;
1496 Ok(())
1497 }
1498
1499 pub fn repair_uncommitted_tail(&mut self, cx: &Cx) -> Result<()> {
1508 let committed_size = u64::try_from(WAL_HEADER_SIZE)
1509 .unwrap_or(u64::MAX)
1510 .checked_add(
1511 u64::try_from(self.frame_count)
1512 .unwrap_or(u64::MAX)
1513 .checked_mul(u64::try_from(self.frame_size()).unwrap_or(u64::MAX))
1514 .ok_or(FrankenError::DatabaseFull)?,
1515 )
1516 .ok_or(FrankenError::DatabaseFull)?;
1517 let file_size = self.file.file_size(cx)?;
1518 if file_size < committed_size {
1519 return Err(FrankenError::WalCorrupt {
1520 detail: format!(
1521 "WAL file shrank below its committed prefix: file_size={file_size}, committed_size={committed_size}"
1522 ),
1523 });
1524 }
1525 if file_size > committed_size {
1526 self.file.truncate(cx, committed_size)?;
1527 }
1528 Ok(())
1529 }
1530
1531 pub fn durable_sync(&mut self, cx: &Cx, kind: SyncKind) -> Result<()> {
1537 #[cfg(any(test, feature = "fault-injection"))]
1538 {
1539 let flags = match kind {
1540 SyncKind::DataOnly => SyncFlags::DATAONLY,
1541 SyncKind::DataAndMetadata | SyncKind::FullDurable => SyncFlags::FULL,
1542 };
1543 crate::fault_hooks::maybe_inject_sync_failure(self.frame_count, flags)?;
1544 }
1545
1546 self.file.durable_sync(cx, kind)?;
1547 self.last_fsynced_frame_count = self.frame_count;
1548
1549 debug!(
1550 target: "fsqlite_wal::durability",
1551 fsynced_up_to = self.frame_count,
1552 kind = ?kind,
1553 "WAL durable sync complete"
1554 );
1555
1556 #[cfg(any(test, feature = "fault-injection"))]
1557 crate::fault_hooks::maybe_inject_crash_at(
1558 crate::fault_hooks::CrashBoundary::AfterFsyncBeforePublish,
1559 &format!("fsynced_up_to={}", self.frame_count),
1560 )?;
1561
1562 Ok(())
1563 }
1564
1565 pub fn assert_publish_safe(&self, publish_frame_count: usize) -> Result<()> {
1571 if self.last_fsynced_frame_count >= publish_frame_count {
1572 return Ok(());
1573 }
1574
1575 let msg = format!(
1576 "publish-before-fsync: attempting to publish frame_count={publish_frame_count} \
1577 but last fsynced only up to {fsynced}",
1578 fsynced = self.last_fsynced_frame_count,
1579 );
1580
1581 debug_assert!(false, "WAL durability invariant violated: {msg}");
1582
1583 if *PARANOID_DURABILITY {
1584 error!(
1585 target: "fsqlite_wal::durability",
1586 publish_frame_count,
1587 last_fsynced = self.last_fsynced_frame_count,
1588 "PARANOID_DURABILITY: publish-before-fsync detected"
1589 );
1590 return Err(FrankenError::Internal(msg));
1591 }
1592
1593 Ok(())
1594 }
1595
1596 #[must_use]
1598 pub fn last_fsynced_frame_count(&self) -> usize {
1599 self.last_fsynced_frame_count
1600 }
1601
1602 pub async fn reset(
1608 &mut self,
1609 cx: &Cx,
1610 new_checkpoint_seq: u32,
1611 new_salts: WalSalts,
1612 truncate_file: bool,
1613 ) -> Result<()> {
1614 let new_header = WalHeader {
1615 magic: self.header.magic,
1616 format_version: WAL_FORMAT_VERSION,
1617 page_size: self.header.page_size,
1618 checkpoint_seq: new_checkpoint_seq,
1619 salts: new_salts,
1620 checksum: SqliteWalChecksum::default(),
1621 };
1622 let header_bytes = new_header.to_bytes()?;
1623
1624 #[cfg(any(test, feature = "fault-injection"))]
1625 crate::fault_hooks::maybe_inject_crash_at(
1626 crate::fault_hooks::CrashBoundary::BeforeWalHeaderWrite,
1627 &format!("checkpoint_seq={new_checkpoint_seq}"),
1628 )?;
1629
1630 self.file.write(cx, &header_bytes, 0).await?;
1631
1632 #[cfg(any(test, feature = "fault-injection"))]
1637 {
1638 let old_fc = self.frame_count;
1639 crate::fault_hooks::maybe_inject_crash_header_truncate(old_fc, new_checkpoint_seq)?;
1640 }
1641
1642 if truncate_file {
1643 self.file.truncate(
1644 cx,
1645 u64::try_from(WAL_HEADER_SIZE).expect("header size fits u64"),
1646 )?;
1647 }
1648
1649 self.file.sync(cx, SyncFlags::NORMAL)?;
1652
1653 self.running_checksum = read_wal_header_checksum(&header_bytes)?;
1654 self.header = WalHeader::from_bytes(&header_bytes)?;
1655 self.frame_count = 0;
1656 self.last_commit_frame = None;
1657 self.last_fsynced_frame_count = 0;
1658 self.frame_scratch.clear();
1659 crate::metrics::GLOBAL_WAL_METRICS.set_wal_frames_current(0);
1660
1661 debug!(
1662 checkpoint_seq = new_checkpoint_seq,
1663 salt1 = new_salts.salt1,
1664 salt2 = new_salts.salt2,
1665 "WAL reset"
1666 );
1667
1668 crate::metrics::GLOBAL_WAL_METRICS.record_wal_reset();
1669
1670 Ok(())
1671 }
1672
1673 pub fn close(mut self, cx: &Cx) -> Result<()> {
1675 self.file.close(cx)
1676 }
1677
1678 #[must_use]
1680 pub fn file(&self) -> &F {
1681 &self.file
1682 }
1683
1684 pub fn file_mut(&mut self) -> &mut F {
1686 &mut self.file
1687 }
1688}
1689
1690#[cfg(test)]
1691mod tests {
1692 use std::time::Instant;
1693
1694 use fsqlite_types::flags::VfsOpenFlags;
1695 use fsqlite_vfs::MemoryVfs;
1696 use fsqlite_vfs::traits::Vfs;
1697 use serde_json::{Value, json};
1698
1699 use super::*;
1700 use crate::test_support::FutureResultTestExt as _;
1701
1702 static FAULT_TEST_LOCK: crate::fault_hooks::FaultInjectionSessionLock =
1704 crate::fault_hooks::FaultInjectionSessionLock::new();
1705
1706 const PAGE_SIZE: u32 = 4096;
1707 const TRACK_C_SCRATCH_BENCH_BEAD_ID: &str = "bd-db300.3.4.3";
1708 const TRACK_C_SCRATCH_BENCH_WARMUP_ITERS: usize = 4;
1709 const TRACK_C_SCRATCH_BENCH_MEASURE_ITERS: usize = 12;
1710
1711 #[derive(Clone, Copy)]
1712 enum TrackCScratchBenchMode {
1713 FreshAllocBaseline,
1714 ScratchReuseCandidate,
1715 }
1716
1717 #[derive(Clone, Copy)]
1718 struct TrackCScratchBenchRun {
1719 elapsed_ns: u64,
1720 explicit_fresh_buffer_allocations: usize,
1721 scratch_capacity_growth_events: usize,
1722 peak_scratch_capacity_bytes: usize,
1723 frame_buffer_bytes_per_operation: usize,
1724 operations_per_sample: usize,
1725 }
1726
1727 fn test_cx() -> Cx {
1728 Cx::default()
1729 }
1730
1731 fn test_salts() -> WalSalts {
1732 WalSalts {
1733 salt1: 0xDEAD_BEEF,
1734 salt2: 0xCAFE_BABE,
1735 }
1736 }
1737
1738 fn sample_page(seed: u8) -> Vec<u8> {
1739 let page_size = usize::try_from(PAGE_SIZE).expect("page size fits usize");
1740 let mut page = vec![0u8; page_size];
1741 for (i, byte) in page.iter_mut().enumerate() {
1742 let reduced = u8::try_from(i % 251).expect("modulo fits u8");
1743 *byte = reduced ^ seed;
1744 }
1745 page
1746 }
1747
1748 fn frame_ref(
1749 page_number: u32,
1750 page_data: &[u8],
1751 db_size_if_commit: u32,
1752 ) -> WalAppendFrameRef<'_> {
1753 WalAppendFrameRef {
1754 page_number,
1755 page_data,
1756 db_size_if_commit,
1757 }
1758 }
1759
1760 fn open_wal_file(vfs: &MemoryVfs, cx: &Cx) -> <MemoryVfs as Vfs>::File {
1761 let flags = VfsOpenFlags::READWRITE | VfsOpenFlags::CREATE | VfsOpenFlags::WAL;
1762 let (file, _) = vfs
1763 .open(cx, Some(std::path::Path::new("test.db-wal")), flags)
1764 .expect("open WAL file");
1765 file
1766 }
1767
1768 fn track_c_scratch_run_summary(runs: &[TrackCScratchBenchRun]) -> Value {
1769 let mut elapsed_samples: Vec<_> = runs.iter().map(|run| run.elapsed_ns).collect();
1770 elapsed_samples.sort_unstable();
1771 let sample_count = elapsed_samples.len();
1772 let min_ns = elapsed_samples.first().copied().unwrap_or(0);
1773 let median_ns = if sample_count == 0 {
1774 0
1775 } else {
1776 elapsed_samples[sample_count / 2]
1777 };
1778 let max_ns = elapsed_samples.last().copied().unwrap_or(0);
1779 let mean_ns = if sample_count == 0 {
1780 0.0
1781 } else {
1782 let total_ns: u128 = elapsed_samples.iter().map(|ns| u128::from(*ns)).sum();
1783 (total_ns as f64) / (sample_count as f64)
1784 };
1785 let explicit_fresh_buffer_allocations = runs
1786 .iter()
1787 .map(|run| run.explicit_fresh_buffer_allocations)
1788 .max()
1789 .unwrap_or(0);
1790 let scratch_capacity_growth_events = runs
1791 .iter()
1792 .map(|run| run.scratch_capacity_growth_events)
1793 .max()
1794 .unwrap_or(0);
1795 let peak_scratch_capacity_bytes = runs
1796 .iter()
1797 .map(|run| run.peak_scratch_capacity_bytes)
1798 .max()
1799 .unwrap_or(0);
1800 let frame_buffer_bytes_per_operation = runs
1801 .first()
1802 .map(|run| run.frame_buffer_bytes_per_operation)
1803 .unwrap_or(0);
1804 let operations_per_sample = runs
1805 .first()
1806 .map(|run| run.operations_per_sample)
1807 .unwrap_or(0);
1808
1809 json!({
1810 "samples_ns": elapsed_samples,
1811 "min_ns": min_ns,
1812 "median_ns": median_ns,
1813 "max_ns": max_ns,
1814 "mean_ns": mean_ns,
1815 "explicit_fresh_buffer_allocations_per_sample": explicit_fresh_buffer_allocations,
1816 "scratch_capacity_growth_events_per_sample": scratch_capacity_growth_events,
1817 "peak_scratch_capacity_bytes": peak_scratch_capacity_bytes,
1818 "frame_buffer_bytes_per_operation": frame_buffer_bytes_per_operation,
1819 "operations_per_sample": operations_per_sample,
1820 "frame_buffer_bytes_requested_per_sample": explicit_fresh_buffer_allocations
1821 .saturating_mul(frame_buffer_bytes_per_operation),
1822 })
1823 }
1824
1825 fn append_frames_fresh_alloc<F: VfsFile>(
1826 wal: &mut WalFile<F>,
1827 cx: &Cx,
1828 frames: &[WalAppendFrameRef<'_>],
1829 ) -> Result<()> {
1830 let frame_size = wal.frame_size();
1831 let mut frame_buf = wal.prepare_frame_bytes(frames)?;
1832 let frame_transforms = frame_buf
1833 .chunks_exact(frame_size)
1834 .map(|frame| {
1835 WalChecksumTransform::for_wal_frame(
1836 frame,
1837 wal.page_size(),
1838 wal.big_endian_checksum(),
1839 )
1840 })
1841 .collect::<Result<Vec<_>>>()?;
1842 wal.append_prepared_frame_bytes(cx, &mut frame_buf, &frame_transforms)
1843 .wait()
1844 }
1845
1846 fn track_c_measure_single_frame_case(
1847 mode: TrackCScratchBenchMode,
1848 operations: usize,
1849 ) -> TrackCScratchBenchRun {
1850 let cx = test_cx();
1851 let vfs = MemoryVfs::new();
1852 let file = open_wal_file(&vfs, &cx);
1853 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
1854 let pages: Vec<Vec<u8>> = (0..operations)
1855 .map(|i| sample_page(u8::try_from(i % 251).expect("modulo fits u8")))
1856 .collect();
1857 let frame_buffer_bytes_per_operation = wal.frame_size();
1858 let mut explicit_fresh_buffer_allocations = 0usize;
1859 let mut scratch_capacity_growth_events = 0usize;
1860 let mut previous_scratch_capacity = 0usize;
1861
1862 let start = Instant::now();
1863 for (idx, page) in pages.iter().enumerate() {
1864 let page_number = u32::try_from(idx).expect("index fits u32") + 1;
1865 match mode {
1866 TrackCScratchBenchMode::FreshAllocBaseline => {
1867 let frames = [WalAppendFrameRef {
1868 page_number,
1869 page_data: page,
1870 db_size_if_commit: page_number,
1871 }];
1872 append_frames_fresh_alloc(&mut wal, &cx, &frames).expect("append baseline");
1873 explicit_fresh_buffer_allocations += 1;
1874 }
1875 TrackCScratchBenchMode::ScratchReuseCandidate => {
1876 wal.append_frame(&cx, page_number, page, page_number)
1877 .expect("append candidate");
1878 let scratch_capacity = wal.frame_scratch_capacity();
1879 if scratch_capacity > previous_scratch_capacity {
1880 scratch_capacity_growth_events += 1;
1881 previous_scratch_capacity = scratch_capacity;
1882 }
1883 }
1884 }
1885 }
1886
1887 TrackCScratchBenchRun {
1888 elapsed_ns: u64::try_from(start.elapsed().as_nanos()).unwrap_or(u64::MAX),
1889 explicit_fresh_buffer_allocations,
1890 scratch_capacity_growth_events,
1891 peak_scratch_capacity_bytes: wal.frame_scratch_capacity(),
1892 frame_buffer_bytes_per_operation,
1893 operations_per_sample: operations,
1894 }
1895 }
1896
1897 fn track_c_measure_batch_case<const N: usize>(
1898 mode: TrackCScratchBenchMode,
1899 operations: usize,
1900 ) -> TrackCScratchBenchRun {
1901 let cx = test_cx();
1902 let vfs = MemoryVfs::new();
1903 let file = open_wal_file(&vfs, &cx);
1904 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
1905 let pages_per_operation: Vec<[Vec<u8>; N]> = (0..operations)
1906 .map(|operation_idx| {
1907 std::array::from_fn(|frame_idx| {
1908 sample_page(
1909 u8::try_from((operation_idx * N + frame_idx) % 251)
1910 .expect("modulo fits u8"),
1911 )
1912 })
1913 })
1914 .collect();
1915 let frame_buffer_bytes_per_operation = wal
1916 .frame_size()
1917 .checked_mul(N)
1918 .expect("frame bytes per operation fit usize");
1919 let mut explicit_fresh_buffer_allocations = 0usize;
1920 let mut scratch_capacity_growth_events = 0usize;
1921 let mut previous_scratch_capacity = 0usize;
1922
1923 let start = Instant::now();
1924 for (operation_idx, pages) in pages_per_operation.iter().enumerate() {
1925 let page_base = u32::try_from(
1926 operation_idx
1927 .checked_mul(N)
1928 .expect("operation frame base fits usize"),
1929 )
1930 .expect("frame base fits u32")
1931 + 1;
1932 let commit_db_size = page_base + u32::try_from(N).expect("N fits u32") - 1;
1933 let frames: [WalAppendFrameRef<'_>; N] =
1934 std::array::from_fn(|frame_idx| WalAppendFrameRef {
1935 page_number: page_base
1936 + u32::try_from(frame_idx).expect("frame index fits u32"),
1937 page_data: &pages[frame_idx],
1938 db_size_if_commit: if frame_idx + 1 == N {
1939 commit_db_size
1940 } else {
1941 0
1942 },
1943 });
1944
1945 match mode {
1946 TrackCScratchBenchMode::FreshAllocBaseline => {
1947 append_frames_fresh_alloc(&mut wal, &cx, &frames).expect("append baseline");
1948 explicit_fresh_buffer_allocations += 1;
1949 }
1950 TrackCScratchBenchMode::ScratchReuseCandidate => {
1951 wal.append_frames(&cx, &frames).expect("append candidate");
1952 let scratch_capacity = wal.frame_scratch_capacity();
1953 if scratch_capacity > previous_scratch_capacity {
1954 scratch_capacity_growth_events += 1;
1955 previous_scratch_capacity = scratch_capacity;
1956 }
1957 }
1958 }
1959 }
1960
1961 TrackCScratchBenchRun {
1962 elapsed_ns: u64::try_from(start.elapsed().as_nanos()).unwrap_or(u64::MAX),
1963 explicit_fresh_buffer_allocations,
1964 scratch_capacity_growth_events,
1965 peak_scratch_capacity_bytes: wal.frame_scratch_capacity(),
1966 frame_buffer_bytes_per_operation,
1967 operations_per_sample: operations,
1968 }
1969 }
1970
1971 fn track_c_scratch_case_report(
1972 scenario_id: &str,
1973 frames_per_operation: usize,
1974 operations_per_sample: usize,
1975 baseline_measure: impl Fn() -> TrackCScratchBenchRun,
1976 candidate_measure: impl Fn() -> TrackCScratchBenchRun,
1977 ) -> Value {
1978 for _ in 0..TRACK_C_SCRATCH_BENCH_WARMUP_ITERS {
1979 let _ = baseline_measure();
1980 let _ = candidate_measure();
1981 }
1982
1983 let baseline_runs: Vec<_> = (0..TRACK_C_SCRATCH_BENCH_MEASURE_ITERS)
1984 .map(|_| baseline_measure())
1985 .collect();
1986 let candidate_runs: Vec<_> = (0..TRACK_C_SCRATCH_BENCH_MEASURE_ITERS)
1987 .map(|_| candidate_measure())
1988 .collect();
1989 let baseline_summary = track_c_scratch_run_summary(&baseline_runs);
1990 let candidate_summary = track_c_scratch_run_summary(&candidate_runs);
1991 let baseline_median = baseline_summary["median_ns"].as_u64().unwrap_or(0);
1992 let candidate_median = candidate_summary["median_ns"].as_u64().unwrap_or(0);
1993 let baseline_allocations = baseline_summary["explicit_fresh_buffer_allocations_per_sample"]
1994 .as_u64()
1995 .unwrap_or(0);
1996 let candidate_growths = candidate_summary["scratch_capacity_growth_events_per_sample"]
1997 .as_u64()
1998 .unwrap_or(0);
1999 let baseline_requested_bytes = baseline_summary["frame_buffer_bytes_requested_per_sample"]
2000 .as_u64()
2001 .unwrap_or(0);
2002 let candidate_peak_scratch_bytes = candidate_summary["peak_scratch_capacity_bytes"]
2003 .as_u64()
2004 .unwrap_or(0);
2005
2006 json!({
2007 "scenario_id": scenario_id,
2008 "frames_per_operation": frames_per_operation,
2009 "operations_per_sample": operations_per_sample,
2010 "fresh_alloc_baseline": baseline_summary,
2011 "scratch_reuse_candidate": candidate_summary,
2012 "fresh_buffer_allocations_avoided_per_sample": baseline_allocations.saturating_sub(candidate_growths),
2013 "buffer_bytes_saved_vs_fresh_requested_per_sample": baseline_requested_bytes.saturating_sub(candidate_peak_scratch_bytes),
2014 "speedup_vs_baseline_median": if candidate_median == 0 {
2015 0.0
2016 } else {
2017 (baseline_median as f64) / (candidate_median as f64)
2018 },
2019 "faster_variant_by_median": if candidate_median <= baseline_median {
2020 "scratch_reuse_candidate"
2021 } else {
2022 "fresh_alloc_baseline"
2023 },
2024 })
2025 }
2026
2027 #[test]
2028 fn test_create_and_open_empty_wal() {
2029 let cx = test_cx();
2030 let vfs = MemoryVfs::new();
2031 let file = open_wal_file(&vfs, &cx);
2032
2033 let wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2034 assert_eq!(wal.frame_count(), 0);
2035 assert_eq!(wal.page_size(), usize::try_from(PAGE_SIZE).unwrap());
2036 assert!(!wal.big_endian_checksum());
2037 assert_eq!(wal.header().checkpoint_seq, 0);
2038 assert_eq!(wal.header().salts, test_salts());
2039
2040 wal.close(&cx).expect("close WAL");
2041
2042 let file2 = open_wal_file(&vfs, &cx);
2044 let wal2 = WalFile::open(&cx, file2).expect("open WAL");
2045 assert_eq!(wal2.frame_count(), 0);
2046 assert_eq!(wal2.header().salts, test_salts());
2047
2048 wal2.close(&cx).expect("close WAL");
2049 }
2050
2051 #[test]
2052 fn test_append_and_read_single_frame() {
2053 let cx = test_cx();
2054 let vfs = MemoryVfs::new();
2055 let file = open_wal_file(&vfs, &cx);
2056
2057 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 1, test_salts()).expect("create WAL");
2058
2059 let page = sample_page(0x42);
2060 wal.append_frame(&cx, 1, &page, 0).expect("append frame");
2061 assert_eq!(wal.frame_count(), 1);
2062
2063 let (header, data) = wal.read_frame(&cx, 0).expect("read frame");
2064 assert_eq!(header.page_number, 1);
2065 assert_eq!(header.db_size, 0);
2066 assert_eq!(header.salts, test_salts());
2067 assert_eq!(data, page);
2068
2069 wal.close(&cx).expect("close WAL");
2070 }
2071
2072 #[test]
2073 fn test_fault_hook_after_wal_append_returns_error_and_records_context() {
2074 let _guard = FAULT_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2075 crate::fault_hooks::clear();
2076
2077 let cx = test_cx();
2078 let vfs = MemoryVfs::new();
2079 let file = open_wal_file(&vfs, &cx);
2080 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 1, test_salts()).expect("create WAL");
2081 let page = sample_page(0x33);
2082 let frames = [WalAppendFrameRef {
2083 page_number: 1,
2084 page_data: &page,
2085 db_size_if_commit: 1,
2086 }];
2087
2088 crate::fault_hooks::arm_after_append(crate::fault_hooks::FaultHookArm::new(
2089 "bd-db300.7.2.2-after-append",
2090 "WAL-AFTER-APPEND",
2091 "wal_append_recovery",
2092 ));
2093
2094 let error = wal
2095 .append_frames(&cx, &frames)
2096 .expect_err("fault hook should force an error after append");
2097 assert!(
2098 error.to_string().contains("fault_inject:wal_after_append"),
2099 "fault error should identify the append hook: {error}"
2100 );
2101 assert_eq!(
2102 wal.frame_count(),
2103 1,
2104 "append should still have reached the WAL"
2105 );
2106
2107 wal.close(&cx).expect("close WAL");
2108 let reopened_file = open_wal_file(&vfs, &cx);
2109 let reopened = WalFile::open(&cx, reopened_file).expect("reopen WAL");
2110 assert_eq!(
2111 reopened.frame_count(),
2112 1,
2113 "reopened WAL should preserve the appended frame for later recovery checks"
2114 );
2115
2116 let records = crate::fault_hooks::take_records();
2117 assert_eq!(
2118 records.len(),
2119 1,
2120 "exactly one append fault should be recorded"
2121 );
2122 assert_eq!(records[0].point, "wal_after_append");
2123 assert_eq!(records[0].run_id, "bd-db300.7.2.2-after-append");
2124 assert_eq!(records[0].scenario_id, "WAL-AFTER-APPEND");
2125 assert_eq!(records[0].invariant_family, "wal_append_recovery");
2126 assert!(
2127 records[0].detail.contains("appended_frames=1"),
2128 "record should preserve append context: {}",
2129 records[0].detail
2130 );
2131
2132 crate::fault_hooks::clear();
2133 }
2134
2135 #[test]
2136 fn test_append_commit_frame() {
2137 let cx = test_cx();
2138 let vfs = MemoryVfs::new();
2139 let file = open_wal_file(&vfs, &cx);
2140
2141 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2142
2143 let page = sample_page(0x10);
2144 wal.append_frame(&cx, 5, &page, 10)
2145 .expect("append commit frame");
2146
2147 let header = wal.read_frame_header(&cx, 0).expect("read header");
2148 assert!(header.is_commit());
2149 assert_eq!(header.db_size, 10);
2150 assert_eq!(header.page_number, 5);
2151
2152 wal.close(&cx).expect("close WAL");
2153 }
2154
2155 #[test]
2156 fn test_multi_frame_checksum_chain() {
2157 let cx = test_cx();
2158 let vfs = MemoryVfs::new();
2159 let file = open_wal_file(&vfs, &cx);
2160
2161 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 3, test_salts()).expect("create WAL");
2162
2163 for i in 0..5u32 {
2165 let page = sample_page(u8::try_from(i).expect("fits"));
2166 let db_size = if i == 4 { 5 } else { 0 };
2167 wal.append_frame(&cx, i + 1, &page, db_size)
2168 .expect("append frame");
2169 }
2170 assert_eq!(wal.frame_count(), 5);
2171
2172 wal.close(&cx).expect("close WAL");
2173
2174 let file2 = open_wal_file(&vfs, &cx);
2176 let wal2 = WalFile::open(&cx, file2).expect("open WAL");
2177 assert_eq!(wal2.frame_count(), 5);
2178
2179 for i in 0..5u32 {
2181 let (header, data) = wal2
2182 .read_frame(&cx, usize::try_from(i).unwrap())
2183 .expect("read frame");
2184 assert_eq!(header.page_number, i + 1);
2185 let expected = sample_page(u8::try_from(i).expect("fits"));
2186 assert_eq!(data, expected);
2187 }
2188
2189 let last_header = wal2.read_frame_header(&cx, 4).expect("read header");
2191 assert!(last_header.is_commit());
2192 assert_eq!(last_header.db_size, 5);
2193
2194 wal2.close(&cx).expect("close WAL");
2195 }
2196
2197 #[test]
2198 fn test_last_commit_frame() {
2199 let cx = test_cx();
2200 let vfs = MemoryVfs::new();
2201 let file = open_wal_file(&vfs, &cx);
2202
2203 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2204
2205 assert_eq!(wal.last_commit_frame(&cx).expect("query"), None);
2207
2208 wal.append_frame(&cx, 1, &sample_page(1), 0)
2210 .expect("append");
2211 assert_eq!(wal.last_commit_frame(&cx).expect("query"), None);
2212
2213 wal.append_frame(&cx, 2, &sample_page(2), 3)
2215 .expect("append");
2216 assert_eq!(wal.last_commit_frame(&cx).expect("query"), Some(1));
2217
2218 wal.append_frame(&cx, 3, &sample_page(3), 0)
2220 .expect("append");
2221 wal.append_frame(&cx, 4, &sample_page(4), 5)
2222 .expect("append");
2223 assert_eq!(wal.last_commit_frame(&cx).expect("query"), Some(3));
2224
2225 wal.close(&cx).expect("close WAL");
2226 }
2227
2228 #[test]
2229 fn test_reset_clears_frames() {
2230 let cx = test_cx();
2231 let vfs = MemoryVfs::new();
2232 let file = open_wal_file(&vfs, &cx);
2233
2234 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2235
2236 for i in 0..3u8 {
2238 let db_size = if i == 2 { 3 } else { 0 };
2239 wal.append_frame(&cx, u32::from(i) + 1, &sample_page(i), db_size)
2240 .expect("append");
2241 }
2242 assert_eq!(wal.frame_count(), 3);
2243
2244 let new_salts = WalSalts {
2246 salt1: 0x1111_2222,
2247 salt2: 0x3333_4444,
2248 };
2249 wal.reset(&cx, 1, new_salts, true).expect("reset");
2250 assert_eq!(wal.frame_count(), 0);
2251 assert_eq!(wal.last_commit_frame(&cx).expect("query"), None);
2252 assert_eq!(wal.header().checkpoint_seq, 1);
2253 assert_eq!(wal.header().salts, new_salts);
2254
2255 wal.append_frame(&cx, 10, &sample_page(0xAA), 1)
2257 .expect("append after reset");
2258 assert_eq!(wal.frame_count(), 1);
2259 assert_eq!(wal.last_commit_frame(&cx).expect("query"), Some(0));
2260
2261 wal.close(&cx).expect("close WAL");
2262
2263 let file2 = open_wal_file(&vfs, &cx);
2265 let wal2 = WalFile::open(&cx, file2).expect("open WAL");
2266 assert_eq!(wal2.frame_count(), 1);
2267 assert_eq!(wal2.header().checkpoint_seq, 1);
2268 assert_eq!(wal2.header().salts, new_salts);
2269
2270 wal2.close(&cx).expect("close WAL");
2271 }
2272
2273 #[test]
2274 fn test_page_size_mismatch_rejected() {
2275 let cx = test_cx();
2276 let vfs = MemoryVfs::new();
2277 let file = open_wal_file(&vfs, &cx);
2278
2279 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2280
2281 let short_page = vec![0u8; 100];
2283 let result = wal.append_frame(&cx, 1, &short_page, 0);
2284 assert!(result.is_err());
2285
2286 let long_page = vec![0u8; 8192];
2287 let result = wal.append_frame(&cx, 1, &long_page, 0);
2288 assert!(result.is_err());
2289
2290 wal.close(&cx).expect("close WAL");
2291 }
2292
2293 #[test]
2294 fn test_frame_index_out_of_range() {
2295 let cx = test_cx();
2296 let vfs = MemoryVfs::new();
2297 let file = open_wal_file(&vfs, &cx);
2298
2299 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2300
2301 assert!(wal.read_frame(&cx, 0).is_err());
2303 assert!(wal.read_frame_header(&cx, 0).is_err());
2304
2305 wal.append_frame(&cx, 1, &sample_page(0), 0)
2307 .expect("append");
2308 assert!(wal.read_frame(&cx, 0).is_ok());
2309 assert!(wal.read_frame(&cx, 1).is_err());
2310
2311 wal.close(&cx).expect("close WAL");
2312 }
2313
2314 #[test]
2315 fn test_reopen_preserves_checksum_chain() {
2316 let cx = test_cx();
2317 let vfs = MemoryVfs::new();
2318 let file = open_wal_file(&vfs, &cx);
2319
2320 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2321
2322 for i in 0..3u8 {
2324 let db_size = if i == 2 { 3 } else { 0 };
2325 wal.append_frame(&cx, u32::from(i) + 1, &sample_page(i), db_size)
2326 .expect("append");
2327 }
2328 let checksum_after_3 = wal.running_checksum();
2329 wal.close(&cx).expect("close WAL");
2330
2331 let file2 = open_wal_file(&vfs, &cx);
2333 let mut wal2 = WalFile::open(&cx, file2).expect("open WAL");
2334 assert_eq!(wal2.frame_count(), 3);
2335 assert_eq!(wal2.running_checksum(), checksum_after_3);
2336
2337 wal2.append_frame(&cx, 4, &sample_page(3), 0)
2338 .expect("append");
2339 wal2.append_frame(&cx, 5, &sample_page(4), 5)
2340 .expect("append commit");
2341 assert_eq!(wal2.frame_count(), 5);
2342 wal2.close(&cx).expect("close WAL");
2343
2344 let file3 = open_wal_file(&vfs, &cx);
2346 let wal3 = WalFile::open(&cx, file3).expect("open WAL");
2347 assert_eq!(wal3.frame_count(), 5);
2348 wal3.close(&cx).expect("close WAL");
2349 }
2350
2351 #[test]
2352 fn sync_records_fsynced_frame_count_for_production_flags() {
2353 let cx = test_cx();
2354 let vfs = MemoryVfs::new();
2355 let file = open_wal_file(&vfs, &cx);
2356
2357 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2358 wal.append_frame(&cx, 1, &sample_page(0), 1)
2359 .expect("append");
2360 wal.sync(&cx, SyncFlags::NORMAL).expect("sync");
2361 assert_eq!(wal.last_fsynced_frame_count(), 1);
2362
2363 wal.append_frame(&cx, 2, &sample_page(1), 2)
2364 .expect("append");
2365 wal.sync(&cx, SyncFlags::FULL).expect("full sync");
2366 assert_eq!(wal.last_fsynced_frame_count(), 2);
2367
2368 wal.append_frame(&cx, 3, &sample_page(2), 3)
2369 .expect("append");
2370 wal.sync(&cx, SyncFlags::DATAONLY).expect("data-only sync");
2371 assert_eq!(wal.last_fsynced_frame_count(), 3);
2372
2373 wal.close(&cx).expect("close WAL");
2374 }
2375
2376 #[test]
2377 fn test_fault_hook_sync_failure_returns_error_and_records_context() {
2378 let _guard = FAULT_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2379 crate::fault_hooks::clear();
2380
2381 let cx = test_cx();
2382 let vfs = MemoryVfs::new();
2383 let file = open_wal_file(&vfs, &cx);
2384 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 1, test_salts()).expect("create WAL");
2385 wal.append_frame(&cx, 1, &sample_page(0x44), 1)
2386 .expect("append frame");
2387 wal.sync(&cx, SyncFlags::NORMAL)
2388 .expect("establish durable watermark");
2389 assert_eq!(wal.last_fsynced_frame_count(), 1);
2390 wal.append_frame(&cx, 2, &sample_page(0x45), 2)
2391 .expect("append unsynced frame");
2392
2393 crate::fault_hooks::arm_sync_failure(crate::fault_hooks::FaultHookArm::new(
2394 "bd-db300.7.2.2-sync-failure",
2395 "WAL-SYNC-FAILURE",
2396 "wal_sync_recovery",
2397 ));
2398
2399 let error = wal
2400 .sync(&cx, SyncFlags::NORMAL)
2401 .expect_err("fault hook should force sync failure");
2402 assert!(
2403 error.to_string().contains("fault_inject:wal_sync_failure"),
2404 "fault error should identify the sync hook: {error}"
2405 );
2406 assert_eq!(
2407 wal.last_fsynced_frame_count(),
2408 1,
2409 "a failed sync must preserve the prior durable-barrier accounting"
2410 );
2411
2412 let records = crate::fault_hooks::take_records();
2413 assert_eq!(
2414 records.len(),
2415 1,
2416 "exactly one sync fault should be recorded"
2417 );
2418 assert_eq!(records[0].point, "wal_sync_failure");
2419 assert_eq!(records[0].run_id, "bd-db300.7.2.2-sync-failure");
2420 assert!(
2421 records[0].detail.contains("frame_count_before=2"),
2422 "record should capture sync context: {}",
2423 records[0].detail
2424 );
2425
2426 crate::fault_hooks::clear();
2427 }
2428
2429 #[test]
2430 fn test_fault_hook_append_busy_countdown_fires_once_and_preserves_retry_surface() {
2431 let _guard = FAULT_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2432 crate::fault_hooks::clear();
2433
2434 let cx = test_cx();
2435 let vfs = MemoryVfs::new();
2436 let file = open_wal_file(&vfs, &cx);
2437 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 1, test_salts()).expect("create WAL");
2438
2439 crate::fault_hooks::arm_append_busy_countdown(
2440 crate::fault_hooks::FaultHookArm::new(
2441 "bd-db300.7.2.2-busy-countdown",
2442 "WAL-APPEND-BUSY",
2443 "wal_append_retry",
2444 ),
2445 2,
2446 );
2447
2448 let first_page = sample_page(0x55);
2449 let first_frames = [WalAppendFrameRef {
2450 page_number: 1,
2451 page_data: &first_page,
2452 db_size_if_commit: 1,
2453 }];
2454 wal.append_frames(&cx, &first_frames)
2455 .expect("countdown should not fire on first append");
2456
2457 let second_page = sample_page(0x66);
2458 let second_frames = [WalAppendFrameRef {
2459 page_number: 2,
2460 page_data: &second_page,
2461 db_size_if_commit: 2,
2462 }];
2463 let busy = wal
2464 .append_frames(&cx, &second_frames)
2465 .expect_err("countdown should fire on second append");
2466 assert!(matches!(busy, FrankenError::Busy));
2467 assert_eq!(
2468 wal.frame_count(),
2469 1,
2470 "busy fault should fire before the second append mutates WAL state"
2471 );
2472
2473 wal.append_frames(&cx, &second_frames)
2474 .expect("hook should disarm after firing once");
2475 assert_eq!(
2476 wal.frame_count(),
2477 2,
2478 "retry should succeed once the hook is spent"
2479 );
2480
2481 let records = crate::fault_hooks::take_records();
2482 assert_eq!(records.len(), 1, "busy countdown should record one trigger");
2483 assert_eq!(records[0].point, "wal_append_busy_countdown");
2484 assert_eq!(records[0].run_id, "bd-db300.7.2.2-busy-countdown");
2485 assert!(
2486 records[0].detail.contains("submitted_frames=1"),
2487 "record should preserve append batch context: {}",
2488 records[0].detail
2489 );
2490
2491 crate::fault_hooks::clear();
2492 }
2493
2494 #[test]
2505 fn test_fault_crash_between_header_and_truncate_recovers_to_zero_frames() {
2506 let _guard = FAULT_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2507 crate::fault_hooks::clear();
2508
2509 let cx = test_cx();
2510 let vfs = MemoryVfs::new();
2511 let file = open_wal_file(&vfs, &cx);
2512 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2513
2514 for i in 1..=3_u32 {
2516 let page = sample_page(i as u8);
2517 wal.append_frame(&cx, i, &page, i)
2518 .expect("append frame before reset fault injection");
2519 }
2520 wal.sync(&cx, SyncFlags::NORMAL).expect("sync WAL");
2521 assert_eq!(wal.frame_count(), 3, "pre-reset: 3 frames");
2522
2523 let original_salts = wal.generation_identity().salts;
2524
2525 crate::fault_hooks::arm_crash_header_truncate(crate::fault_hooks::FaultHookArm::new(
2527 "bd-db300.7.2.2-h9",
2528 "WAL-CRASH-HEADER-TRUNCATE",
2529 "wal_reset_recovery",
2530 ));
2531
2532 let new_salts = WalSalts {
2534 salt1: original_salts.salt1.wrapping_add(1),
2535 salt2: original_salts.salt2.wrapping_add(1),
2536 };
2537 let err = wal
2538 .reset(&cx, 1, new_salts, true)
2539 .expect_err("fault hook should fire between header write and truncate");
2540 assert!(
2541 err.to_string()
2542 .contains("fault_inject:wal_crash_header_truncate"),
2543 "error should identify the hook: {err}"
2544 );
2545
2546 wal.close(&cx).expect("close WAL handle");
2551
2552 let recovered_file = open_wal_file(&vfs, &cx);
2554 let recovered = WalFile::open(&cx, recovered_file).expect("reopen WAL");
2555
2556 assert_eq!(
2559 recovered.frame_count(),
2560 0,
2561 "recovery must discard all old-salt frames after header rewrite"
2562 );
2563 assert_eq!(
2564 recovered.generation_identity().salts,
2565 new_salts,
2566 "recovered header must have the new salts"
2567 );
2568
2569 let records = crate::fault_hooks::take_records();
2571 assert_eq!(records.len(), 1, "exactly one crash hook should fire");
2572 assert_eq!(records[0].point, "wal_crash_header_truncate");
2573 assert_eq!(records[0].scenario_id, "WAL-CRASH-HEADER-TRUNCATE");
2574 assert!(
2575 records[0].detail.contains("old_frame_count=3"),
2576 "record should capture pre-reset frame count: {}",
2577 records[0].detail
2578 );
2579 assert!(
2580 records[0].detail.contains("new_checkpoint_seq=1"),
2581 "record should capture checkpoint seq: {}",
2582 records[0].detail
2583 );
2584
2585 crate::fault_hooks::clear();
2586 }
2587
2588 #[test]
2589 fn test_file_accessors() {
2590 let cx = test_cx();
2591 let vfs = MemoryVfs::new();
2592 let file = open_wal_file(&vfs, &cx);
2593
2594 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2595
2596 let _size = wal.file().file_size(&cx).expect("file_size");
2598 let _size = wal.file_mut().file_size(&cx).expect("file_size via mut");
2599
2600 wal.close(&cx).expect("close WAL");
2601 }
2602
2603 #[test]
2606 fn test_truncated_wal_recovers_committed_prefix() {
2607 let cx = test_cx();
2611 let vfs = MemoryVfs::new();
2612 let file = open_wal_file(&vfs, &cx);
2613
2614 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2615 for i in 0..5u8 {
2616 let db_size = if i == 2 {
2617 3
2618 } else if i == 4 {
2619 5
2620 } else {
2621 0
2622 };
2623 wal.append_frame(&cx, u32::from(i) + 1, &sample_page(i), db_size)
2624 .expect("append");
2625 }
2626 assert_eq!(wal.frame_count(), 5);
2627
2628 let frame_size = wal.frame_size();
2630 let truncate_at = WAL_HEADER_SIZE + frame_size * 3 + frame_size / 2;
2632 let truncate_at_u64 = u64::try_from(truncate_at).expect("truncate_at fits u64");
2633 wal.file_mut()
2634 .truncate(&cx, truncate_at_u64)
2635 .expect("truncate");
2636 wal.close(&cx).expect("close WAL");
2637
2638 let file2 = open_wal_file(&vfs, &cx);
2640 let wal2 = WalFile::open(&cx, file2).expect("open WAL after truncation");
2641 assert_eq!(
2642 wal2.frame_count(),
2643 3,
2644 "only the 3 complete frames before truncation should survive"
2645 );
2646
2647 for i in 0..3u8 {
2649 let (header, data) = wal2.read_frame(&cx, usize::from(i)).expect("read frame");
2650 assert_eq!(header.page_number, u32::from(i) + 1);
2651 assert_eq!(data, sample_page(i));
2652 }
2653 wal2.close(&cx).expect("close WAL");
2654 }
2655
2656 #[test]
2657 fn test_corrupt_frame_payload_detected_on_reopen() {
2658 let cx = test_cx();
2662 let vfs = MemoryVfs::new();
2663 let file = open_wal_file(&vfs, &cx);
2664
2665 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2666 for i in 0..5u8 {
2667 let db_size = if i == 2 {
2668 3
2669 } else if i == 4 {
2670 5
2671 } else {
2672 0
2673 };
2674 wal.append_frame(&cx, u32::from(i) + 1, &sample_page(i), db_size)
2675 .expect("append");
2676 }
2677 let frame_size = wal.frame_size();
2678 wal.close(&cx).expect("close WAL");
2679
2680 let corrupt_offset = WAL_HEADER_SIZE + frame_size * 3 + WAL_FRAME_HEADER_SIZE + 42;
2682 let corrupt_offset_u64 = u64::try_from(corrupt_offset).expect("corrupt_offset fits u64");
2683 let f = open_wal_file(&vfs, &cx);
2684 let mut buf = [0u8; 1];
2685 f.read(&cx, &mut buf, corrupt_offset_u64)
2686 .expect("read byte");
2687 buf[0] ^= 0xFF;
2688 f.write(&cx, &buf, corrupt_offset_u64)
2689 .expect("write corrupted byte");
2690 drop(f);
2691
2692 let file3 = open_wal_file(&vfs, &cx);
2694 let wal3 = WalFile::open(&cx, file3).expect("open WAL after corruption");
2695 assert_eq!(
2696 wal3.frame_count(),
2697 3,
2698 "frames after corruption point should be discarded"
2699 );
2700 wal3.close(&cx).expect("close WAL");
2701 }
2702
2703 #[test]
2704 fn test_multi_commit_recovery_to_last_valid() {
2705 let cx = test_cx();
2709 let vfs = MemoryVfs::new();
2710 let file = open_wal_file(&vfs, &cx);
2711
2712 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2713
2714 for i in 1..=3u32 {
2716 let db_size = if i == 3 { 3 } else { 0 };
2717 wal.append_frame(
2718 &cx,
2719 i,
2720 &sample_page(u8::try_from(i).expect("i fits u8")),
2721 db_size,
2722 )
2723 .expect("append");
2724 }
2725
2726 for i in 4..=6u32 {
2728 let db_size = if i == 6 { 6 } else { 0 };
2729 wal.append_frame(
2730 &cx,
2731 i,
2732 &sample_page(u8::try_from(i).expect("i fits u8")),
2733 db_size,
2734 )
2735 .expect("append");
2736 }
2737 assert_eq!(wal.frame_count(), 6);
2738 let frame_size = wal.frame_size();
2739 wal.close(&cx).expect("close WAL");
2740
2741 let corrupt_offset = WAL_HEADER_SIZE + frame_size * 4 + WAL_FRAME_HEADER_SIZE + 10;
2743 let corrupt_offset_u64 = u64::try_from(corrupt_offset).expect("corrupt_offset fits u64");
2744 let f = open_wal_file(&vfs, &cx);
2745 let mut buf = [0u8; 1];
2746 f.read(&cx, &mut buf, corrupt_offset_u64).expect("read");
2747 buf[0] ^= 0xAA;
2748 f.write(&cx, &buf, corrupt_offset_u64).expect("corrupt");
2749 drop(f);
2750
2751 let file2 = open_wal_file(&vfs, &cx);
2754 let wal2 = WalFile::open(&cx, file2).expect("open WAL after corruption");
2755 assert_eq!(
2756 wal2.frame_count(),
2757 3,
2758 "chain should break at corrupted frame 5, keeping committed prefix (frames 1-3)"
2759 );
2760
2761 let header3 = wal2.read_frame_header(&cx, 2).expect("read frame 3 header");
2763 assert!(header3.is_commit(), "frame 3 should be a commit frame");
2764
2765 wal2.close(&cx).expect("close WAL");
2766 }
2767
2768 #[test]
2769 fn test_wal_growth_bounded_by_restart_checkpoint() {
2770 use crate::checkpoint::{CheckpointMode, CheckpointState};
2773 use crate::checkpoint_executor::execute_checkpoint;
2774 use crate::checkpoint_executor::{CheckpointTarget, CheckpointTargetFuture};
2775 use fsqlite_types::PageNumber;
2776
2777 struct DummyTarget;
2778 impl CheckpointTarget for DummyTarget {
2779 fn write_page<'a>(
2780 &'a mut self,
2781 _: &'a Cx,
2782 _: PageNumber,
2783 _: &'a [u8],
2784 ) -> CheckpointTargetFuture<'a, ()> {
2785 Box::pin(async { Ok(()) })
2786 }
2787 fn truncate_db<'a>(&'a mut self, _: &'a Cx, _: u32) -> CheckpointTargetFuture<'a, ()> {
2788 Box::pin(async { Ok(()) })
2789 }
2790 fn sync_db<'a>(&'a mut self, _: &'a Cx) -> CheckpointTargetFuture<'a, ()> {
2791 Box::pin(async { Ok(()) })
2792 }
2793 }
2794
2795 let cx = test_cx();
2796 let vfs = MemoryVfs::new();
2797 let file = open_wal_file(&vfs, &cx);
2798 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2799
2800 for i in 1..=100u32 {
2802 let seed = u8::try_from(i % 256).expect("seed fits u8");
2803 let db_size = if i % 10 == 0 { i } else { 0 };
2804 wal.append_frame(&cx, (i - 1) % 50 + 1, &sample_page(seed), db_size)
2805 .expect("append");
2806 }
2807 assert_eq!(wal.frame_count(), 100);
2808
2809 let state = CheckpointState {
2811 total_frames: 100,
2812 backfilled_frames: 0,
2813 oldest_reader_frame: None,
2814 };
2815 let mut target = DummyTarget;
2816 let result = execute_checkpoint(&cx, &mut wal, CheckpointMode::Restart, state, &mut target)
2817 .expect("restart checkpoint");
2818
2819 assert_eq!(result.frames_backfilled, 100);
2820 assert!(result.wal_was_reset);
2821 assert_eq!(wal.frame_count(), 0, "WAL should be empty after restart");
2822
2823 wal.append_frame(&cx, 1, &sample_page(0xAA), 1)
2825 .expect("append after reset");
2826 assert_eq!(wal.frame_count(), 1);
2827 assert_eq!(wal.header().checkpoint_seq, 1, "checkpoint_seq incremented");
2828
2829 wal.close(&cx).expect("close WAL");
2830 }
2831
2832 #[test]
2833 fn test_wal_header_corruption_detected() {
2834 let cx = test_cx();
2837 let vfs = MemoryVfs::new();
2838 let file = open_wal_file(&vfs, &cx);
2839
2840 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2841 wal.append_frame(&cx, 1, &sample_page(1), 1)
2842 .expect("append");
2843 wal.close(&cx).expect("close WAL");
2844
2845 let f = open_wal_file(&vfs, &cx);
2847 let corrupted_magic = [0xFF, 0xFF, 0xFF, 0xFF];
2848 f.write(&cx, &corrupted_magic, 0).expect("corrupt header");
2849 drop(f);
2850
2851 let file2 = open_wal_file(&vfs, &cx);
2853 let result = WalFile::open(&cx, file2);
2854 assert!(
2855 result.is_err(),
2856 "opening WAL with corrupted header magic should fail"
2857 );
2858 }
2859
2860 #[test]
2861 fn test_empty_wal_after_crash_reopen() {
2862 let cx = test_cx();
2865 let vfs = MemoryVfs::new();
2866 let file = open_wal_file(&vfs, &cx);
2867
2868 let wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2869 wal.close(&cx).expect("close WAL");
2870
2871 let file2 = open_wal_file(&vfs, &cx);
2872 let wal2 = WalFile::open(&cx, file2).expect("reopen empty WAL");
2873 assert_eq!(wal2.frame_count(), 0);
2874 wal2.close(&cx).expect("close WAL");
2875 }
2876
2877 #[test]
2878 fn test_crash_after_single_uncommitted_frame() {
2879 let cx = test_cx();
2884 let vfs = MemoryVfs::new();
2885 let file = open_wal_file(&vfs, &cx);
2886
2887 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2888 wal.append_frame(&cx, 1, &sample_page(0x77), 0)
2889 .expect("append non-commit");
2890 wal.close(&cx).expect("close WAL");
2891
2892 let file2 = open_wal_file(&vfs, &cx);
2893 let wal2 = WalFile::open(&cx, file2).expect("reopen WAL");
2894 assert_eq!(
2895 wal2.frame_count(),
2896 0,
2897 "uncommitted frame excluded from recovery"
2898 );
2899 wal2.close(&cx).expect("close WAL");
2900 }
2901
2902 #[test]
2903 fn test_frame_offset_calculation_overflow_safety() {
2904 let page_size: u64 = 4096;
2908 let wal_header_size: u64 = 32;
2909 let wal_frame_header_size: u64 = 24;
2910 let frame_size = wal_frame_header_size + page_size;
2911
2912 let large_index: u64 = 1_042_468;
2918
2919 let idx_u64 = large_index;
2920 let expected_offset = wal_header_size + idx_u64 * frame_size;
2921
2922 let calculated_offset = wal_header_size + idx_u64 * frame_size;
2924
2925 assert_eq!(calculated_offset, expected_offset);
2926
2927 }
2930
2931 #[test]
2936 fn test_frame_offsets_sequential_no_gaps() {
2937 let cx = test_cx();
2940 let vfs = MemoryVfs::new();
2941 let file = open_wal_file(&vfs, &cx);
2942
2943 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2944
2945 let n = 20u32;
2946 for i in 0..n {
2947 let db_size = if i == n - 1 { n } else { 0 };
2948 wal.append_frame(
2949 &cx,
2950 i + 1,
2951 &sample_page(u8::try_from(i % 251).unwrap()),
2952 db_size,
2953 )
2954 .expect("append");
2955 }
2956
2957 let frame_size = wal.frame_size();
2958 let file_size = wal.file().file_size(&cx).expect("file_size");
2959 let expected_size =
2960 u64::try_from(WAL_HEADER_SIZE + usize::try_from(n).unwrap() * frame_size).unwrap();
2961 assert_eq!(
2962 file_size, expected_size,
2963 "WAL file size must equal header + n*frame_size with no padding or gaps"
2964 );
2965
2966 for i in 0..n {
2968 let header = wal
2969 .read_frame_header(&cx, usize::try_from(i).unwrap())
2970 .expect("read header");
2971 assert_eq!(header.page_number, i + 1, "frame {i} page_number");
2972 }
2973
2974 wal.close(&cx).expect("close WAL");
2975 }
2976
2977 #[test]
2978 fn test_checksum_determinism_same_input() {
2979 let cx = test_cx();
2982 let vfs1 = MemoryVfs::new();
2983 let vfs2 = MemoryVfs::new();
2984
2985 let mut checksums_a = Vec::new();
2986 let mut checksums_b = Vec::new();
2987
2988 for (vfs, checksums) in [(&vfs1, &mut checksums_a), (&vfs2, &mut checksums_b)] {
2989 let file = open_wal_file(vfs, &cx);
2990 let mut wal =
2991 WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2992
2993 for i in 0..10u8 {
2994 let page = sample_page(i);
2995 let db_size = if i == 9 { 10 } else { 0 };
2996 wal.append_frame(&cx, u32::from(i) + 1, &page, db_size)
2997 .expect("append");
2998 checksums.push(wal.running_checksum());
2999 }
3000 wal.close(&cx).expect("close WAL");
3001 }
3002
3003 assert_eq!(
3004 checksums_a, checksums_b,
3005 "identical inputs must produce identical checksum chains"
3006 );
3007 }
3008
3009 #[test]
3010 fn test_checksum_sensitivity_one_byte_difference() {
3011 let cx = test_cx();
3014 let vfs1 = MemoryVfs::new();
3015 let vfs2 = MemoryVfs::new();
3016
3017 let mut checksums_a = Vec::new();
3018 let mut checksums_b = Vec::new();
3019
3020 let file1 = open_wal_file(&vfs1, &cx);
3021 let mut wal1 = WalFile::create(&cx, file1, PAGE_SIZE, 0, test_salts()).expect("create");
3022 let file2 = open_wal_file(&vfs2, &cx);
3023 let mut wal2 = WalFile::create(&cx, file2, PAGE_SIZE, 0, test_salts()).expect("create");
3024
3025 for i in 0..5u8 {
3026 let mut page = sample_page(i);
3027 let db_size = if i == 4 { 5 } else { 0 };
3028 wal1.append_frame(&cx, u32::from(i) + 1, &page, db_size)
3029 .expect("append");
3030 checksums_a.push(wal1.running_checksum());
3031
3032 if i == 2 {
3034 page[0] ^= 0x01;
3035 }
3036 wal2.append_frame(&cx, u32::from(i) + 1, &page, db_size)
3037 .expect("append");
3038 checksums_b.push(wal2.running_checksum());
3039 }
3040
3041 assert_eq!(checksums_a[0], checksums_b[0], "frame 0 should match");
3043 assert_eq!(checksums_a[1], checksums_b[1], "frame 1 should match");
3044 assert_ne!(checksums_a[2], checksums_b[2], "frame 2 must diverge");
3045 assert_ne!(checksums_a[3], checksums_b[3], "frame 3 must diverge");
3046 assert_ne!(checksums_a[4], checksums_b[4], "frame 4 must diverge");
3047
3048 wal1.close(&cx).expect("close");
3049 wal2.close(&cx).expect("close");
3050 }
3051
3052 #[test]
3053 fn test_commit_boundary_every_frame() {
3054 let cx = test_cx();
3057 let vfs = MemoryVfs::new();
3058 let file = open_wal_file(&vfs, &cx);
3059
3060 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
3061
3062 let n = 8u32;
3063 for i in 0..n {
3064 wal.append_frame(&cx, i + 1, &sample_page(u8::try_from(i).unwrap()), i + 1)
3065 .expect("append");
3066 }
3067 assert_eq!(wal.frame_count(), usize::try_from(n).unwrap());
3068 wal.close(&cx).expect("close");
3069
3070 let file2 = open_wal_file(&vfs, &cx);
3071 let mut wal2 = WalFile::open(&cx, file2).expect("reopen");
3072 assert_eq!(
3073 wal2.frame_count(),
3074 usize::try_from(n).unwrap(),
3075 "all frames are commits so all should survive reopen"
3076 );
3077
3078 for i in 0..n {
3080 let h = wal2
3081 .read_frame_header(&cx, usize::try_from(i).unwrap())
3082 .expect("read");
3083 assert!(h.is_commit(), "frame {i} must be a commit");
3084 assert_eq!(h.db_size, i + 1);
3085 }
3086
3087 let last = wal2.last_commit_frame(&cx).expect("query");
3089 assert_eq!(last, Some(usize::try_from(n - 1).unwrap()));
3090
3091 wal2.close(&cx).expect("close");
3092 }
3093
3094 #[test]
3095 fn test_commit_boundary_interleaved_multi_txn() {
3096 let cx = test_cx();
3101 let vfs = MemoryVfs::new();
3102 let file = open_wal_file(&vfs, &cx);
3103 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
3104
3105 let frames: [(u32, u32); 6] = [
3106 (1, 0),
3107 (2, 0),
3108 (3, 3), (4, 0),
3110 (5, 5), (6, 6), ];
3113
3114 for (pg, db_sz) in frames {
3115 wal.append_frame(&cx, pg, &sample_page(u8::try_from(pg).unwrap()), db_sz)
3116 .expect("append");
3117 }
3118 assert_eq!(wal.frame_count(), 6);
3119 wal.close(&cx).expect("close");
3120
3121 let file2 = open_wal_file(&vfs, &cx);
3123 let mut wal2 = WalFile::open(&cx, file2).expect("reopen");
3124 assert_eq!(wal2.frame_count(), 6);
3125
3126 for i in 0..6u32 {
3128 let (header, data) = wal2
3129 .read_frame(&cx, usize::try_from(i).unwrap())
3130 .expect("read frame");
3131 assert_eq!(header.page_number, i + 1);
3132 let expected = sample_page(u8::try_from(i + 1).expect("fits"));
3133 assert_eq!(data, expected);
3134 }
3135
3136 let last_header = wal2.read_frame_header(&cx, 5).expect("read header");
3138 assert!(last_header.is_commit());
3139 assert_eq!(last_header.db_size, 6);
3140 let last = wal2.last_commit_frame(&cx).expect("query");
3141 assert_eq!(last, Some(5), "last commit is frame 6 (index 5)");
3142
3143 wal2.close(&cx).expect("close");
3144 }
3145
3146 #[test]
3147 fn test_same_page_overwritten_multiple_times() {
3148 let cx = test_cx();
3152 let vfs = MemoryVfs::new();
3153 let file = open_wal_file(&vfs, &cx);
3154 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
3155
3156 let page_num = 42u32;
3157 let versions = 5;
3158 for v in 0..versions {
3159 let db_size = if v == versions - 1 { 100 } else { 0 };
3160 wal.append_frame(&cx, page_num, &sample_page(v), db_size)
3161 .expect("append");
3162 }
3163 assert_eq!(wal.frame_count(), usize::from(versions));
3164
3165 for v in 0..versions {
3167 let (header, data) = wal.read_frame(&cx, usize::from(v)).expect("read frame");
3168 assert_eq!(header.page_number, page_num);
3169 assert_eq!(data, sample_page(v), "frame {v} data mismatch");
3170 }
3171
3172 wal.close(&cx).expect("close");
3173 }
3174
3175 #[test]
3176 fn test_refresh_detects_concurrent_append() {
3177 let cx = test_cx();
3180 let vfs = MemoryVfs::new();
3181 let file1 = open_wal_file(&vfs, &cx);
3182 let mut wal1 = WalFile::create(&cx, file1, PAGE_SIZE, 0, test_salts()).expect("create");
3183
3184 for i in 0..3u8 {
3186 let db_size = if i == 2 { 3 } else { 0 };
3187 wal1.append_frame(&cx, u32::from(i) + 1, &sample_page(i), db_size)
3188 .expect("append");
3189 }
3190 let checksum_after_3 = wal1.running_checksum();
3191 wal1.close(&cx).expect("close wal1");
3192
3193 let file_reader = open_wal_file(&vfs, &cx);
3195 let mut reader = WalFile::open(&cx, file_reader).expect("open reader");
3196 assert_eq!(reader.frame_count(), 3);
3197 assert_eq!(reader.last_commit_frame(&cx).expect("query"), Some(2));
3198 reader.sync(&cx, SyncFlags::NORMAL).expect("sync reader");
3199 assert_eq!(reader.last_fsynced_frame_count(), 3);
3200
3201 let file_w2 = open_wal_file(&vfs, &cx);
3203 let mut w2 = WalFile::open(&cx, file_w2).expect("open w2");
3204 assert_eq!(w2.running_checksum(), checksum_after_3);
3205 w2.append_frame(&cx, 4, &sample_page(3), 0).expect("append");
3206 w2.append_frame(&cx, 5, &sample_page(4), 5)
3207 .expect("append commit");
3208 assert_eq!(w2.frame_count(), 5);
3209 w2.close(&cx).expect("close w2");
3210
3211 assert_eq!(reader.frame_count(), 3);
3213 reader.refresh(&cx).expect("refresh");
3214 assert_eq!(
3215 reader.frame_count(),
3216 5,
3217 "after refresh, reader must see the 2 new committed frames"
3218 );
3219 assert_eq!(
3220 reader.last_fsynced_frame_count(),
3221 3,
3222 "same-generation incremental refresh preserves only the known watermark"
3223 );
3224 assert_eq!(reader.last_commit_frame(&cx).expect("query"), Some(4));
3225
3226 reader.close(&cx).expect("close reader");
3227 }
3228
3229 #[test]
3230 fn refresh_after_reset_clears_stale_durable_watermark() {
3231 let cx = test_cx();
3234 let vfs = MemoryVfs::new();
3235 let file = open_wal_file(&vfs, &cx);
3236 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
3237
3238 wal.append_frame(&cx, 1, &sample_page(1), 1)
3240 .expect("append");
3241 wal.close(&cx).expect("close");
3242
3243 let file_r = open_wal_file(&vfs, &cx);
3245 let mut reader = WalFile::open(&cx, file_r).expect("open reader");
3246 assert_eq!(reader.frame_count(), 1);
3247 assert_eq!(reader.last_commit_frame(&cx).expect("query"), Some(0));
3248 reader
3249 .sync(&cx, SyncFlags::NORMAL)
3250 .expect("sync old generation");
3251 assert_eq!(reader.last_fsynced_frame_count(), 1);
3252
3253 let file_cp = open_wal_file(&vfs, &cx);
3255 let mut cp = WalFile::open(&cx, file_cp).expect("open cp");
3256 let new_salts = WalSalts {
3257 salt1: 0xAAAA_BBBB,
3258 salt2: 0xCCCC_DDDD,
3259 };
3260 cp.reset(&cx, 1, new_salts, false).expect("reset");
3261 cp.append_frame(&cx, 1, &sample_page(0xAA), 1)
3262 .expect("append after reset");
3263 cp.close(&cx).expect("close cp");
3264
3265 reader.refresh(&cx).expect("refresh");
3267 assert_eq!(reader.frame_count(), 1);
3268 assert_eq!(
3269 reader.last_fsynced_frame_count(),
3270 0,
3271 "a new generation's unsynced frame must not inherit the old generation watermark"
3272 );
3273 assert_eq!(reader.last_commit_frame(&cx).expect("query"), Some(0));
3274 assert_eq!(
3275 reader.header().salts,
3276 new_salts,
3277 "salts should be new generation"
3278 );
3279
3280 reader.close(&cx).expect("close reader");
3281 }
3282
3283 #[test]
3284 fn test_refresh_after_reset_with_same_salts_detects_new_generation() {
3285 let cx = test_cx();
3288 let vfs = MemoryVfs::new();
3289 let file = open_wal_file(&vfs, &cx);
3290 let salts = test_salts();
3291 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, salts).expect("create");
3292
3293 wal.append_frame(&cx, 1, &sample_page(1), 1)
3294 .expect("append");
3295 wal.close(&cx).expect("close");
3296
3297 let file_r = open_wal_file(&vfs, &cx);
3298 let mut reader = WalFile::open(&cx, file_r).expect("open reader");
3299 let before = reader.generation_identity();
3300 assert_eq!(before.checkpoint_seq, 0);
3301 assert_eq!(before.salts, salts);
3302 assert_eq!(reader.frame_count(), 1);
3303
3304 let file_cp = open_wal_file(&vfs, &cx);
3305 let mut cp = WalFile::open(&cx, file_cp).expect("open cp");
3306 cp.reset(&cx, 1, salts, false)
3307 .expect("reset with same salts");
3308 cp.append_frame(&cx, 2, &sample_page(0xAA), 2)
3309 .expect("append after reset");
3310 cp.close(&cx).expect("close cp");
3311
3312 reader.refresh(&cx).expect("refresh");
3313 let after = reader.generation_identity();
3314 assert_eq!(
3315 after.checkpoint_seq, 1,
3316 "refresh must observe new checkpoint_seq"
3317 );
3318 assert_eq!(
3319 after.salts, salts,
3320 "same-salt reset is intentional in this test"
3321 );
3322 assert_ne!(
3323 before, after,
3324 "generation identity must change even when salts are reused"
3325 );
3326 assert_eq!(
3327 reader.frame_count(),
3328 1,
3329 "reader must rebuild to new generation"
3330 );
3331
3332 let (header, data) = reader.read_frame(&cx, 0).expect("read rebuilt frame");
3333 assert_eq!(
3334 header.page_number, 2,
3335 "reader must see new-generation frame"
3336 );
3337 assert_eq!(data, sample_page(0xAA));
3338
3339 reader.close(&cx).expect("close reader");
3340 }
3341
3342 #[test]
3343 fn test_group_commit_checksum_chain_matches_single_append() {
3344 use crate::group_commit::{
3347 FrameSubmission, TransactionFrameBatch, write_consolidated_frames,
3348 };
3349
3350 let cx = test_cx();
3351 let vfs_single = MemoryVfs::new();
3352 let vfs_group = MemoryVfs::new();
3353
3354 let pages: Vec<Vec<u8>> = (0..6u8).map(sample_page).collect();
3355 let page_nums: Vec<u32> = (1..=6u32).collect();
3356 let commit_sizes: Vec<u32> = vec![0, 0, 3, 0, 0, 6];
3358
3359 let file_s = open_wal_file(&vfs_single, &cx);
3361 let mut wal_s =
3362 WalFile::create(&cx, file_s, PAGE_SIZE, 0, test_salts()).expect("create single");
3363 for i in 0..6 {
3364 wal_s
3365 .append_frame(&cx, page_nums[i], &pages[i], commit_sizes[i])
3366 .expect("append single");
3367 }
3368 let single_checksum = wal_s.running_checksum();
3369 let single_count = wal_s.frame_count();
3370
3371 let file_g = open_wal_file(&vfs_group, &cx);
3373 let mut wal_g =
3374 WalFile::create(&cx, file_g, PAGE_SIZE, 0, test_salts()).expect("create group");
3375
3376 let batch1 = TransactionFrameBatch::new(
3377 (0..3)
3378 .map(|i| FrameSubmission {
3379 page_number: page_nums[i],
3380 page_data: pages[i].clone(),
3381 db_size_if_commit: commit_sizes[i],
3382 })
3383 .collect(),
3384 );
3385 let batch2 = TransactionFrameBatch::new(
3386 (3..6)
3387 .map(|i| FrameSubmission {
3388 page_number: page_nums[i],
3389 page_data: pages[i].clone(),
3390 db_size_if_commit: commit_sizes[i],
3391 })
3392 .collect(),
3393 );
3394
3395 write_consolidated_frames(&cx, &mut wal_g, &[batch1, batch2]).expect("group write");
3396 let group_checksum = wal_g.running_checksum();
3397 let group_count = wal_g.frame_count();
3398
3399 assert_eq!(single_count, group_count, "frame counts must match");
3400 assert_eq!(
3401 single_checksum, group_checksum,
3402 "group commit must produce identical checksum chain as single-frame append"
3403 );
3404
3405 for i in 0..6 {
3407 let (h_s, d_s) = wal_s.read_frame(&cx, i).expect("read single");
3408 let (h_g, d_g) = wal_g.read_frame(&cx, i).expect("read group");
3409 assert_eq!(h_s.page_number, h_g.page_number, "frame {i} page_number");
3410 assert_eq!(h_s.db_size, h_g.db_size, "frame {i} db_size");
3411 assert_eq!(h_s.checksum, h_g.checksum, "frame {i} checksum");
3412 assert_eq!(h_s.salts, h_g.salts, "frame {i} salts");
3413 assert_eq!(d_s, d_g, "frame {i} data");
3414 }
3415
3416 wal_s.close(&cx).expect("close single");
3417 wal_g.close(&cx).expect("close group");
3418 }
3419
3420 #[test]
3421 fn test_batch_append_checksum_chain_matches_single_append() {
3422 let cx = test_cx();
3423 let vfs_single = MemoryVfs::new();
3424 let vfs_batch = MemoryVfs::new();
3425
3426 let pages: Vec<Vec<u8>> = (0..6u8).map(sample_page).collect();
3427 let page_nums: Vec<u32> = (1..=6u32).collect();
3428 let commit_sizes: Vec<u32> = vec![0, 0, 3, 0, 0, 6];
3429
3430 let file_single = open_wal_file(&vfs_single, &cx);
3431 let mut wal_single =
3432 WalFile::create(&cx, file_single, PAGE_SIZE, 0, test_salts()).expect("create single");
3433 for i in 0..6 {
3434 wal_single
3435 .append_frame(&cx, page_nums[i], &pages[i], commit_sizes[i])
3436 .expect("append single");
3437 }
3438
3439 let file_batch = open_wal_file(&vfs_batch, &cx);
3440 let mut wal_batch =
3441 WalFile::create(&cx, file_batch, PAGE_SIZE, 0, test_salts()).expect("create batch");
3442 let frames: Vec<_> = (0..6)
3443 .map(|i| WalAppendFrameRef {
3444 page_number: page_nums[i],
3445 page_data: &pages[i],
3446 db_size_if_commit: commit_sizes[i],
3447 })
3448 .collect();
3449 wal_batch.append_frames(&cx, &frames).expect("append batch");
3450
3451 assert_eq!(
3452 wal_single.frame_count(),
3453 wal_batch.frame_count(),
3454 "batch append must preserve frame count"
3455 );
3456 assert_eq!(
3457 wal_single.running_checksum(),
3458 wal_batch.running_checksum(),
3459 "batch append must preserve checksum chain"
3460 );
3461
3462 for i in 0..6 {
3463 let (single_header, single_data) = wal_single.read_frame(&cx, i).expect("read single");
3464 let (batch_header, batch_data) = wal_batch.read_frame(&cx, i).expect("read batch");
3465 assert_eq!(single_header, batch_header, "frame header {i} must match");
3466 assert_eq!(single_data, batch_data, "frame payload {i} must match");
3467 }
3468 }
3469
3470 #[test]
3471 fn tracked_batch_write_completion_is_reported_by_memory_source() {
3472 let cx = test_cx();
3473 let vfs = MemoryVfs::new();
3474 let file = open_wal_file(&vfs, &cx);
3475 let mut wal =
3476 WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create tracked WAL");
3477 let page = sample_page(0xA5);
3478 let frames = [WalAppendFrameRef {
3479 page_number: 1,
3480 page_data: &page,
3481 db_size_if_commit: 1,
3482 }];
3483 let completion = VfsWriteCompletion::new();
3484
3485 wal.append_frames_tracked(&cx, &frames, completion.clone())
3486 .expect("append tracked frame batch");
3487
3488 assert_eq!(
3489 completion.state(),
3490 fsqlite_vfs::VfsWriteCompletionState::Success
3491 );
3492 assert_eq!(wal.frame_count(), 1);
3493 }
3494
3495 #[test]
3499 fn test_fused_append_frames_produces_byte_identical_wal_file() {
3500 let cx = test_cx();
3501 let vfs_single = MemoryVfs::new();
3502 let vfs_fused = MemoryVfs::new();
3503
3504 let pages: Vec<Vec<u8>> = (0..4u8).map(sample_page).collect();
3505 let page_nums: Vec<u32> = vec![3, 1, 4, 2];
3506 let commit_sizes: Vec<u32> = vec![0, 0, 0, 4];
3507
3508 let file_s = open_wal_file(&vfs_single, &cx);
3510 let mut wal_s =
3511 WalFile::create(&cx, file_s, PAGE_SIZE, 0, test_salts()).expect("create single");
3512 for i in 0..4 {
3513 wal_s
3514 .append_frame(&cx, page_nums[i], &pages[i], commit_sizes[i])
3515 .expect("append single");
3516 }
3517
3518 let file_f = open_wal_file(&vfs_fused, &cx);
3520 let mut wal_f =
3521 WalFile::create(&cx, file_f, PAGE_SIZE, 0, test_salts()).expect("create fused");
3522 let frames: Vec<_> = (0..4)
3523 .map(|i| WalAppendFrameRef {
3524 page_number: page_nums[i],
3525 page_data: &pages[i],
3526 db_size_if_commit: commit_sizes[i],
3527 })
3528 .collect();
3529 wal_f.append_frames(&cx, &frames).expect("append fused");
3530
3531 assert_eq!(wal_s.frame_count(), wal_f.frame_count());
3533 assert_eq!(wal_s.running_checksum(), wal_f.running_checksum());
3534
3535 let frame_size = wal_s.frame_size();
3536 for i in 0..4 {
3537 let mut buf_s = vec![0u8; frame_size];
3538 let mut buf_f = vec![0u8; frame_size];
3539 wal_s
3540 .read_frame_into(&cx, i, &mut buf_s)
3541 .expect("read single");
3542 wal_f
3543 .read_frame_into(&cx, i, &mut buf_f)
3544 .expect("read fused");
3545 assert_eq!(
3546 buf_s, buf_f,
3547 "raw frame bytes at index {i} must be identical"
3548 );
3549 }
3550 }
3551
3552 #[test]
3556 fn test_append_frames_restores_scratch_on_error() {
3557 let cx = test_cx();
3558 let vfs = MemoryVfs::new();
3559 let file = open_wal_file(&vfs, &cx);
3560 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
3561
3562 let good_page = sample_page(0x11);
3564 wal.append_frame(&cx, 1, &good_page, 0)
3565 .expect("first append");
3566 let checksum_before = wal.running_checksum();
3567 let scratch_cap_before = wal.frame_scratch_capacity();
3568 let mut first_frame_before = vec![0u8; wal.frame_size()];
3569 wal.read_frame_into(&cx, 0, &mut first_frame_before)
3570 .expect("read baseline frame");
3571
3572 let bad_page = vec![0xBBu8; PAGE_SIZE as usize + 1]; let good_page2 = sample_page(0x22);
3575 let bad_frames = vec![
3576 WalAppendFrameRef {
3577 page_number: 2,
3578 page_data: &good_page2,
3579 db_size_if_commit: 0,
3580 },
3581 WalAppendFrameRef {
3582 page_number: 3,
3583 page_data: &bad_page, db_size_if_commit: 3,
3585 },
3586 ];
3587 let err = wal.append_frames(&cx, &bad_frames);
3588 assert!(err.is_err(), "bad page size should cause error");
3589
3590 assert_eq!(
3592 wal.frame_count(),
3593 1,
3594 "failed append must not advance frame count"
3595 );
3596 assert_eq!(
3597 wal.running_checksum(),
3598 checksum_before,
3599 "failed append must preserve the running checksum of prior committed frames"
3600 );
3601 assert!(
3602 wal.frame_scratch_capacity() >= scratch_cap_before,
3603 "scratch capacity must not shrink after error"
3604 );
3605 let mut first_frame_after = vec![0u8; wal.frame_size()];
3606 wal.read_frame_into(&cx, 0, &mut first_frame_after)
3607 .expect("read preserved frame");
3608 assert_eq!(
3609 first_frame_after, first_frame_before,
3610 "failed append must not rewrite previously committed raw frame bytes"
3611 );
3612
3613 let recovery_page = sample_page(0x33);
3615 wal.append_frame(&cx, 2, &recovery_page, 2)
3616 .expect("recovery append after error");
3617 assert_eq!(wal.frame_count(), 2, "recovery append should succeed");
3618 }
3619
3620 #[test]
3621 fn test_append_frame_reuses_frame_scratch_between_calls() {
3622 let cx = test_cx();
3623 let vfs = MemoryVfs::new();
3624 let file = open_wal_file(&vfs, &cx);
3625 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
3626
3627 wal.append_frame(&cx, 1, &sample_page(0x11), 0)
3628 .expect("append first");
3629 let scratch_len = wal.frame_scratch_len();
3630 let scratch_capacity = wal.frame_scratch_capacity();
3631 let scratch_ptr = wal.frame_scratch_ptr();
3632
3633 wal.append_frame(&cx, 2, &sample_page(0x22), 2)
3634 .expect("append second");
3635
3636 assert_eq!(
3637 wal.frame_scratch_len(),
3638 wal.frame_size(),
3639 "single-frame append should keep one frame sized scratch"
3640 );
3641 assert_eq!(
3642 wal.frame_scratch_len(),
3643 scratch_len,
3644 "single-frame scratch length should stay constant across appends"
3645 );
3646 assert_eq!(
3647 wal.frame_scratch_capacity(),
3648 scratch_capacity,
3649 "single-frame scratch should retain its allocation"
3650 );
3651 assert_eq!(
3652 wal.frame_scratch_ptr(),
3653 scratch_ptr,
3654 "single-frame scratch should reuse the same backing buffer"
3655 );
3656 }
3657
3658 #[test]
3659 fn test_batch_append_reuses_frame_scratch_between_calls() {
3660 let cx = test_cx();
3661 let vfs = MemoryVfs::new();
3662 let file = open_wal_file(&vfs, &cx);
3663 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
3664
3665 let first_pages: Vec<Vec<u8>> = (0..3u8).map(sample_page).collect();
3666 let first_frames: Vec<_> = (0..3)
3667 .map(|i| WalAppendFrameRef {
3668 page_number: u32::try_from(i).expect("index fits u32") + 1,
3669 page_data: &first_pages[i],
3670 db_size_if_commit: 0,
3671 })
3672 .collect();
3673 wal.append_frames(&cx, &first_frames)
3674 .expect("append first batch");
3675 let scratch_capacity = wal.frame_scratch_capacity();
3676 let scratch_ptr = wal.frame_scratch_ptr();
3677
3678 let second_pages: Vec<Vec<u8>> = (0..2u8).map(|i| sample_page(i + 10)).collect();
3679 let second_frames: Vec<_> = (0..2)
3680 .map(|i| WalAppendFrameRef {
3681 page_number: u32::try_from(i).expect("index fits u32") + 10,
3682 page_data: &second_pages[i],
3683 db_size_if_commit: if i == 1 { 11 } else { 0 },
3684 })
3685 .collect();
3686 wal.append_frames(&cx, &second_frames)
3687 .expect("append second batch");
3688
3689 assert_eq!(
3690 wal.frame_scratch_len(),
3691 second_frames.len() * wal.frame_size(),
3692 "batch scratch length should track the active batch size"
3693 );
3694 assert_eq!(
3695 wal.frame_scratch_capacity(),
3696 scratch_capacity,
3697 "smaller follow-on batches should retain the existing scratch allocation"
3698 );
3699 assert_eq!(
3700 wal.frame_scratch_ptr(),
3701 scratch_ptr,
3702 "smaller follow-on batches should reuse the same backing buffer"
3703 );
3704 }
3705
3706 #[test]
3707 fn test_reset_clears_frame_scratch_len_without_dropping_capacity() {
3708 let cx = test_cx();
3709 let vfs = MemoryVfs::new();
3710 let file = open_wal_file(&vfs, &cx);
3711 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
3712 let reset_salts = WalSalts {
3713 salt1: 0xABCD_EF01,
3714 salt2: 0x1020_3040,
3715 };
3716
3717 let pages: Vec<Vec<u8>> = (0..4u8).map(sample_page).collect();
3718 let frames: Vec<_> = (0..4)
3719 .map(|i| WalAppendFrameRef {
3720 page_number: u32::try_from(i).expect("index fits u32") + 1,
3721 page_data: &pages[i],
3722 db_size_if_commit: if i == 3 { 4 } else { 0 },
3723 })
3724 .collect();
3725 wal.append_frames(&cx, &frames).expect("append batch");
3726 let scratch_capacity = wal.frame_scratch_capacity();
3727 let scratch_ptr = wal.frame_scratch_ptr();
3728
3729 wal.reset(&cx, 1, reset_salts, false).expect("reset WAL");
3730
3731 assert_eq!(
3732 wal.frame_scratch_len(),
3733 0,
3734 "reset should leave scratch empty for the next append cycle"
3735 );
3736 assert_eq!(
3737 wal.frame_scratch_capacity(),
3738 scratch_capacity,
3739 "reset should preserve scratch capacity for reuse"
3740 );
3741 assert_eq!(
3742 wal.frame_scratch_ptr(),
3743 scratch_ptr,
3744 "reset should keep the existing scratch allocation alive"
3745 );
3746 }
3747
3748 #[test]
3749 #[ignore = "benchmark evidence only"]
3750 fn wal_frame_scratch_benchmark_report() {
3751 let cases = vec![
3752 track_c_scratch_case_report(
3753 "single_frame_256_ops",
3754 1,
3755 256,
3756 || {
3757 track_c_measure_single_frame_case(
3758 TrackCScratchBenchMode::FreshAllocBaseline,
3759 256,
3760 )
3761 },
3762 || {
3763 track_c_measure_single_frame_case(
3764 TrackCScratchBenchMode::ScratchReuseCandidate,
3765 256,
3766 )
3767 },
3768 ),
3769 track_c_scratch_case_report(
3770 "batch_8_frames_64_ops",
3771 8,
3772 64,
3773 || track_c_measure_batch_case::<8>(TrackCScratchBenchMode::FreshAllocBaseline, 64),
3774 || {
3775 track_c_measure_batch_case::<8>(
3776 TrackCScratchBenchMode::ScratchReuseCandidate,
3777 64,
3778 )
3779 },
3780 ),
3781 track_c_scratch_case_report(
3782 "batch_32_frames_16_ops",
3783 32,
3784 16,
3785 || track_c_measure_batch_case::<32>(TrackCScratchBenchMode::FreshAllocBaseline, 16),
3786 || {
3787 track_c_measure_batch_case::<32>(
3788 TrackCScratchBenchMode::ScratchReuseCandidate,
3789 16,
3790 )
3791 },
3792 ),
3793 ];
3794
3795 let report = json!({
3796 "schema_version": "fsqlite.track_c.wal_scratch_benchmark.v1",
3797 "bead_id": TRACK_C_SCRATCH_BENCH_BEAD_ID,
3798 "parent_bead_id": "bd-db300.3.4",
3799 "measured_operation": "wal_frame_assembly_and_append",
3800 "warmup_iterations": TRACK_C_SCRATCH_BENCH_WARMUP_ITERS,
3801 "measurement_iterations": TRACK_C_SCRATCH_BENCH_MEASURE_ITERS,
3802 "vfs": "memory",
3803 "baseline_variant": "fresh_frame_buffer_per_operation",
3804 "candidate_variant": "reusable_wal_handle_frame_scratch",
3805 "cases": cases,
3806 });
3807
3808 println!("BEGIN_BD_DB300_3_4_3_REPORT");
3809 println!("{}", serde_json::to_string_pretty(&report).unwrap());
3810 println!("END_BD_DB300_3_4_3_REPORT");
3811 }
3812
3813 #[test]
3814 fn test_prepared_batch_append_checksum_chain_matches_single_append() {
3815 let cx = test_cx();
3816 let vfs_single = MemoryVfs::new();
3817 let vfs_prepared = MemoryVfs::new();
3818 let page_size = usize::try_from(PAGE_SIZE).expect("page size fits usize");
3819
3820 let pages: Vec<Vec<u8>> = (0..6u8).map(sample_page).collect();
3821 let page_nums: Vec<u32> = (1..=6u32).collect();
3822 let commit_sizes: Vec<u32> = vec![0, 0, 3, 0, 0, 6];
3823
3824 let file_single = open_wal_file(&vfs_single, &cx);
3825 let mut wal_single =
3826 WalFile::create(&cx, file_single, PAGE_SIZE, 0, test_salts()).expect("create single");
3827 for i in 0..6 {
3828 wal_single
3829 .append_frame(&cx, page_nums[i], &pages[i], commit_sizes[i])
3830 .expect("append single");
3831 }
3832
3833 let file_prepared = open_wal_file(&vfs_prepared, &cx);
3834 let mut wal_prepared = WalFile::create(&cx, file_prepared, PAGE_SIZE, 0, test_salts())
3835 .expect("create prepared");
3836 let frames: Vec<_> = (0..6)
3837 .map(|i| WalAppendFrameRef {
3838 page_number: page_nums[i],
3839 page_data: &pages[i],
3840 db_size_if_commit: commit_sizes[i],
3841 })
3842 .collect();
3843 let mut prepared_bytes = wal_prepared
3844 .prepare_frame_bytes(&frames)
3845 .expect("prepare frame bytes");
3846 let frame_transforms = prepared_bytes
3847 .chunks_exact(wal_prepared.frame_size())
3848 .map(|frame| {
3849 WalChecksumTransform::for_wal_frame(
3850 frame,
3851 page_size,
3852 wal_prepared.big_endian_checksum(),
3853 )
3854 })
3855 .collect::<Result<Vec<_>>>()
3856 .expect("compute frame transforms");
3857 wal_prepared
3858 .append_prepared_frame_bytes(&cx, &mut prepared_bytes, &frame_transforms)
3859 .expect("append prepared batch");
3860
3861 assert_eq!(
3862 wal_single.frame_count(),
3863 wal_prepared.frame_count(),
3864 "prepared append must preserve frame count"
3865 );
3866 assert_eq!(
3867 wal_single.running_checksum(),
3868 wal_prepared.running_checksum(),
3869 "prepared append must preserve checksum chain"
3870 );
3871
3872 for i in 0..6 {
3873 let (single_header, single_data) = wal_single.read_frame(&cx, i).expect("read single");
3874 let (prepared_header, prepared_data) =
3875 wal_prepared.read_frame(&cx, i).expect("read prepared");
3876 assert_eq!(
3877 single_header, prepared_header,
3878 "frame header {i} must match"
3879 );
3880 assert_eq!(single_data, prepared_data, "frame payload {i} must match");
3881 }
3882 }
3883
3884 #[test]
3885 fn test_prepared_batch_reseeds_after_intervening_growth() {
3886 let cx = test_cx();
3887 let vfs_single = MemoryVfs::new();
3888 let vfs_prepared = MemoryVfs::new();
3889 let page_size = usize::try_from(PAGE_SIZE).expect("page size fits usize");
3890
3891 let pages: Vec<Vec<u8>> = (0..3u8).map(sample_page).collect();
3892 let page_nums: Vec<u32> = (1..=3u32).collect();
3893 let commit_sizes: Vec<u32> = vec![0, 0, 3];
3894 let intervening_page = sample_page(0xAA);
3895
3896 let file_single = open_wal_file(&vfs_single, &cx);
3897 let mut wal_single =
3898 WalFile::create(&cx, file_single, PAGE_SIZE, 0, test_salts()).expect("create single");
3899 wal_single
3900 .append_frame(&cx, 99, &intervening_page, 0)
3901 .expect("append intervening single");
3902 for i in 0..3 {
3903 wal_single
3904 .append_frame(&cx, page_nums[i], &pages[i], commit_sizes[i])
3905 .expect("append single");
3906 }
3907
3908 let file_prepared = open_wal_file(&vfs_prepared, &cx);
3909 let mut wal_prepared = WalFile::create(&cx, file_prepared, PAGE_SIZE, 0, test_salts())
3910 .expect("create prepared");
3911 let frames: Vec<_> = (0..3)
3912 .map(|i| WalAppendFrameRef {
3913 page_number: page_nums[i],
3914 page_data: &pages[i],
3915 db_size_if_commit: commit_sizes[i],
3916 })
3917 .collect();
3918 let mut prepared_bytes = wal_prepared
3919 .prepare_frame_bytes(&frames)
3920 .expect("prepare frame bytes");
3921 let frame_transforms = prepared_bytes
3922 .chunks_exact(wal_prepared.frame_size())
3923 .map(|frame| {
3924 WalChecksumTransform::for_wal_frame(
3925 frame,
3926 page_size,
3927 wal_prepared.big_endian_checksum(),
3928 )
3929 })
3930 .collect::<Result<Vec<_>>>()
3931 .expect("compute frame transforms");
3932 wal_prepared
3933 .append_frame(&cx, 99, &intervening_page, 0)
3934 .expect("append intervening prepared");
3935 wal_prepared
3936 .append_prepared_frame_bytes(&cx, &mut prepared_bytes, &frame_transforms)
3937 .expect("append prepared batch");
3938
3939 assert_eq!(
3940 wal_single.frame_count(),
3941 wal_prepared.frame_count(),
3942 "prepared append after growth must preserve frame count"
3943 );
3944 assert_eq!(
3945 wal_single.running_checksum(),
3946 wal_prepared.running_checksum(),
3947 "prepared append after growth must rebind to the live checksum seed"
3948 );
3949
3950 for i in 0..wal_single.frame_count() {
3951 let (single_header, single_data) = wal_single.read_frame(&cx, i).expect("read single");
3952 let (prepared_header, prepared_data) =
3953 wal_prepared.read_frame(&cx, i).expect("read prepared");
3954 assert_eq!(
3955 single_header, prepared_header,
3956 "frame header {i} must match"
3957 );
3958 assert_eq!(single_data, prepared_data, "frame payload {i} must match");
3959 }
3960 }
3961
3962 #[test]
3963 fn test_prepared_batch_rewrites_salts_after_reset() {
3964 let cx = test_cx();
3965 let vfs_fresh = MemoryVfs::new();
3966 let vfs_reset = MemoryVfs::new();
3967 let page_size = usize::try_from(PAGE_SIZE).expect("page size fits usize");
3968 let reset_salts = WalSalts {
3969 salt1: 0x0102_0304,
3970 salt2: 0xA0B0_C0D0,
3971 };
3972
3973 let pages: Vec<Vec<u8>> = (0..2u8).map(sample_page).collect();
3974 let page_nums: Vec<u32> = (1..=2u32).collect();
3975 let commit_sizes: Vec<u32> = vec![0, 2];
3976
3977 let file_fresh = open_wal_file(&vfs_fresh, &cx);
3978 let mut wal_fresh =
3979 WalFile::create(&cx, file_fresh, PAGE_SIZE, 7, reset_salts).expect("create fresh");
3980 for i in 0..2 {
3981 wal_fresh
3982 .append_frame(&cx, page_nums[i], &pages[i], commit_sizes[i])
3983 .expect("append fresh");
3984 }
3985
3986 let file_reset = open_wal_file(&vfs_reset, &cx);
3987 let mut wal_reset =
3988 WalFile::create(&cx, file_reset, PAGE_SIZE, 0, test_salts()).expect("create reset");
3989 let frames: Vec<_> = (0..2)
3990 .map(|i| WalAppendFrameRef {
3991 page_number: page_nums[i],
3992 page_data: &pages[i],
3993 db_size_if_commit: commit_sizes[i],
3994 })
3995 .collect();
3996 let mut prepared_bytes = wal_reset
3997 .prepare_frame_bytes(&frames)
3998 .expect("prepare frame bytes");
3999 let frame_transforms = prepared_bytes
4000 .chunks_exact(wal_reset.frame_size())
4001 .map(|frame| {
4002 WalChecksumTransform::for_wal_frame(
4003 frame,
4004 page_size,
4005 wal_reset.big_endian_checksum(),
4006 )
4007 })
4008 .collect::<Result<Vec<_>>>()
4009 .expect("compute frame transforms");
4010 wal_reset
4011 .reset(&cx, 7, reset_salts, false)
4012 .expect("reset WAL");
4013 wal_reset
4014 .append_prepared_frame_bytes(&cx, &mut prepared_bytes, &frame_transforms)
4015 .expect("append prepared batch");
4016
4017 assert_eq!(
4018 wal_fresh.frame_count(),
4019 wal_reset.frame_count(),
4020 "prepared append after reset must preserve frame count"
4021 );
4022 assert_eq!(
4023 wal_fresh.running_checksum(),
4024 wal_reset.running_checksum(),
4025 "prepared append after reset must rebind to the reset checksum seed"
4026 );
4027
4028 for i in 0..2 {
4029 let (fresh_header, fresh_data) = wal_fresh.read_frame(&cx, i).expect("read fresh");
4030 let (reset_header, reset_data) = wal_reset.read_frame(&cx, i).expect("read reset");
4031 assert_eq!(fresh_header, reset_header, "frame header {i} must match");
4032 assert_eq!(
4033 fresh_header.salts, reset_salts,
4034 "frame {i} must use reset salts"
4035 );
4036 assert_eq!(fresh_data, reset_data, "frame payload {i} must match");
4037 }
4038 }
4039
4040 #[test]
4041 fn test_uncommitted_tail_trimmed_on_reopen() {
4042 let cx = test_cx();
4045 let vfs = MemoryVfs::new();
4046 let file = open_wal_file(&vfs, &cx);
4047 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4048
4049 let commit_map: [(u32, u32); 5] = [(1, 0), (2, 0), (3, 3), (4, 0), (5, 0)];
4050 for (pg, db_sz) in commit_map {
4051 wal.append_frame(&cx, pg, &sample_page(u8::try_from(pg).unwrap()), db_sz)
4052 .expect("append");
4053 }
4054 assert_eq!(wal.frame_count(), 5);
4055 wal.close(&cx).expect("close");
4056
4057 let file2 = open_wal_file(&vfs, &cx);
4059 let wal2 = WalFile::open(&cx, file2).expect("reopen");
4060 assert_eq!(
4061 wal2.frame_count(),
4062 3,
4063 "frames after last commit should be trimmed on reopen"
4064 );
4065 wal2.close(&cx).expect("close");
4066 }
4067
4068 #[test]
4069 fn test_large_transaction_50_frames() {
4070 let cx = test_cx();
4072 let vfs = MemoryVfs::new();
4073 let file = open_wal_file(&vfs, &cx);
4074 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4075
4076 let n = 50u32;
4077 for i in 0..n {
4078 let db_size = if i == n - 1 { n } else { 0 };
4079 let seed = u8::try_from(i % 251).unwrap();
4080 wal.append_frame(&cx, i + 1, &sample_page(seed), db_size)
4081 .expect("append");
4082 }
4083 assert_eq!(wal.frame_count(), usize::try_from(n).unwrap());
4084 let final_checksum = wal.running_checksum();
4085 wal.close(&cx).expect("close");
4086
4087 let file2 = open_wal_file(&vfs, &cx);
4089 let wal2 = WalFile::open(&cx, file2).expect("reopen");
4090 assert_eq!(wal2.frame_count(), usize::try_from(n).unwrap());
4091 assert_eq!(wal2.running_checksum(), final_checksum);
4092
4093 for idx in [0, 24, 49] {
4095 let (h, d) = wal2.read_frame(&cx, idx).expect("read");
4096 let i = u32::try_from(idx).unwrap();
4097 assert_eq!(h.page_number, i + 1);
4098 assert_eq!(d, sample_page(u8::try_from(i % 251).unwrap()));
4099 }
4100
4101 wal2.close(&cx).expect("close");
4102 }
4103
4104 #[test]
4105 fn test_append_after_reset_checksum_independent() {
4106 let cx = test_cx();
4110
4111 let salts = WalSalts {
4112 salt1: 0x1234_5678,
4113 salt2: 0x9ABC_DEF0,
4114 };
4115
4116 let vfs1 = MemoryVfs::new();
4118 let file1 = open_wal_file(&vfs1, &cx);
4119 let mut wal_fresh = WalFile::create(&cx, file1, PAGE_SIZE, 1, salts).expect("create fresh");
4120 wal_fresh
4121 .append_frame(&cx, 1, &sample_page(0x42), 1)
4122 .expect("append fresh");
4123 let fresh_checksum = wal_fresh.running_checksum();
4124 wal_fresh.close(&cx).expect("close fresh");
4125
4126 let vfs2 = MemoryVfs::new();
4128 let file2 = open_wal_file(&vfs2, &cx);
4129 let mut wal_reset =
4130 WalFile::create(&cx, file2, PAGE_SIZE, 0, test_salts()).expect("create reset");
4131 wal_reset
4133 .append_frame(&cx, 99, &sample_page(0xFF), 99)
4134 .expect("append old");
4135 wal_reset.reset(&cx, 1, salts, false).expect("reset");
4137 wal_reset
4138 .append_frame(&cx, 1, &sample_page(0x42), 1)
4139 .expect("append after reset");
4140 let reset_checksum = wal_reset.running_checksum();
4141 wal_reset.close(&cx).expect("close reset");
4142
4143 assert_eq!(
4144 fresh_checksum, reset_checksum,
4145 "after reset with same salts, checksum chain must match fresh WAL"
4146 );
4147 }
4148
4149 fn build_two_txn_wal() -> (MemoryVfs, Vec<Vec<u8>>) {
4157 let cx = test_cx();
4158 let vfs = MemoryVfs::new();
4159 let file = open_wal_file(&vfs, &cx);
4160 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4161
4162 let mut pages = Vec::new();
4163 let frame_specs: [(u32, u32); 6] = [
4164 (1, 0),
4165 (2, 0),
4166 (3, 3), (4, 0),
4168 (5, 0),
4169 (6, 6), ];
4171 for (pg, db_sz) in frame_specs {
4172 let page = sample_page(u8::try_from(pg).unwrap());
4173 wal.append_frame(&cx, pg, &page, db_sz).expect("append");
4174 pages.push(page);
4175 }
4176 wal.close(&cx).expect("close");
4177 (vfs, pages)
4178 }
4179
4180 #[test]
4181 fn test_crash_matrix_truncate_at_every_frame_boundary() {
4182 let cx = test_cx();
4185 let frame_size = WAL_FRAME_HEADER_SIZE + usize::try_from(PAGE_SIZE).unwrap();
4186
4187 for cut_frames in 0..=6usize {
4188 let (vfs, _) = build_two_txn_wal();
4190
4191 let cut_at = WAL_HEADER_SIZE + cut_frames * frame_size;
4192 let mut f = open_wal_file(&vfs, &cx);
4193 f.truncate(&cx, u64::try_from(cut_at).unwrap())
4194 .expect("truncate");
4195 drop(f);
4196
4197 let f2 = open_wal_file(&vfs, &cx);
4198 let wal = WalFile::open(&cx, f2).expect("open after truncation");
4199 let expected = match cut_frames {
4200 0..=2 => 0, 3..=5 => 3, 6 => 6, _ => unreachable!(),
4204 };
4205 assert_eq!(
4206 wal.frame_count(),
4207 expected,
4208 "truncated at {cut_frames} frames should give {expected} committed"
4209 );
4210 wal.close(&cx).expect("close");
4211 }
4212
4213 for partial in 0..20usize {
4215 let (vfs, _) = build_two_txn_wal();
4216 let cx = test_cx();
4217
4218 let cut_byte = WAL_HEADER_SIZE + partial * frame_size / 3;
4219 let mut f = open_wal_file(&vfs, &cx);
4220 f.truncate(&cx, u64::try_from(cut_byte).unwrap())
4221 .expect("truncate");
4222 drop(f);
4223
4224 let f2 = open_wal_file(&vfs, &cx);
4225 let wal = WalFile::open(&cx, f2).expect("open");
4226 assert!(
4228 wal.frame_count() == 0 || wal.frame_count() == 3 || wal.frame_count() == 6,
4229 "cut_byte={cut_byte} gave frame_count={}, expected 0/3/6",
4230 wal.frame_count()
4231 );
4232 wal.close(&cx).expect("close");
4233 }
4234 }
4235
4236 #[test]
4237 fn test_crash_matrix_bit_flip_at_every_frame() {
4238 for target_frame in 0..6usize {
4241 let (vfs, _) = build_two_txn_wal();
4242 let cx = test_cx();
4243
4244 let frame_size = WAL_FRAME_HEADER_SIZE + usize::try_from(PAGE_SIZE).unwrap();
4245 let corrupt_offset =
4246 WAL_HEADER_SIZE + target_frame * frame_size + WAL_FRAME_HEADER_SIZE + 42;
4247
4248 let f = open_wal_file(&vfs, &cx);
4250 let mut buf = [0u8; 1];
4251 let off = u64::try_from(corrupt_offset).unwrap();
4252 f.read(&cx, &mut buf, off).expect("read");
4253 buf[0] ^= 0xFF;
4254 f.write(&cx, &buf, off).expect("write corrupt");
4255 drop(f);
4256
4257 let f2 = open_wal_file(&vfs, &cx);
4258 let wal = WalFile::open(&cx, f2).expect("open");
4259 let expected = if target_frame < 3 {
4260 0 } else {
4262 3 };
4264 assert_eq!(
4265 wal.frame_count(),
4266 expected,
4267 "bit flip in frame {target_frame} should give {expected}"
4268 );
4269 wal.close(&cx).expect("close");
4270 }
4271 }
4272
4273 #[test]
4274 fn test_crash_matrix_continue_after_recovery() {
4275 let (vfs, _) = build_two_txn_wal();
4278 let cx = test_cx();
4279
4280 let frame_size = WAL_FRAME_HEADER_SIZE + usize::try_from(PAGE_SIZE).unwrap();
4281
4282 let corrupt_offset = WAL_HEADER_SIZE + 4 * frame_size + WAL_FRAME_HEADER_SIZE + 10;
4284 let f = open_wal_file(&vfs, &cx);
4285 let mut buf = [0u8; 1];
4286 let off = u64::try_from(corrupt_offset).unwrap();
4287 f.read(&cx, &mut buf, off).expect("read");
4288 buf[0] ^= 0xAA;
4289 f.write(&cx, &buf, off).expect("write corrupt");
4290 drop(f);
4291
4292 let f2 = open_wal_file(&vfs, &cx);
4294 let mut wal = WalFile::open(&cx, f2).expect("open");
4295 assert_eq!(wal.frame_count(), 3);
4296
4297 wal.append_frame(&cx, 10, &sample_page(0xAA), 0)
4299 .expect("append");
4300 wal.append_frame(&cx, 11, &sample_page(0xBB), 5)
4301 .expect("append commit");
4302 assert_eq!(wal.frame_count(), 5);
4303 let checksum_after = wal.running_checksum();
4304 wal.close(&cx).expect("close");
4305
4306 let f3 = open_wal_file(&vfs, &cx);
4308 let wal2 = WalFile::open(&cx, f3).expect("reopen");
4309 assert_eq!(wal2.frame_count(), 5);
4310 assert_eq!(wal2.running_checksum(), checksum_after);
4311
4312 for i in 0..3 {
4314 let (h, d) = wal2.read_frame(&cx, i).expect("read");
4315 let pg = u32::try_from(i + 1).unwrap();
4316 assert_eq!(h.page_number, pg);
4317 assert_eq!(d, sample_page(u8::try_from(pg).unwrap()));
4318 }
4319
4320 let (h4, d4) = wal2.read_frame(&cx, 3).expect("read new frame 4");
4322 assert_eq!(h4.page_number, 10);
4323 assert_eq!(d4, sample_page(0xAA));
4324
4325 wal2.close(&cx).expect("close");
4326 }
4327
4328 #[test]
4329 fn test_crash_matrix_zero_length_wal() {
4330 let cx = test_cx();
4332 let vfs = MemoryVfs::new();
4333 let file = open_wal_file(&vfs, &cx);
4334 let wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4335 wal.close(&cx).expect("close");
4336
4337 let f2 = open_wal_file(&vfs, &cx);
4338 let wal2 = WalFile::open(&cx, f2).expect("open");
4339 assert_eq!(wal2.frame_count(), 0);
4340 wal2.close(&cx).expect("close");
4341 }
4342
4343 #[test]
4344 fn test_crash_matrix_header_only_partial_first_frame() {
4345 let cx = test_cx();
4347 let vfs = MemoryVfs::new();
4348 let file = open_wal_file(&vfs, &cx);
4349
4350 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4351 wal.append_frame(&cx, 1, &sample_page(1), 1)
4352 .expect("append");
4353 let partial_size = WAL_HEADER_SIZE + WAL_FRAME_HEADER_SIZE + 10;
4354 wal.file_mut()
4355 .truncate(&cx, u64::try_from(partial_size).unwrap())
4356 .expect("truncate");
4357 wal.close(&cx).expect("close");
4358
4359 let f2 = open_wal_file(&vfs, &cx);
4360 let wal2 = WalFile::open(&cx, f2).expect("open");
4361 assert_eq!(wal2.frame_count(), 0, "partial frame should be dropped");
4362 wal2.close(&cx).expect("close");
4363 }
4364
4365 #[test]
4366 fn test_crash_matrix_many_txns_deterministic_recovery() {
4367 let cx = test_cx();
4370
4371 for crash_txn in 0..=10usize {
4372 let vfs = MemoryVfs::new();
4373 let file = open_wal_file(&vfs, &cx);
4374 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4375
4376 let total_frames = crash_txn * 3;
4377 for txn in 0..crash_txn {
4378 for f in 0..3u32 {
4379 let pg = u32::try_from(txn * 3).unwrap() + f + 1;
4380 let db_size = if f == 2 {
4381 u32::try_from(txn * 3 + 3).unwrap()
4382 } else {
4383 0
4384 };
4385 let seed = u8::try_from(pg % 251).unwrap();
4386 wal.append_frame(&cx, pg, &sample_page(seed), db_size)
4387 .expect("append");
4388 }
4389 }
4390 assert_eq!(wal.frame_count(), total_frames);
4391 wal.close(&cx).expect("close");
4392
4393 let f2 = open_wal_file(&vfs, &cx);
4395 let wal2 = WalFile::open(&cx, f2).expect("open");
4396 assert_eq!(
4397 wal2.frame_count(),
4398 total_frames,
4399 "crash_txn={crash_txn}: all {total_frames} committed frames should survive"
4400 );
4401 wal2.close(&cx).expect("close");
4402
4403 if crash_txn < 10 {
4405 let f3 = open_wal_file(&vfs, &cx);
4407 let mut wal3 = WalFile::open(&cx, f3).expect("open");
4408 let extra_pg = u32::try_from(total_frames + 1).unwrap();
4409 wal3.append_frame(
4410 &cx,
4411 extra_pg,
4412 &sample_page(u8::try_from(extra_pg % 251).unwrap()),
4413 0,
4414 )
4415 .expect("append uncommitted");
4416 wal3.close(&cx).expect("close");
4417
4418 let f4 = open_wal_file(&vfs, &cx);
4420 let wal4 = WalFile::open(&cx, f4).expect("open");
4421 assert_eq!(
4422 wal4.frame_count(),
4423 total_frames,
4424 "crash_txn={crash_txn}: uncommitted extra frame dropped"
4425 );
4426 wal4.close(&cx).expect("close");
4427 }
4428 }
4429 }
4430
4431 #[test]
4432 fn test_crash_matrix_reset_then_crash() {
4433 let cx = test_cx();
4435 let vfs = MemoryVfs::new();
4436 let file = open_wal_file(&vfs, &cx);
4437 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4438
4439 wal.append_frame(&cx, 1, &sample_page(1), 1)
4441 .expect("append");
4442 let new_salts = WalSalts {
4444 salt1: 0x5555_6666,
4445 salt2: 0x7777_8888,
4446 };
4447 wal.reset(&cx, 1, new_salts, true).expect("reset");
4448 assert_eq!(wal.frame_count(), 0);
4449
4450 wal.append_frame(&cx, 1, &sample_page(0xCC), 0)
4452 .expect("append");
4453 wal.append_frame(&cx, 2, &sample_page(0xDD), 0)
4454 .expect("append");
4455 wal.close(&cx).expect("close");
4456
4457 let f2 = open_wal_file(&vfs, &cx);
4459 let wal2 = WalFile::open(&cx, f2).expect("open");
4460 assert_eq!(wal2.frame_count(), 0, "no commits after reset");
4461 assert_eq!(wal2.header().salts, new_salts);
4462 wal2.close(&cx).expect("close");
4463 }
4464
4465 #[test]
4466 fn test_truncated_file_mid_second_txn_recovers_first_commit() {
4467 let cx = test_cx();
4468 let vfs = MemoryVfs::new();
4469 let file = open_wal_file(&vfs, &cx);
4470
4471 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4472
4473 wal.append_frame(&cx, 1, &sample_page(0x11), 0)
4475 .expect("append");
4476 wal.append_frame(&cx, 2, &sample_page(0x22), 2)
4477 .expect("commit txn1");
4478
4479 wal.append_frame(&cx, 3, &sample_page(0x33), 0)
4481 .expect("append");
4482 wal.append_frame(&cx, 4, &sample_page(0x44), 0)
4483 .expect("append");
4484 assert_eq!(wal.frame_count(), 4);
4485 let frame_size = wal.frame_size();
4486 wal.close(&cx).expect("close");
4487
4488 let truncate_offset =
4490 u64::try_from(WAL_HEADER_SIZE + frame_size * 2 + frame_size / 2).unwrap();
4491 let mut f = open_wal_file(&vfs, &cx);
4492 f.truncate(&cx, truncate_offset).expect("truncate");
4493 drop(f);
4494
4495 let file2 = open_wal_file(&vfs, &cx);
4496 let wal2 = WalFile::open(&cx, file2).expect("reopen after truncation");
4497 assert_eq!(
4498 wal2.frame_count(),
4499 2,
4500 "only first committed transaction (2 frames) should survive truncation"
4501 );
4502 let h = wal2.read_frame_header(&cx, 1).expect("read frame 2 header");
4503 assert!(h.is_commit(), "frame 2 must be a commit frame");
4504 wal2.close(&cx).expect("close");
4505 }
4506
4507 #[test]
4508 fn test_recovery_is_idempotent_across_multiple_reopens() {
4509 let cx = test_cx();
4510 let vfs = MemoryVfs::new();
4511 let file = open_wal_file(&vfs, &cx);
4512
4513 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4514
4515 wal.append_frame(&cx, 1, &sample_page(0xAA), 0)
4517 .expect("append");
4518 wal.append_frame(&cx, 2, &sample_page(0xBB), 2)
4519 .expect("commit txn1");
4520 wal.append_frame(&cx, 3, &sample_page(0xCC), 0)
4521 .expect("append");
4522 wal.append_frame(&cx, 4, &sample_page(0xDD), 4)
4523 .expect("commit txn2");
4524 wal.append_frame(&cx, 5, &sample_page(0xEE), 0)
4526 .expect("append");
4527 wal.close(&cx).expect("close");
4528
4529 for reopen in 0..3_u32 {
4531 let f = open_wal_file(&vfs, &cx);
4532 let wal_reopened = WalFile::open(&cx, f).expect("reopen");
4533 assert_eq!(
4534 wal_reopened.frame_count(),
4535 4,
4536 "reopen {reopen}: committed frame count must be 4"
4537 );
4538 let (_, data) = wal_reopened.read_frame(&cx, 3).expect("read frame 4");
4539 assert_eq!(
4540 data,
4541 sample_page(0xDD),
4542 "reopen {reopen}: frame 4 content must be intact"
4543 );
4544 wal_reopened.close(&cx).expect("close");
4545 }
4546 }
4547
4548 #[test]
4549 fn wal_generation_identity_from_header_and_eq() {
4550 let header = WalHeader {
4551 magic: WAL_MAGIC_LE,
4552 format_version: WAL_FORMAT_VERSION,
4553 page_size: PAGE_SIZE,
4554 checkpoint_seq: 7,
4555 salts: test_salts(),
4556 checksum: SqliteWalChecksum { s1: 0, s2: 0 },
4557 };
4558 let identity = WalGenerationIdentity::from_header(&header);
4559 assert_eq!(identity.checkpoint_seq, 7);
4560 assert_eq!(identity.salts, test_salts());
4561 let copied = identity;
4562 assert_eq!(copied, identity);
4563 let other = WalGenerationIdentity {
4564 checkpoint_seq: 8,
4565 salts: test_salts(),
4566 };
4567 assert_ne!(identity, other);
4568 let dbg = format!("{identity:?}");
4569 assert!(dbg.contains("WalGenerationIdentity"));
4570 }
4571
4572 #[test]
4573 fn wal_append_frame_ref_debug_clone_copy() {
4574 let data = [0xABu8; 16];
4575 let frame = WalAppendFrameRef {
4576 page_number: 3,
4577 page_data: &data,
4578 db_size_if_commit: 10,
4579 };
4580 let copied = frame;
4581 assert_eq!(copied.page_number, 3);
4582 assert_eq!(copied.db_size_if_commit, 10);
4583 assert_eq!(copied.page_data[0], 0xAB);
4584 let cloned = frame;
4585 assert_eq!(cloned.page_number, frame.page_number);
4586 let dbg = format!("{frame:?}");
4587 assert!(dbg.contains("WalAppendFrameRef"));
4588 }
4589
4590 #[test]
4591 fn wal_file_generation_identity_matches_create_params() {
4592 let cx = test_cx();
4593 let vfs = MemoryVfs::new();
4594 let file = open_wal_file(&vfs, &cx);
4595 let wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4596 let identity = wal.generation_identity();
4597 assert_eq!(identity.checkpoint_seq, 0);
4598 assert_eq!(identity.salts, test_salts());
4599 wal.close(&cx).expect("close");
4600 }
4601
4602 #[test]
4603 fn wal_file_page_size_and_frame_count_after_create() {
4604 let cx = test_cx();
4605 let vfs = MemoryVfs::new();
4606 let file = open_wal_file(&vfs, &cx);
4607 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4608 assert_eq!(wal.page_size(), PAGE_SIZE as usize);
4609 assert_eq!(wal.frame_count(), 0);
4610 assert!(wal.last_commit_frame(&cx).expect("query").is_none());
4611 wal.append_frame(&cx, 1, &sample_page(1), 5)
4612 .expect("append");
4613 assert_eq!(wal.frame_count(), 1);
4614 assert_eq!(wal.last_commit_frame(&cx).expect("query"), Some(0));
4615 wal.close(&cx).expect("close");
4616 }
4617
4618 #[test]
4619 fn wal_file_big_endian_checksum_and_running_checksum() {
4620 let cx = test_cx();
4621 let vfs = MemoryVfs::new();
4622 let file = open_wal_file(&vfs, &cx);
4623 let wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4624 let _be = wal.big_endian_checksum();
4625 let rc = wal.running_checksum();
4626 assert_eq!(rc.s1.wrapping_add(0), rc.s1);
4627 wal.close(&cx).expect("close");
4628 }
4629
4630 #[test]
4631 fn wal_file_frame_size_equals_header_plus_page() {
4632 let cx = test_cx();
4633 let vfs = MemoryVfs::new();
4634 let file = open_wal_file(&vfs, &cx);
4635 let wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4636 assert_eq!(wal.frame_size(), WAL_FRAME_HEADER_SIZE + PAGE_SIZE as usize);
4637 wal.close(&cx).expect("close");
4638 }
4639
4640 #[test]
4641 fn wal_file_header_accessor() {
4642 let cx = test_cx();
4643 let vfs = MemoryVfs::new();
4644 let file = open_wal_file(&vfs, &cx);
4645 let wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4646 let hdr = wal.header();
4647 assert_eq!(hdr.page_size, PAGE_SIZE);
4648 assert_eq!(hdr.salts, test_salts());
4649 wal.close(&cx).expect("close");
4650 }
4651
4652 #[test]
4653 fn wal_file_file_and_file_mut_accessors() {
4654 let cx = test_cx();
4655 let vfs = MemoryVfs::new();
4656 let file = open_wal_file(&vfs, &cx);
4657 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4658 let _f_ref = wal.file();
4659 let _f_mut = wal.file_mut();
4660 wal.close(&cx).expect("close");
4661 }
4662
4663 #[test]
4664 fn durable_sync_records_fsynced_frame_count() {
4665 let cx = test_cx();
4666 let vfs = MemoryVfs::new();
4667 let file = open_wal_file(&vfs, &cx);
4668 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4669 assert_eq!(wal.last_fsynced_frame_count(), 0);
4670
4671 let page = vec![0xABu8; PAGE_SIZE as usize];
4672 let frames = [frame_ref(1, &page, 1)];
4673 wal.append_frames(&cx, &frames).expect("append");
4674 assert_eq!(wal.frame_count(), 1);
4675 assert_eq!(wal.last_fsynced_frame_count(), 0);
4676
4677 wal.durable_sync(&cx, fsqlite_vfs::SyncKind::FullDurable)
4678 .expect("durable_sync");
4679 assert_eq!(wal.last_fsynced_frame_count(), 1);
4680 wal.close(&cx).expect("close");
4681 }
4682
4683 #[test]
4684 fn assert_publish_safe_passes_after_durable_sync() {
4685 let cx = test_cx();
4686 let vfs = MemoryVfs::new();
4687 let file = open_wal_file(&vfs, &cx);
4688 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4689
4690 let page = vec![0xCDu8; PAGE_SIZE as usize];
4691 let frames = [frame_ref(1, &page, 1)];
4692 wal.append_frames(&cx, &frames).expect("append");
4693 wal.durable_sync(&cx, fsqlite_vfs::SyncKind::FullDurable)
4694 .expect("durable_sync");
4695 wal.assert_publish_safe(1)
4696 .expect("should be safe after fsync");
4697 wal.close(&cx).expect("close");
4698 }
4699
4700 #[test]
4701 fn assert_publish_safe_passes_for_zero_frames() {
4702 let cx = test_cx();
4703 let vfs = MemoryVfs::new();
4704 let file = open_wal_file(&vfs, &cx);
4705 let wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4706 wal.assert_publish_safe(0).expect("zero frames always safe");
4707 wal.close(&cx).expect("close");
4708 }
4709
4710 #[test]
4711 fn durable_sync_with_data_only_kind() {
4712 let cx = test_cx();
4713 let vfs = MemoryVfs::new();
4714 let file = open_wal_file(&vfs, &cx);
4715 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4716
4717 let page = vec![0xEFu8; PAGE_SIZE as usize];
4718 let frames = [frame_ref(2, &page, 1)];
4719 wal.append_frames(&cx, &frames).expect("append");
4720 wal.durable_sync(&cx, fsqlite_vfs::SyncKind::DataOnly)
4721 .expect("data-only sync");
4722 assert_eq!(wal.last_fsynced_frame_count(), 1);
4723 wal.close(&cx).expect("close");
4724 }
4725
4726 #[test]
4727 fn raw_sync_reset_clears_fsynced_count() {
4728 let cx = test_cx();
4729 let vfs = MemoryVfs::new();
4730 let file = open_wal_file(&vfs, &cx);
4731 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4732
4733 let page = vec![0x11u8; PAGE_SIZE as usize];
4734 let frames = [frame_ref(1, &page, 1)];
4735 wal.append_frames(&cx, &frames).expect("append");
4736 wal.sync(&cx, SyncFlags::NORMAL).expect("sync");
4737 assert_eq!(wal.last_fsynced_frame_count(), 1);
4738
4739 let new_salts = WalSalts {
4740 salt1: 0x1111_1111,
4741 salt2: 0x2222_2222,
4742 };
4743 wal.reset(&cx, 1, new_salts, true).expect("reset");
4744 assert_eq!(wal.last_fsynced_frame_count(), 0);
4745 wal.close(&cx).expect("close");
4746 }
4747
4748 #[test]
4749 fn opened_wal_has_fsynced_count_equal_to_frame_count() {
4750 let cx = test_cx();
4751 let vfs = MemoryVfs::new();
4752 let file = open_wal_file(&vfs, &cx);
4753 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4754
4755 let page = vec![0x22u8; PAGE_SIZE as usize];
4756 let frames = [frame_ref(1, &page, 1)];
4757 wal.append_frames(&cx, &frames).expect("append");
4758 wal.durable_sync(&cx, fsqlite_vfs::SyncKind::FullDurable)
4759 .expect("durable_sync");
4760 wal.close(&cx).expect("close");
4761
4762 let file2 = open_wal_file(&vfs, &cx);
4763 let wal2 = WalFile::open(&cx, file2).expect("open");
4764 assert_eq!(wal2.frame_count(), 1);
4765 assert_eq!(
4766 wal2.last_fsynced_frame_count(),
4767 wal2.frame_count(),
4768 "opened WAL assumes existing frames are durable"
4769 );
4770 wal2.close(&cx).expect("close");
4771 }
4772
4773 #[test]
4774 fn crash_before_wal_frame_append_preserves_existing_frames() {
4775 let _guard = FAULT_TEST_LOCK.lock().unwrap();
4776 let cx = test_cx();
4777 let vfs = MemoryVfs::new();
4778 let file = open_wal_file(&vfs, &cx);
4779 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4780
4781 let p1 = sample_page(0xAA);
4782 let first_frames = [frame_ref(1, &p1, 1)];
4783 wal.append_frames(&cx, &first_frames).expect("first append");
4784 wal.durable_sync(&cx, SyncKind::FullDurable)
4785 .expect("sync first frame");
4786 assert_eq!(wal.frame_count(), 1);
4787
4788 crate::fault_hooks::arm_crash_boundary(
4789 crate::fault_hooks::CrashBoundary::BeforeWalFrameAppend,
4790 crate::fault_hooks::FaultHookArm::new(
4791 "crash-before-append",
4792 "WAL-FRAME-APPEND-CRASH",
4793 "test_crash_before_append",
4794 ),
4795 );
4796
4797 let p2 = sample_page(0xBB);
4798 let second_frames = [frame_ref(2, &p2, 2)];
4799 let err = wal
4800 .append_frames(&cx, &second_frames)
4801 .expect_err("should fail at crash boundary");
4802 assert!(
4803 err.to_string().contains("fault_inject"),
4804 "error identifies the fault hook: {err}"
4805 );
4806
4807 crate::fault_hooks::clear_crash_boundary();
4808
4809 wal.close(&cx).expect("close after crash");
4810
4811 let file2 = open_wal_file(&vfs, &cx);
4812 let recovered = WalFile::open(&cx, file2).expect("reopen");
4813 assert_eq!(
4814 recovered.frame_count(),
4815 1,
4816 "only the first committed frame survives; the second was never written"
4817 );
4818 recovered.close(&cx).expect("close recovered");
4819 }
4820
4821 #[test]
4822 fn crash_after_fsync_before_publish_leaves_frames_durable() {
4823 let _guard = FAULT_TEST_LOCK.lock().unwrap();
4824 let cx = test_cx();
4825 let vfs = MemoryVfs::new();
4826 let file = open_wal_file(&vfs, &cx);
4827 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4828
4829 let p1 = sample_page(0xCC);
4830 let frames = [frame_ref(1, &p1, 1)];
4831 wal.append_frames(&cx, &frames).expect("append");
4832
4833 crate::fault_hooks::arm_crash_boundary(
4834 crate::fault_hooks::CrashBoundary::AfterFsyncBeforePublish,
4835 crate::fault_hooks::FaultHookArm::new(
4836 "crash-after-fsync",
4837 "WAL-FSYNC-PUBLISH-CRASH",
4838 "test_crash_after_fsync",
4839 ),
4840 );
4841
4842 let err = wal
4843 .durable_sync(&cx, SyncKind::FullDurable)
4844 .expect_err("should fail at crash boundary");
4845 assert!(
4846 err.to_string().contains("fault_inject"),
4847 "error identifies the fault hook: {err}"
4848 );
4849
4850 crate::fault_hooks::clear_crash_boundary();
4851
4852 wal.close(&cx).expect("close after crash");
4853
4854 let file2 = open_wal_file(&vfs, &cx);
4855 let recovered = WalFile::open(&cx, file2).expect("reopen");
4856 assert_eq!(
4857 recovered.frame_count(),
4858 1,
4859 "frame was fsynced before crash so it survives recovery"
4860 );
4861 recovered.close(&cx).expect("close recovered");
4862 }
4863
4864 #[test]
4865 fn crash_before_wal_header_write_on_reset_preserves_old_generation() {
4866 let _guard = FAULT_TEST_LOCK.lock().unwrap();
4867 let cx = test_cx();
4868 let vfs = MemoryVfs::new();
4869 let file = open_wal_file(&vfs, &cx);
4870 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4871
4872 let p1 = sample_page(0xDD);
4873 let frames = [frame_ref(1, &p1, 1)];
4874 wal.append_frames(&cx, &frames).expect("append");
4875 wal.durable_sync(&cx, SyncKind::FullDurable).expect("sync");
4876 assert_eq!(wal.frame_count(), 1);
4877
4878 let original_salts = wal.generation_identity().salts;
4879
4880 crate::fault_hooks::arm_crash_boundary(
4881 crate::fault_hooks::CrashBoundary::BeforeWalHeaderWrite,
4882 crate::fault_hooks::FaultHookArm::new(
4883 "crash-before-header",
4884 "WAL-HEADER-WRITE-CRASH",
4885 "test_crash_before_header",
4886 ),
4887 );
4888
4889 let new_salts = WalSalts {
4890 salt1: original_salts.salt1.wrapping_add(1),
4891 salt2: original_salts.salt2.wrapping_add(1),
4892 };
4893 let err = wal
4894 .reset(&cx, 1, new_salts, true)
4895 .expect_err("should fail at crash boundary");
4896 assert!(
4897 err.to_string().contains("fault_inject"),
4898 "error identifies the fault hook: {err}"
4899 );
4900
4901 crate::fault_hooks::clear_crash_boundary();
4902
4903 wal.close(&cx).expect("close after crash");
4904
4905 let file2 = open_wal_file(&vfs, &cx);
4906 let recovered = WalFile::open(&cx, file2).expect("reopen");
4907 assert_eq!(
4908 recovered.frame_count(),
4909 1,
4910 "header was never rewritten so old generation with 1 frame persists"
4911 );
4912 assert_eq!(
4913 recovered.generation_identity().salts,
4914 original_salts,
4915 "salts unchanged — reset never wrote new header"
4916 );
4917 recovered.close(&cx).expect("close recovered");
4918 }
4919
4920 #[test]
4921 fn crash_after_frame_append_before_fsync_frames_on_disk_but_not_durable() {
4922 let _guard = FAULT_TEST_LOCK.lock().unwrap();
4923 let cx = test_cx();
4924 let vfs = MemoryVfs::new();
4925 let file = open_wal_file(&vfs, &cx);
4926 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4927
4928 crate::fault_hooks::arm_crash_boundary(
4929 crate::fault_hooks::CrashBoundary::AfterWalFrameAppendBeforeFsync,
4930 crate::fault_hooks::FaultHookArm::new(
4931 "crash-after-append",
4932 "WAL-APPEND-FSYNC-CRASH",
4933 "test_crash_after_append_before_fsync",
4934 ),
4935 );
4936
4937 let p1 = sample_page(0xEE);
4938 let frames = [frame_ref(1, &p1, 1)];
4939 let err = wal
4940 .append_frames(&cx, &frames)
4941 .expect_err("should fail after append but before fsync");
4942 assert!(
4943 err.to_string().contains("fault_inject"),
4944 "error identifies the fault hook: {err}"
4945 );
4946
4947 crate::fault_hooks::clear_crash_boundary();
4948
4949 assert_eq!(
4950 wal.last_fsynced_frame_count(),
4951 0,
4952 "no fsync happened so fsynced count is still zero"
4953 );
4954
4955 wal.close(&cx).expect("close after crash");
4956
4957 let file2 = open_wal_file(&vfs, &cx);
4958 let recovered = WalFile::open(&cx, file2).expect("reopen");
4959 assert!(
4960 recovered.frame_count() <= 1,
4961 "frame may or may not survive recovery depending on MemoryVfs behavior (data written but not fsynced)"
4962 );
4963 recovered.close(&cx).expect("close recovered");
4964 }
4965}