Skip to main content

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}