aion/workloop/iteration.rs
1//! Iteration close and retirement: the continue-as-new machinery, surfaced
2//! for workloops (R3.1) — never a second loop mechanism.
3//!
4//! Each cadence tick opens a bounded history generation; `route start`
5//! compiles to THIS boundary: the iteration's routes land as health samples
6//! against the loop's invariants (R3.3), `IterationClosed` and the existing
7//! `WorkflowContinuedAsNew` terminal are recorded through the loop's single
8//! Recorder, and the successor generation's `WorkflowStarted` is recorded
9//! WITHOUT spawning a process — between fires a workloop is store bytes plus
10//! its sweep-set row (R13.3); the cadence service wakes the generation when
11//! its window fires.
12
13use aion_core::{
14 Event, HealthSample, HealthStatus, Payload, RunId, WorkloopSpec, current_lease_terminal,
15};
16use aion_store::workloop::InvariantStateRecord;
17use chrono::{DateTime, Utc};
18
19use super::error::WorkloopError;
20use crate::durability::{Recorder, WorkflowStartRecord};
21use crate::lifecycle::continue_as_new::guard_no_pending_work;
22use crate::time::retire_run_deadline;
23
24/// One iteration close, as the compiled `route start(carry)` terminal hands
25/// it to the engine.
26#[derive(Clone, Debug)]
27pub struct WorkloopIterationClose {
28 /// Routes the iteration took, in order; the last is its terminal.
29 pub routes: Vec<String>,
30 /// Carry payload threaded into the successor generation (the route's
31 /// payload — `route start(seen: ...)`).
32 pub carry: Payload,
33 /// Typed invariant current-state values the iteration produced, keyed by
34 /// invariant name (type-checked at the surface, type-erased here).
35 pub invariant_states: Vec<(String, Payload)>,
36}
37
38/// What a close produced: the derived samples, the successor generation, and
39/// the invariant records to persist.
40#[derive(Clone, Debug)]
41pub struct IterationOutcome {
42 /// The health samples derived from the routes (R3.3) and recorded on the
43 /// `IterationClosed` event.
44 pub samples: Vec<HealthSample>,
45 /// The successor generation's run id.
46 pub next_run_id: RunId,
47 /// Invariant current-state records to install (R7), one per produced
48 /// value.
49 pub records: Vec<InvariantStateRecord>,
50}
51
52/// Derives one health sample per declared invariant from the iteration's
53/// taken routes: `Confirmed` when any taken route is declared as confirming
54/// the invariant, `Unconfirmed` otherwise — every invariant is sampled on the
55/// same tick (R2.2), so a closing iteration never leaves an invariant
56/// unsampled.
57#[must_use]
58pub fn derive_health_samples(
59 spec: &WorkloopSpec,
60 routes: &[String],
61 window_seq: Option<u64>,
62) -> Vec<HealthSample> {
63 spec.invariants()
64 .iter()
65 .map(|invariant| {
66 let confirmed = routes
67 .iter()
68 .any(|route| invariant.confirms.contains(route));
69 HealthSample {
70 invariant: invariant.name.clone(),
71 status: if confirmed {
72 HealthStatus::Confirmed
73 } else {
74 HealthStatus::Unconfirmed
75 },
76 window_seq,
77 }
78 })
79 .collect()
80}
81
82/// Health samples for an iteration that FAILED: a failed iteration is a red
83/// health sample against every invariant, not a failed loop (R3.3) — the next
84/// iteration IS the retry.
85#[must_use]
86pub fn failed_iteration_samples(spec: &WorkloopSpec, window_seq: Option<u64>) -> Vec<HealthSample> {
87 spec.invariants()
88 .iter()
89 .map(|invariant| HealthSample {
90 invariant: invariant.name.clone(),
91 status: HealthStatus::Unconfirmed,
92 window_seq,
93 })
94 .collect()
95}
96
97/// Refuses invariant-state values naming undeclared invariants and builds
98/// their store records.
99fn invariant_records(
100 spec: &WorkloopSpec,
101 close: &WorkloopIterationClose,
102 loop_id: &aion_core::WorkflowId,
103 window_seq: Option<u64>,
104 recorded_at: DateTime<Utc>,
105) -> Result<Vec<InvariantStateRecord>, WorkloopError> {
106 close
107 .invariant_states
108 .iter()
109 .map(|(name, payload)| {
110 let invariant = spec
111 .invariants()
112 .iter()
113 .find(|invariant| &invariant.name == name)
114 .ok_or_else(|| WorkloopError::UndeclaredInvariant {
115 loop_id: loop_id.clone(),
116 invariant: name.clone(),
117 })?;
118 Ok(InvariantStateRecord {
119 loop_id: loop_id.clone(),
120 invariant: name.clone(),
121 payload: payload.clone(),
122 record_type: invariant.record_type.clone(),
123 window_seq,
124 recorded_at,
125 })
126 })
127 .collect()
128}
129
130/// The current generation's recorded workflow type and package version — the
131/// successor is pinned to the same recorded version; version upgrade for
132/// long-lived loops rides the deploy surface, not the iteration boundary.
133fn current_generation_identity(
134 history: &[Event],
135) -> Result<(String, aion_core::PackageVersion), WorkloopError> {
136 history
137 .iter()
138 .rev()
139 .find_map(|event| {
140 if let Event::WorkflowStarted {
141 workflow_type,
142 package_version,
143 ..
144 } = event
145 {
146 Some((workflow_type.clone(), package_version.clone()))
147 } else {
148 None
149 }
150 })
151 .ok_or_else(|| WorkloopError::Engine {
152 reason: "workloop history has no WorkflowStarted".to_owned(),
153 })
154}
155
156/// Everything the iteration boundary needs to know about the loop and its
157/// current generation, gathered by the caller under the recorder lock.
158#[derive(Clone, Copy, Debug)]
159pub struct IterationContext<'a> {
160 /// The loop's full history, read under the same recorder acquisition.
161 pub history: &'a [Event],
162 /// The closing generation's run id.
163 pub run_id: &'a RunId,
164 /// The loop's workflow identity.
165 pub loop_id: &'a aion_core::WorkflowId,
166 /// The loop's declared spec.
167 pub spec: &'a WorkloopSpec,
168 /// The current fired window, when the loop is cadenced and has fired.
169 pub window_seq: Option<u64>,
170 /// Deterministic recording timestamp for the boundary's events.
171 pub recorded_at: DateTime<Utc>,
172}
173
174/// Closes the current iteration through the loop's single Recorder: records
175/// `IterationClosed { routes, health_samples }`, the existing
176/// `WorkflowContinuedAsNew` terminal carrying the carry payload, retires the
177/// run's deadline, and records the successor generation's `WorkflowStarted` —
178/// all sequential appends under ONE recorder, no process spawned.
179///
180/// The caller holds the recorder lock for the whole call and owns feeding the
181/// returned samples into the cadence service's health accounting plus
182/// persisting the returned invariant records with retention pruning.
183///
184/// # Errors
185///
186/// Refuses a close on a terminal run, with pending work (the continue-as-new
187/// guard, unchanged), or naming undeclared invariants; propagates append
188/// failures.
189pub async fn close_iteration(
190 recorder: &mut Recorder,
191 context: IterationContext<'_>,
192 close: WorkloopIterationClose,
193) -> Result<IterationOutcome, WorkloopError> {
194 let IterationContext {
195 history,
196 run_id,
197 loop_id,
198 spec,
199 window_seq,
200 recorded_at,
201 } = context;
202 if current_lease_terminal(history).is_some() {
203 return Err(WorkloopError::Engine {
204 reason: format!("workloop {loop_id} run {run_id} already recorded a terminal"),
205 });
206 }
207 // 🔴 THE PENDING-WORK GUARD IS SCOPED TO THIS GENERATION'S SEGMENT.
208 //
209 // `guard_no_pending_work` accumulates unsettled activities and children by
210 // forward-scanning whatever slice it is handed, and `WorkflowStarted` is in
211 // its NO-OP arm — a generation boundary does not clear the pending sets. On
212 // an ordinary workflow that is invisible, because the history it is handed
213 // is one run or a few. On a workloop it is neither invisible nor harmless:
214 // the history is EVERY generation the loop has ever had, so one unsettled
215 // activity left behind by generation 7 would refuse the close of generation
216 // 4,000, permanently, and the loop would stop parking for a reason four
217 // thousand generations in its past.
218 //
219 // Pending work is run-scoped by nature — a generation can only settle what
220 // it started — so the segment is the correct unit, and it is also the
221 // bounded one: this scan no longer grows with the loop's age.
222 guard_no_pending_work(aion_core::run_segment(history, run_id)).map_err(|error| {
223 WorkloopError::Engine {
224 reason: error.to_string(),
225 }
226 })?;
227
228 let samples = derive_health_samples(spec, &close.routes, window_seq);
229 let records = invariant_records(spec, &close, loop_id, window_seq, recorded_at)?;
230 let (workflow_type, package_version) = current_generation_identity(history)?;
231
232 // ONE atomic batch: IterationClosed + WorkflowContinuedAsNew + the
233 // successor generation's WorkflowStarted — no crash window between the
234 // boundary's halves, and NO process spawned for the successor (R13.3);
235 // the cadence service wakes it when its window fires. The successor is
236 // pinned to the predecessor's recorded package version.
237 let next_run_id = RunId::new_v4();
238 recorder
239 .record_workloop_iteration_boundary(
240 recorded_at,
241 close.routes.clone(),
242 samples.clone(),
243 close.carry.clone(),
244 run_id.clone(),
245 WorkflowStartRecord {
246 workflow_type,
247 input: close.carry,
248 run_id: next_run_id.clone(),
249 parent_run_id: Some(run_id.clone()),
250 parent_workflow_id: None,
251 package_version,
252 },
253 )
254 .await?;
255 retire_run_deadline(recorder, history, run_id).await?;
256
257 Ok(IterationOutcome {
258 samples,
259 next_run_id,
260 records,
261 })
262}
263
264#[cfg(test)]
265mod tests {
266 use std::time::Duration;
267
268 use aion_core::{InvariantSpec, ToleranceSpec, WorkloopArming};
269
270 use super::*;
271
272 fn spec() -> Result<WorkloopSpec, Box<dyn std::error::Error>> {
273 Ok(WorkloopSpec::new(
274 WorkloopArming::every(Duration::from_secs(1500))?,
275 vec![
276 InvariantSpec {
277 name: String::from("serving"),
278 record_type: String::from("ServeState"),
279 tolerance: ToleranceSpec::count(3),
280 confirms: vec![String::from("sweep"), String::from("dispatch")],
281 },
282 InvariantSpec {
283 name: String::from("drained"),
284 record_type: String::from("DrainState"),
285 tolerance: ToleranceSpec::count(0),
286 confirms: vec![String::from("drain")],
287 },
288 ],
289 Duration::from_secs(86_400),
290 )?)
291 }
292
293 #[test]
294 fn every_invariant_is_sampled_on_the_same_tick() -> Result<(), Box<dyn std::error::Error>> {
295 let samples = derive_health_samples(
296 &spec()?,
297 &[String::from("sweep"), String::from("start")],
298 Some(4),
299 );
300 assert_eq!(samples.len(), 2);
301 assert_eq!(samples[0].invariant, "serving");
302 assert_eq!(samples[0].status, HealthStatus::Confirmed);
303 assert_eq!(samples[0].window_seq, Some(4));
304 // The other invariant's confirming route was not taken: an unhealthy
305 // sample, not an unsampled invariant.
306 assert_eq!(samples[1].invariant, "drained");
307 assert_eq!(samples[1].status, HealthStatus::Unconfirmed);
308 Ok(())
309 }
310
311 #[test]
312 fn a_failed_iteration_reds_every_invariant() -> Result<(), Box<dyn std::error::Error>> {
313 let samples = failed_iteration_samples(&spec()?, None);
314 assert!(
315 samples
316 .iter()
317 .all(|sample| sample.status == HealthStatus::Unconfirmed)
318 );
319 assert_eq!(samples.len(), 2);
320 Ok(())
321 }
322}