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