1use 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
24pub struct EngineLoopEventSink {
26 registry: Arc<Registry>,
27 store: Arc<dyn EventStore>,
28 visibility_store: Arc<dyn VisibilityStore>,
29}
30
31impl EngineLoopEventSink {
32 #[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 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 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 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
151fn 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 #[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 #[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 #[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, ¤t_run, 3),
288 completed(&workflow_id, 4),
289 ];
290
291 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}