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