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