Skip to main content

ironflow_api/
reaper.rs

1//! Recovery of runs abandoned by a dead worker.
2//!
3//! A worker attaches a lease to every run it picks up and refreshes it while it
4//! executes. When the worker dies (OOM, evicted pod, hard shutdown), the lease
5//! stops being refreshed and the run would otherwise stay `Running` forever.
6//! The [`Reaper`] periodically requeues those runs.
7//!
8//! Runs executed in-process by the API server (inline execution, resume after
9//! approval) never hold a lease and are never touched by the reaper.
10
11use 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
28/// How often expired leases are collected.
29pub const DEFAULT_REAPER_INTERVAL: Duration = Duration::from_secs(60);
30
31/// How many runs a single tick recovers.
32///
33/// Bounded so that a mass failure (a whole worker fleet dying at once) resorbs
34/// progressively instead of holding a long transaction on the runs table.
35pub const DEFAULT_REAPER_BATCH_SIZE: u32 = 100;
36
37/// Periodic task that requeues runs whose worker lease expired.
38///
39/// # Examples
40///
41/// ```no_run
42/// use std::sync::Arc;
43/// use std::time::Duration;
44/// use ironflow_api::reaper::Reaper;
45/// use ironflow_core::providers::claude::ClaudeCodeProvider;
46/// use ironflow_engine::engine::Engine;
47/// use ironflow_store::memory::InMemoryStore;
48/// use ironflow_store::store::Store;
49/// use tokio_util::sync::CancellationToken;
50///
51/// # async fn example() {
52/// let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
53/// let engine = Arc::new(Engine::new(store.clone(), Arc::new(ClaudeCodeProvider::new())));
54///
55/// let reaper = Reaper::new(store, engine).interval(Duration::from_secs(30));
56/// tokio::spawn(reaper.run(CancellationToken::new()));
57/// # }
58/// ```
59pub struct Reaper {
60    store: Arc<dyn Store>,
61    engine: Arc<Engine>,
62    interval: Duration,
63    batch_size: u32,
64}
65
66impl Reaper {
67    /// Create a reaper with the default interval and batch size.
68    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    /// Set how often expired leases are collected.
78    ///
79    /// Keep it well below the worker lease TTL, otherwise recovery takes longer
80    /// than the TTL suggests.
81    pub fn interval(mut self, interval: Duration) -> Self {
82        self.interval = interval;
83        self
84    }
85
86    /// Set how many runs a single tick recovers.
87    pub fn batch_size(mut self, batch_size: u32) -> Self {
88        self.batch_size = batch_size;
89        self
90    }
91
92    /// Run the recovery loop until `shutdown` is cancelled.
93    ///
94    /// Store errors are logged and the loop keeps going: a transient database
95    /// failure must not silently stop recovery.
96    pub async fn run(self, shutdown: CancellationToken) {
97        let mut ticker = interval(self.interval);
98        // The first tick fires immediately; skip it so startup is not a burst.
99        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    /// Recover one batch of expired leases.
121    ///
122    /// Exposed for tests and for callers that drive the schedule themselves.
123    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    /// Apply the side effects of a recovery: clean up steps, publish the event.
148    ///
149    /// The `Running` steps of a requeued run are marked interrupted, see
150    /// [`Engine::interrupt_running_steps`]; every open step of a run that
151    /// exhausted its recoveries is failed with [`LEASE_EXPIRED_ERROR`], and its
152    /// sub-workflow runs still active are cancelled
153    /// ([`Engine::cancel_descendants`]).
154    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        // A requeued run resumes in the same attempt: only the steps that were
168        // running are closed, so the next worker executes them again and replays
169        // everything else. A run out of recoveries is over: close every open step.
170        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        // A requeued run re-enters its children when it resumes. A run out of
182        // recoveries never will: nothing would ever drive them again.
183        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    /// Collects the events published during a test.
272    #[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    /// Build a reaper whose engine records every published event.
302    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    /// Pick a run with a lease that is already expired.
311    ///
312    /// The short sleep matters: `Utc::now()` has microsecond resolution, so a
313    /// sub-microsecond TTL can still read as "not yet expired" in the same tick.
314    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        // A lease recovery is not a handler retry: the run stays in its attempt.
336        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    /// Create a step of `run_id` at `position` and move it to `status`.
375    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        // Requeued: the step is executed again by the next worker.
421        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        // Subscribers run in spawned tasks; give them a turn to record.
460        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    /// Create a sub-workflow child of `parent`, left `Running` without a lease
524    /// like a child its parent's worker was executing.
525    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        // Returns instead of looping forever.
579        tokio::time::timeout(Duration::from_secs(5), reaper.run(shutdown))
580            .await
581            .expect("reaper stopped");
582    }
583}