Skip to main content

aion_core/
current_step.rs

1//! Which step a run is on, folded from its own history.
2//!
3//! There is no stored "current step" field anywhere and there deliberately is
4//! not one: exactly like [`crate::WorkflowStatus`], the answer is a PROJECTION
5//! of the authoritative event history, computed on read. A maintained
6//! projection would be a second copy of a fact history already holds, and the
7//! two would drift the first time an append path forgot to update it.
8//!
9//! # What "open" means here, precisely
10//!
11//! An activity ordinal is OPEN when the run's active segment records it as
12//! scheduled (and possibly dispatched) with no terminal event for it. The last
13//! event naming the ordinal decides:
14//!
15//! - [`Event::ActivityScheduled`] (re)opens it at the address the engine
16//!   stamped — the task queue and node the dispatch really went to.
17//! - [`Event::ActivityStarted`] records the delivery attempt and the instant
18//!   the ENGINE dispatched. It is NOT proof a worker took the work: the event
19//!   is written at dispatch, atomically with its `ActivityScheduled`, before
20//!   any worker is selected. That is why [`StepState::Dispatched`] is named for
21//!   what happened rather than for what a reader might hope happened, and why
22//!   liveness is answered by joining the live fleet, never by this fold.
23//! - [`Event::ActivityAdoptionOffered`] advances NOTHING. It records that
24//!   recovery handed the ordinal's still-dangling attempt back to the worker's
25//!   adoption path (#36) — the same execution, the same attempt, already
26//!   dispatched. Retiring the ordinal on it would tell a reader that a builder
27//!   still at work had stopped; re-opening it as scheduled would erase the
28//!   dispatch that is still in flight. Both would be false, and the pin
29//!   `an_adoption_offer_leaves_the_ordinal_exactly_as_dispatched` fails
30//!   against either.
31//! - `ActivityCompleted` / `ActivityFailed` / `ActivityCancelled` retire it.
32//! - [`Event::ActivityAdvisoryExhausted`] is deliberately NOT a terminal: it
33//!   ACCOMPANIES an `ActivityFailed` and never replaces it, so treating it as
34//!   one would retire an ordinal that its own failure already retired.
35//! - [`Event::WorkflowReopened`] SUPERSEDES the recorded terminal of every
36//!   activity it names, which is the whole point of the event — those
37//!   activities resolve to live re-dispatch on replay. The fold re-opens them
38//!   as [`StepState::Reopened`]: they are open again, and nothing has been
39//!   dispatched for them in this lease yet, so calling them `Dispatched` would
40//!   claim a delivery that has not happened.
41//!
42//! # Fan-out means "the current step" is not always one step
43//!
44//! A fan-out has several ordinals open at once. [`current_step`] answers with
45//! the most recently advanced one because a reader asking "what is it doing"
46//! needs a single answer; [`open_steps`] returns all of them so a reader that
47//! must not lose the siblings never has to guess that they existed.
48
49use chrono::{DateTime, Utc};
50
51use crate::{ActivityId, Event, describe_live::OpenStep, describe_live::StepState};
52
53/// Working state for one activity ordinal while the segment is scanned.
54#[derive(Clone, Debug)]
55struct Open {
56    step: OpenStep,
57    /// Position of the last event that advanced this ordinal, so the most
58    /// recently advanced open ordinal can be identified without a second scan.
59    last_advanced_at: usize,
60}
61
62/// Every step the run's active segment records as open, in the order the
63/// ordinals were first opened.
64///
65/// The active segment is everything from the latest [`Event::WorkflowStarted`]
66/// — a continue-as-new starts a fresh segment, and the prior segment's
67/// activities belong to a run that no longer executes.
68///
69/// An `ActivityStarted` with no `ActivityScheduled` in the segment opens
70/// nothing: its address was never recorded in this segment, so there is no
71/// address to report and inventing one would be a guess.
72#[must_use]
73pub fn open_steps(history: &[Event]) -> Vec<OpenStep> {
74    fold(history).into_iter().map(|open| open.step).collect()
75}
76
77/// The single step a reader is told the run is on: the open step whose last
78/// history event is the most recent.
79///
80/// `None` for a run with nothing open — a completed run, a failed run, a run
81/// blocked on a timer or a signal. That absence is an answer, not a gap: the
82/// run is genuinely not inside an activity.
83#[must_use]
84pub fn current_step(history: &[Event]) -> Option<OpenStep> {
85    fold(history)
86        .into_iter()
87        .max_by_key(|open| open.last_advanced_at)
88        .map(|open| open.step)
89}
90
91/// The shared scan behind both readers.
92fn fold(history: &[Event]) -> Vec<Open> {
93    let segment_start = history
94        .iter()
95        .rposition(|event| matches!(event, Event::WorkflowStarted { .. }))
96        .unwrap_or(0);
97    let mut open: Vec<Open> = Vec::new();
98    for (position, event) in history[segment_start..].iter().enumerate() {
99        match event {
100            Event::ActivityScheduled {
101                envelope,
102                activity_id,
103                activity_type,
104                task_queue,
105                node,
106                ..
107            } => {
108                let entry = Open {
109                    step: OpenStep {
110                        activity_id: activity_id.clone(),
111                        activity_type: activity_type.clone(),
112                        task_queue: task_queue.clone(),
113                        node: node.clone(),
114                        scheduled_at: envelope.recorded_at,
115                        state: StepState::Scheduled,
116                    },
117                    last_advanced_at: position,
118                };
119                replace_or_push(&mut open, entry);
120            }
121            Event::ActivityStarted {
122                envelope,
123                activity_id,
124                attempt,
125            } => {
126                if let Some(held) = find_mut(&mut open, activity_id) {
127                    held.step.state = StepState::Dispatched {
128                        attempt: *attempt,
129                        dispatched_at: envelope.recorded_at,
130                    };
131                    held.last_advanced_at = position;
132                }
133            }
134            Event::ActivityCompleted { activity_id, .. }
135            | Event::ActivityFailed { activity_id, .. }
136            | Event::ActivityCancelled { activity_id, .. } => {
137                open.retain(|held| &held.step.activity_id != activity_id);
138            }
139            Event::WorkflowReopened {
140                envelope, reopened, ..
141            } => reopen(
142                &mut open,
143                history,
144                segment_start,
145                reopened,
146                envelope.recorded_at,
147                position,
148            ),
149            // Everything else leaves the fold alone, INCLUDING
150            // `ActivityAdoptionOffered`: the adopted attempt is the one already
151            // folded above — same ordinal, same attempt, same dispatch instant —
152            // so there is nothing to advance and nothing to retire. It is named
153            // here rather than given its own arm because an arm doing nothing is
154            // this arm (clippy::match_same_arms, correctly); what stops a future
155            // edit from retiring or re-opening a live execution is the pin
156            // `an_adoption_offer_leaves_the_ordinal_exactly_as_dispatched`,
157            // which fails against both mistakes.
158            _ => {}
159        }
160    }
161    open
162}
163
164/// Re-open every activity a [`Event::WorkflowReopened`] names.
165///
166/// The reopened ordinal's terminal event has already retired it from `open`, so
167/// its recorded address has to be recovered from the segment's last
168/// `ActivityScheduled` for that ordinal. An ordinal with no scheduling in this
169/// segment is skipped for the same reason a bare `ActivityStarted` is: nothing
170/// recorded its address here, and inventing one would be a guess.
171fn reopen(
172    open: &mut Vec<Open>,
173    history: &[Event],
174    segment_start: usize,
175    reopened: &[ActivityId],
176    recorded_at: DateTime<Utc>,
177    position: usize,
178) {
179    for activity_id in reopened {
180        let Some(scheduled) = last_scheduling(&history[segment_start..], activity_id) else {
181            continue;
182        };
183        let entry = Open {
184            step: OpenStep {
185                state: StepState::Reopened {
186                    reopened_at: recorded_at,
187                },
188                ..scheduled
189            },
190            last_advanced_at: position,
191        };
192        replace_or_push(open, entry);
193    }
194}
195
196/// The address the segment's most recent `ActivityScheduled` for `activity_id`
197/// stamped, projected as a freshly opened step.
198fn last_scheduling(segment: &[Event], activity_id: &ActivityId) -> Option<OpenStep> {
199    segment.iter().rev().find_map(|event| match event {
200        Event::ActivityScheduled {
201            envelope,
202            activity_id: scheduled_id,
203            activity_type,
204            task_queue,
205            node,
206            ..
207        } if scheduled_id == activity_id => Some(OpenStep {
208            activity_id: scheduled_id.clone(),
209            activity_type: activity_type.clone(),
210            task_queue: task_queue.clone(),
211            node: node.clone(),
212            scheduled_at: envelope.recorded_at,
213            state: StepState::Scheduled,
214        }),
215        _ => None,
216    })
217}
218
219/// Replace the held entry for `entry`'s ordinal, or append it when the ordinal
220/// is not held. Ordinals stay in first-opened order either way.
221fn replace_or_push(open: &mut Vec<Open>, entry: Open) {
222    match find_mut(open, &entry.step.activity_id) {
223        Some(held) => *held = entry,
224        None => open.push(entry),
225    }
226}
227
228/// The held entry for `activity_id`, if the ordinal is open.
229fn find_mut<'a>(open: &'a mut [Open], activity_id: &ActivityId) -> Option<&'a mut Open> {
230    open.iter_mut()
231        .find(|held| &held.step.activity_id == activity_id)
232}
233
234#[cfg(test)]
235#[path = "current_step_tests.rs"]
236mod tests;