Skip to main content

aion/workloop/
service.rs

1//! The engine-side cadence service: dead-man switch, tolerance sweep, wake.
2//!
3//! ONE service task sweeps every registered loop off the store's
4//! `due_workloops` set — a sleeping loop is store bytes plus this row's
5//! `next_check_at`, with no resident process, no timer-wheel entry, and no
6//! per-loop task (R13.3). Missed-window evaluation happens HERE, engine-side,
7//! never inside the loop's own fault domain (R4.3): a loop crash is the
8//! simultaneous non-confirmation of every invariant it carried, which is what
9//! makes the single alarm path total (R4.2).
10
11use std::sync::Arc;
12use std::time::Duration;
13
14use aion_core::{HealthSample, HealthStatus, InvariantAlarm, WorkflowId, WorkloopSpec};
15use aion_store::workloop::{WorkloopRecord, WorkloopStore};
16use async_trait::async_trait;
17use chrono::{DateTime, Utc};
18
19use super::error::WorkloopError;
20use super::health::{
21    UnconfirmedEvidence, latch_alarm, observe_confirmed, observe_unconfirmed, peek_alarm,
22};
23use super::windows::{advance_window, initial_window, next_check_at};
24use crate::engine_seam::RecordOutcome;
25
26/// Durable append seam for engine-raised workloop events. Both paths MUST go
27/// through the target loop's single Recorder (the one-writer law).
28#[async_trait]
29pub trait LoopEventSink: Send + Sync {
30    /// Record `CadenceFired { window_seq }` in the loop's history.
31    ///
32    /// Returns [`RecordOutcome::RefusedTerminal`] when the loop's active run
33    /// already holds a terminal — a dead loop has no windows, and the refusal
34    /// is the engine's positive evidence of death.
35    ///
36    /// # Errors
37    ///
38    /// Returns [`WorkloopError`] when the append fails outright.
39    async fn record_cadence_fired(
40        &self,
41        loop_id: &WorkflowId,
42        window_seq: u64,
43    ) -> Result<RecordOutcome, WorkloopError>;
44
45    /// Record `InvariantUnconfirmed` (the one alarm path) in the loop's
46    /// history. UNLIKE cadence fires, this append is honoured even after the
47    /// run's terminal: the alarm that reports a loop's death must not be
48    /// silenced by the very death it reports (R5.3's premise). The event is
49    /// status-invisible, so the terminal projection is untouched.
50    ///
51    /// # Errors
52    ///
53    /// Returns [`WorkloopError`] when the append fails outright.
54    async fn record_invariant_unconfirmed(
55        &self,
56        loop_id: &WorkflowId,
57        alarm: InvariantAlarm,
58    ) -> Result<(), WorkloopError>;
59}
60
61/// Wake seam: bring a suspended loop's current generation to a live process so
62/// the fired iteration can run (wake = load carry, run iteration, suspend —
63/// R13.3). Called only after the fire is durably recorded.
64#[async_trait]
65pub trait WorkloopWaker: Send + Sync {
66    /// Wake the loop's current generation.
67    ///
68    /// # Errors
69    ///
70    /// Returns [`WorkloopError`] when the generation cannot be woken; the
71    /// sweep reports the fault and the recorded fire stands (the next sweep's
72    /// dead-man evaluation counts the unrun window — a failed wake degrades to
73    /// a missed window, never to silence).
74    async fn wake(&self, loop_id: &WorkflowId) -> Result<(), WorkloopError>;
75}
76
77/// What one sweep pass did — the service's own observability surface.
78#[derive(Debug, Default)]
79pub struct SweepReport {
80    /// Loops evaluated this pass.
81    pub swept: usize,
82    /// Cadence fires recorded: (loop, window sequence).
83    pub fired: Vec<(WorkflowId, u64)>,
84    /// Alarms raised on the one path: (loop, alarm).
85    pub alarms: Vec<(WorkflowId, InvariantAlarm)>,
86    /// Loops found dead (cadence fire refused on a terminal run) and
87    /// deregistered after their loop-dead alarms were raised.
88    pub dead: Vec<WorkflowId>,
89    /// Loops found RETIRED (cadence fire refused on a run carrying
90    /// `LoopRetired`) and withdrawn from the sweep set with no alarms — a
91    /// declared stop, reported separately from a death so an operator reading
92    /// this report is never told a decommission was an incident.
93    pub retired: Vec<WorkflowId>,
94    /// Per-loop faults that did not stop the sweep, with their loop.
95    pub faults: Vec<(WorkflowId, String)>,
96}
97
98/// The engine-side cadence service. One instance, one sweep task, N loops.
99pub struct WorkloopService {
100    store: Arc<dyn WorkloopStore>,
101    sink: Arc<dyn LoopEventSink>,
102    waker: Arc<dyn WorkloopWaker>,
103    sweep_interval: Duration,
104    now: Arc<dyn Fn() -> DateTime<Utc> + Send + Sync>,
105}
106
107impl WorkloopService {
108    /// Creates the service. `sweep_interval` is the operator-declared sweep
109    /// cadence — REQUIRED, never defaulted, and it bounds dead-man detection
110    /// latency.
111    ///
112    /// # Errors
113    ///
114    /// Refuses a zero interval ([`WorkloopError::ZeroSweepInterval`]).
115    pub fn new(
116        store: Arc<dyn WorkloopStore>,
117        sink: Arc<dyn LoopEventSink>,
118        waker: Arc<dyn WorkloopWaker>,
119        sweep_interval: Duration,
120    ) -> Result<Self, WorkloopError> {
121        Self::with_clock(store, sink, waker, sweep_interval, Utc::now)
122    }
123
124    /// [`WorkloopService::new`] with an injected clock, for deterministic
125    /// tests.
126    ///
127    /// # Errors
128    ///
129    /// Refuses a zero interval ([`WorkloopError::ZeroSweepInterval`]).
130    pub fn with_clock(
131        store: Arc<dyn WorkloopStore>,
132        sink: Arc<dyn LoopEventSink>,
133        waker: Arc<dyn WorkloopWaker>,
134        sweep_interval: Duration,
135        now: impl Fn() -> DateTime<Utc> + Send + Sync + 'static,
136    ) -> Result<Self, WorkloopError> {
137        if sweep_interval.is_zero() {
138            return Err(WorkloopError::ZeroSweepInterval);
139        }
140        Ok(Self {
141            store,
142            sink,
143            waker,
144            sweep_interval,
145            now: Arc::new(now),
146        })
147    }
148
149    /// The declared sweep interval.
150    #[must_use]
151    pub const fn sweep_interval(&self) -> Duration {
152        self.sweep_interval
153    }
154
155    /// Registers a loop: persists its declared spec and arms the first cadence
156    /// window (and/or duration deadlines) on the sweep set.
157    ///
158    /// # Errors
159    ///
160    /// Refuses a duplicate registration and propagates store failures.
161    pub async fn register(
162        &self,
163        loop_id: WorkflowId,
164        namespace: String,
165        spec: WorkloopSpec,
166    ) -> Result<WorkloopRecord, WorkloopError> {
167        if self.store.get_workloop(&loop_id).await?.is_some() {
168            return Err(WorkloopError::AlreadyRegistered { loop_id });
169        }
170        let now = (self.now)();
171        let next_window_at = match spec.arming().cadence_period() {
172            Some(period) => Some(initial_window(now, period)?),
173            None => None,
174        };
175        let mut record = WorkloopRecord {
176            loop_id,
177            namespace,
178            spec,
179            window_seq: 0,
180            next_window_at,
181            next_check_at: None,
182            last_iteration_closed_window: None,
183            invariant_health: std::collections::BTreeMap::new(),
184            registered_at: now,
185            updated_at: now,
186        };
187        record.next_check_at = next_check_at(&record);
188        self.store.put_workloop(record.clone()).await?;
189        Ok(record)
190    }
191
192    /// Wakes a loop's current generation immediately — the signal-armed fire
193    /// (R2.4): a declared signal arrived, so the iteration runs now, no
194    /// window involved.
195    ///
196    /// # Errors
197    ///
198    /// Propagates the waker's failure.
199    pub async fn wake_now(&self, loop_id: &WorkflowId) -> Result<(), WorkloopError> {
200        self.waker.wake(loop_id).await
201    }
202
203    /// Deregisters a loop (retirement or death). Invariant current-state
204    /// records are untouched — the current record survives indefinitely
205    /// (R8.1).
206    ///
207    /// # Errors
208    ///
209    /// Propagates store failures.
210    pub async fn deregister(&self, loop_id: &WorkflowId) -> Result<bool, WorkloopError> {
211        Ok(self.store.remove_workloop(loop_id).await?)
212    }
213
214    /// Feed an iteration close into health accounting (R3.3): every declared
215    /// invariant is sampled on the same tick — `Confirmed` resets its
216    /// accounting, `Unconfirmed` accrues red-sample evidence and may exceed
217    /// the count-form tolerance immediately. Returns the alarms raised.
218    ///
219    /// # Errors
220    ///
221    /// Refuses samples naming undeclared invariants; propagates store and
222    /// append failures.
223    pub async fn note_iteration_closed(
224        &self,
225        loop_id: &WorkflowId,
226        samples: &[HealthSample],
227    ) -> Result<Vec<InvariantAlarm>, WorkloopError> {
228        let mut record = self.store.get_workloop(loop_id).await?.ok_or_else(|| {
229            WorkloopError::NotRegistered {
230                loop_id: loop_id.clone(),
231            }
232        })?;
233        for sample in samples {
234            if !record
235                .spec
236                .invariants()
237                .iter()
238                .any(|invariant| invariant.name == sample.invariant)
239            {
240                return Err(WorkloopError::UndeclaredInvariant {
241                    loop_id: loop_id.clone(),
242                    invariant: sample.invariant.clone(),
243                });
244            }
245        }
246        let now = (self.now)();
247        for sample in samples {
248            let state = record.invariant_health.entry(sample.invariant.clone());
249            let state = state.or_default();
250            match sample.status {
251                HealthStatus::Confirmed => observe_confirmed(state, now),
252                HealthStatus::Unconfirmed => {
253                    observe_unconfirmed(state, UnconfirmedEvidence::SampleRed);
254                }
255            }
256        }
257        record.last_iteration_closed_window = Some(record.window_seq);
258
259        let mut alarms = Vec::new();
260        let window_ctx = window_context(&record);
261        let anchor = record.registered_at;
262        for invariant in record.spec.invariants().to_vec() {
263            let Some(state) = record.invariant_health.get_mut(&invariant.name) else {
264                continue;
265            };
266            if let Some(alarm) = peek_alarm(
267                &invariant.name,
268                &invariant.tolerance,
269                state,
270                anchor,
271                now,
272                window_ctx,
273            ) {
274                self.sink
275                    .record_invariant_unconfirmed(loop_id, alarm.clone())
276                    .await?;
277                latch_alarm(state);
278                alarms.push(alarm);
279            }
280        }
281
282        record.next_check_at = next_check_at(&record);
283        record.updated_at = now;
284        self.store.put_workloop(record).await?;
285        Ok(alarms)
286    }
287
288    /// One sweep pass over every due loop: fire elapsed cadence windows
289    /// (recording `CadenceFired` through the one Recorder, then waking the
290    /// generation), evaluate the dead-man switch (an iteration with no
291    /// terminal by its next window is a missed window — R3.3a — counted
292    /// against every invariant), and evaluate every declared tolerance,
293    /// raising `InvariantUnconfirmed` with its cause on the one alarm path.
294    ///
295    /// # Errors
296    ///
297    /// Returns an error only when the due-set itself cannot be read; per-loop
298    /// faults are carried in the report so one sick loop cannot silence the
299    /// sweep for the rest.
300    pub async fn tick(&self) -> Result<SweepReport, WorkloopError> {
301        let now = (self.now)();
302        let mut report = SweepReport::default();
303        for record in self.store.due_workloops(now).await? {
304            let loop_id = record.loop_id.clone();
305            if let Err(error) = self.sweep_loop(record, now, &mut report).await {
306                report.faults.push((loop_id, error.to_string()));
307            }
308            report.swept += 1;
309        }
310        Ok(report)
311    }
312
313    async fn sweep_loop(
314        &self,
315        mut record: WorkloopRecord,
316        now: DateTime<Utc>,
317        report: &mut SweepReport,
318    ) -> Result<(), WorkloopError> {
319        let loop_id = record.loop_id.clone();
320        let mut wake_pending = false;
321
322        // --- cadence half: fire the elapsed window (R4.3). ---
323        if let (Some(period), Some(window_at)) =
324            (record.spec.arming().cadence_period(), record.next_window_at)
325            && window_at <= now
326        {
327            // Dead-man first: did the PREVIOUS fired window's iteration close?
328            // An iteration that produced no terminal by its next window is a
329            // missed window, evaluated engine-side, never waited on (R3.3a).
330            if record.window_seq >= 1
331                && record.last_iteration_closed_window != Some(record.window_seq)
332            {
333                for invariant in record.spec.invariants().to_vec() {
334                    let state = record
335                        .invariant_health
336                        .entry(invariant.name.clone())
337                        .or_default();
338                    observe_unconfirmed(state, UnconfirmedEvidence::WindowMissed);
339                }
340            }
341
342            let window_seq = record.window_seq.saturating_add(1);
343            match self.sink.record_cadence_fired(&loop_id, window_seq).await? {
344                // AlreadyRecorded is an acknowledgement-lost duplicate of OUR
345                // own append (single writer): the fire stands and the owed
346                // wake must still follow, exactly as for Recorded.
347                RecordOutcome::Recorded | RecordOutcome::AlreadyRecorded => {
348                    record.window_seq = window_seq;
349                    record.next_window_at = Some(advance_window(window_at, period, now)?);
350                    report.fired.push((loop_id.clone(), window_seq));
351                    wake_pending = true;
352                }
353                RecordOutcome::RefusedTerminal => {
354                    // The loop's run is terminal without a declared retirement:
355                    // the engine positively knows the loop cannot run. Loop
356                    // death is the simultaneous non-confirmation of every
357                    // invariant it carried — the missed-window event fanned out
358                    // with cause loop-dead (R4.3), recorded even though the run
359                    // is terminal.
360                    return self.declare_loop_dead(record, report).await;
361                }
362                RecordOutcome::RefusedRetired => {
363                    // 🔴 A RETIREMENT IS NOT A DEATH.
364                    //
365                    // Retirement records `LoopRetired` + its terminal and THEN
366                    // withdraws the sweep-set row, so a sweep landing between
367                    // those two steps finds a registered loop whose run is
368                    // terminal — the same observation a dead loop produces.
369                    // Answering it with `declare_loop_dead` wrote an
370                    // `AlarmCause::LoopDead` against every invariant of a loop
371                    // that was decommissioned on purpose, and those alarms are
372                    // durable and permanent.
373                    //
374                    // The row is withdrawn here instead. Doing so is not a
375                    // race with the retirement's own `deregister`: removal is
376                    // idempotent and reports whether a row existed, and the
377                    // retirement logs when it finds the row already gone.
378                    let removed = self.store.remove_workloop(&loop_id).await?;
379                    tracing::info!(
380                        %loop_id,
381                        row_existed = removed,
382                        "sweep found a retired workloop still on the sweep set and withdrew it; \
383                         a declared retirement raises no alarms"
384                    );
385                    report.retired.push(loop_id);
386                    return Ok(());
387                }
388            }
389        }
390
391        // --- evaluation half: every declared tolerance, one alarm path. ---
392        let window_ctx = window_context(&record);
393        let anchor = record.registered_at;
394        for invariant in record.spec.invariants().to_vec() {
395            let state = record
396                .invariant_health
397                .entry(invariant.name.clone())
398                .or_default();
399            if let Some(alarm) = peek_alarm(
400                &invariant.name,
401                &invariant.tolerance,
402                state,
403                anchor,
404                now,
405                window_ctx,
406            ) {
407                self.sink
408                    .record_invariant_unconfirmed(&loop_id, alarm.clone())
409                    .await?;
410                latch_alarm(state);
411                report.alarms.push((loop_id.clone(), alarm));
412            }
413        }
414
415        record.next_check_at = next_check_at(&record);
416        record.updated_at = now;
417        self.store.put_workloop(record).await?;
418
419        if wake_pending && let Err(error) = self.waker.wake(&loop_id).await {
420            // The fire is durably recorded; a failed wake degrades to a
421            // missed window at the next sweep, never to silence.
422            report
423                .faults
424                .push((loop_id, format!("wake failed: {error}")));
425        }
426        Ok(())
427    }
428
429    async fn declare_loop_dead(
430        &self,
431        record: WorkloopRecord,
432        report: &mut SweepReport,
433    ) -> Result<(), WorkloopError> {
434        let loop_id = record.loop_id.clone();
435        let window_ctx = window_context(&record);
436        for invariant in record.spec.invariants() {
437            let state = record
438                .invariant_health
439                .get(&invariant.name)
440                .cloned()
441                .unwrap_or_default();
442            let alarm = InvariantAlarm {
443                invariant: invariant.name.clone(),
444                cause: aion_core::AlarmCause::LoopDead,
445                window_seq: window_ctx,
446                last_confirmed_at: state.last_confirmed_at,
447                consecutive_unconfirmed: state.consecutive_unconfirmed,
448            };
449            self.sink
450                .record_invariant_unconfirmed(&loop_id, alarm.clone())
451                .await?;
452            report.alarms.push((loop_id.clone(), alarm));
453        }
454        // Deregister: a dead loop has no windows to sweep. Its alarms are
455        // durably recorded, its history keeps the un-retired terminal, and
456        // its invariant current-state records survive indefinitely (R8.1).
457        self.store.remove_workloop(&loop_id).await?;
458        report.dead.push(loop_id);
459        Ok(())
460    }
461
462    /// Runs the sweep loop until `shutdown` flips true. One task for every
463    /// loop in the store — never a task per loop.
464    pub async fn run(self: Arc<Self>, mut shutdown: tokio::sync::watch::Receiver<bool>) {
465        let mut interval = tokio::time::interval(self.sweep_interval);
466        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
467        loop {
468            tokio::select! {
469                _ = interval.tick() => {
470                    match self.tick().await {
471                        Ok(report) => {
472                            for (loop_id, fault) in &report.faults {
473                                tracing::warn!(%loop_id, fault, "workloop sweep fault");
474                            }
475                        }
476                        Err(error) => {
477                            tracing::error!(%error, "workloop sweep pass failed");
478                        }
479                    }
480                }
481                changed = shutdown.changed() => {
482                    if changed.is_err() || *shutdown.borrow() {
483                        break;
484                    }
485                }
486            }
487        }
488    }
489}
490
491/// Withdraws every registration whose workflow has NO recorded history — the
492/// boot half of `start_workloop`'s non-atomic register-then-start. Returns the
493/// loops withdrawn.
494///
495/// # 🔴 WHY THIS RUNS AT BOOT, BEFORE THE SWEEP TASK EXISTS, AND NOWHERE ELSE
496///
497/// `Engine::start_workloop` writes the sweep-set row BEFORE the workflow
498/// exists, and must: the reverse order lets generation 1 reach
499/// `close_iteration` before its own registration lands and fail the run. The
500/// price is a window in which a crash strands a row for a workflow that has no
501/// history and never will.
502///
503/// Nothing in a RUNNING process can tell that stranded row from the width of
504/// an in-flight start — they are the same observation — so a sweep that
505/// withdrew empty-history rows would race the very birth window the ordering
506/// exists to protect, and a loop would lose its registration between its start
507/// and its first close. A timeout would only make the race longer, and this
508/// codebase does not assume durations.
509///
510/// Boot has the fact the sweep lacks: no start is in flight across a process
511/// boundary. A registration whose workflow has no history at the instant this
512/// engine comes up is therefore provably orphaned, with no clock involved.
513/// It is a FREE function, called before the service and its sweep task are
514/// assembled, so the ordering is a property of the call site rather than a
515/// hope about which of two tasks ticks first.
516///
517/// # Errors
518///
519/// Propagates store failures; a row that cannot be decoded is left in place
520/// and reported, never silently withdrawn — an unreadable row is not evidence
521/// that its loop never started.
522pub async fn withdraw_unstarted_registrations(
523    workloop_store: &Arc<dyn WorkloopStore>,
524    store: &Arc<dyn aion_store::EventStore>,
525) -> Result<Vec<WorkflowId>, WorkloopError> {
526    let listing = workloop_store.list_workloops().await?;
527    let mut withdrawn = Vec::new();
528    for record in listing.workloops {
529        let loop_id = record.loop_id;
530        if !store.read_history(&loop_id).await?.is_empty() {
531            continue;
532        }
533        let existed = workloop_store.remove_workloop(&loop_id).await?;
534        tracing::warn!(
535            %loop_id,
536            row_existed = existed,
537            "withdrawing a workloop registration whose workflow has no recorded history: its \
538             start never landed, so the sweep set carried a row for a loop that never ran and \
539             the dead-man switch would have alarmed on it forever"
540        );
541        withdrawn.push(loop_id);
542    }
543    for row in listing.undecodable {
544        tracing::error!(
545            loop_id = %row.loop_id,
546            "workloop registration row does not decode; it is left in place rather than \
547             withdrawn, because an unreadable row is not evidence that its loop never started"
548        );
549    }
550    Ok(withdrawn)
551}
552
553/// The window context alarms carry: the current fired window on a cadenced
554/// loop that has fired at least once, `None` otherwise (signal-only loops
555/// have no windows; a loop that never fired has none yet).
556pub(crate) fn window_context(record: &WorkloopRecord) -> Option<u64> {
557    (record.spec.arming().cadence_period().is_some() && record.window_seq >= 1)
558        .then_some(record.window_seq)
559}