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            concurrency_limits: Vec::new(),
391            max_cost_usd: None,
392            worker_tags: Vec::new(),
393        }
394    }
395
396    async fn create_terminal_run(store: &InMemoryStore, name: &str, status: RunStatus) -> Uuid {
397        let run = store.create_run(new_run(name)).await.unwrap().into_run();
398        store
399            .update_run_status(run.id, RunStatus::Running)
400            .await
401            .unwrap();
402        store.update_run_status(run.id, status).await.unwrap();
403        run.id
404    }
405
406    async fn backdate_run(store: &InMemoryStore, run_id: Uuid, days: i64) {
407        store
408            .set_run_created_at(run_id, Utc::now() - TimeDelta::days(days))
409            .await;
410    }
411
412    fn build(store: Arc<InMemoryStore>, policy: PurgePolicy) -> RunPurger {
413        let store_dyn: Arc<dyn Store> = store;
414        RunPurger::new(store_dyn, policy)
415    }
416
417    #[tokio::test]
418    async fn purger_purges_old_provider_account_usage() {
419        let store = Arc::new(InMemoryStore::new());
420        let id = Uuid::now_v7();
421        store
422            .create_provider_account(NewProviderAccount {
423                id,
424                name: "perso".to_string(),
425                display_name: "Perso".to_string(),
426                kind: "claude_subscription".to_string(),
427                secret_key: provider_account_secret_key(id),
428                enabled: true,
429                priority: 100,
430                tags: Vec::new(),
431                max_concurrency: None,
432                alert_threshold: 0.8,
433                expires_at: Utc::now() + TimeDelta::days(365),
434                plan: None,
435                created_by: None,
436            })
437            .await
438            .unwrap();
439        let window = |days_ago: i64| NewAccountWindow {
440            window: "five_hour".to_string(),
441            utilization: 0.5,
442            resets_at: None,
443            status: AccountWindowStatus::Allowed,
444            model_scope: None,
445            observed_at: Utc::now() - TimeDelta::days(days_ago),
446        };
447        store
448            .record_provider_account_observation(
449                id,
450                NewProviderAccountObservation {
451                    windows: vec![window(40), window(1)],
452                    auth_failed: false,
453                },
454            )
455            .await
456            .unwrap();
457
458        let policy = PurgePolicy {
459            max_age_days: 90,
460            max_runs_per_workflow: 10000,
461            dry_run: false,
462        };
463        build(store.clone(), policy)
464            .usage_retention_days(30)
465            .tick()
466            .await;
467
468        let history = store
469            .list_provider_account_usage(id, Utc::now() - TimeDelta::days(90))
470            .await
471            .unwrap();
472        assert_eq!(history.len(), 1);
473    }
474
475    #[tokio::test]
476    async fn tick_purges_runs_older_than_max_age() {
477        let store = Arc::new(InMemoryStore::new());
478        let old_id = create_terminal_run(&store, "deploy", RunStatus::Completed).await;
479        backdate_run(&store, old_id, 100).await;
480        let recent_id = create_terminal_run(&store, "deploy", RunStatus::Completed).await;
481
482        let policy = PurgePolicy {
483            max_age_days: 90,
484            max_runs_per_workflow: 10000,
485            dry_run: false,
486        };
487        let purger = build(store.clone(), policy);
488        purger.tick().await;
489
490        assert!(store.get_run(old_id).await.unwrap().is_none());
491        assert!(store.get_run(recent_id).await.unwrap().is_some());
492    }
493
494    #[tokio::test]
495    async fn tick_in_dry_run_does_not_delete() {
496        let store = Arc::new(InMemoryStore::new());
497        let old_id = create_terminal_run(&store, "deploy", RunStatus::Completed).await;
498        backdate_run(&store, old_id, 100).await;
499
500        let policy = PurgePolicy {
501            max_age_days: 90,
502            max_runs_per_workflow: 10000,
503            dry_run: true,
504        };
505        let purger = build(store.clone(), policy);
506        purger.tick().await;
507
508        assert!(store.get_run(old_id).await.unwrap().is_some());
509    }
510
511    #[tokio::test]
512    async fn tick_does_not_purge_non_terminal_runs() {
513        let store = Arc::new(InMemoryStore::new());
514        let pending = store
515            .create_run(new_run("deploy"))
516            .await
517            .unwrap()
518            .into_run();
519        backdate_run(&store, pending.id, 200).await;
520
521        let running = store
522            .create_run(new_run("deploy"))
523            .await
524            .unwrap()
525            .into_run();
526        store
527            .update_run_status(running.id, RunStatus::Running)
528            .await
529            .unwrap();
530        backdate_run(&store, running.id, 200).await;
531
532        let policy = PurgePolicy {
533            max_age_days: 90,
534            max_runs_per_workflow: 10000,
535            dry_run: false,
536        };
537        let purger = build(store.clone(), policy);
538        purger.tick().await;
539
540        assert!(store.get_run(pending.id).await.unwrap().is_some());
541        assert!(store.get_run(running.id).await.unwrap().is_some());
542    }
543
544    #[tokio::test]
545    async fn purge_does_not_delete_blob_when_shared_by_another_artifact() {
546        use ironflow_artifacts::local::LocalBlobStore;
547        use ironflow_artifacts::stream_from_bytes;
548        use ironflow_store::artifact_store::ArtifactStore;
549        use ironflow_store::entities::{NewArtifact, NewStep, StepKind, step_trace_id};
550        use ironflow_store::store::Store;
551        use tempfile::TempDir;
552
553        let dir = TempDir::new().expect("temp dir");
554        let store = Arc::new(InMemoryStore::new());
555        let blob: Arc<dyn BlobStore> = Arc::new(LocalBlobStore::new(dir.path()));
556
557        // Create two runs, each with a step
558        let run_a = store.create_run(new_run("wf")).await.unwrap().into_run();
559        store
560            .update_run_status(run_a.id, RunStatus::Running)
561            .await
562            .unwrap();
563        store
564            .update_run_status(run_a.id, RunStatus::Completed)
565            .await
566            .unwrap();
567        backdate_run(&store, run_a.id, 100).await;
568
569        let step_a = store
570            .create_step(NewStep {
571                run_id: run_a.id,
572                trace_id: step_trace_id(run_a.id, "build", 0),
573                name: "build".to_string(),
574                kind: StepKind::Shell,
575                position: 0,
576                input: None,
577                is_error_handler: false,
578            })
579            .await
580            .unwrap();
581
582        let run_b = store.create_run(new_run("wf")).await.unwrap().into_run();
583        let step_b = store
584            .create_step(NewStep {
585                run_id: run_b.id,
586                trace_id: step_trace_id(run_b.id, "build", 0),
587                name: "build".to_string(),
588                kind: StepKind::Shell,
589                position: 0,
590                input: None,
591                is_error_handler: false,
592            })
593            .await
594            .unwrap();
595
596        // Upload one blob, share the storage_key
597        let shared_key = "artifacts/shared/blob";
598        blob.put(shared_key, stream_from_bytes(b"shared".to_vec()))
599            .await
600            .unwrap();
601
602        let id_a = Uuid::now_v7();
603        store
604            .create_artifact(NewArtifact {
605                id: id_a,
606                run_id: run_a.id,
607                step_id: step_a.id,
608                name: "report.txt".to_string(),
609                storage_key: shared_key.to_string(),
610                content_type: "text/plain".to_string(),
611                size_bytes: 6,
612                sha256: "abc".repeat(21),
613            })
614            .await
615            .unwrap();
616
617        let id_b = Uuid::now_v7();
618        store
619            .create_artifact(NewArtifact {
620                id: id_b,
621                run_id: run_b.id,
622                step_id: step_b.id,
623                name: "report.txt".to_string(),
624                storage_key: shared_key.to_string(),
625                content_type: "text/plain".to_string(),
626                size_bytes: 6,
627                sha256: "abc".repeat(21),
628            })
629            .await
630            .unwrap();
631
632        // Purge run_a (old) -- the blob should survive because run_b still references it
633        let policy = PurgePolicy {
634            max_age_days: 90,
635            max_runs_per_workflow: 10000,
636            dry_run: false,
637        };
638        let store_dyn: Arc<dyn Store> = store.clone();
639        let purger = RunPurger::new(store_dyn, policy).with_blob_store(Some(blob.clone()));
640        purger.tick().await;
641
642        assert!(
643            store.get_run(run_a.id).await.unwrap().is_none(),
644            "run_a purged"
645        );
646        assert!(
647            store.get_run(run_b.id).await.unwrap().is_some(),
648            "run_b kept"
649        );
650
651        // The blob should still exist
652        let get_result = blob.get(shared_key).await;
653        assert!(get_result.is_ok(), "shared blob should not be deleted");
654    }
655
656    #[tokio::test]
657    async fn purger_tick_purges_old_signals() {
658        let store = Arc::new(InMemoryStore::new());
659        store
660            .insert_signal(NewSignal {
661                name: "demo.done".to_string(),
662                key: "k1".to_string(),
663                payload: json!({}),
664                idempotency_id: None,
665            })
666            .await
667            .unwrap();
668        sleep(Duration::from_millis(5)).await;
669        let policy = PurgePolicy {
670            max_age_days: 90,
671            max_runs_per_workflow: 1000,
672            dry_run: false,
673        };
674
675        // A one-day window keeps the fresh signal.
676        build(store.clone(), policy.clone())
677            .signal_retention_days(1)
678            .tick()
679            .await;
680        let kept = store
681            .list_signals(SignalFilter::default(), 1, 20)
682            .await
683            .unwrap();
684        assert_eq!(kept.total, 1);
685
686        // A zero-day window drops everything received before now.
687        build(store.clone(), policy)
688            .signal_retention_days(0)
689            .tick()
690            .await;
691        let purged = store
692            .list_signals(SignalFilter::default(), 1, 20)
693            .await
694            .unwrap();
695        assert_eq!(purged.total, 0);
696    }
697
698    #[tokio::test]
699    async fn run_stops_on_shutdown() {
700        let store = Arc::new(InMemoryStore::new());
701        let policy = PurgePolicy {
702            max_age_days: 90,
703            max_runs_per_workflow: 1000,
704            dry_run: false,
705        };
706        let purger = build(store, policy);
707        let shutdown = CancellationToken::new();
708        shutdown.cancel();
709
710        tokio::time::timeout(Duration::from_secs(5), purger.run(shutdown))
711            .await
712            .expect("purger stopped");
713    }
714}