use crate::storage::{ObjectMetadata, StorageError};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ArchiveTier {
Cold,
Glacier,
DeepArchive,
Tape,
}
impl ArchiveTier {
pub fn retrieval_time(&self) -> Duration {
match self {
Self::Cold => Duration::hours(1),
Self::Glacier => Duration::hours(4),
Self::DeepArchive => Duration::hours(12),
Self::Tape => Duration::days(3),
}
}
pub fn cost_multiplier(&self) -> f64 {
match self {
Self::Cold => 0.5,
Self::Glacier => 0.1,
Self::DeepArchive => 0.02,
Self::Tape => 0.005,
}
}
pub fn requires_restore(&self) -> bool {
matches!(self, Self::Glacier | Self::DeepArchive | Self::Tape)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ArchivalDestination {
AwsGlacier { vault_name: String, region: String },
AzureArchive {
account_name: String,
container_name: String,
},
TapeLibrary {
library_path: PathBuf,
drive_index: usize,
},
Hybrid {
local_path: PathBuf,
cloud_backup: Box<ArchivalDestination>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArchivalPolicy {
pub age_threshold: Duration,
pub size_threshold_mb: u64,
pub access_frequency_threshold: u32,
pub target_tier: ArchiveTier,
pub destination: Option<ArchivalDestination>,
pub min_cost_savings_percent: f64,
pub auto_archive: bool,
}
impl Default for ArchivalPolicy {
fn default() -> Self {
Self {
age_threshold: Duration::days(90),
size_threshold_mb: 100,
access_frequency_threshold: 5,
target_tier: ArchiveTier::Glacier,
destination: None,
min_cost_savings_percent: 30.0,
auto_archive: true,
}
}
}
impl ArchivalPolicy {
pub fn builder() -> ArchivalPolicyBuilder {
ArchivalPolicyBuilder::default()
}
pub fn should_archive(&self, metadata: &ArchivalMetadata) -> bool {
if !self.auto_archive {
return false;
}
let age_check = metadata.age >= self.age_threshold;
let size_check = metadata.size_mb >= self.size_threshold_mb;
let access_check = metadata.access_count <= self.access_frequency_threshold;
let cost_check = metadata.estimated_savings_percent >= self.min_cost_savings_percent;
age_check && size_check && access_check && cost_check
}
}
#[derive(Debug, Default)]
pub struct ArchivalPolicyBuilder {
age_threshold: Option<Duration>,
size_threshold_mb: Option<u64>,
access_frequency_threshold: Option<u32>,
target_tier: Option<ArchiveTier>,
destination: Option<ArchivalDestination>,
min_cost_savings_percent: Option<f64>,
auto_archive: Option<bool>,
}
impl ArchivalPolicyBuilder {
pub fn age_threshold(mut self, duration: Duration) -> Self {
self.age_threshold = Some(duration);
self
}
pub fn size_threshold_mb(mut self, mb: u64) -> Self {
self.size_threshold_mb = Some(mb);
self
}
pub fn access_frequency_threshold(mut self, count: u32) -> Self {
self.access_frequency_threshold = Some(count);
self
}
pub fn target_tier(mut self, tier: ArchiveTier) -> Self {
self.target_tier = Some(tier);
self
}
pub fn destination(mut self, dest: ArchivalDestination) -> Self {
self.destination = Some(dest);
self
}
pub fn min_cost_savings_percent(mut self, percent: f64) -> Self {
self.min_cost_savings_percent = Some(percent);
self
}
pub fn auto_archive(mut self, enabled: bool) -> Self {
self.auto_archive = Some(enabled);
self
}
pub fn build(self) -> ArchivalPolicy {
let default = ArchivalPolicy::default();
ArchivalPolicy {
age_threshold: self.age_threshold.unwrap_or(default.age_threshold),
size_threshold_mb: self.size_threshold_mb.unwrap_or(default.size_threshold_mb),
access_frequency_threshold: self
.access_frequency_threshold
.unwrap_or(default.access_frequency_threshold),
target_tier: self.target_tier.unwrap_or(default.target_tier),
destination: self.destination.or(default.destination),
min_cost_savings_percent: self
.min_cost_savings_percent
.unwrap_or(default.min_cost_savings_percent),
auto_archive: self.auto_archive.unwrap_or(default.auto_archive),
}
}
}
#[derive(Debug, Clone)]
pub struct ArchivalMetadata {
pub key: String,
pub size_mb: u64,
pub age: Duration,
pub access_count: u32,
pub last_access: DateTime<Utc>,
pub current_tier: String,
pub estimated_savings_percent: f64,
}
impl ArchivalMetadata {
pub fn from_object_metadata(
meta: &ObjectMetadata,
access_count: u32,
last_access: DateTime<Utc>,
) -> Self {
let age = Utc::now().signed_duration_since(meta.last_modified);
let size_mb = meta.size / (1024 * 1024);
let base_savings: f64 = if size_mb > 1000 {
50.0
} else if size_mb > 100 {
40.0
} else {
30.0
};
let age_factor: f64 = if age > Duration::days(365) {
1.5
} else if age > Duration::days(180) {
1.2
} else {
1.0
};
let estimated_savings_percent = (base_savings * age_factor).min(95.0);
Self {
key: meta.key.clone(),
size_mb,
age,
access_count,
last_access,
current_tier: "Standard".to_string(),
estimated_savings_percent,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ArchivalStatus {
Active,
Archiving {
started_at: DateTime<Utc>,
tier: ArchiveTier,
},
Archived {
tier: ArchiveTier,
archived_at: DateTime<Utc>,
},
Restoring {
started_at: DateTime<Utc>,
estimated_completion: DateTime<Utc>,
},
Failed {
error: String,
failed_at: DateTime<Utc>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArchivalRecord {
pub key: String,
pub bucket: String,
pub status: ArchivalStatus,
pub size: u64,
pub tier: ArchiveTier,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub metadata: HashMap<String, String>,
}
pub struct ArchivalManager {
policy: ArchivalPolicy,
records: HashMap<String, ArchivalRecord>,
stats: ArchivalStats,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ArchivalStats {
pub total_archived: u64,
pub total_bytes_archived: u64,
pub total_restored: u64,
pub total_failed: u64,
pub current_archived_count: u64,
pub current_archived_bytes: u64,
pub estimated_monthly_savings_usd: f64,
}
impl ArchivalManager {
pub fn new(policy: ArchivalPolicy) -> Self {
Self {
policy,
records: HashMap::new(),
stats: ArchivalStats::default(),
}
}
pub fn policy(&self) -> &ArchivalPolicy {
&self.policy
}
pub fn set_policy(&mut self, policy: ArchivalPolicy) {
self.policy = policy;
}
pub fn should_archive(&self, metadata: &ArchivalMetadata) -> bool {
self.policy.should_archive(metadata)
}
pub async fn archive_object(
&mut self,
bucket: &str,
key: &str,
size: u64,
) -> Result<String, StorageError> {
let archive_id = uuid::Uuid::new_v4().to_string();
let record_key = format!("{}/{}", bucket, key);
let record = ArchivalRecord {
key: key.to_string(),
bucket: bucket.to_string(),
status: ArchivalStatus::Archiving {
started_at: Utc::now(),
tier: self.policy.target_tier,
},
size,
tier: self.policy.target_tier,
created_at: Utc::now(),
updated_at: Utc::now(),
metadata: HashMap::new(),
};
self.records.insert(record_key.clone(), record);
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
if let Some(record) = self.records.get_mut(&record_key) {
record.status = ArchivalStatus::Archived {
tier: self.policy.target_tier,
archived_at: Utc::now(),
};
record.updated_at = Utc::now();
self.stats.total_archived += 1;
self.stats.total_bytes_archived += size;
self.stats.current_archived_count += 1;
self.stats.current_archived_bytes += size;
let cost_multiplier = self.policy.target_tier.cost_multiplier();
let standard_cost_per_gb = 0.023; let size_gb = size as f64 / (1024.0 * 1024.0 * 1024.0);
let monthly_savings = size_gb * standard_cost_per_gb * (1.0 - cost_multiplier);
self.stats.estimated_monthly_savings_usd += monthly_savings;
}
tracing::info!(
bucket = bucket,
key = key,
archive_id = %archive_id,
tier = ?self.policy.target_tier,
"Object archived successfully"
);
Ok(archive_id)
}
pub async fn restore_object(
&mut self,
bucket: &str,
key: &str,
) -> Result<String, StorageError> {
let record_key = format!("{}/{}", bucket, key);
let record = self
.records
.get(&record_key)
.ok_or_else(|| StorageError::NotFound(format!("Archive record not found: {}", key)))?;
let retrieval_time = record.tier.retrieval_time();
let estimated_completion = Utc::now() + retrieval_time;
if let Some(record) = self.records.get_mut(&record_key) {
record.status = ArchivalStatus::Restoring {
started_at: Utc::now(),
estimated_completion,
};
record.updated_at = Utc::now();
}
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
if let Some(record) = self.records.get_mut(&record_key) {
record.status = ArchivalStatus::Active;
record.updated_at = Utc::now();
self.stats.total_restored += 1;
self.stats.current_archived_count = self.stats.current_archived_count.saturating_sub(1);
self.stats.current_archived_bytes = self
.stats
.current_archived_bytes
.saturating_sub(record.size);
}
tracing::info!(
bucket = bucket,
key = key,
estimated_completion = %estimated_completion,
"Object restore initiated"
);
Ok(format!("restore-{}", uuid::Uuid::new_v4()))
}
pub fn get_status(&self, bucket: &str, key: &str) -> Option<&ArchivalStatus> {
let record_key = format!("{}/{}", bucket, key);
self.records.get(&record_key).map(|r| &r.status)
}
pub fn list_archived(&self) -> Vec<&ArchivalRecord> {
self.records
.values()
.filter(|r| matches!(r.status, ArchivalStatus::Archived { .. }))
.collect()
}
pub fn stats(&self) -> &ArchivalStats {
&self.stats
}
pub fn scan_for_candidates(&self, objects: Vec<ArchivalMetadata>) -> Vec<ArchivalMetadata> {
objects
.into_iter()
.filter(|meta| self.should_archive(meta))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_archive_tier_properties() {
assert_eq!(ArchiveTier::Cold.retrieval_time(), Duration::hours(1));
assert_eq!(ArchiveTier::Glacier.retrieval_time(), Duration::hours(4));
assert_eq!(
ArchiveTier::DeepArchive.retrieval_time(),
Duration::hours(12)
);
assert_eq!(ArchiveTier::Tape.retrieval_time(), Duration::days(3));
assert!(ArchiveTier::Cold.cost_multiplier() > ArchiveTier::Glacier.cost_multiplier());
assert!(
ArchiveTier::Glacier.cost_multiplier() > ArchiveTier::DeepArchive.cost_multiplier()
);
assert!(!ArchiveTier::Cold.requires_restore());
assert!(ArchiveTier::Glacier.requires_restore());
}
#[test]
fn test_policy_builder() {
let policy = ArchivalPolicy::builder()
.age_threshold(Duration::days(30))
.size_threshold_mb(50)
.access_frequency_threshold(10)
.target_tier(ArchiveTier::DeepArchive)
.min_cost_savings_percent(25.0)
.auto_archive(true)
.build();
assert_eq!(policy.age_threshold, Duration::days(30));
assert_eq!(policy.size_threshold_mb, 50);
assert_eq!(policy.access_frequency_threshold, 10);
assert_eq!(policy.target_tier, ArchiveTier::DeepArchive);
assert_eq!(policy.min_cost_savings_percent, 25.0);
assert!(policy.auto_archive);
}
#[test]
fn test_should_archive_logic() {
let policy = ArchivalPolicy::builder()
.age_threshold(Duration::days(90))
.size_threshold_mb(100)
.access_frequency_threshold(5)
.min_cost_savings_percent(30.0)
.build();
let metadata = ArchivalMetadata {
key: "test-key".to_string(),
size_mb: 150,
age: Duration::days(100),
access_count: 2,
last_access: Utc::now() - Duration::days(50),
current_tier: "Standard".to_string(),
estimated_savings_percent: 40.0,
};
assert!(policy.should_archive(&metadata));
}
#[test]
fn test_should_not_archive_recent() {
let policy = ArchivalPolicy::default();
let metadata = ArchivalMetadata {
key: "test-key".to_string(),
size_mb: 150,
age: Duration::days(30), access_count: 2,
last_access: Utc::now() - Duration::days(10),
current_tier: "Standard".to_string(),
estimated_savings_percent: 40.0,
};
assert!(!policy.should_archive(&metadata));
}
#[tokio::test]
async fn test_archive_and_restore() {
let policy = ArchivalPolicy::default();
let mut manager = ArchivalManager::new(policy);
let archive_id = manager
.archive_object("test-bucket", "test-key", 1024 * 1024 * 100)
.await
.expect("Failed to archive");
assert!(!archive_id.is_empty());
assert_eq!(manager.stats().total_archived, 1);
assert_eq!(manager.stats().current_archived_count, 1);
let status = manager.get_status("test-bucket", "test-key");
assert!(matches!(status, Some(ArchivalStatus::Archived { .. })));
let restore_id = manager
.restore_object("test-bucket", "test-key")
.await
.expect("Failed to restore");
assert!(!restore_id.is_empty());
assert_eq!(manager.stats().total_restored, 1);
assert_eq!(manager.stats().current_archived_count, 0);
}
#[tokio::test]
async fn test_archival_stats() {
let policy = ArchivalPolicy::builder()
.target_tier(ArchiveTier::Glacier)
.build();
let mut manager = ArchivalManager::new(policy);
manager
.archive_object("bucket1", "key1", 1024 * 1024 * 100)
.await
.expect("Failed to archive");
manager
.archive_object("bucket1", "key2", 1024 * 1024 * 200)
.await
.expect("Failed to archive");
let stats = manager.stats();
assert_eq!(stats.total_archived, 2);
assert_eq!(stats.total_bytes_archived, 1024 * 1024 * 300);
assert!(stats.estimated_monthly_savings_usd > 0.0);
}
#[test]
fn test_scan_for_candidates() {
let policy = ArchivalPolicy::builder()
.age_threshold(Duration::days(90))
.size_threshold_mb(100)
.access_frequency_threshold(5)
.min_cost_savings_percent(30.0)
.build();
let manager = ArchivalManager::new(policy);
let objects = vec![
ArchivalMetadata {
key: "old-large".to_string(),
size_mb: 200,
age: Duration::days(120),
access_count: 2,
last_access: Utc::now() - Duration::days(60),
current_tier: "Standard".to_string(),
estimated_savings_percent: 45.0,
},
ArchivalMetadata {
key: "recent".to_string(),
size_mb: 200,
age: Duration::days(30),
access_count: 2,
last_access: Utc::now() - Duration::days(5),
current_tier: "Standard".to_string(),
estimated_savings_percent: 35.0,
},
ArchivalMetadata {
key: "frequently-accessed".to_string(),
size_mb: 200,
age: Duration::days(120),
access_count: 100,
last_access: Utc::now() - Duration::days(1),
current_tier: "Standard".to_string(),
estimated_savings_percent: 40.0,
},
];
let candidates = manager.scan_for_candidates(objects);
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].key, "old-large");
}
}