Skip to main content

ironflow_api/
purger.rs

1//! Retention-based purging of old runs and their artifacts.
2//!
3//! The [`RunPurger`] periodically removes terminal runs that exceed the
4//! configured retention policy (by age or by per-workflow count) and deletes
5//! their artifact blobs from the blob store.
6//!
7//! This is distinct from the [`Reaper`](crate::reaper::Reaper), which recovers
8//! runs whose worker lease expired. The reaper keeps runs alive; the purger
9//! removes the ones that are done and old.
10//!
11//! [`RunPurger::from_config`] wires a purger from a [`ServerConfig`].
12
13use std::sync::Arc;
14use std::time::Duration;
15
16use chrono::{TimeDelta, Utc};
17
18use uuid::Uuid;
19
20use crate::config::ServerConfig;
21use ironflow_artifacts::blob_store::BlobStore;
22use ironflow_store::entities::{PurgePolicy, PurgeReason};
23use ironflow_store::store::Store;
24use tokio::time::interval;
25use tokio_util::sync::CancellationToken;
26use tracing::{error, info, warn};
27
28#[cfg(feature = "prometheus")]
29use ironflow_core::metric_names::RUNS_PURGED_TOTAL;
30#[cfg(feature = "prometheus")]
31use metrics::counter;
32
33/// How often the purger runs by default (once per day).
34pub const DEFAULT_PURGE_INTERVAL: Duration = Duration::from_secs(86400);
35
36/// How many runs a single tick processes.
37pub const DEFAULT_PURGE_BATCH_SIZE: u32 = 100;
38
39/// Default days of Provider Account usage history kept.
40pub const DEFAULT_USAGE_RETENTION_DAYS: u32 = 30;
41
42/// Default days received signals are kept.
43pub const DEFAULT_SIGNAL_RETENTION_DAYS: u32 = 7;
44
45/// Periodic task that purges terminal runs exceeding the retention policy.
46///
47/// # Examples
48///
49/// ```no_run
50/// use std::sync::Arc;
51/// use std::time::Duration;
52/// use ironflow_api::purger::RunPurger;
53/// use ironflow_store::entities::PurgePolicy;
54/// use ironflow_store::memory::InMemoryStore;
55/// use ironflow_store::store::Store;
56/// use tokio_util::sync::CancellationToken;
57///
58/// # async fn example() {
59/// let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
60/// let policy = PurgePolicy {
61///     max_age_days: 90,
62///     max_runs_per_workflow: 1000,
63///     dry_run: false,
64/// };
65///
66/// let purger = RunPurger::new(store, policy)
67///     .interval(Duration::from_secs(3600));
68/// tokio::spawn(purger.run(CancellationToken::new()));
69/// # }
70/// ```
71pub struct RunPurger {
72    store: Arc<dyn Store>,
73    blob_store: Option<Arc<dyn BlobStore>>,
74    pub(crate) policy: PurgePolicy,
75    pub(crate) interval: Duration,
76    batch_size: u32,
77    pub(crate) usage_retention_days: u32,
78    pub(crate) signal_retention_days: u32,
79}
80
81impl RunPurger {
82    /// Create a purger with the default interval and batch size.
83    pub fn new(store: Arc<dyn Store>, policy: PurgePolicy) -> Self {
84        Self {
85            store,
86            blob_store: None,
87            policy,
88            interval: DEFAULT_PURGE_INTERVAL,
89            batch_size: DEFAULT_PURGE_BATCH_SIZE,
90            usage_retention_days: DEFAULT_USAGE_RETENTION_DAYS,
91            signal_retention_days: DEFAULT_SIGNAL_RETENTION_DAYS,
92        }
93    }
94
95    /// Create a purger from the server configuration.
96    ///
97    /// Takes the purge policy (`PURGE_MAX_AGE_DAYS`, `PURGE_MAX_RUNS_PER_WORKFLOW`,
98    /// `PURGE_DRY_RUN`), the interval (`PURGE_INTERVAL_SECS`) and the retention of
99    /// Provider Account usage history and signals
100    /// (`PROVIDER_ACCOUNT_USAGE_RETENTION_DAYS`, `SIGNAL_RETENTION_DAYS`).
101    /// Chain [`RunPurger::with_blob_store`] to delete artifact blobs too.
102    ///
103    /// # Examples
104    ///
105    /// ```no_run
106    /// use std::sync::Arc;
107    /// use ironflow_api::config::{ConfigError, ServerConfig};
108    /// use ironflow_api::purger::RunPurger;
109    /// use ironflow_store::memory::InMemoryStore;
110    /// use tokio_util::sync::CancellationToken;
111    ///
112    /// # async fn example() -> Result<(), ConfigError> {
113    /// let config = ServerConfig::from_env()?;
114    /// let purger = RunPurger::from_config(Arc::new(InMemoryStore::new()), &config);
115    /// tokio::spawn(purger.run(CancellationToken::new()));
116    /// # Ok(())
117    /// # }
118    /// ```
119    pub fn from_config(store: Arc<dyn Store>, config: &ServerConfig) -> Self {
120        let policy = PurgePolicy {
121            max_age_days: config.purge_max_age_days,
122            max_runs_per_workflow: config.purge_max_runs_per_workflow,
123            dry_run: config.purge_dry_run,
124        };
125        Self::new(store, policy)
126            .interval(Duration::from_secs(config.purge_interval_secs))
127            .usage_retention_days(config.provider_account_usage_retention_days)
128            .signal_retention_days(config.signal_retention_days)
129    }
130
131    /// Set the blob store for artifact deletion.
132    ///
133    /// When `None`, artifact metadata is still removed but no blobs are deleted
134    /// (they either do not exist or become orphans).
135    pub fn with_blob_store(mut self, blob_store: Option<Arc<dyn BlobStore>>) -> Self {
136        self.blob_store = blob_store;
137        self
138    }
139
140    /// Set how often the purger runs.
141    pub fn interval(mut self, interval: Duration) -> Self {
142        self.interval = interval;
143        self
144    }
145
146    /// Set how many runs a single tick processes.
147    pub fn batch_size(mut self, batch_size: u32) -> Self {
148        self.batch_size = batch_size;
149        self
150    }
151
152    /// Set how many days of Provider Account usage history to keep.
153    ///
154    /// # Examples
155    ///
156    /// ```no_run
157    /// use std::sync::Arc;
158    /// use ironflow_api::purger::RunPurger;
159    /// use ironflow_store::entities::PurgePolicy;
160    /// use ironflow_store::memory::InMemoryStore;
161    ///
162    /// let policy = PurgePolicy { max_age_days: 90, max_runs_per_workflow: 1000, dry_run: false };
163    /// let purger = RunPurger::new(Arc::new(InMemoryStore::new()), policy).usage_retention_days(7);
164    /// # let _ = purger;
165    /// ```
166    pub fn usage_retention_days(mut self, days: u32) -> Self {
167        self.usage_retention_days = days;
168        self
169    }
170
171    /// Set how many days received signals are kept.
172    ///
173    /// A signal older than this can no longer resume a run that opens its
174    /// wait step late.
175    ///
176    /// # Examples
177    ///
178    /// ```no_run
179    /// use std::sync::Arc;
180    /// use ironflow_api::purger::RunPurger;
181    /// use ironflow_store::entities::PurgePolicy;
182    /// use ironflow_store::memory::InMemoryStore;
183    ///
184    /// let policy = PurgePolicy { max_age_days: 90, max_runs_per_workflow: 1000, dry_run: false };
185    /// let purger = RunPurger::new(Arc::new(InMemoryStore::new()), policy).signal_retention_days(3);
186    /// # let _ = purger;
187    /// ```
188    pub fn signal_retention_days(mut self, days: u32) -> Self {
189        self.signal_retention_days = days;
190        self
191    }
192
193    /// Run the purge loop until `shutdown` is cancelled.
194    pub async fn run(self, shutdown: CancellationToken) {
195        let mut ticker = interval(self.interval);
196        ticker.tick().await;
197
198        info!(
199            interval_secs = self.interval.as_secs(),
200            batch_size = self.batch_size,
201            max_age_days = self.policy.max_age_days,
202            max_runs_per_workflow = self.policy.max_runs_per_workflow,
203            dry_run = self.policy.dry_run,
204            usage_retention_days = self.usage_retention_days,
205            signal_retention_days = self.signal_retention_days,
206            "purger started"
207        );
208
209        loop {
210            tokio::select! {
211                _ = shutdown.cancelled() => {
212                    info!("purger stopped");
213                    return;
214                }
215                _ = ticker.tick() => {
216                    self.tick().await;
217                }
218            }
219        }
220    }
221
222    /// Delete Provider Account usage history older than the retention.
223    async fn purge_account_usage(&self) {
224        if self.policy.dry_run {
225            return;
226        }
227        let before = Utc::now() - TimeDelta::days(i64::from(self.usage_retention_days));
228        match self.store.purge_provider_account_usage(before).await {
229            Ok(0) => {}
230            Ok(deleted) => info!(deleted, "purged provider account usage history"),
231            Err(err) => error!(error = %err, "failed to purge provider account usage history"),
232        }
233    }
234
235    /// Delete the signals received before the retention window.
236    async fn purge_signals(&self) {
237        if self.policy.dry_run {
238            return;
239        }
240        let before = Utc::now() - TimeDelta::days(i64::from(self.signal_retention_days));
241        match self.store.purge_signals(before).await {
242            Ok(0) => {}
243            Ok(deleted) => info!(deleted, "purged old signals"),
244            Err(err) => error!(error = %err, "failed to purge old signals"),
245        }
246    }
247
248    /// Purge one batch of eligible runs.
249    ///
250    /// Exposed for tests and for callers that drive the schedule themselves.
251    pub async fn tick(&self) {
252        self.purge_account_usage().await;
253        self.purge_signals().await;
254
255        let purgeable = match self
256            .store
257            .list_purgeable_runs(&self.policy, self.batch_size)
258            .await
259        {
260            Ok(p) => p,
261            Err(err) => {
262                error!(error = %err, "failed to list purgeable runs");
263                return;
264            }
265        };
266
267        if purgeable.is_empty() {
268            return;
269        }
270
271        if self.policy.dry_run {
272            for entry in &purgeable {
273                info!(
274                    run_id = %entry.run_id,
275                    workflow = %entry.workflow_name,
276                    reason = %entry.reason,
277                    "[dry-run] would purge run"
278                );
279
280                #[cfg(feature = "prometheus")]
281                counter!(
282                    RUNS_PURGED_TOTAL,
283                    "workflow" => entry.workflow_name.clone(),
284                    "reason" => entry.reason.to_string(),
285                    "dry_run" => "true"
286                )
287                .increment(1);
288            }
289
290            return;
291        }
292
293        warn!(
294            count = purgeable.len(),
295            batch_size = self.batch_size,
296            "purging old runs"
297        );
298
299        for entry in &purgeable {
300            self.purge_run(entry.run_id, &entry.workflow_name, &entry.reason)
301                .await;
302        }
303    }
304
305    async fn purge_run(&self, run_id: Uuid, workflow_name: &str, reason: &PurgeReason) {
306        match self.store.delete_run(run_id).await {
307            Ok(storage_keys) => {
308                if let Some(ref blob_store) = self.blob_store {
309                    for key in &storage_keys {
310                        let refcount = self
311                            .store
312                            .count_artifacts_by_storage_key(key)
313                            .await
314                            .unwrap_or(0);
315                        if refcount > 0 {
316                            continue;
317                        }
318                        if let Err(err) = blob_store.delete(key).await {
319                            error!(
320                                run_id = %run_id,
321                                storage_key = %key,
322                                error = %err,
323                                "failed to delete artifact blob"
324                            );
325                        }
326                    }
327                }
328
329                info!(
330                    run_id = %run_id,
331                    workflow = %workflow_name,
332                    reason = %reason,
333                    "purged run"
334                );
335
336                #[cfg(feature = "prometheus")]
337                counter!(
338                    RUNS_PURGED_TOTAL,
339                    "workflow" => workflow_name.to_string(),
340                    "reason" => reason.to_string(),
341                    "dry_run" => "false"
342                )
343                .increment(1);
344            }
345            Err(err) => {
346                error!(
347                    run_id = %run_id,
348                    workflow = %workflow_name,
349                    error = %err,
350                    "failed to delete run during purge"
351                );
352            }
353        }
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use std::collections::HashMap;
360    use std::time::Duration;
361
362    use chrono::{TimeDelta, Utc};
363    use ironflow_store::entities::{
364        AccountWindowStatus, NewAccountWindow, NewProviderAccount, NewProviderAccountObservation,
365        NewRun, NewSignal, PurgePolicy, RunStatus, SignalFilter, TriggerKind,
366        provider_account_secret_key,
367    };
368    use ironflow_store::memory::InMemoryStore;
369    use ironflow_store::provider_account_store::ProviderAccountStore;
370    use ironflow_store::signal_store::SignalStore;
371    use ironflow_store::store::RunStore;
372    use serde_json::json;
373    use tokio::time::sleep;
374    use uuid::Uuid;
375
376    use super::*;
377
378    fn new_run(name: &str) -> NewRun {
379        NewRun {
380            workflow_name: name.to_string(),
381            trigger: TriggerKind::Manual,
382            payload: json!({}),
383            max_retries: 0,
384            handler_version: None,
385            labels: HashMap::new(),
386            scheduled_at: None,
387            created_by: None,
388            idempotency_key: None,
389            concurrency_key: None,
390            priority: 0,
391            concurrency_limits: Vec::new(),
392            max_cost_usd: None,
393            worker_tags: Vec::new(),
394        }
395    }
396
397    async fn create_terminal_run(store: &InMemoryStore, name: &str, status: RunStatus) -> Uuid {
398        let run = store.create_run(new_run(name)).await.unwrap().into_run();
399        store
400            .update_run_status(run.id, RunStatus::Running)
401            .await
402            .unwrap();
403        store.update_run_status(run.id, status).await.unwrap();
404        run.id
405    }
406
407    async fn backdate_run(store: &InMemoryStore, run_id: Uuid, days: i64) {
408        store
409            .set_run_created_at(run_id, Utc::now() - TimeDelta::days(days))
410            .await;
411    }
412
413    fn build(store: Arc<InMemoryStore>, policy: PurgePolicy) -> RunPurger {
414        let store_dyn: Arc<dyn Store> = store;
415        RunPurger::new(store_dyn, policy)
416    }
417
418    #[tokio::test]
419    async fn purger_purges_old_provider_account_usage() {
420        let store = Arc::new(InMemoryStore::new());
421        let id = Uuid::now_v7();
422        store
423            .create_provider_account(NewProviderAccount {
424                id,
425                name: "perso".to_string(),
426                display_name: "Perso".to_string(),
427                kind: "claude_subscription".to_string(),
428                secret_key: provider_account_secret_key(id),
429                enabled: true,
430                priority: 100,
431                tags: Vec::new(),
432                max_concurrency: None,
433                alert_threshold: 0.8,
434                expires_at: Utc::now() + TimeDelta::days(365),
435                plan: None,
436                created_by: None,
437            })
438            .await
439            .unwrap();
440        let window = |days_ago: i64| NewAccountWindow {
441            window: "five_hour".to_string(),
442            utilization: 0.5,
443            resets_at: None,
444            status: AccountWindowStatus::Allowed,
445            model_scope: None,
446            observed_at: Utc::now() - TimeDelta::days(days_ago),
447        };
448        store
449            .record_provider_account_observation(
450                id,
451                NewProviderAccountObservation {
452                    windows: vec![window(40), window(1)],
453                    auth_failed: false,
454                },
455            )
456            .await
457            .unwrap();
458
459        let policy = PurgePolicy {
460            max_age_days: 90,
461            max_runs_per_workflow: 10000,
462            dry_run: false,
463        };
464        build(store.clone(), policy)
465            .usage_retention_days(30)
466            .tick()
467            .await;
468
469        let history = store
470            .list_provider_account_usage(id, Utc::now() - TimeDelta::days(90))
471            .await
472            .unwrap();
473        assert_eq!(history.len(), 1);
474    }
475
476    #[tokio::test]
477    async fn tick_purges_runs_older_than_max_age() {
478        let store = Arc::new(InMemoryStore::new());
479        let old_id = create_terminal_run(&store, "deploy", RunStatus::Completed).await;
480        backdate_run(&store, old_id, 100).await;
481        let recent_id = create_terminal_run(&store, "deploy", RunStatus::Completed).await;
482
483        let policy = PurgePolicy {
484            max_age_days: 90,
485            max_runs_per_workflow: 10000,
486            dry_run: false,
487        };
488        let purger = build(store.clone(), policy);
489        purger.tick().await;
490
491        assert!(store.get_run(old_id).await.unwrap().is_none());
492        assert!(store.get_run(recent_id).await.unwrap().is_some());
493    }
494
495    #[tokio::test]
496    async fn tick_in_dry_run_does_not_delete() {
497        let store = Arc::new(InMemoryStore::new());
498        let old_id = create_terminal_run(&store, "deploy", RunStatus::Completed).await;
499        backdate_run(&store, old_id, 100).await;
500
501        let policy = PurgePolicy {
502            max_age_days: 90,
503            max_runs_per_workflow: 10000,
504            dry_run: true,
505        };
506        let purger = build(store.clone(), policy);
507        purger.tick().await;
508
509        assert!(store.get_run(old_id).await.unwrap().is_some());
510    }
511
512    #[tokio::test]
513    async fn tick_does_not_purge_non_terminal_runs() {
514        let store = Arc::new(InMemoryStore::new());
515        let pending = store
516            .create_run(new_run("deploy"))
517            .await
518            .unwrap()
519            .into_run();
520        backdate_run(&store, pending.id, 200).await;
521
522        let running = store
523            .create_run(new_run("deploy"))
524            .await
525            .unwrap()
526            .into_run();
527        store
528            .update_run_status(running.id, RunStatus::Running)
529            .await
530            .unwrap();
531        backdate_run(&store, running.id, 200).await;
532
533        let policy = PurgePolicy {
534            max_age_days: 90,
535            max_runs_per_workflow: 10000,
536            dry_run: false,
537        };
538        let purger = build(store.clone(), policy);
539        purger.tick().await;
540
541        assert!(store.get_run(pending.id).await.unwrap().is_some());
542        assert!(store.get_run(running.id).await.unwrap().is_some());
543    }
544
545    #[tokio::test]
546    async fn purge_does_not_delete_blob_when_shared_by_another_artifact() {
547        use ironflow_artifacts::local::LocalBlobStore;
548        use ironflow_artifacts::stream_from_bytes;
549        use ironflow_store::artifact_store::ArtifactStore;
550        use ironflow_store::entities::{NewArtifact, NewStep, StepKind, step_trace_id};
551        use ironflow_store::store::Store;
552        use tempfile::TempDir;
553
554        let dir = TempDir::new().expect("temp dir");
555        let store = Arc::new(InMemoryStore::new());
556        let blob: Arc<dyn BlobStore> = Arc::new(LocalBlobStore::new(dir.path()));
557
558        // Create two runs, each with a step
559        let run_a = store.create_run(new_run("wf")).await.unwrap().into_run();
560        store
561            .update_run_status(run_a.id, RunStatus::Running)
562            .await
563            .unwrap();
564        store
565            .update_run_status(run_a.id, RunStatus::Completed)
566            .await
567            .unwrap();
568        backdate_run(&store, run_a.id, 100).await;
569
570        let step_a = store
571            .create_step(NewStep {
572                run_id: run_a.id,
573                trace_id: step_trace_id(run_a.id, "build", 0),
574                name: "build".to_string(),
575                kind: StepKind::Shell,
576                position: 0,
577                input: None,
578                is_error_handler: false,
579            })
580            .await
581            .unwrap();
582
583        let run_b = store.create_run(new_run("wf")).await.unwrap().into_run();
584        let step_b = store
585            .create_step(NewStep {
586                run_id: run_b.id,
587                trace_id: step_trace_id(run_b.id, "build", 0),
588                name: "build".to_string(),
589                kind: StepKind::Shell,
590                position: 0,
591                input: None,
592                is_error_handler: false,
593            })
594            .await
595            .unwrap();
596
597        // Upload one blob, share the storage_key
598        let shared_key = "artifacts/shared/blob";
599        blob.put(shared_key, stream_from_bytes(b"shared".to_vec()))
600            .await
601            .unwrap();
602
603        let id_a = Uuid::now_v7();
604        store
605            .create_artifact(NewArtifact {
606                id: id_a,
607                run_id: run_a.id,
608                step_id: step_a.id,
609                name: "report.txt".to_string(),
610                storage_key: shared_key.to_string(),
611                content_type: "text/plain".to_string(),
612                size_bytes: 6,
613                sha256: "abc".repeat(21),
614            })
615            .await
616            .unwrap();
617
618        let id_b = Uuid::now_v7();
619        store
620            .create_artifact(NewArtifact {
621                id: id_b,
622                run_id: run_b.id,
623                step_id: step_b.id,
624                name: "report.txt".to_string(),
625                storage_key: shared_key.to_string(),
626                content_type: "text/plain".to_string(),
627                size_bytes: 6,
628                sha256: "abc".repeat(21),
629            })
630            .await
631            .unwrap();
632
633        // Purge run_a (old) -- the blob should survive because run_b still references it
634        let policy = PurgePolicy {
635            max_age_days: 90,
636            max_runs_per_workflow: 10000,
637            dry_run: false,
638        };
639        let store_dyn: Arc<dyn Store> = store.clone();
640        let purger = RunPurger::new(store_dyn, policy).with_blob_store(Some(blob.clone()));
641        purger.tick().await;
642
643        assert!(
644            store.get_run(run_a.id).await.unwrap().is_none(),
645            "run_a purged"
646        );
647        assert!(
648            store.get_run(run_b.id).await.unwrap().is_some(),
649            "run_b kept"
650        );
651
652        // The blob should still exist
653        let get_result = blob.get(shared_key).await;
654        assert!(get_result.is_ok(), "shared blob should not be deleted");
655    }
656
657    #[tokio::test]
658    async fn purger_tick_purges_old_signals() {
659        let store = Arc::new(InMemoryStore::new());
660        store
661            .insert_signal(NewSignal {
662                name: "demo.done".to_string(),
663                key: "k1".to_string(),
664                payload: json!({}),
665                idempotency_id: None,
666            })
667            .await
668            .unwrap();
669        sleep(Duration::from_millis(5)).await;
670        let policy = PurgePolicy {
671            max_age_days: 90,
672            max_runs_per_workflow: 1000,
673            dry_run: false,
674        };
675
676        // A one-day window keeps the fresh signal.
677        build(store.clone(), policy.clone())
678            .signal_retention_days(1)
679            .tick()
680            .await;
681        let kept = store
682            .list_signals(SignalFilter::default(), 1, 20)
683            .await
684            .unwrap();
685        assert_eq!(kept.total, 1);
686
687        // A zero-day window drops everything received before now.
688        build(store.clone(), policy)
689            .signal_retention_days(0)
690            .tick()
691            .await;
692        let purged = store
693            .list_signals(SignalFilter::default(), 1, 20)
694            .await
695            .unwrap();
696        assert_eq!(purged.total, 0);
697    }
698
699    #[tokio::test]
700    async fn run_stops_on_shutdown() {
701        let store = Arc::new(InMemoryStore::new());
702        let policy = PurgePolicy {
703            max_age_days: 90,
704            max_runs_per_workflow: 1000,
705            dry_run: false,
706        };
707        let purger = build(store, policy);
708        let shutdown = CancellationToken::new();
709        shutdown.cancel();
710
711        tokio::time::timeout(Duration::from_secs(5), purger.run(shutdown))
712            .await
713            .expect("purger stopped");
714    }
715}