use crate::{Job, JobId, JobStatus, Result};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use uuid::Uuid;
pub type ArchivalPolicyId = String;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum ArchivalReason {
#[default]
Automatic,
Manual,
Compliance,
Maintenance,
}
impl ArchivalReason {
pub fn as_str(&self) -> &'static str {
match self {
Self::Automatic => "Automatic",
Self::Manual => "Manual",
Self::Compliance => "Compliance",
Self::Maintenance => "Maintenance",
}
}
pub fn parse_from_db(value: &str) -> Option<Self> {
let cleaned = value.trim().trim_matches('"');
[
Self::Automatic,
Self::Manual,
Self::Compliance,
Self::Maintenance,
]
.into_iter()
.find(|reason| reason.as_str().eq_ignore_ascii_case(cleaned))
}
}
impl std::fmt::Display for ArchivalReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ArchiveEvent {
JobArchived {
job_id: JobId,
queue: String,
reason: ArchivalReason,
},
JobRestored {
job_id: JobId,
queue: String,
restored_by: Option<String>,
},
BulkArchiveStarted {
operation_id: String,
estimated_jobs: u64,
},
BulkArchiveProgress {
operation_id: String,
jobs_processed: u64,
total: u64,
},
BulkArchiveCompleted {
operation_id: String,
stats: ArchivalStats,
},
JobsPurged {
count: u64,
older_than: DateTime<Utc>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArchivalPolicy {
pub archive_completed_after: Option<Duration>,
pub archive_failed_after: Option<Duration>,
pub archive_dead_after: Option<Duration>,
pub archive_timed_out_after: Option<Duration>,
pub purge_archived_after: Option<Duration>,
pub compress_payloads: bool,
pub batch_size: usize,
pub enabled: bool,
}
impl Default for ArchivalPolicy {
fn default() -> Self {
Self {
archive_completed_after: Some(Duration::days(30)),
archive_failed_after: Some(Duration::days(90)),
archive_dead_after: Some(Duration::days(90)),
archive_timed_out_after: Some(Duration::days(90)),
purge_archived_after: Some(Duration::days(365)),
compress_payloads: true,
batch_size: 1000,
enabled: true,
}
}
}
impl ArchivalPolicy {
pub fn new() -> Self {
Self::default()
}
pub fn archive_completed_after(mut self, duration: Duration) -> Self {
self.archive_completed_after = Some(duration);
self
}
pub fn archive_failed_after(mut self, duration: Duration) -> Self {
self.archive_failed_after = Some(duration);
self
}
pub fn archive_dead_after(mut self, duration: Duration) -> Self {
self.archive_dead_after = Some(duration);
self
}
pub fn archive_timed_out_after(mut self, duration: Duration) -> Self {
self.archive_timed_out_after = Some(duration);
self
}
pub fn purge_archived_after(mut self, duration: Duration) -> Self {
self.purge_archived_after = Some(duration);
self
}
pub fn compress_archived_payloads(mut self, compress: bool) -> Self {
self.compress_payloads = compress;
self
}
pub fn with_batch_size(mut self, batch_size: usize) -> Self {
self.batch_size = batch_size;
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn should_archive(&self, status: &JobStatus, age: Duration) -> bool {
if !self.enabled {
return false;
}
match status {
JobStatus::Completed => self
.archive_completed_after
.is_some_and(|threshold| age >= threshold),
JobStatus::Failed => self
.archive_failed_after
.is_some_and(|threshold| age >= threshold),
JobStatus::Dead => self
.archive_dead_after
.is_some_and(|threshold| age >= threshold),
JobStatus::TimedOut => self
.archive_timed_out_after
.is_some_and(|threshold| age >= threshold),
_ => false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArchivalConfig {
pub compression_level: u32,
pub verify_compression: bool,
}
impl Default for ArchivalConfig {
fn default() -> Self {
Self {
compression_level: 6, verify_compression: true,
}
}
}
impl ArchivalConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_compression_level(mut self, level: u32) -> Self {
self.compression_level = level.min(9);
self
}
pub fn with_compression_verification(mut self, verify: bool) -> Self {
self.verify_compression = verify;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArchivalStats {
pub jobs_archived: u64,
pub jobs_purged: u64,
pub bytes_archived: u64,
pub bytes_purged: u64,
pub compression_ratio: f64,
pub operation_duration: std::time::Duration,
pub last_run_at: DateTime<Utc>,
}
impl Default for ArchivalStats {
fn default() -> Self {
Self {
jobs_archived: 0,
jobs_purged: 0,
bytes_archived: 0,
bytes_purged: 0,
compression_ratio: 1.0,
operation_duration: std::time::Duration::from_secs(0),
last_run_at: Utc::now(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArchivedJob {
pub id: JobId,
pub queue_name: String,
pub status: JobStatus,
pub created_at: DateTime<Utc>,
pub archived_at: DateTime<Utc>,
pub archival_reason: ArchivalReason,
pub original_payload_size: Option<usize>,
pub payload_compressed: bool,
pub archived_by: Option<String>,
}
pub const MAX_ARCHIVAL_BATCHES_PER_OPERATION: usize = 10_000;
#[derive(Debug)]
pub struct JobArchiver<DB>
where
DB: sqlx::Database,
{
#[allow(dead_code)]
pool: sqlx::Pool<DB>,
policies: HashMap<String, ArchivalPolicy>,
default_policy: ArchivalPolicy,
config: ArchivalConfig,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ScheduledArchivalPass {
pub jobs_archived: u64,
pub jobs_purged: u64,
pub stopped_early: bool,
}
impl<DB> JobArchiver<DB>
where
DB: sqlx::Database,
{
pub fn new(pool: sqlx::Pool<DB>) -> Self {
Self {
pool,
policies: HashMap::new(),
default_policy: ArchivalPolicy::default(),
config: ArchivalConfig::default(),
}
}
pub fn set_default_policy(&mut self, policy: ArchivalPolicy) {
self.default_policy = policy;
}
pub fn with_default_policy(mut self, policy: ArchivalPolicy) -> Self {
self.default_policy = policy;
self
}
pub fn with_config(mut self, config: ArchivalConfig) -> Self {
self.config = config;
self
}
pub fn default_policy(&self) -> &ArchivalPolicy {
&self.default_policy
}
pub async fn run_scheduled_pass<Q, S>(
&self,
queue: &Q,
archived_by: Option<&str>,
should_stop: S,
) -> Result<ScheduledArchivalPass>
where
Q: crate::queue::DatabaseQueue,
S: Fn() -> bool,
{
let mut pass = ScheduledArchivalPass::default();
if self.policies.is_empty() {
let policy = &self.default_policy;
if policy.enabled {
self.scheduled_pass_for(queue, None, policy, archived_by, &should_stop, &mut pass)
.await?;
}
return Ok(pass);
}
let mut plan: Vec<(String, &ArchivalPolicy)> = self
.policies
.iter()
.map(|(name, policy)| (name.clone(), policy))
.collect();
if self.default_policy.enabled {
for name in queue.list_queue_names().await? {
if !self.policies.contains_key(&name) {
plan.push((name, &self.default_policy));
}
}
}
plan.sort_by(|a, b| a.0.cmp(&b.0));
for (name, policy) in plan {
if !policy.enabled {
continue;
}
if should_stop() {
pass.stopped_early = true;
break;
}
self.scheduled_pass_for(
queue,
Some(&name),
policy,
archived_by,
&should_stop,
&mut pass,
)
.await?;
if pass.stopped_early {
break;
}
}
Ok(pass)
}
async fn scheduled_pass_for<Q, S>(
&self,
queue: &Q,
queue_name: Option<&str>,
policy: &ArchivalPolicy,
archived_by: Option<&str>,
should_stop: &S,
pass: &mut ScheduledArchivalPass,
) -> Result<()>
where
Q: crate::queue::DatabaseQueue,
S: Fn() -> bool,
{
let (stats, stopped) = self
.archive_in_batches(
queue,
queue_name,
policy,
ArchivalReason::Automatic,
archived_by,
|_| {},
should_stop,
)
.await?;
pass.jobs_archived += stats.jobs_archived;
if let Some(after) = policy.purge_archived_after {
if stopped || should_stop() {
pass.stopped_early = true;
return Ok(());
}
if let Some(older_than) = age_threshold(Utc::now(), after) {
pass.jobs_purged += match queue_name {
Some(name) => queue.purge_archived_jobs_in_queue(name, older_than).await?,
None => queue.purge_archived_jobs(older_than).await?,
};
}
}
pass.stopped_early |= stopped;
Ok(())
}
pub fn set_policy(&mut self, queue_name: impl Into<String>, policy: ArchivalPolicy) {
self.policies.insert(queue_name.into(), policy);
}
pub fn get_policy(&self, queue_name: &str) -> Option<&ArchivalPolicy> {
self.policies.get(queue_name)
}
pub fn remove_policy(&mut self, queue_name: &str) -> Option<ArchivalPolicy> {
self.policies.remove(queue_name)
}
pub fn set_config(&mut self, config: ArchivalConfig) {
self.config = config;
}
pub fn get_config(&self) -> &ArchivalConfig {
&self.config
}
#[cfg_attr(feature = "postgres", doc = "```rust,no_run")]
#[cfg_attr(not(feature = "postgres"), doc = "```rust,ignore")]
pub async fn archive_jobs_with_progress<Q>(
&self,
queue: &Q,
queue_name: Option<&str>,
reason: ArchivalReason,
archived_by: Option<&str>,
progress_callback: Option<Box<dyn Fn(u64, u64) + Send + Sync>>,
) -> Result<(String, ArchivalStats)>
where
Q: crate::queue::DatabaseQueue,
{
let operation_id = Uuid::new_v4().to_string();
let policy = self.policy_for(queue_name);
let estimated_jobs = self
.estimate_archival_jobs(queue, queue_name, policy)
.await?;
if let Some(callback) = &progress_callback {
callback(0, estimated_jobs);
}
let (stats, _) = self
.archive_in_batches(
queue,
queue_name,
policy,
reason,
archived_by,
|processed| {
if let Some(callback) = &progress_callback {
callback(processed, estimated_jobs.max(processed));
}
},
&|| false,
)
.await?;
if stats.jobs_archived == 0
&& let Some(callback) = &progress_callback
{
callback(0, estimated_jobs);
}
Ok((operation_id, stats))
}
pub async fn archive_jobs_with_events<Q, F>(
&self,
queue: &Q,
queue_name: Option<&str>,
reason: ArchivalReason,
archived_by: Option<&str>,
event_publisher: F,
) -> Result<(String, ArchivalStats)>
where
Q: crate::queue::DatabaseQueue,
F: Fn(ArchiveEvent) + Send + Sync,
{
let operation_id = Uuid::new_v4().to_string();
let policy = self.policy_for(queue_name);
let estimated_jobs = self
.estimate_archival_jobs(queue, queue_name, policy)
.await?;
event_publisher(ArchiveEvent::BulkArchiveStarted {
operation_id: operation_id.clone(),
estimated_jobs,
});
let mut pending_progress: Option<u64> = None;
let (stats, _) = self
.archive_in_batches(
queue,
queue_name,
policy,
reason,
archived_by,
|processed| {
if let Some(previous) = pending_progress.replace(processed) {
event_publisher(ArchiveEvent::BulkArchiveProgress {
operation_id: operation_id.clone(),
jobs_processed: previous,
total: estimated_jobs.max(previous),
});
}
},
&|| false,
)
.await?;
event_publisher(ArchiveEvent::BulkArchiveCompleted {
operation_id: operation_id.clone(),
stats: stats.clone(),
});
Ok((operation_id, stats))
}
fn policy_for(&self, queue_name: Option<&str>) -> &ArchivalPolicy {
queue_name
.and_then(|name| self.policies.get(name))
.unwrap_or(&self.default_policy)
}
#[allow(clippy::too_many_arguments)]
async fn archive_in_batches<Q, F, S>(
&self,
queue: &Q,
queue_name: Option<&str>,
policy: &ArchivalPolicy,
reason: ArchivalReason,
archived_by: Option<&str>,
mut on_batch: F,
should_stop: &S,
) -> Result<(ArchivalStats, bool)>
where
Q: crate::queue::DatabaseQueue,
F: FnMut(u64),
S: Fn() -> bool,
{
let started = std::time::Instant::now();
let mut total = ArchivalStats::default();
let mut weighted_ratio = 0.0;
let mut stopped = false;
for _ in 0..MAX_ARCHIVAL_BATCHES_PER_OPERATION {
if should_stop() {
stopped = true;
break;
}
let batch = queue
.archive_jobs(
queue_name,
policy,
&self.config,
reason.clone(),
archived_by,
)
.await?;
if batch.jobs_archived == 0 {
break;
}
total.jobs_archived += batch.jobs_archived;
total.bytes_archived += batch.bytes_archived;
weighted_ratio += batch.compression_ratio * batch.jobs_archived as f64;
total.last_run_at = batch.last_run_at;
on_batch(total.jobs_archived);
if (batch.jobs_archived as usize) < policy.batch_size {
break;
}
}
if total.jobs_archived > 0 {
total.compression_ratio = weighted_ratio / total.jobs_archived as f64;
}
total.operation_duration = started.elapsed();
Ok((total, stopped))
}
async fn estimate_archival_jobs<Q>(
&self,
queue: &Q,
queue_name: Option<&str>,
policy: &ArchivalPolicy,
) -> Result<u64>
where
Q: crate::queue::DatabaseQueue,
{
if let Some(queue_name) = queue_name {
let stats = queue.get_queue_stats(queue_name).await?;
let mut estimate = 0u64;
if policy.archive_completed_after.is_some() {
estimate += stats.completed_count;
}
if policy.archive_failed_after.is_some() {
estimate += stats.statistics.failed;
}
if policy.archive_dead_after.is_some() {
estimate += stats.dead_count;
}
if policy.archive_timed_out_after.is_some() {
estimate += stats.timed_out_count;
}
Ok(estimate)
} else {
let base_estimate = if policy.archive_completed_after.is_some()
&& policy.archive_failed_after.is_some()
{
2000 } else if policy.archive_completed_after.is_some()
|| policy.archive_failed_after.is_some()
{
1000 } else {
100 };
Ok(base_estimate)
}
}
}
pub trait ArchivalOperations {
fn archive_jobs(
&self,
queue_name: Option<&str>,
policy: &ArchivalPolicy,
config: &ArchivalConfig,
reason: ArchivalReason,
archived_by: Option<&str>,
) -> impl std::future::Future<Output = Result<ArchivalStats>> + Send;
fn restore_job(&self, job_id: JobId) -> impl std::future::Future<Output = Result<Job>> + Send;
fn list_archived_jobs(
&self,
queue_name: Option<&str>,
limit: Option<u32>,
offset: Option<u32>,
) -> impl std::future::Future<Output = Result<Vec<ArchivedJob>>> + Send;
fn purge_archived_jobs(
&self,
older_than: DateTime<Utc>,
) -> impl std::future::Future<Output = Result<u64>> + Send;
fn get_archival_stats(
&self,
queue_name: Option<&str>,
) -> impl std::future::Future<Output = Result<ArchivalStats>> + Send;
}
#[cfg_attr(not(any(feature = "postgres", feature = "mysql")), allow(dead_code))]
pub(crate) fn encode_archived_payload(
payload: &serde_json::Value,
policy: &ArchivalPolicy,
config: &ArchivalConfig,
) -> Result<(Vec<u8>, bool, usize)> {
use flate2::Compression;
use flate2::write::GzEncoder;
use std::io::Write;
let payload_json = serde_json::to_vec(payload)?;
let original_size = payload_json.len();
if !policy.compress_payloads {
return Ok((payload_json, false, original_size));
}
let mut encoder = GzEncoder::new(Vec::new(), Compression::new(config.compression_level));
encoder.write_all(&payload_json)?;
let compressed = encoder.finish()?;
if compressed.len() < original_size {
if config.verify_compression {
verify_compressed_payload(&compressed, &payload_json)?;
}
Ok((compressed, true, original_size))
} else {
Ok((payload_json, false, original_size))
}
}
#[cfg_attr(not(any(feature = "postgres", feature = "mysql")), allow(dead_code))]
fn verify_compressed_payload(compressed: &[u8], original: &[u8]) -> Result<()> {
use flate2::read::GzDecoder;
use std::io::Read;
let mut decompressed = Vec::with_capacity(original.len());
let round_trips = GzDecoder::new(compressed)
.read_to_end(&mut decompressed)
.is_ok()
&& decompressed == original;
if round_trips {
Ok(())
} else {
Err(crate::HammerworkError::Archive {
message: "compressed payload failed verification: it does not decompress to the \
original payload"
.to_string(),
})
}
}
#[cfg_attr(not(any(feature = "postgres", feature = "mysql")), allow(dead_code))]
pub(crate) fn decode_archived_payload(
stored: &[u8],
is_compressed: bool,
) -> Result<serde_json::Value> {
use flate2::read::GzDecoder;
use std::io::Read;
if is_compressed {
let mut decompressed = Vec::new();
GzDecoder::new(stored).read_to_end(&mut decompressed)?;
Ok(serde_json::from_slice(&decompressed)?)
} else {
Ok(serde_json::from_slice(stored)?)
}
}
#[cfg_attr(not(any(feature = "postgres", feature = "mysql")), allow(dead_code))]
pub(crate) fn archival_candidates(
policy: &ArchivalPolicy,
) -> [(Option<Duration>, &'static str, &'static str); 4] {
[
(policy.archive_completed_after, "Completed", "completed_at"),
(policy.archive_failed_after, "Failed", "failed_at"),
(policy.archive_dead_after, "Dead", "failed_at"),
(policy.archive_timed_out_after, "TimedOut", "timed_out_at"),
]
}
pub(crate) fn age_threshold(now: DateTime<Utc>, age: Duration) -> Option<DateTime<Utc>> {
if age <= Duration::zero() {
return Some(now);
}
now.checked_sub_signed(age)
.filter(|threshold| *threshold >= DateTime::<Utc>::UNIX_EPOCH)
}
#[cfg_attr(not(any(feature = "postgres", feature = "mysql")), allow(dead_code))]
pub(crate) fn reset_for_restore(mut job: Job) -> Job {
job.status = JobStatus::Pending;
job.attempts = 0;
job.scheduled_at = Utc::now();
job.started_at = None;
job.completed_at = None;
job.failed_at = None;
job.timed_out_at = None;
job.error_message = None;
job.result_data = None;
job.result_stored_at = None;
job.result_expires_at = None;
job
}
#[cfg_attr(not(any(feature = "postgres", feature = "mysql")), allow(dead_code))]
pub(crate) fn parse_archived_status(value: &str) -> JobStatus {
match value.trim_matches('"') {
"Pending" => JobStatus::Pending,
"Running" => JobStatus::Running,
"Completed" => JobStatus::Completed,
"Failed" => JobStatus::Failed,
"Dead" => JobStatus::Dead,
"TimedOut" => JobStatus::TimedOut,
"Retrying" => JobStatus::Retrying,
"Archived" => JobStatus::Archived,
_ => JobStatus::Dead, }
}
#[cfg_attr(not(any(feature = "postgres", feature = "mysql")), allow(dead_code))]
pub(crate) fn parse_archived_dependency_status(
value: Option<&str>,
) -> crate::workflow::DependencyStatus {
value
.and_then(|s| {
crate::workflow::DependencyStatus::parse_from_db(&s.trim_matches('"').to_lowercase())
.ok()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn age_threshold_handles_extreme_ages() {
let now = Utc::now();
assert_eq!(
age_threshold(now, Duration::days(1)),
Some(now - Duration::days(1))
);
assert_eq!(age_threshold(now, Duration::zero()), Some(now));
assert_eq!(age_threshold(now, Duration::days(-3)), Some(now));
assert_eq!(age_threshold(now, Duration::MIN), Some(now));
assert_eq!(age_threshold(now, Duration::MAX), None);
assert_eq!(age_threshold(now, Duration::days(365 * 100)), None);
}
#[cfg(all(feature = "test", feature = "postgres"))]
#[tokio::test]
async fn scheduled_pass_with_huge_ages_does_not_panic() {
let queue = crate::queue::test::TestQueue::new();
let pool = sqlx::PgPool::connect_lazy("postgres://localhost/unused").unwrap();
let mut archiver = JobArchiver::new(pool);
archiver.set_default_policy(
ArchivalPolicy::new()
.archive_completed_after(Duration::MAX)
.purge_archived_after(Duration::MAX)
.enabled(true),
);
let pass = archiver
.run_scheduled_pass(&queue, None, || false)
.await
.unwrap();
assert_eq!(pass.jobs_archived, 0);
assert_eq!(pass.jobs_purged, 0);
}
#[test]
fn test_archival_reason_db_round_trip() {
for reason in [
ArchivalReason::Automatic,
ArchivalReason::Manual,
ArchivalReason::Compliance,
ArchivalReason::Maintenance,
] {
assert_eq!(reason.to_string(), reason.as_str());
assert_eq!(
ArchivalReason::parse_from_db(reason.as_str()),
Some(reason.clone())
);
let quoted = serde_json::to_string(&reason).unwrap();
assert_eq!(ArchivalReason::parse_from_db("ed), Some(reason));
}
assert_eq!(
ArchivalReason::parse_from_db("manual"),
Some(ArchivalReason::Manual)
);
assert_eq!(ArchivalReason::parse_from_db(""), None);
assert_eq!(ArchivalReason::parse_from_db("Unknown"), None);
}
#[test]
fn test_archived_payload_round_trip() {
let payload = serde_json::json!({"data": "x".repeat(500)});
let config = ArchivalConfig::new();
let compressing = ArchivalPolicy::new().compress_archived_payloads(true);
let (stored, compressed, original) =
encode_archived_payload(&payload, &compressing, &config).unwrap();
assert!(compressed);
assert!(stored.len() < original);
assert_eq!(
decode_archived_payload(&stored, compressed).unwrap(),
payload
);
let plain = ArchivalPolicy::new().compress_archived_payloads(false);
let (stored, compressed, original) =
encode_archived_payload(&payload, &plain, &config).unwrap();
assert!(!compressed);
assert_eq!(stored.len(), original);
assert_eq!(
decode_archived_payload(&stored, compressed).unwrap(),
payload
);
}
#[test]
fn test_parse_archived_columns() {
assert_eq!(parse_archived_status("Completed"), JobStatus::Completed);
assert_eq!(parse_archived_status("\"TimedOut\""), JobStatus::TimedOut);
assert_eq!(
parse_archived_dependency_status(Some("satisfied")),
crate::workflow::DependencyStatus::Satisfied
);
assert_eq!(
parse_archived_dependency_status(Some("Pending")),
crate::workflow::DependencyStatus::None
);
assert_eq!(
parse_archived_dependency_status(None),
crate::workflow::DependencyStatus::None
);
}
#[test]
fn test_archival_policy_default() {
let policy = ArchivalPolicy::default();
assert!(policy.enabled);
assert!(policy.compress_payloads);
assert_eq!(policy.batch_size, 1000);
assert!(policy.archive_completed_after.is_some());
}
#[test]
fn test_archival_policy_builder() {
let policy = ArchivalPolicy::new()
.archive_completed_after(Duration::days(7))
.archive_failed_after(Duration::days(30))
.purge_archived_after(Duration::days(365))
.compress_archived_payloads(true)
.with_batch_size(500)
.enabled(true);
assert_eq!(policy.archive_completed_after, Some(Duration::days(7)));
assert_eq!(policy.archive_failed_after, Some(Duration::days(30)));
assert_eq!(policy.purge_archived_after, Some(Duration::days(365)));
assert!(policy.compress_payloads);
assert_eq!(policy.batch_size, 500);
assert!(policy.enabled);
}
#[test]
fn test_should_archive() {
let policy = ArchivalPolicy::new()
.archive_completed_after(Duration::days(7))
.archive_failed_after(Duration::days(30));
assert!(policy.should_archive(&JobStatus::Completed, Duration::days(8)));
assert!(!policy.should_archive(&JobStatus::Completed, Duration::days(6)));
assert!(policy.should_archive(&JobStatus::Failed, Duration::days(31)));
assert!(!policy.should_archive(&JobStatus::Failed, Duration::days(29)));
assert!(!policy.should_archive(&JobStatus::Pending, Duration::days(100)));
assert!(!policy.should_archive(&JobStatus::Running, Duration::days(100)));
}
#[test]
fn test_should_archive_disabled_policy() {
let policy = ArchivalPolicy::new()
.archive_completed_after(Duration::days(1))
.enabled(false);
assert!(!policy.should_archive(&JobStatus::Completed, Duration::days(10)));
}
#[test]
fn test_archival_config_default() {
let config = ArchivalConfig::default();
assert_eq!(config.compression_level, 6);
assert!(config.verify_compression);
}
#[test]
fn test_archival_config_builder() {
let config = ArchivalConfig::new()
.with_compression_level(9)
.with_compression_verification(false);
assert_eq!(config.compression_level, 9);
assert!(!config.verify_compression);
}
#[test]
fn test_archival_config_ignores_removed_max_payload_size() {
let config: ArchivalConfig = serde_json::from_value(serde_json::json!({
"compression_level": 3,
"max_payload_size": 1024,
"verify_compression": false
}))
.unwrap();
assert_eq!(config.compression_level, 3);
assert!(!config.verify_compression);
}
#[test]
fn test_verify_compressed_payload() {
let original = serde_json::to_vec(&serde_json::json!({"data": "y".repeat(300)})).unwrap();
let payload: serde_json::Value = serde_json::from_slice(&original).unwrap();
let policy = ArchivalPolicy::new().compress_archived_payloads(true);
for verify in [true, false] {
let config = ArchivalConfig::new().with_compression_verification(verify);
let (stored, compressed, _) =
encode_archived_payload(&payload, &policy, &config).unwrap();
assert!(compressed);
assert!(verify_compressed_payload(&stored, &original).is_ok());
}
let (stored, _, _) =
encode_archived_payload(&payload, &policy, &ArchivalConfig::new()).unwrap();
let mut corrupt = stored.clone();
let middle = corrupt.len() / 2;
corrupt[middle] ^= 0xff;
assert!(verify_compressed_payload(&corrupt, &original).is_err());
assert!(verify_compressed_payload(&stored[..stored.len() - 4], &original).is_err());
let err = verify_compressed_payload(&stored, b"{}").unwrap_err();
assert!(err.to_string().contains("failed verification"), "{err}");
}
#[test]
fn test_archival_reason_display() {
assert_eq!(ArchivalReason::Automatic.to_string(), "Automatic");
assert_eq!(ArchivalReason::Manual.to_string(), "Manual");
assert_eq!(ArchivalReason::Compliance.to_string(), "Compliance");
assert_eq!(ArchivalReason::Maintenance.to_string(), "Maintenance");
}
}