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