use crate::error::{Result, TdbError};
use crate::transaction::wal::{LogEntry, LogRecord, Lsn};
use oxicode::Decode;
use parking_lot::{Mutex, RwLock};
use std::collections::VecDeque;
use std::fs::{File, OpenOptions};
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
#[derive(Debug, Clone)]
pub struct WalOptimizerConfig {
pub max_batch_size: usize,
pub max_batch_delay_ms: u64,
pub write_buffer_size: usize,
pub enable_compression: bool,
pub compression_threshold: usize,
pub group_commit_window_us: u64,
pub background_flush_interval_ms: u64,
pub enable_background_flush: bool,
}
impl Default for WalOptimizerConfig {
fn default() -> Self {
Self {
max_batch_size: 100,
max_batch_delay_ms: 10,
write_buffer_size: 256 * 1024, enable_compression: true,
compression_threshold: 1024, group_commit_window_us: 100, background_flush_interval_ms: 50,
enable_background_flush: true,
}
}
}
#[derive(Debug, Clone)]
struct CompressedEntry {
lsn: Lsn,
compressed: bool,
data: Vec<u8>,
}
#[derive(Debug)]
struct WriteBatch {
entries: Vec<CompressedEntry>,
created_at: Instant,
size_bytes: usize,
}
impl WriteBatch {
fn new() -> Self {
Self {
entries: Vec::new(),
created_at: Instant::now(),
size_bytes: 0,
}
}
fn add(&mut self, entry: CompressedEntry) {
self.size_bytes += entry.data.len();
self.entries.push(entry);
}
fn is_full(&self, config: &WalOptimizerConfig) -> bool {
self.entries.len() >= config.max_batch_size
|| self.created_at.elapsed().as_millis() >= config.max_batch_delay_ms as u128
}
fn is_empty(&self) -> bool {
self.entries.is_empty()
}
fn clear(&mut self) {
self.entries.clear();
self.size_bytes = 0;
self.created_at = Instant::now();
}
}
#[derive(Debug, Default)]
pub struct WalOptimizerStats {
pub entries_written: AtomicU64,
pub bytes_written: AtomicU64,
pub compressed_bytes: AtomicU64,
pub batches_written: AtomicU64,
pub group_commits: AtomicU64,
pub background_flushes: AtomicU64,
pub total_flush_time_us: AtomicU64,
pub compressed_entries: AtomicU64,
}
impl WalOptimizerStats {
pub fn compression_ratio(&self) -> f64 {
let written = self.bytes_written.load(Ordering::Relaxed);
let compressed = self.compressed_bytes.load(Ordering::Relaxed);
if written == 0 {
1.0
} else {
written as f64 / compressed as f64
}
}
pub fn avg_batch_size(&self) -> f64 {
let entries = self.entries_written.load(Ordering::Relaxed);
let batches = self.batches_written.load(Ordering::Relaxed);
if batches == 0 {
0.0
} else {
entries as f64 / batches as f64
}
}
pub fn avg_flush_time_us(&self) -> f64 {
let total_time = self.total_flush_time_us.load(Ordering::Relaxed);
let flushes = self.background_flushes.load(Ordering::Relaxed)
+ self.group_commits.load(Ordering::Relaxed);
if flushes == 0 {
0.0
} else {
total_time as f64 / flushes as f64
}
}
}
pub struct WalOptimizer {
config: WalOptimizerConfig,
wal_path: PathBuf,
writer: Mutex<BufWriter<File>>,
current_batch: Mutex<WriteBatch>,
pending_commits: Mutex<VecDeque<Arc<AtomicBool>>>,
stats: Arc<WalOptimizerStats>,
shutdown: Arc<AtomicBool>,
background_thread: Option<thread::JoinHandle<()>>,
}
impl WalOptimizer {
pub fn new<P: AsRef<Path>>(wal_dir: P, config: WalOptimizerConfig) -> Result<Self> {
let wal_path = wal_dir.as_ref().join("wal_optimized.log");
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&wal_path)
.map_err(TdbError::Io)?;
let writer = BufWriter::with_capacity(config.write_buffer_size, file);
let stats = Arc::new(WalOptimizerStats::default());
let shutdown = Arc::new(AtomicBool::new(false));
let background_thread = if config.enable_background_flush {
let stats_clone = Arc::clone(&stats);
let shutdown_clone = Arc::clone(&shutdown);
let flush_interval = Duration::from_millis(config.background_flush_interval_ms);
let wal_path_clone = wal_path.clone();
Some(thread::spawn(move || {
Self::background_flush_loop(
wal_path_clone,
stats_clone,
shutdown_clone,
flush_interval,
);
}))
} else {
None
};
Ok(Self {
config,
wal_path,
writer: Mutex::new(writer),
current_batch: Mutex::new(WriteBatch::new()),
pending_commits: Mutex::new(VecDeque::new()),
stats,
shutdown,
background_thread,
})
}
pub fn append(&self, entry: LogEntry) -> Result<Lsn> {
let lsn = entry.lsn;
let serialized = oxicode::serde::encode_to_vec(&entry, oxicode::config::standard())
.map_err(|e| TdbError::Serialization(e.to_string()))?;
let original_size = serialized.len();
self.stats
.bytes_written
.fetch_add(original_size as u64, Ordering::Relaxed);
let (compressed, data) = if self.config.enable_compression
&& serialized.len() > self.config.compression_threshold
{
let data = oxiarc_lz4::compress(&serialized).unwrap_or_else(|_| serialized.clone());
self.stats
.compressed_entries
.fetch_add(1, Ordering::Relaxed);
(true, data)
} else {
(false, serialized)
};
self.stats
.compressed_bytes
.fetch_add(data.len() as u64, Ordering::Relaxed);
let compressed_entry = CompressedEntry {
lsn,
compressed,
data,
};
let mut batch = self.current_batch.lock();
batch.add(compressed_entry);
if batch.is_full(&self.config) {
self.flush_batch_locked(&mut batch)?;
}
self.stats.entries_written.fetch_add(1, Ordering::Relaxed);
Ok(lsn)
}
pub fn group_commit(&self) -> Result<()> {
let commit_flag = Arc::new(AtomicBool::new(false));
{
let mut pending = self.pending_commits.lock();
pending.push_back(Arc::clone(&commit_flag));
}
let start = Instant::now();
let window = Duration::from_micros(self.config.group_commit_window_us);
while start.elapsed() < window && !commit_flag.load(Ordering::Acquire) {
thread::yield_now();
}
if !commit_flag.load(Ordering::Acquire) {
self.force_commit()?;
}
Ok(())
}
pub fn force_commit(&self) -> Result<()> {
let flush_start = Instant::now();
{
let mut batch = self.current_batch.lock();
if !batch.is_empty() {
self.flush_batch_locked(&mut batch)?;
}
}
{
let mut writer = self.writer.lock();
writer.flush().map_err(TdbError::Io)?;
writer.get_mut().sync_all().map_err(TdbError::Io)?;
}
{
let mut pending = self.pending_commits.lock();
while let Some(flag) = pending.pop_front() {
flag.store(true, Ordering::Release);
}
}
let flush_time_us = flush_start.elapsed().as_micros() as u64;
self.stats
.total_flush_time_us
.fetch_add(flush_time_us, Ordering::Relaxed);
self.stats.group_commits.fetch_add(1, Ordering::Relaxed);
Ok(())
}
fn flush_batch_locked(&self, batch: &mut WriteBatch) -> Result<()> {
if batch.is_empty() {
return Ok(());
}
let mut writer = self.writer.lock();
for entry in &batch.entries {
let flag = if entry.compressed { 1u8 } else { 0u8 };
writer.write_all(&[flag]).map_err(TdbError::Io)?;
let len = (entry.data.len() as u32).to_le_bytes();
writer.write_all(&len).map_err(TdbError::Io)?;
writer.write_all(&entry.data).map_err(TdbError::Io)?;
}
self.stats.batches_written.fetch_add(1, Ordering::Relaxed);
batch.clear();
Ok(())
}
fn background_flush_loop(
_wal_path: PathBuf,
stats: Arc<WalOptimizerStats>,
shutdown: Arc<AtomicBool>,
flush_interval: Duration,
) {
while !shutdown.load(Ordering::Relaxed) {
thread::sleep(flush_interval);
stats.background_flushes.fetch_add(1, Ordering::Relaxed);
}
}
pub fn stats(&self) -> &WalOptimizerStats {
&self.stats
}
pub fn config(&self) -> &WalOptimizerConfig {
&self.config
}
}
impl Drop for WalOptimizer {
fn drop(&mut self) {
self.shutdown.store(true, Ordering::Relaxed);
let _ = self.force_commit();
if let Some(thread) = self.background_thread.take() {
let _ = thread.join();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::transaction::wal::{LogRecord, TxnId};
use std::env;
#[test]
fn test_wal_optimizer_creation() {
let temp_dir = env::temp_dir().join("oxirs_wal_opt_create");
std::fs::create_dir_all(&temp_dir).unwrap();
let config = WalOptimizerConfig::default();
let optimizer = WalOptimizer::new(&temp_dir, config).unwrap();
assert_eq!(optimizer.stats().entries_written.load(Ordering::Relaxed), 0);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_wal_optimizer_append() {
let temp_dir = env::temp_dir().join("oxirs_wal_opt_append");
std::fs::create_dir_all(&temp_dir).unwrap();
let config = WalOptimizerConfig {
enable_background_flush: false,
..Default::default()
};
let optimizer = WalOptimizer::new(&temp_dir, config).unwrap();
let entry = LogEntry {
lsn: Lsn::new(0),
record: LogRecord::Begin {
txn_id: TxnId::new(1),
},
};
let lsn = optimizer.append(entry).unwrap();
assert_eq!(lsn.as_u64(), 0);
assert_eq!(optimizer.stats().entries_written.load(Ordering::Relaxed), 1);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_wal_optimizer_batching() {
let temp_dir = env::temp_dir().join("oxirs_wal_opt_batch");
std::fs::create_dir_all(&temp_dir).unwrap();
let config = WalOptimizerConfig {
max_batch_size: 5,
enable_background_flush: false,
..Default::default()
};
let optimizer = WalOptimizer::new(&temp_dir, config).unwrap();
for i in 0..4 {
let entry = LogEntry {
lsn: Lsn::new(i),
record: LogRecord::Begin {
txn_id: TxnId::new(i),
},
};
optimizer.append(entry).unwrap();
}
assert_eq!(optimizer.stats().batches_written.load(Ordering::Relaxed), 0);
let entry = LogEntry {
lsn: Lsn::new(4),
record: LogRecord::Begin {
txn_id: TxnId::new(4),
},
};
optimizer.append(entry).unwrap();
assert_eq!(optimizer.stats().batches_written.load(Ordering::Relaxed), 1);
assert_eq!(optimizer.stats().entries_written.load(Ordering::Relaxed), 5);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_wal_optimizer_compression() {
let temp_dir = env::temp_dir().join("oxirs_wal_opt_compress");
std::fs::create_dir_all(&temp_dir).unwrap();
let config = WalOptimizerConfig {
enable_compression: true,
compression_threshold: 100,
enable_background_flush: false,
..Default::default()
};
let optimizer = WalOptimizer::new(&temp_dir, config).unwrap();
let large_data = vec![0u8; 2000];
let entry = LogEntry {
lsn: Lsn::new(0),
record: LogRecord::Update {
txn_id: TxnId::new(1),
page_id: 1,
before_image: large_data.clone(),
after_image: large_data,
},
};
optimizer.append(entry).unwrap();
optimizer.force_commit().unwrap();
let stats = optimizer.stats();
let bytes_written = stats.bytes_written.load(Ordering::Relaxed);
let compressed_bytes = stats.compressed_bytes.load(Ordering::Relaxed);
assert!(bytes_written > compressed_bytes);
assert_eq!(stats.compressed_entries.load(Ordering::Relaxed), 1);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_wal_optimizer_force_commit() {
let temp_dir = env::temp_dir().join("oxirs_wal_opt_force");
std::fs::create_dir_all(&temp_dir).unwrap();
let config = WalOptimizerConfig {
enable_background_flush: false,
..Default::default()
};
let optimizer = WalOptimizer::new(&temp_dir, config).unwrap();
let entry = LogEntry {
lsn: Lsn::new(0),
record: LogRecord::Begin {
txn_id: TxnId::new(1),
},
};
optimizer.append(entry).unwrap();
optimizer.force_commit().unwrap();
assert_eq!(optimizer.stats().group_commits.load(Ordering::Relaxed), 1);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_wal_optimizer_stats() {
let temp_dir = env::temp_dir().join("oxirs_wal_opt_stats");
std::fs::create_dir_all(&temp_dir).unwrap();
let config = WalOptimizerConfig {
max_batch_size: 3,
enable_background_flush: false,
..Default::default()
};
let optimizer = WalOptimizer::new(&temp_dir, config).unwrap();
for i in 0..6 {
let entry = LogEntry {
lsn: Lsn::new(i),
record: LogRecord::Begin {
txn_id: TxnId::new(i),
},
};
optimizer.append(entry).unwrap();
}
let stats = optimizer.stats();
assert_eq!(stats.entries_written.load(Ordering::Relaxed), 6);
assert_eq!(stats.batches_written.load(Ordering::Relaxed), 2);
assert_eq!(stats.avg_batch_size(), 3.0);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_wal_optimizer_group_commit() {
let temp_dir = env::temp_dir().join("oxirs_wal_opt_group");
std::fs::create_dir_all(&temp_dir).unwrap();
let config = WalOptimizerConfig {
group_commit_window_us: 1000, enable_background_flush: false,
..Default::default()
};
let optimizer = Arc::new(WalOptimizer::new(&temp_dir, config).unwrap());
let optimizer_clone = Arc::clone(&optimizer);
let handle = thread::spawn(move || {
optimizer_clone.group_commit().unwrap();
});
let entry = LogEntry {
lsn: Lsn::new(0),
record: LogRecord::Commit {
txn_id: TxnId::new(1),
},
};
optimizer.append(entry).unwrap();
handle.join().unwrap();
assert!(optimizer.stats().group_commits.load(Ordering::Relaxed) >= 1);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_compression_ratio() {
let stats = WalOptimizerStats::default();
stats.bytes_written.store(1000, Ordering::Relaxed);
stats.compressed_bytes.store(500, Ordering::Relaxed);
assert_eq!(stats.compression_ratio(), 2.0);
}
#[test]
fn test_avg_flush_time() {
let stats = WalOptimizerStats::default();
stats.total_flush_time_us.store(10000, Ordering::Relaxed);
stats.group_commits.store(5, Ordering::Relaxed);
stats.background_flushes.store(5, Ordering::Relaxed);
assert_eq!(stats.avg_flush_time_us(), 1000.0);
}
#[test]
fn test_write_batch_is_full() {
let config = WalOptimizerConfig {
max_batch_size: 3,
max_batch_delay_ms: 100,
..Default::default()
};
let mut batch = WriteBatch::new();
assert!(!batch.is_full(&config));
for i in 0..3 {
batch.add(CompressedEntry {
lsn: Lsn::new(i),
compressed: false,
data: vec![0u8; 100],
});
}
assert!(batch.is_full(&config));
}
#[test]
fn test_write_batch_clear() {
let mut batch = WriteBatch::new();
batch.add(CompressedEntry {
lsn: Lsn::new(0),
compressed: false,
data: vec![0u8; 100],
});
assert!(!batch.is_empty());
assert_eq!(batch.size_bytes, 100);
batch.clear();
assert!(batch.is_empty());
assert_eq!(batch.size_bytes, 0);
}
#[test]
fn test_config_default() {
let config = WalOptimizerConfig::default();
assert_eq!(config.max_batch_size, 100);
assert_eq!(config.max_batch_delay_ms, 10);
assert_eq!(config.write_buffer_size, 256 * 1024);
assert!(config.enable_compression);
assert_eq!(config.compression_threshold, 1024);
}
}