1use std::sync::Arc;
14use std::time::Duration;
15
16use chrono::{TimeDelta, Utc};
17
18use uuid::Uuid;
19
20use crate::config::ServerConfig;
21use ironflow_artifacts::blob_store::BlobStore;
22use ironflow_store::entities::{PurgePolicy, PurgeReason};
23use ironflow_store::store::Store;
24use tokio::time::interval;
25use tokio_util::sync::CancellationToken;
26use tracing::{error, info, warn};
27
28#[cfg(feature = "prometheus")]
29use ironflow_core::metric_names::RUNS_PURGED_TOTAL;
30#[cfg(feature = "prometheus")]
31use metrics::counter;
32
33pub const DEFAULT_PURGE_INTERVAL: Duration = Duration::from_secs(86400);
35
36pub const DEFAULT_PURGE_BATCH_SIZE: u32 = 100;
38
39pub const DEFAULT_USAGE_RETENTION_DAYS: u32 = 30;
41
42pub const DEFAULT_SIGNAL_RETENTION_DAYS: u32 = 7;
44
45pub struct RunPurger {
72 store: Arc<dyn Store>,
73 blob_store: Option<Arc<dyn BlobStore>>,
74 pub(crate) policy: PurgePolicy,
75 pub(crate) interval: Duration,
76 batch_size: u32,
77 pub(crate) usage_retention_days: u32,
78 pub(crate) signal_retention_days: u32,
79}
80
81impl RunPurger {
82 pub fn new(store: Arc<dyn Store>, policy: PurgePolicy) -> Self {
84 Self {
85 store,
86 blob_store: None,
87 policy,
88 interval: DEFAULT_PURGE_INTERVAL,
89 batch_size: DEFAULT_PURGE_BATCH_SIZE,
90 usage_retention_days: DEFAULT_USAGE_RETENTION_DAYS,
91 signal_retention_days: DEFAULT_SIGNAL_RETENTION_DAYS,
92 }
93 }
94
95 pub fn from_config(store: Arc<dyn Store>, config: &ServerConfig) -> Self {
120 let policy = PurgePolicy {
121 max_age_days: config.purge_max_age_days,
122 max_runs_per_workflow: config.purge_max_runs_per_workflow,
123 dry_run: config.purge_dry_run,
124 };
125 Self::new(store, policy)
126 .interval(Duration::from_secs(config.purge_interval_secs))
127 .usage_retention_days(config.provider_account_usage_retention_days)
128 .signal_retention_days(config.signal_retention_days)
129 }
130
131 pub fn with_blob_store(mut self, blob_store: Option<Arc<dyn BlobStore>>) -> Self {
136 self.blob_store = blob_store;
137 self
138 }
139
140 pub fn interval(mut self, interval: Duration) -> Self {
142 self.interval = interval;
143 self
144 }
145
146 pub fn batch_size(mut self, batch_size: u32) -> Self {
148 self.batch_size = batch_size;
149 self
150 }
151
152 pub fn usage_retention_days(mut self, days: u32) -> Self {
167 self.usage_retention_days = days;
168 self
169 }
170
171 pub fn signal_retention_days(mut self, days: u32) -> Self {
189 self.signal_retention_days = days;
190 self
191 }
192
193 pub async fn run(self, shutdown: CancellationToken) {
195 let mut ticker = interval(self.interval);
196 ticker.tick().await;
197
198 info!(
199 interval_secs = self.interval.as_secs(),
200 batch_size = self.batch_size,
201 max_age_days = self.policy.max_age_days,
202 max_runs_per_workflow = self.policy.max_runs_per_workflow,
203 dry_run = self.policy.dry_run,
204 usage_retention_days = self.usage_retention_days,
205 signal_retention_days = self.signal_retention_days,
206 "purger started"
207 );
208
209 loop {
210 tokio::select! {
211 _ = shutdown.cancelled() => {
212 info!("purger stopped");
213 return;
214 }
215 _ = ticker.tick() => {
216 self.tick().await;
217 }
218 }
219 }
220 }
221
222 async fn purge_account_usage(&self) {
224 if self.policy.dry_run {
225 return;
226 }
227 let before = Utc::now() - TimeDelta::days(i64::from(self.usage_retention_days));
228 match self.store.purge_provider_account_usage(before).await {
229 Ok(0) => {}
230 Ok(deleted) => info!(deleted, "purged provider account usage history"),
231 Err(err) => error!(error = %err, "failed to purge provider account usage history"),
232 }
233 }
234
235 async fn purge_signals(&self) {
237 if self.policy.dry_run {
238 return;
239 }
240 let before = Utc::now() - TimeDelta::days(i64::from(self.signal_retention_days));
241 match self.store.purge_signals(before).await {
242 Ok(0) => {}
243 Ok(deleted) => info!(deleted, "purged old signals"),
244 Err(err) => error!(error = %err, "failed to purge old signals"),
245 }
246 }
247
248 pub async fn tick(&self) {
252 self.purge_account_usage().await;
253 self.purge_signals().await;
254
255 let purgeable = match self
256 .store
257 .list_purgeable_runs(&self.policy, self.batch_size)
258 .await
259 {
260 Ok(p) => p,
261 Err(err) => {
262 error!(error = %err, "failed to list purgeable runs");
263 return;
264 }
265 };
266
267 if purgeable.is_empty() {
268 return;
269 }
270
271 if self.policy.dry_run {
272 for entry in &purgeable {
273 info!(
274 run_id = %entry.run_id,
275 workflow = %entry.workflow_name,
276 reason = %entry.reason,
277 "[dry-run] would purge run"
278 );
279
280 #[cfg(feature = "prometheus")]
281 counter!(
282 RUNS_PURGED_TOTAL,
283 "workflow" => entry.workflow_name.clone(),
284 "reason" => entry.reason.to_string(),
285 "dry_run" => "true"
286 )
287 .increment(1);
288 }
289
290 return;
291 }
292
293 warn!(
294 count = purgeable.len(),
295 batch_size = self.batch_size,
296 "purging old runs"
297 );
298
299 for entry in &purgeable {
300 self.purge_run(entry.run_id, &entry.workflow_name, &entry.reason)
301 .await;
302 }
303 }
304
305 async fn purge_run(&self, run_id: Uuid, workflow_name: &str, reason: &PurgeReason) {
306 match self.store.delete_run(run_id).await {
307 Ok(storage_keys) => {
308 if let Some(ref blob_store) = self.blob_store {
309 for key in &storage_keys {
310 let refcount = self
311 .store
312 .count_artifacts_by_storage_key(key)
313 .await
314 .unwrap_or(0);
315 if refcount > 0 {
316 continue;
317 }
318 if let Err(err) = blob_store.delete(key).await {
319 error!(
320 run_id = %run_id,
321 storage_key = %key,
322 error = %err,
323 "failed to delete artifact blob"
324 );
325 }
326 }
327 }
328
329 info!(
330 run_id = %run_id,
331 workflow = %workflow_name,
332 reason = %reason,
333 "purged run"
334 );
335
336 #[cfg(feature = "prometheus")]
337 counter!(
338 RUNS_PURGED_TOTAL,
339 "workflow" => workflow_name.to_string(),
340 "reason" => reason.to_string(),
341 "dry_run" => "false"
342 )
343 .increment(1);
344 }
345 Err(err) => {
346 error!(
347 run_id = %run_id,
348 workflow = %workflow_name,
349 error = %err,
350 "failed to delete run during purge"
351 );
352 }
353 }
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use std::collections::HashMap;
360 use std::time::Duration;
361
362 use chrono::{TimeDelta, Utc};
363 use ironflow_store::entities::{
364 AccountWindowStatus, NewAccountWindow, NewProviderAccount, NewProviderAccountObservation,
365 NewRun, NewSignal, PurgePolicy, RunStatus, SignalFilter, TriggerKind,
366 provider_account_secret_key,
367 };
368 use ironflow_store::memory::InMemoryStore;
369 use ironflow_store::provider_account_store::ProviderAccountStore;
370 use ironflow_store::signal_store::SignalStore;
371 use ironflow_store::store::RunStore;
372 use serde_json::json;
373 use tokio::time::sleep;
374 use uuid::Uuid;
375
376 use super::*;
377
378 fn new_run(name: &str) -> NewRun {
379 NewRun {
380 workflow_name: name.to_string(),
381 trigger: TriggerKind::Manual,
382 payload: json!({}),
383 max_retries: 0,
384 handler_version: None,
385 labels: HashMap::new(),
386 scheduled_at: None,
387 created_by: None,
388 idempotency_key: None,
389 concurrency_key: None,
390 priority: 0,
391 concurrency_limits: Vec::new(),
392 max_cost_usd: None,
393 worker_tags: Vec::new(),
394 }
395 }
396
397 async fn create_terminal_run(store: &InMemoryStore, name: &str, status: RunStatus) -> Uuid {
398 let run = store.create_run(new_run(name)).await.unwrap().into_run();
399 store
400 .update_run_status(run.id, RunStatus::Running)
401 .await
402 .unwrap();
403 store.update_run_status(run.id, status).await.unwrap();
404 run.id
405 }
406
407 async fn backdate_run(store: &InMemoryStore, run_id: Uuid, days: i64) {
408 store
409 .set_run_created_at(run_id, Utc::now() - TimeDelta::days(days))
410 .await;
411 }
412
413 fn build(store: Arc<InMemoryStore>, policy: PurgePolicy) -> RunPurger {
414 let store_dyn: Arc<dyn Store> = store;
415 RunPurger::new(store_dyn, policy)
416 }
417
418 #[tokio::test]
419 async fn purger_purges_old_provider_account_usage() {
420 let store = Arc::new(InMemoryStore::new());
421 let id = Uuid::now_v7();
422 store
423 .create_provider_account(NewProviderAccount {
424 id,
425 name: "perso".to_string(),
426 display_name: "Perso".to_string(),
427 kind: "claude_subscription".to_string(),
428 secret_key: provider_account_secret_key(id),
429 enabled: true,
430 priority: 100,
431 tags: Vec::new(),
432 max_concurrency: None,
433 alert_threshold: 0.8,
434 expires_at: Utc::now() + TimeDelta::days(365),
435 plan: None,
436 created_by: None,
437 })
438 .await
439 .unwrap();
440 let window = |days_ago: i64| NewAccountWindow {
441 window: "five_hour".to_string(),
442 utilization: 0.5,
443 resets_at: None,
444 status: AccountWindowStatus::Allowed,
445 model_scope: None,
446 observed_at: Utc::now() - TimeDelta::days(days_ago),
447 };
448 store
449 .record_provider_account_observation(
450 id,
451 NewProviderAccountObservation {
452 windows: vec![window(40), window(1)],
453 auth_failed: false,
454 },
455 )
456 .await
457 .unwrap();
458
459 let policy = PurgePolicy {
460 max_age_days: 90,
461 max_runs_per_workflow: 10000,
462 dry_run: false,
463 };
464 build(store.clone(), policy)
465 .usage_retention_days(30)
466 .tick()
467 .await;
468
469 let history = store
470 .list_provider_account_usage(id, Utc::now() - TimeDelta::days(90))
471 .await
472 .unwrap();
473 assert_eq!(history.len(), 1);
474 }
475
476 #[tokio::test]
477 async fn tick_purges_runs_older_than_max_age() {
478 let store = Arc::new(InMemoryStore::new());
479 let old_id = create_terminal_run(&store, "deploy", RunStatus::Completed).await;
480 backdate_run(&store, old_id, 100).await;
481 let recent_id = create_terminal_run(&store, "deploy", RunStatus::Completed).await;
482
483 let policy = PurgePolicy {
484 max_age_days: 90,
485 max_runs_per_workflow: 10000,
486 dry_run: false,
487 };
488 let purger = build(store.clone(), policy);
489 purger.tick().await;
490
491 assert!(store.get_run(old_id).await.unwrap().is_none());
492 assert!(store.get_run(recent_id).await.unwrap().is_some());
493 }
494
495 #[tokio::test]
496 async fn tick_in_dry_run_does_not_delete() {
497 let store = Arc::new(InMemoryStore::new());
498 let old_id = create_terminal_run(&store, "deploy", RunStatus::Completed).await;
499 backdate_run(&store, old_id, 100).await;
500
501 let policy = PurgePolicy {
502 max_age_days: 90,
503 max_runs_per_workflow: 10000,
504 dry_run: true,
505 };
506 let purger = build(store.clone(), policy);
507 purger.tick().await;
508
509 assert!(store.get_run(old_id).await.unwrap().is_some());
510 }
511
512 #[tokio::test]
513 async fn tick_does_not_purge_non_terminal_runs() {
514 let store = Arc::new(InMemoryStore::new());
515 let pending = store
516 .create_run(new_run("deploy"))
517 .await
518 .unwrap()
519 .into_run();
520 backdate_run(&store, pending.id, 200).await;
521
522 let running = store
523 .create_run(new_run("deploy"))
524 .await
525 .unwrap()
526 .into_run();
527 store
528 .update_run_status(running.id, RunStatus::Running)
529 .await
530 .unwrap();
531 backdate_run(&store, running.id, 200).await;
532
533 let policy = PurgePolicy {
534 max_age_days: 90,
535 max_runs_per_workflow: 10000,
536 dry_run: false,
537 };
538 let purger = build(store.clone(), policy);
539 purger.tick().await;
540
541 assert!(store.get_run(pending.id).await.unwrap().is_some());
542 assert!(store.get_run(running.id).await.unwrap().is_some());
543 }
544
545 #[tokio::test]
546 async fn purge_does_not_delete_blob_when_shared_by_another_artifact() {
547 use ironflow_artifacts::local::LocalBlobStore;
548 use ironflow_artifacts::stream_from_bytes;
549 use ironflow_store::artifact_store::ArtifactStore;
550 use ironflow_store::entities::{NewArtifact, NewStep, StepKind, step_trace_id};
551 use ironflow_store::store::Store;
552 use tempfile::TempDir;
553
554 let dir = TempDir::new().expect("temp dir");
555 let store = Arc::new(InMemoryStore::new());
556 let blob: Arc<dyn BlobStore> = Arc::new(LocalBlobStore::new(dir.path()));
557
558 let run_a = store.create_run(new_run("wf")).await.unwrap().into_run();
560 store
561 .update_run_status(run_a.id, RunStatus::Running)
562 .await
563 .unwrap();
564 store
565 .update_run_status(run_a.id, RunStatus::Completed)
566 .await
567 .unwrap();
568 backdate_run(&store, run_a.id, 100).await;
569
570 let step_a = store
571 .create_step(NewStep {
572 run_id: run_a.id,
573 trace_id: step_trace_id(run_a.id, "build", 0),
574 name: "build".to_string(),
575 kind: StepKind::Shell,
576 position: 0,
577 input: None,
578 is_error_handler: false,
579 })
580 .await
581 .unwrap();
582
583 let run_b = store.create_run(new_run("wf")).await.unwrap().into_run();
584 let step_b = store
585 .create_step(NewStep {
586 run_id: run_b.id,
587 trace_id: step_trace_id(run_b.id, "build", 0),
588 name: "build".to_string(),
589 kind: StepKind::Shell,
590 position: 0,
591 input: None,
592 is_error_handler: false,
593 })
594 .await
595 .unwrap();
596
597 let shared_key = "artifacts/shared/blob";
599 blob.put(shared_key, stream_from_bytes(b"shared".to_vec()))
600 .await
601 .unwrap();
602
603 let id_a = Uuid::now_v7();
604 store
605 .create_artifact(NewArtifact {
606 id: id_a,
607 run_id: run_a.id,
608 step_id: step_a.id,
609 name: "report.txt".to_string(),
610 storage_key: shared_key.to_string(),
611 content_type: "text/plain".to_string(),
612 size_bytes: 6,
613 sha256: "abc".repeat(21),
614 })
615 .await
616 .unwrap();
617
618 let id_b = Uuid::now_v7();
619 store
620 .create_artifact(NewArtifact {
621 id: id_b,
622 run_id: run_b.id,
623 step_id: step_b.id,
624 name: "report.txt".to_string(),
625 storage_key: shared_key.to_string(),
626 content_type: "text/plain".to_string(),
627 size_bytes: 6,
628 sha256: "abc".repeat(21),
629 })
630 .await
631 .unwrap();
632
633 let policy = PurgePolicy {
635 max_age_days: 90,
636 max_runs_per_workflow: 10000,
637 dry_run: false,
638 };
639 let store_dyn: Arc<dyn Store> = store.clone();
640 let purger = RunPurger::new(store_dyn, policy).with_blob_store(Some(blob.clone()));
641 purger.tick().await;
642
643 assert!(
644 store.get_run(run_a.id).await.unwrap().is_none(),
645 "run_a purged"
646 );
647 assert!(
648 store.get_run(run_b.id).await.unwrap().is_some(),
649 "run_b kept"
650 );
651
652 let get_result = blob.get(shared_key).await;
654 assert!(get_result.is_ok(), "shared blob should not be deleted");
655 }
656
657 #[tokio::test]
658 async fn purger_tick_purges_old_signals() {
659 let store = Arc::new(InMemoryStore::new());
660 store
661 .insert_signal(NewSignal {
662 name: "demo.done".to_string(),
663 key: "k1".to_string(),
664 payload: json!({}),
665 idempotency_id: None,
666 })
667 .await
668 .unwrap();
669 sleep(Duration::from_millis(5)).await;
670 let policy = PurgePolicy {
671 max_age_days: 90,
672 max_runs_per_workflow: 1000,
673 dry_run: false,
674 };
675
676 build(store.clone(), policy.clone())
678 .signal_retention_days(1)
679 .tick()
680 .await;
681 let kept = store
682 .list_signals(SignalFilter::default(), 1, 20)
683 .await
684 .unwrap();
685 assert_eq!(kept.total, 1);
686
687 build(store.clone(), policy)
689 .signal_retention_days(0)
690 .tick()
691 .await;
692 let purged = store
693 .list_signals(SignalFilter::default(), 1, 20)
694 .await
695 .unwrap();
696 assert_eq!(purged.total, 0);
697 }
698
699 #[tokio::test]
700 async fn run_stops_on_shutdown() {
701 let store = Arc::new(InMemoryStore::new());
702 let policy = PurgePolicy {
703 max_age_days: 90,
704 max_runs_per_workflow: 1000,
705 dry_run: false,
706 };
707 let purger = build(store, policy);
708 let shutdown = CancellationToken::new();
709 shutdown.cancel();
710
711 tokio::time::timeout(Duration::from_secs(5), purger.run(shutdown))
712 .await
713 .expect("purger stopped");
714 }
715}