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 if let Err(err) = blob_store.delete(key).await {
188 error!(
189 run_id = %run_id,
190 storage_key = %key,
191 error = %err,
192 "failed to delete artifact blob"
193 );
194 }
195 }
196 }
197
198 info!(
199 run_id = %run_id,
200 workflow = %workflow_name,
201 reason = %reason,
202 "purged run"
203 );
204
205 #[cfg(feature = "prometheus")]
206 counter!(
207 RUNS_PURGED_TOTAL,
208 "workflow" => workflow_name.to_string(),
209 "reason" => reason.to_string(),
210 "dry_run" => "false"
211 )
212 .increment(1);
213 }
214 Err(err) => {
215 error!(
216 run_id = %run_id,
217 workflow = %workflow_name,
218 error = %err,
219 "failed to delete run during purge"
220 );
221 }
222 }
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use std::collections::HashMap;
229 use std::time::Duration;
230
231 use chrono::{TimeDelta, Utc};
232 use ironflow_store::entities::{NewRun, PurgePolicy, RunStatus, TriggerKind};
233 use ironflow_store::memory::InMemoryStore;
234 use ironflow_store::store::RunStore;
235 use serde_json::json;
236 use uuid::Uuid;
237
238 use super::*;
239
240 fn new_run(name: &str) -> NewRun {
241 NewRun {
242 workflow_name: name.to_string(),
243 trigger: TriggerKind::Manual,
244 payload: json!({}),
245 max_retries: 0,
246 handler_version: None,
247 labels: HashMap::new(),
248 scheduled_at: None,
249 created_by: None,
250 idempotency_key: None,
251 max_cost_usd: None,
252 }
253 }
254
255 async fn create_terminal_run(store: &InMemoryStore, name: &str, status: RunStatus) -> Uuid {
256 let run = store.create_run(new_run(name)).await.unwrap().into_run();
257 store
258 .update_run_status(run.id, RunStatus::Running)
259 .await
260 .unwrap();
261 store.update_run_status(run.id, status).await.unwrap();
262 run.id
263 }
264
265 async fn backdate_run(store: &InMemoryStore, run_id: Uuid, days: i64) {
266 store
267 .set_run_created_at(run_id, Utc::now() - TimeDelta::days(days))
268 .await;
269 }
270
271 fn build(store: Arc<InMemoryStore>, policy: PurgePolicy) -> RunPurger {
272 let store_dyn: Arc<dyn Store> = store;
273 RunPurger::new(store_dyn, policy)
274 }
275
276 #[tokio::test]
277 async fn tick_purges_runs_older_than_max_age() {
278 let store = Arc::new(InMemoryStore::new());
279 let old_id = create_terminal_run(&store, "deploy", RunStatus::Completed).await;
280 backdate_run(&store, old_id, 100).await;
281 let recent_id = create_terminal_run(&store, "deploy", RunStatus::Completed).await;
282
283 let policy = PurgePolicy {
284 max_age_days: 90,
285 max_runs_per_workflow: 10000,
286 dry_run: false,
287 };
288 let purger = build(store.clone(), policy);
289 purger.tick().await;
290
291 assert!(store.get_run(old_id).await.unwrap().is_none());
292 assert!(store.get_run(recent_id).await.unwrap().is_some());
293 }
294
295 #[tokio::test]
296 async fn tick_in_dry_run_does_not_delete() {
297 let store = Arc::new(InMemoryStore::new());
298 let old_id = create_terminal_run(&store, "deploy", RunStatus::Completed).await;
299 backdate_run(&store, old_id, 100).await;
300
301 let policy = PurgePolicy {
302 max_age_days: 90,
303 max_runs_per_workflow: 10000,
304 dry_run: true,
305 };
306 let purger = build(store.clone(), policy);
307 purger.tick().await;
308
309 assert!(store.get_run(old_id).await.unwrap().is_some());
310 }
311
312 #[tokio::test]
313 async fn tick_does_not_purge_non_terminal_runs() {
314 let store = Arc::new(InMemoryStore::new());
315 let pending = store
316 .create_run(new_run("deploy"))
317 .await
318 .unwrap()
319 .into_run();
320 backdate_run(&store, pending.id, 200).await;
321
322 let running = store
323 .create_run(new_run("deploy"))
324 .await
325 .unwrap()
326 .into_run();
327 store
328 .update_run_status(running.id, RunStatus::Running)
329 .await
330 .unwrap();
331 backdate_run(&store, running.id, 200).await;
332
333 let policy = PurgePolicy {
334 max_age_days: 90,
335 max_runs_per_workflow: 10000,
336 dry_run: false,
337 };
338 let purger = build(store.clone(), policy);
339 purger.tick().await;
340
341 assert!(store.get_run(pending.id).await.unwrap().is_some());
342 assert!(store.get_run(running.id).await.unwrap().is_some());
343 }
344
345 #[tokio::test]
346 async fn run_stops_on_shutdown() {
347 let store = Arc::new(InMemoryStore::new());
348 let policy = PurgePolicy {
349 max_age_days: 90,
350 max_runs_per_workflow: 1000,
351 dry_run: false,
352 };
353 let purger = build(store, policy);
354 let shutdown = CancellationToken::new();
355 shutdown.cancel();
356
357 tokio::time::timeout(Duration::from_secs(5), purger.run(shutdown))
358 .await
359 .expect("purger stopped");
360 }
361}