1use 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
22pub const DEFAULT_COMMIT_CHANNEL_CAPACITY: usize = 16;
24
25#[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#[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 #[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#[derive(Debug, Clone)]
316pub struct TwoPhaseCommitSender {
317 shared: Arc<TwoPhaseQueueShared>,
318}
319
320impl TwoPhaseCommitSender {
321 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 #[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 #[must_use]
344 pub fn occupancy(&self) -> usize {
345 self.shared.occupancy()
346 }
347
348 #[must_use]
350 pub fn capacity(&self) -> usize {
351 self.shared.capacity()
352 }
353}
354
355#[derive(Debug, Clone)]
357pub struct TwoPhaseCommitReceiver {
358 shared: Arc<TwoPhaseQueueShared>,
359}
360
361impl TwoPhaseCommitReceiver {
362 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 #[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#[derive(Debug)]
410pub struct SendPermit {
411 shared: Arc<TwoPhaseQueueShared>,
412 logical_permit: Option<LogicalCapacityPermit>,
413 reservation_seq: Option<u64>,
414}
415
416impl SendPermit {
417 #[must_use]
419 pub fn reservation_seq(&self) -> u64 {
420 self.reservation_seq.unwrap_or(0)
421 }
422
423 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 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#[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#[derive(Debug)]
499pub struct TrackedSendPermit {
500 leaked_permits: Arc<AtomicU64>,
501 permit: Option<SendPermit>,
502}
503
504impl TrackedSendPermit {
505 pub fn send(mut self, request: CommitRequest) {
507 if let Some(permit) = self.permit.take() {
508 permit.send(request);
509 }
510 }
511
512 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#[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#[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#[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#[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#[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#[derive(Debug, Clone, Copy)]
619pub struct CommitRepairEvent {
620 pub commit_seq: u64,
621 pub seq: u64,
623 pub recorded_at: Instant,
625 pub kind: CommitRepairEventKind,
626}
627
628#[derive(Debug, Clone, Copy, PartialEq, Eq)]
630pub enum RepairState {
631 NotScheduled,
632 Pending,
633 Completed,
634 Failed,
635}
636
637#[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#[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
660pub 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
668pub 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#[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#[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
812pub 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 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 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 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
1196const GROUP_COMMIT_BEAD_ID: &str = "bd-l4gl";
1201const GROUP_COMMIT_IDLE_POLL_INTERVAL: Duration = Duration::from_millis(50);
1202
1203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1206pub enum BatchPhase {
1207 Validate,
1209 WalAppend,
1211 Fsync,
1213 Publish,
1215}
1216
1217#[derive(Debug, Clone, PartialEq, Eq)]
1219pub enum GroupCommitResponse {
1220 Committed { wal_offset: u64, commit_seq: u64 },
1222 Conflict { reason: String },
1224}
1225
1226#[derive(Debug)]
1228pub struct BatchResult {
1229 pub committed: Vec<(u64, u64, u64)>,
1231 pub conflicted: Vec<(u64, String)>,
1233 pub fsync_count: u32,
1235 pub phase_order: Vec<BatchPhase>,
1237}
1238
1239pub trait WalBatchWriter: Send + Sync {
1244 fn append_batch(&self, requests: &[&CommitRequest]) -> Result<Vec<u64>>;
1247
1248 fn sync(&self) -> Result<()>;
1250}
1251
1252pub trait WriteSetValidator: Send + Sync {
1257 fn validate(
1260 &self,
1261 request: &CommitRequest,
1262 committed_pages: &BTreeSet<u32>,
1263 ) -> std::result::Result<(), String>;
1264}
1265
1266#[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#[derive(Debug)]
1291pub struct InMemoryWalWriter {
1292 next_offset: AtomicU64,
1293 sync_count: AtomicU64,
1294 total_appended: AtomicU64,
1295 fsync_delay: Duration,
1297}
1298
1299impl InMemoryWalWriter {
1300 #[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 #[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 #[must_use]
1324 pub fn sync_count(&self) -> u64 {
1325 self.sync_count.load(Ordering::Acquire)
1326 }
1327
1328 #[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#[derive(Debug, Clone, Copy)]
1575pub struct GroupCommitConfig {
1576 pub max_batch_size: usize,
1578 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#[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
1599pub 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 #[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 #[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_order.push(BatchPhase::Validate);
1675 let mut merged_committed =
1676 lock_with_recovery(&self.committed_pages, "committed_pages").clone();
1677 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_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_order.push(BatchPhase::Fsync);
1731 self.wal.sync()?;
1732 let fsync_count = 1;
1733
1734 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 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 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 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 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 #[must_use]
1874 pub fn total_batches(&self) -> u64 {
1875 self.total_batches.load(Ordering::Acquire)
1876 }
1877
1878 #[must_use]
1880 pub fn published_versions(&self) -> Vec<PublishedVersion> {
1881 lock_with_recovery(&self.published, "published_versions").clone()
1882 }
1883
1884 #[must_use]
1886 pub fn batch_history(&self) -> Vec<BatchResult> {
1887 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 #[must_use]
1901 pub fn wal_handle(&self) -> Arc<W> {
1902 Arc::clone(&self.wal)
1903 }
1904
1905 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 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 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 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 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 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 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 let permit1 = sender.reserve();
2361 permit1.send(req(1, &[10]));
2362 let permit2 = sender.reserve();
2363 permit2.send(req(2, &[20]));
2364
2365 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 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 thread::sleep(Duration::from_millis(50));
2385
2386 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 let fsync_delay = Duration::from_micros(50);
2405
2406 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 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 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 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 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 let _ = coord
2461 .process_batch(vec![req(1, &[10])])
2462 .expect("first batch should succeed");
2463
2464 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 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 assert_eq!(result.phase_order[0], BatchPhase::Validate);
2478 assert_eq!(result.phase_order[1], BatchPhase::WalAppend);
2479
2480 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 let _ = coord
2549 .process_batch(vec![req(1, &[10, 20])])
2550 .expect("first batch should succeed");
2551
2552 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 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 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 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 assert!(validator.validate(&req(1, &[40, 50]), &committed).is_ok());
2606
2607 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}