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