Skip to main content

aion/workloop/
close.rs

1//! The iteration-close path, shared by the engine API verb and the
2//! `close_iteration/3` NIF.
3//!
4//! Both callers must produce the SAME durable effect — one atomic
5//! `[IterationClosed + WorkflowContinuedAsNew + successor WorkflowStarted]`
6//! batch through the loop's one Recorder, no successor process, invariant
7//! records installed with retention pruning, samples fed into tolerance
8//! accounting — so the behaviour lives here once rather than in each caller.
9//! A close reached from compiled workflow code and a close reached from an
10//! operator API call are the same close.
11
12use std::sync::Arc;
13
14use aion_core::{Event, RunId, WorkflowId};
15use aion_store::EventStore;
16use aion_store::visibility::VisibilityStore;
17use aion_store::workloop::WorkloopStore;
18use chrono::Utc;
19
20use super::iteration::{self, WorkloopIterationClose};
21use super::service::{WorkloopService, window_context};
22use crate::durability::Recorder;
23use crate::error::EngineError;
24use crate::registry::{Registry, TerminalOutcome};
25
26/// The engine components one iteration close needs.
27///
28/// Held by both `Engine` (through its workloop runtime) and the NIF bridge,
29/// so compiled workflow code reaches exactly the engine-side close the API
30/// verb reaches.
31#[derive(Clone)]
32pub struct IterationCloseContext {
33    /// Registration and invariant-record store.
34    pub workloop_store: Arc<dyn WorkloopStore>,
35    /// The cadence service, for health accounting.
36    pub service: Arc<WorkloopService>,
37    /// Event store backing the loop's history.
38    pub store: Arc<dyn EventStore>,
39    /// Visibility projection store.
40    pub visibility_store: Arc<dyn VisibilityStore>,
41    /// Registry holding the closing generation's live handle, if resident.
42    pub registry: Arc<Registry>,
43}
44
45/// Closes the current iteration at the continue-as-new boundary (R3.1).
46///
47/// Derives one health sample per declared invariant from the taken routes
48/// (R3.3), records the boundary batch through the loop's Recorder — spawning
49/// NO successor process (R13.3) — installs the produced invariant
50/// current-state records with retention pruning (R7/R8), and feeds the
51/// samples into tolerance accounting. Returns the successor generation's run
52/// id.
53///
54/// # Errors
55///
56/// Refuses an unregistered loop, a terminal run, pending work, and undeclared
57/// invariants; propagates store/append failures.
58pub async fn close_iteration(
59    context: &IterationCloseContext,
60    loop_id: &WorkflowId,
61    close: WorkloopIterationClose,
62) -> Result<RunId, EngineError> {
63    let record = context
64        .workloop_store
65        .get_workloop(loop_id)
66        .await
67        .map_err(EngineError::from)?
68        .ok_or_else(|| EngineError::InvalidState {
69            reason: format!("workflow {loop_id} is not a registered workloop"),
70        })?;
71    let window_seq = window_context(&record);
72    let spec = record.spec.clone();
73    let retention = record.spec.retention();
74
75    let carry_for_notify = close.carry.clone();
76    let close_for_recorder = close;
77    let outcome = with_loop_recorder(context, loop_id, move |recorder, history| {
78        let spec = spec.clone();
79        let close = close_for_recorder.clone();
80        let loop_id = loop_id.clone();
81        Box::pin(async move {
82            let run_id = active_run_id(history).ok_or_else(|| {
83                crate::durability::DurabilityError::HistoryShape {
84                    reason: format!("workloop {loop_id} has no recorded generation"),
85                }
86            })?;
87            iteration::close_iteration(
88                recorder,
89                iteration::IterationContext {
90                    history,
91                    run_id: &run_id,
92                    loop_id: &loop_id,
93                    spec: &spec,
94                    window_seq,
95                    recorded_at: Utc::now(),
96                },
97                close,
98            )
99            .await
100            .map_err(|error| crate::durability::DurabilityError::HistoryShape {
101                reason: error.to_string(),
102            })
103        })
104    })
105    .await?;
106
107    // The closing generation's live handle (if the iteration ran resident) is
108    // retired exactly as continue-as-new retires it: notify the continuation
109    // and drop the registry entry. The successor stays unregistered —
110    // SUSPENDED — until a cadence fire or an armed signal wakes it. That is
111    // the whole park: no resident process between fires.
112    if let Some(handle) = registry_handle(context, loop_id)? {
113        let closed_run = handle.run_id().clone();
114        handle.completion().notify(TerminalOutcome::ContinuedAsNew {
115            input: carry_for_notify,
116            workflow_type: None,
117            parent_run_id: closed_run.clone(),
118        });
119        context.registry.remove(loop_id, &closed_run)?;
120    }
121
122    // Invariant current-state records (R7) + declared-window retention (R8):
123    // ONE write per invariant installs the record and applies the declared
124    // window in the same commit, so retention that is declared is retention
125    // that happens — and a park costs `2 + N` durable commits rather than
126    // `2 + 2N`.
127    let cutoff = retention_cutoff(Utc::now(), retention)?;
128    for state_record in outcome.records.clone() {
129        context
130            .workloop_store
131            .put_invariant_record(state_record, cutoff)
132            .await
133            .map_err(EngineError::from)?;
134    }
135
136    context
137        .service
138        .note_iteration_closed(loop_id, &outcome.samples)
139        .await
140        .map_err(EngineError::from)?;
141
142    Ok(outcome.next_run_id)
143}
144
145/// The instant prior invariant generations are pruned against: `now` less the
146/// declared retention window.
147///
148/// # 🔴 A FALLBACK HERE POINTS THE WRONG WAY, SO THERE IS NONE
149///
150/// This was `chrono::Duration::from_std(retention).unwrap_or_else(|_|
151/// chrono::Duration::zero())`. A zero fallback makes the cutoff `now`, which
152/// prunes EVERY prior generation — the precise inverse of what an out-of-range
153/// (that is, enormous) retention declares. A swallowed conversion whose
154/// fallback destroys the data the declaration asked to keep is strictly worse
155/// than a refused close: the close is retryable, the deleted generations are
156/// not.
157///
158/// `WorkloopSpec` already refuses a retention this cannot convert, so a spec
159/// declared through the engine cannot reach the first branch. It is still
160/// propagated rather than asserted away: unreachable-by-construction is a
161/// property of today's declaration path, and a spec is durable bytes that
162/// outlive it.
163fn retention_cutoff(
164    now: chrono::DateTime<Utc>,
165    retention: std::time::Duration,
166) -> Result<chrono::DateTime<Utc>, EngineError> {
167    let window =
168        chrono::Duration::from_std(retention).map_err(|error| EngineError::InvalidState {
169            reason: format!(
170                "workloop retention window of {seconds}s cannot be expressed as a calendar \
171                 duration ({error}), so no retention cutoff exists; refusing rather than \
172                 pruning against a fallback that would delete every prior generation",
173                seconds = retention.as_secs()
174            ),
175        })?;
176    now.checked_sub_signed(window)
177        .ok_or_else(|| EngineError::InvalidState {
178            reason: format!(
179                "subtracting the declared workloop retention window of {seconds}s from \
180                 {now} left the representable calendar range, so no retention cutoff exists",
181                seconds = retention.as_secs()
182            ),
183        })
184}
185
186fn registry_handle(
187    context: &IterationCloseContext,
188    loop_id: &WorkflowId,
189) -> Result<Option<crate::registry::WorkflowHandle>, EngineError> {
190    // aion#213: `with_loop_recorder` APPENDS DURABLY through whatever this
191    // returns, so a first-match scan here is the coin toss `Registry::sole_handle`
192    // exists to refuse — it would pick a generation's recorder at random and
193    // write the loop's history through it.
194    context.registry.sole_handle(loop_id)
195}
196
197/// Append through the loop's ONE Recorder: the live handle's recorder when
198/// registered, a one-shot `Recorder::resume_at` when suspended (the
199/// sanctioned non-resident pattern). The closure receives the history read
200/// under the same acquisition, so check-then-append is not interleaved.
201async fn with_loop_recorder<T>(
202    context: &IterationCloseContext,
203    loop_id: &WorkflowId,
204    record: impl for<'a> FnOnce(
205        &'a mut Recorder,
206        &'a [Event],
207    ) -> std::pin::Pin<
208        Box<
209            dyn std::future::Future<Output = Result<T, crate::durability::DurabilityError>>
210                + Send
211                + 'a,
212        >,
213    >,
214) -> Result<T, EngineError> {
215    if let Some(handle) = registry_handle(context, loop_id)? {
216        let recorder = handle.recorder();
217        let mut recorder = recorder.lock().await;
218        let history = context.store.read_history(loop_id).await?;
219        let value = record(&mut recorder, &history).await?;
220        return Ok(value);
221    }
222    let history = context.store.read_history(loop_id).await?;
223    let head = history.iter().map(Event::seq).max().unwrap_or_default();
224    let mut recorder = Recorder::resume_at(loop_id.clone(), Arc::clone(&context.store), head);
225    if let Some(run_id) = active_run_id(&history) {
226        recorder = recorder.with_visibility(run_id, Arc::clone(&context.visibility_store));
227    }
228    let value = record(&mut recorder, &history).await?;
229    Ok(value)
230}
231
232/// The loop's currently active generation — the latest recorded start.
233pub(crate) fn active_run_id(history: &[Event]) -> Option<RunId> {
234    history.iter().rev().find_map(|event| match event {
235        Event::WorkflowStarted { run_id, .. } => Some(run_id.clone()),
236        _ => None,
237    })
238}
239
240#[cfg(test)]
241mod tests {
242    use std::time::Duration;
243
244    use chrono::TimeZone;
245
246    use super::retention_cutoff;
247    use crate::error::EngineError;
248
249    fn now() -> Result<chrono::DateTime<chrono::Utc>, Box<dyn std::error::Error>> {
250        chrono::Utc
251            .with_ymd_and_hms(2026, 8, 26, 12, 0, 0)
252            .single()
253            .ok_or_else(|| "test instant must be valid".into())
254    }
255
256    /// The ordinary case, and the control for the refusal below: a declared
257    /// window subtracts to an instant that far predates `now`.
258    #[test]
259    fn a_declared_window_subtracts_to_its_own_past() -> Result<(), Box<dyn std::error::Error>> {
260        let now = now()?;
261        let cutoff = retention_cutoff(now, Duration::from_secs(14 * 86_400))?;
262        assert_eq!(cutoff, now - chrono::Duration::days(14));
263        Ok(())
264    }
265
266    /// 🔴 THE SWALLOWED FALLBACK POINTED THE WRONG WAY.
267    ///
268    /// With `unwrap_or_else(|_| Duration::zero())` this input produced a
269    /// cutoff of exactly `now` — which prunes EVERY prior generation, the
270    /// inverse of a retention window so long it could not be represented. The
271    /// assertion is therefore not merely "an error is returned": it is that
272    /// the function does not answer `now`, because `now` is the specific wrong
273    /// answer the old code gave.
274    #[test]
275    fn an_unrepresentable_window_refuses_instead_of_pruning_everything()
276    -> Result<(), Box<dyn std::error::Error>> {
277        let now = now()?;
278        let outcome = retention_cutoff(now, Duration::from_secs(u64::MAX / 1_000));
279        assert!(
280            !matches!(&outcome, Ok(cutoff) if *cutoff == now),
281            "an unrepresentable retention must never yield a cutoff of `now`: that prunes \
282             every prior generation, which is the opposite of what it declares"
283        );
284        let refusal = outcome
285            .err()
286            .ok_or("an unrepresentable retention window must be refused")?;
287        assert!(
288            matches!(&refusal, EngineError::InvalidState { reason }
289                if reason.contains("retention")),
290            "the refusal must name the retention window: {refusal}"
291        );
292        Ok(())
293    }
294
295    /// The other end of the same conversion: a window that converts but whose
296    /// subtraction leaves the calendar range still has no cutoff, and must not
297    /// saturate into one.
298    #[test]
299    fn a_window_that_underflows_the_calendar_refuses() {
300        let early = chrono::DateTime::<chrono::Utc>::MIN_UTC + chrono::Duration::days(1);
301        let outcome = retention_cutoff(early, Duration::from_secs(1_000 * 365 * 86_400));
302        assert!(
303            outcome.is_err(),
304            "subtracting past the representable range must refuse, not saturate: {outcome:?}"
305        );
306    }
307}