Skip to main content

aion/workloop/
sink.rs

1//! Production [`LoopEventSink`]: every workloop event through the loop's ONE
2//! Recorder.
3//!
4//! Two acquisition paths, one writer: a REGISTERED loop's events append under
5//! its live handle's recorder lock; an unregistered (suspended, store-bytes-
6//! only) loop's events append through a one-shot `Recorder::resume_at` — the
7//! sanctioned pattern for a run with no live recorder (see
8//! `lifecycle::pause`'s discipline note). The two can never race: `resume_at`
9//! is used only when no registry entry exists.
10
11use aion_core::{Event, InvariantAlarm, RunId, WorkflowId};
12use aion_store::EventStore;
13use aion_store::visibility::VisibilityStore;
14use async_trait::async_trait;
15use chrono::Utc;
16use std::sync::Arc;
17
18use super::error::WorkloopError;
19use super::service::LoopEventSink;
20use crate::durability::Recorder;
21use crate::engine_seam::RecordOutcome;
22use crate::registry::Registry;
23
24/// Recorder-backed sink used by the engine's cadence service.
25pub struct EngineLoopEventSink {
26    registry: Arc<Registry>,
27    store: Arc<dyn EventStore>,
28    visibility_store: Arc<dyn VisibilityStore>,
29}
30
31impl EngineLoopEventSink {
32    /// Builds the sink over the engine's registry and stores.
33    #[must_use]
34    pub fn new(
35        registry: Arc<Registry>,
36        store: Arc<dyn EventStore>,
37        visibility_store: Arc<dyn VisibilityStore>,
38    ) -> Self {
39        Self {
40            registry,
41            store,
42            visibility_store,
43        }
44    }
45
46    fn registered_recorder(
47        &self,
48        loop_id: &WorkflowId,
49    ) -> Result<Option<crate::registry::WorkflowHandle>, WorkloopError> {
50        Ok(self
51            .registry
52            .list()
53            .map_err(|error| WorkloopError::Engine {
54                reason: error.to_string(),
55            })?
56            .into_iter()
57            .find(|handle| handle.workflow_id() == loop_id))
58    }
59
60    /// Append through the right recorder for the loop's residency, holding
61    /// the one-writer law. `refuse_terminal` decides whether an active-run
62    /// terminal refuses the append (cadence fires) or not (alarms).
63    async fn record_with_recorder<F>(
64        &self,
65        loop_id: &WorkflowId,
66        refuse_terminal: bool,
67        record: F,
68    ) -> Result<RecordOutcome, WorkloopError>
69    where
70        F: for<'a> AsyncFnOnce(&'a mut Recorder) -> Result<(), crate::durability::DurabilityError>,
71    {
72        if let Some(handle) = self.registered_recorder(loop_id)? {
73            let recorder = handle.recorder();
74            let mut recorder = recorder.lock().await;
75            let history = self.store.read_history(loop_id).await?;
76            if refuse_terminal && let Some(refusal) = terminal_refusal(&history) {
77                return Ok(refusal);
78            }
79            record(&mut recorder).await?;
80            return Ok(RecordOutcome::Recorded);
81        }
82
83        let history = self.store.read_history(loop_id).await?;
84        // 🔴 A LOOP WITH NO RECORDED START IS NOT A LOOP TO FIRE AT.
85        //
86        // `start_workloop` writes the sweep-set row BEFORE the workflow
87        // exists, so the loop can be registered for the width of one start.
88        // A crash in that window leaves the row behind permanently. Either
89        // way, appending a `CadenceFired` here would open the workflow's
90        // history with an event that is not a `WorkflowStarted` — a history
91        // no replay, projection or recovery can read. The fault is REPORTED
92        // (the sweep carries it per-loop and keeps going) rather than
93        // absorbed, and boot reconciliation withdraws the genuinely orphaned
94        // rows.
95        if history.is_empty() {
96            return Err(WorkloopError::Engine {
97                reason: format!(
98                    "workloop {loop_id} is on the sweep set but its workflow has no recorded \
99                     history, so there is nothing to fire at. This is either the width of an \
100                     in-flight `start_workloop` — the registration precedes the start by \
101                     design — or a registration whose start never landed, which engine boot \
102                     reconciliation withdraws"
103                ),
104            });
105        }
106        if refuse_terminal && let Some(refusal) = terminal_refusal(&history) {
107            return Ok(refusal);
108        }
109        let head = history.iter().map(Event::seq).max().unwrap_or_default();
110        let mut recorder = Recorder::resume_at(loop_id.clone(), Arc::clone(&self.store), head);
111        if let Some(run_id) = active_run_id(&history) {
112            recorder = recorder.with_visibility(run_id, Arc::clone(&self.visibility_store));
113        }
114        record(&mut recorder).await?;
115        Ok(RecordOutcome::Recorded)
116    }
117}
118
119#[async_trait]
120impl LoopEventSink for EngineLoopEventSink {
121    async fn record_cadence_fired(
122        &self,
123        loop_id: &WorkflowId,
124        window_seq: u64,
125    ) -> Result<RecordOutcome, WorkloopError> {
126        self.record_with_recorder(loop_id, true, async move |recorder: &mut Recorder| {
127            recorder.record_cadence_fired(Utc::now(), window_seq).await
128        })
129        .await
130    }
131
132    async fn record_invariant_unconfirmed(
133        &self,
134        loop_id: &WorkflowId,
135        alarm: InvariantAlarm,
136    ) -> Result<(), WorkloopError> {
137        // The alarm append is honoured even after the run's terminal: the
138        // alarm that reports a loop's death must not be silenced by the very
139        // death it reports. InvariantUnconfirmed is status-invisible, so the
140        // terminal projection is untouched.
141        self.record_with_recorder(loop_id, false, async move |recorder: &mut Recorder| {
142            recorder
143                .record_invariant_unconfirmed(Utc::now(), alarm)
144                .await
145        })
146        .await
147        .map(|_| ())
148    }
149}
150
151/// The refusal a cadence fire earns when the loop's active run (latest
152/// `WorkflowStarted`) already recorded a terminal — and WHICH refusal.
153///
154/// `None` means the run is live and the fire may proceed.
155///
156/// # 🔴 THE TWO TERMINALS ARE TOLD APART HERE
157///
158/// A terminal alone is the engine's positive evidence that the loop cannot
159/// run: [`RecordOutcome::RefusedTerminal`], which the sweep answers by
160/// declaring the loop dead and alarming every invariant. A terminal preceded
161/// by `LoopRetired` in the SAME run segment is a declared stop:
162/// [`RecordOutcome::RefusedRetired`], which the sweep answers by withdrawing
163/// the row quietly. Reading only "is there a terminal" made a clean retirement
164/// indistinguishable from a death, and the retirement path passes through that
165/// exact state on its way out.
166///
167/// The scan is segment-scoped, not whole-history: a `LoopRetired` can only
168/// speak for the run it was recorded in, and a workloop's history holds every
169/// generation it ever had.
170fn terminal_refusal(history: &[Event]) -> Option<RecordOutcome> {
171    let run_id = active_run_id(history)?;
172    crate::lifecycle::completion::terminal_outcome_from_history(history, &run_id)?;
173    let retired = aion_core::run_segment(history, &run_id)
174        .iter()
175        .any(|event| matches!(event, Event::LoopRetired { .. }));
176    Some(if retired {
177        RecordOutcome::RefusedRetired
178    } else {
179        RecordOutcome::RefusedTerminal
180    })
181}
182
183fn active_run_id(history: &[Event]) -> Option<RunId> {
184    history.iter().rev().find_map(|event| match event {
185        Event::WorkflowStarted { run_id, .. } => Some(run_id.clone()),
186        _ => None,
187    })
188}
189
190#[cfg(test)]
191mod tests {
192    use aion_core::{ContentType, EventEnvelope, PackageVersion, Payload, WorkflowId};
193
194    use super::{Event, RecordOutcome, RunId, terminal_refusal};
195
196    fn envelope(workflow_id: &WorkflowId, seq: u64) -> EventEnvelope {
197        EventEnvelope {
198            seq,
199            recorded_at: chrono::Utc::now(),
200            workflow_id: workflow_id.clone(),
201        }
202    }
203
204    fn started(workflow_id: &WorkflowId, run_id: &RunId, seq: u64) -> Event {
205        Event::WorkflowStarted {
206            envelope: envelope(workflow_id, seq),
207            workflow_type: String::from("queue_watch"),
208            input: Payload::new(ContentType::Json, b"{}".to_vec()),
209            run_id: run_id.clone(),
210            parent_run_id: None,
211            parent_workflow_id: None,
212            package_version: PackageVersion::new("a".repeat(64)),
213        }
214    }
215
216    fn retired(workflow_id: &WorkflowId, seq: u64) -> Event {
217        Event::LoopRetired {
218            envelope: envelope(workflow_id, seq),
219            reason: String::from("decommissioned"),
220        }
221    }
222
223    fn completed(workflow_id: &WorkflowId, seq: u64) -> Event {
224        Event::WorkflowCompleted {
225            envelope: envelope(workflow_id, seq),
226            result: Payload::new(ContentType::Json, b"{}".to_vec()),
227        }
228    }
229
230    /// A live run is no refusal at all — the control for both cases below.
231    #[test]
232    fn a_live_run_refuses_no_cadence_fire() {
233        let workflow_id = WorkflowId::new_v4();
234        let run = RunId::new_v4();
235        assert!(terminal_refusal(&[started(&workflow_id, &run, 1)]).is_none());
236    }
237
238    /// 🔴 THE TWO TERMINALS ARE TOLD APART, AND THE DIFFERENCE DECIDES WHETHER
239    /// A LOOP IS DECLARED DEAD.
240    ///
241    /// An undeclared terminal is positive evidence the loop cannot run, and the
242    /// sweep answers it with `AlarmCause::LoopDead` against every invariant. A
243    /// terminal preceded by `LoopRetired` is a declared stop and must raise
244    /// nothing. Retirement passes through exactly this state on its way out —
245    /// terminal recorded, sweep-set row not yet withdrawn — so collapsing the
246    /// two wrote a permanent death record into the history of a loop that was
247    /// decommissioned on purpose.
248    #[test]
249    fn a_terminal_without_a_retirement_is_a_death_and_with_one_is_not() {
250        let workflow_id = WorkflowId::new_v4();
251        let run = RunId::new_v4();
252
253        let died = vec![started(&workflow_id, &run, 1), completed(&workflow_id, 2)];
254        assert!(matches!(
255            terminal_refusal(&died),
256            Some(RecordOutcome::RefusedTerminal)
257        ));
258
259        let was_retired = vec![
260            started(&workflow_id, &run, 1),
261            retired(&workflow_id, 2),
262            completed(&workflow_id, 3),
263        ];
264        assert!(matches!(
265            terminal_refusal(&was_retired),
266            Some(RecordOutcome::RefusedRetired)
267        ));
268    }
269
270    /// 🔴 A `LoopRetired` SPEAKS ONLY FOR THE RUN IT WAS RECORDED IN.
271    ///
272    /// A workloop's history holds every generation it ever had. A whole-history
273    /// scan would let a retirement recorded generations ago make a LATER
274    /// generation's undeclared death read as a declared stop — the loop would
275    /// die silently and the sweep would withdraw it without an alarm. This is
276    /// the case a non-segment-scoped check waves through, and it is reachable:
277    /// a retirement that failed after recording its marker leaves exactly this
278    /// shape.
279    #[test]
280    fn a_prior_generations_retirement_does_not_excuse_this_generations_death() {
281        let workflow_id = WorkflowId::new_v4();
282        let old_run = RunId::new_v4();
283        let current_run = RunId::new_v4();
284        let history = vec![
285            started(&workflow_id, &old_run, 1),
286            retired(&workflow_id, 2),
287            started(&workflow_id, &current_run, 3),
288            completed(&workflow_id, 4),
289        ];
290
291        // Fixture control: the marker really is in the history being scanned,
292        // so a green here is scoping and not an absent event.
293        assert!(
294            history
295                .iter()
296                .any(|event| matches!(event, Event::LoopRetired { .. })),
297            "fixture control: the history must carry a LoopRetired for this to prove scoping"
298        );
299        assert!(
300            matches!(
301                terminal_refusal(&history),
302                Some(RecordOutcome::RefusedTerminal)
303            ),
304            "a retirement in an earlier generation must not excuse this one's death"
305        );
306    }
307}