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