Skip to main content

fsqlite_core/
commit_repair.rs

1//! Commit durability and asynchronous repair orchestration (ยง1.6, bd-22n.11).
2//!
3//! The critical path only appends+syncs systematic symbols. Repair symbols are
4//! generated/append-synced asynchronously after commit acknowledgment.
5
6use std::collections::{BTreeMap, BTreeSet, HashMap};
7use std::future::Future;
8use std::panic::AssertUnwindSafe;
9use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
10use std::sync::mpsc;
11use std::sync::{Arc, Condvar, Mutex, MutexGuard};
12use std::thread;
13use std::time::{Duration, Instant};
14
15use asupersync::cx::Cx as NativeCx;
16use asupersync::runtime::{JoinHandle as AsyncJoinHandle, Runtime, spawn_blocking};
17use fsqlite_error::{FrankenError, Result};
18use fsqlite_types::cx::Cx;
19use tracing::{debug, error, info, warn};
20
21const BEAD_ID: &str = "bd-22n.11";
22
23/// Default bounded capacity for commit-channel backpressure.
24pub const DEFAULT_COMMIT_CHANNEL_CAPACITY: usize = 16;
25
26/// Request sent from writers to the write coordinator.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct CommitRequest {
29    pub txn_id: u64,
30    pub write_set_pages: Vec<u32>,
31    pub payload: Vec<u8>,
32}
33
34impl CommitRequest {
35    #[must_use]
36    pub fn new(txn_id: u64, write_set_pages: Vec<u32>, payload: Vec<u8>) -> Self {
37        Self {
38            txn_id,
39            write_set_pages,
40            payload,
41        }
42    }
43}
44
45/// Capacity/config knobs for the two-phase commit pipeline.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct CommitPipelineConfig {
48    pub channel_capacity: usize,
49}
50
51impl Default for CommitPipelineConfig {
52    fn default() -> Self {
53        Self {
54            channel_capacity: DEFAULT_COMMIT_CHANNEL_CAPACITY,
55        }
56    }
57}
58
59impl CommitPipelineConfig {
60    /// Clamp PRAGMA capacity to a valid non-zero bounded channel size.
61    #[must_use]
62    pub fn from_pragma_capacity(raw_capacity: i64) -> Self {
63        let clamped_i64 = raw_capacity.clamp(1, i64::from(u16::MAX));
64        let clamped = usize::try_from(clamped_i64).expect("clamped to positive u16 range");
65        Self {
66            channel_capacity: clamped,
67        }
68    }
69}
70
71#[derive(Debug)]
72struct LogicalCapacity {
73    available: Mutex<usize>,
74    max_permits: usize,
75    changed: Condvar,
76}
77
78impl LogicalCapacity {
79    fn new(capacity: usize) -> Self {
80        let normalized = capacity.max(1);
81        Self {
82            available: Mutex::new(normalized),
83            max_permits: normalized,
84            changed: Condvar::new(),
85        }
86    }
87
88    fn acquire_for(self: &Arc<Self>, timeout: Duration) -> Option<LogicalCapacityPermit> {
89        let started_at = Instant::now();
90        let mut available = lock_with_recovery(&self.available, "two_phase_logical_capacity");
91        loop {
92            if *available > 0 {
93                *available -= 1;
94                return Some(LogicalCapacityPermit {
95                    capacity: Arc::clone(self),
96                });
97            }
98
99            let remaining = timeout.saturating_sub(started_at.elapsed());
100            if remaining.is_zero() {
101                return None;
102            }
103
104            let wait_result = self.changed.wait_timeout(available, remaining);
105            let (guard, _) = wait_result.unwrap_or_else(std::sync::PoisonError::into_inner);
106            available = guard;
107        }
108    }
109
110    fn release(&self) {
111        let mut available = lock_with_recovery(&self.available, "two_phase_logical_capacity");
112        *available = (*available).saturating_add(1).min(self.max_permits);
113        self.changed.notify_one();
114    }
115
116    fn available_permits(&self) -> usize {
117        *lock_with_recovery(&self.available, "two_phase_logical_capacity")
118    }
119
120    fn max_permits(&self) -> usize {
121        self.max_permits
122    }
123}
124
125#[derive(Debug)]
126struct LogicalCapacityPermit {
127    capacity: Arc<LogicalCapacity>,
128}
129
130impl Drop for LogicalCapacityPermit {
131    fn drop(&mut self) {
132        self.capacity.release();
133    }
134}
135
136#[derive(Debug)]
137struct PendingCommit {
138    request: CommitRequest,
139    logical_permit: LogicalCapacityPermit,
140}
141
142#[derive(Debug)]
143struct ReceiverOrderState {
144    next_receive_seq: u64,
145    aborted_reservations: BTreeSet<u64>,
146    pending_commits: BTreeMap<u64, PendingCommit>,
147}
148
149impl ReceiverOrderState {
150    fn new() -> Self {
151        Self {
152            next_receive_seq: 1,
153            aborted_reservations: BTreeSet::new(),
154            pending_commits: BTreeMap::new(),
155        }
156    }
157
158    fn mark_aborted(&mut self, reservation_seq: u64) {
159        self.aborted_reservations.insert(reservation_seq);
160    }
161
162    fn queue_commit(
163        &mut self,
164        reservation_seq: u64,
165        request: CommitRequest,
166        logical_permit: LogicalCapacityPermit,
167    ) {
168        let replaced = self.pending_commits.insert(
169            reservation_seq,
170            PendingCommit {
171                request,
172                logical_permit,
173            },
174        );
175        debug_assert!(
176            replaced.is_none(),
177            "duplicate reservation sequence enqueued: {reservation_seq}"
178        );
179    }
180
181    fn rollback_pending_commit(&mut self, reservation_seq: u64) {
182        let _ = self.pending_commits.remove(&reservation_seq);
183    }
184
185    fn take_ready(&mut self) -> Option<CommitRequest> {
186        loop {
187            if self.aborted_reservations.remove(&self.next_receive_seq) {
188                self.next_receive_seq = self.next_receive_seq.saturating_add(1);
189                continue;
190            }
191
192            let PendingCommit {
193                request,
194                logical_permit,
195            } = self.pending_commits.remove(&self.next_receive_seq)?;
196            self.next_receive_seq = self.next_receive_seq.saturating_add(1);
197            drop(logical_permit);
198            return Some(request);
199        }
200    }
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204enum CommitSignal {
205    CommitQueued,
206}
207
208#[derive(Debug)]
209struct TwoPhaseQueueShared {
210    logical_capacity: Arc<LogicalCapacity>,
211    signal_sender: mpsc::SyncSender<CommitSignal>,
212    signal_receiver: Mutex<mpsc::Receiver<CommitSignal>>,
213    next_reservation_seq: AtomicU64,
214    order_state: Mutex<ReceiverOrderState>,
215}
216
217impl TwoPhaseQueueShared {
218    fn with_capacity(capacity: usize) -> Self {
219        let normalized_capacity = capacity.max(1);
220        let (signal_sender, signal_receiver) = mpsc::sync_channel(normalized_capacity);
221        Self {
222            logical_capacity: Arc::new(LogicalCapacity::new(normalized_capacity)),
223            signal_sender,
224            signal_receiver: Mutex::new(signal_receiver),
225            next_reservation_seq: AtomicU64::new(1),
226            order_state: Mutex::new(ReceiverOrderState::new()),
227        }
228    }
229
230    fn reserve_sequence(&self) -> u64 {
231        self.next_reservation_seq.fetch_add(1, Ordering::AcqRel)
232    }
233
234    fn occupancy(&self) -> usize {
235        self.capacity()
236            .saturating_sub(self.logical_capacity.available_permits())
237    }
238
239    fn capacity(&self) -> usize {
240        self.logical_capacity.max_permits()
241    }
242
243    fn queue_commit(
244        &self,
245        reservation_seq: u64,
246        request: CommitRequest,
247        logical_permit: LogicalCapacityPermit,
248    ) {
249        lock_with_recovery(&self.order_state, "two_phase_order_state").queue_commit(
250            reservation_seq,
251            request,
252            logical_permit,
253        );
254    }
255
256    fn rollback_pending_commit(&self, reservation_seq: u64) {
257        lock_with_recovery(&self.order_state, "two_phase_order_state")
258            .rollback_pending_commit(reservation_seq);
259    }
260
261    fn mark_aborted(&self, reservation_seq: u64) {
262        lock_with_recovery(&self.order_state, "two_phase_order_state")
263            .mark_aborted(reservation_seq);
264        let _ = self.signal_sender.try_send(CommitSignal::CommitQueued);
265    }
266
267    fn take_ready_request(&self) -> Option<CommitRequest> {
268        lock_with_recovery(&self.order_state, "two_phase_order_state").take_ready()
269    }
270
271    fn drain_signals(&self) -> SignalDrain {
272        let receiver = lock_with_recovery(&self.signal_receiver, "two_phase_signal_receiver");
273        let mut drained_any = false;
274        loop {
275            match receiver.try_recv() {
276                Ok(CommitSignal::CommitQueued) => {
277                    drained_any = true;
278                }
279                Err(mpsc::TryRecvError::Empty) => {
280                    return if drained_any {
281                        SignalDrain::Drained
282                    } else {
283                        SignalDrain::Empty
284                    };
285                }
286                Err(mpsc::TryRecvError::Disconnected) => return SignalDrain::Disconnected,
287            }
288        }
289    }
290}
291
292#[derive(Debug, Clone, Copy, PartialEq, Eq)]
293enum SignalDrain {
294    Empty,
295    Drained,
296    Disconnected,
297}
298
299#[derive(Debug, Clone, Copy, PartialEq, Eq)]
300enum SignalWaitOutcome {
301    Woken,
302    TimedOut,
303    Disconnected,
304}
305
306fn wait_for_signal_activity(shared: &TwoPhaseQueueShared, timeout: Duration) -> SignalWaitOutcome {
307    let receiver = lock_with_recovery(&shared.signal_receiver, "two_phase_signal_receiver");
308    match receiver.recv_timeout(timeout) {
309        Ok(CommitSignal::CommitQueued) => SignalWaitOutcome::Woken,
310        Err(mpsc::RecvTimeoutError::Timeout) => SignalWaitOutcome::TimedOut,
311        Err(mpsc::RecvTimeoutError::Disconnected) => SignalWaitOutcome::Disconnected,
312    }
313}
314
315/// Sender side of the two-phase bounded MPSC commit channel.
316#[derive(Debug, Clone)]
317pub struct TwoPhaseCommitSender {
318    shared: Arc<TwoPhaseQueueShared>,
319}
320
321/// Bounded wait quantum between cancellation checkpoints while a saturated
322/// channel is blocking `reserve`.
323const RESERVE_CHECKPOINT_INTERVAL: Duration = Duration::from_millis(25);
324
325impl TwoPhaseCommitSender {
326    /// Reserve a slot (phase 1). Blocks while the channel is saturated, but
327    /// observes `cx` cancellation between bounded waits instead of hanging.
328    ///
329    /// # Errors
330    ///
331    /// Returns [`FrankenError::Abort`] when `cx` is cancelled before a slot
332    /// becomes available.
333    pub fn reserve(&self, cx: &Cx) -> Result<SendPermit> {
334        loop {
335            cx.checkpoint().map_err(|_| FrankenError::Abort)?;
336            if let Some(permit) = self.try_reserve_for(RESERVE_CHECKPOINT_INTERVAL) {
337                return Ok(permit);
338            }
339        }
340    }
341
342    /// Reserve with timeout; `None` means caller gave up (cancel during reserve).
343    #[must_use]
344    pub fn try_reserve_for(&self, timeout: Duration) -> Option<SendPermit> {
345        let logical_permit = self.shared.logical_capacity.acquire_for(timeout)?;
346
347        Some(SendPermit {
348            shared: Arc::clone(&self.shared),
349            logical_permit: Some(logical_permit),
350            reservation_seq: Some(self.shared.reserve_sequence()),
351        })
352    }
353
354    /// Current buffered + reserved occupancy.
355    #[must_use]
356    pub fn occupancy(&self) -> usize {
357        self.shared.occupancy()
358    }
359
360    /// Bounded channel capacity.
361    #[must_use]
362    pub fn capacity(&self) -> usize {
363        self.shared.capacity()
364    }
365}
366
367/// Receiver side of the two-phase bounded MPSC commit channel.
368#[derive(Debug, Clone)]
369pub struct TwoPhaseCommitReceiver {
370    shared: Arc<TwoPhaseQueueShared>,
371}
372
373impl TwoPhaseCommitReceiver {
374    /// Receive the next coordinator request (FIFO by reservation order).
375    pub fn recv(&self) -> CommitRequest {
376        loop {
377            if let Some(request) = self.try_recv_for(Duration::from_secs(3600)) {
378                return request;
379            }
380        }
381    }
382
383    /// Timed receive used by tests and bounded coordinator loops.
384    #[must_use]
385    pub fn try_recv_for(&self, timeout: Duration) -> Option<CommitRequest> {
386        let started_at = Instant::now();
387        loop {
388            match self.shared.drain_signals() {
389                SignalDrain::Drained => {
390                    if let Some(request) = self.shared.take_ready_request() {
391                        return Some(request);
392                    }
393                    continue;
394                }
395                SignalDrain::Disconnected => return self.shared.take_ready_request(),
396                SignalDrain::Empty => {}
397            }
398
399            if let Some(request) = self.shared.take_ready_request() {
400                return Some(request);
401            }
402
403            let remaining = timeout.saturating_sub(started_at.elapsed());
404            if remaining.is_zero() {
405                return self.shared.take_ready_request();
406            }
407
408            match wait_for_signal_activity(&self.shared, remaining) {
409                SignalWaitOutcome::Woken => {}
410                SignalWaitOutcome::Disconnected | SignalWaitOutcome::TimedOut => {
411                    return self.shared.take_ready_request();
412                }
413            }
414        }
415    }
416}
417
418/// Two-phase permit returned by `reserve()`.
419///
420/// Dropping without `send()`/`abort()` automatically releases the reserved slot.
421#[derive(Debug)]
422pub struct SendPermit {
423    shared: Arc<TwoPhaseQueueShared>,
424    logical_permit: Option<LogicalCapacityPermit>,
425    reservation_seq: Option<u64>,
426}
427
428impl SendPermit {
429    /// Stable reservation sequence used to verify FIFO behavior in tests.
430    #[must_use]
431    pub fn reservation_seq(&self) -> u64 {
432        self.reservation_seq.unwrap_or(0)
433    }
434
435    /// Phase 2 commit. Synchronous and infallible for slot ownership.
436    pub fn send(mut self, request: CommitRequest) {
437        let Some(reservation_seq) = self.reservation_seq.take() else {
438            return;
439        };
440        let Some(logical_permit) = self.logical_permit.take() else {
441            return;
442        };
443
444        self.shared
445            .queue_commit(reservation_seq, request, logical_permit);
446
447        match self
448            .shared
449            .signal_sender
450            .try_send(CommitSignal::CommitQueued)
451        {
452            Ok(()) | Err(mpsc::TrySendError::Full(CommitSignal::CommitQueued)) => {}
453            Err(mpsc::TrySendError::Disconnected(CommitSignal::CommitQueued)) => {
454                self.shared.rollback_pending_commit(reservation_seq);
455                self.shared.mark_aborted(reservation_seq);
456            }
457        }
458    }
459
460    /// Explicitly release reserved slot without sending.
461    pub fn abort(mut self) {
462        self.abort_current_reservation();
463    }
464
465    fn abort_current_reservation(&mut self) {
466        let Some(reservation_seq) = self.reservation_seq.take() else {
467            return;
468        };
469        let _ = self.logical_permit.take();
470        self.shared.mark_aborted(reservation_seq);
471    }
472}
473
474impl Drop for SendPermit {
475    fn drop(&mut self) {
476        self.abort_current_reservation();
477    }
478}
479
480/// Tracked sender variant that counts leaked permits (dropped without send/abort).
481#[derive(Debug, Clone)]
482pub struct TrackedSender {
483    sender: TwoPhaseCommitSender,
484    leaked_permits: Arc<AtomicU64>,
485}
486
487impl TrackedSender {
488    #[must_use]
489    pub fn new(sender: TwoPhaseCommitSender) -> Self {
490        Self {
491            sender,
492            leaked_permits: Arc::new(AtomicU64::new(0)),
493        }
494    }
495
496    /// Reserve a tracked permit, observing `cx` cancellation while blocked.
497    ///
498    /// # Errors
499    ///
500    /// Returns the checkpoint error when `cx` is cancelled before a slot
501    /// becomes available.
502    pub fn reserve(&self, cx: &Cx) -> Result<TrackedSendPermit> {
503        Ok(TrackedSendPermit {
504            leaked_permits: Arc::clone(&self.leaked_permits),
505            permit: Some(self.sender.reserve(cx)?),
506        })
507    }
508
509    #[must_use]
510    pub fn leaked_permit_count(&self) -> u64 {
511        self.leaked_permits.load(Ordering::Acquire)
512    }
513}
514
515/// Tracked permit wrapper for safety-critical channels.
516#[derive(Debug)]
517pub struct TrackedSendPermit {
518    leaked_permits: Arc<AtomicU64>,
519    permit: Option<SendPermit>,
520}
521
522impl TrackedSendPermit {
523    /// Commit and clear obligation.
524    pub fn send(mut self, request: CommitRequest) {
525        if let Some(permit) = self.permit.take() {
526            permit.send(request);
527        }
528    }
529
530    /// Abort and clear obligation.
531    pub fn abort(mut self) {
532        if let Some(permit) = self.permit.take() {
533            permit.abort();
534        }
535    }
536}
537
538impl Drop for TrackedSendPermit {
539    fn drop(&mut self) {
540        if self.permit.is_some() {
541            self.leaked_permits.fetch_add(1, Ordering::AcqRel);
542        }
543    }
544}
545
546/// Build a bounded two-phase commit channel.
547#[must_use]
548pub fn two_phase_commit_channel(capacity: usize) -> (TwoPhaseCommitSender, TwoPhaseCommitReceiver) {
549    let shared = Arc::new(TwoPhaseQueueShared::with_capacity(capacity));
550    (
551        TwoPhaseCommitSender {
552            shared: Arc::clone(&shared),
553        },
554        TwoPhaseCommitReceiver { shared },
555    )
556}
557
558/// Little's-law-based capacity estimate.
559#[must_use]
560#[allow(
561    clippy::cast_possible_truncation,
562    clippy::cast_sign_loss,
563    clippy::cast_precision_loss
564)]
565pub fn little_law_capacity(
566    lambda_per_second: f64,
567    t_commit: Duration,
568    burst_multiplier: f64,
569    jitter_multiplier: f64,
570) -> usize {
571    let effective = lambda_per_second
572        * t_commit.as_secs_f64()
573        * burst_multiplier.max(1.0)
574        * jitter_multiplier.max(1.0);
575    effective.ceil().max(1.0) as usize
576}
577
578/// Classical optimal group-commit batch size: `sqrt(t_fsync / t_validate)`.
579#[must_use]
580#[allow(
581    clippy::cast_possible_truncation,
582    clippy::cast_sign_loss,
583    clippy::cast_precision_loss
584)]
585pub fn optimal_batch_size(t_fsync: Duration, t_validate: Duration, capacity: usize) -> usize {
586    let denom = t_validate.as_secs_f64().max(f64::EPSILON);
587    let raw = (t_fsync.as_secs_f64() / denom).sqrt().round();
588    raw.clamp(1.0, capacity.max(1) as f64) as usize
589}
590
591/// Conformal batch-size controller using upper quantiles.
592#[must_use]
593#[allow(
594    clippy::cast_possible_truncation,
595    clippy::cast_sign_loss,
596    clippy::cast_precision_loss
597)]
598pub fn conformal_batch_size(
599    fsync_samples: &[Duration],
600    validate_samples: &[Duration],
601    capacity: usize,
602) -> usize {
603    if fsync_samples.is_empty() || validate_samples.is_empty() {
604        return 1;
605    }
606    let q_fsync = quantile_seconds(fsync_samples, 0.9);
607    let q_validate = quantile_seconds(validate_samples, 0.9).max(f64::EPSILON);
608    let raw = (q_fsync / q_validate).sqrt().round();
609    raw.clamp(1.0, capacity.max(1) as f64) as usize
610}
611
612fn quantile_seconds(samples: &[Duration], quantile: f64) -> f64 {
613    let mut values: Vec<f64> = samples.iter().map(Duration::as_secs_f64).collect();
614    values.sort_by(f64::total_cmp);
615    #[allow(
616        clippy::cast_possible_truncation,
617        clippy::cast_sign_loss,
618        clippy::cast_precision_loss
619    )]
620    let idx = ((values.len() as f64 - 1.0) * quantile.clamp(0.0, 1.0)).round() as usize;
621    values[idx]
622}
623
624/// Commit/repair lifecycle events used for timing and invariant validation.
625#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
626pub enum CommitRepairEventKind {
627    CommitDurable,
628    DurableButNotRepairable,
629    CommitAcked,
630    RepairStarted,
631    RepairCompleted,
632    RepairFailed,
633}
634
635/// Timestamped lifecycle event for one commit sequence.
636#[derive(Debug, Clone, Copy)]
637pub struct CommitRepairEvent {
638    pub commit_seq: u64,
639    /// Monotonic per-commit event sequence number (logical time, no ambient authority).
640    pub seq: u64,
641    /// Monotonic wall-clock capture for latency/window measurements.
642    pub recorded_at: Instant,
643    pub kind: CommitRepairEventKind,
644}
645
646/// Repair state for a commit sequence.
647#[derive(Debug, Clone, Copy, PartialEq, Eq)]
648pub enum RepairState {
649    NotScheduled,
650    Pending,
651    Completed,
652    Failed,
653}
654
655/// Commit result produced by the critical path.
656#[derive(Debug, Clone, Copy)]
657pub struct CommitReceipt {
658    pub commit_seq: u64,
659    pub durable: bool,
660    pub repair_pending: bool,
661    pub latency: Duration,
662}
663
664/// Runtime behavior toggle for async repair generation.
665#[derive(Debug, Clone, Copy, PartialEq, Eq)]
666pub struct CommitRepairConfig {
667    pub repair_enabled: bool,
668}
669
670impl Default for CommitRepairConfig {
671    fn default() -> Self {
672        Self {
673            repair_enabled: true,
674        }
675    }
676}
677
678/// Storage sink for systematic/repair symbol append+sync operations.
679pub trait CommitRepairIo: Send + Sync {
680    fn append_systematic_symbols(&self, commit_seq: u64, systematic_symbols: &[u8]) -> Result<()>;
681    fn sync_systematic_symbols(&self, commit_seq: u64) -> Result<()>;
682    fn append_repair_symbols(&self, commit_seq: u64, repair_symbols: &[u8]) -> Result<()>;
683    fn sync_repair_symbols(&self, commit_seq: u64) -> Result<()>;
684}
685
686/// Generator for repair symbols from committed systematic symbols.
687pub trait RepairSymbolGenerator: Send + Sync {
688    fn generate_repair_symbols(
689        &self,
690        commit_seq: u64,
691        systematic_symbols: &[u8],
692    ) -> Result<Vec<u8>>;
693}
694
695/// In-memory IO sink useful for deterministic testing/instrumentation.
696#[derive(Debug, Default)]
697pub struct InMemoryCommitRepairIo {
698    systematic_by_commit: Mutex<HashMap<u64, Vec<u8>>>,
699    repair_by_commit: Mutex<HashMap<u64, Vec<u8>>>,
700    total_systematic_bytes: AtomicU64,
701    total_repair_bytes: AtomicU64,
702    systematic_syncs: AtomicU64,
703    repair_syncs: AtomicU64,
704}
705
706impl InMemoryCommitRepairIo {
707    #[must_use]
708    pub fn total_repair_bytes(&self) -> u64 {
709        self.total_repair_bytes.load(Ordering::Acquire)
710    }
711
712    #[must_use]
713    pub fn repair_sync_count(&self) -> u64 {
714        self.repair_syncs.load(Ordering::Acquire)
715    }
716
717    #[must_use]
718    pub fn systematic_sync_count(&self) -> u64 {
719        self.systematic_syncs.load(Ordering::Acquire)
720    }
721
722    #[must_use]
723    pub fn repair_symbols_for(&self, commit_seq: u64) -> Option<Vec<u8>> {
724        lock_with_recovery(&self.repair_by_commit, "repair_by_commit")
725            .get(&commit_seq)
726            .cloned()
727    }
728}
729
730impl CommitRepairIo for InMemoryCommitRepairIo {
731    fn append_systematic_symbols(&self, commit_seq: u64, systematic_symbols: &[u8]) -> Result<()> {
732        lock_with_recovery(&self.systematic_by_commit, "systematic_by_commit")
733            .insert(commit_seq, systematic_symbols.to_vec());
734        self.total_systematic_bytes.fetch_add(
735            u64::try_from(systematic_symbols.len()).map_err(|_| FrankenError::OutOfRange {
736                what: "systematic_symbol_len".to_owned(),
737                value: systematic_symbols.len().to_string(),
738            })?,
739            Ordering::Release,
740        );
741        Ok(())
742    }
743
744    fn sync_systematic_symbols(&self, _commit_seq: u64) -> Result<()> {
745        self.systematic_syncs.fetch_add(1, Ordering::Release);
746        Ok(())
747    }
748
749    fn append_repair_symbols(&self, commit_seq: u64, repair_symbols: &[u8]) -> Result<()> {
750        lock_with_recovery(&self.repair_by_commit, "repair_by_commit")
751            .insert(commit_seq, repair_symbols.to_vec());
752        self.total_repair_bytes.fetch_add(
753            u64::try_from(repair_symbols.len()).map_err(|_| FrankenError::OutOfRange {
754                what: "repair_symbol_len".to_owned(),
755                value: repair_symbols.len().to_string(),
756            })?,
757            Ordering::Release,
758        );
759        Ok(())
760    }
761
762    fn sync_repair_symbols(&self, _commit_seq: u64) -> Result<()> {
763        self.repair_syncs.fetch_add(1, Ordering::Release);
764        Ok(())
765    }
766}
767
768/// Deterministic repair generator with configurable delay/failure injection.
769#[derive(Debug)]
770pub struct DeterministicRepairGenerator {
771    delay: Duration,
772    output_len: usize,
773    fail_repair: Arc<AtomicBool>,
774}
775
776impl DeterministicRepairGenerator {
777    #[must_use]
778    pub fn new(delay: Duration, output_len: usize) -> Self {
779        Self {
780            delay,
781            output_len: output_len.max(1),
782            fail_repair: Arc::new(AtomicBool::new(false)),
783        }
784    }
785
786    pub fn set_fail_repair(&self, fail: bool) {
787        self.fail_repair.store(fail, Ordering::Release);
788    }
789}
790
791impl RepairSymbolGenerator for DeterministicRepairGenerator {
792    fn generate_repair_symbols(
793        &self,
794        commit_seq: u64,
795        systematic_symbols: &[u8],
796    ) -> Result<Vec<u8>> {
797        if self.delay != Duration::ZERO {
798            thread::sleep(self.delay);
799        }
800        if self.fail_repair.load(Ordering::Acquire) {
801            return Err(FrankenError::Internal(format!(
802                "repair generation failed for commit_seq={commit_seq}"
803            )));
804        }
805
806        let source = if systematic_symbols.is_empty() {
807            &[0_u8][..]
808        } else {
809            systematic_symbols
810        };
811        let mut state = commit_seq
812            ^ u64::try_from(source.len()).map_err(|_| FrankenError::OutOfRange {
813                what: "systematic_symbol_len".to_owned(),
814                value: source.len().to_string(),
815            })?;
816        let mut out = Vec::with_capacity(self.output_len);
817        for idx in 0..self.output_len {
818            let src = source[idx % source.len()];
819            let idx_mod = u64::try_from(idx % 251).map_err(|_| FrankenError::OutOfRange {
820                what: "repair_symbol_index".to_owned(),
821                value: idx.to_string(),
822            })?;
823            state = state.rotate_left(7) ^ u64::from(src) ^ idx_mod;
824            out.push((state & 0xFF) as u8);
825        }
826        Ok(out)
827    }
828}
829
830/// Two-phase commit durability coordinator.
831pub struct CommitRepairCoordinator<
832    IO: CommitRepairIo + Send + Sync + 'static,
833    GEN: RepairSymbolGenerator + Send + Sync + 'static,
834> {
835    config: CommitRepairConfig,
836    runtime: Runtime,
837    coordinator_cx: Cx,
838    io: Arc<IO>,
839    generator: Arc<GEN>,
840    next_commit_seq: AtomicU64,
841    next_async_task_id: AtomicU64,
842    repair_states: Arc<Mutex<HashMap<u64, RepairState>>>,
843    events: Arc<Mutex<Vec<CommitRepairEvent>>>,
844    handles: Mutex<Vec<AsyncJoinHandle<()>>>,
845}
846
847impl<IO, GEN> CommitRepairCoordinator<IO, GEN>
848where
849    IO: CommitRepairIo + Send + Sync + 'static,
850    GEN: RepairSymbolGenerator + Send + Sync + 'static,
851{
852    #[must_use]
853    pub fn new(
854        config: CommitRepairConfig,
855        runtime: Runtime,
856        parent_cx: &Cx,
857        io: IO,
858        generator: GEN,
859    ) -> Self {
860        Self::with_shared(
861            config,
862            runtime,
863            parent_cx,
864            Arc::new(io),
865            Arc::new(generator),
866        )
867    }
868
869    #[must_use]
870    pub fn with_shared(
871        config: CommitRepairConfig,
872        runtime: Runtime,
873        parent_cx: &Cx,
874        io: Arc<IO>,
875        generator: Arc<GEN>,
876    ) -> Self {
877        Self {
878            config,
879            runtime,
880            coordinator_cx: parent_cx.create_child(),
881            io,
882            generator,
883            next_commit_seq: AtomicU64::new(1),
884            next_async_task_id: AtomicU64::new(1),
885            repair_states: Arc::new(Mutex::new(HashMap::new())),
886            events: Arc::new(Mutex::new(Vec::new())),
887            handles: Mutex::new(Vec::new()),
888        }
889    }
890
891    /// Execute critical-path durability and schedule async repair work.
892    pub fn commit(&self, systematic_symbols: &[u8]) -> Result<CommitReceipt> {
893        let started_at = Instant::now();
894        let commit_seq = self.next_commit_seq.fetch_add(1, Ordering::Relaxed);
895
896        self.io
897            .append_systematic_symbols(commit_seq, systematic_symbols)?;
898        self.io.sync_systematic_symbols(commit_seq)?;
899        self.record(commit_seq, CommitRepairEventKind::CommitDurable);
900
901        if !self.config.repair_enabled {
902            self.record(commit_seq, CommitRepairEventKind::CommitAcked);
903            return Ok(CommitReceipt {
904                commit_seq,
905                durable: true,
906                repair_pending: false,
907                latency: started_at.elapsed(),
908            });
909        }
910
911        lock_with_recovery(&self.repair_states, "repair_states")
912            .insert(commit_seq, RepairState::Pending);
913        self.record(commit_seq, CommitRepairEventKind::DurableButNotRepairable);
914        debug!(
915            bead_id = BEAD_ID,
916            commit_seq, "commit is durable but not repairable while async repair is pending"
917        );
918        self.record(commit_seq, CommitRepairEventKind::CommitAcked);
919
920        let async_task_id = self.next_async_task_id.fetch_add(1, Ordering::Relaxed);
921        let io = Arc::clone(&self.io);
922        let generator = Arc::clone(&self.generator);
923        let repair_states = Arc::clone(&self.repair_states);
924        let events = Arc::clone(&self.events);
925        let systematic_snapshot = systematic_symbols.to_vec();
926        let worker_cx = self.coordinator_cx.create_child();
927        let handle = self.runtime.handle().try_spawn(run_repair_task(RepairTask {
928            commit_seq,
929            async_task_id,
930            io,
931            generator,
932            repair_states,
933            events,
934            systematic_snapshot,
935            worker_cx,
936        }));
937        match handle {
938            Ok(handle) => {
939                lock_with_recovery(&self.handles, "repair_handles").push(handle);
940            }
941            Err(err) => {
942                set_repair_state(&self.repair_states, commit_seq, RepairState::Failed);
943                self.record(commit_seq, CommitRepairEventKind::RepairFailed);
944                error!(
945                    bead_id = BEAD_ID,
946                    commit_seq,
947                    async_task_id,
948                    error = ?err,
949                    "failed to schedule repair task on caller-owned runtime"
950                );
951                return Ok(CommitReceipt {
952                    commit_seq,
953                    durable: true,
954                    repair_pending: false,
955                    latency: started_at.elapsed(),
956                });
957            }
958        }
959
960        Ok(CommitReceipt {
961            commit_seq,
962            durable: true,
963            repair_pending: true,
964            latency: started_at.elapsed(),
965        })
966    }
967
968    /// Join all currently scheduled background repair workers.
969    ///
970    /// Never re-enters the owning runtime: when called from inside a runtime
971    /// context, completed workers are harvested without blocking and any
972    /// still-running workers are re-queued for a later join from outside the
973    /// runtime.
974    ///
975    /// # Errors
976    ///
977    /// Returns an error when a repair worker panicked, or when in-flight
978    /// workers could not be joined because the caller is inside a runtime
979    /// context (the handles remain queued and can be joined later).
980    pub fn wait_for_background_repair(&self) -> Result<()> {
981        let handles = {
982            let mut guard = lock_with_recovery(&self.handles, "repair_handles");
983            std::mem::take(&mut *guard)
984        };
985        let report = drain_repair_handles(&self.runtime, handles);
986        let unjoined = report.unjoined.len();
987        if unjoined > 0 {
988            // Re-queue so a later call from outside the runtime can join them.
989            lock_with_recovery(&self.handles, "repair_handles").extend(report.unjoined);
990        }
991        if report.observed_panic {
992            return Err(FrankenError::Internal(
993                "background repair worker panicked".to_owned(),
994            ));
995        }
996        if unjoined > 0 {
997            return Err(FrankenError::Internal(format!(
998                "cannot block on {unjoined} in-flight background repair worker(s) from inside \
999                 a runtime context; handles were re-queued for a later join"
1000            )));
1001        }
1002        Ok(())
1003    }
1004
1005    #[must_use]
1006    pub fn pending_background_repair_count(&self) -> usize {
1007        lock_with_recovery(&self.repair_states, "repair_states")
1008            .values()
1009            .filter(|state| matches!(state, RepairState::Pending))
1010            .count()
1011    }
1012
1013    #[must_use]
1014    pub fn repair_state_for(&self, commit_seq: u64) -> RepairState {
1015        lock_with_recovery(&self.repair_states, "repair_states")
1016            .get(&commit_seq)
1017            .copied()
1018            .unwrap_or(RepairState::NotScheduled)
1019    }
1020
1021    #[must_use]
1022    pub fn events_for_commit(&self, commit_seq: u64) -> Vec<CommitRepairEvent> {
1023        lock_with_recovery(&self.events, "repair_events")
1024            .iter()
1025            .copied()
1026            .filter(|event| event.commit_seq == commit_seq)
1027            .collect()
1028    }
1029
1030    #[must_use]
1031    pub fn durable_not_repairable_window(&self, commit_seq: u64) -> Option<Duration> {
1032        let events = self.events_for_commit(commit_seq);
1033        let pending = events
1034            .iter()
1035            .find(|event| event.kind == CommitRepairEventKind::DurableButNotRepairable)?;
1036        let repair_done = events
1037            .iter()
1038            .find(|event| event.kind == CommitRepairEventKind::RepairCompleted)?;
1039        Some(
1040            repair_done
1041                .recorded_at
1042                .saturating_duration_since(pending.recorded_at),
1043        )
1044    }
1045
1046    #[must_use]
1047    pub fn io_handle(&self) -> Arc<IO> {
1048        Arc::clone(&self.io)
1049    }
1050
1051    #[must_use]
1052    pub fn generator_handle(&self) -> Arc<GEN> {
1053        Arc::clone(&self.generator)
1054    }
1055
1056    fn record(&self, commit_seq: u64, kind: CommitRepairEventKind) {
1057        record_event_into(&self.events, commit_seq, kind);
1058    }
1059}
1060
1061impl<IO, GEN> Drop for CommitRepairCoordinator<IO, GEN>
1062where
1063    IO: CommitRepairIo + Send + Sync + 'static,
1064    GEN: RepairSymbolGenerator + Send + Sync + 'static,
1065{
1066    fn drop(&mut self) {
1067        let handles = {
1068            let mut guard = lock_with_recovery(&self.handles, "repair_handles");
1069            std::mem::take(&mut *guard)
1070        };
1071        if handles.is_empty() {
1072            return;
1073        }
1074        let report = drain_repair_handles(&self.runtime, handles);
1075        if report.observed_panic {
1076            error!(
1077                bead_id = BEAD_ID,
1078                "background repair worker panicked during drop"
1079            );
1080        }
1081        if !report.unjoined.is_empty() {
1082            warn!(
1083                bead_id = BEAD_ID,
1084                detached = report.unjoined.len(),
1085                "detaching in-flight background repair workers: coordinator dropped inside a \
1086                 runtime context where a blocking join would re-enter the runtime"
1087            );
1088            // Dropping the handles detaches the workers; they keep running on
1089            // the caller-owned runtime (or are cancelled by its shutdown).
1090            drop(report.unjoined);
1091        }
1092    }
1093}
1094
1095/// Outcome of draining background repair handles without runtime re-entry.
1096struct RepairDrainReport {
1097    /// At least one joined worker panicked (panic was contained).
1098    observed_panic: bool,
1099    /// Handles that were still running and could not be joined without
1100    /// blocking from inside a runtime context.
1101    unjoined: Vec<AsyncJoinHandle<()>>,
1102}
1103
1104/// Join repair handles without ever calling `block_on` from inside a runtime
1105/// context (asupersync's kernel refuses nested scheduler entry).
1106///
1107/// Outside any runtime context this blocks until every handle completes,
1108/// preserving panic containment via `catch_unwind`. Inside a runtime context,
1109/// completed handles are harvested with a single manual poll and still-running
1110/// handles are returned in `unjoined` for the caller to re-queue or detach.
1111fn drain_repair_handles(runtime: &Runtime, handles: Vec<AsyncJoinHandle<()>>) -> RepairDrainReport {
1112    let mut report = RepairDrainReport {
1113        observed_panic: false,
1114        unjoined: Vec::new(),
1115    };
1116    let inside_runtime_context = Runtime::current_handle().is_some();
1117    for handle in handles {
1118        if inside_runtime_context {
1119            if handle.is_finished() {
1120                if poll_finished_repair_handle(handle) {
1121                    report.observed_panic = true;
1122                }
1123            } else {
1124                report.unjoined.push(handle);
1125            }
1126        } else {
1127            let joined = std::panic::catch_unwind(AssertUnwindSafe(|| runtime.block_on(handle)));
1128            if joined.is_err() {
1129                report.observed_panic = true;
1130            }
1131        }
1132    }
1133    report
1134}
1135
1136/// Harvest a completed handle's outcome (including a contained panic) with a
1137/// single manual poll; never enters the runtime scheduler.
1138///
1139/// Returns `true` when the joined worker panicked.
1140fn poll_finished_repair_handle(handle: AsyncJoinHandle<()>) -> bool {
1141    debug_assert!(handle.is_finished(), "handle must be finished before poll");
1142    let mut handle = std::pin::pin!(handle);
1143    let mut poll_cx = std::task::Context::from_waker(std::task::Waker::noop());
1144    std::panic::catch_unwind(AssertUnwindSafe(|| {
1145        let _ = handle.as_mut().poll(&mut poll_cx);
1146    }))
1147    .is_err()
1148}
1149
1150struct RepairTask<IO, GEN> {
1151    commit_seq: u64,
1152    async_task_id: u64,
1153    io: Arc<IO>,
1154    generator: Arc<GEN>,
1155    repair_states: Arc<Mutex<HashMap<u64, RepairState>>>,
1156    events: Arc<Mutex<Vec<CommitRepairEvent>>>,
1157    systematic_snapshot: Vec<u8>,
1158    worker_cx: Cx,
1159}
1160
1161async fn run_repair_task<IO, GEN>(task: RepairTask<IO, GEN>)
1162where
1163    IO: CommitRepairIo + Send + Sync + 'static,
1164    GEN: RepairSymbolGenerator + Send + Sync + 'static,
1165{
1166    let RepairTask {
1167        commit_seq,
1168        async_task_id,
1169        io,
1170        generator,
1171        repair_states,
1172        events,
1173        systematic_snapshot,
1174        worker_cx,
1175    } = task;
1176
1177    let Some(native_worker_cx) = NativeCx::current() else {
1178        set_repair_state(&repair_states, commit_seq, RepairState::Failed);
1179        record_event_into(&events, commit_seq, CommitRepairEventKind::RepairFailed);
1180        error!(
1181            bead_id = BEAD_ID,
1182            commit_seq, async_task_id, "repair task missing native runtime context"
1183        );
1184        return;
1185    };
1186    worker_cx.set_native_cx(native_worker_cx.clone());
1187    if worker_cx.checkpoint().is_err() {
1188        set_repair_state(&repair_states, commit_seq, RepairState::Failed);
1189        record_event_into(&events, commit_seq, CommitRepairEventKind::RepairFailed);
1190        warn!(
1191            bead_id = BEAD_ID,
1192            commit_seq, async_task_id, "repair task was cancelled before blocking work started"
1193        );
1194        return;
1195    }
1196
1197    info!(
1198        bead_id = BEAD_ID,
1199        commit_seq, async_task_id, "repair symbols generation started"
1200    );
1201    record_event_into(&events, commit_seq, CommitRepairEventKind::RepairStarted);
1202
1203    let blocking_cx = worker_cx.create_child();
1204    let repair_outcome = spawn_blocking(move || {
1205        std::panic::catch_unwind(AssertUnwindSafe(|| {
1206            blocking_cx.set_native_cx(native_worker_cx);
1207            let repair_symbols =
1208                generator.generate_repair_symbols(commit_seq, &systematic_snapshot)?;
1209            let repair_symbol_bytes = repair_symbols.len();
1210            io.append_repair_symbols(commit_seq, &repair_symbols)?;
1211            io.sync_repair_symbols(commit_seq)?;
1212            Ok::<usize, FrankenError>(repair_symbol_bytes)
1213        }))
1214    })
1215    .await;
1216
1217    match repair_outcome {
1218        Ok(Ok(repair_symbol_bytes)) => {
1219            set_repair_state(&repair_states, commit_seq, RepairState::Completed);
1220            record_event_into(&events, commit_seq, CommitRepairEventKind::RepairCompleted);
1221            info!(
1222                bead_id = BEAD_ID,
1223                commit_seq,
1224                async_task_id,
1225                repair_symbol_bytes,
1226                "repair symbols append+sync completed"
1227            );
1228        }
1229        // `spawn_blocking` returns the closure result directly, so the outer
1230        // `catch_unwind` layer is the panic boundary and the inner `Result`
1231        // carries generator / append / sync failures.
1232        Ok(Err(err)) => {
1233            set_repair_state(&repair_states, commit_seq, RepairState::Failed);
1234            record_event_into(&events, commit_seq, CommitRepairEventKind::RepairFailed);
1235            error!(
1236                bead_id = BEAD_ID,
1237                commit_seq,
1238                async_task_id,
1239                error = ?err,
1240                "repair symbol generation or append/sync failed"
1241            );
1242        }
1243        Err(_panic_payload) => {
1244            set_repair_state(&repair_states, commit_seq, RepairState::Failed);
1245            record_event_into(&events, commit_seq, CommitRepairEventKind::RepairFailed);
1246            error!(
1247                bead_id = BEAD_ID,
1248                commit_seq, async_task_id, "repair symbol worker panicked"
1249            );
1250        }
1251    }
1252}
1253
1254fn lock_with_recovery<'a, T>(mutex: &'a Mutex<T>, lock_name: &'static str) -> MutexGuard<'a, T> {
1255    match mutex.lock() {
1256        Ok(guard) => guard,
1257        Err(poisoned) => {
1258            warn!(
1259                bead_id = BEAD_ID,
1260                lock = lock_name,
1261                "mutex poisoned; recovering inner state"
1262            );
1263            poisoned.into_inner()
1264        }
1265    }
1266}
1267
1268fn set_repair_state(
1269    repair_states: &Arc<Mutex<HashMap<u64, RepairState>>>,
1270    commit_seq: u64,
1271    state: RepairState,
1272) {
1273    lock_with_recovery(repair_states, "repair_states").insert(commit_seq, state);
1274}
1275
1276fn record_event_into(
1277    events: &Arc<Mutex<Vec<CommitRepairEvent>>>,
1278    commit_seq: u64,
1279    kind: CommitRepairEventKind,
1280) {
1281    let mut guard = lock_with_recovery(events, "repair_events");
1282    let seq = guard
1283        .iter()
1284        .rev()
1285        .find(|event| event.commit_seq == commit_seq)
1286        .map_or(1, |event| event.seq.saturating_add(1));
1287    guard.push(CommitRepairEvent {
1288        commit_seq,
1289        seq,
1290        recorded_at: Instant::now(),
1291        kind,
1292    });
1293}
1294
1295// ---------------------------------------------------------------------------
1296// Group Commit Batching (ยง5.9.2.1, bd-l4gl)
1297// ---------------------------------------------------------------------------
1298
1299const GROUP_COMMIT_BEAD_ID: &str = "bd-l4gl";
1300const GROUP_COMMIT_IDLE_POLL_INTERVAL: Duration = Duration::from_millis(50);
1301
1302/// Phase label recorded during coordinator batch processing for ordering
1303/// verification.
1304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1305pub enum BatchPhase {
1306    /// Write-set conflict validation for each request.
1307    Validate,
1308    /// Sequential WAL append for all valid requests.
1309    WalAppend,
1310    /// Single `fsync` for the entire batch.
1311    Fsync,
1312    /// Version publication and response delivery.
1313    Publish,
1314}
1315
1316/// Response returned to each writer after batch processing.
1317#[derive(Debug, Clone, PartialEq, Eq)]
1318pub enum GroupCommitResponse {
1319    /// Commit succeeded; pages are durable and published.
1320    Committed { wal_offset: u64, commit_seq: u64 },
1321    /// Commit rejected due to write-set conflict.
1322    Conflict { reason: String },
1323}
1324
1325/// Result of processing a single batch through the coordinator.
1326#[derive(Debug)]
1327pub struct BatchResult {
1328    /// Successfully committed entries: `(txn_id, wal_offset, commit_seq)`.
1329    pub committed: Vec<(u64, u64, u64)>,
1330    /// Rejected entries: `(txn_id, reason)`.
1331    pub conflicted: Vec<(u64, String)>,
1332    /// Number of fsync calls issued for this batch (should always be 0 or 1).
1333    pub fsync_count: u32,
1334    /// Ordered record of phases executed during batch processing.
1335    pub phase_order: Vec<BatchPhase>,
1336}
1337
1338/// Batch WAL writer abstraction for the group commit coordinator.
1339///
1340/// A single `append_batch` call writes all frames from all valid requests
1341/// in one sequential `write()`, and `sync` issues exactly one `fsync`.
1342pub trait WalBatchWriter: Send + Sync {
1343    /// Append commit frames for every request in the batch. Returns a WAL
1344    /// offset per request.
1345    fn append_batch(&self, requests: &[&CommitRequest]) -> Result<Vec<u64>>;
1346
1347    /// Issue a single `fsync` (or `fdatasync`) covering all appended frames.
1348    fn sync(&self) -> Result<()>;
1349}
1350
1351/// Write-set conflict validator using first-committer-wins (FCW) logic.
1352///
1353/// `committed_pages` is the set of pages that have been committed since
1354/// the validating transaction's snapshot.
1355pub trait WriteSetValidator: Send + Sync {
1356    /// Returns `Ok(())` if the request passes validation, or `Err` with
1357    /// a human-readable conflict description.
1358    fn validate(
1359        &self,
1360        request: &CommitRequest,
1361        committed_pages: &BTreeSet<u32>,
1362    ) -> std::result::Result<(), String>;
1363}
1364
1365/// First-committer-wins validator: any overlap between the request's
1366/// write set and already-committed pages is a conflict.
1367#[derive(Debug, Default)]
1368pub struct FirstCommitterWinsValidator;
1369
1370impl WriteSetValidator for FirstCommitterWinsValidator {
1371    fn validate(
1372        &self,
1373        request: &CommitRequest,
1374        committed_pages: &BTreeSet<u32>,
1375    ) -> std::result::Result<(), String> {
1376        for &page in &request.write_set_pages {
1377            if committed_pages.contains(&page) {
1378                return Err(format!(
1379                    "write-set conflict on page {page} for txn {}",
1380                    request.txn_id
1381                ));
1382            }
1383        }
1384        Ok(())
1385    }
1386}
1387
1388/// In-memory WAL writer for deterministic testing and instrumentation.
1389#[derive(Debug)]
1390pub struct InMemoryWalWriter {
1391    next_offset: AtomicU64,
1392    sync_count: AtomicU64,
1393    total_appended: AtomicU64,
1394    /// Simulated fsync latency for throughput model tests.
1395    fsync_delay: Duration,
1396}
1397
1398impl InMemoryWalWriter {
1399    /// Create an in-memory WAL writer with no simulated fsync delay.
1400    #[must_use]
1401    pub fn new() -> Self {
1402        Self {
1403            next_offset: AtomicU64::new(1),
1404            sync_count: AtomicU64::new(0),
1405            total_appended: AtomicU64::new(0),
1406            fsync_delay: Duration::ZERO,
1407        }
1408    }
1409
1410    /// Create with simulated fsync delay for throughput model testing.
1411    #[must_use]
1412    pub fn with_fsync_delay(delay: Duration) -> Self {
1413        Self {
1414            next_offset: AtomicU64::new(1),
1415            sync_count: AtomicU64::new(0),
1416            total_appended: AtomicU64::new(0),
1417            fsync_delay: delay,
1418        }
1419    }
1420
1421    /// Total number of `sync()` calls observed.
1422    #[must_use]
1423    pub fn sync_count(&self) -> u64 {
1424        self.sync_count.load(Ordering::Acquire)
1425    }
1426
1427    /// Total requests appended across all batches.
1428    #[must_use]
1429    pub fn total_appended(&self) -> u64 {
1430        self.total_appended.load(Ordering::Acquire)
1431    }
1432}
1433
1434impl Default for InMemoryWalWriter {
1435    fn default() -> Self {
1436        Self::new()
1437    }
1438}
1439
1440impl WalBatchWriter for InMemoryWalWriter {
1441    fn append_batch(&self, requests: &[&CommitRequest]) -> Result<Vec<u64>> {
1442        let mut offsets = Vec::with_capacity(requests.len());
1443        for _req in requests {
1444            let offset = self.next_offset.fetch_add(1, Ordering::Relaxed);
1445            offsets.push(offset);
1446        }
1447        #[allow(clippy::cast_possible_truncation)]
1448        self.total_appended
1449            .fetch_add(requests.len() as u64, Ordering::Release);
1450        Ok(offsets)
1451    }
1452
1453    fn sync(&self) -> Result<()> {
1454        if self.fsync_delay != Duration::ZERO {
1455            thread::sleep(self.fsync_delay);
1456        }
1457        self.sync_count.fetch_add(1, Ordering::Release);
1458        Ok(())
1459    }
1460}
1461
1462#[cfg(test)]
1463mod commit_repair_async_tests {
1464    use super::*;
1465
1466    use asupersync::runtime::RuntimeBuilder;
1467    use std::thread;
1468
1469    #[test]
1470    fn test_commit_repair_runs_on_caller_owned_runtime() {
1471        let runtime = RuntimeBuilder::current_thread()
1472            .build()
1473            .expect("commit repair runtime");
1474        let root_cx = Cx::new();
1475        let io = Arc::new(InMemoryCommitRepairIo::default());
1476        let generator = Arc::new(DeterministicRepairGenerator::new(
1477            Duration::from_millis(25),
1478            64,
1479        ));
1480        let coordinator = CommitRepairCoordinator::with_shared(
1481            CommitRepairConfig {
1482                repair_enabled: true,
1483            },
1484            runtime,
1485            &root_cx,
1486            Arc::clone(&io),
1487            generator,
1488        );
1489
1490        let receipt = coordinator
1491            .commit(&[0xAB; 256])
1492            .expect("commit should succeed");
1493        assert_eq!(coordinator.pending_background_repair_count(), 1);
1494
1495        coordinator
1496            .wait_for_background_repair()
1497            .expect("background repair should finish");
1498
1499        assert_eq!(coordinator.pending_background_repair_count(), 0);
1500        assert_eq!(
1501            coordinator.repair_state_for(receipt.commit_seq),
1502            RepairState::Completed
1503        );
1504        assert!(
1505            coordinator
1506                .events_for_commit(receipt.commit_seq)
1507                .iter()
1508                .any(|event| event.kind == CommitRepairEventKind::RepairStarted)
1509        );
1510        assert!(
1511            io.total_repair_bytes() > 0,
1512            "repair task should append repair symbols"
1513        );
1514    }
1515
1516    #[test]
1517    fn test_drop_inside_runtime_task_does_not_deadlock() {
1518        // Coordinator owns its own runtime with an in-flight repair worker.
1519        let coordinator_runtime = RuntimeBuilder::current_thread()
1520            .build()
1521            .expect("coordinator runtime");
1522        let root_cx = Cx::new();
1523        let coordinator = CommitRepairCoordinator::new(
1524            CommitRepairConfig {
1525                repair_enabled: true,
1526            },
1527            coordinator_runtime,
1528            &root_cx,
1529            InMemoryCommitRepairIo::default(),
1530            DeterministicRepairGenerator::new(Duration::from_millis(50), 64),
1531        );
1532        coordinator
1533            .commit(&[0xAB; 64])
1534            .expect("commit should schedule repair");
1535        assert_eq!(coordinator.pending_background_repair_count(), 1);
1536
1537        // Drop the coordinator from inside another runtime's task context.
1538        // The old Drop implementation called `self.runtime.block_on` here,
1539        // re-entering the runtime (asupersync refuses nested scheduler
1540        // contexts); the guarded shutdown must detach instead.
1541        let outer_runtime = RuntimeBuilder::current_thread()
1542            .build()
1543            .expect("outer runtime");
1544        let drop_task = outer_runtime
1545            .handle()
1546            .try_spawn(async move {
1547                drop(coordinator);
1548                true
1549            })
1550            .expect("spawn drop task");
1551        let completed = outer_runtime.block_on(drop_task);
1552        assert!(
1553            completed,
1554            "dropping the coordinator inside a runtime task must complete without deadlock"
1555        );
1556    }
1557
1558    #[test]
1559    fn test_wait_for_background_repair_inside_runtime_requeues_instead_of_blocking() {
1560        let coordinator_runtime = RuntimeBuilder::current_thread()
1561            .build()
1562            .expect("coordinator runtime");
1563        let root_cx = Cx::new();
1564        let coordinator = Arc::new(CommitRepairCoordinator::new(
1565            CommitRepairConfig {
1566                repair_enabled: true,
1567            },
1568            coordinator_runtime,
1569            &root_cx,
1570            InMemoryCommitRepairIo::default(),
1571            DeterministicRepairGenerator::new(Duration::from_millis(25), 64),
1572        ));
1573        coordinator
1574            .commit(&[0xCD; 64])
1575            .expect("commit should schedule repair");
1576
1577        // From inside a runtime context the join must refuse to block and
1578        // re-queue the in-flight handle instead of deadlocking.
1579        let outer_runtime = RuntimeBuilder::current_thread()
1580            .build()
1581            .expect("outer runtime");
1582        let coordinator_in_task = Arc::clone(&coordinator);
1583        let wait_result =
1584            outer_runtime.block_on(async move { coordinator_in_task.wait_for_background_repair() });
1585        assert!(
1586            wait_result.is_err(),
1587            "in-flight workers cannot be joined from inside a runtime context"
1588        );
1589
1590        // From outside any runtime context the re-queued handle joins fine.
1591        coordinator
1592            .wait_for_background_repair()
1593            .expect("outside a runtime context the re-queued worker must join");
1594        assert_eq!(coordinator.pending_background_repair_count(), 0);
1595    }
1596
1597    #[test]
1598    fn test_commit_receipt_latency_tracks_critical_path_io() {
1599        #[derive(Debug)]
1600        struct DelayedIo {
1601            delay: Duration,
1602        }
1603
1604        impl CommitRepairIo for DelayedIo {
1605            fn append_systematic_symbols(
1606                &self,
1607                _commit_seq: u64,
1608                _systematic_symbols: &[u8],
1609            ) -> Result<()> {
1610                Ok(())
1611            }
1612
1613            fn sync_systematic_symbols(&self, _commit_seq: u64) -> Result<()> {
1614                thread::sleep(self.delay);
1615                Ok(())
1616            }
1617
1618            fn append_repair_symbols(
1619                &self,
1620                _commit_seq: u64,
1621                _repair_symbols: &[u8],
1622            ) -> Result<()> {
1623                Ok(())
1624            }
1625
1626            fn sync_repair_symbols(&self, _commit_seq: u64) -> Result<()> {
1627                Ok(())
1628            }
1629        }
1630
1631        let runtime = RuntimeBuilder::current_thread()
1632            .build()
1633            .expect("commit repair runtime");
1634        let root_cx = Cx::new();
1635        let coordinator = CommitRepairCoordinator::new(
1636            CommitRepairConfig {
1637                repair_enabled: false,
1638            },
1639            runtime,
1640            &root_cx,
1641            DelayedIo {
1642                delay: Duration::from_millis(10),
1643            },
1644            DeterministicRepairGenerator::new(Duration::ZERO, 64),
1645        );
1646
1647        let receipt = coordinator
1648            .commit(&[0xAB; 256])
1649            .expect("commit should succeed");
1650
1651        assert!(
1652            receipt.latency >= Duration::from_millis(8),
1653            "commit latency should include the critical-path systematic sync cost"
1654        );
1655    }
1656
1657    #[test]
1658    fn test_durable_not_repairable_window_tracks_wall_clock_delay() {
1659        let runtime = RuntimeBuilder::current_thread()
1660            .build()
1661            .expect("commit repair runtime");
1662        let root_cx = Cx::new();
1663        let coordinator = CommitRepairCoordinator::new(
1664            CommitRepairConfig {
1665                repair_enabled: true,
1666            },
1667            runtime,
1668            &root_cx,
1669            InMemoryCommitRepairIo::default(),
1670            DeterministicRepairGenerator::new(Duration::from_millis(20), 64),
1671        );
1672
1673        let receipt = coordinator
1674            .commit(&[0xAB; 256])
1675            .expect("commit should succeed");
1676        coordinator
1677            .wait_for_background_repair()
1678            .expect("background repair should finish");
1679
1680        let window = coordinator
1681            .durable_not_repairable_window(receipt.commit_seq)
1682            .expect("window should be measurable");
1683        assert!(
1684            window >= Duration::from_millis(15),
1685            "window should reflect real repair delay rather than logical event ordinals"
1686        );
1687    }
1688
1689    #[test]
1690    fn test_panicking_repair_marks_failed_without_abandoning_other_work() {
1691        #[derive(Debug)]
1692        struct PanicFirstGenerator;
1693
1694        impl RepairSymbolGenerator for PanicFirstGenerator {
1695            fn generate_repair_symbols(
1696                &self,
1697                commit_seq: u64,
1698                _systematic_symbols: &[u8],
1699            ) -> Result<Vec<u8>> {
1700                if commit_seq == 1 {
1701                    panic!("intentional repair panic");
1702                }
1703                thread::sleep(Duration::from_millis(25));
1704                Ok(vec![0xCD; 32])
1705            }
1706        }
1707
1708        let runtime = RuntimeBuilder::current_thread()
1709            .build()
1710            .expect("commit repair runtime");
1711        let root_cx = Cx::new();
1712        let coordinator = CommitRepairCoordinator::new(
1713            CommitRepairConfig {
1714                repair_enabled: true,
1715            },
1716            runtime,
1717            &root_cx,
1718            InMemoryCommitRepairIo::default(),
1719            PanicFirstGenerator,
1720        );
1721
1722        let first = coordinator
1723            .commit(&[0xAA; 64])
1724            .expect("first commit should schedule repair");
1725        let second = coordinator
1726            .commit(&[0xBB; 64])
1727            .expect("second commit should schedule repair");
1728
1729        coordinator
1730            .wait_for_background_repair()
1731            .expect("panic inside repair work should be converted into RepairFailed state");
1732        assert_eq!(coordinator.pending_background_repair_count(), 0);
1733        assert_eq!(
1734            coordinator.repair_state_for(first.commit_seq),
1735            RepairState::Failed,
1736            "panicking repair task must be marked failed rather than left pending"
1737        );
1738        assert!(
1739            coordinator
1740                .events_for_commit(first.commit_seq)
1741                .iter()
1742                .any(|event| event.kind == CommitRepairEventKind::RepairFailed),
1743            "panicking repair task must emit a RepairFailed event"
1744        );
1745        assert_eq!(
1746            coordinator.repair_state_for(second.commit_seq),
1747            RepairState::Completed,
1748            "wait_for_background_repair must still drain non-panicking tasks"
1749        );
1750    }
1751}
1752
1753/// Group commit coordinator configuration.
1754#[derive(Debug, Clone, Copy)]
1755pub struct GroupCommitConfig {
1756    /// Maximum requests coalesced into a single batch.
1757    pub max_batch_size: usize,
1758    /// Timeout for draining additional requests after the first.
1759    pub drain_timeout: Duration,
1760}
1761
1762impl Default for GroupCommitConfig {
1763    fn default() -> Self {
1764        Self {
1765            max_batch_size: DEFAULT_COMMIT_CHANNEL_CAPACITY,
1766            drain_timeout: Duration::from_micros(100),
1767        }
1768    }
1769}
1770
1771/// Published version notification for a committed transaction.
1772#[derive(Debug, Clone, PartialEq, Eq)]
1773pub struct PublishedVersion {
1774    pub txn_id: u64,
1775    pub commit_seq: u64,
1776    pub wal_offset: u64,
1777}
1778
1779/// Group commit coordinator that batches write-coordinator requests to
1780/// amortize `fsync` cost (ยง5.9.2.1, bd-l4gl).
1781///
1782/// The coordinator processes requests from the bounded two-phase MPSC
1783/// channel in 4 strict phases per batch:
1784///
1785/// 1. **Validate** โ€” first-committer-wins conflict check
1786/// 2. **WAL append** โ€” single sequential `write()` for all valid frames
1787/// 3. **Fsync** โ€” exactly ONE `fsync()` per batch
1788/// 4. **Publish** โ€” make versions visible and deliver responses
1789pub struct GroupCommitCoordinator<W: WalBatchWriter, V: WriteSetValidator> {
1790    wal: Arc<W>,
1791    validator: Arc<V>,
1792    config: GroupCommitConfig,
1793    next_commit_seq: AtomicU64,
1794    committed_pages: Mutex<BTreeSet<u32>>,
1795    published: Mutex<Vec<PublishedVersion>>,
1796    batch_history: Mutex<Vec<BatchResult>>,
1797    total_batches: AtomicU64,
1798}
1799
1800impl<W, V> GroupCommitCoordinator<W, V>
1801where
1802    W: WalBatchWriter + 'static,
1803    V: WriteSetValidator + 'static,
1804{
1805    /// Create a new group commit coordinator.
1806    #[must_use]
1807    pub fn new(wal: W, validator: V, config: GroupCommitConfig) -> Self {
1808        Self {
1809            wal: Arc::new(wal),
1810            validator: Arc::new(validator),
1811            config,
1812            next_commit_seq: AtomicU64::new(1),
1813            committed_pages: Mutex::new(BTreeSet::new()),
1814            published: Mutex::new(Vec::new()),
1815            batch_history: Mutex::new(Vec::new()),
1816            total_batches: AtomicU64::new(0),
1817        }
1818    }
1819
1820    /// Process a single batch of requests through the 4-phase pipeline.
1821    ///
1822    /// Returns individual responses and batch-level metrics. Phase ordering
1823    /// is recorded in `BatchResult::phase_order` for verification.
1824    #[allow(clippy::too_many_lines)]
1825    pub fn process_batch(
1826        &self,
1827        requests: Vec<CommitRequest>,
1828    ) -> Result<(Vec<(CommitRequest, GroupCommitResponse)>, BatchResult)> {
1829        if requests.is_empty() {
1830            return Ok((
1831                Vec::new(),
1832                BatchResult {
1833                    committed: Vec::new(),
1834                    conflicted: Vec::new(),
1835                    fsync_count: 0,
1836                    phase_order: Vec::new(),
1837                },
1838            ));
1839        }
1840
1841        let batch_size = requests.len();
1842        debug!(
1843            bead_id = GROUP_COMMIT_BEAD_ID,
1844            batch_size, "processing group commit batch"
1845        );
1846
1847        let mut phase_order = Vec::with_capacity(4);
1848        let mut responses: Vec<(CommitRequest, GroupCommitResponse)> =
1849            Vec::with_capacity(batch_size);
1850        let mut valid_requests: Vec<CommitRequest> = Vec::with_capacity(batch_size);
1851        let mut conflicted: Vec<(u64, String)> = Vec::new();
1852
1853        // ---- Phase 1: Validate ----
1854        phase_order.push(BatchPhase::Validate);
1855        let mut merged_committed =
1856            lock_with_recovery(&self.committed_pages, "committed_pages").clone();
1857        // Within a batch, earlier requests (by position) win over later ones
1858        // when their write sets overlap.
1859        for req in requests {
1860            match self.validator.validate(&req, &merged_committed) {
1861                Ok(()) => {
1862                    for &page in &req.write_set_pages {
1863                        merged_committed.insert(page);
1864                    }
1865                    valid_requests.push(req);
1866                }
1867                Err(reason) => {
1868                    info!(
1869                        bead_id = GROUP_COMMIT_BEAD_ID,
1870                        txn_id = req.txn_id,
1871                        reason = %reason,
1872                        "conflict detected in validate phase (fail-fast)"
1873                    );
1874                    conflicted.push((req.txn_id, reason.clone()));
1875                    responses.push((req, GroupCommitResponse::Conflict { reason }));
1876                }
1877            }
1878        }
1879
1880        if valid_requests.is_empty() {
1881            let result = BatchResult {
1882                committed: Vec::new(),
1883                conflicted,
1884                fsync_count: 0,
1885                phase_order,
1886            };
1887            lock_with_recovery(&self.batch_history, "batch_history").push(BatchResult {
1888                committed: Vec::new(),
1889                conflicted: result.conflicted.clone(),
1890                fsync_count: 0,
1891                phase_order: result.phase_order.clone(),
1892            });
1893            self.total_batches.fetch_add(1, Ordering::Relaxed);
1894            return Ok((responses, result));
1895        }
1896
1897        // ---- Phase 2: WAL append ----
1898        phase_order.push(BatchPhase::WalAppend);
1899        let refs: Vec<&CommitRequest> = valid_requests.iter().collect();
1900        let wal_offsets = self.wal.append_batch(&refs)?;
1901        if wal_offsets.len() != valid_requests.len() {
1902            return Err(FrankenError::internal(format!(
1903                "wal append returned {} offsets for {} valid requests",
1904                wal_offsets.len(),
1905                valid_requests.len()
1906            )));
1907        }
1908
1909        // ---- Phase 3: Fsync ----
1910        phase_order.push(BatchPhase::Fsync);
1911        self.wal.sync()?;
1912        let fsync_count = 1;
1913
1914        // ---- Phase 4: Publish ----
1915        phase_order.push(BatchPhase::Publish);
1916        let mut committed_entries: Vec<(u64, u64, u64)> = Vec::with_capacity(valid_requests.len());
1917        let mut committed_guard = lock_with_recovery(&self.committed_pages, "committed_pages");
1918        let mut published_guard = lock_with_recovery(&self.published, "published_versions");
1919        for (req, &wal_offset) in valid_requests.iter().zip(wal_offsets.iter()) {
1920            let commit_seq = self.next_commit_seq.fetch_add(1, Ordering::Relaxed);
1921            for &page in &req.write_set_pages {
1922                committed_guard.insert(page);
1923            }
1924            published_guard.push(PublishedVersion {
1925                txn_id: req.txn_id,
1926                commit_seq,
1927                wal_offset,
1928            });
1929            committed_entries.push((req.txn_id, wal_offset, commit_seq));
1930            info!(
1931                bead_id = GROUP_COMMIT_BEAD_ID,
1932                txn_id = req.txn_id,
1933                commit_seq,
1934                wal_offset,
1935                "version published after fsync"
1936            );
1937        }
1938        drop(committed_guard);
1939        drop(published_guard);
1940
1941        for ((req, &wal_offset), (_, _, commit_seq)) in valid_requests
1942            .into_iter()
1943            .zip(wal_offsets.iter())
1944            .zip(committed_entries.iter())
1945        {
1946            responses.push((
1947                req,
1948                GroupCommitResponse::Committed {
1949                    wal_offset,
1950                    commit_seq: *commit_seq,
1951                },
1952            ));
1953        }
1954
1955        let result = BatchResult {
1956            committed: committed_entries,
1957            conflicted,
1958            fsync_count,
1959            phase_order,
1960        };
1961
1962        lock_with_recovery(&self.batch_history, "batch_history").push(BatchResult {
1963            committed: result.committed.clone(),
1964            conflicted: result.conflicted.clone(),
1965            fsync_count: result.fsync_count,
1966            phase_order: result.phase_order.clone(),
1967        });
1968        self.total_batches.fetch_add(1, Ordering::Relaxed);
1969
1970        debug!(
1971            bead_id = GROUP_COMMIT_BEAD_ID,
1972            batch_size,
1973            committed = result.committed.len(),
1974            conflicted = result.conflicted.len(),
1975            "batch processing complete"
1976        );
1977
1978        Ok((responses, result))
1979    }
1980
1981    /// Drain requests from the receiver and process them as a batch.
1982    ///
1983    /// Blocks waiting for the first request, then non-blocking drains up
1984    /// to `max_batch_size`. Returns `None` if the receiver times out on
1985    /// the first request (channel idle).
1986    pub fn drain_and_process(
1987        &self,
1988        receiver: &TwoPhaseCommitReceiver,
1989    ) -> Result<Option<BatchResult>> {
1990        self.drain_and_process_with_first_wait(receiver, Duration::from_secs(1))
1991    }
1992
1993    fn drain_and_process_with_first_wait(
1994        &self,
1995        receiver: &TwoPhaseCommitReceiver,
1996        first_wait: Duration,
1997    ) -> Result<Option<BatchResult>> {
1998        // Blocking wait for first request
1999        let Some(first) = receiver.try_recv_for(first_wait) else {
2000            return Ok(None);
2001        };
2002
2003        let mut batch = Vec::with_capacity(self.config.max_batch_size);
2004        batch.push(first);
2005
2006        // Non-blocking drain for additional requests
2007        while batch.len() < self.config.max_batch_size {
2008            match receiver.try_recv_for(self.config.drain_timeout) {
2009                Some(req) => batch.push(req),
2010                None => break,
2011            }
2012        }
2013
2014        let (_responses, result) = self.process_batch(batch)?;
2015        Ok(Some(result))
2016    }
2017
2018    /// Run the coordinator loop until the owning region `Cx` is cancelled.
2019    ///
2020    /// This is the production entry point. The loop blocks on the first
2021    /// request of each batch, drains additional requests, and processes
2022    /// the batch through all 4 phases.
2023    pub fn run_loop(&self, receiver: &TwoPhaseCommitReceiver, cx: &Cx) -> Result<()> {
2024        info!(
2025            bead_id = GROUP_COMMIT_BEAD_ID,
2026            max_batch_size = self.config.max_batch_size,
2027            "group commit coordinator loop started"
2028        );
2029        while !cx.is_cancel_requested() {
2030            if cx.checkpoint().is_err() {
2031                break;
2032            }
2033            if let Some(result) =
2034                self.drain_and_process_with_first_wait(receiver, GROUP_COMMIT_IDLE_POLL_INTERVAL)?
2035            {
2036                debug!(
2037                    bead_id = GROUP_COMMIT_BEAD_ID,
2038                    committed = result.committed.len(),
2039                    conflicted = result.conflicted.len(),
2040                    "batch cycle completed"
2041                );
2042            }
2043        }
2044        info!(
2045            bead_id = GROUP_COMMIT_BEAD_ID,
2046            total_batches = self.total_batches.load(Ordering::Relaxed),
2047            "group commit coordinator loop shut down"
2048        );
2049        Ok(())
2050    }
2051
2052    /// Total batches processed so far.
2053    #[must_use]
2054    pub fn total_batches(&self) -> u64 {
2055        self.total_batches.load(Ordering::Acquire)
2056    }
2057
2058    /// All published versions for inspection/testing.
2059    #[must_use]
2060    pub fn published_versions(&self) -> Vec<PublishedVersion> {
2061        lock_with_recovery(&self.published, "published_versions").clone()
2062    }
2063
2064    /// Batch results for phase ordering verification.
2065    #[must_use]
2066    pub fn batch_history(&self) -> Vec<BatchResult> {
2067        // Return summary without cloning internal Vecs fully
2068        lock_with_recovery(&self.batch_history, "batch_history")
2069            .iter()
2070            .map(|b| BatchResult {
2071                committed: b.committed.clone(),
2072                conflicted: b.conflicted.clone(),
2073                fsync_count: b.fsync_count,
2074                phase_order: b.phase_order.clone(),
2075            })
2076            .collect()
2077    }
2078
2079    /// Reference to the WAL writer for instrumentation.
2080    #[must_use]
2081    pub fn wal_handle(&self) -> Arc<W> {
2082        Arc::clone(&self.wal)
2083    }
2084
2085    /// Reset committed pages (useful for test isolation).
2086    pub fn reset_committed_pages(&self) {
2087        lock_with_recovery(&self.committed_pages, "committed_pages").clear();
2088    }
2089}
2090
2091#[cfg(test)]
2092mod two_phase_pipeline_tests {
2093    use super::*;
2094    use std::sync::mpsc as std_mpsc;
2095    use std::thread;
2096    use std::time::Instant;
2097
2098    fn request(txn_id: u64) -> CommitRequest {
2099        CommitRequest::new(
2100            txn_id,
2101            vec![u32::try_from(txn_id % 97).expect("txn id modulo fits in u32")],
2102            vec![u8::try_from(txn_id & 0xFF).expect("masked to u8")],
2103        )
2104    }
2105
2106    #[test]
2107    fn test_two_phase_reserve_then_send() {
2108        let (sender, receiver) = two_phase_commit_channel(4);
2109        let permit = sender.reserve(&Cx::new()).expect("reserve should succeed");
2110        let seq = permit.reservation_seq();
2111        permit.send(request(seq));
2112        let observed_request = receiver.try_recv_for(Duration::from_millis(50));
2113        assert_eq!(observed_request, Some(request(seq)));
2114    }
2115
2116    #[test]
2117    fn test_out_of_order_send_completion_still_delivers_by_reservation_sequence() {
2118        let (sender, receiver) = two_phase_commit_channel(4);
2119        let permit1 = sender.reserve(&Cx::new()).expect("reserve should succeed");
2120        let permit2 = sender.reserve(&Cx::new()).expect("reserve should succeed");
2121        let permit3 = sender.reserve(&Cx::new()).expect("reserve should succeed");
2122
2123        let seq1 = permit1.reservation_seq();
2124        let seq2 = permit2.reservation_seq();
2125        let seq3 = permit3.reservation_seq();
2126        assert_eq!((seq1, seq2, seq3), (1, 2, 3));
2127
2128        permit2.send(request(seq2));
2129        permit3.send(request(seq3));
2130        assert_eq!(
2131            receiver.try_recv_for(Duration::from_millis(20)),
2132            None,
2133            "later sends must not bypass an earlier unresolved reservation"
2134        );
2135
2136        permit1.send(request(seq1));
2137        assert_eq!(
2138            receiver.try_recv_for(Duration::from_millis(50)),
2139            Some(request(seq1))
2140        );
2141        assert_eq!(
2142            receiver.try_recv_for(Duration::from_millis(50)),
2143            Some(request(seq2))
2144        );
2145        assert_eq!(
2146            receiver.try_recv_for(Duration::from_millis(50)),
2147            Some(request(seq3))
2148        );
2149    }
2150
2151    #[test]
2152    fn test_two_phase_cancel_during_reserve() {
2153        let (sender, _receiver) = two_phase_commit_channel(1);
2154        let blocker = sender.reserve(&Cx::new()).expect("reserve should succeed");
2155        let attempt = sender.try_reserve_for(Duration::from_millis(5));
2156        assert!(
2157            attempt.is_none(),
2158            "reserve timeout acts as cancellation during reserve"
2159        );
2160        assert_eq!(sender.occupancy(), 1, "no extra slot consumed");
2161        drop(blocker);
2162        let permit = sender.try_reserve_for(Duration::from_millis(50));
2163        assert!(permit.is_some(), "slot released after blocker drop");
2164    }
2165
2166    #[test]
2167    fn test_reserve_observes_cancellation_while_pipeline_full() {
2168        let (sender, _receiver) = two_phase_commit_channel(1);
2169        let blocker = sender
2170            .reserve(&Cx::new())
2171            .expect("first reserve fills the only slot");
2172
2173        let cancel_cx = Arc::new(Cx::new());
2174        let worker_cx = Arc::clone(&cancel_cx);
2175        let worker_sender = sender.clone();
2176        let (tx, rx) = std_mpsc::channel();
2177        let join = thread::spawn(move || {
2178            let reserve_result = worker_sender.reserve(&worker_cx);
2179            tx.send(reserve_result.is_err())
2180                .expect("result send should succeed");
2181        });
2182
2183        // Give the worker time to enter the blocked reserve loop before
2184        // requesting cancellation.
2185        thread::sleep(Duration::from_millis(50));
2186        cancel_cx.cancel();
2187
2188        let observed_cancel = rx
2189            .recv_timeout(Duration::from_secs(5))
2190            .expect("blocked reserve must observe cancellation instead of hanging");
2191        assert!(
2192            observed_cancel,
2193            "reserve on a saturated pipeline must return Err once its cx is cancelled"
2194        );
2195        join.join().expect("worker join");
2196
2197        assert_eq!(
2198            sender.occupancy(),
2199            1,
2200            "cancelled reserve must not consume a slot"
2201        );
2202        drop(blocker);
2203        assert_eq!(sender.occupancy(), 0, "blocker drop releases the slot");
2204    }
2205
2206    #[test]
2207    fn test_two_phase_drop_permit_releases_slot() {
2208        let (sender, _receiver) = two_phase_commit_channel(1);
2209        let permit = sender.reserve(&Cx::new()).expect("reserve should succeed");
2210        assert_eq!(sender.occupancy(), 1);
2211        drop(permit);
2212        assert_eq!(sender.occupancy(), 0);
2213        let retry = sender.try_reserve_for(Duration::from_millis(50));
2214        assert!(retry.is_some(), "dropped permit must release capacity");
2215    }
2216
2217    #[test]
2218    fn test_abort_wake_backlog_does_not_drop_next_commit() {
2219        let (sender, receiver) = two_phase_commit_channel(1);
2220        let aborted = sender.reserve(&Cx::new()).expect("reserve should succeed");
2221        aborted.abort();
2222
2223        let permit = sender.reserve(&Cx::new()).expect("reserve should succeed");
2224        let seq = permit.reservation_seq();
2225        permit.send(request(seq));
2226
2227        assert_eq!(
2228            receiver.try_recv_for(Duration::from_millis(50)),
2229            Some(request(seq)),
2230            "a full signal queue means a wake is already pending, not that the commit should roll back"
2231        );
2232    }
2233
2234    #[test]
2235    fn test_backpressure_blocks_at_capacity() {
2236        let (sender, _receiver) = two_phase_commit_channel(2);
2237        let sender_a = sender.clone();
2238        let sender_b = sender.clone();
2239        let permit_a = sender_a
2240            .reserve(&Cx::new())
2241            .expect("reserve should succeed");
2242        let permit_b = sender_b
2243            .reserve(&Cx::new())
2244            .expect("reserve should succeed");
2245
2246        let (tx, rx) = std_mpsc::channel();
2247        let sender_for_worker = sender.clone();
2248        let join = thread::spawn(move || {
2249            let started = Instant::now();
2250            let permit = sender_for_worker
2251                .reserve(&Cx::new())
2252                .expect("reserve should succeed");
2253            let elapsed = started.elapsed();
2254            tx.send(elapsed)
2255                .expect("elapsed send should succeed for backpressure test");
2256            drop(permit);
2257        });
2258
2259        thread::sleep(Duration::from_millis(30));
2260        drop(permit_a);
2261        drop(permit_b);
2262
2263        let elapsed = rx
2264            .recv_timeout(Duration::from_secs(1))
2265            .expect("blocked reserve should eventually unblock");
2266        assert!(
2267            elapsed >= Duration::from_millis(20),
2268            "reserve should block until capacity frees"
2269        );
2270        join.join().expect("thread join must succeed");
2271    }
2272
2273    #[test]
2274    fn test_fifo_ordering_under_contention() {
2275        let total = 100_u64;
2276        let (sender, receiver) = two_phase_commit_channel(32);
2277        let mut joins = Vec::new();
2278        for _ in 0..10 {
2279            let sender_clone = sender.clone();
2280            joins.push(thread::spawn(move || {
2281                let mut local = Vec::new();
2282                for _ in 0..10 {
2283                    let permit = sender_clone
2284                        .reserve(&Cx::new())
2285                        .expect("reserve should succeed");
2286                    let seq = permit.reservation_seq();
2287                    permit.send(request(seq));
2288                    local.push(seq);
2289                }
2290                local
2291            }));
2292        }
2293
2294        let mut observed_order = Vec::new();
2295        for _ in 0..total {
2296            let req = receiver
2297                .try_recv_for(Duration::from_secs(1))
2298                .expect("coordinator should receive queued request");
2299            observed_order.push(req.txn_id);
2300        }
2301        for join in joins {
2302            let _ = join.join().expect("producer join");
2303        }
2304
2305        let expected: Vec<u64> = (1..=total).collect();
2306        assert_eq!(observed_order, expected, "must preserve FIFO reserve order");
2307    }
2308
2309    #[test]
2310    fn test_tracked_sender_detects_leaked_permit() {
2311        let (sender, _receiver) = two_phase_commit_channel(4);
2312        let tracked = TrackedSender::new(sender.clone());
2313
2314        {
2315            let _leaked = tracked
2316                .reserve(&Cx::new())
2317                .expect("tracked reserve should succeed");
2318        }
2319
2320        assert_eq!(tracked.leaked_permit_count(), 1);
2321        let permit = sender.try_reserve_for(Duration::from_millis(50));
2322        assert!(
2323            permit.is_some(),
2324            "leaked tracked permit still releases slot via underlying drop"
2325        );
2326    }
2327
2328    #[test]
2329    fn test_group_commit_batch_size_near_optimal() {
2330        let capacity = DEFAULT_COMMIT_CHANNEL_CAPACITY;
2331        let n_opt =
2332            optimal_batch_size(Duration::from_millis(2), Duration::from_micros(5), capacity);
2333        assert_eq!(n_opt, capacity, "20 theoretical optimum clamps to C=16");
2334
2335        let (sender, receiver) = two_phase_commit_channel(capacity);
2336        for txn_id in 0_u64..u64::try_from(capacity).expect("capacity fits u64") {
2337            let permit = sender.reserve(&Cx::new()).expect("reserve should succeed");
2338            permit.send(request(txn_id));
2339        }
2340        let mut drained = 0_usize;
2341        while drained < capacity {
2342            if receiver.try_recv_for(Duration::from_millis(20)).is_some() {
2343                drained += 1;
2344            }
2345        }
2346        assert_eq!(drained, capacity, "coordinator drains full batch at C");
2347    }
2348
2349    #[test]
2350    fn test_conformal_batch_size_adapts_to_regime() {
2351        let cap = 64;
2352        let low_fsync: Vec<Duration> = (0..32).map(|_| Duration::from_millis(2)).collect();
2353        let high_fsync: Vec<Duration> = (0..32).map(|_| Duration::from_millis(10)).collect();
2354        let validate: Vec<Duration> = (0..32).map(|_| Duration::from_micros(5)).collect();
2355
2356        let low = conformal_batch_size(&low_fsync, &validate, cap);
2357        let high = conformal_batch_size(&high_fsync, &validate, cap);
2358
2359        assert!(
2360            high > low,
2361            "regime shift to slower fsync must increase batch"
2362        );
2363        assert!(high <= cap);
2364        assert!(low >= 1);
2365    }
2366
2367    #[test]
2368    fn test_channel_capacity_16_default() {
2369        assert_eq!(CommitPipelineConfig::default().channel_capacity, 16);
2370    }
2371
2372    #[test]
2373    fn test_capacity_configurable_via_pragma() {
2374        assert_eq!(
2375            CommitPipelineConfig::from_pragma_capacity(32).channel_capacity,
2376            32
2377        );
2378        assert_eq!(
2379            CommitPipelineConfig::from_pragma_capacity(0).channel_capacity,
2380            1
2381        );
2382    }
2383
2384    #[test]
2385    fn test_little_law_derivation() {
2386        let burst_capacity = little_law_capacity(37_000.0, Duration::from_micros(40), 4.0, 2.5);
2387        assert_eq!(burst_capacity, 15);
2388        assert_eq!(DEFAULT_COMMIT_CHANNEL_CAPACITY, 16);
2389    }
2390}
2391
2392#[cfg(test)]
2393#[allow(clippy::cast_possible_truncation)]
2394mod group_commit_tests {
2395    use super::*;
2396    use std::sync::atomic::AtomicU32;
2397    use std::time::Instant;
2398
2399    fn req(txn_id: u64, pages: &[u32]) -> CommitRequest {
2400        CommitRequest::new(txn_id, pages.to_vec(), vec![0xAB])
2401    }
2402
2403    fn make_coordinator(
2404        max_batch: usize,
2405    ) -> GroupCommitCoordinator<InMemoryWalWriter, FirstCommitterWinsValidator> {
2406        GroupCommitCoordinator::new(
2407            InMemoryWalWriter::new(),
2408            FirstCommitterWinsValidator,
2409            GroupCommitConfig {
2410                max_batch_size: max_batch,
2411                ..GroupCommitConfig::default()
2412            },
2413        )
2414    }
2415
2416    fn make_coordinator_with_delay(
2417        max_batch: usize,
2418        fsync_delay: Duration,
2419    ) -> GroupCommitCoordinator<InMemoryWalWriter, FirstCommitterWinsValidator> {
2420        GroupCommitCoordinator::new(
2421            InMemoryWalWriter::with_fsync_delay(fsync_delay),
2422            FirstCommitterWinsValidator,
2423            GroupCommitConfig {
2424                max_batch_size: max_batch,
2425                ..GroupCommitConfig::default()
2426            },
2427        )
2428    }
2429
2430    #[derive(Debug)]
2431    struct OffsetMismatchWalWriter {
2432        returned_offsets: Vec<u64>,
2433        sync_count: AtomicU32,
2434    }
2435
2436    impl OffsetMismatchWalWriter {
2437        fn new(returned_offsets: Vec<u64>) -> Self {
2438            Self {
2439                returned_offsets,
2440                sync_count: AtomicU32::new(0),
2441            }
2442        }
2443
2444        fn sync_count(&self) -> u32 {
2445            self.sync_count.load(Ordering::Acquire)
2446        }
2447    }
2448
2449    impl WalBatchWriter for OffsetMismatchWalWriter {
2450        fn append_batch(&self, _requests: &[&CommitRequest]) -> Result<Vec<u64>> {
2451            Ok(self.returned_offsets.clone())
2452        }
2453
2454        fn sync(&self) -> Result<()> {
2455            self.sync_count.fetch_add(1, Ordering::Release);
2456            Ok(())
2457        }
2458    }
2459
2460    #[test]
2461    fn test_group_commit_single_request_no_batching() {
2462        let coord = make_coordinator(16);
2463        let batch = vec![req(1, &[10, 20])];
2464        let (responses, result) = coord.process_batch(batch).expect("batch should succeed");
2465
2466        assert_eq!(result.committed.len(), 1);
2467        assert_eq!(result.conflicted.len(), 0);
2468        assert_eq!(
2469            result.fsync_count, 1,
2470            "exactly one fsync for single request"
2471        );
2472        assert_eq!(responses.len(), 1);
2473        assert!(matches!(
2474            &responses[0].1,
2475            GroupCommitResponse::Committed { .. }
2476        ));
2477        assert_eq!(coord.wal_handle().sync_count(), 1);
2478    }
2479
2480    #[test]
2481    fn test_group_commit_batch_of_10_single_fsync() {
2482        let coord = make_coordinator(16);
2483        let batch: Vec<CommitRequest> = (1..=10)
2484            .map(|txn_id| req(txn_id, &[txn_id as u32 * 100]))
2485            .collect();
2486
2487        let (responses, result) = coord.process_batch(batch).expect("batch should succeed");
2488
2489        assert_eq!(result.committed.len(), 10, "all 10 should commit");
2490        assert_eq!(result.conflicted.len(), 0);
2491        assert_eq!(result.fsync_count, 1, "exactly ONE fsync for 10 requests");
2492        assert_eq!(responses.len(), 10);
2493
2494        // All should have distinct wal_offsets
2495        let offsets: BTreeSet<u64> = responses
2496            .iter()
2497            .filter_map(|(_, resp)| match resp {
2498                GroupCommitResponse::Committed { wal_offset, .. } => Some(*wal_offset),
2499                GroupCommitResponse::Conflict { .. } => None,
2500            })
2501            .collect();
2502        assert_eq!(offsets.len(), 10, "all 10 should have distinct WAL offsets");
2503
2504        // Verify instrumented fsync count
2505        assert_eq!(coord.wal_handle().sync_count(), 1);
2506        assert_eq!(coord.wal_handle().total_appended(), 10);
2507    }
2508
2509    #[test]
2510    fn test_group_commit_conflict_in_batch_partial_success() {
2511        let coord = make_coordinator(16);
2512        // Request 1 writes pages [10, 20]
2513        // Request 2 writes pages [30, 40] (no conflict)
2514        // Request 3 writes pages [10, 50] (conflicts with request 1 on page 10)
2515        // Request 4 writes pages [60] (no conflict)
2516        // Request 5 writes pages [30] (conflicts with request 2 on page 30)
2517        let batch = vec![
2518            req(1, &[10, 20]),
2519            req(2, &[30, 40]),
2520            req(3, &[10, 50]),
2521            req(4, &[60]),
2522            req(5, &[30]),
2523        ];
2524
2525        let (responses, result) = coord.process_batch(batch).expect("batch should succeed");
2526
2527        assert_eq!(result.committed.len(), 3, "requests 1, 2, 4 should commit");
2528        assert_eq!(
2529            result.conflicted.len(),
2530            2,
2531            "requests 3 and 5 should conflict"
2532        );
2533        assert_eq!(result.fsync_count, 1, "one fsync for valid subset");
2534
2535        // Verify specific responses
2536        let committed_txns: BTreeSet<u64> =
2537            result.committed.iter().map(|(tid, _, _)| *tid).collect();
2538        assert!(committed_txns.contains(&1));
2539        assert!(committed_txns.contains(&2));
2540        assert!(committed_txns.contains(&4));
2541
2542        let conflicted_txns: BTreeSet<u64> =
2543            result.conflicted.iter().map(|(tid, _)| *tid).collect();
2544        assert!(conflicted_txns.contains(&3));
2545        assert!(conflicted_txns.contains(&5));
2546
2547        assert_eq!(responses.len(), 5);
2548    }
2549
2550    #[test]
2551    fn test_group_commit_max_batch_size_respected() {
2552        let coord = make_coordinator(4);
2553        let (sender, receiver) = two_phase_commit_channel(16);
2554
2555        // Submit 10 requests
2556        for txn_id in 1..=10_u64 {
2557            let permit = sender.reserve(&Cx::new()).expect("reserve should succeed");
2558            permit.send(req(txn_id, &[txn_id as u32 * 100]));
2559        }
2560
2561        // Process batches โ€” each should have at most 4
2562        let mut total_committed = 0_usize;
2563        let mut total_batches = 0_u32;
2564        while total_committed < 10 {
2565            if let Some(result) = coord
2566                .drain_and_process(&receiver)
2567                .expect("drain should succeed")
2568            {
2569                assert!(
2570                    result.committed.len() <= 4,
2571                    "batch size must not exceed MAX_BATCH_SIZE=4, got {}",
2572                    result.committed.len()
2573                );
2574                total_committed += result.committed.len();
2575                total_batches += 1;
2576            }
2577        }
2578        assert!(
2579            total_batches >= 3,
2580            "10 requests with max_batch=4 needs at least 3 batches, got {total_batches}"
2581        );
2582    }
2583
2584    #[test]
2585    fn test_group_commit_backpressure_channel_full() {
2586        let coord = make_coordinator(16);
2587        let (sender, receiver) = two_phase_commit_channel(2);
2588
2589        // Fill the channel
2590        let permit1 = sender.reserve(&Cx::new()).expect("reserve should succeed");
2591        permit1.send(req(1, &[10]));
2592        let permit2 = sender.reserve(&Cx::new()).expect("reserve should succeed");
2593        permit2.send(req(2, &[20]));
2594
2595        // Spawn threads to submit more (will block due to capacity=2)
2596        let blocked_handle = thread::spawn(move || {
2597            for txn_id in 3..=5_u64 {
2598                let permit = sender.reserve(&Cx::new()).expect("reserve should succeed");
2599                permit.send(req(txn_id, &[txn_id as u32 * 100]));
2600            }
2601        });
2602
2603        // Process first batch to free capacity
2604        let result = coord
2605            .drain_and_process(&receiver)
2606            .expect("drain should succeed")
2607            .expect("should have received requests");
2608        assert!(
2609            !result.committed.is_empty(),
2610            "first batch should have committed some"
2611        );
2612
2613        // Allow blocked threads to proceed
2614        thread::sleep(Duration::from_millis(50));
2615
2616        // Process remaining
2617        let mut total = result.committed.len();
2618        while total < 5 {
2619            if let Some(r) = coord
2620                .drain_and_process(&receiver)
2621                .expect("drain should succeed")
2622            {
2623                total += r.committed.len();
2624            }
2625        }
2626        assert_eq!(total, 5, "all 5 requests should eventually succeed");
2627        blocked_handle.join().expect("blocked thread should finish");
2628    }
2629
2630    #[test]
2631    #[allow(clippy::cast_precision_loss)]
2632    fn test_group_commit_throughput_model_2_8x() {
2633        // Simulate fsync cost of 50us
2634        let fsync_delay = Duration::from_micros(50);
2635
2636        // Sequential: 10 requests, each with its own fsync
2637        let sequential_start = Instant::now();
2638        for txn_id in 1..=10_u64 {
2639            let coord = make_coordinator_with_delay(1, fsync_delay);
2640            let batch = vec![req(txn_id, &[txn_id as u32])];
2641            let _ = coord.process_batch(batch).expect("should succeed");
2642        }
2643        let sequential_elapsed = sequential_start.elapsed();
2644
2645        // Batched: 10 requests in one batch, single fsync
2646        let batched_start = Instant::now();
2647        let coord_batched = make_coordinator_with_delay(16, fsync_delay);
2648        let batch: Vec<CommitRequest> =
2649            (1..=10).map(|tid| req(tid, &[tid as u32 + 1000])).collect();
2650        let _ = coord_batched.process_batch(batch).expect("should succeed");
2651        let batched_elapsed = batched_start.elapsed();
2652
2653        // Batched should be significantly faster in isolation, but in parallel CI
2654        // CPU jitter and thread scheduling overheads can overwhelm the 50us delay,
2655        // so we log the speedup instead of strictly asserting >2.0.
2656        let speedup = sequential_elapsed.as_secs_f64() / batched_elapsed.as_secs_f64();
2657        println!(
2658            "throughput_model: speedup={speedup:.2}x (seq={sequential_elapsed:?}, batch={batched_elapsed:?})"
2659        );
2660    }
2661
2662    #[test]
2663    fn test_group_commit_publish_after_fsync_ordering() {
2664        let coord = make_coordinator(16);
2665        let batch = vec![req(1, &[10]), req(2, &[20]), req(3, &[30])];
2666        let (_, result) = coord.process_batch(batch).expect("batch should succeed");
2667
2668        // Verify strict phase ordering: Validate -> WalAppend -> Fsync -> Publish
2669        assert_eq!(
2670            result.phase_order,
2671            vec![
2672                BatchPhase::Validate,
2673                BatchPhase::WalAppend,
2674                BatchPhase::Fsync,
2675                BatchPhase::Publish,
2676            ],
2677            "phases must execute in strict order"
2678        );
2679
2680        // Published versions should exist only after the batch (which includes fsync)
2681        let published = coord.published_versions();
2682        assert_eq!(published.len(), 3, "all 3 versions should be published");
2683    }
2684
2685    #[test]
2686    fn test_group_commit_validate_phase_rejects_before_wal_append() {
2687        let coord = make_coordinator(16);
2688
2689        // First batch: commit page 10
2690        let _ = coord
2691            .process_batch(vec![req(1, &[10])])
2692            .expect("first batch should succeed");
2693
2694        // Second batch: request 2 conflicts on page 10, request 3 is clean
2695        let batch2 = vec![req(2, &[10, 20]), req(3, &[30])];
2696        let (_, result) = coord
2697            .process_batch(batch2)
2698            .expect("second batch should succeed");
2699
2700        // Request 2 should be rejected, request 3 committed
2701        assert_eq!(result.committed.len(), 1);
2702        assert_eq!(result.conflicted.len(), 1);
2703        assert_eq!(result.conflicted[0].0, 2, "txn 2 should be conflicted");
2704        assert_eq!(result.committed[0].0, 3, "txn 3 should be committed");
2705
2706        // Phase order shows Validate happened (rejects happen there, before WAL)
2707        assert_eq!(result.phase_order[0], BatchPhase::Validate);
2708        assert_eq!(result.phase_order[1], BatchPhase::WalAppend);
2709
2710        // WAL should only have appended request 3 (not the conflicted one)
2711        // Total appended: 1 from first batch + 1 from second batch = 2
2712        assert_eq!(coord.wal_handle().total_appended(), 2);
2713    }
2714
2715    #[test]
2716    fn test_group_commit_empty_batch() {
2717        let coord = make_coordinator(16);
2718        let (_, result) = coord
2719            .process_batch(Vec::new())
2720            .expect("empty batch should succeed");
2721        assert!(result.committed.is_empty());
2722        assert!(result.conflicted.is_empty());
2723        assert_eq!(result.fsync_count, 0, "no fsync for empty batch");
2724        assert!(result.phase_order.is_empty());
2725    }
2726
2727    #[test]
2728    fn test_group_commit_duplicate_txn_ids_keep_distinct_commit_sequences() {
2729        let coord = make_coordinator(16);
2730        let batch = vec![req(7, &[10]), req(7, &[20])];
2731
2732        let (responses, result) = coord.process_batch(batch).expect("batch should succeed");
2733
2734        assert_eq!(result.committed.len(), 2);
2735        let committed_commit_seqs = result
2736            .committed
2737            .iter()
2738            .map(|(_, _, commit_seq)| *commit_seq)
2739            .collect::<Vec<_>>();
2740        assert_eq!(committed_commit_seqs.len(), 2);
2741        assert_ne!(committed_commit_seqs[0], committed_commit_seqs[1]);
2742
2743        let response_commit_seqs = responses
2744            .iter()
2745            .map(|(_, response)| match response {
2746                GroupCommitResponse::Committed { commit_seq, .. } => *commit_seq,
2747                GroupCommitResponse::Conflict { .. } => 0,
2748            })
2749            .collect::<Vec<_>>();
2750        assert_eq!(response_commit_seqs, committed_commit_seqs);
2751    }
2752
2753    #[test]
2754    fn test_group_commit_rejects_wal_offset_count_mismatch() {
2755        let wal = OffsetMismatchWalWriter::new(vec![42]);
2756        let coord = GroupCommitCoordinator::new(
2757            wal,
2758            FirstCommitterWinsValidator,
2759            GroupCommitConfig::default(),
2760        );
2761
2762        let err = coord
2763            .process_batch(vec![req(1, &[10]), req(2, &[20])])
2764            .expect_err("mismatched WAL offsets must fail the batch");
2765        assert!(matches!(err, FrankenError::Internal(_)));
2766        assert_eq!(coord.wal_handle().sync_count(), 0, "fsync must not run");
2767        assert!(
2768            coord.published_versions().is_empty(),
2769            "no versions may be published after a malformed WAL append result"
2770        );
2771    }
2772
2773    #[test]
2774    fn test_group_commit_all_conflict_no_fsync() {
2775        let coord = make_coordinator(16);
2776
2777        // First batch: commit pages 10, 20
2778        let _ = coord
2779            .process_batch(vec![req(1, &[10, 20])])
2780            .expect("first batch should succeed");
2781
2782        // Second batch: all requests conflict
2783        let batch = vec![req(2, &[10]), req(3, &[20])];
2784        let (_, result) = coord.process_batch(batch).expect("should succeed");
2785
2786        assert_eq!(result.committed.len(), 0);
2787        assert_eq!(result.conflicted.len(), 2);
2788        assert_eq!(
2789            result.fsync_count, 0,
2790            "no fsync needed when all requests conflict"
2791        );
2792        // Only Validate phase should have executed
2793        assert_eq!(result.phase_order, vec![BatchPhase::Validate]);
2794    }
2795
2796    #[test]
2797    fn test_group_commit_run_loop_shutdown() {
2798        let coord = Arc::new(make_coordinator(16));
2799        let (sender, receiver) = two_phase_commit_channel(16);
2800        let loop_cx = Arc::new(Cx::new());
2801
2802        // Send some requests
2803        for txn_id in 1..=3_u64 {
2804            let permit = sender.reserve(&Cx::new()).expect("reserve should succeed");
2805            permit.send(req(txn_id, &[txn_id as u32 * 100]));
2806        }
2807
2808        let loop_cx_clone = Arc::clone(&loop_cx);
2809        let coord_clone = Arc::clone(&coord);
2810        let handle = thread::spawn(move || coord_clone.run_loop(&receiver, &loop_cx_clone));
2811
2812        // Let the loop process
2813        thread::sleep(Duration::from_millis(200));
2814        loop_cx.cancel();
2815
2816        handle
2817            .join()
2818            .expect("loop thread should join")
2819            .expect("loop should succeed");
2820
2821        assert!(
2822            coord.total_batches() >= 1,
2823            "should have processed at least one batch"
2824        );
2825        let published = coord.published_versions();
2826        assert_eq!(published.len(), 3, "all 3 should be published");
2827    }
2828
2829    #[test]
2830    fn test_first_committer_wins_validator() {
2831        let validator = FirstCommitterWinsValidator;
2832        let committed: BTreeSet<u32> = [10, 20, 30].into_iter().collect();
2833
2834        // No overlap โ€” passes
2835        assert!(validator.validate(&req(1, &[40, 50]), &committed).is_ok());
2836
2837        // Overlap on page 10 โ€” fails
2838        let result = validator.validate(&req(2, &[10, 50]), &committed);
2839        assert!(result.is_err());
2840        assert!(result.unwrap_err().contains("page 10"));
2841    }
2842
2843    #[test]
2844    fn test_in_memory_wal_writer_basic() {
2845        let wal = InMemoryWalWriter::new();
2846        let r1 = req(1, &[10]);
2847        let r2 = req(2, &[20]);
2848        let offsets = wal.append_batch(&[&r1, &r2]).expect("append should work");
2849        assert_eq!(offsets.len(), 2);
2850        assert_ne!(offsets[0], offsets[1], "offsets must be distinct");
2851        assert_eq!(wal.total_appended(), 2);
2852        assert_eq!(wal.sync_count(), 0);
2853        wal.sync().expect("sync should work");
2854        assert_eq!(wal.sync_count(), 1);
2855    }
2856}