Skip to main content

akar_storage/
group_commit.rs

1//! Group commit — batches concurrent WAL flushes into a single fsync.
2//!
3//! # Model
4//!
5//! Leader/follower group commit sitting at the WAL durability boundary
6//! (StorageManager `commit_transaction` step 1). Each submitting thread:
7//!
8//! 1. Appends its WAL records (including the `Commit` record) under the WAL
9//!    lock — *without* an fsync.
10//! 2. Enqueues a pending flush request tagged with the thread's LSN.
11//! 3. Tries to become the *leader*; the leader drains the queue for
12//!    [`GroupCommitConfig::drain_timeout`], then performs a **single** fsync
13//!    covering every drained request and delivers the shared `durable_batch_lsn`
14//!    back to each waiter.
15//! 4. Followers wait on their own result slot; if no leader finishes them
16//!    within [`GroupCommitConfig::leader_timeout`], they retry leadership
17//!    (self-healing — e.g. a leader thread was descheduled).
18//!
19//! The durability guarantee is preserved: a submitter only returns success
20//! after an fsync that started after it enqueued. Recovery format is
21//! untouched — this coordinates *when* an fsync happens, never *what* is
22//! written.
23//!
24//! # Fsync target
25//!
26//! [`GroupCommit`] is generic over [`WalLike`]; `akar-storage` implements it
27//! for `Mutex<WAL>`, so a whole group shares the same underlying WAL file.
28
29use std::collections::VecDeque;
30use std::io::{self};
31use std::sync::atomic::{AtomicU64, Ordering};
32use std::sync::{Arc, Condvar, Mutex, MutexGuard};
33use std::time::{Duration, Instant};
34
35/// Default time a leader keeps draining the queue before fsyncing.
36pub const DEFAULT_DRAIN_TIMEOUT: Duration = Duration::from_micros(200);
37
38/// Default time a follower waits before it attempts to take over leadership.
39pub const DEFAULT_LEADER_TIMEOUT: Duration = Duration::from_millis(50);
40
41/// A durable flush target that many commits can share.
42///
43/// For the WAL this maps to `wal.flush_to_disk()`, i.e. one `fsync`.
44pub trait WalLike: Send + Sync {
45    /// Durably persist all records appended to the target so far.
46    fn flush_to_disk(&self) -> io::Result<()>;
47}
48
49impl WalLike for std::sync::Mutex<crate::wal::WAL> {
50    fn flush_to_disk(&self) -> io::Result<()> {
51        let mut wal = self
52            .lock()
53            .map_err(|e| io::Error::other(format!("WAL lock poisoned: {e}")))?;
54        wal.flush_to_disk()
55    }
56}
57
58/// Group-commit configuration.
59#[derive(Debug, Clone, Copy)]
60pub struct GroupCommitConfig {
61    /// Master switch; when `false` callers should fall back to an inline
62    /// fsync (the coordinator itself is inert about the flag).
63    pub enabled: bool,
64    /// Window a leader keeps collecting submissions before fsyncing.
65    pub drain_timeout: Duration,
66    /// How long a follower waits before attempting to take over leadership.
67    pub leader_timeout: Duration,
68}
69
70impl Default for GroupCommitConfig {
71    fn default() -> Self {
72        Self {
73            enabled: true,
74            drain_timeout: DEFAULT_DRAIN_TIMEOUT,
75            leader_timeout: DEFAULT_LEADER_TIMEOUT,
76        }
77    }
78}
79
80/// Result delivered to a submitter once its group is durable.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub struct GroupCommitResult {
83    /// This commit's LSN (monotonic across submissions, FIFO).
84    pub lsn: u64,
85    /// Highest LSN covered by the fsync that made this commit durable.
86    /// `>= lsn` because the whole group shares one fsync.
87    pub durable_batch_lsn: u64,
88    /// Number of requests coalesced into this commit's fsync group.
89    pub group_size: usize,
90}
91
92/// Simple cumulative statistics for observability.
93#[derive(Debug, Default, Clone, Copy)]
94pub struct GroupCommitStats {
95    /// Number of fsync groups completed.
96    pub batches: u64,
97    /// Number of commits that each waited for a group fsync.
98    pub commits: u64,
99    /// Total group sizes across all batches (for average sizing).
100    pub total_group_entries: u64,
101}
102
103impl GroupCommitStats {
104    /// Mean group size across completed batches (0.0 when none yet).
105    pub fn avg_group_size(&self) -> f64 {
106        if self.batches == 0 {
107            0.0
108        } else {
109            self.total_group_entries as f64 / self.batches as f64
110        }
111    }
112}
113
114/// Pending flush request waiting for a group-leader fsync.
115///
116/// `None` means not yet delivered (still pending); `Some(Ok/Err)` is the
117/// group outcome recorded by the leading commit.
118struct PendingCommit {
119    lsn: u64,
120    slot: Arc<(Mutex<Option<io::Result<GroupCommitResult>>>, Condvar)>,
121}
122
123fn new_slot() -> Arc<(Mutex<Option<io::Result<GroupCommitResult>>>, Condvar)> {
124    Arc::new((Mutex::new(None), Condvar::new()))
125}
126
127/// Leader/follower group-commit coordinator over a shared [`WalLike`] target.
128pub struct GroupCommit<W: WalLike> {
129    wal: Arc<W>,
130    config: GroupCommitConfig,
131    /// Monotonic LSN assigned at submission time (FIFO for committed groups).
132    next_lsn: AtomicU64,
133    queue: Mutex<VecDeque<PendingCommit>>,
134    /// Serializes leadership — at most one leader drains+fsyncs at a time.
135    leader_lock: Mutex<()>,
136    stats: Mutex<GroupCommitStats>,
137}
138
139impl<W: WalLike> GroupCommit<W> {
140    /// Create a coordinator over `wal` with the given config.
141    pub fn new(wal: Arc<W>, config: GroupCommitConfig) -> Self {
142        Self {
143            wal,
144            config,
145            next_lsn: AtomicU64::new(0),
146            queue: Mutex::new(VecDeque::new()),
147            leader_lock: Mutex::new(()),
148            stats: Mutex::new(GroupCommitStats::default()),
149        }
150    }
151
152    pub fn config(&self) -> &GroupCommitConfig {
153        &self.config
154    }
155
156    /// Current cumulative statistics.
157    pub fn stats(&self) -> GroupCommitStats {
158        *self.stats.lock().unwrap()
159    }
160
161    /// Wait for a durable flush of everything appended so far.
162    ///
163    /// Enqueues this submission, then either leads this group or waits for a
164    /// leader to include it. Returns once an fsync that started after the
165    /// enqueue has completed.
166    pub fn flush(&self) -> io::Result<GroupCommitResult> {
167        let lsn = self.next_lsn.fetch_add(1, Ordering::Relaxed) + 1;
168        let slot = new_slot();
169        {
170            let mut queue = self.queue.lock().unwrap();
171            queue.push_back(PendingCommit {
172                lsn,
173                slot: slot.clone(),
174            });
175        }
176
177        // Try to become leader — the guard is handed to `run_leader` and held
178        // for the whole drain, so at most one leader flushes per group.
179        if let Ok(guard) = self.leader_lock.try_lock() {
180            self.run_leader(guard);
181        }
182        let deadline = Instant::now() + self.config.leader_timeout;
183        let (lock, cvar) = &*slot;
184        let mut result = lock.lock().unwrap();
185        loop {
186            if let Some(outcome) = result.take() {
187                return match outcome {
188                    Ok(ok) => Ok(ok),
189                    Err(e) => Err(io::Error::new(e.kind(), e.to_string())),
190                };
191            }
192            if Instant::now() >= deadline {
193                // Release our slot lock before leading: `run_leader` must be
194                // able to deliver into every drained slot, including our own.
195                drop(result);
196                if let Ok(guard) = self.leader_lock.try_lock() {
197                    self.run_leader(guard);
198                }
199                result = lock.lock().unwrap();
200                continue;
201            }
202            let (guard, _to) = cvar.wait_timeout(result, Duration::from_millis(1)).unwrap();
203            result = guard;
204        }
205    }
206
207    /// Become the group leader: collect submissions for `drain_timeout`, then
208    /// perform one fsync covering the whole drained batch.
209    ///
210    /// `_guard` is the acquired leader lock, kept alive for the whole drain so
211    /// exactly one leader flushes per group.
212    fn run_leader(&self, _guard: MutexGuard<'_, ()>) {
213        let deadline = Instant::now() + self.config.drain_timeout;
214        let mut batch: Vec<PendingCommit> = Vec::new();
215
216        loop {
217            let mut drained = {
218                let mut queue = self.queue.lock().unwrap();
219                let mut v = Vec::with_capacity(queue.len());
220                while let Some(entry) = queue.pop_front() {
221                    v.push(entry);
222                }
223                drop(queue);
224                v
225            };
226            if !drained.is_empty() {
227                batch.append(&mut drained);
228            }
229            if batch.is_empty() {
230                // Tasked but nothing queued yet (caller enques before
231                // proposing leadership, so this only happens on re-entry).
232                return;
233            }
234            if Instant::now() >= deadline {
235                break;
236            }
237            std::thread::sleep(Duration::from_micros(100));
238        }
239
240        let group_size = batch.len();
241        let durable_batch_lsn = batch.iter().map(|e| e.lsn).max().unwrap_or(0);
242        let io_result = self.wal.flush_to_disk();
243
244        {
245            let mut stats = self.stats.lock().unwrap();
246            stats.batches += 1;
247            stats.commits += group_size as u64;
248            stats.total_group_entries += group_size as u64;
249        }
250
251        for entry in batch {
252            let outcome = match &io_result {
253                Ok(()) => Ok(GroupCommitResult {
254                    lsn: entry.lsn,
255                    durable_batch_lsn,
256                    group_size,
257                }),
258                Err(e) => Err(io::Error::new(e.kind(), format!("group fsync failed: {e}"))),
259            };
260            let (lock, cvar) = &*entry.slot;
261            let mut slot = lock.lock().unwrap();
262            *slot = Some(outcome);
263            drop(slot);
264            cvar.notify_all();
265        }
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use std::sync::atomic::AtomicU64;
273    use std::sync::atomic::Ordering as AtomicOrdering;
274    use std::thread;
275
276    /// Deterministic fake WAL: counts fsyncs and sleeps to widen the window so
277    /// concurrent submissions can coalesce.
278    struct MockWal {
279        fsyncs: AtomicU64,
280        delay: Duration,
281    }
282
283    impl WalLike for MockWal {
284        fn flush_to_disk(&self) -> io::Result<()> {
285            if !self.delay.is_zero() {
286                thread::sleep(self.delay);
287            }
288            self.fsyncs.fetch_add(1, AtomicOrdering::SeqCst);
289            Ok(())
290        }
291    }
292
293    fn make_gc(drain: Duration) -> (Arc<GroupCommit<MockWal>>, Arc<MockWal>) {
294        let wal = Arc::new(MockWal {
295            fsyncs: AtomicU64::new(0),
296            delay: Duration::from_millis(2),
297        });
298        let gc = Arc::new(GroupCommit::new(
299            wal.clone(),
300            GroupCommitConfig {
301                enabled: true,
302                drain_timeout: drain,
303                leader_timeout: Duration::from_millis(100),
304            },
305        ));
306        (gc, wal)
307    }
308
309    #[test]
310    fn test_single_commit_flushes_once() {
311        let (gc, wal) = make_gc(Duration::from_millis(2));
312        let result = gc.flush().unwrap();
313        assert_eq!(result.lsn, 1);
314        assert_eq!(result.durable_batch_lsn, 1);
315        assert_eq!(result.group_size, 1);
316        assert_eq!(wal.fsyncs.load(AtomicOrdering::SeqCst), 1);
317        let stats = gc.stats();
318        assert_eq!(stats.batches, 1);
319        assert_eq!(stats.commits, 1);
320    }
321
322    #[test]
323    fn test_concurrent_commits_coalesce_into_one_fsync() {
324        let (gc, wal) = make_gc(Duration::from_millis(10));
325        let threads: Vec<_> = (0..12)
326            .map(|_| {
327                let gc = gc.clone();
328                thread::spawn(move || gc.flush().unwrap())
329            })
330            .collect();
331        let results: Vec<GroupCommitResult> = threads.into_iter().map(|t| t.join().unwrap()).collect();
332
333        assert_eq!(results.len(), 12);
334        // LSNs are strictly monotonic across submissions.
335        let mut sorted: Vec<u64> = results.iter().map(|r| r.lsn).collect();
336        sorted.sort_unstable();
337        assert_eq!(sorted, (1..=12).collect::<Vec<u64>>());
338        // Every commit's durable watermark is at least its own LSN.
339        for r in &results {
340            assert!(r.durable_batch_lsn >= r.lsn);
341        }
342        // At least one group covered more than one commit.
343        assert!(results.iter().any(|r| r.group_size > 1), "expected coalescing");
344        // Far fewer fsyncs than commits — exactly one per group.
345        let fsyncs = wal.fsyncs.load(AtomicOrdering::SeqCst);
346        assert!(fsyncs < 12, "group commit should batch fsyncs, got {fsyncs}");
347        assert_eq!(fsyncs, gc.stats().batches);
348        assert_eq!(gc.stats().commits, 12);
349        assert!(gc.stats().avg_group_size() > 1.0);
350    }
351
352    #[test]
353    fn test_concurrent_commits_no_coalescing_guarantee_needed() {
354        // Even when coalescing doesn't happen, every commit must still return
355        // success with correct per-LSN results.
356        let (gc, wal) = make_gc(Duration::from_micros(0));
357        let threads: Vec<_> = (0..8)
358            .map(|_| {
359                let gc = gc.clone();
360                thread::spawn(move || gc.flush().unwrap())
361            })
362            .collect();
363        let results: Vec<GroupCommitResult> = threads.into_iter().map(|t| t.join().unwrap()).collect();
364        assert_eq!(results.len(), 8);
365        let mut sorted: Vec<u64> = results.iter().map(|r| r.lsn).collect();
366        sorted.sort_unstable();
367        assert_eq!(sorted, (1..=8).collect::<Vec<u64>>());
368        assert!(wal.fsyncs.load(AtomicOrdering::SeqCst) <= 8);
369    }
370
371    #[test]
372    fn test_stale_follower_self_heals() {
373        // A follower whose group was already flushed (its entry drained by a
374        // leader before it reached the queue) grabs leadership on timeout and
375        // fsyncs its own bytes — no hangs, no lost durability.
376        let (gc, wal) = make_gc(Duration::from_millis(1));
377        let garbage: Arc<(Mutex<Option<io::Result<GroupCommitResult>>>, Condvar)> = new_slot();
378        // Push an orphaned entry nobody will complete.
379        {
380            let mut queue = gc.queue.lock().unwrap();
381            queue.push_back(PendingCommit {
382                lsn: 999,
383                slot: garbage,
384            });
385        }
386        // A real flush must complete despite the orphaned entry (it leads and
387        // drains the orphan too).
388        let result = gc.flush().unwrap();
389        assert!(result.lsn >= 1);
390        assert!(wal.fsyncs.load(AtomicOrdering::SeqCst) >= 1);
391    }
392
393    #[test]
394    fn test_fsync_error_propagates() {
395        struct FailingWal;
396        impl WalLike for FailingWal {
397            fn flush_to_disk(&self) -> io::Result<()> {
398                Err(io::Error::other("disk on fire"))
399            }
400        }
401        let gc = Arc::new(GroupCommit::new(Arc::new(FailingWal), GroupCommitConfig::default()));
402        let err = gc.flush().unwrap_err();
403        assert!(err.to_string().contains("disk on fire"));
404    }
405}