#![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};
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(all(feature = "db", not(feature = "sqlite")))]
const SWEEP_BATCH_ROWS: i64 = 500;
#[cfg(all(feature = "db", not(feature = "sqlite")))]
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 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> {
let table = dataset.table()?;
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>,
}
pub async fn run_retention(
state: &AppState,
options: &RetentionRunOptions<'_>,
) -> 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 {
let report = run_one_dataset(state, &config, dataset, options.dry_run).await;
audit_sweep(state, &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;
};
let registry = state.extension::<GdprRegistry>();
if let Some(reason) = legal_hold_for(dataset, registry.as_deref()) {
report.skipped = Some(format!("legal hold: {reason}"));
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()),
));
}
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) -> 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"
);
if dataset == RetentionDataset::Sessions {
note.push_str(
", and a custom SessionStore installed with AppBuilder::with_session_store must \
apply the window itself",
);
}
note
}
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(all(feature = "db", not(feature = "sqlite")))]
const fn sweep_key_column(dataset: RetentionDataset) -> &'static str {
match dataset {
RetentionDataset::JobTracking => "key",
_ => "id",
}
}
#[cfg(all(feature = "db", not(feature = "sqlite")))]
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;
};
match sweep_postgres(&pool, dataset, window, dry_run).await {
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.rows_removed = failure.removed;
report.truncated = true;
report.error = Some(failure.error.to_string());
}
}
}
#[cfg(any(not(feature = "db"), feature = "sqlite"))]
#[allow(clippy::unused_async)]
async fn apply_sweep(
_state: &AppState,
_dataset: RetentionDataset,
_window: Duration,
_dry_run: bool,
report: &mut RetentionDatasetReport,
) {
report.skipped = Some(
if cfg!(feature = "sqlite") {
"the retention sweep is Postgres-only; on SQLite the durable job runtime prunes \
its own tables (see retention.job_history) and a sweep for the rest is planned"
} else {
"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).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,
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).await?;
Ok(SweptDataset {
cutoff,
eligible,
removed,
truncated: remaining > 0,
})
}
#[cfg(all(feature = "db", not(feature = "sqlite")))]
fn sweep_failed(removed: u64, message: String) -> SweepFailure {
SweepFailure {
cutoff: None,
removed,
error: Box::new(crate::AutumnError::internal_server_error_msg(message)),
}
}
#[cfg(all(feature = "db", not(feature = "sqlite")))]
fn sweep_failed_at(cutoff: DateTime<Utc>, removed: u64, message: String) -> SweepFailure {
SweepFailure {
cutoff: Some(cutoff),
removed,
error: Box::new(crate::AutumnError::internal_server_error_msg(message)),
}
}
#[cfg(all(feature = "db", not(feature = "sqlite")))]
struct SweepFailure {
cutoff: Option<DateTime<Utc>>,
removed: 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,
) -> 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,
format!("retention count for {table} failed: {e}"),
)
})
}
#[cfg(all(feature = "db", not(feature = "sqlite")))]
struct SweptDataset {
cutoff: DateTime<Utc>,
eligible: u64,
removed: u64,
truncated: bool,
}
async fn audit_sweep(state: &AppState, report: &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;
}
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"
);
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::warn!(
error = %error,
dataset = %report.dataset,
"retention sweep audit record could not be written"
);
}
}
#[must_use]
pub fn framework_retention_task(config: &RetentionConfig) -> Option<TaskInfo> {
if !config.any_window_configured() {
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(&state, &RetentionRunOptions::default()).await?;
for report in &reports {
if let Some(error) = report.error.as_deref() {
tracing::warn!(
dataset = %report.dataset,
error,
"framework retention sweep failed for one dataset"
);
}
}
Ok(())
})
},
})
}
#[cfg(test)]
mod tests {
use super::*;
#[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"
);
}
}
}
}