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 const DEFAULT_SIGNAL_RETENTION_DAYS: u32 = 7;
41
42pub 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 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 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 pub fn interval(mut self, interval: Duration) -> Self {
103 self.interval = interval;
104 self
105 }
106
107 pub fn batch_size(mut self, batch_size: u32) -> Self {
109 self.batch_size = batch_size;
110 self
111 }
112
113 pub fn usage_retention_days(mut self, days: u32) -> Self {
128 self.usage_retention_days = days;
129 self
130 }
131
132 pub fn signal_retention_days(mut self, days: u32) -> Self {
150 self.signal_retention_days = days;
151 self
152 }
153
154 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 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 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 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 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 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 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 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 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 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}