adk_graph/executor.rs
1//! Pregel-based execution engine for graphs
2//!
3//! Executes graphs using the Pregel model with super-steps.
4
5#[cfg(feature = "node-cache")]
6use crate::cache::{NodeCache, compute_cache_key};
7use crate::deferred::FanInTracker;
8use crate::error::{GraphError, InterruptedExecution, Result};
9use crate::graph::CompiledGraph;
10use crate::interrupt::Interrupt;
11use crate::node::{ExecutionConfig, NodeContext};
12use crate::state::{Checkpoint, State};
13use crate::stream::{StreamEvent, StreamMode};
14use crate::timeout::{OnTimeout, ProgressHandle, execute_with_timeout, item_timeout_budget};
15use futures::stream::{self, StreamExt};
16use std::collections::HashMap;
17use std::sync::Arc;
18use std::time::Instant;
19
20/// Result of a super-step execution
21#[derive(Default)]
22pub struct SuperStepResult {
23 /// Nodes that were executed
24 pub executed_nodes: Vec<String>,
25 /// Interrupt if one occurred
26 pub interrupt: Option<Interrupt>,
27 /// Stream events generated
28 pub events: Vec<StreamEvent>,
29 /// Nodes that named their own successors, keyed by node name.
30 pub goto: HashMap<String, Vec<String>>,
31}
32
33/// What a completed run produced, and what it asks of its caller.
34#[derive(Debug, Clone)]
35pub struct GraphOutcome {
36 /// The final state.
37 pub state: State,
38 /// Nodes of the parent graph a node asked to run next, if any.
39 ///
40 /// Set by [`NodeOutput::with_goto_parent`](crate::node::NodeOutput::with_goto_parent).
41 /// A graph that is not a subgraph has no parent, so this is ignored.
42 pub goto_parent: Option<Vec<String>>,
43}
44
45/// Pregel-based executor for graphs
46pub struct PregelExecutor<'a> {
47 graph: &'a CompiledGraph,
48 config: ExecutionConfig,
49 state: State,
50 step: usize,
51 pending_nodes: Vec<String>,
52 /// Parent nodes a node asked to run next; see `NodeOutput::with_goto_parent`.
53 goto_parent: Option<Vec<String>>,
54 /// Tracks deferred nodes waiting for all upstream paths to complete.
55 pending_deferred: HashMap<String, FanInTracker>,
56 /// Tracks when each deferred node first entered the pending state (for fan-in timeout).
57 deferred_start_times: HashMap<String, Instant>,
58 /// Attempts already spent per node, carried through a resume so a retry
59 /// budget is not restarted.
60 attempts: HashMap<String, u32>,
61 /// Outputs of children invoked imperatively, keyed by child path. Shared with
62 /// every node's invoker so a resumed parent serves finished children from it.
63 child_ledger: Arc<std::sync::Mutex<HashMap<String, serde_json::Value>>>,
64 /// The node whose static interrupt this run has already answered.
65 ///
66 /// Restored from the checkpoint on resume and cleared once that node has
67 /// executed, so the gate re-arms for a later arrival through a cycle.
68 cleared_interrupt: Option<String>,
69 /// Per-node caches initialized from `CompiledGraph::cache_policies`.
70 #[cfg(feature = "node-cache")]
71 node_caches: HashMap<String, NodeCache>,
72}
73
74impl<'a> PregelExecutor<'a> {
75 /// Create a new executor
76 pub fn new(graph: &'a CompiledGraph, config: ExecutionConfig) -> Self {
77 #[cfg(feature = "node-cache")]
78 let node_caches = graph
79 .cache_policies
80 .iter()
81 .map(|(name, policy)| (name.clone(), NodeCache::from_policy(policy)))
82 .collect();
83
84 Self {
85 graph,
86 config,
87 state: State::new(),
88 step: 0,
89 pending_nodes: vec![],
90 goto_parent: None,
91 pending_deferred: HashMap::new(),
92 deferred_start_times: HashMap::new(),
93 attempts: HashMap::new(),
94 child_ledger: Arc::new(std::sync::Mutex::new(HashMap::new())),
95 cleared_interrupt: None,
96 #[cfg(feature = "node-cache")]
97 node_caches,
98 }
99 }
100
101 /// Attempt to resume from an existing checkpoint.
102 ///
103 /// If a checkpoint is found (either by explicit `resume_from` ID or by latest
104 /// checkpoint for the thread), restores state, pending_nodes, and step from it,
105 /// then merges the provided input on top. Returns `true` if resumed.
106 ///
107 /// If no checkpoint is found, returns `false` so the caller can proceed with
108 /// fresh-start logic.
109 async fn try_resume_from_checkpoint(&mut self, input: &State) -> Result<bool> {
110 let checkpoint = if let Some(checkpoint_id) = &self.config.resume_from {
111 // Resume from a specific checkpoint by ID
112 if let Some(cp) = self.graph.checkpointer.as_ref() {
113 cp.load_by_id(checkpoint_id).await?
114 } else {
115 None
116 }
117 } else if let Some(cp) = self.graph.checkpointer.as_ref() {
118 // Try to load the latest checkpoint for this thread
119 cp.load(&self.config.thread_id).await?
120 } else {
121 None
122 };
123
124 if let Some(checkpoint) = checkpoint {
125 // Restore state from checkpoint
126 self.state = checkpoint.state;
127 self.pending_nodes = checkpoint.pending_nodes;
128 self.step = checkpoint.step;
129 self.cleared_interrupt = checkpoint.cleared_interrupt;
130 self.attempts = checkpoint.attempts;
131 *self.child_ledger.lock().expect("child ledger") = checkpoint.child_ledger;
132
133 // Merge input on top of restored state
134 for (key, value) in input {
135 self.graph.schema.apply_update(&mut self.state, key, value.clone());
136 }
137
138 Ok(true)
139 } else {
140 Ok(false)
141 }
142 }
143
144 /// Run the graph to completion
145 pub async fn run(&mut self, input: State) -> Result<State> {
146 // Check for existing checkpoint to resume from
147 let resumed = self.try_resume_from_checkpoint(&input).await?;
148
149 if !resumed {
150 // No checkpoint found — fresh start
151 self.state = self.initialize_state(input).await?;
152 self.pending_nodes = self.graph.get_entry_nodes();
153 }
154
155 // Main execution loop
156 while !self.pending_nodes.is_empty() {
157 // Check recursion limit
158 if self.step >= self.config.recursion_limit {
159 return Err(GraphError::RecursionLimitExceeded(self.step));
160 }
161
162 // Execute super-step
163 let result = match self.execute_super_step().await {
164 Ok(result) => result,
165 Err(error) => {
166 // Checkpoint before propagating, so a retry budget already
167 // spent is not handed out again by the next invocation. The
168 // frontier still holds the failed node, which is what makes
169 // the run resumable at all.
170 let any_retryable = self
171 .pending_nodes
172 .iter()
173 .any(|node| self.graph.retry_policy_for(node).is_some());
174 if any_retryable {
175 let _ = self.save_checkpoint().await;
176 }
177 return Err(error);
178 }
179 };
180
181 // Handle interrupts
182 if let Some(interrupt) = result.interrupt {
183 // Record the gate being answered so the resumed run executes
184 // this node rather than stopping at it again.
185 if let Interrupt::Before(node) = &interrupt {
186 self.cleared_interrupt = Some(node.clone());
187 }
188 // `After` has the opposite timing: the node ran and its updates
189 // are applied, so the resume point is its successors. Saving the
190 // executing frontier would re-run it and re-raise the gate.
191 if matches!(interrupt, Interrupt::After(_)) {
192 let next = self.next_frontier(&result.executed_nodes, &result.goto)?;
193 self.pending_nodes =
194 self.filter_deferred_nodes(next, &result.executed_nodes)?;
195 }
196 // For `Before`, the frontier saved is deliberately the one that
197 // was executing: the node produced no updates, so resuming must
198 // run it, which the marker above now permits.
199 let checkpoint_id = self.save_checkpoint().await?;
200 return Err(GraphError::Interrupted(Box::new(InterruptedExecution::new(
201 self.config.thread_id.clone(),
202 checkpoint_id,
203 interrupt,
204 self.state.clone(),
205 self.step,
206 ))));
207 }
208
209 // The gate re-arms once its node has run, so a cycle returning to
210 // the same node asks again.
211 if let Some(cleared) = &self.cleared_interrupt
212 && result.executed_nodes.iter().any(|n| n == cleared)
213 {
214 self.cleared_interrupt = None;
215 }
216
217 // Advance the frontier *before* checkpointing. A checkpoint records
218 // what still has to run, so saving while `pending_nodes` still holds
219 // the nodes that just finished would re-execute them on resume.
220 let next_candidates = self.next_frontier(&result.executed_nodes, &result.goto)?;
221 self.pending_nodes =
222 self.filter_deferred_nodes(next_candidates, &result.executed_nodes)?;
223 self.step += 1;
224
225 // An empty frontier is a terminal checkpoint: resuming it re-reads
226 // the final state instead of restarting the graph.
227 self.save_checkpoint().await?;
228
229 if self.pending_nodes.is_empty() {
230 break;
231 }
232 }
233
234 Ok(self.state.clone())
235 }
236
237 /// Run with streaming
238 pub fn run_stream(
239 mut self,
240 input: State,
241 mode: StreamMode,
242 ) -> impl futures::Stream<Item = Result<StreamEvent>> + 'a {
243 async_stream::stream! {
244 // Check for existing checkpoint to resume from
245 let resumed = match self.try_resume_from_checkpoint(&input).await {
246 Ok(r) => r,
247 Err(e) => {
248 yield Err(e);
249 return;
250 }
251 };
252
253 if resumed {
254 // Emit a resumed event indicating execution was restored from checkpoint
255 yield Ok(StreamEvent::resumed(self.step, self.pending_nodes.clone()));
256 } else {
257 // No checkpoint found — fresh start
258 match self.initialize_state(input).await {
259 Ok(state) => self.state = state,
260 Err(e) => {
261 yield Err(e);
262 return;
263 }
264 }
265 self.pending_nodes = self.graph.get_entry_nodes();
266 }
267
268 // Stream initial state if requested
269 if matches!(mode, StreamMode::Values) {
270 yield Ok(StreamEvent::state(self.state.clone(), self.step));
271 }
272
273 // Main execution loop
274 while !self.pending_nodes.is_empty() {
275 // Check recursion limit
276 if self.step >= self.config.recursion_limit {
277 yield Err(GraphError::RecursionLimitExceeded(self.step));
278 return;
279 }
280
281 // Emit node_start events BEFORE execution (in Debug mode)
282 if matches!(mode, StreamMode::Debug | StreamMode::Custom | StreamMode::Messages) {
283 for node_name in &self.pending_nodes {
284 yield Ok(StreamEvent::node_start(node_name, self.step));
285 }
286 }
287
288 // For Messages mode, stream from nodes directly
289 if matches!(mode, StreamMode::Messages) {
290 let mut result = SuperStepResult::default();
291
292 // The same gate `execute_super_step` applies. This loop does
293 // not call it, so without this the mode ignored every gate.
294 if let Some(interrupt) = self.gate_before(&self.pending_nodes) {
295 result.interrupt = Some(interrupt);
296 }
297
298 for node_name in &self.pending_nodes {
299 if result.interrupt.is_some() {
300 break;
301 }
302 if let Some(node) = self.graph.nodes.get(node_name) {
303 let mut ctx = NodeContext::new(self.state.clone(), self.config.clone(), self.step);
304 ctx.set_parent_schema(Arc::new(self.graph.schema.clone()));
305 ctx.set_child_invoker(Arc::new(crate::child::ChildInvoker::new(
306 self.graph.nodes.clone(),
307 Arc::clone(&self.child_ledger),
308 node_name.clone(),
309 )));
310
311 // Attach progress handle if idle timeout is configured
312 let policy = self.graph.timeout_policy_for(node_name).cloned();
313 if let Some(ref p) = policy
314 && p.idle_timeout.is_some() {
315 ctx.set_progress_handle(ProgressHandle::new());
316 }
317
318 let start = std::time::Instant::now();
319
320 // The timeout policy now applies to the streamed
321 // execution itself. For a stream, "idle" means no
322 // event was produced within the idle timeout.
323 let max_attempts = match policy.as_ref().map(|p| &p.on_timeout) {
324 Some(OnTimeout::Retry { max_attempts }) => (*max_attempts).max(1),
325 _ => 1,
326 };
327 let mut collected_events = Vec::new();
328 let mut streamed_updates = Vec::new();
329 let mut streamed_goto: Option<(String, Vec<String>)> = None;
330 let mut streamed_interrupt: Option<Interrupt> = None;
331 let mut timed_out_after;
332 let mut attempt = 0;
333
334 loop {
335 attempt += 1;
336 collected_events.clear();
337 streamed_updates.clear();
338 timed_out_after = None;
339 let attempt_start = std::time::Instant::now();
340 let mut node_stream = node.execute_stream(&ctx);
341 let mut failure = None;
342
343 loop {
344 let budget = policy
345 .as_ref()
346 .and_then(|p| item_timeout_budget(p, attempt_start.elapsed()));
347 let item = match budget {
348 Some(budget) => {
349 match tokio::time::timeout(budget, node_stream.next()).await {
350 Ok(item) => item,
351 Err(_) => {
352 timed_out_after = Some(attempt_start.elapsed());
353 break;
354 }
355 }
356 }
357 None => node_stream.next().await,
358 };
359
360 match item {
361 Some(Ok(event)) => {
362 // Yield Message events immediately
363 if matches!(event, StreamEvent::Message { .. }) {
364 yield Ok(event.clone());
365 }
366 // The node reports its state updates on the
367 // stream, so they are taken from the single
368 // execution that produced these events.
369 if let StreamEvent::Updates { ref updates, .. } = event {
370 streamed_updates.push(updates.clone());
371 }
372 // A node that routed itself reports it here.
373 if let StreamEvent::RouteDispatched {
374 ref source,
375 ref targets,
376 } = event
377 {
378 streamed_goto =
379 Some((source.clone(), targets.clone()));
380 }
381 // As does a node asking to pause.
382 if let StreamEvent::NodeInterrupt {
383 ref message,
384 ref data,
385 ..
386 } = event
387 {
388 streamed_interrupt =
389 Some(Interrupt::Dynamic {
390 message: message.clone(),
391 data: data.clone(),
392 });
393 }
394 collected_events.push(event);
395 }
396 Some(Err(e)) => {
397 failure = Some(e);
398 break;
399 }
400 None => break,
401 }
402 }
403 drop(node_stream);
404
405 if let Some(e) = failure {
406 yield Err(e);
407 return;
408 }
409 if timed_out_after.is_none() || attempt >= max_attempts {
410 break;
411 }
412 }
413
414 if let Some(elapsed) = timed_out_after {
415 let on_timeout =
416 policy.as_ref().map(|p| p.on_timeout.clone()).unwrap_or_default();
417 match on_timeout {
418 OnTimeout::Skip => {
419 tracing::warn!(
420 node = %node_name,
421 elapsed = ?elapsed,
422 "node timed out while streaming, skipping"
423 );
424 streamed_updates.clear();
425 }
426 OnTimeout::Fail | OnTimeout::Retry { .. } => {
427 yield Err(GraphError::NodeTimedOut {
428 node: node_name.clone(),
429 elapsed,
430 });
431 return;
432 }
433 }
434 }
435
436 let duration_ms = start.elapsed().as_millis() as u64;
437 result.executed_nodes.push(node_name.clone());
438 result.events.push(StreamEvent::node_end(node_name, self.step, duration_ms));
439 result.events.extend(collected_events);
440
441 if let Some((source, targets)) = streamed_goto {
442 result.goto.insert(source, targets);
443 }
444 if let Some(interrupt) = streamed_interrupt {
445 result.interrupt = Some(interrupt);
446 }
447
448 for updates in streamed_updates {
449 self.ensure_channels_declared(
450 node_name,
451 updates.keys().map(String::as_str),
452 )?;
453 for (key, value) in updates {
454 self.graph.schema.apply_update(&mut self.state, &key, value);
455 }
456 }
457 }
458 }
459
460 // Yield node_end events
461 for event in &result.events {
462 if matches!(event, StreamEvent::NodeEnd { .. }) {
463 yield Ok(event.clone());
464 }
465 }
466
467 // A node that arms a gate on completion stops the run here, unless
468 // it already asked to pause itself.
469 if result.interrupt.is_none()
470 && let Some(interrupt) = self.gate_after(&result.executed_nodes)
471 {
472 result.interrupt = Some(interrupt);
473 }
474
475 // This branch returns rather than falling through to the shared
476 // handling below, so the pause is reported here.
477 if let Some(interrupt) = result.interrupt {
478 if let Interrupt::Before(node) = &interrupt {
479 self.cleared_interrupt = Some(node.clone());
480 }
481 // `After` resumes at the successors, because that node has
482 // already applied its updates; see `run`.
483 if matches!(interrupt, Interrupt::After(_)) {
484 let next =
485 self.next_frontier(&result.executed_nodes, &result.goto)?;
486 match self.filter_deferred_nodes(next, &result.executed_nodes) {
487 Ok(frontier) => self.pending_nodes = frontier,
488 Err(error) => {
489 yield Err(error);
490 return;
491 }
492 }
493 }
494 // Persist before reporting: without this the pause cannot be
495 // resumed and the work already done is lost.
496 if let Err(error) = self.save_checkpoint().await {
497 yield Err(error);
498 return;
499 }
500 yield Ok(StreamEvent::interrupted(
501 result.executed_nodes.first().map(|s| s.as_str()).unwrap_or("unknown"),
502 &interrupt.to_string(),
503 ));
504 return;
505 }
506
507 // The gate re-arms once its node has run; see `run`.
508 if let Some(cleared) = &self.cleared_interrupt
509 && result.executed_nodes.iter().any(|n| n == cleared)
510 {
511 self.cleared_interrupt = None;
512 }
513
514 self.pending_nodes = {
515 let next_candidates = self.next_frontier(&result.executed_nodes, &result.goto)?;
516 match self.filter_deferred_nodes(next_candidates, &result.executed_nodes) {
517 Ok(nodes) => nodes,
518 Err(e) => {
519 yield Err(e);
520 return;
521 }
522 }
523 };
524 self.step += 1;
525
526 // The other path checkpoints every super-step. This one did not,
527 // so a run in this mode left no state to resume from and
528 // `get_state` reported nothing.
529 if let Err(e) = self.save_checkpoint().await {
530 yield Err(e);
531 return;
532 }
533 continue;
534 }
535
536 // Execute super-step (non-streaming)
537 let result = match self.execute_super_step().await {
538 Ok(r) => r,
539 Err(e) => {
540 yield Err(e);
541 return;
542 }
543 };
544
545 // Yield events based on mode (node_end and custom events)
546 for event in &result.events {
547 match (&mode, &event) {
548 // Skip node_start since we already emitted it above
549 (StreamMode::Custom | StreamMode::Debug, StreamEvent::NodeStart { .. }) => {}
550 (StreamMode::Custom, _) => yield Ok(event.clone()),
551 (StreamMode::Debug, _) => yield Ok(event.clone()),
552 _ => {}
553 }
554 }
555
556 // Yield state/updates
557 match mode {
558 StreamMode::Values => {
559 yield Ok(StreamEvent::state(self.state.clone(), self.step));
560 }
561 StreamMode::Updates => {
562 yield Ok(StreamEvent::step_complete(
563 self.step,
564 result.executed_nodes.clone(),
565 ));
566 }
567 _ => {}
568 }
569
570 // Handle interrupts
571 if let Some(interrupt) = result.interrupt {
572 // Record the gate being answered; see `run`.
573 if let Interrupt::Before(node) = &interrupt {
574 self.cleared_interrupt = Some(node.clone());
575 }
576 // `After` resumes at the successors; see `run`.
577 if matches!(interrupt, Interrupt::After(_)) {
578 let next =
579 self.next_frontier(&result.executed_nodes, &result.goto)?;
580 match self.filter_deferred_nodes(next, &result.executed_nodes) {
581 Ok(frontier) => self.pending_nodes = frontier,
582 Err(error) => {
583 yield Err(error);
584 return;
585 }
586 }
587 }
588 // Persist before reporting: without this the interrupt is
589 // unresumable, because resuming loads the checkpoint for the
590 // thread. The frontier saved is the one that was executing,
591 // since an interrupted node still owes its updates.
592 if let Err(e) = self.save_checkpoint().await {
593 yield Err(e);
594 return;
595 }
596 yield Ok(StreamEvent::interrupted(
597 result.executed_nodes.first().map(|s| s.as_str()).unwrap_or("unknown"),
598 &interrupt.to_string(),
599 ));
600 return;
601 }
602
603 // The gate re-arms once its node has run; see `run`.
604 if let Some(cleared) = &self.cleared_interrupt
605 && result.executed_nodes.iter().any(|n| n == cleared)
606 {
607 self.cleared_interrupt = None;
608 }
609
610 // Advance the frontier before checkpointing, so the checkpoint
611 // records what still has to run rather than what just finished.
612 //
613 // Reported only on the debug stream, because building it evaluates
614 // each router a second time.
615 if matches!(mode, StreamMode::Debug) {
616 match self.graph.route_dispatches(&result.executed_nodes, &self.state) {
617 Ok(dispatches) => {
618 for (source, targets) in dispatches {
619 yield Ok(StreamEvent::route_dispatched(&source, targets));
620 }
621 }
622 Err(error) => {
623 yield Err(error);
624 return;
625 }
626 }
627 }
628
629 self.pending_nodes = {
630 let next_candidates = self.next_frontier(&result.executed_nodes, &result.goto)?;
631 match self.filter_deferred_nodes(next_candidates, &result.executed_nodes) {
632 Ok(nodes) => nodes,
633 Err(e) => {
634 yield Err(e);
635 return;
636 }
637 }
638 };
639 self.step += 1;
640
641 if let Err(e) = self.save_checkpoint().await {
642 yield Err(e);
643 return;
644 }
645 }
646
647 yield Ok(StreamEvent::done(self.state.clone(), self.step + 1));
648 }
649 }
650
651 /// Filter deferred nodes from the next candidates.
652 ///
653 /// For each candidate node that is configured as deferred, check whether all
654 /// upstream paths have completed. If not, hold the node in `pending_deferred`
655 /// and record the outputs from the just-executed nodes. If all upstream paths
656 /// have completed, inject the merged output into state and allow the node to
657 /// proceed.
658 ///
659 /// If a deferred node has a `fan_in_timeout` configured and the timeout has
660 /// elapsed:
661 /// - If at least one upstream path has completed, proceed with partial results.
662 /// - If zero upstream paths have completed, return `GraphError::FanInTimedOut`.
663 fn filter_deferred_nodes(
664 &mut self,
665 candidates: Vec<String>,
666 executed_nodes: &[String],
667 ) -> Result<Vec<String>> {
668 let mut ready_nodes = Vec::new();
669
670 for candidate in candidates {
671 if let Some(config) = self.graph.deferred_configs.get(&candidate) {
672 // This is a deferred node — check if all upstream paths are done
673 let upstream = self.graph.get_upstream_nodes(&candidate);
674
675 // Get or create the tracker for this deferred node
676 let tracker = self.pending_deferred.entry(candidate.clone()).or_insert_with(|| {
677 let sources: Vec<&str> = upstream.iter().map(|s| s.as_str()).collect();
678 FanInTracker::new(sources)
679 });
680
681 // Record the start time if this is the first time we see this deferred node
682 self.deferred_start_times.entry(candidate.clone()).or_insert_with(Instant::now);
683
684 // Record outputs from the just-executed nodes that are upstream of this deferred node
685 for executed in executed_nodes {
686 if upstream.contains(executed) {
687 // Use the current state as the output representation for this upstream node.
688 // We capture a snapshot of the state that this upstream node contributed to.
689 let output = self.state.get(executed).cloned().unwrap_or_else(|| {
690 // If no state key matches the node name, capture the full state
691 serde_json::Value::Object(
692 self.state.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
693 )
694 });
695 tracker.record(executed, output);
696 }
697 }
698
699 if tracker.is_ready() {
700 // All upstream paths have completed — merge and inject into state
701 let merged = tracker.merge(&config.merge_strategy);
702 let fan_in_key = format!("{candidate}_fan_in");
703 self.graph.schema.apply_update(&mut self.state, &fan_in_key, merged);
704
705 // Remove from pending_deferred and start times since it's now ready
706 self.pending_deferred.remove(&candidate);
707 self.deferred_start_times.remove(&candidate);
708 ready_nodes.push(candidate);
709 } else if let Some(timeout_duration) = config.fan_in_timeout {
710 // Check if the fan-in timeout has elapsed
711 let start_time = self.deferred_start_times[&candidate];
712 if start_time.elapsed() >= timeout_duration {
713 let received = tracker.received_count();
714 let expected = tracker.expected_count();
715
716 // `min_predecessors` decides how many arrivals are enough
717 // to release the node once the timeout expires.
718 let required = config.min_predecessors.unwrap_or(1).max(1);
719 if received >= required {
720 // Proceed with partial results
721 tracing::warn!(
722 node = %candidate,
723 received,
724 expected,
725 "fan-in timeout expired, proceeding with partial results"
726 );
727 let merged = tracker.merge(&config.merge_strategy);
728 let fan_in_key = format!("{candidate}_fan_in");
729 self.graph.schema.apply_update(&mut self.state, &fan_in_key, merged);
730
731 // Clean up tracking state
732 self.pending_deferred.remove(&candidate);
733 self.deferred_start_times.remove(&candidate);
734 ready_nodes.push(candidate);
735 } else {
736 // Too few arrived to release the node.
737 self.pending_deferred.remove(&candidate);
738 self.deferred_start_times.remove(&candidate);
739 return Err(GraphError::FanInTimedOut {
740 node: candidate,
741 received,
742 expected,
743 });
744 }
745 }
746 }
747 // If not ready and no timeout (or timeout not yet elapsed), the node stays
748 // in pending_deferred and is NOT added to ready_nodes
749 } else {
750 // Not a deferred node — schedule normally
751 ready_nodes.push(candidate);
752 }
753 }
754
755 Ok(ready_nodes)
756 }
757
758 /// Initialize state from input and/or checkpoint
759 async fn initialize_state(&self, input: State) -> Result<State> {
760 // Start with schema defaults
761 let mut state = self.graph.schema.initialize_state();
762
763 // If resuming from checkpoint, load it
764 if let Some(checkpoint_id) = &self.config.resume_from {
765 if let Some(cp) = self.graph.checkpointer.as_ref()
766 && let Some(checkpoint) = cp.load_by_id(checkpoint_id).await?
767 {
768 state = checkpoint.state;
769 }
770 } else if let Some(cp) = self.graph.checkpointer.as_ref() {
771 // Try to load latest checkpoint for thread
772 if let Some(checkpoint) = cp.load(&self.config.thread_id).await? {
773 state = checkpoint.state;
774 }
775 }
776
777 // Merge input into state
778 for (key, value) in input {
779 self.graph.schema.apply_update(&mut state, &key, value);
780 }
781
782 Ok(state)
783 }
784
785 /// Execute one super-step (plan -> execute -> update)
786 async fn execute_super_step(&mut self) -> Result<SuperStepResult> {
787 let mut result = SuperStepResult::default();
788
789 if let Some(interrupt) = self.gate_before(&self.pending_nodes) {
790 return Ok(SuperStepResult { interrupt: Some(interrupt), ..Default::default() });
791 }
792
793 // --- Node cache: check for cache hits before executing ---
794 #[cfg(feature = "node-cache")]
795 let mut cached_results: HashMap<String, serde_json::Value> = HashMap::new();
796 #[cfg(feature = "node-cache")]
797 let mut nodes_to_execute: Vec<String> = Vec::new();
798
799 #[cfg(feature = "node-cache")]
800 {
801 for node_name in &self.pending_nodes {
802 if let Some(cache) = self.node_caches.get(node_name) {
803 let cache_key = compute_cache_key(node_name, &self.state);
804 let cached_value = cache.get(&cache_key).await;
805 tracing::debug!(
806 node = %node_name,
807 cache_hit = cached_value.is_some(),
808 cache_key = %cache_key,
809 "node cache lookup"
810 );
811 if let Some(value) = cached_value {
812 // Cache hit — store the cached result for later application
813 cached_results.insert(node_name.clone(), value);
814 } else {
815 // Cache miss — node needs execution
816 nodes_to_execute.push(node_name.clone());
817 }
818 } else {
819 // No cache configured — node needs execution
820 nodes_to_execute.push(node_name.clone());
821 }
822 }
823 }
824
825 // Apply cached results immediately
826 #[cfg(feature = "node-cache")]
827 {
828 for (node_name, cached_value) in &cached_results {
829 result.executed_nodes.push(node_name.clone());
830 result.events.push(StreamEvent::node_end(node_name, self.step, 0));
831
832 // Reconstruct updates from the cached JSON value (a map of key -> value)
833 if let Some(updates_map) = cached_value.as_object() {
834 self.ensure_channels_declared(
835 node_name,
836 updates_map.keys().map(String::as_str),
837 )?;
838 for (key, value) in updates_map {
839 self.graph.schema.apply_update(&mut self.state, key, value.clone());
840 }
841 }
842 }
843 }
844
845 // Determine which nodes to execute (all if cache feature is disabled).
846 //
847 // Sorted so that a bounded dispatch admits nodes in a fixed order rather
848 // than whatever order the frontier happened to be built in.
849 #[cfg(feature = "node-cache")]
850 let pending_for_execution = {
851 nodes_to_execute.sort();
852 &nodes_to_execute
853 };
854 #[cfg(not(feature = "node-cache"))]
855 let pending_for_execution = {
856 self.pending_nodes.sort();
857 &self.pending_nodes
858 };
859
860 // Execute all pending nodes in parallel
861 let nodes: Vec<_> = pending_for_execution
862 .iter()
863 .filter_map(|name| self.graph.nodes.get(name).map(|n| (name.clone(), n.clone())))
864 .collect();
865
866 // Look up timeout and retry policies for each node before spawning futures
867 let timeout_policies: Vec<_> =
868 nodes.iter().map(|(name, _)| self.graph.timeout_policy_for(name).cloned()).collect();
869 let retry_policies: Vec<_> =
870 nodes.iter().map(|(name, _)| self.graph.retry_policy_for(name).cloned()).collect();
871 // Attempts already spent, so a resumed run continues its budget rather
872 // than starting again. adk-python does not persist this.
873 let prior_attempts: Vec<u32> =
874 nodes.iter().map(|(name, _)| self.attempts.get(name).copied().unwrap_or(0)).collect();
875
876 let futures: Vec<_> = nodes
877 .into_iter()
878 .zip(timeout_policies)
879 .zip(retry_policies)
880 .zip(prior_attempts)
881 .map(|((((name, node), policy), retry), spent)| {
882 let mut ctx = NodeContext::new(self.state.clone(), self.config.clone(), self.step);
883 // A node body may invoke other nodes. The invoker carries the
884 // graph's nodes and the shared ledger, so a resumed parent serves
885 // children that already finished. These invocations are awaited
886 // inline by the parent and are deliberately outside the
887 // concurrency budget: counting them could deadlock, because the
888 // parent holds its own slot while waiting.
889 ctx.set_parent_schema(Arc::new(self.graph.schema.clone()));
890 ctx.set_child_invoker(Arc::new(crate::child::ChildInvoker::new(
891 self.graph.nodes.clone(),
892 Arc::clone(&self.child_ledger),
893 name.clone(),
894 )));
895
896 // Attach a ProgressHandle when idle timeout is configured
897 if let Some(ref p) = policy
898 && p.idle_timeout.is_some()
899 {
900 ctx.set_progress_handle(ProgressHandle::new());
901 }
902
903 let step = self.step;
904 async move {
905 let start = Instant::now();
906 let mut attempts = spent;
907 let output = loop {
908 let result = match policy {
909 Some(ref timeout_policy) => {
910 execute_with_timeout(node.as_ref(), &ctx, timeout_policy).await
911 }
912 None => node.execute(&ctx).await,
913 };
914 attempts += 1;
915
916 let Err(ref error) = result else { break result };
917 let Some(ref retry) = retry else { break result };
918 if !retry.allows_another_attempt(attempts)
919 || !retry.retry_on.should_retry(error)
920 {
921 break result;
922 }
923
924 let delay = retry.delay_for_attempt(attempts);
925 tracing::warn!(
926 node = %name,
927 attempt = attempts,
928 max_attempts = retry.max_attempts,
929 delay_ms = delay.as_millis(),
930 error = %error,
931 "node failed, retrying after backoff"
932 );
933 tokio::time::sleep(delay).await;
934 };
935 let duration_ms = start.elapsed().as_millis() as u64;
936 (name, output, duration_ms, step, attempts)
937 }
938 })
939 .collect();
940
941 // Bound the dispatch. `buffer_unordered` polls futures in the order they
942 // are produced, and the frontier is sorted above, so admission order does
943 // not depend on which node finished first.
944 let concurrency = self
945 .graph
946 .max_concurrency
947 .map_or(pending_for_execution.len(), |limit| limit.min(pending_for_execution.len()))
948 .max(1);
949 let outputs: Vec<_> = stream::iter(futures).buffer_unordered(concurrency).collect().await;
950
951 // Collect all updates and check for errors/interrupts
952 let mut all_updates = Vec::new();
953
954 for (node_name, output_result, duration_ms, step, attempts) in outputs {
955 // Record the budget spent, so a resumed run does not restart it. A
956 // node that finally succeeded keeps no entry: its budget is spent
957 // only while it is failing.
958 if output_result.is_err() {
959 self.attempts.insert(node_name.clone(), attempts);
960 } else {
961 self.attempts.remove(&node_name);
962 }
963 result.executed_nodes.push(node_name.clone());
964 result.events.push(StreamEvent::node_end(&node_name, step, duration_ms));
965
966 match output_result {
967 Ok(output) => {
968 // Check for dynamic interrupt
969 if let Some(interrupt) = output.interrupt {
970 return Ok(SuperStepResult {
971 interrupt: Some(interrupt),
972 executed_nodes: result.executed_nodes,
973 events: result.events,
974 goto: result.goto,
975 });
976 }
977
978 // Collect custom events
979 result.events.extend(output.events);
980
981 // Store result in cache on miss
982 #[cfg(feature = "node-cache")]
983 {
984 if let Some(cache) = self.node_caches.get(&node_name) {
985 let cache_key = compute_cache_key(&node_name, &self.state);
986 let updates_value = serde_json::to_value(&output.updates)
987 .unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
988 let ttl = self.graph.cache_policies.get(&node_name).and_then(|p| p.ttl);
989 cache.set(&cache_key, updates_value, ttl).await;
990 }
991 }
992
993 // A node that named its successors overrides its declared edges.
994 if let Some(targets) = output.goto {
995 result.goto.insert(node_name.clone(), targets);
996 }
997 // A node handing control to the graph that holds this one. The
998 // run finishes normally; the caller reads this from the outcome.
999 if let Some(targets) = output.goto_parent {
1000 self.goto_parent = Some(targets);
1001 }
1002
1003 // Collect updates with their node, so application order can be
1004 // made independent of which future resolved first.
1005 all_updates.push((node_name.clone(), output.updates));
1006 }
1007 Err(e) => {
1008 // The retry budget is spent. A handler may record what
1009 // happened and name a recovery node instead of ending the run.
1010 // An interrupt never reaches here as a failure.
1011 match self.graph.error_handler_for(&node_name) {
1012 Some(handler) if !matches!(e, GraphError::Interrupted(_)) => {
1013 let recovery = handler(&node_name, &e, &self.state)?;
1014 if let Some(targets) = recovery.goto {
1015 result.goto.insert(node_name.clone(), targets);
1016 }
1017 result.executed_nodes.push(node_name.clone());
1018 all_updates.push((node_name, recovery.updates));
1019 }
1020 _ => {
1021 return Err(GraphError::NodeExecutionFailed {
1022 node: node_name,
1023 message: e.to_string(),
1024 });
1025 }
1026 }
1027 }
1028 }
1029 }
1030
1031 // Apply all updates atomically using reducers.
1032 //
1033 // `buffer_unordered` yields futures as they resolve, so the collected
1034 // order follows timing. A non-commutative reducer — `Append` builds an
1035 // array, so order is the result — would then give a different state for
1036 // the same input depending on which node finished first. Sorting by
1037 // (node, channel) makes the order total and timing-independent: node
1038 // names are unique within a graph, and a node's own updates are held in a
1039 // map whose iteration order is itself unspecified.
1040 all_updates.sort_by(|(left, _), (right, _)| left.cmp(right));
1041 for (node, updates) in all_updates {
1042 let mut keys: Vec<_> = updates.keys().cloned().collect();
1043 keys.sort();
1044 self.ensure_channels_declared(&node, keys.iter().map(String::as_str))?;
1045 for key in keys {
1046 if let Some(value) = updates.get(&key) {
1047 self.graph.schema.apply_update(&mut self.state, &key, value.clone());
1048 }
1049 }
1050 }
1051
1052 if let Some(interrupt) = self.gate_after(&result.executed_nodes) {
1053 return Ok(SuperStepResult { interrupt: Some(interrupt), ..result });
1054 }
1055
1056 Ok(result)
1057 }
1058
1059 /// Save a checkpoint
1060 /// Returns the gate a pending node arms, if any.
1061 ///
1062 /// A node whose gate this run has already answered runs instead of
1063 /// interrupting again; without that a resume reaches the same conclusion and
1064 /// the node never executes.
1065 ///
1066 /// Shared by both execution paths. `StreamMode::Messages` runs nodes in its
1067 /// own loop, and when this check lived only in `execute_super_step` that mode
1068 /// ignored every gate.
1069 fn gate_before(&self, pending: &[String]) -> Option<Interrupt> {
1070 pending
1071 .iter()
1072 .find(|node| {
1073 self.graph.interrupt_before.contains(*node)
1074 && self.cleared_interrupt.as_deref() != Some(node.as_str())
1075 })
1076 .map(|node| Interrupt::Before(node.clone()))
1077 }
1078
1079 /// Returns the gate an executed node arms, if any.
1080 fn gate_after(&self, executed: &[String]) -> Option<Interrupt> {
1081 executed
1082 .iter()
1083 .find(|node| self.graph.interrupt_after.contains(*node))
1084 .map(|node| Interrupt::After(node.clone()))
1085 }
1086
1087 /// Computes the next frontier, letting a node's `goto` stand in for its edges.
1088 ///
1089 /// A node that named successors has its declared edges skipped, so a `goto`
1090 /// replaces an edge rather than adding to one. `END` is accepted and
1091 /// contributes no successor, which is how a branch stops.
1092 ///
1093 /// # Errors
1094 ///
1095 /// Returns [`GraphError::UnknownRouteTarget`] when a `goto` names a node the
1096 /// graph does not hold.
1097 fn next_frontier(
1098 &self,
1099 executed: &[String],
1100 goto: &HashMap<String, Vec<String>>,
1101 ) -> Result<Vec<String>> {
1102 // A node that routed itself does not also follow its declared edges.
1103 let followed_edges: Vec<String> =
1104 executed.iter().filter(|node| !goto.contains_key(*node)).cloned().collect();
1105 let mut next = self.graph.get_next_nodes(&followed_edges, &self.state)?;
1106
1107 // Sorted, so a multi-target goto admits its nodes in a fixed order.
1108 let mut routed: Vec<(&String, &Vec<String>)> = goto.iter().collect();
1109 routed.sort_by_key(|(node, _)| node.as_str());
1110
1111 for (node, targets) in routed {
1112 for target in targets {
1113 if target == crate::edge::END {
1114 continue;
1115 }
1116 if self.graph.node(target).is_none() {
1117 return Err(GraphError::UnknownRouteTarget(format!(
1118 "node '{node}' routed to '{target}', which is not a node in this graph"
1119 )));
1120 }
1121 if !next.contains(target) {
1122 next.push(target.clone());
1123 }
1124 }
1125 }
1126 Ok(next)
1127 }
1128
1129 /// Rejects an update naming a channel the schema does not declare.
1130 ///
1131 /// Inert unless the graph asked for enforcement, and inert when the schema
1132 /// declares no channels, so an existing graph is unaffected either way.
1133 fn ensure_channels_declared<'k>(
1134 &self,
1135 node: &str,
1136 keys: impl IntoIterator<Item = &'k str>,
1137 ) -> Result<()> {
1138 if !self.graph.strict_channels {
1139 return Ok(());
1140 }
1141 match self.graph.schema.first_undeclared(keys) {
1142 Some(channel) => Err(GraphError::UndeclaredChannel {
1143 node: node.to_string(),
1144 channel: channel.to_string(),
1145 }),
1146 None => Ok(()),
1147 }
1148 }
1149
1150 async fn save_checkpoint(&self) -> Result<String> {
1151 if let Some(cp) = &self.graph.checkpointer {
1152 let mut checkpoint = Checkpoint::new(
1153 &self.config.thread_id,
1154 self.state.clone(),
1155 self.step,
1156 self.pending_nodes.clone(),
1157 );
1158 checkpoint.cleared_interrupt = self.cleared_interrupt.clone();
1159 checkpoint.attempts = self.attempts.clone();
1160 checkpoint.child_ledger = self.child_ledger.lock().expect("child ledger").clone();
1161 let id = cp.save(&checkpoint).await?;
1162
1163 // Trimmed as the run proceeds, so the cost stays proportional to the run
1164 // and no external job is needed. After the save, so the newest counts.
1165 if let Some(policy) = &self.graph.retention {
1166 let removed = cp.prune(&self.config.thread_id, policy).await?;
1167 if removed > 0 {
1168 tracing::debug!(
1169 thread_id = %self.config.thread_id,
1170 removed,
1171 "pruned old checkpoints"
1172 );
1173 }
1174 }
1175 return Ok(id);
1176 }
1177 Ok(String::new())
1178 }
1179}
1180
1181/// Convenience methods for CompiledGraph
1182impl CompiledGraph {
1183 /// Execute the graph synchronously
1184 pub async fn invoke(&self, input: State, config: ExecutionConfig) -> Result<State> {
1185 self.invoke_detailed(input, config).await.map(|outcome| outcome.state)
1186 }
1187
1188 /// Executes and reports what the run asked of its caller.
1189 ///
1190 /// Only a graph run as a [`SubgraphNode`](crate::subgraph::SubgraphNode) has
1191 /// anything to report beyond its state, so [`Self::invoke`] is the usual
1192 /// entry point.
1193 pub async fn invoke_detailed(
1194 &self,
1195 input: State,
1196 config: ExecutionConfig,
1197 ) -> Result<GraphOutcome> {
1198 let mut executor = PregelExecutor::new(self, config);
1199 let state = executor.run(input).await?;
1200 Ok(GraphOutcome { state, goto_parent: executor.goto_parent })
1201 }
1202
1203 /// Execute with streaming
1204 pub fn stream(
1205 &self,
1206 input: State,
1207 config: ExecutionConfig,
1208 mode: StreamMode,
1209 ) -> impl futures::Stream<Item = Result<StreamEvent>> + '_ {
1210 tracing::debug!("CompiledGraph::stream called with mode {:?}", mode);
1211 let executor = PregelExecutor::new(self, config);
1212 executor.run_stream(input, mode)
1213 }
1214
1215 /// Get current state for a thread
1216 pub async fn get_state(&self, thread_id: &str) -> Result<Option<State>> {
1217 if let Some(cp) = &self.checkpointer {
1218 Ok(cp.load(thread_id).await?.map(|c| c.state))
1219 } else {
1220 Ok(None)
1221 }
1222 }
1223
1224 /// Update state for a thread (for human-in-the-loop)
1225 pub async fn update_state(
1226 &self,
1227 thread_id: &str,
1228 updates: impl IntoIterator<Item = (String, serde_json::Value)>,
1229 ) -> Result<()> {
1230 if let Some(cp) = &self.checkpointer
1231 && let Some(checkpoint) = cp.load(thread_id).await?
1232 {
1233 let mut state = checkpoint.state;
1234 for (key, value) in updates {
1235 self.schema.apply_update(&mut state, &key, value);
1236 }
1237 let new_checkpoint =
1238 Checkpoint::new(thread_id, state, checkpoint.step, checkpoint.pending_nodes);
1239 cp.save(&new_checkpoint).await?;
1240 }
1241 Ok(())
1242 }
1243}
1244
1245#[cfg(test)]
1246mod tests {
1247 use super::*;
1248 use crate::edge::{END, START};
1249 use crate::graph::StateGraph;
1250 use crate::node::NodeOutput;
1251 use serde_json::json;
1252
1253 #[tokio::test]
1254 async fn test_simple_execution() {
1255 let graph = StateGraph::with_channels(&["value"])
1256 .add_node_fn("set_value", |_ctx| async {
1257 Ok(NodeOutput::new().with_update("value", json!(42)))
1258 })
1259 .add_edge(START, "set_value")
1260 .add_edge("set_value", END)
1261 .compile()
1262 .unwrap();
1263
1264 let result = graph.invoke(State::new(), ExecutionConfig::new("test")).await.unwrap();
1265
1266 assert_eq!(result.get("value"), Some(&json!(42)));
1267 }
1268
1269 #[tokio::test]
1270 async fn test_sequential_execution() {
1271 let graph = StateGraph::with_channels(&["value"])
1272 .add_node_fn("step1", |_ctx| async {
1273 Ok(NodeOutput::new().with_update("value", json!(1)))
1274 })
1275 .add_node_fn("step2", |ctx| async move {
1276 let current = ctx.get("value").and_then(|v| v.as_i64()).unwrap_or(0);
1277 Ok(NodeOutput::new().with_update("value", json!(current + 10)))
1278 })
1279 .add_edge(START, "step1")
1280 .add_edge("step1", "step2")
1281 .add_edge("step2", END)
1282 .compile()
1283 .unwrap();
1284
1285 let result = graph.invoke(State::new(), ExecutionConfig::new("test")).await.unwrap();
1286
1287 assert_eq!(result.get("value"), Some(&json!(11)));
1288 }
1289
1290 #[tokio::test]
1291 async fn test_conditional_routing() {
1292 let graph = StateGraph::with_channels(&["path", "result"])
1293 .add_node_fn("router", |ctx| async move {
1294 let path = ctx.get("path").and_then(|v| v.as_str()).unwrap_or("a");
1295 Ok(NodeOutput::new().with_update("route", json!(path)))
1296 })
1297 .add_node_fn("path_a", |_ctx| async {
1298 Ok(NodeOutput::new().with_update("result", json!("went to A")))
1299 })
1300 .add_node_fn("path_b", |_ctx| async {
1301 Ok(NodeOutput::new().with_update("result", json!("went to B")))
1302 })
1303 .add_edge(START, "router")
1304 .add_conditional_edges(
1305 "router",
1306 |state| state.get("route").and_then(|v| v.as_str()).unwrap_or(END).to_string(),
1307 [("a", "path_a"), ("b", "path_b"), (END, END)],
1308 )
1309 .add_edge("path_a", END)
1310 .add_edge("path_b", END)
1311 .compile()
1312 .unwrap();
1313
1314 // Test path A
1315 let mut input = State::new();
1316 input.insert("path".to_string(), json!("a"));
1317 let result = graph.invoke(input, ExecutionConfig::new("test")).await.unwrap();
1318 assert_eq!(result.get("result"), Some(&json!("went to A")));
1319
1320 // Test path B
1321 let mut input = State::new();
1322 input.insert("path".to_string(), json!("b"));
1323 let result = graph.invoke(input, ExecutionConfig::new("test")).await.unwrap();
1324 assert_eq!(result.get("result"), Some(&json!("went to B")));
1325 }
1326
1327 #[tokio::test]
1328 async fn test_cycle_with_limit() {
1329 let graph = StateGraph::with_channels(&["count"])
1330 .add_node_fn("increment", |ctx| async move {
1331 let count = ctx.get("count").and_then(|v| v.as_i64()).unwrap_or(0);
1332 Ok(NodeOutput::new().with_update("count", json!(count + 1)))
1333 })
1334 .add_edge(START, "increment")
1335 .add_conditional_edges(
1336 "increment",
1337 |state| {
1338 let count = state.get("count").and_then(|v| v.as_i64()).unwrap_or(0);
1339 if count < 5 { "increment".to_string() } else { END.to_string() }
1340 },
1341 [("increment", "increment"), (END, END)],
1342 )
1343 .compile()
1344 .unwrap();
1345
1346 let result = graph.invoke(State::new(), ExecutionConfig::new("test")).await.unwrap();
1347
1348 assert_eq!(result.get("count"), Some(&json!(5)));
1349 }
1350
1351 #[tokio::test]
1352 async fn test_recursion_limit() {
1353 let graph = StateGraph::with_channels(&["count"])
1354 .add_node_fn("loop", |ctx| async move {
1355 let count = ctx.get("count").and_then(|v| v.as_i64()).unwrap_or(0);
1356 Ok(NodeOutput::new().with_update("count", json!(count + 1)))
1357 })
1358 .add_edge(START, "loop")
1359 .add_edge("loop", "loop") // Infinite loop
1360 .compile()
1361 .unwrap()
1362 .with_recursion_limit(10);
1363
1364 let result = graph.invoke(State::new(), ExecutionConfig::new("test")).await;
1365
1366 // The recursion limit check happens when step >= limit, so it will exceed at step 10
1367 assert!(
1368 matches!(result, Err(GraphError::RecursionLimitExceeded(_))),
1369 "Expected RecursionLimitExceeded error, got: {:?}",
1370 result
1371 );
1372 }
1373}