1use super::wal::{Lsn, TxnId, WriteAheadLog};
12use crate::error::Result;
13use std::sync::{Arc, Condvar, Mutex};
14use std::time::{Duration, Instant};
15
16#[derive(Debug, Clone)]
18pub struct GroupCommitConfig {
19 pub max_batch_size: usize,
21 pub max_wait_time: Duration,
23 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#[derive(Debug)]
39struct PendingCommit {
40 txn_id: TxnId,
42 commit_lsn: Lsn,
44 requested_at: Instant,
46}
47
48pub struct GroupCommitCoordinator {
57 config: GroupCommitConfig,
59 wal: Arc<WriteAheadLog>,
61 pending: Arc<Mutex<PendingCommitQueue>>,
63 commit_cv: Arc<Condvar>,
65 stats: Arc<Mutex<GroupCommitStats>>,
67}
68
69struct PendingCommitQueue {
71 commits: Vec<PendingCommit>,
73 last_flush: Instant,
75 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 if self.commits.len() >= config.max_batch_size {
103 return true;
104 }
105
106 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#[derive(Debug, Default)]
123pub struct GroupCommitStats {
124 pub total_commits: u64,
126 pub total_flushes: u64,
128 pub avg_batch_size: f64,
130 pub total_wait_time_us: u64,
132 pub max_wait_time_us: u64,
134}
135
136impl GroupCommitCoordinator {
137 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 pub fn commit(&self, txn_id: TxnId, commit_lsn: Lsn) -> Result<()> {
154 let requested_at = Instant::now();
155
156 if !self.config.enabled {
157 self.wal.flush()?;
159
160 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; 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 {
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 self.try_flush()?;
183
184 let timeout = self.config.max_wait_time * 2; self.wait_for_flush(commit_lsn, timeout)?;
187
188 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 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 let batch = pending.drain_batch();
208 let batch_size = batch.len();
209
210 pending.last_flush = Instant::now();
212
213 let max_lsn = batch
215 .iter()
216 .map(|c| c.commit_lsn)
217 .max()
218 .unwrap_or(Lsn::ZERO);
219
220 drop(pending);
222
223 self.wal.flush()?;
225
226 {
228 let mut pending = self.pending.lock().expect("lock poisoned");
229 pending.last_flushed_lsn = max_lsn;
230 }
231
232 self.commit_cv.notify_all();
234
235 {
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 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 if pending.last_flushed_lsn >= target_lsn {
257 return Ok(());
258 }
259
260 let now = Instant::now();
262 if now >= deadline {
263 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 drop(pending);
279 self.force_flush()?;
280 return Ok(());
281 }
282 }
283 }
284
285 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 self.wal.flush()?;
308
309 {
311 let mut pending = self.pending.lock().expect("lock poisoned");
312 pending.last_flushed_lsn = max_lsn;
313 }
314
315 self.commit_cv.notify_all();
317
318 {
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 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 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 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 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), enabled: true,
409 };
410 let coordinator = Arc::new(GroupCommitCoordinator::new(wal.clone(), config));
411
412 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 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), enabled: false, };
452 let coordinator = Arc::new(GroupCommitCoordinator::new(wal.clone(), config));
453
454 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 coordinator.commit(TxnId::new(i), lsn).unwrap();
463 }
464
465 let stats = coordinator.stats();
466 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 assert_eq!(stats.avg_batch_size, 0.0);
498
499 std::fs::remove_dir_all(&temp_dir).ok();
500 }
501
502 #[test]
503 #[ignore] 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), enabled: true,
513 };
514 let coordinator = Arc::new(GroupCommitCoordinator::new(wal.clone(), config));
515
516 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), enabled: true,
542 };
543 let coordinator = Arc::new(GroupCommitCoordinator::new(wal.clone(), config));
544
545 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 assert!(stats.total_flushes <= 5); std::fs::remove_dir_all(&temp_dir).ok();
571 }
572}