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