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;