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