Skip to main content

async_nodes/
run.rs

1//! Run-scoped state: the value store, the schedulable snapshot [`RunState`], the
2//! node-facing [`RunContext`], and suspension packaging.
3//!
4//! The engine owns one trunk [`RunState`] per run. It carries the consumer state `S`
5//! (`user`), the pin-value store (the dataflow profile's IO surface), and all activation
6//! accounting — pending tokens, back-edge iteration counters, per-node execution counters,
7//! the current frontier, and the superstep counter. Because *all* of that lives in one
8//! struct, suspending mid-loop and resuming later is just "stop, keep the struct, continue"
9//! — loop counters continue from where they were, they do not reset.
10
11use std::any::Any;
12
13use indexmap::{IndexMap, IndexSet};
14use serde::{Deserialize, Serialize};
15
16use super::{
17    BspContext,
18    error::BspError,
19    graph::{EdgeId, InputPin, NodeId, OutputPin, PortIndex},
20    value::{BspOutputMapExt, BspValue},
21};
22
23/// The engine-owned store of pin values (the dataflow profile's IO surface).
24///
25/// Values are type-erased (`Arc<dyn Any>`) and are **never serialized** — suspension
26/// snapshots restore pin values through `waiting_inputs` re-injection only.
27#[derive(Debug, Default)]
28pub struct ValueStore {
29    pub(crate) outputs: IndexMap<OutputPin, BspValue>,
30    pub(crate) injected: IndexMap<InputPin, BspValue>,
31}
32
33impl ValueStore {
34    /// Returns the value currently held by an output pin, if any.
35    #[must_use]
36    pub fn get_output(&self, pin: OutputPin) -> Option<&BspValue> {
37        self.outputs.get(&pin)
38    }
39
40    /// Returns the value currently held by an output pin, downcast to `T`.
41    #[must_use]
42    pub fn get_output_typed<T: Any + Send + Sync + Clone>(&self, pin: OutputPin) -> Option<T> {
43        self.outputs.get(&pin)?.downcast_ref::<T>().cloned()
44    }
45
46    /// The full map of output-pin values written so far.
47    #[must_use]
48    pub const fn outputs(&self) -> &IndexMap<OutputPin, BspValue> {
49        &self.outputs
50    }
51
52    /// Returns a value injected into an input pin (e.g. by a resume injection), if any.
53    #[must_use]
54    pub fn get_injected(&self, pin: InputPin) -> Option<&BspValue> {
55        self.injected.get(&pin)
56    }
57
58    /// The full map of injected input-pin values.
59    #[must_use]
60    pub const fn injected(&self) -> &IndexMap<InputPin, BspValue> {
61        &self.injected
62    }
63
64    /// Writes a typed value into an output pin.
65    ///
66    /// Useful for restoring linear state profiles after out-of-band deserialization.
67    pub fn set_output_typed<T: Any + Send + Sync + 'static>(&mut self, pin: OutputPin, value: T) {
68        self.outputs.insert(pin, BspValue::new(value));
69    }
70
71    /// Injects a typed value into an input pin.
72    pub fn set_injected_typed<T: Any + Send + Sync + 'static>(&mut self, pin: InputPin, value: T) {
73        self.injected.insert(pin, BspValue::new(value));
74    }
75}
76
77/// The complete schedulable state of one run: consumer state, value store, and all
78/// activation accounting. Suspension, resumption, and step-limit results all package this.
79#[derive(Debug)]
80pub struct RunState<S> {
81    /// The consumer-owned state. The engine moves it; it never inspects it.
82    pub user: S,
83    pub(crate) values: ValueStore,
84    /// Pending (unconsumed) activation tokens per edge. Edge ids are declaration indices.
85    pub(crate) pending_tokens: IndexMap<EdgeId, u64>,
86    /// Per-edge iteration counters for edges carrying a `max_iterations` budget.
87    pub(crate) iterations: IndexMap<EdgeId, u64>,
88    /// Per-node execution counters (counts `execute` invocations, including retries).
89    pub(crate) executions: IndexMap<NodeId, u64>,
90    /// The node errors recorded so far (populated by `RouteToErrorEdge` recovery).
91    pub errors: IndexMap<NodeId, BspError>,
92    /// Nodes scheduled for the current superstep (used to re-enter after suspension).
93    pub(crate) frontier: IndexSet<NodeId>,
94    /// Number of supersteps executed so far (counts toward `max_steps`).
95    pub step: u64,
96}
97
98impl<S> RunState<S> {
99    /// Creates the initial run state for a fresh run.
100    pub(crate) fn new(user: S) -> Self {
101        Self {
102            user,
103            values: ValueStore::default(),
104            pending_tokens: IndexMap::new(),
105            iterations: IndexMap::new(),
106            executions: IndexMap::new(),
107            errors: IndexMap::new(),
108            frontier: IndexSet::new(),
109            step: 0,
110        }
111    }
112
113    /// The engine-owned value store.
114    #[must_use]
115    pub const fn values(&self) -> &ValueStore {
116        &self.values
117    }
118
119    /// Mutable access to the engine-owned value store (e.g. to restore pin state out of band).
120    pub const fn values_mut(&mut self) -> &mut ValueStore {
121        &mut self.values
122    }
123
124    /// Per-edge iteration counters, keyed by edge declaration index.
125    #[must_use]
126    pub const fn iterations(&self) -> &IndexMap<EdgeId, u64> {
127        &self.iterations
128    }
129
130    /// Per-node execution counters (counts `execute` invocations, including retries).
131    #[must_use]
132    pub const fn executions(&self) -> &IndexMap<NodeId, u64> {
133        &self.executions
134    }
135
136    /// How many times a node has executed so far.
137    #[must_use]
138    pub fn execution_count(&self, node_id: NodeId) -> u64 {
139        self.executions.get(&node_id).copied().unwrap_or(0)
140    }
141
142    /// How many times an edge has fired against its iteration budget so far.
143    #[must_use]
144    pub fn iteration_count(&self, edge: EdgeId) -> u64 {
145        self.iterations.get(&edge).copied().unwrap_or(0)
146    }
147
148    /// Retrieves a typed value associated with an output pin, if it exists.
149    #[must_use]
150    pub fn get_output_typed<T: Any + Send + Sync + Clone>(&self, pin: OutputPin) -> Option<T> {
151        self.values.get_output_typed(pin)
152    }
153}
154
155/// Everything a node may touch, nothing it may not.
156///
157/// The engine hands one of these to each `execute` call. Nodes mutate the consumer state
158/// through [`RunContext::state`], read gathered pin inputs, and stage pin outputs; they
159/// **never name their successors** — the engine decides what happens next from the edges.
160///
161/// # Retry & rollback contract
162///
163/// When a node carries a retry policy, a failed attempt is re-invoked with the *same*
164/// gathered inputs and an incremented [`RunContext::execution_no`]. A node that stages
165/// writes into `state` should therefore treat `execution_no > 0` as "undo my partial
166/// writes from the previous attempt first" — that is the exactly-once-ish rollback hook.
167pub struct RunContext<'a, S> {
168    /// The consumer state (history, stores, metrics…). `&mut`, owned by the engine
169    /// between executions — required for fork/merge and suspension snapshots.
170    pub state: &'a mut S,
171    /// The id of the node being executed.
172    pub node_id: NodeId,
173    /// Dependency-injection context (unchanged from the dataflow engine).
174    pub ctx: &'a BspContext,
175    /// This node's Nth `execute` invocation, 0-based (loop- and retry-aware).
176    pub execution_no: u64,
177    pub(crate) inputs: IndexMap<PortIndex, BspValue>,
178    pub(crate) outputs: IndexMap<PortIndex, BspValue>,
179    pub(crate) skip: bool,
180}
181
182impl<'a, S> RunContext<'a, S> {
183    pub(crate) fn new(
184        state: &'a mut S,
185        node_id: NodeId,
186        ctx: &'a BspContext,
187        execution_no: u64,
188        inputs: IndexMap<PortIndex, BspValue>,
189    ) -> Self {
190        Self {
191            state,
192            node_id,
193            ctx,
194            execution_no,
195            inputs,
196            outputs: IndexMap::new(),
197            skip: false,
198        }
199    }
200
201    /// Reads a gathered input value, downcast to `T`, without consuming it.
202    #[must_use]
203    pub fn input<T: Any + Send + Sync + Clone>(&self, port: PortIndex) -> Option<T> {
204        self.inputs.get(&port)?.downcast_ref::<T>().cloned()
205    }
206
207    /// Reads a gathered input value without consuming it.
208    #[must_use]
209    pub fn input_value(&self, port: PortIndex) -> Option<&BspValue> {
210        self.inputs.get(&port)
211    }
212
213    /// Takes a gathered input value out of the context (dataflow profile; used by the
214    /// `#[node]` macros to unpack named ports).
215    pub fn take_input(&mut self, port: PortIndex) -> Option<BspValue> {
216        self.inputs.swap_remove(&port)
217    }
218
219    /// Stages a typed value for an output port.
220    ///
221    /// Values staged here merge with (and are overridden by) the port map returned in
222    /// [`crate::node::NodeOutcome::Success`].
223    pub fn set_output<T: Any + Send + Sync>(&mut self, port: PortIndex, value: T) {
224        self.outputs.insert(port, BspValue::new(value));
225    }
226
227    /// Marks every declared output port to be written with the `BspSkip` marker
228    /// (dataflow profile skip propagation), regardless of any staged outputs.
229    pub fn skip_outputs(&mut self) {
230        self.skip = true;
231    }
232
233    /// Drains the staged outputs and the skip flag after execution.
234    pub(crate) fn take_staged(&mut self) -> (IndexMap<PortIndex, BspValue>, bool) {
235        (std::mem::take(&mut self.outputs), self.skip)
236    }
237}
238
239/// Represents the return status of a BSP execution run.
240///
241/// This is the single, unified result type of every entry point
242/// ([`crate::engine::BspEngine::run`], `resume`, and their sequential counterparts).
243/// A tripped `max_steps` budget is a first-class outcome carrying the live run state —
244/// never an `Ok` with silently salvaged state, and never an error: hitting a circuit
245/// breaker is an expected operational outcome for bounded loops.
246#[derive(Debug)]
247pub enum EngineResult<S = ()> {
248    /// The graph ran to completion: the frontier emptied with no pending tokens left.
249    Complete(Box<RunState<S>>),
250    /// At least one node requested suspension; the packaged snapshot can be resumed.
251    Suspended(Box<SuspendedExecution<S>>),
252    /// The global `max_steps` superstep budget was exhausted before natural completion.
253    StepLimitReached(Box<RunState<S>>),
254}
255
256/// Package containing suspension parameters to resume execution later.
257#[derive(Debug)]
258pub struct SuspendedExecution<S = ()> {
259    /// The nodes waiting for external input, with their payloads and expected pins.
260    pub suspensions: Vec<PendingSuspension>,
261    pub(crate) state: RunState<S>,
262}
263
264/// Single record of a suspended node waiting for external inputs.
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct PendingSuspension {
267    /// The node that requested suspension.
268    pub node_id: NodeId,
269    /// Explicit indices of inputs that must be provided to resume.
270    pub waiting_inputs: Vec<PortIndex>,
271    /// Arbitrary context parameters passed back to the host application.
272    pub payload: serde_json::Value,
273}
274
275impl<S> SuspendedExecution<S> {
276    /// Returns an immutable reference to the inner run state.
277    #[must_use]
278    pub const fn state(&self) -> &RunState<S> {
279        &self.state
280    }
281
282    /// Returns a mutable reference to the inner run state.
283    pub const fn state_mut(&mut self) -> &mut RunState<S> {
284        &mut self.state
285    }
286
287    /// Consumes the package, returning the inner run state.
288    #[must_use]
289    pub fn into_state(self) -> RunState<S> {
290        self.state
291    }
292}
293
294/// The serializable subset of a suspended run.
295///
296/// **The value store is deliberately excluded** (see [`SuspendedExecution::to_json`]).
297#[derive(Serialize, Deserialize)]
298struct SuspensionSnapshot<S> {
299    suspensions: Vec<PendingSuspension>,
300    user: S,
301    pending_tokens: IndexMap<EdgeId, u64>,
302    iterations: IndexMap<EdgeId, u64>,
303    executions: IndexMap<NodeId, u64>,
304    frontier: Vec<NodeId>,
305    step: u64,
306}
307
308impl<S> SuspendedExecution<S>
309where
310    S: Serialize + for<'de> Deserialize<'de>,
311{
312    /// Serializes the suspension snapshot to JSON.
313    ///
314    /// **The pin-value store is NOT serialized.** `BspValue` is type-erased
315    /// (`Arc<dyn Any>`) and has no serialized form; pretending otherwise would silently
316    /// corrupt resume fidelity. The snapshot covers the consumer state `S`, token
317    /// accounting, iteration/execution counters, the frontier, and the suspension list.
318    /// On resume, dataflow-profile pin values restore exclusively through `waiting_inputs`
319    /// re-injection (plus any consumer-managed restore of `S`).
320    ///
321    /// Resuming a deserialized snapshot requires a `BspEngine` built over an identical
322    /// graph: node and edge ids are positional.
323    pub fn to_json(&self) -> Result<String, BspError> {
324        let snapshot = SuspensionSnapshot {
325            suspensions: self.suspensions.clone(),
326            user: &self.state.user,
327            pending_tokens: self.state.pending_tokens.clone(),
328            iterations: self.state.iterations.clone(),
329            executions: self.state.executions.clone(),
330            frontier: self.state.frontier.iter().copied().collect(),
331            step: self.state.step,
332        };
333        serde_json::to_string(&snapshot).map_err(|e| BspError::SuspensionSerde(e.to_string()))
334    }
335
336    /// Restores a suspension snapshot from JSON produced by [`SuspendedExecution::to_json`].
337    ///
338    /// The value store comes back empty: pin values must be re-supplied through resume
339    /// injections keyed by each suspension's `waiting_inputs`.
340    pub fn from_json(json: &str) -> Result<Self, BspError> {
341        let snapshot: SuspensionSnapshot<S> =
342            serde_json::from_str(json).map_err(|e| BspError::SuspensionSerde(e.to_string()))?;
343        Ok(Self {
344            suspensions: snapshot.suspensions,
345            state: RunState {
346                user: snapshot.user,
347                values: ValueStore::default(),
348                pending_tokens: snapshot.pending_tokens,
349                iterations: snapshot.iterations,
350                executions: snapshot.executions,
351                errors: IndexMap::new(),
352                frontier: snapshot.frontier.into_iter().collect(),
353                step: snapshot.step,
354            },
355        })
356    }
357}
358
359impl<S> BspOutputMapExt for RunState<S> {
360    /// Typed access to the value store's output map, so a completed run reads exactly like
361    /// the dataflow engine's output map did.
362    fn get_typed<T: Any + Send + Sync + Clone>(&self, pin: OutputPin) -> Result<T, BspError> {
363        self.values.outputs().get_typed(pin)
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    #[test]
372    fn test_value_store_roundtrip() {
373        let mut store = ValueStore::default();
374        let pin = OutputPin {
375            node_id: 1,
376            port: 0,
377        };
378        store.set_output_typed(pin, 42_i64);
379        assert_eq!(store.get_output_typed::<i64>(pin), Some(42));
380        assert_eq!(store.get_output_typed::<String>(pin), None);
381
382        let input = InputPin {
383            node_id: 2,
384            port: 1,
385        };
386        store.set_injected_typed(input, true);
387        assert!(store.get_injected(input).is_some());
388    }
389
390    #[test]
391    fn test_run_state_counters() {
392        let mut state = RunState::new(());
393        state.executions.insert(3, 7);
394        state.iterations.insert(9, 2);
395        assert_eq!(state.execution_count(3), 7);
396        assert_eq!(state.execution_count(4), 0);
397        assert_eq!(state.iteration_count(9), 2);
398        assert_eq!(state.iteration_count(10), 0);
399    }
400
401    #[test]
402    fn test_run_context_io() {
403        let mut user = 0_u64;
404        let ctx = BspContext::new();
405        let mut inputs = IndexMap::new();
406        inputs.insert(0, BspValue::new(21_i64));
407        {
408            let mut run = RunContext::new(&mut user, 5, &ctx, 3, inputs);
409            assert_eq!(run.node_id, 5);
410            assert_eq!(run.execution_no, 3);
411            assert_eq!(run.input::<i64>(0), Some(21));
412            assert_eq!(
413                run.take_input(0)
414                    .and_then(|v| v.downcast::<i64>().ok())
415                    .map(|a| *a),
416                Some(21)
417            );
418            assert!(run.input_value(0).is_none());
419            run.set_output(1, "done".to_string());
420            run.skip_outputs();
421            let (staged, skip) = run.take_staged();
422            assert!(skip);
423            assert!(staged.contains_key(&1));
424        }
425    }
426
427    #[test]
428    fn test_suspension_snapshot_roundtrip_preserves_counters() {
429        let mut state: RunState<String> = RunState::new("consumer-state".to_string());
430        state.pending_tokens.insert(4, 2);
431        state.iterations.insert(4, 5);
432        state.executions.insert(1, 6);
433        state.frontier.insert(1);
434        state.step = 11;
435        let suspended = SuspendedExecution {
436            suspensions: vec![PendingSuspension {
437                node_id: 1,
438                waiting_inputs: vec![0],
439                payload: serde_json::json!({"why": "test"}),
440            }],
441            state,
442        };
443
444        let json = suspended.to_json().unwrap();
445        // The value store is never serialized.
446        assert!(!json.contains("outputs"));
447
448        let restored = SuspendedExecution::<String>::from_json(&json).unwrap();
449        assert_eq!(restored.state.user, "consumer-state");
450        assert_eq!(restored.state.iteration_count(4), 5);
451        assert_eq!(restored.state.execution_count(1), 6);
452        assert_eq!(restored.state.pending_tokens.get(&4), Some(&2));
453        assert_eq!(restored.state.step, 11);
454        assert!(restored.state.frontier.contains(&1));
455        assert_eq!(restored.suspensions[0].waiting_inputs, vec![0]);
456    }
457
458    #[test]
459    fn test_suspension_snapshot_rejects_garbage() {
460        let err = SuspendedExecution::<String>::from_json("{not json").unwrap_err();
461        assert!(matches!(err, BspError::SuspensionSerde(_)));
462    }
463}