#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
use std::time::Duration;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::AutumnResult;
use crate::config::{AutumnConfig, RetentionConfig};
use crate::gdpr::{ErasureStrategy, GdprRegistry};
use crate::state::AppState;
use crate::task::{Schedule, TaskCoordination, TaskInfo};
use std::fmt::Write as _;
pub const RETENTION_SWEEP_TASK_NAME: &str = "autumn-retention-sweep";
pub const RETENTION_AUDIT_ACTION: &str = "retention.sweep";
pub const RETENTION_AUDIT_ACTOR: &str = "autumn:retention";
#[cfg(feature = "db")]
const SWEEP_BATCH_ROWS: i64 = 500;
#[cfg(feature = "db")]
const SWEEP_MAX_BATCHES: usize = 1_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RetentionEnforcement {
Sweep,
BackendTtl,
ArchiveRewrite,
StorePrune,
}
impl std::fmt::Display for RetentionEnforcement {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Sweep => "sweep",
Self::BackendTtl => "backend ttl",
Self::ArchiveRewrite => "archive rewrite",
Self::StorePrune => "store prune",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RetentionDataset {
JobHistory,
CommitHooks,
JobTracking,
Idempotency,
ExperimentAssignments,
WebhookReplay,
Sessions,
AuditArchives,
CustomDomains,
}
pub const RETENTION_DATASETS: [RetentionDataset; 9] = [
RetentionDataset::JobHistory,
RetentionDataset::CommitHooks,
RetentionDataset::JobTracking,
RetentionDataset::Idempotency,
RetentionDataset::ExperimentAssignments,
RetentionDataset::WebhookReplay,
RetentionDataset::Sessions,
RetentionDataset::AuditArchives,
RetentionDataset::CustomDomains,
];
impl RetentionDataset {
#[must_use]
pub const fn key(self) -> &'static str {
match self {
Self::JobHistory => "job_history",
Self::CommitHooks => "commit_hooks",
Self::JobTracking => "job_tracking",
Self::Idempotency => "idempotency",
Self::ExperimentAssignments => "experiment_assignments",
Self::WebhookReplay => "webhook_replay",
Self::Sessions => "sessions",
Self::AuditArchives => "audit_archives",
Self::CustomDomains => "custom_domains",
}
}
#[must_use]
pub fn from_key(key: &str) -> Option<Self> {
RETENTION_DATASETS
.into_iter()
.find(|dataset| dataset.key() == key)
}
#[must_use]
pub const fn description(self) -> &'static str {
match self {
Self::JobHistory => "Finished job rows (completed/failed/discarded) in autumn_jobs",
Self::CommitHooks => {
"Finished #[after_commit] hook rows in autumn_repository_commit_hooks"
}
Self::JobTracking => "Tracked-job progress/result records in autumn_job_tracking",
Self::Idempotency => "Stored Idempotency-Key responses",
Self::ExperimentAssignments => {
"Sticky experiment assignments in autumn_experiment_assignments"
}
Self::WebhookReplay => "Inbound webhook replay markers",
Self::Sessions => "Server-side session records",
Self::AuditArchives => "Entries in the JSONL audit archive",
Self::CustomDomains => "Tenant custom-domain registry records and their certificates",
}
}
#[must_use]
pub const fn table(self) -> Option<&'static str> {
match self {
Self::JobHistory => Some("autumn_jobs"),
Self::CommitHooks => Some("autumn_repository_commit_hooks"),
Self::JobTracking => Some("autumn_job_tracking"),
Self::ExperimentAssignments => Some("autumn_experiment_assignments"),
Self::Idempotency
| Self::WebhookReplay
| Self::Sessions
| Self::AuditArchives
| Self::CustomDomains => None,
}
}
#[must_use]
pub fn table_for(self, config: &AutumnConfig) -> Option<&'static str> {
if matches!(self, Self::JobHistory | Self::JobTracking)
&& !matches!(config.jobs.backend.as_str(), "postgres" | "sqlite")
{
return None;
}
self.table()
}
#[must_use]
pub fn enforcement_for(self, config: &AutumnConfig) -> RetentionEnforcement {
if self == Self::JobTracking && config.jobs.backend != "postgres" {
return RetentionEnforcement::BackendTtl;
}
self.enforcement()
}
#[must_use]
pub const fn enforcement(self) -> RetentionEnforcement {
match self {
Self::JobHistory
| Self::CommitHooks
| Self::JobTracking
| Self::ExperimentAssignments => RetentionEnforcement::Sweep,
Self::Idempotency | Self::WebhookReplay | Self::Sessions => {
RetentionEnforcement::BackendTtl
}
Self::AuditArchives => RetentionEnforcement::ArchiveRewrite,
Self::CustomDomains => RetentionEnforcement::StorePrune,
}
}
#[must_use]
pub const fn default_behavior(self) -> &'static str {
match self {
Self::JobHistory
| Self::CommitHooks
| Self::ExperimentAssignments
| Self::AuditArchives => "forever",
Self::CustomDomains => "forever (until the app offboards the domain)",
Self::JobTracking => "jobs.tracking.ttl_secs (24h by default)",
Self::Idempotency => "idempotency.ttl_secs (24h by default)",
Self::WebhookReplay => "the endpoint's replay_window_secs (24h by default)",
Self::Sessions => "session.max_age_secs (the session cookie's lifetime)",
}
}
fn subsystem_ttl(self, config: &AutumnConfig) -> Option<(&'static str, Duration)> {
match self {
Self::JobTracking => Some((
"jobs.tracking.ttl_secs",
Duration::from_secs(config.jobs.tracking.ttl_secs),
)),
Self::Idempotency => Some((
"idempotency.ttl_secs",
Duration::from_secs(config.idempotency.ttl_secs),
)),
Self::Sessions => Some((
"session.max_age_secs",
Duration::from_secs(config.session.max_age_secs),
)),
Self::WebhookReplay => {
let longest = config
.security
.webhooks
.endpoints
.iter()
.filter(|endpoint| endpoint.replay_protection)
.map(|endpoint| endpoint.replay_window_secs)
.max();
longest.map(|secs| {
(
"security.webhooks.endpoints[].replay_window_secs",
Duration::from_secs(secs),
)
})
}
Self::JobHistory
| Self::CommitHooks
| Self::ExperimentAssignments
| Self::AuditArchives
| Self::CustomDomains => None,
}
}
}
impl std::fmt::Display for RetentionDataset {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.key())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RetentionSource {
Unset,
RetentionSection,
SubsystemTtl(&'static str),
}
impl std::fmt::Display for RetentionSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unset => f.write_str("unset"),
Self::RetentionSection => f.write_str("[retention]"),
Self::SubsystemTtl(key) => write!(f, "{key}"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EffectiveRetention {
pub dataset: RetentionDataset,
pub window: Option<Duration>,
pub source: RetentionSource,
}
#[must_use]
pub fn effective_retention(config: &AutumnConfig, dataset: RetentionDataset) -> EffectiveRetention {
let policy = config.retention.window(dataset.key());
let subsystem = dataset.subsystem_ttl(config);
let (window, source) = match (policy, subsystem) {
(None, None) => (None, RetentionSource::Unset),
(Some(policy), None) => (Some(policy), RetentionSource::RetentionSection),
(None, Some((key, ttl))) => (Some(ttl), RetentionSource::SubsystemTtl(key)),
(Some(policy), Some((key, ttl))) => {
if policy <= ttl {
(Some(policy), RetentionSource::RetentionSection)
} else {
(Some(ttl), RetentionSource::SubsystemTtl(key))
}
}
};
EffectiveRetention {
dataset,
window,
source,
}
}
#[must_use]
pub fn legal_hold_for(
dataset: RetentionDataset,
registry: Option<&GdprRegistry>,
) -> Option<String> {
table_legal_hold(dataset.table()?, registry)
}
#[must_use]
pub fn table_legal_hold(table: &str, registry: Option<&GdprRegistry>) -> Option<String> {
let registration = registry?.get(table)?;
if registration.erasure_strategy != ErasureStrategy::Retain {
return None;
}
Some(
registration
.retain_reason
.clone()
.unwrap_or_else(|| format!("{table} is registered under GDPR legal hold")),
)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetentionDatasetReport {
pub dataset: String,
pub description: String,
pub enforcement: String,
pub window_secs: Option<u64>,
pub source: String,
pub cutoff: Option<String>,
pub eligible_rows: Option<u64>,
pub rows_removed: u64,
#[serde(default)]
pub truncated: bool,
pub dry_run: bool,
pub skipped: Option<String>,
pub duration_ms: u64,
pub error: Option<String>,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct RetentionRunOptions<'a> {
pub dry_run: bool,
pub dataset: Option<&'a str>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RetentionScope {
#[default]
All,
Shared,
PerReplica,
}
impl RetentionScope {
const fn covers(self, enforcement: RetentionEnforcement) -> bool {
let per_replica = matches!(enforcement, RetentionEnforcement::ArchiveRewrite);
match self {
Self::All => true,
Self::Shared => !per_replica,
Self::PerReplica => per_replica,
}
}
}
pub async fn run_retention(
state: &AppState,
options: &RetentionRunOptions<'_>,
) -> AutumnResult<Vec<RetentionDatasetReport>> {
run_retention_for(state, options, RetentionScope::All).await
}
pub async fn run_retention_for(
state: &AppState,
options: &RetentionRunOptions<'_>,
scope: RetentionScope,
) -> AutumnResult<Vec<RetentionDatasetReport>> {
let datasets = resolve_datasets(options.dataset)?;
let config = state.config_arc();
let mut reports = Vec::with_capacity(datasets.len());
for dataset in datasets
.into_iter()
.filter(|dataset| scope.covers(dataset.enforcement()))
{
let mut report = run_one_dataset(state, &config, dataset, options.dry_run).await;
audit_sweep(state, &mut report).await;
reports.push(report);
}
Ok(reports)
}
fn resolve_datasets(filter: Option<&str>) -> AutumnResult<Vec<RetentionDataset>> {
let Some(filter) = filter else {
return Ok(RETENTION_DATASETS.to_vec());
};
RetentionDataset::from_key(filter).map_or_else(
|| {
let known: Vec<&str> = RETENTION_DATASETS.iter().map(|d| d.key()).collect();
Err(crate::AutumnError::not_found_msg(format!(
"unknown retention dataset {filter:?}; known datasets: {}",
known.join(", ")
)))
},
|dataset| Ok(vec![dataset]),
)
}
async fn run_one_dataset(
state: &AppState,
config: &AutumnConfig,
dataset: RetentionDataset,
dry_run: bool,
) -> RetentionDatasetReport {
let started = crate::time::ambient_monotonic();
let effective = effective_retention(config, dataset);
let cutoff = effective
.window
.and_then(|window| chrono::Duration::from_std(window).ok())
.map(|window| crate::time::ambient_now() - window);
let mut report = RetentionDatasetReport {
dataset: dataset.key().to_owned(),
description: dataset.description().to_owned(),
enforcement: dataset.enforcement_for(config).to_string(),
window_secs: effective.window.map(|w| w.as_secs()),
source: effective.source.to_string(),
cutoff: cutoff.map(|c| c.to_rfc3339()),
eligible_rows: None,
rows_removed: 0,
truncated: false,
dry_run,
skipped: None,
duration_ms: 0,
error: None,
};
let (Some(window), Some(cutoff)) = (effective.window, cutoff) else {
report.skipped = Some("no retention window configured".to_owned());
report.duration_ms = elapsed_ms(started);
return report;
};
if dataset == RetentionDataset::JobHistory && dataset.table_for(config).is_none() {
report.skipped = Some(format!(
"NOT enforced: jobs.backend = {:?} keeps finished jobs outside autumn_jobs, \
so this sweep cannot reach them",
config.jobs.backend
));
report.duration_ms = elapsed_ms(started);
return report;
}
let registry = state.extension::<GdprRegistry>();
if let Some(reason) = legal_hold_for(dataset, registry.as_deref()) {
report.skipped = Some(if dataset.table_for(config).is_some() {
format!("legal hold: {reason}")
} else {
format!(
"legal hold: {reason}. NOT preserved: jobs.backend = {:?} stores these \
records outside the held table, and the window shortens their TTL",
config.jobs.backend
)
});
report.duration_ms = elapsed_ms(started);
return report;
}
match dataset.enforcement_for(config) {
RetentionEnforcement::Sweep => {
apply_sweep(state, dataset, window, dry_run, &mut report).await;
}
RetentionEnforcement::BackendTtl => {
report.skipped = Some(backend_ttl_note(
dataset,
config,
effective.window.map_or(0, |window| window.as_secs()),
registry.as_deref(),
));
}
RetentionEnforcement::ArchiveRewrite => {
apply_archive_purge(state, cutoff, dry_run, &mut report).await;
}
RetentionEnforcement::StorePrune => {
apply_custom_domain_prune(state, cutoff, dry_run, &mut report).await;
}
}
report.duration_ms = elapsed_ms(started);
report
}
fn backend_ttl_note(
dataset: RetentionDataset,
config: &AutumnConfig,
window_secs: u64,
registry: Option<&GdprRegistry>,
) -> String {
if dataset == RetentionDataset::Sessions
&& config.session.backend == crate::session::SessionBackend::Memory
{
return format!(
"NOT enforced: the in-memory session store keeps records for the life of the \
process, so this {window_secs}s window bounds only the session cookie. Set \
session.backend = \"redis\" (or install a SessionStore that applies the \
window) to bound the server-side records"
);
}
let mut note = format!(
"enforced at write time: records are stored with a TTL capped at {window_secs}s by \
the backend, not deleted by this sweep. Records written before this window was set \
keep their original TTL until it elapses"
);
let held = (dataset == RetentionDataset::Idempotency
&& config.idempotency.backend == crate::config::IdempotencyBackend::Database)
.then(|| table_legal_hold("autumn_idempotency_keys", registry))
.flatten();
if let Some(reason) = held {
let _ = write!(
note,
". NOT removed: autumn_idempotency_keys is under legal hold: {reason}. The \
store keeps expired rows until the hold ends"
);
} else if evicts_lazily(dataset, config) {
note.push_str(
". With the built-in store, expired records are not served. The store \
removes them only on later writes. A quiet process can keep them past the \
window. Use redis to remove them on time",
);
}
if dataset == RetentionDataset::Sessions {
note.push_str(
", and a custom SessionStore installed with AppBuilder::with_session_store must \
apply the window itself",
);
}
note
}
fn evicts_lazily(dataset: RetentionDataset, config: &AutumnConfig) -> bool {
match dataset {
RetentionDataset::Idempotency => {
config.idempotency.backend != crate::config::IdempotencyBackend::Redis
}
RetentionDataset::WebhookReplay => {
config.security.webhooks.replay.backend == crate::webhook::WebhookReplayBackend::Memory
}
RetentionDataset::JobTracking => !matches!(
config.jobs.backend.as_str(),
"postgres" | "redis" | "sqlite"
),
_ => false,
}
}
fn elapsed_ms(started: crate::time::MonotonicInstant) -> u64 {
u64::try_from(
crate::time::ambient_monotonic()
.saturating_duration_since(started)
.as_millis(),
)
.unwrap_or(u64::MAX)
}
async fn apply_custom_domain_prune(
state: &AppState,
cutoff: DateTime<Utc>,
dry_run: bool,
report: &mut RetentionDatasetReport,
) {
let Some(pruner) =
state.extension::<std::sync::Arc<dyn crate::custom_domain::CustomDomainPruner>>()
else {
report.skipped =
Some("custom domains are not enabled ([server.tls.acme.custom_domains])".to_owned());
return;
};
match pruner.prune(cutoff.timestamp(), dry_run).await {
Ok(removed) => report.rows_removed = removed,
Err(e) => report.error = Some(e),
}
}
async fn apply_archive_purge(
state: &AppState,
cutoff: DateTime<Utc>,
dry_run: bool,
report: &mut RetentionDatasetReport,
) {
let Some(logger) = state.extension::<crate::audit::AuditLogger>() else {
report.skipped = Some("no audit logger is installed".to_owned());
return;
};
let summary = logger.purge_before(cutoff, dry_run).await;
if summary.supported {
report.eligible_rows = Some(summary.entries_removed);
if !dry_run {
report.rows_removed = summary.entries_removed;
}
} else if summary.errors.is_empty() {
report.skipped = Some(
"no installed audit sink supports purging (see AuditSink::purge_before)".to_owned(),
);
}
report.error = summary.error_message();
}
#[cfg(any(test, all(feature = "db", not(feature = "sqlite"))))]
#[must_use]
const fn stale_row_predicate(dataset: RetentionDataset) -> Option<(&'static str, &'static str)> {
match dataset {
RetentionDataset::JobHistory => Some((
"autumn_jobs",
"status IN ('completed', 'failed', 'discarded') AND finished_at IS NOT NULL AND finished_at < $1 AND NOT (unique_key IS NOT NULL AND unique_window = 'ttl')",
)),
RetentionDataset::CommitHooks => Some((
"autumn_repository_commit_hooks",
"status IN ('completed', 'failed', 'after_hook_failed') \
AND finished_at IS NOT NULL AND finished_at < $1",
)),
RetentionDataset::JobTracking => Some(("autumn_job_tracking", "updated_at < $1")),
RetentionDataset::ExperimentAssignments => Some((
"autumn_experiment_assignments",
"assigned_at < $1 AND NOT EXISTS ( SELECT 1 FROM autumn_experiments e WHERE e.name = autumn_experiment_assignments.experiment AND e.state IN ('draft', 'running', 'concluded') )",
)),
RetentionDataset::Idempotency
| RetentionDataset::WebhookReplay
| RetentionDataset::Sessions
| RetentionDataset::AuditArchives
| RetentionDataset::CustomDomains => None,
}
}
#[cfg(feature = "db")]
const fn sweep_key_column(dataset: RetentionDataset) -> &'static str {
match dataset {
RetentionDataset::JobTracking => "key",
_ => "id",
}
}
#[cfg(feature = "db")]
async fn apply_sweep(
state: &AppState,
dataset: RetentionDataset,
window: Duration,
dry_run: bool,
report: &mut RetentionDatasetReport,
) {
let Some(pool) = state.pool().cloned() else {
report.skipped =
Some("no database is configured, so this dataset has no rows here".to_owned());
return;
};
#[cfg(not(feature = "sqlite"))]
let outcome = sweep_postgres(&pool, dataset, window, dry_run).await;
#[cfg(feature = "sqlite")]
let outcome = match sweep_sqlite(&pool, dataset, window, dry_run).await {
Ok(SqliteSweep::Swept(swept)) => Ok(swept),
Ok(SqliteSweep::MissingTable(table)) => {
report.skipped = Some(format!(
"{table} does not exist in this database, so this dataset has no rows here"
));
return;
}
Ok(SqliteSweep::Skipped(reason)) => {
report.skipped = Some(reason);
return;
}
Err(failure) => Err(failure),
};
match outcome {
Ok(swept) => {
report.cutoff = Some(swept.cutoff.to_rfc3339());
report.eligible_rows = Some(swept.eligible);
report.rows_removed = swept.removed;
report.truncated = swept.truncated;
}
Err(failure) => {
if let Some(cutoff) = failure.cutoff {
report.cutoff = Some(cutoff.to_rfc3339());
}
report.eligible_rows = failure.eligible;
report.rows_removed = failure.removed;
report.truncated = true;
report.error = Some(failure.error.to_string());
}
}
}
#[cfg(not(feature = "db"))]
#[allow(clippy::unused_async)]
async fn apply_sweep(
_state: &AppState,
_dataset: RetentionDataset,
_window: Duration,
_dry_run: bool,
report: &mut RetentionDatasetReport,
) {
report.skipped = Some("this build has no database support (`db` feature off)".to_owned());
}
#[cfg(all(feature = "db", not(feature = "sqlite")))]
type PgPool = diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>;
#[cfg(all(feature = "db", not(feature = "sqlite")))]
async fn sweep_postgres(
pool: &PgPool,
dataset: RetentionDataset,
window: Duration,
dry_run: bool,
) -> Result<SweptDataset, SweepFailure> {
use diesel_async::RunQueryDsl as _;
let Some((table, predicate)) = stale_row_predicate(dataset) else {
return Err(sweep_failed(
0,
format!("{dataset} is not a sweep-enforced dataset"),
));
};
let key = sweep_key_column(dataset);
let mut conn = pool.get().await.map_err(|e| {
sweep_failed(
0,
format!("retention sweep could not acquire a connection: {e}"),
)
})?;
let cutoff = resolve_cutoff(&mut conn, table, window).await?;
let eligible = count_stale(&mut conn, table, predicate, cutoff, 0, None).await?;
if dry_run || eligible == 0 {
return Ok(SweptDataset {
cutoff,
eligible,
removed: 0,
truncated: false,
});
}
let mut removed: u64 = 0;
for _ in 0..SWEEP_MAX_BATCHES {
let affected = diesel::sql_query(format!(
"DELETE FROM {table} WHERE {predicate} AND {key} IN \
(SELECT {key} FROM {table} WHERE {predicate} LIMIT $2)"
))
.bind::<diesel::sql_types::Timestamptz, _>(cutoff)
.bind::<diesel::sql_types::BigInt, _>(SWEEP_BATCH_ROWS)
.execute(&mut *conn)
.await
.map_err(|e| {
sweep_failed_at(
cutoff,
removed,
Some(eligible),
format!("retention sweep of {table} failed: {e}"),
)
})?;
removed = removed.saturating_add(u64::try_from(affected).unwrap_or(u64::MAX));
if affected == 0 {
break;
}
}
let remaining =
count_stale(&mut conn, table, predicate, cutoff, removed, Some(eligible)).await?;
Ok(SweptDataset {
cutoff,
eligible,
removed,
truncated: remaining > 0,
})
}
#[cfg(feature = "db")]
fn sweep_failed(removed: u64, message: String) -> SweepFailure {
SweepFailure {
cutoff: None,
removed,
eligible: None,
error: Box::new(crate::AutumnError::internal_server_error_msg(message)),
}
}
#[cfg(feature = "db")]
fn sweep_failed_at(
cutoff: DateTime<Utc>,
removed: u64,
eligible: Option<u64>,
message: String,
) -> SweepFailure {
SweepFailure {
cutoff: Some(cutoff),
removed,
eligible,
error: Box::new(crate::AutumnError::internal_server_error_msg(message)),
}
}
#[cfg(feature = "db")]
struct SweepFailure {
cutoff: Option<DateTime<Utc>>,
removed: u64,
eligible: Option<u64>,
error: Box<crate::AutumnError>,
}
#[cfg(all(feature = "db", not(feature = "sqlite")))]
async fn resolve_cutoff(
conn: &mut diesel_async::AsyncPgConnection,
table: &str,
window: Duration,
) -> Result<DateTime<Utc>, SweepFailure> {
use diesel_async::RunQueryDsl as _;
#[derive(diesel::QueryableByName)]
struct CutoffRow {
#[diesel(sql_type = diesel::sql_types::Timestamptz)]
cutoff: DateTime<Utc>,
}
let Ok(window_secs) = u32::try_from(window.as_secs()) else {
return Err(sweep_failed(
0,
format!(
"retention window for {table} ({}s) exceeds the maximum the sweep can \
apply; refusing rather than silently sweeping a shorter window",
window.as_secs()
),
));
};
diesel::sql_query("SELECT NOW() - make_interval(secs => $1) AS cutoff")
.bind::<diesel::sql_types::Double, _>(f64::from(window_secs))
.get_result::<CutoffRow>(conn)
.await
.map(|row| row.cutoff)
.map_err(|e| {
sweep_failed(
0,
format!("retention could not resolve the cutoff for {table}: {e}"),
)
})
}
#[cfg(all(feature = "db", not(feature = "sqlite")))]
async fn count_stale(
conn: &mut diesel_async::AsyncPgConnection,
table: &str,
predicate: &str,
cutoff: DateTime<Utc>,
removed_so_far: u64,
eligible: Option<u64>,
) -> Result<u64, SweepFailure> {
use diesel_async::RunQueryDsl as _;
#[derive(diesel::QueryableByName)]
struct CountRow {
#[diesel(sql_type = diesel::sql_types::BigInt)]
count: i64,
}
diesel::sql_query(format!(
"SELECT COUNT(*) AS count FROM {table} WHERE {predicate}"
))
.bind::<diesel::sql_types::Timestamptz, _>(cutoff)
.get_result::<CountRow>(conn)
.await
.map(|row| u64::try_from(row.count).unwrap_or(0))
.map_err(|e| {
sweep_failed_at(
cutoff,
removed_so_far,
eligible,
format!("retention count for {table} failed: {e}"),
)
})
}
#[cfg(feature = "db")]
struct SweptDataset {
cutoff: DateTime<Utc>,
eligible: u64,
removed: u64,
truncated: bool,
}
#[cfg(any(test, all(feature = "db", feature = "sqlite")))]
const SQLITE_NOW_MS: &str = "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)";
#[cfg(any(test, all(feature = "db", feature = "sqlite")))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SqliteClock {
Millis,
Text,
}
#[cfg(any(test, all(feature = "db", feature = "sqlite")))]
#[must_use]
fn stale_row_predicate_sqlite(
dataset: RetentionDataset,
) -> Option<(&'static str, String, SqliteClock)> {
match dataset {
RetentionDataset::JobHistory => Some((
"autumn_jobs",
format!(
"status IN ('completed', 'failed', 'discarded') \
AND finished_at IS NOT NULL AND finished_at < ?1 \
AND NOT (unique_key IS NOT NULL AND unique_window = 'ttl' \
AND enqueued_at > ({SQLITE_NOW_MS}) - COALESCE(unique_ttl_ms, 0))"
),
SqliteClock::Millis,
)),
RetentionDataset::CommitHooks => Some((
"autumn_repository_commit_hooks",
"status IN ('completed', 'failed', 'after_hook_failed') \
AND finished_at IS NOT NULL AND finished_at < ?1"
.to_owned(),
SqliteClock::Text,
)),
RetentionDataset::ExperimentAssignments => Some((
"autumn_experiment_assignments",
"assigned_at < ?1 AND NOT EXISTS ( \
SELECT 1 FROM autumn_experiments e \
WHERE e.name = autumn_experiment_assignments.experiment \
AND e.state IN ('draft', 'running', 'concluded'))"
.to_owned(),
SqliteClock::Text,
)),
RetentionDataset::JobTracking
| RetentionDataset::Idempotency
| RetentionDataset::WebhookReplay
| RetentionDataset::Sessions
| RetentionDataset::AuditArchives
| RetentionDataset::CustomDomains => None,
}
}
#[cfg(any(test, all(feature = "db", feature = "sqlite")))]
const fn sqlite_sweep_index(dataset: RetentionDataset) -> Option<&'static str> {
match dataset {
RetentionDataset::CommitHooks => Some(
"CREATE INDEX IF NOT EXISTS idx_autumn_commit_hooks_finished \
ON autumn_repository_commit_hooks (finished_at) \
WHERE status IN ('completed', 'failed', 'after_hook_failed')",
),
RetentionDataset::ExperimentAssignments => Some(
"CREATE INDEX IF NOT EXISTS idx_autumn_exp_assignments_assigned_at \
ON autumn_experiment_assignments (assigned_at)",
),
_ => None,
}
}
#[cfg(all(feature = "db", feature = "sqlite"))]
enum SqliteCutoff {
Millis(i64),
Text(String),
}
#[cfg(all(feature = "db", feature = "sqlite"))]
enum SqliteSweep {
Swept(SweptDataset),
MissingTable(&'static str),
Skipped(String),
}
#[cfg(all(feature = "db", feature = "sqlite"))]
#[derive(diesel::QueryableByName)]
struct SqliteCount {
#[diesel(sql_type = diesel::sql_types::BigInt)]
count: i64,
}
#[cfg(all(feature = "db", feature = "sqlite"))]
#[allow(clippy::too_many_lines)]
async fn sweep_sqlite(
pool: &diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>,
dataset: RetentionDataset,
window: Duration,
dry_run: bool,
) -> Result<SqliteSweep, SweepFailure> {
use diesel_async::RunQueryDsl as _;
let Some((table, predicate, clock)) = stale_row_predicate_sqlite(dataset) else {
return Err(sweep_failed(
0,
format!("{dataset} is not a sweep-enforced dataset"),
));
};
let key = sweep_key_column(dataset);
let mut conn = pool.get().await.map_err(|e| {
sweep_failed(
0,
format!("retention sweep could not acquire a connection: {e}"),
)
})?;
for needed in std::iter::once(table)
.chain((dataset == RetentionDataset::ExperimentAssignments).then_some("autumn_experiments"))
{
if !sqlite_table_exists(&mut conn, needed).await? {
return Ok(SqliteSweep::MissingTable(needed));
}
}
if dataset == RetentionDataset::JobHistory
&& !sqlite_column_exists(&mut conn, table, "unique_ttl_ms").await?
{
return Ok(SqliteSweep::Skipped(format!(
"{table} predates the unique_ttl_ms column, so the sweep cannot check TTL holds. \
Start the app once so the job runtime upgrades the table, then run this again"
)));
}
let (cutoff_at, cutoff) = resolve_sqlite_cutoff(&mut conn, table, window, clock).await?;
let eligible =
count_stale_sqlite(&mut conn, table, &predicate, &cutoff, 0, None, cutoff_at).await?;
if dry_run || eligible == 0 {
return Ok(SqliteSweep::Swept(SweptDataset {
cutoff: cutoff_at,
eligible,
removed: 0,
truncated: false,
}));
}
if let Some(index) = sqlite_sweep_index(dataset) {
diesel::sql_query(index)
.execute(&mut *conn)
.await
.map_err(|e| {
sweep_failed_at(
cutoff_at,
0,
Some(eligible),
format!("retention could not create the sweep index on {table}: {e}"),
)
})?;
}
let mut removed: u64 = 0;
for _ in 0..SWEEP_MAX_BATCHES {
let sql = format!(
"DELETE FROM {table} WHERE {predicate} AND {key} IN \
(SELECT {key} FROM {table} WHERE {predicate} LIMIT ?2)"
);
let query = diesel::sql_query(sql);
let affected = match &cutoff {
SqliteCutoff::Millis(ms) => {
query
.bind::<diesel::sql_types::BigInt, _>(*ms)
.bind::<diesel::sql_types::BigInt, _>(SWEEP_BATCH_ROWS)
.execute(&mut *conn)
.await
}
SqliteCutoff::Text(text) => {
query
.bind::<diesel::sql_types::Text, _>(text.clone())
.bind::<diesel::sql_types::BigInt, _>(SWEEP_BATCH_ROWS)
.execute(&mut *conn)
.await
}
}
.map_err(|e| {
sweep_failed_at(
cutoff_at,
removed,
Some(eligible),
format!("retention sweep of {table} failed: {e}"),
)
})?;
removed = removed.saturating_add(u64::try_from(affected).unwrap_or(u64::MAX));
if affected == 0 {
break;
}
}
let remaining = count_stale_sqlite(
&mut conn,
table,
&predicate,
&cutoff,
removed,
Some(eligible),
cutoff_at,
)
.await?;
Ok(SqliteSweep::Swept(SweptDataset {
cutoff: cutoff_at,
eligible,
removed,
truncated: remaining > 0,
}))
}
#[cfg(all(feature = "db", feature = "sqlite"))]
async fn sqlite_table_exists(
conn: &mut crate::db::PooledConnection,
table: &str,
) -> Result<bool, SweepFailure> {
use diesel_async::RunQueryDsl as _;
diesel::sql_query(
"SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name = ?1",
)
.bind::<diesel::sql_types::Text, _>(table)
.get_result::<SqliteCount>(&mut **conn)
.await
.map(|row| row.count > 0)
.map_err(|e| sweep_failed(0, format!("retention could not look for {table}: {e}")))
}
#[cfg(all(feature = "db", feature = "sqlite"))]
async fn sqlite_column_exists(
conn: &mut crate::db::PooledConnection,
table: &str,
column: &str,
) -> Result<bool, SweepFailure> {
use diesel_async::RunQueryDsl as _;
diesel::sql_query("SELECT COUNT(*) AS count FROM pragma_table_info(?1) WHERE name = ?2")
.bind::<diesel::sql_types::Text, _>(table)
.bind::<diesel::sql_types::Text, _>(column)
.get_result::<SqliteCount>(&mut **conn)
.await
.map(|row| row.count > 0)
.map_err(|e| sweep_failed(0, format!("retention could not inspect {table}: {e}")))
}
#[cfg(all(feature = "db", feature = "sqlite"))]
async fn resolve_sqlite_cutoff(
conn: &mut crate::db::PooledConnection,
table: &str,
window: Duration,
clock: SqliteClock,
) -> Result<(DateTime<Utc>, SqliteCutoff), SweepFailure> {
use diesel_async::RunQueryDsl as _;
#[derive(diesel::QueryableByName)]
struct CutoffRow {
#[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::BigInt>)]
cutoff_ms: Option<i64>,
}
let too_wide = || {
sweep_failed(
0,
format!(
"retention window for {table} ({}s) exceeds the maximum the sweep can \
apply; refusing rather than silently sweeping a shorter window",
window.as_secs()
),
)
};
let window_ms = i64::try_from(window.as_millis()).map_err(|_| too_wide())?;
let row = diesel::sql_query(format!("SELECT {SQLITE_NOW_MS} - ?1 AS cutoff_ms"))
.bind::<diesel::sql_types::BigInt, _>(window_ms)
.get_result::<CutoffRow>(&mut **conn)
.await
.map_err(|e| {
sweep_failed(
0,
format!("retention could not resolve the cutoff for {table}: {e}"),
)
})?;
let ms = row.cutoff_ms.ok_or_else(too_wide)?;
let at = match clock {
SqliteClock::Millis => DateTime::<Utc>::from_timestamp_millis(ms),
SqliteClock::Text => DateTime::<Utc>::from_timestamp(ms.div_euclid(1000), 0),
}
.ok_or_else(too_wide)?;
let cutoff = match clock {
SqliteClock::Millis => SqliteCutoff::Millis(ms),
SqliteClock::Text => SqliteCutoff::Text(at.format("%Y-%m-%d %H:%M:%S").to_string()),
};
Ok((at, cutoff))
}
#[cfg(all(feature = "db", feature = "sqlite"))]
async fn count_stale_sqlite(
conn: &mut crate::db::PooledConnection,
table: &str,
predicate: &str,
cutoff: &SqliteCutoff,
removed_so_far: u64,
eligible: Option<u64>,
cutoff_at: DateTime<Utc>,
) -> Result<u64, SweepFailure> {
use diesel_async::RunQueryDsl as _;
let query = diesel::sql_query(format!(
"SELECT COUNT(*) AS count FROM {table} WHERE {predicate}"
));
match cutoff {
SqliteCutoff::Millis(ms) => {
query
.bind::<diesel::sql_types::BigInt, _>(*ms)
.get_result::<SqliteCount>(&mut **conn)
.await
}
SqliteCutoff::Text(text) => {
query
.bind::<diesel::sql_types::Text, _>(text.clone())
.get_result::<SqliteCount>(&mut **conn)
.await
}
}
.map(|row| u64::try_from(row.count).unwrap_or(0))
.map_err(|e| {
sweep_failed_at(
cutoff_at,
removed_so_far,
eligible,
format!("retention count for {table} failed: {e}"),
)
})
}
async fn audit_sweep(state: &AppState, report: &mut RetentionDatasetReport) {
let is_legal_hold = report
.skipped
.as_deref()
.is_some_and(|reason| reason.starts_with("legal hold:"));
let swept = report.eligible_rows.is_some() || report.error.is_some();
if report.dry_run || (!swept && !is_legal_hold) {
return;
}
let status = if report.error.is_some() {
crate::audit::AuditStatus::Failure
} else {
crate::audit::AuditStatus::Success
};
let mut event = crate::audit::AuditEvent::new(
RETENTION_AUDIT_ACTOR,
RETENTION_AUDIT_ACTION,
report.dataset.clone(),
None,
status,
)
.with_metadata("dataset", report.dataset.clone())
.with_metadata("cutoff", report.cutoff.clone().unwrap_or_default())
.with_metadata("rows_removed", report.rows_removed.to_string())
.with_metadata("truncated", report.truncated.to_string());
if let Some(eligible) = report.eligible_rows {
event = event.with_metadata("eligible_rows", eligible.to_string());
}
if let Some(skipped) = report.skipped.as_deref() {
event = event.with_metadata("skipped", skipped);
}
if let Some(error) = report.error.as_deref() {
event = event.with_metadata("error", error);
}
if let Err(error) = crate::audit::write_from_state(state, event).await {
tracing::error!(
error = %error,
dataset = %report.dataset,
rows_removed = report.rows_removed,
"retention sweep audit record could not be written"
);
let audit_failure = format!("audit record not written: {error}");
report.error = Some(match report.error.take() {
Some(sweep_failure) => format!("{sweep_failure}; {audit_failure}"),
None => audit_failure,
});
}
tracing::info!(
target: "autumn.audit",
dataset = %report.dataset,
cutoff = report.cutoff.as_deref().unwrap_or("-"),
eligible_rows = report.eligible_rows.unwrap_or(0),
rows_removed = report.rows_removed,
truncated = report.truncated,
skipped = report.skipped.as_deref().unwrap_or(""),
error = report.error.as_deref().unwrap_or(""),
"retention_sweep"
);
}
#[must_use]
pub fn purge_refusal(profile: Option<&str>, force: bool) -> Option<String> {
if force {
return None;
}
let name = profile.map(str::trim);
if name.is_some_and(|name| {
["dev", "development", "test"]
.iter()
.any(|safe| name.eq_ignore_ascii_case(safe))
}) {
return None;
}
Some(format!(
"refusing to purge framework data: the app resolved the {} profile. The configured \
[retention] policy already runs inside the app. Re-run with --force to purge now",
name.map_or_else(|| "an unknown".to_owned(), |name| format!("{name:?}"))
))
}
fn fail_on_dataset_errors(reports: &[RetentionDatasetReport]) -> AutumnResult<()> {
let failures: Vec<String> = reports
.iter()
.filter_map(|report| {
report
.error
.as_deref()
.map(|error| format!("{}: {error}", report.dataset))
})
.collect();
if failures.is_empty() {
return Ok(());
}
Err(crate::AutumnError::internal_server_error_msg(format!(
"retention sweep failed for {} dataset(s): {}",
failures.len(),
failures.join("; ")
)))
}
pub const RETENTION_ARCHIVE_TASK_NAME: &str = "autumn-retention-archive-sweep";
fn shared_window_configured(config: &RetentionConfig) -> bool {
config.windows().iter().any(|(key, window)| {
window.is_some()
&& RetentionDataset::from_key(key)
.is_none_or(|dataset| RetentionScope::Shared.covers(dataset.enforcement()))
})
}
#[must_use]
pub fn framework_retention_task(config: &RetentionConfig) -> Option<TaskInfo> {
if !shared_window_configured(config) {
return None;
}
Some(TaskInfo {
name: RETENTION_SWEEP_TASK_NAME.to_owned(),
schedule: Schedule::FixedDelay(config.sweep_interval_duration()),
coordination: TaskCoordination::Fleet,
handler: |state| {
Box::pin(async move {
let reports = run_retention_for(
&state,
&RetentionRunOptions::default(),
RetentionScope::Shared,
)
.await?;
fail_on_dataset_errors(&reports)
})
},
})
}
#[must_use]
pub fn framework_retention_tasks(config: &RetentionConfig) -> Vec<TaskInfo> {
framework_retention_task(config)
.into_iter()
.chain(framework_archive_retention_task(config))
.collect()
}
#[must_use]
pub fn framework_archive_retention_task(config: &RetentionConfig) -> Option<TaskInfo> {
config.audit_archives.as_ref()?;
Some(TaskInfo {
name: RETENTION_ARCHIVE_TASK_NAME.to_owned(),
schedule: Schedule::FixedDelay(config.sweep_interval_duration()),
coordination: TaskCoordination::PerReplica,
handler: |state| {
Box::pin(async move {
let reports = run_retention_for(
&state,
&RetentionRunOptions::default(),
RetentionScope::PerReplica,
)
.await?;
fail_on_dataset_errors(&reports)
})
},
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::audit::{AuditError, AuditEvent, AuditLogger, AuditSink};
use std::sync::Arc;
struct FailingSink;
impl AuditSink for FailingSink {
fn write(
&self,
_event: AuditEvent,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), AuditError>> + Send + '_>>
{
Box::pin(async { Err(AuditError::new("disk full")) })
}
}
fn held_report() -> RetentionDatasetReport {
RetentionDatasetReport {
dataset: "job_history".to_owned(),
description: String::new(),
enforcement: "sweep".to_owned(),
window_secs: Some(86_400),
source: "[retention]".to_owned(),
cutoff: Some("2026-01-01T00:00:00+00:00".to_owned()),
eligible_rows: Some(3),
rows_removed: 3,
truncated: false,
dry_run: false,
skipped: None,
duration_ms: 0,
error: None,
}
}
fn state_with_sink(sink: impl AuditSink) -> AppState {
let state = AppState::for_test();
state.insert_extension(AuditLogger::new().with_sink(Arc::new(sink)));
state
}
#[tokio::test]
async fn a_failed_audit_write_sets_the_report_error() {
let state = state_with_sink(FailingSink);
let mut report = held_report();
audit_sweep(&state, &mut report).await;
let error = report.error.expect("audit failure must reach the report");
assert!(error.contains("audit record"), "{error}");
assert!(error.contains("disk full"), "{error}");
assert_eq!(report.rows_removed, 3, "removed rows stay reported");
}
#[derive(Clone, Default)]
struct SharedBuf(Arc<std::sync::Mutex<Vec<u8>>>);
impl std::io::Write for SharedBuf {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().expect("buf").extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for SharedBuf {
type Writer = Self;
fn make_writer(&'a self) -> Self {
self.clone()
}
}
#[tokio::test]
async fn the_sweep_trace_carries_the_audit_write_failure() {
let buf = SharedBuf::default();
let subscriber = tracing_subscriber::fmt()
.with_writer(buf.clone())
.with_ansi(false)
.finish();
let _guard = tracing::dispatcher::set_default(&tracing::Dispatch::new(subscriber));
let state = state_with_sink(FailingSink);
let mut report = held_report();
audit_sweep(&state, &mut report).await;
let output = String::from_utf8(buf.0.lock().expect("buf").clone()).expect("utf8");
let line = output
.lines()
.find(|line| line.contains("retention_sweep"))
.expect("sweep trace line");
assert!(line.contains("audit record not written"), "{line}");
}
#[tokio::test]
async fn a_failed_legal_hold_audit_write_sets_the_report_error() {
let state = state_with_sink(FailingSink);
let mut report = held_report();
report.eligible_rows = None;
report.rows_removed = 0;
report.skipped = Some("legal hold: tax records".to_owned());
audit_sweep(&state, &mut report).await;
assert!(report.error.is_some(), "{report:?}");
}
#[tokio::test]
async fn a_failed_audit_write_keeps_the_sweep_error() {
let state = state_with_sink(FailingSink);
let mut report = held_report();
report.error = Some("statement timeout".to_owned());
audit_sweep(&state, &mut report).await;
let error = report.error.expect("error");
assert!(error.contains("statement timeout"), "{error}");
assert!(error.contains("disk full"), "{error}");
}
#[tokio::test]
async fn a_written_audit_record_leaves_the_report_clean() {
let state = AppState::for_test();
let mut report = held_report();
audit_sweep(&state, &mut report).await;
assert_eq!(report.error, None);
}
#[tokio::test]
async fn a_dry_run_never_fails_on_the_audit_sink() {
let state = state_with_sink(FailingSink);
let mut report = held_report();
report.dry_run = true;
audit_sweep(&state, &mut report).await;
assert_eq!(report.error, None);
}
fn config_with_backend(backend: &str) -> AutumnConfig {
let mut config = AutumnConfig::default();
config.jobs.backend = backend.to_owned();
config.retention.job_history = Some("90d".to_owned());
config.retention.job_tracking = Some("7d".to_owned());
config
}
#[tokio::test]
async fn job_history_is_not_enforced_on_a_redis_or_local_jobs_backend() {
for backend in ["redis", "local"] {
let config = config_with_backend(backend);
let report = run_one_dataset(
&AppState::for_test(),
&config,
RetentionDataset::JobHistory,
false,
)
.await;
let skipped = report.skipped.expect("the sweep cannot reach these jobs");
assert!(skipped.contains("NOT enforced"), "{backend}: {skipped}");
assert!(skipped.contains(backend), "{backend}: {skipped}");
assert_eq!(report.rows_removed, 0);
}
}
#[tokio::test]
async fn job_history_is_not_reported_unenforced_on_postgres_or_sqlite() {
for backend in ["postgres", "sqlite"] {
let config = config_with_backend(backend);
let report = run_one_dataset(
&AppState::for_test(),
&config,
RetentionDataset::JobHistory,
true,
)
.await;
let skipped = report.skipped.unwrap_or_default();
assert!(!skipped.contains("NOT enforced"), "{backend}: {skipped}");
}
}
#[tokio::test]
async fn a_legal_hold_is_not_claimed_for_records_outside_the_held_table() {
let state = AppState::for_test();
let registry = GdprRegistry::new().register(crate::gdpr::ModelRegistration::retain(
"autumn_job_tracking",
"litigation",
));
state.insert_extension(registry);
let redis = config_with_backend("redis");
let report = run_one_dataset(&state, &redis, RetentionDataset::JobTracking, true).await;
let skipped = report.skipped.expect("held");
assert!(skipped.starts_with("legal hold:"), "{skipped}");
assert!(skipped.contains("NOT preserved"), "{skipped}");
let postgres = config_with_backend("postgres");
let report = run_one_dataset(&state, &postgres, RetentionDataset::JobTracking, true).await;
let skipped = report.skipped.expect("held");
assert!(!skipped.contains("NOT preserved"), "{skipped}");
}
#[test]
fn only_jobs_datasets_depend_on_the_jobs_backend() {
let postgres = config_with_backend("postgres");
let redis = config_with_backend("redis");
let sensitive: Vec<&str> = RETENTION_DATASETS
.iter()
.filter(|dataset| dataset.table_for(&postgres) != dataset.table_for(&redis))
.map(|dataset| dataset.key())
.collect();
assert_eq!(sensitive, ["job_history", "job_tracking"]);
}
#[tokio::test]
async fn the_shared_scope_leaves_the_archive_to_each_replica() {
let state = AppState::for_test();
let options = RetentionRunOptions::default();
let shared = run_retention_for(&state, &options, RetentionScope::Shared)
.await
.expect("run");
let local = run_retention_for(&state, &options, RetentionScope::PerReplica)
.await
.expect("run");
assert!(shared.iter().all(|r| r.dataset != "audit_archives"));
assert_eq!(
local.iter().map(|r| r.dataset.as_str()).collect::<Vec<_>>(),
["audit_archives"]
);
let all = run_retention(&state, &options).await.expect("run");
assert_eq!(all.len(), shared.len() + local.len());
}
#[test]
fn purge_is_refused_unless_the_app_resolved_a_dev_or_test_profile() {
for profile in [Some("dev"), Some("development"), Some("test"), Some("Dev")] {
assert_eq!(purge_refusal(profile, false), None, "{profile:?}");
}
for profile in [Some("prod"), Some("production"), Some("staging"), None] {
let refusal = purge_refusal(profile, false).expect("must refuse");
assert!(refusal.contains("--force"), "{refusal}");
}
}
#[test]
fn force_lifts_the_purge_refusal() {
assert_eq!(purge_refusal(Some("prod"), true), None);
assert_eq!(purge_refusal(None, true), None);
}
#[test]
fn the_task_list_follows_the_configured_windows() {
let names = |config: &RetentionConfig| {
framework_retention_tasks(config)
.into_iter()
.map(|task| task.name)
.collect::<Vec<_>>()
};
let mut config = RetentionConfig::default();
assert!(names(&config).is_empty());
config.audit_archives = Some("400d".to_owned());
assert_eq!(names(&config), [RETENTION_ARCHIVE_TASK_NAME]);
config.job_history = Some("90d".to_owned());
assert_eq!(
names(&config),
[RETENTION_SWEEP_TASK_NAME, RETENTION_ARCHIVE_TASK_NAME]
);
}
#[test]
fn fail_on_dataset_errors_passes_when_clean() {
assert!(fail_on_dataset_errors(&[held_report()]).is_ok());
}
#[test]
fn fail_on_dataset_errors_names_every_failed_dataset() {
let mut first = held_report();
first.error = Some("timeout".to_owned());
let mut second = held_report();
second.dataset = "commit_hooks".to_owned();
second.error = Some("denied".to_owned());
let ok = held_report();
let message = fail_on_dataset_errors(&[first, ok, second])
.expect_err("a failed dataset must fail the task")
.to_string();
assert!(message.contains("job_history"), "{message}");
assert!(message.contains("commit_hooks"), "{message}");
assert!(message.contains("timeout"), "{message}");
}
#[test]
fn in_memory_stores_warn_about_late_eviction() {
let mut config = AutumnConfig::default();
config.idempotency.backend = crate::config::IdempotencyBackend::Memory;
config.security.webhooks.replay.backend = crate::webhook::WebhookReplayBackend::Memory;
config.jobs.backend = "local".to_owned();
for dataset in [
RetentionDataset::Idempotency,
RetentionDataset::WebhookReplay,
RetentionDataset::JobTracking,
] {
let note = backend_ttl_note(dataset, &config, 60, None);
assert!(note.contains("built-in store"), "{dataset}: {note}");
assert!(note.contains("quiet process"), "{dataset}: {note}");
}
}
#[test]
fn shared_stores_do_not_warn_about_late_eviction() {
let mut config = AutumnConfig::default();
config.idempotency.backend = crate::config::IdempotencyBackend::Redis;
config.security.webhooks.replay.backend = crate::webhook::WebhookReplayBackend::Redis;
config.jobs.backend = "redis".to_owned();
for dataset in [
RetentionDataset::Idempotency,
RetentionDataset::WebhookReplay,
RetentionDataset::JobTracking,
] {
let note = backend_ttl_note(dataset, &config, 60, None);
assert!(!note.contains("quiet process"), "{dataset}: {note}");
}
}
#[test]
fn only_the_dataset_with_an_in_memory_store_warns() {
let mut config = AutumnConfig::default();
config.idempotency.backend = crate::config::IdempotencyBackend::Memory;
config.security.webhooks.replay.backend = crate::webhook::WebhookReplayBackend::Redis;
config.jobs.backend = "redis".to_owned();
let warns =
|dataset| backend_ttl_note(dataset, &config, 60, None).contains("quiet process");
assert!(warns(RetentionDataset::Idempotency));
assert!(!warns(RetentionDataset::WebhookReplay));
assert!(!warns(RetentionDataset::JobTracking));
}
#[test]
fn the_database_idempotency_store_warns_and_sqlite_jobs_do_not() {
let mut config = AutumnConfig::default();
config.idempotency.backend = crate::config::IdempotencyBackend::Database;
assert!(evicts_lazily(RetentionDataset::Idempotency, &config));
config.jobs.backend = "sqlite".to_owned();
assert!(!evicts_lazily(RetentionDataset::JobTracking, &config));
}
#[test]
fn an_unknown_jobs_backend_warns_because_it_falls_back_to_memory() {
let mut config = AutumnConfig::default();
config.jobs.backend = "postgresql".to_owned();
assert!(evicts_lazily(RetentionDataset::JobTracking, &config));
config.jobs.backend = "redis".to_owned();
assert!(!evicts_lazily(RetentionDataset::JobTracking, &config));
}
#[test]
fn a_held_database_idempotency_table_is_not_described_as_swept() {
let mut config = AutumnConfig::default();
config.idempotency.backend = crate::config::IdempotencyBackend::Database;
let registry = GdprRegistry::new().register(crate::gdpr::ModelRegistration::retain(
"autumn_idempotency_keys",
"litigation",
));
let note = backend_ttl_note(RetentionDataset::Idempotency, &config, 60, Some(®istry));
assert!(note.contains("legal hold: litigation"), "{note}");
assert!(!note.contains("quiet process"), "{note}");
}
#[test]
fn the_memory_session_note_keeps_its_own_verdict() {
let mut config = AutumnConfig::default();
config.session.backend = crate::session::SessionBackend::Memory;
let note = backend_ttl_note(RetentionDataset::Sessions, &config, 60, None);
assert!(note.starts_with("NOT enforced"), "{note}");
assert!(!note.contains("quiet process"), "{note}");
}
#[cfg(feature = "db")]
#[test]
fn a_failure_after_the_count_keeps_the_eligible_count() {
let failure = sweep_failed_at(Utc::now(), 500, Some(5_000_000), "timeout".to_owned());
assert_eq!(failure.eligible, Some(5_000_000));
assert_eq!(failure.removed, 500);
assert_eq!(sweep_failed(0, "no pool".to_owned()).eligible, None);
}
#[test]
fn sqlite_predicates_use_one_cutoff_and_the_same_statuses_as_postgres() {
for dataset in RETENTION_DATASETS {
let sqlite = stale_row_predicate_sqlite(dataset);
let pg = stale_row_predicate(dataset);
assert_eq!(
sqlite.as_ref().map(|(table, ..)| *table),
pg.map(|(table, _)| table)
.filter(|_| dataset != RetentionDataset::JobTracking),
"{dataset}: same table set, job_tracking is TTL-only on SQLite"
);
if let Some((_, predicate, _)) = sqlite {
assert!(predicate.contains("?1"), "{dataset}: {predicate}");
assert!(
!predicate.contains("$1") && !predicate.contains("?2"),
"{predicate}"
);
if let Some((_, pg_predicate)) = pg {
if pg_predicate.contains("status IN (") {
assert_eq!(
statuses_in(&predicate),
statuses_in(pg_predicate),
"{dataset}"
);
}
if pg_predicate.contains("state IN (") {
assert_eq!(states_in(&predicate), states_in(pg_predicate), "{dataset}");
}
}
}
}
}
#[test]
fn every_sqlite_sweep_has_an_index_or_one_already_exists() {
for dataset in RETENTION_DATASETS {
let Some(index) = sqlite_sweep_index(dataset) else {
continue;
};
let (table, ..) = stale_row_predicate_sqlite(dataset).expect("a sweep");
assert!(index.contains(table), "{dataset}: {index}");
assert!(index.contains("IF NOT EXISTS"), "{index}");
}
assert!(sqlite_sweep_index(RetentionDataset::CommitHooks).is_some());
assert!(sqlite_sweep_index(RetentionDataset::ExperimentAssignments).is_some());
}
#[test]
fn sqlite_job_history_keeps_a_live_ttl_dedup_hold() {
let (_, predicate, clock) =
stale_row_predicate_sqlite(RetentionDataset::JobHistory).expect("sweepable");
assert_eq!(clock, SqliteClock::Millis);
assert!(predicate.contains("unique_ttl_ms"), "{predicate}");
assert!(predicate.contains("finished_at IS NOT NULL"), "{predicate}");
}
#[test]
fn resolve_datasets_defaults_to_every_dataset() {
assert_eq!(
resolve_datasets(None).expect("no filter always resolves"),
RETENTION_DATASETS.to_vec()
);
}
#[test]
fn resolve_datasets_rejects_an_unknown_key_and_lists_the_valid_ones() {
let error = resolve_datasets(Some("jobs")).expect_err("a typo must fail loudly");
let message = error.to_string();
assert!(message.contains("jobs"), "{message}");
assert!(
message.contains("job_history"),
"the error must list the valid keys: {message}"
);
}
#[test]
fn job_history_never_matches_a_live_job_row() {
let (table, predicate) =
stale_row_predicate(RetentionDataset::JobHistory).expect("job_history is sweepable");
assert_eq!(table, "autumn_jobs");
let statuses = statuses_in(predicate);
assert_eq!(
statuses,
vec!["completed", "discarded", "failed"],
"job_history must match every terminal status and no live one: {predicate}"
);
for live in ["enqueued", "running"] {
assert!(
!statuses.contains(&live.to_owned()),
"{live:?} is a live status and must never be swept: {predicate}"
);
}
assert!(predicate.contains("finished_at IS NOT NULL"), "{predicate}");
assert!(
predicate.contains("unique_window = 'ttl'"),
"a TTL-window dedup token must be excluded: {predicate}"
);
}
#[test]
fn commit_hooks_sweeps_every_terminal_hook_status() {
let (table, predicate) =
stale_row_predicate(RetentionDataset::CommitHooks).expect("commit_hooks is sweepable");
assert_eq!(table, "autumn_repository_commit_hooks");
assert_eq!(
statuses_in(predicate),
vec!["after_hook_failed", "completed", "failed"],
"every status that sets finished_at must be swept, or those rows \
grow forever despite a configured window: {predicate}"
);
assert!(predicate.contains("finished_at IS NOT NULL"), "{predicate}");
}
#[test]
fn experiment_assignments_protect_every_restartable_state() {
let (table, predicate) = stale_row_predicate(RetentionDataset::ExperimentAssignments)
.expect("experiment_assignments is sweepable");
assert_eq!(table, "autumn_experiment_assignments");
assert_eq!(
states_in(predicate),
vec!["concluded", "draft", "running"],
"an assignment may only be swept once its experiment can never run \
again. `ExperimentService::start` restores draft OR concluded to \
running and refuses only archived, so all three belong in the \
protected set — dropping `concluded` (as this predicate originally \
did) lets a restart re-bucket every returning actor: {predicate}"
);
assert!(
!states_in(predicate).contains(&"archived".to_owned()),
"archived is the one terminal state and must stay collectable: {predicate}"
);
}
fn states_in(predicate: &str) -> Vec<String> {
let needle = "e.state IN (";
let start = predicate
.find(needle)
.map(|at| at + needle.len())
.expect("predicate names a state set");
let end = start + predicate[start..].find(')').expect("closing paren");
let mut states: Vec<String> = predicate[start..end]
.split(',')
.map(|state| state.trim().trim_matches('\'').to_owned())
.collect();
states.sort();
states
}
fn statuses_in(predicate: &str) -> Vec<String> {
let start = predicate
.find("status IN (")
.map(|at| at + "status IN (".len())
.expect("predicate names a status set");
let end = start + predicate[start..].find(')').expect("closing paren");
let mut statuses: Vec<String> = predicate[start..end]
.split(',')
.map(|status| status.trim().trim_matches('\'').to_owned())
.collect();
statuses.sort();
statuses
}
#[test]
fn ttl_enforced_datasets_have_no_sweep_predicate() {
for dataset in RETENTION_DATASETS {
if dataset.enforcement() == RetentionEnforcement::Sweep {
assert!(stale_row_predicate(dataset).is_some(), "{dataset}");
} else {
assert!(
stale_row_predicate(dataset).is_none(),
"{dataset} is not sweep-enforced but has a sweep predicate"
);
}
}
}
}