use crate::backup::{BackupConfig, BackupManager, BackupMetadata, BackupType};
use crate::error::{Result, TdbError};
use parking_lot::RwLock;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, SystemTime};
pub type SnapshotId = u64;
#[derive(Debug, Clone)]
pub struct SnapshotConfig {
pub max_snapshots: usize,
pub snapshot_ttl: Duration,
pub compress: bool,
pub verify: bool,
}
impl Default for SnapshotConfig {
fn default() -> Self {
Self {
max_snapshots: 10,
snapshot_ttl: Duration::from_secs(3600), compress: true,
verify: true,
}
}
}
#[derive(Debug, Clone)]
pub struct Snapshot {
pub id: SnapshotId,
pub db_path: PathBuf,
pub wal_lsn: u64,
pub created_at: SystemTime,
pub expires_at: SystemTime,
pub status: SnapshotStatus,
pub active_backups: usize,
pub size_bytes: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SnapshotStatus {
Creating,
Ready,
InUse,
Expired,
Released,
}
#[derive(Debug, Clone, Default)]
pub struct OnlineBackupStats {
pub snapshots_created: u64,
pub active_snapshots: usize,
pub backups_completed: u64,
pub bytes_backed_up: u64,
pub avg_backup_duration_ms: u64,
pub snapshots_expired: u64,
}
pub struct OnlineBackupManager {
config: SnapshotConfig,
snapshots: Arc<RwLock<HashMap<SnapshotId, Snapshot>>>,
next_snapshot_id: Arc<RwLock<SnapshotId>>,
stats: Arc<RwLock<OnlineBackupStats>>,
backup_manager: BackupManager,
}
impl OnlineBackupManager {
pub fn new(config: SnapshotConfig) -> Self {
let backup_config = BackupConfig {
compress: config.compress,
verify: config.verify,
backup_type: BackupType::Full,
};
Self {
config,
snapshots: Arc::new(RwLock::new(HashMap::new())),
next_snapshot_id: Arc::new(RwLock::new(1)),
stats: Arc::new(RwLock::new(OnlineBackupStats::default())),
backup_manager: BackupManager::new(backup_config),
}
}
pub fn create_snapshot(&self, db_path: impl AsRef<Path>) -> Result<Snapshot> {
let db_path = db_path.as_ref();
{
let snapshots = self.snapshots.read();
if snapshots.len() >= self.config.max_snapshots {
drop(snapshots);
self.cleanup_expired_snapshots()?;
let snapshots = self.snapshots.read();
if snapshots.len() >= self.config.max_snapshots {
return Err(TdbError::Other(format!(
"Maximum snapshot limit reached: {}",
self.config.max_snapshots
)));
}
}
}
let snapshot_id = {
let mut next_id = self.next_snapshot_id.write();
let id = *next_id;
*next_id += 1;
id
};
let wal_lsn = self.get_current_wal_lsn(db_path)?;
let size_bytes = self.calculate_db_size(db_path)?;
let now = SystemTime::now();
let snapshot = Snapshot {
id: snapshot_id,
db_path: db_path.to_path_buf(),
wal_lsn,
created_at: now,
expires_at: now + self.config.snapshot_ttl,
status: SnapshotStatus::Ready,
active_backups: 0,
size_bytes,
};
self.snapshots.write().insert(snapshot_id, snapshot.clone());
let mut stats = self.stats.write();
stats.snapshots_created += 1;
stats.active_snapshots += 1;
Ok(snapshot)
}
pub fn backup_snapshot(
&self,
snapshot: &Snapshot,
backup_dir: impl AsRef<Path>,
) -> Result<BackupMetadata> {
if !self.is_snapshot_valid(snapshot.id)? {
return Err(TdbError::Other(format!(
"Snapshot {} is no longer valid",
snapshot.id
)));
}
self.mark_snapshot_in_use(snapshot.id)?;
let start_time = SystemTime::now();
let result = self
.backup_manager
.create_backup(&snapshot.db_path, &backup_dir);
self.release_snapshot_usage(snapshot.id)?;
if let Ok(ref metadata) = result {
let duration = start_time
.elapsed()
.unwrap_or(Duration::from_secs(0))
.as_millis() as u64;
let mut stats = self.stats.write();
stats.backups_completed += 1;
stats.bytes_backed_up += metadata.size_bytes;
if stats.backups_completed == 1 {
stats.avg_backup_duration_ms = duration;
} else {
stats.avg_backup_duration_ms =
(stats.avg_backup_duration_ms * (stats.backups_completed - 1) + duration)
/ stats.backups_completed;
}
}
result
}
pub fn backup_snapshot_incremental(
&self,
snapshot: &Snapshot,
backup_dir: impl AsRef<Path>,
) -> Result<BackupMetadata> {
if !self.is_snapshot_valid(snapshot.id)? {
return Err(TdbError::Other(format!(
"Snapshot {} is no longer valid",
snapshot.id
)));
}
self.mark_snapshot_in_use(snapshot.id)?;
let start_time = SystemTime::now();
let mut incremental_manager = BackupManager::new(BackupConfig {
compress: self.config.compress,
verify: self.config.verify,
backup_type: BackupType::Incremental,
});
let result = incremental_manager.create_incremental_backup(&snapshot.db_path, &backup_dir);
self.release_snapshot_usage(snapshot.id)?;
if let Ok(ref metadata) = result {
let duration = start_time
.elapsed()
.unwrap_or(Duration::from_secs(0))
.as_millis() as u64;
let mut stats = self.stats.write();
stats.backups_completed += 1;
stats.bytes_backed_up += metadata.size_bytes;
if stats.backups_completed == 1 {
stats.avg_backup_duration_ms = duration;
} else {
stats.avg_backup_duration_ms =
(stats.avg_backup_duration_ms * (stats.backups_completed - 1) + duration)
/ stats.backups_completed;
}
}
result
}
pub fn release_snapshot(&self, snapshot_id: SnapshotId) -> Result<()> {
let mut snapshots = self.snapshots.write();
if let Some(snapshot) = snapshots.get_mut(&snapshot_id) {
if snapshot.active_backups > 0 {
return Err(TdbError::Other(format!(
"Cannot release snapshot {}: {} active backup(s)",
snapshot_id, snapshot.active_backups
)));
}
snapshot.status = SnapshotStatus::Released;
snapshots.remove(&snapshot_id);
let mut stats = self.stats.write();
stats.active_snapshots = stats.active_snapshots.saturating_sub(1);
Ok(())
} else {
Err(TdbError::Other(format!(
"Snapshot {} not found",
snapshot_id
)))
}
}
pub fn list_snapshots(&self) -> Vec<Snapshot> {
self.snapshots
.read()
.values()
.filter(|s| s.status != SnapshotStatus::Released)
.cloned()
.collect()
}
pub fn get_snapshot(&self, snapshot_id: SnapshotId) -> Option<Snapshot> {
self.snapshots.read().get(&snapshot_id).cloned()
}
pub fn is_snapshot_valid(&self, snapshot_id: SnapshotId) -> Result<bool> {
let snapshots = self.snapshots.read();
if let Some(snapshot) = snapshots.get(&snapshot_id) {
let now = SystemTime::now();
Ok(snapshot.status != SnapshotStatus::Released && now < snapshot.expires_at)
} else {
Ok(false)
}
}
pub fn cleanup_expired_snapshots(&self) -> Result<usize> {
let now = SystemTime::now();
let mut snapshots = self.snapshots.write();
let mut removed = 0;
let expired_ids: Vec<SnapshotId> = snapshots
.iter()
.filter(|(_, snapshot)| snapshot.active_backups == 0 && now >= snapshot.expires_at)
.map(|(id, _)| *id)
.collect();
for id in expired_ids {
if let Some(mut snapshot) = snapshots.remove(&id) {
snapshot.status = SnapshotStatus::Expired;
removed += 1;
}
}
if removed > 0 {
let mut stats = self.stats.write();
stats.snapshots_expired += removed as u64;
stats.active_snapshots = stats.active_snapshots.saturating_sub(removed);
}
Ok(removed)
}
pub fn get_stats(&self) -> OnlineBackupStats {
self.stats.read().clone()
}
fn mark_snapshot_in_use(&self, snapshot_id: SnapshotId) -> Result<()> {
let mut snapshots = self.snapshots.write();
if let Some(snapshot) = snapshots.get_mut(&snapshot_id) {
snapshot.active_backups += 1;
snapshot.status = SnapshotStatus::InUse;
Ok(())
} else {
Err(TdbError::Other(format!(
"Snapshot {} not found",
snapshot_id
)))
}
}
fn release_snapshot_usage(&self, snapshot_id: SnapshotId) -> Result<()> {
let mut snapshots = self.snapshots.write();
if let Some(snapshot) = snapshots.get_mut(&snapshot_id) {
snapshot.active_backups = snapshot.active_backups.saturating_sub(1);
if snapshot.active_backups == 0 {
snapshot.status = SnapshotStatus::Ready;
}
Ok(())
} else {
Err(TdbError::Other(format!(
"Snapshot {} not found",
snapshot_id
)))
}
}
fn get_current_wal_lsn(&self, db_path: &Path) -> Result<u64> {
let timestamp = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_err(|e| TdbError::Other(format!("Time error: {}", e)))?
.as_secs();
Ok(timestamp)
}
#[allow(clippy::only_used_in_recursion)]
fn calculate_db_size(&self, db_path: &Path) -> Result<u64> {
if !db_path.exists() {
return Ok(0);
}
let mut total_size = 0u64;
for entry in fs::read_dir(db_path).map_err(TdbError::Io)? {
let entry = entry.map_err(TdbError::Io)?;
let path = entry.path();
if path.is_file() {
let metadata = fs::metadata(&path).map_err(TdbError::Io)?;
total_size += metadata.len();
} else if path.is_dir() {
total_size += self.calculate_db_size(&path)?;
}
}
Ok(total_size)
}
}
impl Default for OnlineBackupManager {
fn default() -> Self {
Self::new(SnapshotConfig::default())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
use std::thread;
#[test]
fn test_create_snapshot() {
let temp_dir = env::temp_dir().join("oxirs_tdb_snapshot_test");
fs::remove_dir_all(&temp_dir).ok();
let db_dir = temp_dir.join("db");
fs::create_dir_all(&db_dir).unwrap();
fs::write(db_dir.join("data.tdb"), b"test data").unwrap();
let manager = OnlineBackupManager::new(SnapshotConfig::default());
let snapshot = manager.create_snapshot(&db_dir).unwrap();
assert_eq!(snapshot.id, 1);
assert_eq!(snapshot.status, SnapshotStatus::Ready);
assert_eq!(snapshot.active_backups, 0);
assert!(snapshot.size_bytes > 0);
let stats = manager.get_stats();
assert_eq!(stats.snapshots_created, 1);
assert_eq!(stats.active_snapshots, 1);
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_snapshot_validation() {
let temp_dir = env::temp_dir().join("oxirs_tdb_snapshot_valid_test");
fs::remove_dir_all(&temp_dir).ok();
let db_dir = temp_dir.join("db");
fs::create_dir_all(&db_dir).unwrap();
fs::write(db_dir.join("data.tdb"), b"test").unwrap();
let manager = OnlineBackupManager::new(SnapshotConfig::default());
let snapshot = manager.create_snapshot(&db_dir).unwrap();
assert!(manager.is_snapshot_valid(snapshot.id).unwrap());
assert!(!manager.is_snapshot_valid(9999).unwrap());
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_release_snapshot() {
let temp_dir = env::temp_dir().join("oxirs_tdb_snapshot_release_test");
fs::remove_dir_all(&temp_dir).ok();
let db_dir = temp_dir.join("db");
fs::create_dir_all(&db_dir).unwrap();
fs::write(db_dir.join("data.tdb"), b"test").unwrap();
let manager = OnlineBackupManager::new(SnapshotConfig::default());
let snapshot = manager.create_snapshot(&db_dir).unwrap();
assert_eq!(manager.get_stats().active_snapshots, 1);
manager.release_snapshot(snapshot.id).unwrap();
assert_eq!(manager.get_stats().active_snapshots, 0);
assert!(!manager.is_snapshot_valid(snapshot.id).unwrap());
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_online_backup() {
let temp_dir = env::temp_dir().join("oxirs_tdb_online_backup_test");
fs::remove_dir_all(&temp_dir).ok();
let db_dir = temp_dir.join("db");
let backup_dir = temp_dir.join("backups");
fs::create_dir_all(&db_dir).unwrap();
fs::write(db_dir.join("data.tdb"), b"production data").unwrap();
let manager = OnlineBackupManager::new(SnapshotConfig::default());
let snapshot = manager.create_snapshot(&db_dir).unwrap();
let metadata = manager.backup_snapshot(&snapshot, &backup_dir).unwrap();
assert_eq!(metadata.backup_type, BackupType::Full);
assert!(metadata.size_bytes > 0);
let stats = manager.get_stats();
assert_eq!(stats.backups_completed, 1);
assert!(stats.bytes_backed_up > 0);
manager.release_snapshot(snapshot.id).unwrap();
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_concurrent_backups() {
let temp_dir = env::temp_dir().join("oxirs_tdb_concurrent_backup_test");
fs::remove_dir_all(&temp_dir).ok();
let db_dir = temp_dir.join("db");
fs::create_dir_all(&db_dir).unwrap();
fs::write(db_dir.join("data.tdb"), b"shared data").unwrap();
let manager = Arc::new(OnlineBackupManager::new(SnapshotConfig::default()));
let snapshot1 = manager.create_snapshot(&db_dir).unwrap();
let snapshot2 = manager.create_snapshot(&db_dir).unwrap();
assert_eq!(manager.get_stats().active_snapshots, 2);
let backup_dir1 = temp_dir.join("backup1");
let backup_dir2 = temp_dir.join("backup2");
let manager_clone1 = manager.clone();
let manager_clone2 = manager.clone();
let snap1 = snapshot1.clone();
let snap2 = snapshot2.clone();
let bdir1 = backup_dir1.clone();
let bdir2 = backup_dir2.clone();
let handle1 =
thread::spawn(move || manager_clone1.backup_snapshot(&snap1, &bdir1).unwrap());
let handle2 =
thread::spawn(move || manager_clone2.backup_snapshot(&snap2, &bdir2).unwrap());
let _meta1 = handle1.join().unwrap();
let _meta2 = handle2.join().unwrap();
let stats = manager.get_stats();
assert_eq!(stats.backups_completed, 2);
manager.release_snapshot(snapshot1.id).unwrap();
manager.release_snapshot(snapshot2.id).unwrap();
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_snapshot_expiration() {
let temp_dir = env::temp_dir().join("oxirs_tdb_snapshot_expire_test");
fs::remove_dir_all(&temp_dir).ok();
let db_dir = temp_dir.join("db");
fs::create_dir_all(&db_dir).unwrap();
fs::write(db_dir.join("data.tdb"), b"test").unwrap();
let config = SnapshotConfig {
snapshot_ttl: Duration::from_millis(100),
..Default::default()
};
let manager = OnlineBackupManager::new(config);
let snapshot = manager.create_snapshot(&db_dir).unwrap();
assert!(manager.is_snapshot_valid(snapshot.id).unwrap());
thread::sleep(Duration::from_millis(150));
assert!(!manager.is_snapshot_valid(snapshot.id).unwrap());
let removed = manager.cleanup_expired_snapshots().unwrap();
assert_eq!(removed, 1);
let stats = manager.get_stats();
assert_eq!(stats.snapshots_expired, 1);
assert_eq!(stats.active_snapshots, 0);
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_max_snapshots_limit() {
let temp_dir = env::temp_dir().join("oxirs_tdb_max_snapshots_test");
fs::remove_dir_all(&temp_dir).ok();
let db_dir = temp_dir.join("db");
fs::create_dir_all(&db_dir).unwrap();
fs::write(db_dir.join("data.tdb"), b"test").unwrap();
let config = SnapshotConfig {
max_snapshots: 3,
..Default::default()
};
let manager = OnlineBackupManager::new(config);
let s1 = manager.create_snapshot(&db_dir).unwrap();
let s2 = manager.create_snapshot(&db_dir).unwrap();
let s3 = manager.create_snapshot(&db_dir).unwrap();
assert_eq!(manager.get_stats().active_snapshots, 3);
let result = manager.create_snapshot(&db_dir);
assert!(result.is_err());
manager.release_snapshot(s1.id).unwrap();
let s4 = manager.create_snapshot(&db_dir).unwrap();
assert!(s4.id > 0);
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_snapshot_prevents_release_during_backup() {
let temp_dir = env::temp_dir().join("oxirs_tdb_prevent_release_test");
fs::remove_dir_all(&temp_dir).ok();
let db_dir = temp_dir.join("db");
let backup_dir = temp_dir.join("backup");
fs::create_dir_all(&db_dir).unwrap();
fs::write(db_dir.join("data.tdb"), b"test").unwrap();
let manager = OnlineBackupManager::new(SnapshotConfig::default());
let snapshot = manager.create_snapshot(&db_dir).unwrap();
manager.mark_snapshot_in_use(snapshot.id).unwrap();
let result = manager.release_snapshot(snapshot.id);
assert!(result.is_err());
manager.release_snapshot_usage(snapshot.id).unwrap();
manager.release_snapshot(snapshot.id).unwrap();
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_list_snapshots() {
let temp_dir = env::temp_dir().join("oxirs_tdb_list_snapshots_test");
fs::remove_dir_all(&temp_dir).ok();
let db_dir = temp_dir.join("db");
fs::create_dir_all(&db_dir).unwrap();
fs::write(db_dir.join("data.tdb"), b"test").unwrap();
let manager = OnlineBackupManager::new(SnapshotConfig::default());
manager.create_snapshot(&db_dir).unwrap();
manager.create_snapshot(&db_dir).unwrap();
manager.create_snapshot(&db_dir).unwrap();
let snapshots = manager.list_snapshots();
assert_eq!(snapshots.len(), 3);
for snapshot in &snapshots {
assert_eq!(snapshot.status, SnapshotStatus::Ready);
}
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_incremental_online_backup() {
let temp_dir = env::temp_dir().join("oxirs_tdb_online_incremental_test");
fs::remove_dir_all(&temp_dir).ok();
let db_dir = temp_dir.join("db");
let backup_dir = temp_dir.join("backups");
fs::create_dir_all(&db_dir).unwrap();
fs::write(db_dir.join("file1.tdb"), b"initial data").unwrap();
let manager = OnlineBackupManager::new(SnapshotConfig::default());
let snapshot1 = manager.create_snapshot(&db_dir).unwrap();
let full_meta = manager.backup_snapshot(&snapshot1, &backup_dir).unwrap();
assert_eq!(full_meta.backup_type, BackupType::Full);
manager.release_snapshot(snapshot1.id).unwrap();
thread::sleep(Duration::from_millis(10));
fs::write(db_dir.join("file2.tdb"), b"new data").unwrap();
let snapshot2 = manager.create_snapshot(&db_dir).unwrap();
let inc_meta = manager
.backup_snapshot_incremental(&snapshot2, &backup_dir)
.unwrap();
assert_eq!(inc_meta.backup_type, BackupType::Incremental);
assert!(inc_meta.parent_backup.is_some());
manager.release_snapshot(snapshot2.id).unwrap();
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_snapshot_statistics() {
let temp_dir = env::temp_dir().join("oxirs_tdb_snapshot_stats_test");
fs::remove_dir_all(&temp_dir).ok();
let db_dir = temp_dir.join("db");
let backup_dir = temp_dir.join("backup");
fs::create_dir_all(&db_dir).unwrap();
fs::write(db_dir.join("data.tdb"), b"test data").unwrap();
let manager = OnlineBackupManager::new(SnapshotConfig::default());
let snapshot = manager.create_snapshot(&db_dir).unwrap();
manager.backup_snapshot(&snapshot, &backup_dir).unwrap();
let stats = manager.get_stats();
assert_eq!(stats.snapshots_created, 1);
assert_eq!(stats.active_snapshots, 1);
assert_eq!(stats.backups_completed, 1);
assert!(stats.bytes_backed_up > 0);
manager.release_snapshot(snapshot.id).unwrap();
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_snapshot_wal_lsn_tracking() {
let temp_dir = env::temp_dir().join("oxirs_tdb_snapshot_lsn_test");
fs::remove_dir_all(&temp_dir).ok();
let db_dir = temp_dir.join("db");
fs::create_dir_all(&db_dir).unwrap();
fs::write(db_dir.join("data.tdb"), b"test").unwrap();
let manager = OnlineBackupManager::new(SnapshotConfig::default());
let snapshot1 = manager.create_snapshot(&db_dir).unwrap();
thread::sleep(Duration::from_millis(10));
let snapshot2 = manager.create_snapshot(&db_dir).unwrap();
assert!(snapshot2.wal_lsn >= snapshot1.wal_lsn);
fs::remove_dir_all(&temp_dir).ok();
}
}