Skip to main content

oxirs_tdb/transaction/
group_commit.rs

1//! Group commit optimization for WAL
2//!
3//! This module implements group commit, a technique that batches multiple
4//! transaction commits together to reduce the number of fsync() operations.
5//!
6//! Benefits:
7//! - Reduces disk I/O overhead by batching fsync calls
8//! - Improves throughput for write-heavy workloads
9//! - Maintains durability guarantees (all commits are written before ack)
10
11use super::wal::{Lsn, TxnId, WriteAheadLog};
12use crate::error::Result;
13use std::sync::{Arc, Condvar, Mutex};
14use std::time::{Duration, Instant};
15
16/// Configuration for group commit
17#[derive(Debug, Clone)]
18pub struct GroupCommitConfig {
19    /// Maximum number of commits to batch together
20    pub max_batch_size: usize,
21    /// Maximum time to wait before flushing (even if batch not full)
22    pub max_wait_time: Duration,
23    /// Whether group commit is enabled
24    pub enabled: bool,
25}
26
27impl Default for GroupCommitConfig {
28    fn default() -> Self {
29        Self {
30            max_batch_size: 100,
31            max_wait_time: Duration::from_millis(10),
32            enabled: true,
33        }
34    }
35}
36
37/// Pending commit request
38#[derive(Debug)]
39struct PendingCommit {
40    /// Transaction ID
41    txn_id: TxnId,
42    /// LSN of commit record
43    commit_lsn: Lsn,
44    /// Time when commit was requested
45    requested_at: Instant,
46}
47
48/// Group commit coordinator
49///
50/// Batches multiple transaction commits together to reduce fsync overhead.
51/// When a transaction wants to commit:
52/// 1. It adds itself to the pending queue
53/// 2. It waits on the condition variable
54/// 3. When batch is full or timeout expires, a flush occurs
55/// 4. All transactions in the batch are notified
56pub struct GroupCommitCoordinator {
57    /// Configuration
58    config: GroupCommitConfig,
59    /// Write-ahead log
60    wal: Arc<WriteAheadLog>,
61    /// Pending commits
62    pending: Arc<Mutex<PendingCommitQueue>>,
63    /// Condition variable for commit notifications
64    commit_cv: Arc<Condvar>,
65    /// Statistics
66    stats: Arc<Mutex<GroupCommitStats>>,
67}
68
69/// Queue of pending commits
70struct PendingCommitQueue {
71    /// List of pending commits
72    commits: Vec<PendingCommit>,
73    /// Last flush time
74    last_flush: Instant,
75    /// Last flushed LSN
76    last_flushed_lsn: Lsn,
77}
78
79impl PendingCommitQueue {
80    fn new() -> Self {
81        Self {
82            commits: Vec::new(),
83            last_flush: Instant::now(),
84            last_flushed_lsn: Lsn::ZERO,
85        }
86    }
87
88    fn is_empty(&self) -> bool {
89        self.commits.is_empty()
90    }
91
92    fn len(&self) -> usize {
93        self.commits.len()
94    }
95
96    fn should_flush(&self, config: &GroupCommitConfig) -> bool {
97        if self.commits.is_empty() {
98            return false;
99        }
100
101        // Flush if batch is full
102        if self.commits.len() >= config.max_batch_size {
103            return true;
104        }
105
106        // Flush if oldest commit has waited too long
107        if let Some(oldest) = self.commits.first() {
108            if oldest.requested_at.elapsed() >= config.max_wait_time {
109                return true;
110            }
111        }
112
113        false
114    }
115
116    fn drain_batch(&mut self) -> Vec<PendingCommit> {
117        std::mem::take(&mut self.commits)
118    }
119}
120
121/// Group commit statistics
122#[derive(Debug, Default)]
123pub struct GroupCommitStats {
124    /// Total number of commits processed
125    pub total_commits: u64,
126    /// Total number of flush operations
127    pub total_flushes: u64,
128    /// Average batch size
129    pub avg_batch_size: f64,
130    /// Total wait time across all commits (microseconds)
131    pub total_wait_time_us: u64,
132    /// Maximum wait time seen (microseconds)
133    pub max_wait_time_us: u64,
134}
135
136impl GroupCommitCoordinator {
137    /// Create a new group commit coordinator
138    pub fn new(wal: Arc<WriteAheadLog>, config: GroupCommitConfig) -> Self {
139        Self {
140            config,
141            wal,
142            pending: Arc::new(Mutex::new(PendingCommitQueue::new())),
143            commit_cv: Arc::new(Condvar::new()),
144            stats: Arc::new(Mutex::new(GroupCommitStats::default())),
145        }
146    }
147
148    /// Request a commit (blocking until commit is durable)
149    ///
150    /// This method adds the transaction to the pending queue and waits
151    /// until the WAL is flushed. Multiple transactions may be flushed
152    /// together in a single batch.
153    pub fn commit(&self, txn_id: TxnId, commit_lsn: Lsn) -> Result<()> {
154        let requested_at = Instant::now();
155
156        if !self.config.enabled {
157            // Group commit disabled - flush immediately
158            self.wal.flush()?;
159
160            // Still update stats
161            let wait_time_us = requested_at.elapsed().as_micros() as u64;
162            let mut stats = self.stats.lock().expect("lock poisoned");
163            stats.total_commits += 1;
164            stats.total_flushes += 1; // Each commit flushes immediately when disabled
165            stats.total_wait_time_us += wait_time_us;
166            stats.max_wait_time_us = stats.max_wait_time_us.max(wait_time_us);
167
168            return Ok(());
169        }
170
171        // Add to pending queue
172        {
173            let mut pending = self.pending.lock().expect("lock poisoned");
174            pending.commits.push(PendingCommit {
175                txn_id,
176                commit_lsn,
177                requested_at,
178            });
179        }
180
181        // Try to flush if conditions are met
182        self.try_flush()?;
183
184        // Wait for flush (with timeout to prevent deadlock)
185        let timeout = self.config.max_wait_time * 2; // Safety margin
186        self.wait_for_flush(commit_lsn, timeout)?;
187
188        // Record wait time
189        let wait_time_us = requested_at.elapsed().as_micros() as u64;
190        let mut stats = self.stats.lock().expect("lock poisoned");
191        stats.total_commits += 1;
192        stats.total_wait_time_us += wait_time_us;
193        stats.max_wait_time_us = stats.max_wait_time_us.max(wait_time_us);
194
195        Ok(())
196    }
197
198    /// Try to flush pending commits if conditions are met
199    fn try_flush(&self) -> Result<()> {
200        let mut pending = self.pending.lock().expect("lock poisoned");
201
202        if !pending.should_flush(&self.config) {
203            return Ok(());
204        }
205
206        // Take the batch
207        let batch = pending.drain_batch();
208        let batch_size = batch.len();
209
210        // Record flush time
211        pending.last_flush = Instant::now();
212
213        // Find highest LSN in batch
214        let max_lsn = batch
215            .iter()
216            .map(|c| c.commit_lsn)
217            .max()
218            .unwrap_or(Lsn::ZERO);
219
220        // Release lock before flushing (don't hold lock during I/O)
221        drop(pending);
222
223        // Flush WAL to disk
224        self.wal.flush()?;
225
226        // Update flushed LSN and notify waiters
227        {
228            let mut pending = self.pending.lock().expect("lock poisoned");
229            pending.last_flushed_lsn = max_lsn;
230        }
231
232        // Notify all waiting transactions
233        self.commit_cv.notify_all();
234
235        // Update statistics
236        {
237            let mut stats = self.stats.lock().expect("lock poisoned");
238            stats.total_flushes += 1;
239            let total_commits = stats.total_commits as f64;
240            stats.avg_batch_size = (stats.avg_batch_size * (total_commits - batch_size as f64)
241                + batch_size as f64)
242                / total_commits.max(1.0);
243        }
244
245        Ok(())
246    }
247
248    /// Wait for a specific LSN to be flushed
249    fn wait_for_flush(&self, target_lsn: Lsn, timeout: Duration) -> Result<()> {
250        let deadline = Instant::now() + timeout;
251
252        let mut pending = self.pending.lock().expect("lock poisoned");
253
254        loop {
255            // Check if already flushed
256            if pending.last_flushed_lsn >= target_lsn {
257                return Ok(());
258            }
259
260            // Wait with timeout
261            let now = Instant::now();
262            if now >= deadline {
263                // Timeout - force flush
264                drop(pending);
265                self.force_flush()?;
266                return Ok(());
267            }
268
269            let remaining = deadline.duration_since(now);
270            let (guard, timeout_result) = self
271                .commit_cv
272                .wait_timeout(pending, remaining)
273                .expect("lock poisoned");
274            pending = guard;
275
276            if timeout_result.timed_out() {
277                // Timeout - force flush
278                drop(pending);
279                self.force_flush()?;
280                return Ok(());
281            }
282        }
283    }
284
285    /// Force an immediate flush of pending commits
286    pub fn force_flush(&self) -> Result<()> {
287        let mut pending = self.pending.lock().expect("lock poisoned");
288
289        if pending.is_empty() {
290            return Ok(());
291        }
292
293        let batch = pending.drain_batch();
294        let batch_size = batch.len();
295
296        let max_lsn = batch
297            .iter()
298            .map(|c| c.commit_lsn)
299            .max()
300            .unwrap_or(Lsn::ZERO);
301
302        pending.last_flush = Instant::now();
303
304        drop(pending);
305
306        // Flush WAL
307        self.wal.flush()?;
308
309        // Update flushed LSN
310        {
311            let mut pending = self.pending.lock().expect("lock poisoned");
312            pending.last_flushed_lsn = max_lsn;
313        }
314
315        // Notify waiters
316        self.commit_cv.notify_all();
317
318        // Update stats
319        {
320            let mut stats = self.stats.lock().expect("lock poisoned");
321            stats.total_flushes += 1;
322            let total_commits = stats.total_commits as f64;
323            stats.avg_batch_size = (stats.avg_batch_size * (total_commits - batch_size as f64)
324                + batch_size as f64)
325                / total_commits.max(1.0);
326        }
327
328        Ok(())
329    }
330
331    /// Get current statistics
332    pub fn stats(&self) -> GroupCommitStats {
333        let stats = self.stats.lock().expect("lock poisoned");
334        GroupCommitStats {
335            total_commits: stats.total_commits,
336            total_flushes: stats.total_flushes,
337            avg_batch_size: stats.avg_batch_size,
338            total_wait_time_us: stats.total_wait_time_us,
339            max_wait_time_us: stats.max_wait_time_us,
340        }
341    }
342
343    /// Get average commits per flush
344    pub fn avg_commits_per_flush(&self) -> f64 {
345        let stats = self.stats.lock().expect("lock poisoned");
346        if stats.total_flushes == 0 {
347            0.0
348        } else {
349            stats.total_commits as f64 / stats.total_flushes as f64
350        }
351    }
352
353    /// Get number of pending commits
354    pub fn pending_count(&self) -> usize {
355        self.pending.lock().expect("lock poisoned").len()
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use std::env;
363    use std::thread;
364
365    #[test]
366    fn test_group_commit_config() {
367        let config = GroupCommitConfig::default();
368        assert_eq!(config.max_batch_size, 100);
369        assert_eq!(config.max_wait_time, Duration::from_millis(10));
370        assert!(config.enabled);
371    }
372
373    #[test]
374    fn test_single_commit() {
375        let temp_dir = env::temp_dir().join("oxirs_group_commit_single");
376        std::fs::create_dir_all(&temp_dir).unwrap();
377
378        let wal = Arc::new(WriteAheadLog::new(&temp_dir).unwrap());
379        let coordinator = GroupCommitCoordinator::new(wal.clone(), GroupCommitConfig::default());
380
381        let lsn = wal
382            .append(super::super::wal::LogRecord::Commit {
383                txn_id: TxnId::new(1),
384            })
385            .unwrap();
386
387        coordinator.commit(TxnId::new(1), lsn).unwrap();
388
389        // Explicitly force flush to ensure stats are updated
390        coordinator.force_flush().unwrap();
391
392        let stats = coordinator.stats();
393        assert_eq!(stats.total_commits, 1);
394        assert!(stats.total_flushes >= 1);
395
396        std::fs::remove_dir_all(&temp_dir).ok();
397    }
398
399    #[test]
400    fn test_batch_commit() {
401        let temp_dir = env::temp_dir().join("oxirs_group_commit_batch");
402        std::fs::create_dir_all(&temp_dir).unwrap();
403
404        let wal = Arc::new(WriteAheadLog::new(&temp_dir).unwrap());
405        let config = GroupCommitConfig {
406            max_batch_size: 5,
407            max_wait_time: Duration::from_millis(50), // Reduced from 100ms
408            enabled: true,
409        };
410        let coordinator = Arc::new(GroupCommitCoordinator::new(wal.clone(), config));
411
412        // Spawn multiple threads to commit concurrently (reduced for faster tests)
413        let mut handles = vec![];
414        for i in 0..5 {
415            let coordinator = Arc::clone(&coordinator);
416            let wal = Arc::clone(&wal);
417            let handle = thread::spawn(move || {
418                let lsn = wal
419                    .append(super::super::wal::LogRecord::Commit {
420                        txn_id: TxnId::new(i),
421                    })
422                    .unwrap();
423                coordinator.commit(TxnId::new(i), lsn).unwrap();
424            });
425            handles.push(handle);
426        }
427
428        for handle in handles {
429            handle.join().unwrap();
430        }
431
432        let stats = coordinator.stats();
433        assert_eq!(stats.total_commits, 5);
434        // Should have fewer flushes than commits due to batching
435        assert!(stats.total_flushes <= 5);
436        assert!(stats.avg_batch_size >= 1.0);
437
438        std::fs::remove_dir_all(&temp_dir).ok();
439    }
440
441    #[test]
442    fn test_force_flush() {
443        let temp_dir = env::temp_dir().join("oxirs_group_commit_force");
444        std::fs::create_dir_all(&temp_dir).unwrap();
445
446        let wal = Arc::new(WriteAheadLog::new(&temp_dir).unwrap());
447        let config = GroupCommitConfig {
448            max_batch_size: 100,
449            max_wait_time: Duration::from_millis(50), // Short timeout for tests
450            enabled: false,                           // Disable to manually control flushing
451        };
452        let coordinator = Arc::new(GroupCommitCoordinator::new(wal.clone(), config));
453
454        // Do sequential commits instead of concurrent to avoid potential deadlocks
455        for i in 0..3 {
456            let lsn = wal
457                .append(super::super::wal::LogRecord::Commit {
458                    txn_id: TxnId::new(i),
459                })
460                .unwrap();
461            // With group commit disabled, each commit flushes immediately
462            coordinator.commit(TxnId::new(i), lsn).unwrap();
463        }
464
465        let stats = coordinator.stats();
466        // With group commit disabled, each commit flushes immediately
467        assert_eq!(stats.total_commits, 3);
468        assert!(stats.total_flushes >= 1);
469
470        std::fs::remove_dir_all(&temp_dir).ok();
471    }
472
473    #[test]
474    fn test_disabled_group_commit() {
475        let temp_dir = env::temp_dir().join("oxirs_group_commit_disabled");
476        std::fs::create_dir_all(&temp_dir).unwrap();
477
478        let wal = Arc::new(WriteAheadLog::new(&temp_dir).unwrap());
479        let config = GroupCommitConfig {
480            enabled: false,
481            ..Default::default()
482        };
483        let coordinator = GroupCommitCoordinator::new(wal.clone(), config);
484
485        for i in 0..5 {
486            let lsn = wal
487                .append(super::super::wal::LogRecord::Commit {
488                    txn_id: TxnId::new(i),
489                })
490                .unwrap();
491            coordinator.commit(TxnId::new(i), lsn).unwrap();
492        }
493
494        let stats = coordinator.stats();
495        assert_eq!(stats.total_commits, 5);
496        // With group commit disabled, each commit should flush immediately
497        assert_eq!(stats.avg_batch_size, 0.0);
498
499        std::fs::remove_dir_all(&temp_dir).ok();
500    }
501
502    #[test]
503    #[ignore] // Timing-dependent test, not suitable for CI
504    fn test_timeout_flush() {
505        let temp_dir = env::temp_dir().join("oxirs_group_commit_timeout");
506        std::fs::create_dir_all(&temp_dir).unwrap();
507
508        let wal = Arc::new(WriteAheadLog::new(&temp_dir).unwrap());
509        let config = GroupCommitConfig {
510            max_batch_size: 100,
511            max_wait_time: Duration::from_millis(50), // Short timeout
512            enabled: true,
513        };
514        let coordinator = Arc::new(GroupCommitCoordinator::new(wal.clone(), config));
515
516        // Single commit should timeout and flush
517        let lsn = wal
518            .append(super::super::wal::LogRecord::Commit {
519                txn_id: TxnId::new(1),
520            })
521            .unwrap();
522
523        coordinator.commit(TxnId::new(1), lsn).unwrap();
524
525        let stats = coordinator.stats();
526        assert_eq!(stats.total_commits, 1);
527        assert!(stats.max_wait_time_us > 0);
528
529        std::fs::remove_dir_all(&temp_dir).ok();
530    }
531
532    #[test]
533    fn test_concurrent_commits() {
534        let temp_dir = env::temp_dir().join("oxirs_group_commit_concurrent");
535        std::fs::create_dir_all(&temp_dir).unwrap();
536
537        let wal = Arc::new(WriteAheadLog::new(&temp_dir).unwrap());
538        let config = GroupCommitConfig {
539            max_batch_size: 10,
540            max_wait_time: Duration::from_millis(50), // Reduced from 100ms
541            enabled: true,
542        };
543        let coordinator = Arc::new(GroupCommitCoordinator::new(wal.clone(), config));
544
545        // Concurrent commits (reduced to 5 for faster tests)
546        let mut handles = vec![];
547        for i in 0..5 {
548            let coordinator = Arc::clone(&coordinator);
549            let wal = Arc::clone(&wal);
550            let handle = thread::spawn(move || {
551                let lsn = wal
552                    .append(super::super::wal::LogRecord::Commit {
553                        txn_id: TxnId::new(i),
554                    })
555                    .unwrap();
556                coordinator.commit(TxnId::new(i), lsn).unwrap();
557            });
558            handles.push(handle);
559        }
560
561        for handle in handles {
562            handle.join().unwrap();
563        }
564
565        let stats = coordinator.stats();
566        assert_eq!(stats.total_commits, 5);
567        // With batch size of 10 and 5 commits, batching may or may not occur
568        assert!(stats.total_flushes <= 5); // Should not exceed total commits
569
570        std::fs::remove_dir_all(&temp_dir).ok();
571    }
572}