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