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 }
251 }
252
253 fn lease(worker_id: &str, ttl: Duration) -> Option<LeaseRequest> {
254 Some(LeaseRequest {
255 worker_id: worker_id.to_string(),
256 ttl,
257 })
258 }
259
260 fn build(store: Arc<InMemoryStore>) -> (Reaper, Arc<Engine>) {
261 let store_dyn: Arc<dyn Store> = store;
262 let engine = Arc::new(Engine::new(
263 store_dyn.clone(),
264 Arc::new(ClaudeCodeProvider::new()),
265 ));
266 (Reaper::new(store_dyn, engine.clone()), engine)
267 }
268
269 #[derive(Default)]
271 struct EventRecorder {
272 events: Mutex<Vec<Event>>,
273 }
274
275 impl EventRecorder {
276 fn events(&self) -> Vec<Event> {
277 self.events.lock().expect("recorder lock").clone()
278 }
279 }
280
281 struct RecorderHandle(Arc<EventRecorder>);
282
283 impl EventSubscriber for RecorderHandle {
284 fn name(&self) -> &str {
285 "test-recorder"
286 }
287
288 fn handle<'a>(&'a self, event: &'a Event) -> SubscriberFuture<'a> {
289 Box::pin(async move {
290 self.0
291 .events
292 .lock()
293 .expect("recorder lock")
294 .push(event.clone());
295 })
296 }
297 }
298
299 fn build_recording(store: Arc<InMemoryStore>) -> (Reaper, Arc<EventRecorder>) {
301 let store_dyn: Arc<dyn Store> = store;
302 let mut engine = Engine::new(store_dyn.clone(), Arc::new(ClaudeCodeProvider::new()));
303 let recorder = Arc::new(EventRecorder::default());
304 engine.subscribe(RecorderHandle(recorder.clone()), Event::ALL);
305 (Reaper::new(store_dyn, Arc::new(engine)), recorder)
306 }
307
308 async fn picked_with_expired_lease(store: &InMemoryStore, max_retries: u32) -> Uuid {
313 store.create_run(new_run(max_retries)).await.unwrap();
314 let run = store
315 .pick_next_pending(lease("worker-1", Duration::from_nanos(1)))
316 .await
317 .unwrap()
318 .unwrap();
319 sleep(Duration::from_millis(2)).await;
320 run.id
321 }
322
323 #[tokio::test]
324 async fn tick_requeues_run_with_expired_lease() {
325 let store = Arc::new(InMemoryStore::new());
326 let run_id = picked_with_expired_lease(&store, 3).await;
327 let (reaper, _engine) = build(store.clone());
328
329 reaper.tick().await;
330
331 let run = store.get_run(run_id).await.unwrap().unwrap();
332 assert_eq!(run.status.state, RunStatus::Pending);
333 assert_eq!(run.retry_count, 0);
335 assert_eq!(run.lease_recoveries, 1);
336 assert!(run.worker_id.is_none());
337 assert!(run.lease_expires_at.is_none());
338 }
339
340 #[tokio::test]
341 async fn tick_leaves_valid_lease_alone() {
342 let store = Arc::new(InMemoryStore::new());
343 store.create_run(new_run(3)).await.unwrap();
344 let run = store
345 .pick_next_pending(lease("worker-1", Duration::from_secs(90)))
346 .await
347 .unwrap()
348 .unwrap();
349 let (reaper, _engine) = build(store.clone());
350
351 reaper.tick().await;
352
353 let after = store.get_run(run.id).await.unwrap().unwrap();
354 assert_eq!(after.status.state, RunStatus::Running);
355 assert_eq!(after.retry_count, 0);
356 assert_eq!(after.worker_id.as_deref(), Some("worker-1"));
357 }
358
359 #[tokio::test]
360 async fn tick_fails_run_once_retries_are_exhausted() {
361 let store = Arc::new(InMemoryStore::new());
362 let run_id = picked_with_expired_lease(&store, 0).await;
363 let (reaper, _engine) = build(store.clone());
364
365 reaper.tick().await;
366
367 let run = store.get_run(run_id).await.unwrap().unwrap();
368 assert_eq!(run.status.state, RunStatus::Failed);
369 assert_eq!(run.error.as_deref(), Some(LEASE_EXPIRED_ERROR));
370 }
371
372 async fn step_at(
374 store: &InMemoryStore,
375 run_id: Uuid,
376 position: u32,
377 status: StepStatus,
378 ) -> Uuid {
379 let name = format!("step-{position}");
380 let step = store
381 .create_step(NewStep {
382 run_id,
383 trace_id: step_trace_id(run_id, &name, position),
384 name,
385 kind: StepKind::Shell,
386 position,
387 input: None,
388 is_error_handler: false,
389 })
390 .await
391 .unwrap();
392 if status != StepStatus::Pending {
393 store
394 .update_step(
395 step.id,
396 StepUpdate {
397 status: Some(status),
398 ..Default::default()
399 },
400 )
401 .await
402 .unwrap();
403 }
404 step.id
405 }
406
407 #[tokio::test]
408 async fn tick_fails_orphaned_steps() {
409 let store = Arc::new(InMemoryStore::new());
410 let run_id = picked_with_expired_lease(&store, 3).await;
411 let running = step_at(&store, run_id, 0, StepStatus::Running).await;
412 let (reaper, _engine) = build(store.clone());
413
414 reaper.tick().await;
415
416 let step = store.get_step(running).await.unwrap().unwrap();
417 assert_eq!(step.status.state, StepStatus::Failed);
418 assert_eq!(step.error.as_deref(), Some(STEP_INTERRUPTED_ERROR));
420 }
421
422 #[tokio::test]
423 async fn tick_leaves_pending_steps_of_a_requeued_run_alone() {
424 let store = Arc::new(InMemoryStore::new());
425 let run_id = picked_with_expired_lease(&store, 3).await;
426 let pending = step_at(&store, run_id, 0, StepStatus::Pending).await;
427 let (reaper, _engine) = build(store.clone());
428
429 reaper.tick().await;
430
431 let step = store.get_step(pending).await.unwrap().unwrap();
432 assert_eq!(step.status.state, StepStatus::Pending);
433 assert!(step.error.is_none());
434 }
435
436 #[tokio::test]
437 async fn tick_fails_orphaned_steps_with_lease_expired_once_retries_are_exhausted() {
438 let store = Arc::new(InMemoryStore::new());
439 let run_id = picked_with_expired_lease(&store, 0).await;
440 let running = step_at(&store, run_id, 0, StepStatus::Running).await;
441 let (reaper, _engine) = build(store.clone());
442
443 reaper.tick().await;
444
445 let step = store.get_step(running).await.unwrap().unwrap();
446 assert_eq!(step.status.state, StepStatus::Failed);
447 assert_eq!(step.error.as_deref(), Some(LEASE_EXPIRED_ERROR));
448 }
449
450 #[tokio::test]
451 async fn tick_publishes_status_change_event() {
452 let store = Arc::new(InMemoryStore::new());
453 let run_id = picked_with_expired_lease(&store, 3).await;
454 let (reaper, recorder) = build_recording(store.clone());
455
456 reaper.tick().await;
457 yield_now().await;
459
460 let status_changes: Vec<_> = recorder
461 .events()
462 .into_iter()
463 .filter_map(|event| match event {
464 Event::RunStatusChanged(e) if e.run_id == run_id => Some((e.from, e.to, e.error)),
465 _ => None,
466 })
467 .collect();
468
469 assert_eq!(
470 status_changes,
471 vec![(RunStatus::Running, RunStatus::Pending, None)]
472 );
473 }
474
475 #[tokio::test]
476 async fn tick_publishes_error_when_retries_are_exhausted() {
477 let store = Arc::new(InMemoryStore::new());
478 let run_id = picked_with_expired_lease(&store, 0).await;
479 let (reaper, recorder) = build_recording(store.clone());
480
481 reaper.tick().await;
482 yield_now().await;
483
484 let matched = recorder.events().into_iter().any(|event| {
485 matches!(
486 event,
487 Event::RunStatusChanged(ref e)
488 if e.run_id == run_id
489 && e.to == RunStatus::Failed
490 && e.error.as_deref() == Some(LEASE_EXPIRED_ERROR)
491 )
492 });
493 assert!(matched, "expected a failed status change with an error");
494 }
495
496 #[tokio::test]
497 async fn tick_respects_batch_size() {
498 let store = Arc::new(InMemoryStore::new());
499 for _ in 0..3 {
500 picked_with_expired_lease(&store, 3).await;
501 }
502 let (reaper, _engine) = build(store.clone());
503 let reaper = reaper.batch_size(2);
504
505 reaper.tick().await;
506
507 let pending = store
508 .list_runs(
509 RunFilter {
510 status: Some(RunStatus::Pending),
511 ..Default::default()
512 },
513 1,
514 100,
515 )
516 .await
517 .unwrap();
518 assert_eq!(pending.total, 2);
519 }
520
521 async fn running_child(store: &InMemoryStore, parent: Uuid) -> Uuid {
524 let mut req = new_run(0);
525 req.trigger = TriggerKind::Workflow;
526 req.labels = HashMap::from([(PARENT_RUN_ID_LABEL.to_string(), parent.to_string())]);
527 let child = store.create_run(req).await.unwrap().into_run();
528 store
529 .update_run_status(child.id, RunStatus::Running)
530 .await
531 .unwrap();
532 child.id
533 }
534
535 #[tokio::test]
536 async fn tick_cancels_the_child_of_a_run_out_of_recoveries() {
537 let store = Arc::new(InMemoryStore::new());
538 let run_id = picked_with_expired_lease(&store, 0).await;
539 let child = running_child(&store, run_id).await;
540 let (reaper, _engine) = build(store.clone());
541
542 reaper.tick().await;
543
544 let child = store.get_run(child).await.unwrap().unwrap();
545 assert_eq!(child.status.state, RunStatus::Cancelled);
546 assert!(
547 child
548 .error
549 .as_deref()
550 .is_some_and(|e| e.contains(LEASE_EXPIRED_ERROR)),
551 "got {:?}",
552 child.error
553 );
554 }
555
556 #[tokio::test]
557 async fn tick_leaves_the_child_of_a_requeued_run_to_be_resumed_with_it() {
558 let store = Arc::new(InMemoryStore::new());
559 let run_id = picked_with_expired_lease(&store, 3).await;
560 let child = running_child(&store, run_id).await;
561 let (reaper, _engine) = build(store.clone());
562
563 reaper.tick().await;
564
565 let child = store.get_run(child).await.unwrap().unwrap();
566 assert_eq!(child.status.state, RunStatus::Running);
567 }
568
569 #[tokio::test]
570 async fn run_stops_on_shutdown() {
571 let store = Arc::new(InMemoryStore::new());
572 let (reaper, _engine) = build(store);
573 let shutdown = CancellationToken::new();
574 shutdown.cancel();
575
576 tokio::time::timeout(Duration::from_secs(5), reaper.run(shutdown))
578 .await
579 .expect("reaper stopped");
580 }
581}