1use crate::WalGenerationIdentity;
38use fsqlite_types::sync_primitives::{Duration, Instant};
39use serde::{Deserialize, Serialize};
40use std::collections::VecDeque;
41use std::sync::{
42 LazyLock,
43 atomic::{AtomicBool, AtomicU64, Ordering},
44};
45
46use fsqlite_error::{FrankenError, Result};
47use fsqlite_types::cx::Cx;
48use fsqlite_vfs::{SyncKind, VfsFile};
49use tracing::{debug, info, trace};
50
51use crate::wal::{WalAppendFrameRef, WalFile};
52
53fn env_flag_enabled(value: &str) -> bool {
54 let value = value.trim();
55 value == "1"
56 || value.eq_ignore_ascii_case("true")
57 || value.eq_ignore_ascii_case("yes")
58 || value.eq_ignore_ascii_case("on")
59}
60
61#[must_use]
63pub fn detailed_consolidation_metrics_enabled() -> bool {
64 static ENABLED: LazyLock<bool> = LazyLock::new(|| {
65 std::env::var("FSQLITE_WAL_DETAILED_COMMIT_METRICS")
66 .is_ok_and(|value| env_flag_enabled(&value))
67 || std::env::var("FSQLITE_BENCH_PROFILE_INSERT")
68 .is_ok_and(|value| env_flag_enabled(&value))
69 });
70 *ENABLED
71}
72
73static COMMIT_PHASE_TIMING_ENABLED: AtomicBool = AtomicBool::new(false);
74
75pub fn set_commit_phase_timing_enabled(enabled: bool) -> bool {
81 COMMIT_PHASE_TIMING_ENABLED.swap(enabled, Ordering::Relaxed)
82}
83
84#[must_use]
86pub fn commit_phase_timing_forced_enabled() -> bool {
87 COMMIT_PHASE_TIMING_ENABLED.load(Ordering::Relaxed)
88}
89
90#[must_use]
92pub fn commit_phase_timing_enabled() -> bool {
93 detailed_consolidation_metrics_enabled() || commit_phase_timing_forced_enabled()
94}
95
96#[derive(Debug, Clone, Copy)]
102pub struct GroupCommitConfig {
103 pub max_group_size: usize,
107
108 pub max_group_delay: Duration,
112
113 pub max_group_delay_ceiling: Duration,
118}
119
120impl Default for GroupCommitConfig {
121 fn default() -> Self {
122 Self {
123 max_group_size: 64,
124 max_group_delay: Duration::from_millis(1),
125 max_group_delay_ceiling: Duration::from_millis(10),
126 }
127 }
128}
129
130impl GroupCommitConfig {
131 #[must_use]
133 pub fn validated(mut self) -> Self {
134 if self.max_group_size == 0 {
135 self.max_group_size = 1;
136 }
137 if self.max_group_delay > self.max_group_delay_ceiling {
138 self.max_group_delay = self.max_group_delay_ceiling;
139 }
140 self
141 }
142}
143
144#[derive(Debug, Clone)]
150pub struct FrameSubmission {
151 pub page_number: u32,
153 pub page_data: Vec<u8>,
155 pub db_size_if_commit: u32,
157}
158
159#[derive(Debug, Clone)]
161pub struct TransactionFrameBatch {
162 pub frames: Vec<FrameSubmission>,
164 pub conflict_pages: Vec<u32>,
172 pub conflict_snapshot: Option<TransactionConflictSnapshot>,
177 pub conflict_page_baselines: Vec<TransactionConflictPageBaseline>,
187 pub context: TransactionFrameBatchContext,
189 pub published_durable_freelist: Option<Vec<u32>>,
199 pub freed_pages: Vec<u32>,
203 pub consumed_freelist_pages: Vec<u32>,
210 pub consumed_durable_freelist_pages: Vec<u32>,
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub struct TransactionConflictSnapshot {
224 pub generation: WalGenerationIdentity,
225 pub last_commit_frame: Option<usize>,
226 pub commit_count: u64,
227 pub snapshot_db_size: u32,
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
238pub struct TransactionConflictPageBaseline {
239 pub page_number: u32,
241 pub page_hash: [u8; 32],
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
247pub struct TransactionFrameBatchContext {
248 pub batch_id: u64,
250 pub lane_id: u16,
252 pub staged_frame_count: u32,
254 pub staging_elapsed_ns: u64,
256}
257
258impl TransactionFrameBatch {
259 #[must_use]
261 pub fn new(frames: Vec<FrameSubmission>) -> Self {
262 Self {
263 frames,
264 conflict_pages: Vec::new(),
265 conflict_snapshot: None,
266 conflict_page_baselines: Vec::new(),
267 context: TransactionFrameBatchContext::default(),
268 published_durable_freelist: None,
269 freed_pages: Vec::new(),
270 consumed_freelist_pages: Vec::new(),
271 consumed_durable_freelist_pages: Vec::new(),
272 }
273 }
274
275 #[must_use]
281 pub fn with_freelist_publication(
282 mut self,
283 published_durable_freelist: Option<Vec<u32>>,
284 freed_pages: Vec<u32>,
285 consumed_freelist_pages: Vec<u32>,
286 consumed_durable_freelist_pages: Vec<u32>,
287 ) -> Self {
288 self.published_durable_freelist = published_durable_freelist;
289 self.freed_pages = freed_pages;
290 self.consumed_freelist_pages = consumed_freelist_pages;
291 self.consumed_durable_freelist_pages = consumed_durable_freelist_pages;
292 self
293 }
294
295 #[must_use]
297 pub fn with_conflict_snapshot(
298 mut self,
299 conflict_pages: Vec<u32>,
300 conflict_snapshot: Option<TransactionConflictSnapshot>,
301 ) -> Self {
302 self.conflict_pages = conflict_pages;
303 self.conflict_snapshot = conflict_snapshot;
304 self
305 }
306
307 #[must_use]
309 pub fn with_conflict_page_baselines(
310 mut self,
311 conflict_page_baselines: Vec<TransactionConflictPageBaseline>,
312 ) -> Self {
313 self.conflict_page_baselines = conflict_page_baselines;
314 self
315 }
316
317 #[must_use]
319 pub fn with_context(mut self, context: TransactionFrameBatchContext) -> Self {
320 self.context = context;
321 self
322 }
323
324 #[must_use]
326 pub fn frame_count(&self) -> usize {
327 self.frames.len()
328 }
329
330 #[must_use]
332 pub fn has_commit_frame(&self) -> bool {
333 self.frames.last().is_some_and(|f| f.db_size_if_commit > 0)
334 }
335}
336
337#[derive(Debug, Clone, Copy, PartialEq, Eq)]
343pub enum ConsolidationPhase {
344 Filling,
346 Flushing,
348 Complete,
350}
351
352#[derive(Debug, Clone, Copy, PartialEq, Eq)]
354pub enum SubmitOutcome {
355 Flusher,
357 Waiter,
359}
360
361#[derive(Debug, Clone, Copy, PartialEq, Eq)]
363pub struct SubmitReceipt {
364 pub outcome: SubmitOutcome,
366 pub target_epoch: u64,
368}
369
370const PHASE_HISTOGRAM_CAPACITY: usize = 4096;
381
382pub struct PhaseHistogram {
383 samples: Box<[AtomicU64]>,
385 write_idx: AtomicU64,
387 max_us: AtomicU64,
389 count: AtomicU64,
391 sum_us: AtomicU64,
393 recent_tail_us: AtomicU64,
395}
396
397impl PhaseHistogram {
398 #[must_use]
399 pub fn new() -> Self {
400 let samples: Vec<AtomicU64> = std::iter::repeat_with(|| AtomicU64::new(0))
401 .take(PHASE_HISTOGRAM_CAPACITY)
402 .collect();
403 Self {
404 samples: samples.into_boxed_slice(),
405 write_idx: AtomicU64::new(0),
406 max_us: AtomicU64::new(0),
407 count: AtomicU64::new(0),
408 sum_us: AtomicU64::new(0),
409 recent_tail_us: AtomicU64::new(0),
410 }
411 }
412
413 pub fn record(&self, value_us: u64) {
415 let idx =
416 self.write_idx.fetch_add(1, Ordering::Relaxed) as usize % PHASE_HISTOGRAM_CAPACITY;
417 self.samples[idx].store(value_us, Ordering::Relaxed);
418 self.count.fetch_add(1, Ordering::Relaxed);
419 self.sum_us.fetch_add(value_us, Ordering::Relaxed);
420 let mut prev = self.max_us.load(Ordering::Relaxed);
422 while value_us > prev {
423 match self.max_us.compare_exchange_weak(
424 prev,
425 value_us,
426 Ordering::Relaxed,
427 Ordering::Relaxed,
428 ) {
429 Ok(_) => break,
430 Err(actual) => prev = actual,
431 }
432 }
433
434 let mut prev_tail = self.recent_tail_us.load(Ordering::Relaxed);
439 loop {
440 let decayed = prev_tail.saturating_mul(15) / 16;
441 let next_tail = value_us.max(decayed);
442 match self.recent_tail_us.compare_exchange_weak(
443 prev_tail,
444 next_tail,
445 Ordering::Relaxed,
446 Ordering::Relaxed,
447 ) {
448 Ok(_) => break,
449 Err(actual) => prev_tail = actual,
450 }
451 }
452 }
453
454 #[must_use]
456 pub fn recent_tail_us(&self) -> u64 {
457 self.recent_tail_us.load(Ordering::Relaxed)
458 }
459
460 #[must_use]
462 pub fn percentiles(&self) -> PhasePercentiles {
463 let total_count = self.count.load(Ordering::Relaxed);
464 let max = self.max_us.load(Ordering::Relaxed);
465 let sum = self.sum_us.load(Ordering::Relaxed);
466
467 if total_count == 0 {
468 return PhasePercentiles {
469 p50: 0,
470 p95: 0,
471 p99: 0,
472 max: 0,
473 count: 0,
474 mean_us: 0,
475 };
476 }
477
478 let n = total_count.min(PHASE_HISTOGRAM_CAPACITY as u64) as usize;
480 let mut buf = Vec::with_capacity(n);
481 for i in 0..n {
482 buf.push(self.samples[i].load(Ordering::Relaxed));
483 }
484 buf.sort_unstable();
485
486 let p = |pct: usize| -> u64 {
487 if buf.is_empty() {
488 return 0;
489 }
490 let idx = (pct * buf.len()) / 100;
491 buf[idx.min(buf.len() - 1)]
492 };
493
494 PhasePercentiles {
495 p50: p(50),
496 p95: p(95),
497 p99: p(99),
498 max,
499 count: total_count,
500 mean_us: sum / total_count,
501 }
502 }
503
504 pub fn reset(&self) {
506 for s in &self.samples {
507 s.store(0, Ordering::Relaxed);
508 }
509 self.write_idx.store(0, Ordering::Relaxed);
510 self.max_us.store(0, Ordering::Relaxed);
511 self.count.store(0, Ordering::Relaxed);
512 self.sum_us.store(0, Ordering::Relaxed);
513 self.recent_tail_us.store(0, Ordering::Relaxed);
514 }
515}
516
517impl Default for PhaseHistogram {
518 fn default() -> Self {
519 Self::new()
520 }
521}
522
523#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
525pub struct PhasePercentiles {
526 pub p50: u64,
527 pub p95: u64,
528 pub p99: u64,
529 pub max: u64,
530 pub count: u64,
531 pub mean_us: u64,
532}
533
534pub struct WakeReasonCounters {
540 pub notify: AtomicU64,
543 pub timeout: AtomicU64,
545 pub flusher_takeover: AtomicU64,
547 pub failed_epoch: AtomicU64,
549 pub busy_retry: AtomicU64,
551}
552
553impl WakeReasonCounters {
554 const fn new() -> Self {
555 Self {
556 notify: AtomicU64::new(0),
557 timeout: AtomicU64::new(0),
558 flusher_takeover: AtomicU64::new(0),
559 failed_epoch: AtomicU64::new(0),
560 busy_retry: AtomicU64::new(0),
561 }
562 }
563
564 #[must_use]
566 pub fn snapshot(&self) -> WakeReasonSnapshot {
567 WakeReasonSnapshot {
568 notify: self.notify.load(Ordering::Relaxed),
569 timeout: self.timeout.load(Ordering::Relaxed),
570 flusher_takeover: self.flusher_takeover.load(Ordering::Relaxed),
571 failed_epoch: self.failed_epoch.load(Ordering::Relaxed),
572 busy_retry: self.busy_retry.load(Ordering::Relaxed),
573 }
574 }
575
576 pub fn reset(&self) {
578 self.notify.store(0, Ordering::Relaxed);
579 self.timeout.store(0, Ordering::Relaxed);
580 self.flusher_takeover.store(0, Ordering::Relaxed);
581 self.failed_epoch.store(0, Ordering::Relaxed);
582 self.busy_retry.store(0, Ordering::Relaxed);
583 }
584}
585
586#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
588pub struct WakeReasonSnapshot {
589 pub notify: u64,
590 pub timeout: u64,
591 pub flusher_takeover: u64,
592 pub failed_epoch: u64,
593 pub busy_retry: u64,
594}
595
596impl WakeReasonSnapshot {
597 #[must_use]
599 pub fn total(&self) -> u64 {
600 self.notify + self.timeout + self.flusher_takeover + self.failed_epoch + self.busy_retry
601 }
602}
603
604pub struct ConsolidationMetrics {
610 pub groups_flushed: AtomicU64,
612 pub frames_consolidated: AtomicU64,
614 pub transactions_batched: AtomicU64,
616 pub fsyncs_total: AtomicU64,
618 pub flush_duration_us_total: AtomicU64,
620 pub wait_duration_us_total: AtomicU64,
622 pub max_group_size_observed: AtomicU64,
624 pub busy_retries: AtomicU64,
626
627 pub prepare_us_total: AtomicU64,
630 pub batch_build_us_total: AtomicU64,
632 pub conflict_snapshot_us_total: AtomicU64,
634 pub lane_prepare_us_total: AtomicU64,
636 pub consolidator_lock_wait_us_total: AtomicU64,
638 pub consolidator_flushing_wait_us_total: AtomicU64,
640 pub flusher_arrival_wait_us_total: AtomicU64,
642 pub inner_lock_wait_us_total: AtomicU64,
644 pub exclusive_lock_us_total: AtomicU64,
646 pub wal_append_us_total: AtomicU64,
648 pub flush_frame_prep_us_total: AtomicU64,
650 pub append_conflict_check_us_total: AtomicU64,
652 pub append_frames_us_total: AtomicU64,
654 pub wal_sync_us_total: AtomicU64,
656 pub waiter_epoch_wait_us_total: AtomicU64,
658 pub flusher_commits: AtomicU64,
660 pub waiter_commits: AtomicU64,
662 pub commit_phase_a_us_total: AtomicU64,
665 pub commit_phase_b_us_total: AtomicU64,
667 pub commit_phase_c1_us_total: AtomicU64,
669 pub commit_phase_c2_us_total: AtomicU64,
671 pub commit_phase_count: AtomicU64,
673
674 pub hist_consolidator_lock_wait: PhaseHistogram,
677 pub hist_arrival_wait: PhaseHistogram,
679 pub hist_wal_backend_lock_wait: PhaseHistogram,
681 pub hist_wal_append: PhaseHistogram,
683 pub hist_exclusive_lock: PhaseHistogram,
685 pub hist_waiter_epoch_wait: PhaseHistogram,
687 pub hist_phase_b: PhaseHistogram,
689 pub hist_wal_sync: PhaseHistogram,
691 pub hist_full_commit: PhaseHistogram,
693
694 pub wake_reasons: WakeReasonCounters,
697}
698
699impl ConsolidationMetrics {
700 #[must_use]
702 pub fn new() -> Self {
703 Self {
704 groups_flushed: AtomicU64::new(0),
705 frames_consolidated: AtomicU64::new(0),
706 transactions_batched: AtomicU64::new(0),
707 fsyncs_total: AtomicU64::new(0),
708 flush_duration_us_total: AtomicU64::new(0),
709 wait_duration_us_total: AtomicU64::new(0),
710 max_group_size_observed: AtomicU64::new(0),
711 busy_retries: AtomicU64::new(0),
712 prepare_us_total: AtomicU64::new(0),
714 batch_build_us_total: AtomicU64::new(0),
715 conflict_snapshot_us_total: AtomicU64::new(0),
716 lane_prepare_us_total: AtomicU64::new(0),
717 consolidator_lock_wait_us_total: AtomicU64::new(0),
718 consolidator_flushing_wait_us_total: AtomicU64::new(0),
719 flusher_arrival_wait_us_total: AtomicU64::new(0),
720 inner_lock_wait_us_total: AtomicU64::new(0),
721 exclusive_lock_us_total: AtomicU64::new(0),
722 wal_append_us_total: AtomicU64::new(0),
723 flush_frame_prep_us_total: AtomicU64::new(0),
724 append_conflict_check_us_total: AtomicU64::new(0),
725 append_frames_us_total: AtomicU64::new(0),
726 wal_sync_us_total: AtomicU64::new(0),
727 waiter_epoch_wait_us_total: AtomicU64::new(0),
728 flusher_commits: AtomicU64::new(0),
729 waiter_commits: AtomicU64::new(0),
730 commit_phase_a_us_total: AtomicU64::new(0),
731 commit_phase_b_us_total: AtomicU64::new(0),
732 commit_phase_c1_us_total: AtomicU64::new(0),
733 commit_phase_c2_us_total: AtomicU64::new(0),
734 commit_phase_count: AtomicU64::new(0),
735 hist_consolidator_lock_wait: PhaseHistogram::new(),
737 hist_arrival_wait: PhaseHistogram::new(),
738 hist_wal_backend_lock_wait: PhaseHistogram::new(),
739 hist_wal_append: PhaseHistogram::new(),
740 hist_exclusive_lock: PhaseHistogram::new(),
741 hist_waiter_epoch_wait: PhaseHistogram::new(),
742 hist_phase_b: PhaseHistogram::new(),
743 hist_wal_sync: PhaseHistogram::new(),
744 hist_full_commit: PhaseHistogram::new(),
745 wake_reasons: WakeReasonCounters::new(),
746 }
747 }
748
749 pub fn record_flush(&self, frames: u64, transactions: u64, duration_us: u64) {
751 self.groups_flushed.fetch_add(1, Ordering::Relaxed);
752 self.frames_consolidated
753 .fetch_add(frames, Ordering::Relaxed);
754 self.transactions_batched
755 .fetch_add(transactions, Ordering::Relaxed);
756 self.fsyncs_total.fetch_add(1, Ordering::Relaxed);
757 self.flush_duration_us_total
758 .fetch_add(duration_us, Ordering::Relaxed);
759 self.max_group_size_observed
761 .fetch_max(frames, Ordering::Relaxed);
762 }
763
764 pub fn record_wait(&self, duration_us: u64) {
766 self.wait_duration_us_total
767 .fetch_add(duration_us, Ordering::Relaxed);
768 }
769
770 pub fn record_busy_retry(&self) {
772 self.busy_retries.fetch_add(1, Ordering::Relaxed);
773 }
774
775 pub fn record_prepare_breakdown(
777 &self,
778 batch_build_us: u64,
779 conflict_snapshot_us: u64,
780 lane_prepare_us: u64,
781 ) {
782 self.batch_build_us_total
783 .fetch_add(batch_build_us, Ordering::Relaxed);
784 self.conflict_snapshot_us_total
785 .fetch_add(conflict_snapshot_us, Ordering::Relaxed);
786 self.lane_prepare_us_total
787 .fetch_add(lane_prepare_us, Ordering::Relaxed);
788 }
789
790 pub fn record_flush_breakdown(
792 &self,
793 flush_frame_prep_us: u64,
794 append_conflict_check_us: u64,
795 append_frames_us: u64,
796 ) {
797 self.flush_frame_prep_us_total
798 .fetch_add(flush_frame_prep_us, Ordering::Relaxed);
799 self.append_conflict_check_us_total
800 .fetch_add(append_conflict_check_us, Ordering::Relaxed);
801 self.append_frames_us_total
802 .fetch_add(append_frames_us, Ordering::Relaxed);
803 }
804
805 #[allow(clippy::too_many_arguments)]
807 pub fn record_phase_timing(
808 &self,
809 prepare_us: u64,
810 consolidator_lock_wait_us: u64,
811 consolidator_flushing_wait_us: u64,
812 is_flusher: bool,
813 flusher_arrival_wait_us: u64,
814 inner_lock_wait_us: u64,
815 exclusive_lock_us: u64,
816 wal_append_us: u64,
817 wal_sync_us: u64,
818 waiter_epoch_wait_us: u64,
819 ) {
820 self.prepare_us_total
821 .fetch_add(prepare_us, Ordering::Relaxed);
822 self.consolidator_lock_wait_us_total
823 .fetch_add(consolidator_lock_wait_us, Ordering::Relaxed);
824 self.consolidator_flushing_wait_us_total
825 .fetch_add(consolidator_flushing_wait_us, Ordering::Relaxed);
826
827 self.hist_consolidator_lock_wait
829 .record(consolidator_lock_wait_us);
830
831 if is_flusher {
832 self.flusher_arrival_wait_us_total
833 .fetch_add(flusher_arrival_wait_us, Ordering::Relaxed);
834 self.inner_lock_wait_us_total
835 .fetch_add(inner_lock_wait_us, Ordering::Relaxed);
836 self.exclusive_lock_us_total
837 .fetch_add(exclusive_lock_us, Ordering::Relaxed);
838 self.wal_append_us_total
839 .fetch_add(wal_append_us, Ordering::Relaxed);
840 self.wal_sync_us_total
841 .fetch_add(wal_sync_us, Ordering::Relaxed);
842 self.flusher_commits.fetch_add(1, Ordering::Relaxed);
843
844 self.hist_arrival_wait.record(flusher_arrival_wait_us);
846 self.hist_wal_backend_lock_wait.record(inner_lock_wait_us);
847 self.hist_wal_append.record(wal_append_us);
848 self.hist_exclusive_lock.record(exclusive_lock_us);
849 self.hist_wal_sync.record(wal_sync_us);
850 } else {
851 self.waiter_epoch_wait_us_total
852 .fetch_add(waiter_epoch_wait_us, Ordering::Relaxed);
853 self.waiter_commits.fetch_add(1, Ordering::Relaxed);
854
855 self.hist_waiter_epoch_wait.record(waiter_epoch_wait_us);
857 }
858
859 let phase_b_total = consolidator_lock_wait_us
861 + consolidator_flushing_wait_us
862 + if is_flusher {
863 flusher_arrival_wait_us
864 + inner_lock_wait_us
865 + exclusive_lock_us
866 + wal_append_us
867 + wal_sync_us
868 } else {
869 waiter_epoch_wait_us
870 };
871 self.hist_phase_b.record(phase_b_total);
872 }
873
874 pub fn record_commit_phases(
876 &self,
877 phase_a_us: u64,
878 phase_b_us: u64,
879 phase_c1_us: u64,
880 phase_c2_us: u64,
881 ) {
882 self.commit_phase_a_us_total
883 .fetch_add(phase_a_us, Ordering::Relaxed);
884 self.commit_phase_b_us_total
885 .fetch_add(phase_b_us, Ordering::Relaxed);
886 self.commit_phase_c1_us_total
887 .fetch_add(phase_c1_us, Ordering::Relaxed);
888 self.commit_phase_c2_us_total
889 .fetch_add(phase_c2_us, Ordering::Relaxed);
890 self.commit_phase_count.fetch_add(1, Ordering::Relaxed);
891
892 self.hist_full_commit
894 .record(phase_a_us + phase_b_us + phase_c1_us + phase_c2_us);
895 }
896
897 #[must_use]
899 pub fn snapshot(&self) -> ConsolidationMetricsSnapshot {
900 ConsolidationMetricsSnapshot {
901 groups_flushed: self.groups_flushed.load(Ordering::Relaxed),
902 frames_consolidated: self.frames_consolidated.load(Ordering::Relaxed),
903 transactions_batched: self.transactions_batched.load(Ordering::Relaxed),
904 fsyncs_total: self.fsyncs_total.load(Ordering::Relaxed),
905 flush_duration_us_total: self.flush_duration_us_total.load(Ordering::Relaxed),
906 wait_duration_us_total: self.wait_duration_us_total.load(Ordering::Relaxed),
907 max_group_size_observed: self.max_group_size_observed.load(Ordering::Relaxed),
908 busy_retries: self.busy_retries.load(Ordering::Relaxed),
909 prepare_us_total: self.prepare_us_total.load(Ordering::Relaxed),
911 batch_build_us_total: self.batch_build_us_total.load(Ordering::Relaxed),
912 conflict_snapshot_us_total: self.conflict_snapshot_us_total.load(Ordering::Relaxed),
913 lane_prepare_us_total: self.lane_prepare_us_total.load(Ordering::Relaxed),
914 consolidator_lock_wait_us_total: self
915 .consolidator_lock_wait_us_total
916 .load(Ordering::Relaxed),
917 consolidator_flushing_wait_us_total: self
918 .consolidator_flushing_wait_us_total
919 .load(Ordering::Relaxed),
920 flusher_arrival_wait_us_total: self
921 .flusher_arrival_wait_us_total
922 .load(Ordering::Relaxed),
923 inner_lock_wait_us_total: self.inner_lock_wait_us_total.load(Ordering::Relaxed),
924 exclusive_lock_us_total: self.exclusive_lock_us_total.load(Ordering::Relaxed),
925 wal_append_us_total: self.wal_append_us_total.load(Ordering::Relaxed),
926 flush_frame_prep_us_total: self.flush_frame_prep_us_total.load(Ordering::Relaxed),
927 append_conflict_check_us_total: self
928 .append_conflict_check_us_total
929 .load(Ordering::Relaxed),
930 append_frames_us_total: self.append_frames_us_total.load(Ordering::Relaxed),
931 wal_sync_us_total: self.wal_sync_us_total.load(Ordering::Relaxed),
932 waiter_epoch_wait_us_total: self.waiter_epoch_wait_us_total.load(Ordering::Relaxed),
933 flusher_commits: self.flusher_commits.load(Ordering::Relaxed),
934 waiter_commits: self.waiter_commits.load(Ordering::Relaxed),
935 commit_phase_a_us_total: self.commit_phase_a_us_total.load(Ordering::Relaxed),
936 commit_phase_b_us_total: self.commit_phase_b_us_total.load(Ordering::Relaxed),
937 commit_phase_c1_us_total: self.commit_phase_c1_us_total.load(Ordering::Relaxed),
938 commit_phase_c2_us_total: self.commit_phase_c2_us_total.load(Ordering::Relaxed),
939 commit_phase_count: self.commit_phase_count.load(Ordering::Relaxed),
940 hist_consolidator_lock_wait: self.hist_consolidator_lock_wait.percentiles(),
942 hist_arrival_wait: self.hist_arrival_wait.percentiles(),
943 hist_wal_backend_lock_wait: self.hist_wal_backend_lock_wait.percentiles(),
944 hist_wal_append: self.hist_wal_append.percentiles(),
945 hist_exclusive_lock: self.hist_exclusive_lock.percentiles(),
946 hist_waiter_epoch_wait: self.hist_waiter_epoch_wait.percentiles(),
947 hist_phase_b: self.hist_phase_b.percentiles(),
948 hist_wal_sync: self.hist_wal_sync.percentiles(),
949 hist_full_commit: self.hist_full_commit.percentiles(),
950 wake_reasons: self.wake_reasons.snapshot(),
951 }
952 }
953
954 pub fn reset(&self) {
956 self.groups_flushed.store(0, Ordering::Relaxed);
957 self.frames_consolidated.store(0, Ordering::Relaxed);
958 self.transactions_batched.store(0, Ordering::Relaxed);
959 self.fsyncs_total.store(0, Ordering::Relaxed);
960 self.flush_duration_us_total.store(0, Ordering::Relaxed);
961 self.wait_duration_us_total.store(0, Ordering::Relaxed);
962 self.max_group_size_observed.store(0, Ordering::Relaxed);
963 self.busy_retries.store(0, Ordering::Relaxed);
964 self.prepare_us_total.store(0, Ordering::Relaxed);
966 self.batch_build_us_total.store(0, Ordering::Relaxed);
967 self.conflict_snapshot_us_total.store(0, Ordering::Relaxed);
968 self.lane_prepare_us_total.store(0, Ordering::Relaxed);
969 self.consolidator_lock_wait_us_total
970 .store(0, Ordering::Relaxed);
971 self.consolidator_flushing_wait_us_total
972 .store(0, Ordering::Relaxed);
973 self.flusher_arrival_wait_us_total
974 .store(0, Ordering::Relaxed);
975 self.inner_lock_wait_us_total.store(0, Ordering::Relaxed);
976 self.exclusive_lock_us_total.store(0, Ordering::Relaxed);
977 self.wal_append_us_total.store(0, Ordering::Relaxed);
978 self.flush_frame_prep_us_total.store(0, Ordering::Relaxed);
979 self.append_conflict_check_us_total
980 .store(0, Ordering::Relaxed);
981 self.append_frames_us_total.store(0, Ordering::Relaxed);
982 self.wal_sync_us_total.store(0, Ordering::Relaxed);
983 self.waiter_epoch_wait_us_total.store(0, Ordering::Relaxed);
984 self.flusher_commits.store(0, Ordering::Relaxed);
985 self.waiter_commits.store(0, Ordering::Relaxed);
986 self.commit_phase_a_us_total.store(0, Ordering::Relaxed);
987 self.commit_phase_b_us_total.store(0, Ordering::Relaxed);
988 self.commit_phase_c1_us_total.store(0, Ordering::Relaxed);
989 self.commit_phase_c2_us_total.store(0, Ordering::Relaxed);
990 self.commit_phase_count.store(0, Ordering::Relaxed);
991 self.hist_consolidator_lock_wait.reset();
993 self.hist_arrival_wait.reset();
994 self.hist_wal_backend_lock_wait.reset();
995 self.hist_wal_append.reset();
996 self.hist_exclusive_lock.reset();
997 self.hist_waiter_epoch_wait.reset();
998 self.hist_phase_b.reset();
999 self.hist_wal_sync.reset();
1000 self.hist_full_commit.reset();
1001 self.wake_reasons.reset();
1002 }
1003}
1004
1005impl Default for ConsolidationMetrics {
1006 fn default() -> Self {
1007 Self::new()
1008 }
1009}
1010
1011#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1013pub struct ConsolidationMetricsSnapshot {
1014 pub groups_flushed: u64,
1015 pub frames_consolidated: u64,
1016 pub transactions_batched: u64,
1017 pub fsyncs_total: u64,
1018 pub flush_duration_us_total: u64,
1019 pub wait_duration_us_total: u64,
1020 pub max_group_size_observed: u64,
1021 pub busy_retries: u64,
1022 pub prepare_us_total: u64,
1024 pub batch_build_us_total: u64,
1025 pub conflict_snapshot_us_total: u64,
1026 pub lane_prepare_us_total: u64,
1027 pub consolidator_lock_wait_us_total: u64,
1028 pub consolidator_flushing_wait_us_total: u64,
1029 pub flusher_arrival_wait_us_total: u64,
1030 pub inner_lock_wait_us_total: u64,
1031 pub exclusive_lock_us_total: u64,
1032 pub wal_append_us_total: u64,
1033 pub flush_frame_prep_us_total: u64,
1034 pub append_conflict_check_us_total: u64,
1035 pub append_frames_us_total: u64,
1036 pub wal_sync_us_total: u64,
1037 pub waiter_epoch_wait_us_total: u64,
1038 pub flusher_commits: u64,
1039 pub waiter_commits: u64,
1040 pub commit_phase_a_us_total: u64,
1042 pub commit_phase_b_us_total: u64,
1043 pub commit_phase_c1_us_total: u64,
1044 pub commit_phase_c2_us_total: u64,
1045 pub commit_phase_count: u64,
1046 pub hist_consolidator_lock_wait: PhasePercentiles,
1048 pub hist_arrival_wait: PhasePercentiles,
1049 pub hist_wal_backend_lock_wait: PhasePercentiles,
1050 pub hist_wal_append: PhasePercentiles,
1051 pub hist_exclusive_lock: PhasePercentiles,
1052 pub hist_waiter_epoch_wait: PhasePercentiles,
1053 pub hist_phase_b: PhasePercentiles,
1054 pub hist_wal_sync: PhasePercentiles,
1055 pub hist_full_commit: PhasePercentiles,
1056 pub wake_reasons: WakeReasonSnapshot,
1058}
1059
1060impl ConsolidationMetricsSnapshot {
1061 #[must_use]
1063 pub fn avg_group_size(&self) -> u64 {
1064 self.frames_consolidated
1065 .checked_div(self.groups_flushed)
1066 .unwrap_or(0)
1067 }
1068
1069 #[must_use]
1071 pub fn avg_transactions_per_group(&self) -> u64 {
1072 self.transactions_batched
1073 .checked_div(self.groups_flushed)
1074 .unwrap_or(0)
1075 }
1076
1077 #[must_use]
1079 pub fn avg_flush_duration_us(&self) -> u64 {
1080 self.flush_duration_us_total
1081 .checked_div(self.groups_flushed)
1082 .unwrap_or(0)
1083 }
1084
1085 #[must_use]
1090 pub fn fsync_reduction_ratio(&self) -> u64 {
1091 self.transactions_batched
1092 .checked_div(self.fsyncs_total)
1093 .unwrap_or(0)
1094 }
1095
1096 #[must_use]
1098 pub fn total_commits(&self) -> u64 {
1099 self.flusher_commits.saturating_add(self.waiter_commits)
1100 }
1101
1102 #[must_use]
1104 pub fn avg_prepare_us(&self) -> u64 {
1105 self.prepare_us_total
1106 .checked_div(self.total_commits())
1107 .unwrap_or(0)
1108 }
1109
1110 #[must_use]
1112 pub fn avg_consolidator_lock_wait_us(&self) -> u64 {
1113 self.consolidator_lock_wait_us_total
1114 .checked_div(self.total_commits())
1115 .unwrap_or(0)
1116 }
1117
1118 #[must_use]
1120 pub fn avg_wal_io_us(&self) -> u64 {
1121 self.wal_append_us_total
1122 .saturating_add(self.wal_sync_us_total)
1123 .checked_div(self.flusher_commits)
1124 .unwrap_or(0)
1125 }
1126
1127 #[must_use]
1129 pub fn avg_waiter_wait_us(&self) -> u64 {
1130 self.waiter_epoch_wait_us_total
1131 .checked_div(self.waiter_commits)
1132 .unwrap_or(0)
1133 }
1134
1135 #[must_use]
1141 pub fn flusher_lock_wait_us_total(&self) -> u64 {
1142 self.inner_lock_wait_us_total
1143 .saturating_add(self.exclusive_lock_us_total)
1144 .saturating_add(self.consolidator_flushing_wait_us_total)
1145 }
1146
1147 #[must_use]
1150 pub fn wal_service_us_total(&self) -> u64 {
1151 self.wal_append_us_total
1152 .saturating_add(self.wal_sync_us_total)
1153 }
1154
1155 #[must_use]
1159 #[allow(clippy::cast_precision_loss)]
1160 pub fn flusher_lock_wait_fraction(&self) -> f64 {
1161 let lock = self.flusher_lock_wait_us_total();
1162 let service = self.wal_service_us_total();
1163 let total = lock.saturating_add(service);
1164 if total == 0 {
1165 return 0.0;
1166 }
1167 lock as f64 / total as f64
1168 }
1169
1170 #[must_use]
1172 pub fn is_lock_topology_limited(&self) -> bool {
1173 self.flusher_lock_wait_us_total() > self.wal_service_us_total()
1174 }
1175
1176 #[must_use]
1178 pub fn phase_timing_report(&self) -> String {
1179 let total = self.total_commits();
1180 if total == 0 {
1181 return "no commits".to_string();
1182 }
1183
1184 let avg_prepare = self.avg_prepare_us();
1186 let avg_consol_lock = self.avg_consolidator_lock_wait_us();
1187 let avg_flushing_wait = self
1188 .consolidator_flushing_wait_us_total
1189 .checked_div(total)
1190 .unwrap_or(0);
1191
1192 let avg_arrival_wait = self
1194 .flusher_arrival_wait_us_total
1195 .checked_div(self.flusher_commits)
1196 .unwrap_or(0);
1197 let avg_inner_lock = self
1198 .inner_lock_wait_us_total
1199 .checked_div(self.flusher_commits)
1200 .unwrap_or(0);
1201 let avg_excl_lock = self
1202 .exclusive_lock_us_total
1203 .checked_div(self.flusher_commits)
1204 .unwrap_or(0);
1205 let avg_append = self
1206 .wal_append_us_total
1207 .checked_div(self.flusher_commits)
1208 .unwrap_or(0);
1209 let avg_sync = self
1210 .wal_sync_us_total
1211 .checked_div(self.flusher_commits)
1212 .unwrap_or(0);
1213
1214 let avg_epoch_wait = self.avg_waiter_wait_us();
1216
1217 format!(
1218 "commits: {} (flusher={}, waiter={})\n\
1219 per-commit avg:\n\
1220 ├─ prepare: {}µs\n\
1221 ├─ consolidator_lock_wait: {}µs\n\
1222 ├─ flushing_wait: {}µs\n\
1223 flusher path ({} commits):\n\
1224 ├─ arrival_wait: {}µs\n\
1225 ├─ inner_lock_wait: {}µs\n\
1226 ├─ exclusive_lock: {}µs\n\
1227 ├─ wal_append: {}µs\n\
1228 └─ wal_sync: {}µs (total WAL I/O: {}µs)\n\
1229 waiter path ({} commits):\n\
1230 └─ epoch_wait: {}µs\n\
1231 full commit path ({} commits):\n\
1232 ├─ phase_A (prepare+inner.lock): {}µs\n\
1233 ├─ phase_B (group_commit): {}µs\n\
1234 ├─ phase_C1 (post-commit+inner.lock): {}µs\n\
1235 └─ phase_C2 (publish): {}µs",
1236 total,
1237 self.flusher_commits,
1238 self.waiter_commits,
1239 avg_prepare,
1240 avg_consol_lock,
1241 avg_flushing_wait,
1242 self.flusher_commits,
1243 avg_arrival_wait,
1244 avg_inner_lock,
1245 avg_excl_lock,
1246 avg_append,
1247 avg_sync,
1248 avg_append + avg_sync,
1249 self.waiter_commits,
1250 avg_epoch_wait,
1251 self.commit_phase_count,
1252 self.commit_phase_a_us_total
1253 .checked_div(self.commit_phase_count)
1254 .unwrap_or(0),
1255 self.commit_phase_b_us_total
1256 .checked_div(self.commit_phase_count)
1257 .unwrap_or(0),
1258 self.commit_phase_c1_us_total
1259 .checked_div(self.commit_phase_count)
1260 .unwrap_or(0),
1261 self.commit_phase_c2_us_total
1262 .checked_div(self.commit_phase_count)
1263 .unwrap_or(0),
1264 )
1265 }
1266}
1267
1268impl std::fmt::Display for ConsolidationMetricsSnapshot {
1269 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1270 write!(
1271 f,
1272 "groups={} frames={} txns={} fsyncs={} avg_group={} \
1273 avg_flush_us={} max_group={} busy_retries={} reduction={}x",
1274 self.groups_flushed,
1275 self.frames_consolidated,
1276 self.transactions_batched,
1277 self.fsyncs_total,
1278 self.avg_group_size(),
1279 self.avg_flush_duration_us(),
1280 self.max_group_size_observed,
1281 self.busy_retries,
1282 self.fsync_reduction_ratio(),
1283 )
1284 }
1285}
1286
1287pub static GLOBAL_CONSOLIDATION_METRICS: LazyLock<ConsolidationMetrics> =
1289 LazyLock::new(ConsolidationMetrics::new);
1290
1291#[cfg(test)]
1292pub(crate) static GLOBAL_CONSOLIDATION_METRICS_TEST_LOCK: LazyLock<std::sync::Mutex<()>> =
1293 LazyLock::new(|| std::sync::Mutex::new(()));
1294
1295#[derive(Debug)]
1306pub struct GroupCommitConsolidator {
1307 phase: ConsolidationPhase,
1309 pending_batches: VecDeque<TransactionFrameBatch>,
1311 pending_frame_count: usize,
1313 config: GroupCommitConfig,
1315 filling_started: Option<Instant>,
1317 epoch: u64,
1319 completed_epoch: u64,
1321 next_epoch_batches: VecDeque<TransactionFrameBatch>,
1325 next_epoch_frame_count: usize,
1327 promoted_epoch_flusher_vacant: bool,
1330}
1331
1332impl GroupCommitConsolidator {
1333 #[must_use]
1335 pub fn new(config: GroupCommitConfig) -> Self {
1336 let config = config.validated();
1337 Self {
1338 phase: ConsolidationPhase::Filling,
1339 pending_batches: VecDeque::new(),
1340 pending_frame_count: 0,
1341 config,
1342 filling_started: None,
1343 epoch: 0,
1344 completed_epoch: 0,
1345 next_epoch_batches: VecDeque::new(),
1346 next_epoch_frame_count: 0,
1347 promoted_epoch_flusher_vacant: false,
1348 }
1349 }
1350
1351 #[must_use]
1353 pub const fn phase(&self) -> ConsolidationPhase {
1354 self.phase
1355 }
1356
1357 #[must_use]
1359 pub const fn epoch(&self) -> u64 {
1360 self.epoch
1361 }
1362
1363 #[must_use]
1365 pub const fn max_group_delay(&self) -> Duration {
1366 self.config.max_group_delay
1367 }
1368
1369 #[must_use]
1371 pub const fn pending_frame_count(&self) -> usize {
1372 self.pending_frame_count
1373 }
1374
1375 #[must_use]
1377 pub fn pending_batch_count(&self) -> usize {
1378 self.pending_batches.len()
1379 }
1380
1381 pub fn submit_batch(&mut self, batch: TransactionFrameBatch) -> Result<SubmitReceipt> {
1390 if self.phase == ConsolidationPhase::Flushing {
1395 self.next_epoch_frame_count += batch.frame_count();
1396 self.next_epoch_batches.push_back(batch);
1397
1398 trace!(
1399 target: "fsqlite_wal::group_commit",
1400 epoch = self.epoch,
1401 next_epoch_frames = self.next_epoch_frame_count,
1402 next_epoch_batches = self.next_epoch_batches.len(),
1403 "batch pipelined for next epoch (submitted during FLUSHING)"
1404 );
1405
1406 return Ok(SubmitReceipt {
1409 outcome: SubmitOutcome::Waiter,
1410 target_epoch: self.epoch.saturating_add(1),
1411 });
1412 }
1413
1414 if self.phase == ConsolidationPhase::Complete {
1416 self.transition_to_filling();
1417 }
1418
1419 let is_first = self.pending_batches.is_empty();
1420
1421 if is_first {
1422 self.filling_started = Some(Instant::now());
1423 self.promoted_epoch_flusher_vacant = false;
1424 }
1425
1426 self.pending_frame_count += batch.frame_count();
1427 self.pending_batches.push_back(batch);
1428
1429 let outcome = if is_first {
1430 SubmitOutcome::Flusher
1431 } else {
1432 SubmitOutcome::Waiter
1433 };
1434
1435 trace!(
1436 target: "fsqlite_wal::group_commit",
1437 epoch = self.epoch,
1438 pending_frames = self.pending_frame_count,
1439 pending_batches = self.pending_batches.len(),
1440 outcome = ?outcome,
1441 "batch submitted"
1442 );
1443
1444 Ok(SubmitReceipt {
1445 outcome,
1446 target_epoch: self.epoch.saturating_add(1),
1447 })
1448 }
1449
1450 #[must_use]
1456 pub fn should_flush_now(&self) -> bool {
1457 if self.pending_frame_count >= self.config.max_group_size {
1458 return true;
1459 }
1460 if let Some(started) = self.filling_started
1461 && started.elapsed() >= self.config.max_group_delay
1462 {
1463 return true;
1464 }
1465 false
1466 }
1467
1468 #[must_use]
1470 pub fn time_until_flush(&self) -> Duration {
1471 if self.pending_frame_count >= self.config.max_group_size {
1472 return Duration::ZERO;
1473 }
1474 self.filling_started
1475 .map_or(self.config.max_group_delay, |started| {
1476 self.config
1477 .max_group_delay
1478 .saturating_sub(started.elapsed())
1479 })
1480 }
1481
1482 #[must_use]
1484 pub fn fill_age(&self) -> Duration {
1485 self.filling_started
1486 .map_or(Duration::ZERO, |started| started.elapsed())
1487 }
1488
1489 pub fn begin_flush(&mut self) -> Result<Vec<TransactionFrameBatch>> {
1498 if self.phase != ConsolidationPhase::Filling {
1499 return Err(FrankenError::Internal(format!(
1500 "begin_flush called in {:?} phase, expected Filling",
1501 self.phase
1502 )));
1503 }
1504
1505 self.phase = ConsolidationPhase::Flushing;
1506 self.promoted_epoch_flusher_vacant = false;
1507 self.epoch += 1;
1508
1509 let batches: Vec<_> = self.pending_batches.drain(..).collect();
1510 let frame_count = self.pending_frame_count;
1511 self.pending_frame_count = 0;
1512
1513 debug!(
1514 target: "fsqlite_wal::group_commit",
1515 epoch = self.epoch,
1516 batches = batches.len(),
1517 frames = frame_count,
1518 "begin_flush: FILLING → FLUSHING"
1519 );
1520
1521 Ok(batches)
1522 }
1523
1524 pub fn complete_flush(&mut self) -> Result<bool> {
1534 if self.phase != ConsolidationPhase::Flushing {
1535 return Err(FrankenError::Internal(format!(
1536 "complete_flush called in {:?} phase, expected Flushing",
1537 self.phase
1538 )));
1539 }
1540
1541 self.completed_epoch = self.epoch;
1542 self.filling_started = None;
1543
1544 if self.next_epoch_batches.is_empty() {
1550 self.phase = ConsolidationPhase::Complete;
1551 self.promoted_epoch_flusher_vacant = false;
1552 debug!(
1553 target: "fsqlite_wal::group_commit",
1554 epoch = self.epoch,
1555 "complete_flush: FLUSHING → COMPLETE"
1556 );
1557 Ok(false)
1558 } else {
1559 let promoted_count = self.next_epoch_batches.len();
1560 let promoted_frames = self.next_epoch_frame_count;
1561 self.pending_batches = std::mem::take(&mut self.next_epoch_batches);
1562 self.pending_frame_count = self.next_epoch_frame_count;
1563 self.next_epoch_frame_count = 0;
1564 self.phase = ConsolidationPhase::Filling;
1565 self.filling_started = Some(Instant::now());
1566 self.promoted_epoch_flusher_vacant = true;
1567
1568 debug!(
1569 target: "fsqlite_wal::group_commit",
1570 epoch = self.epoch,
1571 promoted_batches = promoted_count,
1572 promoted_frames = promoted_frames,
1573 "complete_flush: FLUSHING → FILLING (epoch pipelining)"
1574 );
1575 Ok(true) }
1577 }
1578
1579 #[must_use]
1581 pub fn has_pipelined_batches(&self) -> bool {
1582 !self.next_epoch_batches.is_empty()
1583 }
1584
1585 #[must_use]
1588 pub const fn has_flusher_vacancy(&self) -> bool {
1589 self.promoted_epoch_flusher_vacant
1590 }
1591
1592 #[must_use]
1597 pub fn claim_flusher_vacancy(&mut self) -> bool {
1598 if self.phase == ConsolidationPhase::Filling
1599 && self.promoted_epoch_flusher_vacant
1600 && !self.pending_batches.is_empty()
1601 {
1602 self.promoted_epoch_flusher_vacant = false;
1603 return true;
1604 }
1605 false
1606 }
1607
1608 pub fn abort_filling(&mut self, expected_epoch: u64) -> Result<u64> {
1620 if self.phase != ConsolidationPhase::Filling || self.pending_batches.is_empty() {
1621 return Err(FrankenError::Internal(format!(
1622 "abort_filling called in {:?} phase with {} pending batches",
1623 self.phase,
1624 self.pending_batches.len()
1625 )));
1626 }
1627
1628 let failed_epoch = self
1629 .epoch
1630 .checked_add(1)
1631 .ok_or(FrankenError::DatabaseFull)?;
1632 if failed_epoch != expected_epoch {
1633 return Err(FrankenError::Internal(format!(
1634 "abort_filling expected epoch {expected_epoch}, but active filling epoch targets {failed_epoch}"
1635 )));
1636 }
1637 self.epoch = failed_epoch;
1638 self.pending_batches.clear();
1639 self.pending_frame_count = 0;
1640 self.filling_started = None;
1641 self.phase = ConsolidationPhase::Complete;
1642 self.promoted_epoch_flusher_vacant = false;
1643
1644 debug!(
1645 target: "fsqlite_wal::group_commit",
1646 epoch = failed_epoch,
1647 "abort_filling: FILLING → COMPLETE"
1648 );
1649 Ok(failed_epoch)
1650 }
1651
1652 pub fn abort_flush(&mut self) -> Result<()> {
1661 if self.phase != ConsolidationPhase::Flushing {
1662 return Err(FrankenError::Internal(format!(
1663 "abort_flush called in {:?} phase, expected Flushing",
1664 self.phase
1665 )));
1666 }
1667
1668 if self.next_epoch_batches.is_empty() {
1672 self.phase = ConsolidationPhase::Complete;
1673 self.filling_started = None;
1674 self.promoted_epoch_flusher_vacant = false;
1675 } else {
1676 self.pending_batches = std::mem::take(&mut self.next_epoch_batches);
1677 self.pending_frame_count = self.next_epoch_frame_count;
1678 self.next_epoch_frame_count = 0;
1679 self.phase = ConsolidationPhase::Filling;
1680 self.filling_started = Some(Instant::now());
1681 self.promoted_epoch_flusher_vacant = true;
1682 }
1684
1685 debug!(
1686 target: "fsqlite_wal::group_commit",
1687 epoch = self.epoch,
1688 "abort_flush: FLUSHING → {:?}",
1689 self.phase
1690 );
1691
1692 Ok(())
1693 }
1694
1695 fn transition_to_filling(&mut self) {
1697 self.phase = ConsolidationPhase::Filling;
1698 self.filling_started = None;
1699 self.promoted_epoch_flusher_vacant = false;
1700 trace!(
1701 target: "fsqlite_wal::group_commit",
1702 epoch = self.epoch,
1703 "COMPLETE → FILLING"
1704 );
1705 }
1706
1707 #[must_use]
1709 pub const fn completed_epoch(&self) -> u64 {
1710 self.completed_epoch
1711 }
1712}
1713
1714pub async fn write_consolidated_frames<F: VfsFile>(
1729 cx: &Cx,
1730 wal: &mut WalFile<F>,
1731 batches: &[TransactionFrameBatch],
1732) -> Result<usize> {
1733 let frame_size = wal.frame_size();
1734 let total_frames: usize = batches.iter().map(TransactionFrameBatch::frame_count).sum();
1735 if total_frames == 0 {
1736 return Ok(0);
1737 }
1738
1739 let total_bytes = total_frames
1740 .checked_mul(frame_size)
1741 .ok_or_else(|| FrankenError::Internal("frame batch size overflow".to_owned()))?;
1742 let frame_refs = batches.iter().flat_map(|batch| {
1743 batch.frames.iter().map(|frame| WalAppendFrameRef {
1744 page_number: frame.page_number,
1745 page_data: &frame.page_data,
1746 db_size_if_commit: frame.db_size_if_commit,
1747 })
1748 });
1749
1750 let span = tracing::info_span!(
1751 target: "fsqlite_wal::group_commit",
1752 "consolidated_write",
1753 total_frames,
1754 total_bytes,
1755 batches = batches.len(),
1756 );
1757 let _guard = span.enter();
1758
1759 wal.append_frame_iter(cx, total_frames, frame_refs).await?;
1760 wal.durable_sync(cx, SyncKind::FullDurable)?;
1761 let bytes_written = u64::try_from(total_bytes).unwrap_or(u64::MAX);
1762
1763 info!(
1764 target: "fsqlite_wal::group_commit",
1765 frames_written = total_frames,
1766 bytes_written,
1767 batches = batches.len(),
1768 "consolidated write + fsync complete"
1769 );
1770
1771 Ok(total_frames)
1772}
1773
1774#[cfg(test)]
1779mod tests {
1780 use fsqlite_types::flags::VfsOpenFlags;
1781 use fsqlite_vfs::MemoryVfs;
1782 use fsqlite_vfs::traits::Vfs;
1783
1784 use super::*;
1785 use crate::checksum::WalSalts;
1786 use crate::test_support::FutureResultTestExt as _;
1787
1788 const PAGE_SIZE: u32 = 4096;
1789
1790 fn test_cx() -> Cx {
1791 Cx::default()
1792 }
1793
1794 fn test_salts() -> WalSalts {
1795 WalSalts {
1796 salt1: 0xDEAD_BEEF,
1797 salt2: 0xCAFE_BABE,
1798 }
1799 }
1800
1801 fn sample_page(seed: u8) -> Vec<u8> {
1802 let page_size = usize::try_from(PAGE_SIZE).expect("page size fits usize");
1803 let mut page = vec![0u8; page_size];
1804 for (i, byte) in page.iter_mut().enumerate() {
1805 let reduced = u8::try_from(i % 251).expect("modulo fits u8");
1806 *byte = reduced ^ seed;
1807 }
1808 page
1809 }
1810
1811 fn open_wal_file(vfs: &MemoryVfs, cx: &Cx) -> <MemoryVfs as Vfs>::File {
1812 let flags = VfsOpenFlags::READWRITE | VfsOpenFlags::CREATE | VfsOpenFlags::WAL;
1813 let (file, _) = vfs
1814 .open(cx, Some(std::path::Path::new("test.db-wal")), flags)
1815 .expect("open WAL file");
1816 file
1817 }
1818
1819 struct ResetGlobalConsolidationMetrics;
1820
1821 impl Drop for ResetGlobalConsolidationMetrics {
1822 fn drop(&mut self) {
1823 GLOBAL_CONSOLIDATION_METRICS.reset();
1824 }
1825 }
1826
1827 fn with_global_consolidation_metrics<T>(body: impl FnOnce() -> T) -> T {
1828 let _guard = GLOBAL_CONSOLIDATION_METRICS_TEST_LOCK
1829 .lock()
1830 .expect("global consolidation metrics test lock poisoned");
1831 let _reset = ResetGlobalConsolidationMetrics;
1832 GLOBAL_CONSOLIDATION_METRICS.reset();
1833 body()
1834 }
1835
1836 #[test]
1839 fn test_consolidator_initial_state() {
1840 let c = GroupCommitConsolidator::new(GroupCommitConfig::default());
1841 assert_eq!(c.phase(), ConsolidationPhase::Filling);
1842 assert_eq!(c.epoch(), 0);
1843 assert_eq!(c.pending_frame_count(), 0);
1844 assert_eq!(c.pending_batch_count(), 0);
1845 }
1846
1847 #[test]
1848 fn test_consolidator_first_writer_becomes_flusher() {
1849 let mut c = GroupCommitConsolidator::new(GroupCommitConfig::default());
1850 let batch = TransactionFrameBatch::new(vec![FrameSubmission {
1851 page_number: 1,
1852 page_data: sample_page(0x01),
1853 db_size_if_commit: 0,
1854 }]);
1855 let receipt = c.submit_batch(batch).unwrap();
1856 assert_eq!(receipt.outcome, SubmitOutcome::Flusher);
1857 assert_eq!(receipt.target_epoch, 1);
1858 assert_eq!(c.pending_frame_count(), 1);
1859 assert_eq!(c.pending_batch_count(), 1);
1860 }
1861
1862 #[test]
1863 fn test_consolidator_second_writer_becomes_waiter() {
1864 let mut c = GroupCommitConsolidator::new(GroupCommitConfig::default());
1865
1866 let batch1 = TransactionFrameBatch::new(vec![FrameSubmission {
1867 page_number: 1,
1868 page_data: sample_page(0x01),
1869 db_size_if_commit: 0,
1870 }]);
1871 assert_eq!(
1872 c.submit_batch(batch1).unwrap().outcome,
1873 SubmitOutcome::Flusher
1874 );
1875
1876 let batch2 = TransactionFrameBatch::new(vec![FrameSubmission {
1877 page_number: 2,
1878 page_data: sample_page(0x02),
1879 db_size_if_commit: 0,
1880 }]);
1881 assert_eq!(
1882 c.submit_batch(batch2).unwrap().outcome,
1883 SubmitOutcome::Waiter
1884 );
1885 assert_eq!(c.pending_frame_count(), 2);
1886 assert_eq!(c.pending_batch_count(), 2);
1887 }
1888
1889 #[test]
1890 fn test_consolidator_cancelled_filling_epoch_is_consumed_atomically() {
1891 let mut c = GroupCommitConsolidator::new(GroupCommitConfig::default());
1892 for page_number in 1..=2 {
1893 let receipt = c
1894 .submit_batch(TransactionFrameBatch::new(vec![FrameSubmission {
1895 page_number,
1896 page_data: sample_page(u8::try_from(page_number).unwrap()),
1897 db_size_if_commit: page_number,
1898 }]))
1899 .unwrap();
1900 assert_eq!(
1901 receipt.target_epoch, 1,
1902 "every member of the filling group must share the failed epoch"
1903 );
1904 }
1905
1906 assert_eq!(c.abort_filling(1).unwrap(), 1);
1907 assert_eq!(c.phase(), ConsolidationPhase::Complete);
1908 assert_eq!(c.epoch(), 1);
1909 assert_eq!(c.pending_batch_count(), 0);
1910 assert_eq!(c.pending_frame_count(), 0);
1911
1912 let replacement = c
1913 .submit_batch(TransactionFrameBatch::new(vec![FrameSubmission {
1914 page_number: 3,
1915 page_data: sample_page(0x03),
1916 db_size_if_commit: 3,
1917 }]))
1918 .unwrap();
1919 assert_eq!(replacement.outcome, SubmitOutcome::Flusher);
1920 assert_eq!(
1921 replacement.target_epoch, 2,
1922 "a retained failure for epoch 1 must not poison the next group"
1923 );
1924 }
1925
1926 #[test]
1927 fn test_consolidator_cancelled_filling_obligation_cannot_consume_newer_epoch() {
1928 let mut c = GroupCommitConsolidator::new(GroupCommitConfig::default());
1929 let receipt = c
1930 .submit_batch(TransactionFrameBatch::new(vec![FrameSubmission {
1931 page_number: 1,
1932 page_data: sample_page(0x01),
1933 db_size_if_commit: 1,
1934 }]))
1935 .unwrap();
1936 assert_eq!(receipt.target_epoch, 1);
1937
1938 let error = c
1939 .abort_filling(2)
1940 .expect_err("a stale obligation must not consume the active filling epoch");
1941 assert!(
1942 error.to_string().contains("expected epoch 2"),
1943 "unexpected error: {error}"
1944 );
1945 assert_eq!(c.phase(), ConsolidationPhase::Filling);
1946 assert_eq!(c.epoch(), 0);
1947 assert_eq!(c.pending_batch_count(), 1);
1948 assert_eq!(c.pending_frame_count(), 1);
1949 }
1950
1951 #[test]
1952 fn test_consolidator_filling_flushing_complete_cycle() {
1953 let mut c = GroupCommitConsolidator::new(GroupCommitConfig::default());
1954
1955 for i in 0..3u8 {
1957 let batch = TransactionFrameBatch::new(vec![FrameSubmission {
1958 page_number: u32::from(i) + 1,
1959 page_data: sample_page(i),
1960 db_size_if_commit: if i == 2 { 3 } else { 0 },
1961 }]);
1962 c.submit_batch(batch).unwrap();
1963 }
1964 assert_eq!(c.phase(), ConsolidationPhase::Filling);
1965 assert_eq!(c.pending_frame_count(), 3);
1966
1967 let batches = c.begin_flush().unwrap();
1969 assert_eq!(c.phase(), ConsolidationPhase::Flushing);
1970 assert_eq!(batches.len(), 3);
1971 assert_eq!(c.epoch(), 1);
1972 assert_eq!(c.pending_frame_count(), 0);
1973
1974 let batch_extra = TransactionFrameBatch::new(vec![FrameSubmission {
1976 page_number: 10,
1977 page_data: sample_page(0x10),
1978 db_size_if_commit: 0,
1979 }]);
1980 let receipt = c.submit_batch(batch_extra).unwrap();
1981 assert_eq!(receipt.outcome, SubmitOutcome::Waiter);
1982 assert_eq!(
1983 receipt.target_epoch, 2,
1984 "pipelined submissions belong to the promoted next epoch"
1985 );
1986
1987 let promoted = c.complete_flush().unwrap();
1989 assert!(promoted);
1990 assert_eq!(c.phase(), ConsolidationPhase::Filling);
1991 assert_eq!(c.completed_epoch(), 1);
1992 assert_eq!(c.pending_batch_count(), 1);
1993 assert!(c.has_flusher_vacancy());
1994
1995 let batches = c.begin_flush().unwrap();
1997 assert_eq!(c.phase(), ConsolidationPhase::Flushing);
1998 assert_eq!(c.epoch(), 2);
1999 assert_eq!(batches.len(), 1);
2000 }
2001
2002 #[test]
2003 fn test_consolidator_auto_transitions_complete_to_filling() {
2004 let mut c = GroupCommitConsolidator::new(GroupCommitConfig::default());
2005
2006 let batch1 = TransactionFrameBatch::new(vec![FrameSubmission {
2008 page_number: 1,
2009 page_data: sample_page(0x01),
2010 db_size_if_commit: 1,
2011 }]);
2012 c.submit_batch(batch1).unwrap();
2013 c.begin_flush().unwrap();
2014 c.complete_flush().unwrap();
2015 assert_eq!(c.phase(), ConsolidationPhase::Complete);
2016
2017 let batch2 = TransactionFrameBatch::new(vec![FrameSubmission {
2019 page_number: 2,
2020 page_data: sample_page(0x02),
2021 db_size_if_commit: 2,
2022 }]);
2023 let receipt = c.submit_batch(batch2).unwrap();
2024 assert_eq!(receipt.outcome, SubmitOutcome::Flusher);
2025 assert_eq!(receipt.target_epoch, 2);
2026 assert_eq!(c.phase(), ConsolidationPhase::Filling);
2027 }
2028
2029 #[test]
2030 fn test_consolidator_should_flush_on_max_group_size() {
2031 let config = GroupCommitConfig {
2032 max_group_size: 3,
2033 ..GroupCommitConfig::default()
2034 };
2035 let mut c = GroupCommitConsolidator::new(config);
2036
2037 for i in 0..2u8 {
2039 c.submit_batch(TransactionFrameBatch::new(vec![FrameSubmission {
2040 page_number: u32::from(i) + 1,
2041 page_data: sample_page(i),
2042 db_size_if_commit: 0,
2043 }]))
2044 .unwrap();
2045 }
2046 assert!(!c.should_flush_now());
2047
2048 c.submit_batch(TransactionFrameBatch::new(vec![FrameSubmission {
2050 page_number: 3,
2051 page_data: sample_page(2),
2052 db_size_if_commit: 3,
2053 }]))
2054 .unwrap();
2055 assert!(c.should_flush_now());
2056 }
2057
2058 #[test]
2059 fn test_consolidator_begin_flush_errors_in_wrong_phase() {
2060 let mut c = GroupCommitConsolidator::new(GroupCommitConfig::default());
2061
2062 c.submit_batch(TransactionFrameBatch::new(vec![FrameSubmission {
2064 page_number: 1,
2065 page_data: sample_page(0x01),
2066 db_size_if_commit: 1,
2067 }]))
2068 .unwrap();
2069 c.begin_flush().unwrap();
2070
2071 assert!(c.begin_flush().is_err());
2073 }
2074
2075 #[test]
2076 fn test_consolidator_complete_flush_errors_in_wrong_phase() {
2077 let c = &mut GroupCommitConsolidator::new(GroupCommitConfig::default());
2078 assert!(c.complete_flush().is_err());
2080 }
2081
2082 #[test]
2083 fn test_consolidator_abort_flush_releases_epoch_and_allows_next_cycle() {
2084 let mut c = GroupCommitConsolidator::new(GroupCommitConfig::default());
2085
2086 c.submit_batch(TransactionFrameBatch::new(vec![FrameSubmission {
2087 page_number: 1,
2088 page_data: sample_page(0x01),
2089 db_size_if_commit: 1,
2090 }]))
2091 .unwrap();
2092 c.begin_flush().unwrap();
2093 c.abort_flush().unwrap();
2094 assert_eq!(c.phase(), ConsolidationPhase::Complete);
2095 assert_eq!(c.completed_epoch(), 0);
2096
2097 let receipt = c
2098 .submit_batch(TransactionFrameBatch::new(vec![FrameSubmission {
2099 page_number: 2,
2100 page_data: sample_page(0x02),
2101 db_size_if_commit: 2,
2102 }]))
2103 .unwrap();
2104 assert_eq!(receipt.outcome, SubmitOutcome::Flusher);
2105 assert_eq!(receipt.target_epoch, 2);
2106 assert_eq!(c.phase(), ConsolidationPhase::Filling);
2107 assert_eq!(c.pending_batch_count(), 1);
2108 assert_eq!(c.epoch(), 1);
2109 }
2110
2111 #[test]
2112 fn test_consolidator_promoted_epoch_exposes_flusher_takeover_claim_if_original_stops() {
2113 let mut c = GroupCommitConsolidator::new(GroupCommitConfig::default());
2114
2115 let batch1 = TransactionFrameBatch::new(vec![FrameSubmission {
2116 page_number: 1,
2117 page_data: sample_page(0x01),
2118 db_size_if_commit: 1,
2119 }]);
2120 assert_eq!(
2121 c.submit_batch(batch1).unwrap().outcome,
2122 SubmitOutcome::Flusher
2123 );
2124
2125 let _flushing_batches = c.begin_flush().unwrap();
2126 assert_eq!(c.epoch(), 1);
2127 assert_eq!(c.phase(), ConsolidationPhase::Flushing);
2128
2129 let pipelined_batch = TransactionFrameBatch::new(vec![FrameSubmission {
2130 page_number: 2,
2131 page_data: sample_page(0x02),
2132 db_size_if_commit: 2,
2133 }]);
2134 let receipt = c.submit_batch(pipelined_batch).unwrap();
2135 assert_eq!(receipt.outcome, SubmitOutcome::Waiter);
2136 assert_eq!(receipt.target_epoch, 2);
2137
2138 let promoted = c.complete_flush().unwrap();
2139 assert!(
2140 promoted,
2141 "pipelined epoch must be promoted back to FILLING for the next flush"
2142 );
2143 assert_eq!(c.phase(), ConsolidationPhase::Filling);
2144 assert_eq!(c.pending_batch_count(), 1);
2145 assert_eq!(c.pending_frame_count(), 1);
2146 assert_eq!(c.epoch(), 1);
2147 assert_eq!(c.completed_epoch(), 1);
2148 assert!(c.has_flusher_vacancy());
2149
2150 let takeover_batch = TransactionFrameBatch::new(vec![FrameSubmission {
2151 page_number: 3,
2152 page_data: sample_page(0x03),
2153 db_size_if_commit: 3,
2154 }]);
2155 let receipt = c.submit_batch(takeover_batch).unwrap();
2156 assert_eq!(
2157 receipt.outcome,
2158 SubmitOutcome::Waiter,
2159 "promoted work stays queued until someone explicitly claims the flusher vacancy"
2160 );
2161 assert_eq!(receipt.target_epoch, 2);
2162 assert!(c.claim_flusher_vacancy());
2163 assert!(!c.has_flusher_vacancy());
2164 assert!(!c.claim_flusher_vacancy());
2165
2166 let takeover_batches = c.begin_flush().unwrap();
2167 assert_eq!(c.phase(), ConsolidationPhase::Flushing);
2168 assert_eq!(c.epoch(), 2);
2169 assert_eq!(takeover_batches.len(), 2);
2170 }
2171
2172 #[test]
2175 fn test_consolidated_write_single_batch() {
2176 let cx = test_cx();
2177 let vfs = MemoryVfs::new();
2178 let file = open_wal_file(&vfs, &cx);
2179 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2180
2181 let batches = vec![TransactionFrameBatch::new(vec![
2182 FrameSubmission {
2183 page_number: 1,
2184 page_data: sample_page(0x01),
2185 db_size_if_commit: 0,
2186 },
2187 FrameSubmission {
2188 page_number: 2,
2189 page_data: sample_page(0x02),
2190 db_size_if_commit: 0,
2191 },
2192 FrameSubmission {
2193 page_number: 3,
2194 page_data: sample_page(0x03),
2195 db_size_if_commit: 3,
2196 },
2197 ])];
2198
2199 let written = write_consolidated_frames(&cx, &mut wal, &batches).expect("write");
2200 assert_eq!(written, 3);
2201 assert_eq!(wal.frame_count(), 3);
2202
2203 for i in 0..3u32 {
2205 let (header, data) = wal
2206 .read_frame(&cx, usize::try_from(i).unwrap())
2207 .expect("read frame");
2208 assert_eq!(header.page_number, i + 1);
2209 let seed = u8::try_from(i + 1).expect("fits");
2210 assert_eq!(data, sample_page(seed));
2211 }
2212
2213 let last_header = wal.read_frame_header(&cx, 2).expect("read header");
2215 assert!(last_header.is_commit());
2216 assert_eq!(last_header.db_size, 3);
2217
2218 wal.close(&cx).expect("close WAL");
2219 }
2220
2221 #[test]
2222 fn test_consolidated_write_multiple_batches() {
2223 let cx = test_cx();
2224 let vfs = MemoryVfs::new();
2225 let file = open_wal_file(&vfs, &cx);
2226 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2227
2228 let batches = vec![
2230 TransactionFrameBatch::new(vec![
2231 FrameSubmission {
2232 page_number: 10,
2233 page_data: sample_page(0x10),
2234 db_size_if_commit: 0,
2235 },
2236 FrameSubmission {
2237 page_number: 11,
2238 page_data: sample_page(0x11),
2239 db_size_if_commit: 11,
2240 },
2241 ]),
2242 TransactionFrameBatch::new(vec![
2243 FrameSubmission {
2244 page_number: 20,
2245 page_data: sample_page(0x20),
2246 db_size_if_commit: 0,
2247 },
2248 FrameSubmission {
2249 page_number: 21,
2250 page_data: sample_page(0x21),
2251 db_size_if_commit: 21,
2252 },
2253 ]),
2254 ];
2255
2256 let written = write_consolidated_frames(&cx, &mut wal, &batches).expect("write");
2257 assert_eq!(written, 4);
2258 assert_eq!(wal.frame_count(), 4);
2259
2260 let expected_pages = [10, 11, 20, 21];
2262 for (i, &expected_page) in expected_pages.iter().enumerate() {
2263 let header = wal.read_frame_header(&cx, i).expect("read header");
2264 assert_eq!(header.page_number, expected_page);
2265 }
2266
2267 wal.close(&cx).expect("close WAL");
2268 }
2269
2270 #[test]
2271 fn test_consolidated_write_preserves_checksum_chain() {
2272 let cx = test_cx();
2273 let vfs = MemoryVfs::new();
2274 let file = open_wal_file(&vfs, &cx);
2275 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2276
2277 wal.append_frame(&cx, 1, &sample_page(0x01), 0)
2279 .expect("append");
2280 wal.append_frame(&cx, 2, &sample_page(0x02), 2)
2281 .expect("append commit");
2282 assert_eq!(wal.frame_count(), 2);
2283 let _checksum_after_2 = wal.running_checksum();
2284
2285 let batches = vec![TransactionFrameBatch::new(vec![
2287 FrameSubmission {
2288 page_number: 3,
2289 page_data: sample_page(0x03),
2290 db_size_if_commit: 0,
2291 },
2292 FrameSubmission {
2293 page_number: 4,
2294 page_data: sample_page(0x04),
2295 db_size_if_commit: 4,
2296 },
2297 ])];
2298
2299 let written = write_consolidated_frames(&cx, &mut wal, &batches).expect("write");
2300 assert_eq!(written, 2);
2301 assert_eq!(wal.frame_count(), 4);
2302
2303 wal.close(&cx).expect("close WAL");
2305 let file2 = open_wal_file(&vfs, &cx);
2306 let wal2 = WalFile::open(&cx, file2).expect("reopen WAL");
2307 assert_eq!(
2308 wal2.frame_count(),
2309 4,
2310 "all 4 frames should be valid on reopen (checksum chain intact)"
2311 );
2312
2313 wal2.close(&cx).expect("close WAL");
2314 }
2315
2316 #[test]
2317 fn test_consolidated_write_empty_batch() {
2318 let cx = test_cx();
2319 let vfs = MemoryVfs::new();
2320 let file = open_wal_file(&vfs, &cx);
2321 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2322
2323 let written = write_consolidated_frames(&cx, &mut wal, &[]).expect("write empty");
2324 assert_eq!(written, 0);
2325 assert_eq!(wal.frame_count(), 0);
2326
2327 wal.close(&cx).expect("close WAL");
2328 }
2329
2330 #[test]
2331 fn test_consolidated_write_page_size_mismatch_rejected() {
2332 let cx = test_cx();
2333 let vfs = MemoryVfs::new();
2334 let file = open_wal_file(&vfs, &cx);
2335 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2336
2337 let batches = vec![TransactionFrameBatch::new(vec![FrameSubmission {
2338 page_number: 1,
2339 page_data: vec![0u8; 100], db_size_if_commit: 0,
2341 }])];
2342
2343 assert!(
2344 write_consolidated_frames(&cx, &mut wal, &batches).is_err(),
2345 "wrong page size should be rejected"
2346 );
2347
2348 wal.close(&cx).expect("close WAL");
2349 }
2350
2351 #[test]
2354 fn test_consolidation_metrics_basic() {
2355 let m = ConsolidationMetrics::new();
2356 m.record_flush(10, 3, 500);
2357 m.record_flush(20, 5, 1000);
2358 m.record_wait(100);
2359 m.record_busy_retry();
2360 m.record_busy_retry();
2361
2362 let snap = m.snapshot();
2363 assert_eq!(snap.groups_flushed, 2);
2364 assert_eq!(snap.frames_consolidated, 30);
2365 assert_eq!(snap.transactions_batched, 8);
2366 assert_eq!(snap.fsyncs_total, 2);
2367 assert_eq!(snap.flush_duration_us_total, 1500);
2368 assert_eq!(snap.wait_duration_us_total, 100);
2369 assert_eq!(snap.max_group_size_observed, 20);
2370 assert_eq!(snap.busy_retries, 2);
2371 assert_eq!(snap.avg_group_size(), 15);
2372 assert_eq!(snap.avg_transactions_per_group(), 4);
2373 assert_eq!(snap.avg_flush_duration_us(), 750);
2374 assert_eq!(snap.fsync_reduction_ratio(), 4);
2375 }
2376
2377 #[test]
2378 fn test_consolidation_metrics_reset() {
2379 let m = ConsolidationMetrics::new();
2380 m.record_flush(10, 3, 500);
2381 m.record_busy_retry();
2382 m.reset();
2383 let snap = m.snapshot();
2384 assert_eq!(snap.groups_flushed, 0);
2385 assert_eq!(snap.frames_consolidated, 0);
2386 assert_eq!(snap.busy_retries, 0);
2387 }
2388
2389 #[test]
2390 fn test_consolidation_metrics_display() {
2391 let m = ConsolidationMetrics::new();
2392 m.record_flush(10, 5, 500);
2393 m.record_busy_retry();
2394 let s = m.snapshot().to_string();
2395 assert!(s.contains("groups=1"));
2396 assert!(s.contains("frames=10"));
2397 assert!(s.contains("txns=5"));
2398 assert!(s.contains("busy_retries=1"));
2399 assert!(s.contains("reduction=5x"));
2400 }
2401
2402 #[test]
2403 fn test_consolidation_metrics_snapshot_serializes_phase_distributions() {
2404 let m = ConsolidationMetrics::new();
2405 m.record_phase_timing(10, 5, 2, true, 20, 3, 8, 50, 30, 0);
2406 m.record_phase_timing(11, 6, 2, false, 0, 0, 0, 0, 0, 100);
2407 m.wake_reasons.notify.fetch_add(1, Ordering::Relaxed);
2408 m.wake_reasons.timeout.fetch_add(2, Ordering::Relaxed);
2409
2410 let encoded =
2411 serde_json::to_value(m.snapshot()).expect("consolidation snapshot should serialize");
2412
2413 assert_eq!(
2414 encoded["hist_wal_append"]["count"].as_u64(),
2415 Some(1),
2416 "flusher histogram should serialize sample counts"
2417 );
2418 assert_eq!(
2419 encoded["hist_waiter_epoch_wait"]["count"].as_u64(),
2420 Some(1),
2421 "waiter histogram should serialize sample counts"
2422 );
2423 assert_eq!(
2424 encoded["wake_reasons"]["notify"].as_u64(),
2425 Some(1),
2426 "wake reasons should serialize nested counters"
2427 );
2428 assert_eq!(
2429 encoded["wake_reasons"]["timeout"].as_u64(),
2430 Some(2),
2431 "wake reasons should preserve all fields"
2432 );
2433 }
2434
2435 #[test]
2436 fn test_phase_histogram_recent_tail_decays_without_snapshot() {
2437 let h = PhaseHistogram::new();
2438 h.record(1_600);
2439 assert_eq!(h.recent_tail_us(), 1_600);
2440
2441 h.record(0);
2442 assert_eq!(
2443 h.recent_tail_us(),
2444 1_500,
2445 "recent tail should decay by one sixteenth per sample"
2446 );
2447
2448 h.record(2_000);
2449 assert_eq!(
2450 h.recent_tail_us(),
2451 2_000,
2452 "new spikes should replace the decayed tail immediately"
2453 );
2454
2455 h.reset();
2456 assert_eq!(h.recent_tail_us(), 0);
2457 }
2458
2459 #[test]
2465 fn test_fsync_reduction_deterministic_proof() {
2466 with_global_consolidation_metrics(|| {
2467 let n = 10_u64;
2468 GLOBAL_CONSOLIDATION_METRICS.record_flush(n * 2, n, 1000);
2469
2470 let snap = GLOBAL_CONSOLIDATION_METRICS.snapshot();
2471 assert_eq!(snap.fsyncs_total, 1);
2472 assert_eq!(snap.transactions_batched, n);
2473 assert_eq!(
2474 snap.fsync_reduction_ratio(),
2475 n,
2476 "10 transactions in 1 fsync = 10x reduction"
2477 );
2478 });
2479 }
2480
2481 #[test]
2484 fn test_config_validated_clamps_zero_group_size() {
2485 let config = GroupCommitConfig {
2486 max_group_size: 0,
2487 ..GroupCommitConfig::default()
2488 };
2489 let validated = config.validated();
2490 assert_eq!(validated.max_group_size, 1);
2491 }
2492
2493 #[test]
2494 fn test_config_validated_clamps_excessive_delay() {
2495 let config = GroupCommitConfig {
2496 max_group_delay: Duration::from_millis(100),
2497 max_group_delay_ceiling: Duration::from_millis(10),
2498 ..GroupCommitConfig::default()
2499 };
2500 let validated = config.validated();
2501 assert_eq!(validated.max_group_delay, Duration::from_millis(10));
2502 }
2503
2504 #[test]
2507 fn test_batch_has_commit_frame() {
2508 let batch_with_commit = TransactionFrameBatch::new(vec![
2509 FrameSubmission {
2510 page_number: 1,
2511 page_data: vec![],
2512 db_size_if_commit: 0,
2513 },
2514 FrameSubmission {
2515 page_number: 2,
2516 page_data: vec![],
2517 db_size_if_commit: 5,
2518 },
2519 ]);
2520 assert!(batch_with_commit.has_commit_frame());
2521
2522 let batch_without = TransactionFrameBatch::new(vec![FrameSubmission {
2523 page_number: 1,
2524 page_data: vec![],
2525 db_size_if_commit: 0,
2526 }]);
2527 assert!(!batch_without.has_commit_frame());
2528 }
2529
2530 #[test]
2533 fn test_full_consolidation_cycle_with_wal_write() {
2534 let cx = test_cx();
2535 let vfs = MemoryVfs::new();
2536 let file = open_wal_file(&vfs, &cx);
2537 let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2538
2539 let mut consolidator = GroupCommitConsolidator::new(GroupCommitConfig {
2540 max_group_size: 10,
2541 ..GroupCommitConfig::default()
2542 });
2543
2544 let batch1 = TransactionFrameBatch::new(vec![
2546 FrameSubmission {
2547 page_number: 1,
2548 page_data: sample_page(0x01),
2549 db_size_if_commit: 0,
2550 },
2551 FrameSubmission {
2552 page_number: 2,
2553 page_data: sample_page(0x02),
2554 db_size_if_commit: 2,
2555 },
2556 ]);
2557 let receipt1 = consolidator.submit_batch(batch1).unwrap();
2558 assert_eq!(receipt1.outcome, SubmitOutcome::Flusher);
2559 assert_eq!(receipt1.target_epoch, 1);
2560
2561 let batch2 = TransactionFrameBatch::new(vec![FrameSubmission {
2562 page_number: 3,
2563 page_data: sample_page(0x03),
2564 db_size_if_commit: 3,
2565 }]);
2566 let receipt2 = consolidator.submit_batch(batch2).unwrap();
2567 assert_eq!(receipt2.outcome, SubmitOutcome::Waiter);
2568 assert_eq!(receipt2.target_epoch, 1);
2569
2570 let batch3 = TransactionFrameBatch::new(vec![
2571 FrameSubmission {
2572 page_number: 4,
2573 page_data: sample_page(0x04),
2574 db_size_if_commit: 0,
2575 },
2576 FrameSubmission {
2577 page_number: 5,
2578 page_data: sample_page(0x05),
2579 db_size_if_commit: 5,
2580 },
2581 ]);
2582 let receipt3 = consolidator.submit_batch(batch3).unwrap();
2583 assert_eq!(receipt3.outcome, SubmitOutcome::Waiter);
2584 assert_eq!(receipt3.target_epoch, 1);
2585
2586 let batches = consolidator.begin_flush().unwrap();
2588 assert_eq!(batches.len(), 3);
2589
2590 let written = write_consolidated_frames(&cx, &mut wal, &batches).expect("write");
2592 assert_eq!(written, 5);
2593
2594 consolidator.complete_flush().unwrap();
2596 assert_eq!(consolidator.phase(), ConsolidationPhase::Complete);
2597
2598 assert_eq!(wal.frame_count(), 5);
2600
2601 wal.close(&cx).expect("close WAL");
2603 let file2 = open_wal_file(&vfs, &cx);
2604 let wal2 = WalFile::open(&cx, file2).expect("reopen WAL");
2605 assert_eq!(wal2.frame_count(), 5, "all frames valid on reopen");
2606 wal2.close(&cx).expect("close WAL");
2607 }
2608
2609 #[test]
2612 fn phase_histogram_empty_returns_zeros() {
2613 let h = PhaseHistogram::new();
2614 let p = h.percentiles();
2615 assert_eq!(p.count, 0);
2616 assert_eq!(p.p50, 0);
2617 assert_eq!(p.p99, 0);
2618 assert_eq!(p.max, 0);
2619 assert_eq!(p.mean_us, 0);
2620 }
2621
2622 #[test]
2623 fn phase_histogram_single_sample() {
2624 let h = PhaseHistogram::new();
2625 h.record(42);
2626 let p = h.percentiles();
2627 assert_eq!(p.count, 1);
2628 assert_eq!(p.p50, 42);
2629 assert_eq!(p.p95, 42);
2630 assert_eq!(p.p99, 42);
2631 assert_eq!(p.max, 42);
2632 assert_eq!(p.mean_us, 42);
2633 }
2634
2635 #[test]
2636 fn phase_histogram_percentiles_ordered() {
2637 let h = PhaseHistogram::new();
2638 for i in 1..=100 {
2639 h.record(i);
2640 }
2641 let p = h.percentiles();
2642 assert_eq!(p.count, 100);
2643 assert!(p.p50 <= p.p95, "p50={} should be <= p95={}", p.p50, p.p95);
2644 assert!(p.p95 <= p.p99, "p95={} should be <= p99={}", p.p95, p.p99);
2645 assert!(p.p99 <= p.max, "p99={} should be <= max={}", p.p99, p.max);
2646 assert_eq!(p.max, 100);
2647 assert_eq!(p.mean_us, 50);
2648 }
2649
2650 #[test]
2651 fn phase_histogram_max_tracks_outlier() {
2652 let h = PhaseHistogram::new();
2653 for _ in 0..100 {
2654 h.record(10);
2655 }
2656 h.record(99999);
2657 let p = h.percentiles();
2658 assert_eq!(p.max, 99999);
2659 assert_eq!(p.p50, 10);
2660 }
2661
2662 #[test]
2663 fn phase_histogram_reset_clears_state() {
2664 let h = PhaseHistogram::new();
2665 for i in 0..50 {
2666 h.record(i * 10);
2667 }
2668 h.reset();
2669 let p = h.percentiles();
2670 assert_eq!(p.count, 0);
2671 assert_eq!(p.max, 0);
2672 }
2673
2674 #[test]
2675 fn phase_histogram_concurrent_writers_no_panic() {
2676 use std::sync::Arc;
2677 let h = Arc::new(PhaseHistogram::new());
2678 let barrier = Arc::new(std::sync::Barrier::new(4));
2679 let mut handles = Vec::new();
2680 for t in 0..4u64 {
2681 let h = Arc::clone(&h);
2682 let b = Arc::clone(&barrier);
2683 handles.push(std::thread::spawn(move || {
2684 b.wait();
2685 for i in 0..500 {
2686 h.record(t * 1000 + i);
2687 }
2688 }));
2689 }
2690 for handle in handles {
2691 handle.join().unwrap();
2692 }
2693 let p = h.percentiles();
2694 assert_eq!(p.count, 2000);
2695 assert!(p.max >= 3499);
2696 }
2697
2698 #[test]
2701 fn wake_reason_counters_track_all_reasons() {
2702 let w = WakeReasonCounters::new();
2703 w.notify.fetch_add(10, Ordering::Relaxed);
2704 w.timeout.fetch_add(3, Ordering::Relaxed);
2705 w.flusher_takeover.fetch_add(1, Ordering::Relaxed);
2706 w.failed_epoch.fetch_add(2, Ordering::Relaxed);
2707 w.busy_retry.fetch_add(5, Ordering::Relaxed);
2708 let s = w.snapshot();
2709 assert_eq!(s.notify, 10);
2710 assert_eq!(s.timeout, 3);
2711 assert_eq!(s.flusher_takeover, 1);
2712 assert_eq!(s.failed_epoch, 2);
2713 assert_eq!(s.busy_retry, 5);
2714 assert_eq!(s.total(), 21);
2715 }
2716
2717 #[test]
2718 fn wake_reason_reset_clears() {
2719 let w = WakeReasonCounters::new();
2720 w.notify.fetch_add(99, Ordering::Relaxed);
2721 w.reset();
2722 let s = w.snapshot();
2723 assert_eq!(s.total(), 0);
2724 }
2725
2726 #[test]
2729 fn consolidation_metrics_snapshot_includes_distributions() {
2730 with_global_consolidation_metrics(|| {
2731 for i in 0..10u64 {
2732 GLOBAL_CONSOLIDATION_METRICS.record_phase_timing(
2733 10 + i,
2734 5 + i,
2735 2,
2736 true,
2737 20 + i,
2738 3 + i,
2739 8 + i,
2740 50 + i,
2741 30 + i,
2742 0,
2743 );
2744 }
2745 for i in 0..5u64 {
2746 GLOBAL_CONSOLIDATION_METRICS.record_phase_timing(
2747 10 + i,
2748 5 + i,
2749 2,
2750 false,
2751 0,
2752 0,
2753 0,
2754 0,
2755 0,
2756 100 + i,
2757 );
2758 }
2759
2760 let snap = GLOBAL_CONSOLIDATION_METRICS.snapshot();
2761 assert_eq!(snap.hist_consolidator_lock_wait.count, 15);
2762 assert_eq!(snap.hist_arrival_wait.count, 10);
2763 assert_eq!(snap.hist_wal_append.count, 10);
2764 assert_eq!(snap.hist_waiter_epoch_wait.count, 5);
2765 assert_eq!(snap.hist_phase_b.count, 15);
2766 assert!(snap.hist_wal_append.p50 > 0);
2767 assert!(snap.hist_wal_append.max >= 59);
2768 });
2769 }
2770
2771 #[test]
2772 fn transaction_conflict_snapshot_debug_clone_copy_eq() {
2773 let generation = WalGenerationIdentity {
2774 checkpoint_seq: 0,
2775 salts: WalSalts { salt1: 0, salt2: 0 },
2776 };
2777 let a = TransactionConflictSnapshot {
2778 generation,
2779 last_commit_frame: Some(42),
2780 commit_count: 7,
2781 snapshot_db_size: 0,
2782 };
2783 let copied = a;
2784 assert_eq!(copied, a);
2785 let b = TransactionConflictSnapshot {
2786 generation,
2787 last_commit_frame: None,
2788 commit_count: 7,
2789 snapshot_db_size: 0,
2790 };
2791 assert_ne!(a, b);
2792 let dbg = format!("{a:?}");
2793 assert!(dbg.contains("TransactionConflictSnapshot"));
2794 }
2795
2796 #[test]
2797 fn transaction_frame_batch_context_default_and_eq() {
2798 let def = TransactionFrameBatchContext::default();
2799 assert_eq!(def.batch_id, 0);
2800 assert_eq!(def.lane_id, 0);
2801 assert_eq!(def.staged_frame_count, 0);
2802 assert_eq!(def.staging_elapsed_ns, 0);
2803 let other = TransactionFrameBatchContext {
2804 batch_id: 1,
2805 lane_id: 3,
2806 staged_frame_count: 10,
2807 staging_elapsed_ns: 500,
2808 };
2809 assert_ne!(def, other);
2810 let copied = other;
2811 assert_eq!(copied, other);
2812 let dbg = format!("{def:?}");
2813 assert!(dbg.contains("TransactionFrameBatchContext"));
2814 }
2815
2816 #[test]
2817 fn consolidation_phase_and_submit_outcome_all_variants() {
2818 let phases = [
2819 ConsolidationPhase::Filling,
2820 ConsolidationPhase::Flushing,
2821 ConsolidationPhase::Complete,
2822 ];
2823 for (i, p) in phases.iter().enumerate() {
2824 let copied = *p;
2825 assert_eq!(copied, *p);
2826 for (j, q) in phases.iter().enumerate() {
2827 assert_eq!(i == j, p == q);
2828 }
2829 }
2830 assert_ne!(SubmitOutcome::Flusher, SubmitOutcome::Waiter);
2831 let copied = SubmitOutcome::Flusher;
2832 assert_eq!(copied, SubmitOutcome::Flusher);
2833 let dbg = format!("{:?}", SubmitOutcome::Waiter);
2834 assert!(dbg.contains("Waiter"));
2835 }
2836
2837 #[test]
2838 fn transaction_frame_batch_builders() {
2839 let frame = FrameSubmission {
2840 page_number: 5,
2841 page_data: vec![0u8; 16],
2842 db_size_if_commit: 0,
2843 };
2844 let batch = TransactionFrameBatch::new(vec![frame.clone()])
2845 .with_conflict_snapshot(vec![5, 10], None)
2846 .with_context(TransactionFrameBatchContext {
2847 batch_id: 99,
2848 lane_id: 2,
2849 staged_frame_count: 1,
2850 staging_elapsed_ns: 100,
2851 });
2852 assert_eq!(batch.frame_count(), 1);
2853 assert!(!batch.has_commit_frame());
2854 assert_eq!(batch.conflict_pages, vec![5, 10]);
2855 assert!(batch.conflict_snapshot.is_none());
2856 assert_eq!(batch.context.batch_id, 99);
2857 assert_eq!(batch.context.lane_id, 2);
2858 }
2859
2860 #[test]
2861 fn group_commit_config_default_copy_debug() {
2862 let cfg = GroupCommitConfig::default();
2863 let copied = cfg;
2864 assert_eq!(copied.max_group_size, 64);
2865 assert_eq!(copied.max_group_delay, Duration::from_millis(1));
2866 assert_eq!(copied.max_group_delay_ceiling, Duration::from_millis(10));
2867 let dbg = format!("{cfg:?}");
2868 assert!(dbg.contains("GroupCommitConfig"));
2869 }
2870
2871 #[test]
2872 fn frame_submission_debug_clone() {
2873 let fs = FrameSubmission {
2874 page_number: 42,
2875 page_data: vec![0xAB; 8],
2876 db_size_if_commit: 0,
2877 };
2878 let cloned = fs.clone();
2879 assert_eq!(cloned.page_number, 42);
2880 assert_eq!(cloned.page_data.len(), 8);
2881 assert_eq!(cloned.db_size_if_commit, 0);
2882 let dbg = format!("{fs:?}");
2883 assert!(dbg.contains("FrameSubmission"));
2884 }
2885
2886 #[test]
2887 fn phase_percentiles_default_copy_eq() {
2888 let pp = PhasePercentiles::default();
2889 let copied = pp;
2890 assert_eq!(copied, pp);
2891 assert_eq!(pp.p50, 0);
2892 assert_eq!(pp.p99, 0);
2893 assert_eq!(pp.max, 0);
2894 assert_eq!(pp.count, 0);
2895 }
2896
2897 #[test]
2898 fn wake_reason_snapshot_default_total_zero() {
2899 let ws = WakeReasonSnapshot::default();
2900 assert_eq!(ws.total(), 0);
2901 let copied = ws;
2902 assert_eq!(copied, ws);
2903 let dbg = format!("{ws:?}");
2904 assert!(dbg.contains("WakeReasonSnapshot"));
2905 }
2906}