use crate::error::{Result, TdbError};
use parking_lot::RwLock;
use std::collections::HashMap;
use std::fs::{self, File};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, SystemTime};
#[derive(Debug, Clone)]
pub struct WalArchiveConfig {
pub archive_dir: PathBuf,
pub enable_archiving: bool,
pub compress_archives: bool,
pub retention_period: Duration,
pub max_archive_size: u64,
pub verify_archives: bool,
pub archive_prefix: String,
}
impl Default for WalArchiveConfig {
fn default() -> Self {
Self {
archive_dir: PathBuf::from("wal_archive"),
enable_archiving: false,
compress_archives: true,
retention_period: Duration::from_secs(30 * 24 * 3600), max_archive_size: 10 * 1024 * 1024 * 1024, verify_archives: true,
archive_prefix: "wal".to_string(),
}
}
}
#[derive(Debug, Clone)]
pub struct WalArchiveMetadata {
pub file_name: String,
pub original_path: PathBuf,
pub archived_at: SystemTime,
pub file_size: u64,
pub compressed: bool,
pub checksum: u32,
pub lsn_range: (u64, u64),
}
pub struct WalArchiver {
config: WalArchiveConfig,
metadata: RwLock<HashMap<String, WalArchiveMetadata>>,
total_archives: AtomicU64,
total_bytes_archived: AtomicU64,
total_deleted: AtomicU64,
active: AtomicBool,
stats: WalArchiverStats,
}
impl WalArchiver {
pub fn new(config: WalArchiveConfig) -> Result<Self> {
if config.enable_archiving {
fs::create_dir_all(&config.archive_dir).map_err(|e| {
TdbError::Other(format!("Failed to create archive directory: {}", e))
})?;
}
Ok(Self {
config,
metadata: RwLock::new(HashMap::new()),
total_archives: AtomicU64::new(0),
total_bytes_archived: AtomicU64::new(0),
total_deleted: AtomicU64::new(0),
active: AtomicBool::new(true),
stats: WalArchiverStats::default(),
})
}
pub fn archive_wal_file(
&self,
wal_file_path: &Path,
lsn_start: u64,
lsn_end: u64,
) -> Result<String> {
if !self.config.enable_archiving {
return Err(TdbError::Other("WAL archiving is disabled".to_string()));
}
if !self.active.load(Ordering::Acquire) {
return Err(TdbError::Other("WAL archiver is not active".to_string()));
}
let timestamp = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("SystemTime should be after UNIX_EPOCH")
.as_secs();
let archive_name = if self.config.compress_archives {
format!(
"{}_{:016x}_{:016x}_{}.wal.gz",
self.config.archive_prefix, lsn_start, lsn_end, timestamp
)
} else {
format!(
"{}_{:016x}_{:016x}_{}.wal",
self.config.archive_prefix, lsn_start, lsn_end, timestamp
)
};
let archive_path = self.config.archive_dir.join(&archive_name);
let mut wal_data = Vec::new();
File::open(wal_file_path)
.and_then(|mut f| f.read_to_end(&mut wal_data))
.map_err(|e| TdbError::Other(format!("Failed to read WAL file: {}", e)))?;
let checksum = calculate_crc32(&wal_data);
let archive_data = if self.config.compress_archives {
compress_data(&wal_data)?
} else {
wal_data.clone()
};
File::create(&archive_path)
.and_then(|mut f| f.write_all(&archive_data))
.map_err(|e| TdbError::Other(format!("Failed to write archive: {}", e)))?;
if self.config.verify_archives {
self.verify_archive(&archive_path, checksum)?;
}
let metadata = WalArchiveMetadata {
file_name: archive_name.clone(),
original_path: wal_file_path.to_path_buf(),
archived_at: SystemTime::now(),
file_size: archive_data.len() as u64,
compressed: self.config.compress_archives,
checksum,
lsn_range: (lsn_start, lsn_end),
};
self.metadata.write().insert(archive_name.clone(), metadata);
self.total_archives.fetch_add(1, Ordering::Relaxed);
self.total_bytes_archived
.fetch_add(archive_data.len() as u64, Ordering::Relaxed);
self.stats
.successful_archives
.fetch_add(1, Ordering::Relaxed);
Ok(archive_name)
}
fn verify_archive(&self, archive_path: &Path, expected_checksum: u32) -> Result<()> {
let mut archive_data = Vec::new();
File::open(archive_path)
.and_then(|mut f| f.read_to_end(&mut archive_data))
.map_err(|e| {
TdbError::Other(format!("Failed to read archive for verification: {}", e))
})?;
let data = if self.config.compress_archives {
decompress_data(&archive_data)?
} else {
archive_data
};
let actual_checksum = calculate_crc32(&data);
if actual_checksum != expected_checksum {
return Err(TdbError::Other(format!(
"Archive verification failed: checksum mismatch (expected {}, got {})",
expected_checksum, actual_checksum
)));
}
Ok(())
}
pub fn restore_wal_file(&self, archive_name: &str, output_path: &Path) -> Result<()> {
let metadata = self
.metadata
.read()
.get(archive_name)
.cloned()
.ok_or_else(|| TdbError::Other(format!("Archive {} not found", archive_name)))?;
let archive_path = self.config.archive_dir.join(&metadata.file_name);
let mut archive_data = Vec::new();
File::open(&archive_path)
.and_then(|mut f| f.read_to_end(&mut archive_data))
.map_err(|e| TdbError::Other(format!("Failed to read archive: {}", e)))?;
let wal_data = if metadata.compressed {
decompress_data(&archive_data)?
} else {
archive_data
};
let checksum = calculate_crc32(&wal_data);
if checksum != metadata.checksum {
return Err(TdbError::Other(
"Archive restoration failed: checksum mismatch".to_string(),
));
}
File::create(output_path)
.and_then(|mut f| f.write_all(&wal_data))
.map_err(|e| TdbError::Other(format!("Failed to write WAL file: {}", e)))?;
self.stats
.successful_restores
.fetch_add(1, Ordering::Relaxed);
Ok(())
}
pub fn list_archives(&self) -> Vec<WalArchiveMetadata> {
self.metadata.read().values().cloned().collect()
}
pub fn get_metadata(&self, archive_name: &str) -> Option<WalArchiveMetadata> {
self.metadata.read().get(archive_name).cloned()
}
pub fn find_archives_for_lsn_range(
&self,
start_lsn: u64,
end_lsn: u64,
) -> Vec<WalArchiveMetadata> {
self.metadata
.read()
.values()
.filter(|metadata| {
metadata.lsn_range.0 <= end_lsn && metadata.lsn_range.1 >= start_lsn
})
.cloned()
.collect()
}
pub fn cleanup_old_archives(&self) -> Result<usize> {
if self.config.retention_period.is_zero() {
return Ok(0);
}
let cutoff_time = SystemTime::now()
.checked_sub(self.config.retention_period)
.ok_or_else(|| TdbError::Other("Invalid retention period".to_string()))?;
let mut deleted_count = 0;
let mut to_delete = Vec::new();
for (name, metadata) in self.metadata.read().iter() {
if metadata.archived_at < cutoff_time {
to_delete.push((name.clone(), metadata.file_name.clone()));
}
}
for (name, file_name) in to_delete {
let archive_path = self.config.archive_dir.join(&file_name);
if let Err(e) = fs::remove_file(&archive_path) {
eprintln!("Failed to delete archive {}: {}", file_name, e);
continue;
}
self.metadata.write().remove(&name);
deleted_count += 1;
self.total_deleted.fetch_add(1, Ordering::Relaxed);
}
Ok(deleted_count)
}
pub fn total_archive_size(&self) -> u64 {
self.metadata.read().values().map(|m| m.file_size).sum()
}
pub fn is_size_limit_exceeded(&self) -> bool {
if self.config.max_archive_size == 0 {
return false;
}
self.total_archive_size() > self.config.max_archive_size
}
pub fn stats(&self) -> WalArchiverStatsSnapshot {
WalArchiverStatsSnapshot {
total_archives: self.total_archives.load(Ordering::Relaxed),
total_bytes_archived: self.total_bytes_archived.load(Ordering::Relaxed),
total_deleted: self.total_deleted.load(Ordering::Relaxed),
successful_archives: self.stats.successful_archives.load(Ordering::Relaxed),
failed_archives: self.stats.failed_archives.load(Ordering::Relaxed),
successful_restores: self.stats.successful_restores.load(Ordering::Relaxed),
failed_restores: self.stats.failed_restores.load(Ordering::Relaxed),
current_archive_size: self.total_archive_size(),
}
}
pub fn stop(&self) {
self.active.store(false, Ordering::Release);
}
pub fn is_active(&self) -> bool {
self.active.load(Ordering::Acquire)
}
}
#[derive(Debug, Default)]
struct WalArchiverStats {
successful_archives: AtomicU64,
failed_archives: AtomicU64,
successful_restores: AtomicU64,
failed_restores: AtomicU64,
}
#[derive(Debug, Clone)]
pub struct WalArchiverStatsSnapshot {
pub total_archives: u64,
pub total_bytes_archived: u64,
pub total_deleted: u64,
pub successful_archives: u64,
pub failed_archives: u64,
pub successful_restores: u64,
pub failed_restores: u64,
pub current_archive_size: u64,
}
impl WalArchiverStatsSnapshot {
pub fn archive_success_rate(&self) -> f64 {
let total = self.successful_archives + self.failed_archives;
if total == 0 {
0.0
} else {
(self.successful_archives as f64 / total as f64) * 100.0
}
}
pub fn restore_success_rate(&self) -> f64 {
let total = self.successful_restores + self.failed_restores;
if total == 0 {
0.0
} else {
(self.successful_restores as f64 / total as f64) * 100.0
}
}
}
fn calculate_crc32(data: &[u8]) -> u32 {
const CRC32_POLY: u32 = 0xEDB88320;
let mut crc = 0xFFFFFFFF_u32;
for &byte in data {
crc ^= u32::from(byte);
for _ in 0..8 {
if crc & 1 != 0 {
crc = (crc >> 1) ^ CRC32_POLY;
} else {
crc >>= 1;
}
}
}
!crc
}
fn compress_data(data: &[u8]) -> Result<Vec<u8>> {
Ok(data.to_vec())
}
fn decompress_data(data: &[u8]) -> Result<Vec<u8>> {
Ok(data.to_vec())
}
#[cfg(test)]
#[allow(clippy::field_reassign_with_default)]
mod tests {
use super::*;
use std::env;
fn temp_archive_dir(name: &str) -> PathBuf {
let mut path = env::temp_dir();
path.push(format!("oxirs_tdb_wal_archive_test_{}", name));
path
}
fn temp_wal_file(name: &str, content: &[u8]) -> PathBuf {
let mut path = env::temp_dir();
path.push(format!("oxirs_tdb_wal_test_{}.wal", name));
let mut file = File::create(&path).unwrap();
file.write_all(content).unwrap();
path
}
#[test]
fn test_wal_archive_config_default() {
let config = WalArchiveConfig::default();
assert!(!config.enable_archiving);
assert!(config.compress_archives);
assert_eq!(config.archive_prefix, "wal");
}
#[test]
fn test_wal_archiver_creation() {
let archive_dir = temp_archive_dir("creation");
let mut config = WalArchiveConfig::default();
config.archive_dir = archive_dir.clone();
config.enable_archiving = true;
let archiver = WalArchiver::new(config).unwrap();
assert!(archiver.is_active());
assert!(archive_dir.exists());
fs::remove_dir_all(archive_dir).ok();
}
#[test]
fn test_archive_wal_file() {
let archive_dir = temp_archive_dir("archive");
let mut config = WalArchiveConfig::default();
config.archive_dir = archive_dir.clone();
config.enable_archiving = true;
config.verify_archives = true;
let archiver = WalArchiver::new(config).unwrap();
let wal_content = b"Test WAL data for archiving";
let wal_path = temp_wal_file("archive_test", wal_content);
let archive_name = archiver.archive_wal_file(&wal_path, 1000, 2000).unwrap();
assert!(!archive_name.is_empty());
let stats = archiver.stats();
assert_eq!(stats.total_archives, 1);
assert_eq!(stats.successful_archives, 1);
fs::remove_file(wal_path).ok();
fs::remove_dir_all(archive_dir).ok();
}
#[test]
fn test_restore_wal_file() {
let archive_dir = temp_archive_dir("restore");
let mut config = WalArchiveConfig::default();
config.archive_dir = archive_dir.clone();
config.enable_archiving = true;
let archiver = WalArchiver::new(config).unwrap();
let wal_content = b"Test WAL data for restoration";
let wal_path = temp_wal_file("restore_test", wal_content);
let archive_name = archiver.archive_wal_file(&wal_path, 1000, 2000).unwrap();
let restore_path = temp_wal_file("restore_output", &[]);
archiver
.restore_wal_file(&archive_name, &restore_path)
.unwrap();
let mut restored_content = Vec::new();
File::open(&restore_path)
.unwrap()
.read_to_end(&mut restored_content)
.unwrap();
assert_eq!(&restored_content, wal_content);
let stats = archiver.stats();
assert_eq!(stats.successful_restores, 1);
fs::remove_file(wal_path).ok();
fs::remove_file(restore_path).ok();
fs::remove_dir_all(archive_dir).ok();
}
#[test]
fn test_list_archives() {
let archive_dir = temp_archive_dir("list");
let mut config = WalArchiveConfig::default();
config.archive_dir = archive_dir.clone();
config.enable_archiving = true;
let archiver = WalArchiver::new(config).unwrap();
for i in 0..3 {
let wal_content = format!("WAL data {}", i);
let wal_path = temp_wal_file(&format!("list_{}", i), wal_content.as_bytes());
archiver
.archive_wal_file(&wal_path, i * 1000, (i + 1) * 1000)
.unwrap();
fs::remove_file(wal_path).ok();
}
let archives = archiver.list_archives();
assert_eq!(archives.len(), 3);
fs::remove_dir_all(archive_dir).ok();
}
#[test]
fn test_find_archives_for_lsn_range() {
let archive_dir = temp_archive_dir("lsn_range");
let mut config = WalArchiveConfig::default();
config.archive_dir = archive_dir.clone();
config.enable_archiving = true;
let archiver = WalArchiver::new(config).unwrap();
let wal_path1 = temp_wal_file("lsn1", b"WAL 1");
let wal_path2 = temp_wal_file("lsn2", b"WAL 2");
let wal_path3 = temp_wal_file("lsn3", b"WAL 3");
archiver.archive_wal_file(&wal_path1, 1000, 2000).unwrap();
archiver.archive_wal_file(&wal_path2, 2000, 3000).unwrap();
archiver.archive_wal_file(&wal_path3, 3000, 4000).unwrap();
let archives = archiver.find_archives_for_lsn_range(1500, 2500);
assert_eq!(archives.len(), 2);
fs::remove_file(wal_path1).ok();
fs::remove_file(wal_path2).ok();
fs::remove_file(wal_path3).ok();
fs::remove_dir_all(archive_dir).ok();
}
#[test]
fn test_cleanup_old_archives() {
let archive_dir = temp_archive_dir("cleanup");
let mut config = WalArchiveConfig::default();
config.archive_dir = archive_dir.clone();
config.enable_archiving = true;
config.retention_period = Duration::from_millis(100);
let archiver = WalArchiver::new(config).unwrap();
let wal_path = temp_wal_file("cleanup_test", b"Old WAL data");
archiver.archive_wal_file(&wal_path, 1000, 2000).unwrap();
assert_eq!(archiver.list_archives().len(), 1);
std::thread::sleep(Duration::from_millis(200));
let deleted = archiver.cleanup_old_archives().unwrap();
assert_eq!(deleted, 1);
assert_eq!(archiver.list_archives().len(), 0);
fs::remove_file(wal_path).ok();
fs::remove_dir_all(archive_dir).ok();
}
#[test]
fn test_total_archive_size() {
let archive_dir = temp_archive_dir("size");
let mut config = WalArchiveConfig::default();
config.archive_dir = archive_dir.clone();
config.enable_archiving = true;
let archiver = WalArchiver::new(config).unwrap();
let wal_path = temp_wal_file("size_test", b"Test data for size calculation");
archiver.archive_wal_file(&wal_path, 1000, 2000).unwrap();
let total_size = archiver.total_archive_size();
assert!(total_size > 0);
fs::remove_file(wal_path).ok();
fs::remove_dir_all(archive_dir).ok();
}
#[test]
fn test_archiver_stop() {
let archive_dir = temp_archive_dir("stop");
let mut config = WalArchiveConfig::default();
config.archive_dir = archive_dir.clone();
config.enable_archiving = true;
let archiver = WalArchiver::new(config).unwrap();
assert!(archiver.is_active());
archiver.stop();
assert!(!archiver.is_active());
let wal_path = temp_wal_file("stop_test", b"Test");
let result = archiver.archive_wal_file(&wal_path, 1000, 2000);
assert!(result.is_err());
fs::remove_file(wal_path).ok();
fs::remove_dir_all(archive_dir).ok();
}
#[test]
fn test_crc32_calculation() {
let data = b"Hello, CRC32!";
let checksum = calculate_crc32(data);
assert_eq!(checksum, calculate_crc32(data));
let checksum2 = calculate_crc32(b"Different data");
assert_ne!(checksum, checksum2);
}
#[test]
fn test_stats_calculations() {
let stats = WalArchiverStatsSnapshot {
total_archives: 100,
total_bytes_archived: 1_000_000,
total_deleted: 20,
successful_archives: 95,
failed_archives: 5,
successful_restores: 80,
failed_restores: 20,
current_archive_size: 500_000,
};
assert_eq!(stats.archive_success_rate(), 95.0);
assert_eq!(stats.restore_success_rate(), 80.0);
}
#[test]
fn test_disabled_archiving() {
let archive_dir = temp_archive_dir("disabled");
let mut config = WalArchiveConfig::default();
config.archive_dir = archive_dir.clone();
config.enable_archiving = false;
let archiver = WalArchiver::new(config).unwrap();
let wal_path = temp_wal_file("disabled_test", b"Test");
let result = archiver.archive_wal_file(&wal_path, 1000, 2000);
assert!(result.is_err());
fs::remove_file(wal_path).ok();
fs::remove_dir_all(archive_dir).ok();
}
#[test]
fn test_get_metadata() {
let archive_dir = temp_archive_dir("metadata");
let mut config = WalArchiveConfig::default();
config.archive_dir = archive_dir.clone();
config.enable_archiving = true;
let archiver = WalArchiver::new(config).unwrap();
let wal_path = temp_wal_file("metadata_test", b"Metadata test");
let archive_name = archiver.archive_wal_file(&wal_path, 5000, 6000).unwrap();
let metadata = archiver.get_metadata(&archive_name);
assert!(metadata.is_some());
let meta = metadata.unwrap();
assert_eq!(meta.lsn_range, (5000, 6000));
assert_eq!(meta.file_name, archive_name);
fs::remove_file(wal_path).ok();
fs::remove_dir_all(archive_dir).ok();
}
}