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;