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