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    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    /// Collects the events published during a test.
247    #[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    /// Build a reaper whose engine records every published event.
277    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    /// Pick a run with a lease that is already expired.
286    ///
287    /// The short sleep matters: `Utc::now()` has microsecond resolution, so a
288    /// sub-microsecond TTL can still read as "not yet expired" in the same tick.
289    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        // Subscribers run in spawned tasks; give them a turn to record.
389        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        // Returns instead of looping forever.
460        tokio::time::timeout(Duration::from_secs(5), reaper.run(shutdown))
461            .await
462            .expect("reaper stopped");
463    }
464}