Skip to main content

a3s_flow/engine/
continuation.rs

1use std::collections::BTreeSet;
2
3use chrono::{DateTime, Utc};
4
5use crate::error::{FlowError, Result};
6use crate::model::{validate_run_id, WorkflowRunSnapshot, WorkflowSpec};
7
8use super::validation::ensure_same_start;
9use super::FlowEngine;
10
11impl FlowEngine {
12    /// Follow persisted continue-as-new links from `run_id` in execution order.
13    ///
14    /// Every returned snapshot owns an independent, append-only event stream.
15    /// Missing successors and cycles fail closed instead of silently returning
16    /// a partial lineage.
17    pub async fn continuation_chain(&self, run_id: &str) -> Result<Vec<WorkflowRunSnapshot>> {
18        validate_run_id(run_id)?;
19        let mut current_run_id = run_id.to_string();
20        let mut visited = BTreeSet::new();
21        let mut chain = Vec::new();
22        let mut expected_start: Option<(WorkflowSpec, serde_json::Value)> = None;
23
24        for hop in 0..=self.max_continue_as_new_hops {
25            if !visited.insert(current_run_id.clone()) {
26                return Err(FlowError::ContinueAsNewCycle(current_run_id));
27            }
28            let snapshot = self.snapshot(&current_run_id).await?;
29            if let Some((expected_spec, expected_input)) = expected_start.as_ref() {
30                ensure_same_start(&current_run_id, &snapshot, expected_spec, expected_input)?;
31            }
32            let successor_run_id = snapshot
33                .continuation
34                .as_ref()
35                .map(|continuation| continuation.successor_run_id.clone());
36            expected_start = snapshot
37                .continuation
38                .as_ref()
39                .map(|continuation| (snapshot.spec.clone(), continuation.input.clone()));
40            chain.push(snapshot);
41            let Some(successor_run_id) = successor_run_id else {
42                return Ok(chain);
43            };
44            if hop == self.max_continue_as_new_hops {
45                return Err(FlowError::ContinueAsNewLimitExceeded(
46                    self.max_continue_as_new_hops,
47                ));
48            }
49            current_run_id = successor_run_id;
50        }
51
52        Err(FlowError::ContinueAsNewLimitExceeded(
53            self.max_continue_as_new_hops,
54        ))
55    }
56
57    /// Replay and dispatch until the execution reaches a terminal state or an
58    /// open wait, hook, retry, or child-workflow suspension.
59    ///
60    /// A continue-as-new terminal event is followed into its fresh successor
61    /// segment. The returned snapshot therefore belongs to the active leaf of
62    /// the execution chain, which can differ from `run_id`.
63    pub async fn drive(&self, run_id: &str) -> Result<WorkflowRunSnapshot> {
64        self.drive_at(run_id, Utc::now()).await
65    }
66
67    pub(super) async fn drive_at(
68        &self,
69        run_id: &str,
70        now: DateTime<Utc>,
71    ) -> Result<WorkflowRunSnapshot> {
72        self.drive_at_with_child_context(run_id, now, 0, &BTreeSet::new())
73            .await
74    }
75
76    /// Repair a committed continuation boundary before replaying its active
77    /// leaf. A fully terminal execution remains readable without runtime-build
78    /// admission because no workflow code will run.
79    pub(super) async fn recover_and_drive_continuation_leaf(
80        &self,
81        run_id: &str,
82    ) -> Result<WorkflowRunSnapshot> {
83        self.recover_and_drive_continuation_leaf_at(run_id, Utc::now())
84            .await
85    }
86
87    pub(super) async fn recover_and_drive_continuation_leaf_at(
88        &self,
89        run_id: &str,
90        now: DateTime<Utc>,
91    ) -> Result<WorkflowRunSnapshot> {
92        let leaf = self.ensure_continuation_leaf(run_id).await?;
93        if leaf.status.is_terminal() {
94            return Ok(leaf);
95        }
96        self.drive_at(&leaf.run_id, now).await
97    }
98
99    pub(super) async fn drive_at_with_child_context(
100        &self,
101        run_id: &str,
102        now: DateTime<Utc>,
103        child_depth: usize,
104        ancestors: &BTreeSet<String>,
105    ) -> Result<WorkflowRunSnapshot> {
106        let mut current_run_id = run_id.to_string();
107        let mut visited = BTreeSet::new();
108
109        for hop in 0..=self.max_continue_as_new_hops {
110            if ancestors.contains(&current_run_id) {
111                return Err(FlowError::ChildWorkflowCycle(current_run_id));
112            }
113            if !visited.insert(current_run_id.clone()) {
114                return Err(FlowError::ContinueAsNewCycle(current_run_id));
115            }
116
117            let allow_continue_as_new = hop < self.max_continue_as_new_hops;
118            let mut active_ancestry = ancestors.clone();
119            active_ancestry.extend(visited.iter().cloned());
120            let snapshot = self
121                .drive_run_at(
122                    &current_run_id,
123                    now,
124                    allow_continue_as_new,
125                    child_depth,
126                    &active_ancestry,
127                )
128                .await?;
129            let Some(successor_run_id) = self
130                .ensure_continuation_successor(&snapshot, &visited, hop)
131                .await?
132            else {
133                return Ok(snapshot);
134            };
135            current_run_id = successor_run_id;
136        }
137
138        Err(FlowError::ContinueAsNewLimitExceeded(
139            self.max_continue_as_new_hops,
140        ))
141    }
142
143    pub(super) async fn ensure_continuation_leaf(
144        &self,
145        run_id: &str,
146    ) -> Result<WorkflowRunSnapshot> {
147        validate_run_id(run_id)?;
148        let mut current_run_id = run_id.to_string();
149        let mut visited = BTreeSet::new();
150
151        for hop in 0..=self.max_continue_as_new_hops {
152            if !visited.insert(current_run_id.clone()) {
153                return Err(FlowError::ContinueAsNewCycle(current_run_id));
154            }
155            let snapshot = self.snapshot(&current_run_id).await?;
156            let Some(successor_run_id) = self
157                .ensure_continuation_successor(&snapshot, &visited, hop)
158                .await?
159            else {
160                return Ok(snapshot);
161            };
162            current_run_id = successor_run_id;
163        }
164
165        Err(FlowError::ContinueAsNewLimitExceeded(
166            self.max_continue_as_new_hops,
167        ))
168    }
169
170    async fn ensure_continuation_successor(
171        &self,
172        snapshot: &WorkflowRunSnapshot,
173        visited: &BTreeSet<String>,
174        hop: usize,
175    ) -> Result<Option<String>> {
176        let Some(continuation) = snapshot.continuation.as_ref() else {
177            return Ok(None);
178        };
179        if hop == self.max_continue_as_new_hops {
180            return Err(FlowError::ContinueAsNewLimitExceeded(
181                self.max_continue_as_new_hops,
182            ));
183        }
184        if visited.contains(&continuation.successor_run_id) {
185            return Err(FlowError::ContinueAsNewCycle(
186                continuation.successor_run_id.clone(),
187            ));
188        }
189        // The predecessor already committed the successor's exact identity,
190        // spec, and input. Repair that lifecycle without runtime code; the
191        // replay loop admits any non-terminal successor before invoking it.
192        self.ensure_run_started_with_admission(
193            &continuation.successor_run_id,
194            &snapshot.spec,
195            &continuation.input,
196            false,
197        )
198        .await?;
199        Ok(Some(continuation.successor_run_id.clone()))
200    }
201}