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