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