1use std::sync::Arc;
12use std::time::Duration;
13
14use chrono::Utc;
15use ironflow_engine::engine::Engine;
16use ironflow_engine::notify::{Event, RunStatusChangedEvent};
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) {
155 let run = &entry.run;
156
157 warn!(
158 run_id = %run.id,
159 workflow = %run.workflow_name,
160 worker_id = run.worker_id.as_deref().unwrap_or("unknown"),
161 retry_count = run.retry_count,
162 lease_recoveries = run.lease_recoveries,
163 to = %entry.to,
164 "worker lease expired"
165 );
166
167 let cleanup = if entry.to == RunStatus::Pending {
171 self.engine.interrupt_running_steps(run.id).await
172 } else {
173 self.engine
174 .fail_orphaned_steps(run.id, LEASE_EXPIRED_ERROR)
175 .await
176 };
177 if let Err(err) = cleanup {
178 error!(run_id = %run.id, error = %err, "failed to clean up orphaned steps");
179 }
180
181 if entry.to == RunStatus::Failed {
184 let reason = format!("parent run {} stopped: {LEASE_EXPIRED_ERROR}", run.id);
185 if let Err(err) = self.engine.cancel_descendants(run.id, &reason).await {
186 error!(run_id = %run.id, error = %err, "failed to cancel the children of a reaped run");
187 }
188 }
189
190 #[cfg(feature = "prometheus")]
191 {
192 let outcome = if entry.to == RunStatus::Failed {
193 "failed"
194 } else {
195 "requeued"
196 };
197 counter!(RUNS_REAPED_TOTAL, "outcome" => outcome).increment(1);
198 }
199
200 self.engine
201 .event_publisher()
202 .publish(Event::RunStatusChanged(RunStatusChangedEvent {
203 run_id: run.id,
204 workflow_name: run.workflow_name.clone(),
205 from: entry.from,
206 to: entry.to,
207 error: (entry.to == RunStatus::Failed).then(|| LEASE_EXPIRED_ERROR.to_string()),
208 cost_usd: run.cost_usd,
209 duration_ms: run.duration_ms,
210 labels: run.labels.clone(),
211 at: Utc::now(),
212 }));
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use std::collections::HashMap;
219 use std::sync::Mutex;
220
221 use ironflow_core::providers::claude::ClaudeCodeProvider;
222 use ironflow_engine::notify::{EventSubscriber, SubscriberFuture};
223 use ironflow_store::entities::{
224 LeaseRequest, NewRun, NewStep, PARENT_RUN_ID_LABEL, RunFilter, StepKind, StepStatus,
225 StepUpdate, TriggerKind, step_trace_id,
226 };
227 use ironflow_store::memory::InMemoryStore;
228 use ironflow_store::store::{RunStore, STEP_INTERRUPTED_ERROR};
229 use serde_json::json;
230 use tokio::task::yield_now;
231 use tokio::time::sleep;
232 use uuid::Uuid;
233
234 use super::*;
235
236 fn new_run(max_retries: u32) -> NewRun {
237 NewRun {
238 workflow_name: "test".to_string(),
239 trigger: TriggerKind::Manual,
240 payload: json!({}),
241 max_retries,
242 handler_version: None,
243 labels: HashMap::new(),
244 scheduled_at: None,
245 created_by: None,
246 idempotency_key: None,
247 concurrency_key: None,
248 concurrency_limits: Vec::new(),
249 max_cost_usd: None,
250 worker_tags: Vec::new(),
251 }
252 }
253
254 fn lease(worker_id: &str, ttl: Duration) -> Option<LeaseRequest> {
255 Some(LeaseRequest {
256 worker_id: worker_id.to_string(),
257 ttl,
258 })
259 }
260
261 fn build(store: Arc<InMemoryStore>) -> (Reaper, Arc<Engine>) {
262 let store_dyn: Arc<dyn Store> = store;
263 let engine = Arc::new(Engine::new(
264 store_dyn.clone(),
265 Arc::new(ClaudeCodeProvider::new()),
266 ));
267 (Reaper::new(store_dyn, engine.clone()), engine)
268 }
269
270 #[derive(Default)]
272 struct EventRecorder {
273 events: Mutex<Vec<Event>>,
274 }
275
276 impl EventRecorder {
277 fn events(&self) -> Vec<Event> {
278 self.events.lock().expect("recorder lock").clone()
279 }
280 }
281
282 struct RecorderHandle(Arc<EventRecorder>);
283
284 impl EventSubscriber for RecorderHandle {
285 fn name(&self) -> &str {
286 "test-recorder"
287 }
288
289 fn handle<'a>(&'a self, event: &'a Event) -> SubscriberFuture<'a> {
290 Box::pin(async move {
291 self.0
292 .events
293 .lock()
294 .expect("recorder lock")
295 .push(event.clone());
296 })
297 }
298 }
299
300 fn build_recording(store: Arc<InMemoryStore>) -> (Reaper, Arc<EventRecorder>) {
302 let store_dyn: Arc<dyn Store> = store;
303 let mut engine = Engine::new(store_dyn.clone(), Arc::new(ClaudeCodeProvider::new()));
304 let recorder = Arc::new(EventRecorder::default());
305 engine.subscribe(RecorderHandle(recorder.clone()), Event::ALL);
306 (Reaper::new(store_dyn, Arc::new(engine)), recorder)
307 }
308
309 async fn picked_with_expired_lease(store: &InMemoryStore, max_retries: u32) -> Uuid {
314 store.create_run(new_run(max_retries)).await.unwrap();
315 let run = store
316 .pick_next_pending(lease("worker-1", Duration::from_nanos(1)))
317 .await
318 .unwrap()
319 .unwrap();
320 sleep(Duration::from_millis(2)).await;
321 run.id
322 }
323
324 #[tokio::test]
325 async fn tick_requeues_run_with_expired_lease() {
326 let store = Arc::new(InMemoryStore::new());
327 let run_id = picked_with_expired_lease(&store, 3).await;
328 let (reaper, _engine) = build(store.clone());
329
330 reaper.tick().await;
331
332 let run = store.get_run(run_id).await.unwrap().unwrap();
333 assert_eq!(run.status.state, RunStatus::Pending);
334 assert_eq!(run.retry_count, 0);
336 assert_eq!(run.lease_recoveries, 1);
337 assert!(run.worker_id.is_none());
338 assert!(run.lease_expires_at.is_none());
339 }
340
341 #[tokio::test]
342 async fn tick_leaves_valid_lease_alone() {
343 let store = Arc::new(InMemoryStore::new());
344 store.create_run(new_run(3)).await.unwrap();
345 let run = store
346 .pick_next_pending(lease("worker-1", Duration::from_secs(90)))
347 .await
348 .unwrap()
349 .unwrap();
350 let (reaper, _engine) = build(store.clone());
351
352 reaper.tick().await;
353
354 let after = store.get_run(run.id).await.unwrap().unwrap();
355 assert_eq!(after.status.state, RunStatus::Running);
356 assert_eq!(after.retry_count, 0);
357 assert_eq!(after.worker_id.as_deref(), Some("worker-1"));
358 }
359
360 #[tokio::test]
361 async fn tick_fails_run_once_retries_are_exhausted() {
362 let store = Arc::new(InMemoryStore::new());
363 let run_id = picked_with_expired_lease(&store, 0).await;
364 let (reaper, _engine) = build(store.clone());
365
366 reaper.tick().await;
367
368 let run = store.get_run(run_id).await.unwrap().unwrap();
369 assert_eq!(run.status.state, RunStatus::Failed);
370 assert_eq!(run.error.as_deref(), Some(LEASE_EXPIRED_ERROR));
371 }
372
373 async fn step_at(
375 store: &InMemoryStore,
376 run_id: Uuid,
377 position: u32,
378 status: StepStatus,
379 ) -> Uuid {
380 let name = format!("step-{position}");
381 let step = store
382 .create_step(NewStep {
383 run_id,
384 trace_id: step_trace_id(run_id, &name, position),
385 name,
386 kind: StepKind::Shell,
387 position,
388 input: None,
389 is_error_handler: false,
390 })
391 .await
392 .unwrap();
393 if status != StepStatus::Pending {
394 store
395 .update_step(
396 step.id,
397 StepUpdate {
398 status: Some(status),
399 ..Default::default()
400 },
401 )
402 .await
403 .unwrap();
404 }
405 step.id
406 }
407
408 #[tokio::test]
409 async fn tick_fails_orphaned_steps() {
410 let store = Arc::new(InMemoryStore::new());
411 let run_id = picked_with_expired_lease(&store, 3).await;
412 let running = step_at(&store, run_id, 0, StepStatus::Running).await;
413 let (reaper, _engine) = build(store.clone());
414
415 reaper.tick().await;
416
417 let step = store.get_step(running).await.unwrap().unwrap();
418 assert_eq!(step.status.state, StepStatus::Failed);
419 assert_eq!(step.error.as_deref(), Some(STEP_INTERRUPTED_ERROR));
421 }
422
423 #[tokio::test]
424 async fn tick_leaves_pending_steps_of_a_requeued_run_alone() {
425 let store = Arc::new(InMemoryStore::new());
426 let run_id = picked_with_expired_lease(&store, 3).await;
427 let pending = step_at(&store, run_id, 0, StepStatus::Pending).await;
428 let (reaper, _engine) = build(store.clone());
429
430 reaper.tick().await;
431
432 let step = store.get_step(pending).await.unwrap().unwrap();
433 assert_eq!(step.status.state, StepStatus::Pending);
434 assert!(step.error.is_none());
435 }
436
437 #[tokio::test]
438 async fn tick_fails_orphaned_steps_with_lease_expired_once_retries_are_exhausted() {
439 let store = Arc::new(InMemoryStore::new());
440 let run_id = picked_with_expired_lease(&store, 0).await;
441 let running = step_at(&store, run_id, 0, StepStatus::Running).await;
442 let (reaper, _engine) = build(store.clone());
443
444 reaper.tick().await;
445
446 let step = store.get_step(running).await.unwrap().unwrap();
447 assert_eq!(step.status.state, StepStatus::Failed);
448 assert_eq!(step.error.as_deref(), Some(LEASE_EXPIRED_ERROR));
449 }
450
451 #[tokio::test]
452 async fn tick_publishes_status_change_event() {
453 let store = Arc::new(InMemoryStore::new());
454 let run_id = picked_with_expired_lease(&store, 3).await;
455 let (reaper, recorder) = build_recording(store.clone());
456
457 reaper.tick().await;
458 yield_now().await;
460
461 let status_changes: Vec<_> = recorder
462 .events()
463 .into_iter()
464 .filter_map(|event| match event {
465 Event::RunStatusChanged(e) if e.run_id == run_id => Some((e.from, e.to, e.error)),
466 _ => None,
467 })
468 .collect();
469
470 assert_eq!(
471 status_changes,
472 vec![(RunStatus::Running, RunStatus::Pending, None)]
473 );
474 }
475
476 #[tokio::test]
477 async fn tick_publishes_error_when_retries_are_exhausted() {
478 let store = Arc::new(InMemoryStore::new());
479 let run_id = picked_with_expired_lease(&store, 0).await;
480 let (reaper, recorder) = build_recording(store.clone());
481
482 reaper.tick().await;
483 yield_now().await;
484
485 let matched = recorder.events().into_iter().any(|event| {
486 matches!(
487 event,
488 Event::RunStatusChanged(ref e)
489 if e.run_id == run_id
490 && e.to == RunStatus::Failed
491 && e.error.as_deref() == Some(LEASE_EXPIRED_ERROR)
492 )
493 });
494 assert!(matched, "expected a failed status change with an error");
495 }
496
497 #[tokio::test]
498 async fn tick_respects_batch_size() {
499 let store = Arc::new(InMemoryStore::new());
500 for _ in 0..3 {
501 picked_with_expired_lease(&store, 3).await;
502 }
503 let (reaper, _engine) = build(store.clone());
504 let reaper = reaper.batch_size(2);
505
506 reaper.tick().await;
507
508 let pending = store
509 .list_runs(
510 RunFilter {
511 status: Some(RunStatus::Pending),
512 ..Default::default()
513 },
514 1,
515 100,
516 )
517 .await
518 .unwrap();
519 assert_eq!(pending.total, 2);
520 }
521
522 async fn running_child(store: &InMemoryStore, parent: Uuid) -> Uuid {
525 let mut req = new_run(0);
526 req.trigger = TriggerKind::Workflow;
527 req.labels = HashMap::from([(PARENT_RUN_ID_LABEL.to_string(), parent.to_string())]);
528 let child = store.create_run(req).await.unwrap().into_run();
529 store
530 .update_run_status(child.id, RunStatus::Running)
531 .await
532 .unwrap();
533 child.id
534 }
535
536 #[tokio::test]
537 async fn tick_cancels_the_child_of_a_run_out_of_recoveries() {
538 let store = Arc::new(InMemoryStore::new());
539 let run_id = picked_with_expired_lease(&store, 0).await;
540 let child = running_child(&store, run_id).await;
541 let (reaper, _engine) = build(store.clone());
542
543 reaper.tick().await;
544
545 let child = store.get_run(child).await.unwrap().unwrap();
546 assert_eq!(child.status.state, RunStatus::Cancelled);
547 assert!(
548 child
549 .error
550 .as_deref()
551 .is_some_and(|e| e.contains(LEASE_EXPIRED_ERROR)),
552 "got {:?}",
553 child.error
554 );
555 }
556
557 #[tokio::test]
558 async fn tick_leaves_the_child_of_a_requeued_run_to_be_resumed_with_it() {
559 let store = Arc::new(InMemoryStore::new());
560 let run_id = picked_with_expired_lease(&store, 3).await;
561 let child = running_child(&store, run_id).await;
562 let (reaper, _engine) = build(store.clone());
563
564 reaper.tick().await;
565
566 let child = store.get_run(child).await.unwrap().unwrap();
567 assert_eq!(child.status.state, RunStatus::Running);
568 }
569
570 #[tokio::test]
571 async fn run_stops_on_shutdown() {
572 let store = Arc::new(InMemoryStore::new());
573 let (reaper, _engine) = build(store);
574 let shutdown = CancellationToken::new();
575 shutdown.cancel();
576
577 tokio::time::timeout(Duration::from_secs(5), reaper.run(shutdown))
579 .await
580 .expect("reaper stopped");
581 }
582}