1use std::sync::Arc;
12use std::time::Duration;
13
14use chrono::Utc;
15use ironflow_engine::engine::Engine;
16use ironflow_engine::notify::Event;
17use ironflow_store::entities::{ReapedRun, RunStatus};
18use ironflow_store::store::{LEASE_EXPIRED_ERROR, 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_REAPED_TOTAL;
25#[cfg(feature = "prometheus")]
26use metrics::counter;
27
28pub const DEFAULT_REAPER_INTERVAL: Duration = Duration::from_secs(60);
30
31pub const DEFAULT_REAPER_BATCH_SIZE: u32 = 100;
36
37pub struct Reaper {
60 store: Arc<dyn Store>,
61 engine: Arc<Engine>,
62 interval: Duration,
63 batch_size: u32,
64}
65
66impl Reaper {
67 pub fn new(store: Arc<dyn Store>, engine: Arc<Engine>) -> Self {
69 Self {
70 store,
71 engine,
72 interval: DEFAULT_REAPER_INTERVAL,
73 batch_size: DEFAULT_REAPER_BATCH_SIZE,
74 }
75 }
76
77 pub fn interval(mut self, interval: Duration) -> Self {
82 self.interval = interval;
83 self
84 }
85
86 pub fn batch_size(mut self, batch_size: u32) -> Self {
88 self.batch_size = batch_size;
89 self
90 }
91
92 pub async fn run(self, shutdown: CancellationToken) {
97 let mut ticker = interval(self.interval);
98 ticker.tick().await;
100
101 info!(
102 interval_secs = self.interval.as_secs(),
103 batch_size = self.batch_size,
104 "reaper started"
105 );
106
107 loop {
108 tokio::select! {
109 _ = shutdown.cancelled() => {
110 info!("reaper stopped");
111 return;
112 }
113 _ = ticker.tick() => {
114 self.tick().await;
115 }
116 }
117 }
118 }
119
120 pub async fn tick(&self) {
124 let reaped = match self.store.reap_expired_leases(self.batch_size).await {
125 Ok(reaped) => reaped,
126 Err(err) => {
127 error!(error = %err, "failed to collect expired leases");
128 return;
129 }
130 };
131
132 if reaped.is_empty() {
133 return;
134 }
135
136 warn!(
137 count = reaped.len(),
138 batch_size = self.batch_size,
139 "recovered runs with an expired worker lease"
140 );
141
142 for entry in &reaped {
143 self.finish_recovery(entry).await;
144 }
145 }
146
147 async fn finish_recovery(&self, entry: &ReapedRun) {
149 let run = &entry.run;
150
151 warn!(
152 run_id = %run.id,
153 workflow = %run.workflow_name,
154 worker_id = run.worker_id.as_deref().unwrap_or("unknown"),
155 retry_count = run.retry_count,
156 to = %entry.to,
157 "worker lease expired"
158 );
159
160 if let Err(err) = self
161 .engine
162 .fail_orphaned_steps(run.id, LEASE_EXPIRED_ERROR)
163 .await
164 {
165 error!(run_id = %run.id, error = %err, "failed to clean up orphaned steps");
166 }
167
168 #[cfg(feature = "prometheus")]
169 {
170 let outcome = if entry.to == RunStatus::Failed {
171 "failed"
172 } else {
173 "requeued"
174 };
175 counter!(RUNS_REAPED_TOTAL, "outcome" => outcome).increment(1);
176 }
177
178 self.engine
179 .event_publisher()
180 .publish(Event::RunStatusChanged {
181 run_id: run.id,
182 workflow_name: run.workflow_name.clone(),
183 from: entry.from,
184 to: entry.to,
185 error: (entry.to == RunStatus::Failed).then(|| LEASE_EXPIRED_ERROR.to_string()),
186 cost_usd: run.cost_usd,
187 duration_ms: run.duration_ms,
188 labels: run.labels.clone(),
189 at: Utc::now(),
190 });
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use std::collections::HashMap;
197 use std::sync::Mutex;
198
199 use ironflow_core::providers::claude::ClaudeCodeProvider;
200 use ironflow_engine::notify::{EventSubscriber, SubscriberFuture};
201 use ironflow_store::entities::{
202 LeaseRequest, NewRun, NewStep, RunFilter, StepKind, StepStatus, StepUpdate, TriggerKind,
203 step_trace_id,
204 };
205 use ironflow_store::memory::InMemoryStore;
206 use ironflow_store::store::RunStore;
207 use serde_json::json;
208 use tokio::task::yield_now;
209 use tokio::time::sleep;
210 use uuid::Uuid;
211
212 use super::*;
213
214 fn new_run(max_retries: u32) -> NewRun {
215 NewRun {
216 workflow_name: "test".to_string(),
217 trigger: TriggerKind::Manual,
218 payload: json!({}),
219 max_retries,
220 handler_version: None,
221 labels: HashMap::new(),
222 scheduled_at: None,
223 created_by: None,
224 idempotency_key: None,
225 max_cost_usd: None,
226 }
227 }
228
229 fn lease(worker_id: &str, ttl: Duration) -> Option<LeaseRequest> {
230 Some(LeaseRequest {
231 worker_id: worker_id.to_string(),
232 ttl,
233 })
234 }
235
236 fn build(store: Arc<InMemoryStore>) -> (Reaper, Arc<Engine>) {
237 let store_dyn: Arc<dyn Store> = store;
238 let engine = Arc::new(Engine::new(
239 store_dyn.clone(),
240 Arc::new(ClaudeCodeProvider::new()),
241 ));
242 (Reaper::new(store_dyn, engine.clone()), engine)
243 }
244
245 #[derive(Default)]
247 struct EventRecorder {
248 events: Mutex<Vec<Event>>,
249 }
250
251 impl EventRecorder {
252 fn events(&self) -> Vec<Event> {
253 self.events.lock().expect("recorder lock").clone()
254 }
255 }
256
257 struct RecorderHandle(Arc<EventRecorder>);
258
259 impl EventSubscriber for RecorderHandle {
260 fn name(&self) -> &str {
261 "test-recorder"
262 }
263
264 fn handle<'a>(&'a self, event: &'a Event) -> SubscriberFuture<'a> {
265 Box::pin(async move {
266 self.0
267 .events
268 .lock()
269 .expect("recorder lock")
270 .push(event.clone());
271 })
272 }
273 }
274
275 fn build_recording(store: Arc<InMemoryStore>) -> (Reaper, Arc<EventRecorder>) {
277 let store_dyn: Arc<dyn Store> = store;
278 let mut engine = Engine::new(store_dyn.clone(), Arc::new(ClaudeCodeProvider::new()));
279 let recorder = Arc::new(EventRecorder::default());
280 engine.subscribe(RecorderHandle(recorder.clone()), Event::ALL);
281 (Reaper::new(store_dyn, Arc::new(engine)), recorder)
282 }
283
284 async fn picked_with_expired_lease(store: &InMemoryStore, max_retries: u32) -> Uuid {
289 store.create_run(new_run(max_retries)).await.unwrap();
290 let run = store
291 .pick_next_pending(lease("worker-1", Duration::from_nanos(1)))
292 .await
293 .unwrap()
294 .unwrap();
295 sleep(Duration::from_millis(2)).await;
296 run.id
297 }
298
299 #[tokio::test]
300 async fn tick_requeues_run_with_expired_lease() {
301 let store = Arc::new(InMemoryStore::new());
302 let run_id = picked_with_expired_lease(&store, 3).await;
303 let (reaper, _engine) = build(store.clone());
304
305 reaper.tick().await;
306
307 let run = store.get_run(run_id).await.unwrap().unwrap();
308 assert_eq!(run.status.state, RunStatus::Pending);
309 assert_eq!(run.retry_count, 1);
310 assert!(run.worker_id.is_none());
311 assert!(run.lease_expires_at.is_none());
312 }
313
314 #[tokio::test]
315 async fn tick_leaves_valid_lease_alone() {
316 let store = Arc::new(InMemoryStore::new());
317 store.create_run(new_run(3)).await.unwrap();
318 let run = store
319 .pick_next_pending(lease("worker-1", Duration::from_secs(90)))
320 .await
321 .unwrap()
322 .unwrap();
323 let (reaper, _engine) = build(store.clone());
324
325 reaper.tick().await;
326
327 let after = store.get_run(run.id).await.unwrap().unwrap();
328 assert_eq!(after.status.state, RunStatus::Running);
329 assert_eq!(after.retry_count, 0);
330 assert_eq!(after.worker_id.as_deref(), Some("worker-1"));
331 }
332
333 #[tokio::test]
334 async fn tick_fails_run_once_retries_are_exhausted() {
335 let store = Arc::new(InMemoryStore::new());
336 let run_id = picked_with_expired_lease(&store, 0).await;
337 let (reaper, _engine) = build(store.clone());
338
339 reaper.tick().await;
340
341 let run = store.get_run(run_id).await.unwrap().unwrap();
342 assert_eq!(run.status.state, RunStatus::Failed);
343 assert_eq!(run.error.as_deref(), Some(LEASE_EXPIRED_ERROR));
344 }
345
346 #[tokio::test]
347 async fn tick_fails_orphaned_steps() {
348 let store = Arc::new(InMemoryStore::new());
349 let run_id = picked_with_expired_lease(&store, 3).await;
350 let step = store
351 .create_step(NewStep {
352 run_id,
353 trace_id: step_trace_id(run_id, "step-1", 0),
354 name: "step-1".to_string(),
355 kind: StepKind::Shell,
356 position: 0,
357 input: None,
358 is_error_handler: false,
359 })
360 .await
361 .unwrap();
362 store
363 .update_step(
364 step.id,
365 StepUpdate {
366 status: Some(StepStatus::Running),
367 ..Default::default()
368 },
369 )
370 .await
371 .unwrap();
372 let (reaper, _engine) = build(store.clone());
373
374 reaper.tick().await;
375
376 let steps = store.list_steps(run_id).await.unwrap();
377 assert_eq!(steps[0].status.state, StepStatus::Failed);
378 }
379
380 #[tokio::test]
381 async fn tick_publishes_status_change_event() {
382 let store = Arc::new(InMemoryStore::new());
383 let run_id = picked_with_expired_lease(&store, 3).await;
384 let (reaper, recorder) = build_recording(store.clone());
385
386 reaper.tick().await;
387 yield_now().await;
389
390 let status_changes: Vec<_> = recorder
391 .events()
392 .into_iter()
393 .filter_map(|event| match event {
394 Event::RunStatusChanged {
395 run_id: id,
396 from,
397 to,
398 error,
399 ..
400 } if id == run_id => Some((from, to, error)),
401 _ => None,
402 })
403 .collect();
404
405 assert_eq!(
406 status_changes,
407 vec![(RunStatus::Running, RunStatus::Pending, None)]
408 );
409 }
410
411 #[tokio::test]
412 async fn tick_publishes_error_when_retries_are_exhausted() {
413 let store = Arc::new(InMemoryStore::new());
414 let run_id = picked_with_expired_lease(&store, 0).await;
415 let (reaper, recorder) = build_recording(store.clone());
416
417 reaper.tick().await;
418 yield_now().await;
419
420 let matched = recorder.events().into_iter().any(|event| {
421 matches!(
422 event,
423 Event::RunStatusChanged { run_id: id, to, error: Some(err), .. }
424 if id == run_id && to == RunStatus::Failed && err == LEASE_EXPIRED_ERROR
425 )
426 });
427 assert!(matched, "expected a failed status change with an error");
428 }
429
430 #[tokio::test]
431 async fn tick_respects_batch_size() {
432 let store = Arc::new(InMemoryStore::new());
433 for _ in 0..3 {
434 picked_with_expired_lease(&store, 3).await;
435 }
436 let (reaper, _engine) = build(store.clone());
437 let reaper = reaper.batch_size(2);
438
439 reaper.tick().await;
440
441 let pending = store
442 .list_runs(
443 RunFilter {
444 status: Some(RunStatus::Pending),
445 ..Default::default()
446 },
447 1,
448 100,
449 )
450 .await
451 .unwrap();
452 assert_eq!(pending.total, 2);
453 }
454
455 #[tokio::test]
456 async fn run_stops_on_shutdown() {
457 let store = Arc::new(InMemoryStore::new());
458 let (reaper, _engine) = build(store);
459 let shutdown = CancellationToken::new();
460 shutdown.cancel();
461
462 tokio::time::timeout(Duration::from_secs(5), reaper.run(shutdown))
464 .await
465 .expect("reaper stopped");
466 }
467}