1use 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
28pub const DEFAULT_PURGE_INTERVAL: Duration = Duration::from_secs(86400);
30
31pub const DEFAULT_PURGE_BATCH_SIZE: u32 = 100;
33
34pub 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 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 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 pub fn interval(mut self, interval: Duration) -> Self {
91 self.interval = interval;
92 self
93 }
94
95 pub fn batch_size(mut self, batch_size: u32) -> Self {
97 self.batch_size = batch_size;
98 self
99 }
100
101 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 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 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 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 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 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}