Skip to main content

fsqlite_wal/
group_commit.rs

1//! Group commit with consolidation for WAL frame writes (bd-ncivz.3).
2//!
3//! Amortizes `fsync` overhead across multiple concurrent transactions by
4//! batching WAL frame writes into a single I/O + fsync operation.
5//!
6//! # Consolidation Protocol
7//!
8//! Writers submit sealed frame batches to a consolidation queue.
9//! The protocol transitions through three phases:
10//!
11//! ```text
12//! FILLING ──▶ FLUSHING ──▶ COMPLETE ──▶ FILLING (next epoch)
13//! ```
14//!
15//! - **FILLING**: Accepting new frame batches from writers.
16//! - **FLUSHING**: The flusher (first writer to arrive) writes all accumulated
17//!   frames to the WAL file via a single consolidated I/O, then fsyncs.
18//! - **COMPLETE**: All waiters are notified; committed frames are durable.
19//!
20//! The first writer to enter a FILLING phase becomes the *flusher*.
21//! Subsequent writers add their frames and park on a condvar. When the
22//! flusher decides to flush (batch full OR max delay exceeded), it writes
23//! all accumulated frames, fsyncs once, and wakes all parked writers.
24//!
25//! # I/O Optimization
26//!
27//! Consolidated writes serialize all frame buffers into a single contiguous
28//! write to the WAL file, avoiding per-frame syscall overhead. The single
29//! `fsync` after the batch write makes all frames durable atomically.
30//!
31//! # Tuning
32//!
33//! - `max_group_size`: Maximum frames per group before forced flush (default: 64).
34//! - `max_group_delay`: Maximum time to wait for additional writers before
35//!   flushing (default: 1ms). Bounded to ensure tail latency.
36
37use 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/// Whether expensive per-substep consolidation timing is enabled.
62#[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
75/// Enable or disable WAL commit phase timing for explicit profiling windows.
76///
77/// Normal commits still update correctness-critical WAL state and cheap frame
78/// counters, but they do not need to sample the wall clock for every phase
79/// unless a caller is collecting a hot-path profile or detailed WAL metrics.
80pub fn set_commit_phase_timing_enabled(enabled: bool) -> bool {
81    COMMIT_PHASE_TIMING_ENABLED.swap(enabled, Ordering::Relaxed)
82}
83
84/// Whether commit phase timing has been explicitly enabled by a profiling caller.
85#[must_use]
86pub fn commit_phase_timing_forced_enabled() -> bool {
87    COMMIT_PHASE_TIMING_ENABLED.load(Ordering::Relaxed)
88}
89
90/// Whether commit phase timing should sample `Instant::now()`.
91#[must_use]
92pub fn commit_phase_timing_enabled() -> bool {
93    detailed_consolidation_metrics_enabled() || commit_phase_timing_forced_enabled()
94}
95
96// ---------------------------------------------------------------------------
97// Configuration
98// ---------------------------------------------------------------------------
99
100/// Configuration for group commit consolidation.
101#[derive(Debug, Clone, Copy)]
102pub struct GroupCommitConfig {
103    /// Maximum number of frames per consolidated group before forced flush.
104    ///
105    /// Default: 64 frames (~260 KB at 4 KB page size).
106    pub max_group_size: usize,
107
108    /// Maximum time to wait for additional writers before flushing.
109    ///
110    /// Default: 1ms. Bounded to ensure tail latency stays under 10ms.
111    pub max_group_delay: Duration,
112
113    /// Hard ceiling on group delay (the maximum the tunable can be set to).
114    ///
115    /// Default: 10ms. This is the absolute upper bound on commit latency
116    /// added by group commit batching.
117    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    /// Validate and clamp configuration values.
132    #[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// ---------------------------------------------------------------------------
145// Frame submission
146// ---------------------------------------------------------------------------
147
148/// A single WAL frame submitted for consolidated writing.
149#[derive(Debug, Clone)]
150pub struct FrameSubmission {
151    /// Database page number this frame writes.
152    pub page_number: u32,
153    /// Page data (must be exactly `page_size` bytes).
154    pub page_data: Vec<u8>,
155    /// Database size in pages for commit frames, or 0 for non-commit frames.
156    pub db_size_if_commit: u32,
157}
158
159/// A batch of frames from a single transaction, submitted atomically.
160#[derive(Debug, Clone)]
161pub struct TransactionFrameBatch {
162    /// Frames belonging to this transaction, in write order.
163    pub frames: Vec<FrameSubmission>,
164    /// Pages that must obey first-committer-wins against committed WAL frames
165    /// newer than this transaction's read snapshot.
166    ///
167    /// The process-local MVCC registry catches conflicts between connections
168    /// in the same process. These fields carry the same commit intent through
169    /// the process-global WAL group-commit queue so the eventual flusher can
170    /// also reject stale cross-process commits under the WAL append gate.
171    pub conflict_pages: Vec<u32>,
172    /// WAL visibility snapshot pinned when the submitting transaction began.
173    /// If another process has committed any of `conflict_pages` after this
174    /// horizon, the batch must fail with BUSY_SNAPSHOT instead of appending a
175    /// stale page image that could hide the other writer's committed rows.
176    pub conflict_snapshot: Option<TransactionConflictSnapshot>,
177    /// Full-page hashes captured from the submitting transaction's pinned
178    /// snapshot, keyed by exact page number.
179    ///
180    /// These are used only when an external checkpoint replaces/resets the WAL
181    /// generation while the transaction is open. A generation change alone
182    /// does not prove a write conflict: a stock SQLite reader can checkpoint
183    /// an otherwise unchanged WAL. The eventual flusher may admit that benign
184    /// transition only when every conflict candidate has a baseline and the
185    /// latest committed full-page image hashes identically.
186    pub conflict_page_baselines: Vec<TransactionConflictPageBaseline>,
187    /// Lane-local staging context captured before group-commit submission.
188    pub context: TransactionFrameBatchContext,
189    /// bd-gh302 / bd-0shxy: the exact durable freelist content this batch's
190    /// page-1 + trunk frames publish, when the submitting transaction
191    /// serialized freelist metadata (`None` otherwise). The publication is
192    /// derived from the transaction's BEGIN-TIME freelist view, so a peer may
193    /// have consumed one of these pages since; the flusher must validate the
194    /// list against the CURRENT durable freelist under the append gate and
195    /// fail the batch closed instead of resurrecting a consumed page
196    /// ("committed-freelist resurrection": one physical page granted to
197    /// multiple connections through re-published stale freelist state).
198    pub published_durable_freelist: Option<Vec<u32>>,
199    /// Pages the submitting transaction itself durably freed in this commit.
200    /// These legitimately appear in `published_durable_freelist` without being
201    /// on the current durable freelist yet.
202    pub freed_pages: Vec<u32>,
203    /// Committed-freelist pages the submitting transaction consumed
204    /// (allocated) in this commit. These legitimately appear on the current
205    /// durable freelist without being in `published_durable_freelist`; any
206    /// OTHER page missing from the publication would be erased from the
207    /// durable freelist (the dual of resurrection: a peer's newly freed page
208    /// silently dropped, leaking it as "never used").
209    pub consumed_freelist_pages: Vec<u32>,
210    /// bd-r82et: the subset of `consumed_freelist_pages` that was on the
211    /// DURABLE freelist when the submitting transaction popped it — pages a
212    /// peer connection could also have observed as committed-free. Only these
213    /// are eligible for the append gate's double-consumption refusal: a page
214    /// consumed from the in-memory-only freelist (e.g. an aborted
215    /// transaction's returned EOF page) was never durably free, so no peer
216    /// can have popped it and its absence from the current durable freelist
217    /// is expected, not evidence of a conflict.
218    pub consumed_durable_freelist_pages: Vec<u32>,
219}
220
221/// WAL conflict horizon captured by a submitting transaction.
222#[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    /// Committed database size (pages) the submitting transaction's allocator
228    /// used as its base. Any page number greater than this was freshly
229    /// allocated by this transaction; if such a page already exists within the
230    /// current durable committed size, a peer connection allocated and
231    /// committed the same physical page first — a cross-connection EOF
232    /// double-allocation (bd-o81ov). 0 means "not tracked" (skip the guard).
233    pub snapshot_db_size: u32,
234}
235
236/// Snapshot-bound full-page hash for one cross-process conflict candidate.
237#[derive(Debug, Clone, Copy, PartialEq, Eq)]
238pub struct TransactionConflictPageBaseline {
239    /// Exact 1-based database page number associated with `page_hash`.
240    pub page_number: u32,
241    /// BLAKE3 hash of the complete page image visible to the transaction.
242    pub page_hash: [u8; 32],
243}
244
245/// Lane-local staging context attached to a transaction batch.
246#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
247pub struct TransactionFrameBatchContext {
248    /// Monotonic identifier used to correlate the batch with lane-local staging.
249    pub batch_id: u64,
250    /// Stable lane identity chosen for the submitting writer.
251    pub lane_id: u16,
252    /// Number of frames locally staged for this batch.
253    pub staged_frame_count: u32,
254    /// Time spent in local staging before queue submission.
255    pub staging_elapsed_ns: u64,
256}
257
258impl TransactionFrameBatch {
259    /// Create a new batch with the given frames.
260    #[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    /// Attach the durable-freelist publication this batch carries plus the
276    /// submitting transaction's own durably freed and consumed pages
277    /// (bd-gh302/bd-0shxy). `consumed_durable_freelist_pages` is the subset
278    /// of `consumed_freelist_pages` popped from the durable freelist
279    /// (bd-r82et) — the only pops a peer could double-consume.
280    #[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    /// Attach cross-process conflict metadata for the submitting transaction.
296    #[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    /// Attach snapshot-bound full-page hashes for WAL-generation transitions.
308    #[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    /// Attach lane-local staging context to this batch.
318    #[must_use]
319    pub fn with_context(mut self, context: TransactionFrameBatchContext) -> Self {
320        self.context = context;
321        self
322    }
323
324    /// Number of frames in this batch.
325    #[must_use]
326    pub fn frame_count(&self) -> usize {
327        self.frames.len()
328    }
329
330    /// Whether this batch contains a commit frame (last frame has `db_size > 0`).
331    #[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// ---------------------------------------------------------------------------
338// Consolidation phase state machine
339// ---------------------------------------------------------------------------
340
341/// Phase of the consolidation protocol.
342#[derive(Debug, Clone, Copy, PartialEq, Eq)]
343pub enum ConsolidationPhase {
344    /// Accepting new frame batches. The first writer becomes the flusher.
345    Filling,
346    /// The flusher is writing all accumulated frames to the WAL and fsyncing.
347    Flushing,
348    /// All frames in this epoch are durable. Waiters may proceed.
349    Complete,
350}
351
352/// Outcome of submitting a transaction batch for consolidated writing.
353#[derive(Debug, Clone, Copy, PartialEq, Eq)]
354pub enum SubmitOutcome {
355    /// This writer became the flusher and should call `flush_group`.
356    Flusher,
357    /// This writer's frames were accepted; it should wait for flush completion.
358    Waiter,
359}
360
361/// Result of submitting a transaction batch to the group-commit consolidator.
362#[derive(Debug, Clone, Copy, PartialEq, Eq)]
363pub struct SubmitReceipt {
364    /// Whether the caller should flush or wait.
365    pub outcome: SubmitOutcome,
366    /// The epoch whose completion makes this submitted batch durable.
367    pub target_epoch: u64,
368}
369
370// ---------------------------------------------------------------------------
371// Lock-free phase histogram (bd-db300.3.8.1)
372// ---------------------------------------------------------------------------
373
374/// Fixed-capacity ring buffer for lock-free percentile estimation.
375///
376/// Writers atomically advance `write_idx` and store samples. Readers
377/// snapshot the ring and sort to extract percentiles. The ring overwrites
378/// old samples when full, giving a rolling window of the last N observations.
379/// This is intentionally simple — no locks, no allocations on the hot path.
380const PHASE_HISTOGRAM_CAPACITY: usize = 4096;
381
382pub struct PhaseHistogram {
383    /// Circular sample buffer.
384    samples: Box<[AtomicU64]>,
385    /// Monotonic write index (wraps via modulo).
386    write_idx: AtomicU64,
387    /// Running max (updated atomically on each record).
388    max_us: AtomicU64,
389    /// Total samples recorded (not capped by ring size).
390    count: AtomicU64,
391    /// Running sum for mean computation.
392    sum_us: AtomicU64,
393    /// Cheap decaying tail estimate for hot-path policy decisions.
394    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    /// Record a single sample (microseconds). Lock-free.
414    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        // Update max.
421        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        // Hot-path consumers need a cheap tail-pressure signal but must not
435        // call `percentiles()`, which copies and sorts the whole ring. Maintain
436        // a conservative decaying maximum: spikes influence a few subsequent
437        // decisions, then fade without requiring a telemetry snapshot.
438        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    /// Return a constant-time recent tail estimate for scheduler hot paths.
455    #[must_use]
456    pub fn recent_tail_us(&self) -> u64 {
457        self.recent_tail_us.load(Ordering::Relaxed)
458    }
459
460    /// Snapshot percentiles: returns (p50, p95, p99, max, count, mean_us).
461    #[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        // Copy samples into a local vec and sort.
479        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    /// Reset all samples and counters.
505    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/// Percentile snapshot from a `PhaseHistogram`.
524#[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
534// ---------------------------------------------------------------------------
535// Wake-reason accounting (bd-db300.3.8.1)
536// ---------------------------------------------------------------------------
537
538/// Tracks why a waiter woke up during epoch wait.
539pub struct WakeReasonCounters {
540    /// Normal non-timeout completion, observed either before parking or after
541    /// direct delivery from the active waiter-notification strategy.
542    pub notify: AtomicU64,
543    /// Woken by a bounded timeout before rechecking the terminal epoch state.
544    pub timeout: AtomicU64,
545    /// Waiter took over as flusher (flusher died or slow).
546    pub flusher_takeover: AtomicU64,
547    /// Woken to observe a failed epoch.
548    pub failed_epoch: AtomicU64,
549    /// Woken but must busy-retry (spurious or race).
550    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    /// Snapshot all counters.
565    #[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    /// Reset all counters.
577    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/// Point-in-time snapshot of wake reasons.
587#[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    /// Total wake events.
598    #[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
604// ---------------------------------------------------------------------------
605// Consolidation metrics
606// ---------------------------------------------------------------------------
607
608/// Atomic counters for group commit consolidation observability.
609pub struct ConsolidationMetrics {
610    /// Total groups flushed.
611    pub groups_flushed: AtomicU64,
612    /// Total frames written via consolidated groups.
613    pub frames_consolidated: AtomicU64,
614    /// Total transactions batched.
615    pub transactions_batched: AtomicU64,
616    /// Total fsync operations (one per group).
617    pub fsyncs_total: AtomicU64,
618    /// Total time spent flushing (microseconds).
619    pub flush_duration_us_total: AtomicU64,
620    /// Total time writers spent waiting for flush (microseconds).
621    pub wait_duration_us_total: AtomicU64,
622    /// Maximum group size observed.
623    pub max_group_size_observed: AtomicU64,
624    /// Total busy retries during flush (exponential backoff).
625    pub busy_retries: AtomicU64,
626
627    // ── Phase timing instrumentation ──
628    /// Time building batch before entering consolidator (microseconds).
629    pub prepare_us_total: AtomicU64,
630    /// Time cloning staged pages into an owned group-commit batch (microseconds).
631    pub batch_build_us_total: AtomicU64,
632    /// Time pinning WAL conflict snapshot and attaching metadata (microseconds).
633    pub conflict_snapshot_us_total: AtomicU64,
634    /// Time spent preparing lane-local WAL frame bytes (microseconds).
635    pub lane_prepare_us_total: AtomicU64,
636    /// Time waiting to acquire consolidator.lock() (microseconds).
637    pub consolidator_lock_wait_us_total: AtomicU64,
638    /// Time waiting while consolidator phase == FLUSHING (microseconds).
639    pub consolidator_flushing_wait_us_total: AtomicU64,
640    /// Time flusher spends waiting for more batches (microseconds).
641    pub flusher_arrival_wait_us_total: AtomicU64,
642    /// Time waiting to acquire inner.lock() (microseconds).
643    pub inner_lock_wait_us_total: AtomicU64,
644    /// Time acquiring EXCLUSIVE file lock (microseconds).
645    pub exclusive_lock_us_total: AtomicU64,
646    /// Time in WAL append_frames (microseconds).
647    pub wal_append_us_total: AtomicU64,
648    /// Time preparing flusher frame refs and prepared batches (microseconds).
649    pub flush_frame_prep_us_total: AtomicU64,
650    /// Time checking stale WAL conflicts immediately before append (microseconds).
651    pub append_conflict_check_us_total: AtomicU64,
652    /// Time spent in the WAL append call itself (microseconds).
653    pub append_frames_us_total: AtomicU64,
654    /// Time in WAL sync/fsync (microseconds).
655    pub wal_sync_us_total: AtomicU64,
656    /// Time waiters spend waiting for epoch completion (microseconds).
657    pub waiter_epoch_wait_us_total: AtomicU64,
658    /// Count of commits that took flusher role.
659    pub flusher_commits: AtomicU64,
660    /// Count of commits that took waiter role.
661    pub waiter_commits: AtomicU64,
662    // ── Full commit path phase timing ──
663    /// Phase A: prepare under inner.lock (microseconds).
664    pub commit_phase_a_us_total: AtomicU64,
665    /// Phase B: WAL group commit (microseconds).
666    pub commit_phase_b_us_total: AtomicU64,
667    /// Phase C1: post-commit metadata under inner.lock (microseconds).
668    pub commit_phase_c1_us_total: AtomicU64,
669    /// Phase C2: publish to snapshot plane (microseconds).
670    pub commit_phase_c2_us_total: AtomicU64,
671    /// Total commits with phase timing recorded.
672    pub commit_phase_count: AtomicU64,
673
674    // ── Per-phase distribution histograms (bd-db300.3.8.1) ──
675    /// Distribution: consolidator lock wait.
676    pub hist_consolidator_lock_wait: PhaseHistogram,
677    /// Distribution: arrival wait (flusher only).
678    pub hist_arrival_wait: PhaseHistogram,
679    /// Distribution: WAL backend (inner) lock wait.
680    pub hist_wal_backend_lock_wait: PhaseHistogram,
681    /// Distribution: WAL append_frames.
682    pub hist_wal_append: PhaseHistogram,
683    /// Distribution: exclusive file lock acquisition.
684    pub hist_exclusive_lock: PhaseHistogram,
685    /// Distribution: waiter epoch wait.
686    pub hist_waiter_epoch_wait: PhaseHistogram,
687    /// Distribution: full Phase B (group commit path, flusher + waiter).
688    pub hist_phase_b: PhaseHistogram,
689    /// Distribution: WAL sync/fsync.
690    pub hist_wal_sync: PhaseHistogram,
691    /// Distribution: full commit (phase A + B + C).
692    pub hist_full_commit: PhaseHistogram,
693
694    // ── Wake-reason accounting (bd-db300.3.8.1) ──
695    /// Why waiters woke up during epoch wait.
696    pub wake_reasons: WakeReasonCounters,
697}
698
699impl ConsolidationMetrics {
700    /// Create zeroed metrics.
701    #[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            // Phase timing
713            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            // Phase histograms (bd-db300.3.8.1)
736            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    /// Record a completed group flush.
750    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        // Update max group size.
760        self.max_group_size_observed
761            .fetch_max(frames, Ordering::Relaxed);
762    }
763
764    /// Record waiter wait time.
765    pub fn record_wait(&self, duration_us: u64) {
766        self.wait_duration_us_total
767            .fetch_add(duration_us, Ordering::Relaxed);
768    }
769
770    /// Record a flush retry triggered by a transient busy error.
771    pub fn record_busy_retry(&self) {
772        self.busy_retries.fetch_add(1, Ordering::Relaxed);
773    }
774
775    /// Record pre-queue commit preparation breakdown.
776    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    /// Record flusher-side frame preparation and append breakdown.
791    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    /// Record phase timing for a commit operation.
806    #[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        // bd-db300.3.8.1: record to per-phase histograms.
828        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            // bd-db300.3.8.1: flusher-specific histograms.
845            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            // bd-db300.3.8.1: waiter-specific histogram.
856            self.hist_waiter_epoch_wait.record(waiter_epoch_wait_us);
857        }
858
859        // bd-db300.3.8.1: Phase B total = consolidator_lock + flushing_wait + flusher/waiter work.
860        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    /// Record full commit path phase timing.
875    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        // bd-db300.3.8.1: full commit distribution.
893        self.hist_full_commit
894            .record(phase_a_us + phase_b_us + phase_c1_us + phase_c2_us);
895    }
896
897    /// Take a point-in-time snapshot.
898    #[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            // Phase timing
910            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            // Per-phase distributions (bd-db300.3.8.1)
941            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    /// Reset all counters to zero.
955    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        // Phase timing
965        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        // Histograms and wake reasons (bd-db300.3.8.1)
992        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/// Point-in-time snapshot of consolidation metrics.
1012#[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    // Phase timing (all in microseconds)
1023    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    // Full commit path phases
1041    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    // ── Per-phase distributions (bd-db300.3.8.1) ──
1047    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    // ── Wake reasons (bd-db300.3.8.1) ──
1057    pub wake_reasons: WakeReasonSnapshot,
1058}
1059
1060impl ConsolidationMetricsSnapshot {
1061    /// Average frames per group, or 0 if no groups flushed.
1062    #[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    /// Average transactions per group, or 0 if no groups flushed.
1070    #[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    /// Average flush duration in microseconds, or 0 if no groups flushed.
1078    #[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    /// Fsync reduction ratio: transactions_batched / fsyncs_total.
1086    ///
1087    /// Without group commit, each transaction needs its own fsync.
1088    /// With group commit, N transactions share 1 fsync.
1089    #[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    /// Total commits (flusher + waiter).
1097    #[must_use]
1098    pub fn total_commits(&self) -> u64 {
1099        self.flusher_commits.saturating_add(self.waiter_commits)
1100    }
1101
1102    /// Average prepare time per commit (microseconds).
1103    #[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    /// Average consolidator lock wait per commit (microseconds).
1111    #[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    /// Average WAL I/O time per flusher (microseconds).
1119    #[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    /// Average waiter epoch wait time (microseconds).
1128    #[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    // ── bd-db300.3.8.2: lock-wait vs WAL-service split ──
1136
1137    /// Total flusher lock-wait time (inner_lock + exclusive_lock +
1138    /// flushing_wait), microseconds. This is time spent WAITING to acquire
1139    /// the WAL backend write path, NOT doing WAL I/O.
1140    #[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    /// Total WAL service time (append + sync), microseconds. This is time
1148    /// spent doing actual WAL I/O AFTER acquiring the write lock.
1149    #[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    /// Lock-wait fraction of total flusher phase-B time (0.0–1.0).
1156    /// Values > 0.5 indicate the regime is lock-topology-limited, not
1157    /// I/O-limited.
1158    #[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    /// Whether the flusher is lock-topology-limited (lock wait > service).
1171    #[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    /// Generate detailed phase timing report.
1177    #[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        // Calculate per-commit averages
1185        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        // Flusher-only metrics (per flusher)
1193        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        // Waiter-only metrics
1215        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
1287/// Global consolidation metrics singleton.
1288pub 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// ---------------------------------------------------------------------------
1296// Group commit consolidator (single-threaded core)
1297// ---------------------------------------------------------------------------
1298
1299/// The group commit consolidator accumulates frame batches from concurrent
1300/// writers and flushes them to the WAL file in consolidated groups.
1301///
1302/// This struct manages the FILLING→FLUSHING→COMPLETE state machine.
1303/// It is designed to be held behind a `Mutex` and accessed by concurrent
1304/// writers through `GroupCommitQueue`.
1305#[derive(Debug)]
1306pub struct GroupCommitConsolidator {
1307    /// Current consolidation phase.
1308    phase: ConsolidationPhase,
1309    /// Accumulated frame batches in the current FILLING phase.
1310    pending_batches: VecDeque<TransactionFrameBatch>,
1311    /// Total number of frames across all pending batches.
1312    pending_frame_count: usize,
1313    /// Configuration.
1314    config: GroupCommitConfig,
1315    /// When the current FILLING phase started (for max_group_delay).
1316    filling_started: Option<Instant>,
1317    /// Monotonic epoch counter: incremented once per group flush.
1318    epoch: u64,
1319    /// Number of completed flush results awaiting pickup by waiters.
1320    completed_epoch: u64,
1321    /// Epoch pipelining: batches submitted during FLUSHING phase, queued
1322    /// for the next epoch. This eliminates the flushing_wait bottleneck —
1323    /// threads never block waiting for a flush to complete.
1324    next_epoch_batches: VecDeque<TransactionFrameBatch>,
1325    /// Total frames across next_epoch_batches.
1326    next_epoch_frame_count: usize,
1327    /// Whether a promoted epoch in FILLING currently has pending work but no
1328    /// explicitly claimed flusher because the previous flusher may have stopped.
1329    promoted_epoch_flusher_vacant: bool,
1330}
1331
1332impl GroupCommitConsolidator {
1333    /// Create a new consolidator with the given configuration.
1334    #[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    /// Current consolidation phase.
1352    #[must_use]
1353    pub const fn phase(&self) -> ConsolidationPhase {
1354        self.phase
1355    }
1356
1357    /// Current epoch.
1358    #[must_use]
1359    pub const fn epoch(&self) -> u64 {
1360        self.epoch
1361    }
1362
1363    /// Maximum time a batch may remain in the filling epoch before flush.
1364    #[must_use]
1365    pub const fn max_group_delay(&self) -> Duration {
1366        self.config.max_group_delay
1367    }
1368
1369    /// Number of pending frames in the current FILLING phase.
1370    #[must_use]
1371    pub const fn pending_frame_count(&self) -> usize {
1372        self.pending_frame_count
1373    }
1374
1375    /// Number of pending transaction batches.
1376    #[must_use]
1377    pub fn pending_batch_count(&self) -> usize {
1378        self.pending_batches.len()
1379    }
1380
1381    /// Submit a transaction's frame batch for consolidation.
1382    ///
1383    /// Returns `Flusher` if this writer should call `flush_group`, or
1384    /// `Waiter` if this writer should wait for the flush to complete.
1385    ///
1386    /// # Errors
1387    ///
1388    /// Returns `Err` if the consolidator is in an unexpected phase.
1389    pub fn submit_batch(&mut self, batch: TransactionFrameBatch) -> Result<SubmitReceipt> {
1390        // ── Epoch pipelining: accept submissions during FLUSHING ──
1391        // Instead of blocking, queue batches for the next epoch. This
1392        // eliminates the flushing_wait bottleneck entirely — threads
1393        // never block waiting for a flush to complete.
1394        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            // Always a Waiter — the next epoch's flusher will be elected
1407            // when complete_flush() promotes these batches.
1408            return Ok(SubmitReceipt {
1409                outcome: SubmitOutcome::Waiter,
1410                target_epoch: self.epoch.saturating_add(1),
1411            });
1412        }
1413
1414        // If we're in COMPLETE, transition to new FILLING epoch.
1415        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    /// Check whether the flusher should flush now.
1451    ///
1452    /// Returns `true` if:
1453    /// - The batch is full (`pending_frame_count >= max_group_size`), OR
1454    /// - The max group delay has been exceeded.
1455    #[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    /// Time remaining before the flusher must flush (for sleep/wait).
1469    #[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    /// Age of the current FILLING epoch from the first submitted batch.
1483    #[must_use]
1484    pub fn fill_age(&self) -> Duration {
1485        self.filling_started
1486            .map_or(Duration::ZERO, |started| started.elapsed())
1487    }
1488
1489    /// Transition to FLUSHING phase and take ownership of the pending batches.
1490    ///
1491    /// Returns the batches to be written and the page size needed for
1492    /// frame construction.
1493    ///
1494    /// # Errors
1495    ///
1496    /// Returns `Err` if not in FILLING phase.
1497    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    /// Mark the current flush as complete. Waiters can now proceed.
1525    ///
1526    /// # Errors
1527    ///
1528    /// Returns `Err` if not in FLUSHING phase.
1529    /// Returns `true` if pipelined batches were promoted and the caller
1530    /// should flush again. If the original flusher does not continue,
1531    /// a fresh submitter may explicitly claim the promoted epoch via
1532    /// [`Self::claim_flusher_vacancy`].
1533    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        // ── Epoch pipelining: promote next-epoch batches ──
1545        // If threads submitted during FLUSHING, their batches are in
1546        // next_epoch_batches. Promote them to pending_batches and
1547        // transition directly to FILLING (skipping COMPLETE) so the
1548        // current flusher can immediately begin_flush() again.
1549        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) // Caller must flush again
1576        }
1577    }
1578
1579    /// Whether pipelined batches are waiting for the next epoch.
1580    #[must_use]
1581    pub fn has_pipelined_batches(&self) -> bool {
1582        !self.next_epoch_batches.is_empty()
1583    }
1584
1585    /// Whether a promoted epoch in `Filling` currently needs an explicit
1586    /// flusher claim before a replacement flusher takes over.
1587    #[must_use]
1588    pub const fn has_flusher_vacancy(&self) -> bool {
1589        self.promoted_epoch_flusher_vacant
1590    }
1591
1592    /// Claim the promoted-epoch flusher vacancy after the original flusher
1593    /// stopped before calling `begin_flush()` again.
1594    ///
1595    /// Returns `true` if the caller successfully claimed the vacancy.
1596    #[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    /// Abort a not-yet-started filling epoch after its elected flusher is
1609    /// cancelled.
1610    ///
1611    /// No WAL bytes have been written while the consolidator is still in
1612    /// `Filling`, so every queued batch can fail atomically. The failed epoch
1613    /// is consumed before returning; the next submitter therefore receives a
1614    /// fresh target epoch instead of colliding with retained failure metadata.
1615    ///
1616    /// # Errors
1617    ///
1618    /// Returns `Err` unless the expected non-empty filling epoch is active.
1619    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    /// Abort the current flush after the flusher observed an I/O error.
1653    ///
1654    /// This transitions the state machine out of `Flushing` so waiters can be
1655    /// released with the epoch-level failure published by the caller.
1656    ///
1657    /// # Errors
1658    ///
1659    /// Returns `Err` if not in `Flushing` phase.
1660    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        // On abort, promote pipelined batches the same way as
1669        // complete_flush — those transactions weren't part of the
1670        // failed flush, so they should be retried in the next epoch.
1671        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            // Keep filling_started set — promoted batches need the timeout
1683        }
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    /// Transition from COMPLETE to FILLING for the next epoch.
1696    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    /// The completed epoch counter (for waiter synchronization).
1708    #[must_use]
1709    pub const fn completed_epoch(&self) -> u64 {
1710        self.completed_epoch
1711    }
1712}
1713
1714// ---------------------------------------------------------------------------
1715// Batch frame writer
1716// ---------------------------------------------------------------------------
1717
1718/// Write a consolidated batch of frames to the WAL file.
1719///
1720/// Serializes all frames into a single contiguous buffer and writes it
1721/// in one `write` call, then fsyncs. This amortizes syscall overhead
1722/// and ensures all frames in the group become durable atomically.
1723///
1724/// Updates the WAL file's `running_checksum` and `frame_count` for each
1725/// frame in the batch, maintaining the checksum chain invariant.
1726///
1727/// Returns the number of frames written.
1728pub 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// ---------------------------------------------------------------------------
1775// Tests
1776// ---------------------------------------------------------------------------
1777
1778#[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    // ── Consolidator state machine tests ──
1837
1838    #[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        // Submit 3 batches.
1956        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        // Begin flush: FILLING → FLUSHING.
1968        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        // Pipelined submissions during FLUSHING become waiters for the next epoch.
1975        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        // Complete flush: FLUSHING → FILLING with a promoted next epoch.
1988        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        // The original flusher may continue immediately without an explicit claim.
1996        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        // First cycle.
2007        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        // Second submission auto-transitions to FILLING.
2018        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        // Submit 2 frames — should not flush yet.
2038        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        // Submit 3rd frame — should flush now.
2049        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        // Submit and begin flush.
2063        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        // Cannot begin flush again in FLUSHING phase.
2072        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        // Cannot complete flush in FILLING phase.
2079        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    // ── Consolidated write tests ──
2173
2174    #[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        // Verify frame contents.
2204        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        // Last frame should be commit.
2214        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        // Two transactions, each with 2 frames.
2229        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        // Verify page numbers.
2261        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        // Write some frames the normal way first.
2278        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        // Now write a consolidated batch.
2286        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        // Verify checksum chain is intact by reopening.
2304        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], // wrong size
2340            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    // ── Metrics tests ──
2352
2353    #[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    /// Deterministic proof that consolidation achieves fsync reduction.
2460    ///
2461    /// Without consolidation: N transactions × 1 fsync each = N fsyncs.
2462    /// With consolidation: N transactions in 1 group = 1 fsync.
2463    /// Reduction: N/1 = N (for N=10, reduction = 10x).
2464    #[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    // ── Config validation tests ──
2482
2483    #[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    // ── TransactionFrameBatch tests ──
2505
2506    #[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    // ── Full consolidation + write integration test ──
2531
2532    #[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        // Simulate 3 concurrent writers submitting batches.
2545        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        // Flusher begins flush.
2587        let batches = consolidator.begin_flush().unwrap();
2588        assert_eq!(batches.len(), 3);
2589
2590        // Write all frames in one consolidated I/O.
2591        let written = write_consolidated_frames(&cx, &mut wal, &batches).expect("write");
2592        assert_eq!(written, 5);
2593
2594        // Mark flush complete.
2595        consolidator.complete_flush().unwrap();
2596        assert_eq!(consolidator.phase(), ConsolidationPhase::Complete);
2597
2598        // Verify WAL integrity.
2599        assert_eq!(wal.frame_count(), 5);
2600
2601        // Reopen to verify checksum chain.
2602        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    // ── PhaseHistogram tests (bd-db300.3.8.1) ──────────────────────
2610
2611    #[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    // ── WakeReasonCounters tests (bd-db300.3.8.1) ──────────────────
2699
2700    #[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    // ── Integration: histograms in ConsolidationMetrics ──────────────
2727
2728    #[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}