1use 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
30pub const DEFAULT_PURGE_INTERVAL: Duration = Duration::from_secs(86400);
32
33pub const DEFAULT_PURGE_BATCH_SIZE: u32 = 100;
35
36pub const DEFAULT_USAGE_RETENTION_DAYS: u32 = 30;
38
39pub 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 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 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 pub fn interval(mut self, interval: Duration) -> Self {
98 self.interval = interval;
99 self
100 }
101
102 pub fn batch_size(mut self, batch_size: u32) -> Self {
104 self.batch_size = batch_size;
105 self
106 }
107
108 pub fn usage_retention_days(mut self, days: u32) -> Self {
123 self.usage_retention_days = days;
124 self
125 }
126
127 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 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 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 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 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 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 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}