-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathExecutionGraph.java
More file actions
193 lines (173 loc) · 7.48 KB
/
Copy pathExecutionGraph.java
File metadata and controls
193 lines (173 loc) · 7.48 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
package com.chatbot.agent.runtime.graph;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Deque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* A validated, immutable execution plan.
*
* <p><b>An ExecutionGraph that exists is well-formed.</b> Validation happens once, at construction:
* unique ids, every edge resolving to a declared node, no self-edges, no cycles. Downstream code
* never re-checks and cannot be handed a half-valid graph, which removes a whole category of
* defensive checks from the scheduler.
*
* <p>Node ordering is preserved and deterministic throughout. Two runs of the same plan schedule
* identically; without that, a failure that depends on ordering is irreproducible.
*
* @see <a href="../../../../../../../../docs/RUNTIME_DESIGN.md">RUNTIME_DESIGN.md</a>
*/
public final class ExecutionGraph {
private final Map<String, ExecutionNode> nodes;
private final List<ExecutionEdge> edges;
private final Map<String, Set<String>> dependencies; // node -> nodes it waits for
private final Map<String, Set<String>> dependents; // node -> nodes waiting on it
private final List<String> topologicalOrder;
public ExecutionGraph(Collection<ExecutionNode> nodes, Collection<ExecutionEdge> edges) {
this.nodes = new LinkedHashMap<>();
for (ExecutionNode n : nodes) {
if (this.nodes.putIfAbsent(n.getId(), n) != null) {
throw new GraphValidationException("Duplicate node id: '" + n.getId() + "'");
}
}
if (this.nodes.isEmpty()) {
throw new GraphValidationException("Graph must contain at least one node");
}
this.edges = List.copyOf(edges);
this.dependencies = new HashMap<>();
this.dependents = new HashMap<>();
for (String id : this.nodes.keySet()) {
dependencies.put(id, new LinkedHashSet<>());
dependents.put(id, new LinkedHashSet<>());
}
for (ExecutionEdge e : this.edges) {
if (!this.nodes.containsKey(e.from())) {
throw new GraphValidationException(
"Edge " + e + " references unknown node '" + e.from() + "'");
}
if (!this.nodes.containsKey(e.to())) {
throw new GraphValidationException(
"Edge " + e + " references unknown node '" + e.to() + "'");
}
if (e.from().equals(e.to())) {
throw new GraphValidationException(
"Self-dependency on node '" + e.from() + "': a node cannot depend on itself");
}
dependencies.get(e.to()).add(e.from());
dependents.get(e.from()).add(e.to());
}
this.topologicalOrder = topologicallySort(); // throws if a cycle exists
}
/**
* Kahn's algorithm. Produces the execution order and detects cycles in the same pass: if fewer
* nodes are emitted than exist, the remainder are exactly the nodes involved in, or downstream
* of, a cycle.
*
* <p>Iterative rather than recursive DFS on purpose - a deep or adversarial graph must fail
* validation, not overflow the JVM stack. A StackOverflowError here would turn a rejected input
* into a crash.
*/
private List<String> topologicallySort() {
Map<String, Integer> inDegree = new LinkedHashMap<>();
for (String id : nodes.keySet()) {
inDegree.put(id, dependencies.get(id).size());
}
// Insertion order is preserved, so the emitted order is stable across runs.
Deque<String> ready = new ArrayDeque<>();
inDegree.forEach((id, deg) -> { if (deg == 0) ready.add(id); });
List<String> order = new ArrayList<>(nodes.size());
while (!ready.isEmpty()) {
String id = ready.poll();
order.add(id);
for (String dep : dependents.get(id)) {
if (inDegree.merge(dep, -1, Integer::sum) == 0) {
ready.add(dep);
}
}
}
if (order.size() != nodes.size()) {
Set<String> remaining = new LinkedHashSet<>(nodes.keySet());
remaining.removeAll(order);
throw new GraphValidationException(
"Cycle detected. Nodes involved: " + remaining
+ ". Cycle path: " + describeCycle(remaining));
}
return List.copyOf(order);
}
/**
* Walk the unresolved remainder to name an actual cycle path.
*
* <p>"A cycle exists" is not actionable on a graph of any size; {@code A -> B -> C -> A} is.
*/
private String describeCycle(Set<String> remaining) {
for (String start : remaining) {
List<String> path = new ArrayList<>();
Set<String> onPath = new HashSet<>();
String current = start;
while (current != null && remaining.contains(current)) {
if (!onPath.add(current)) {
int from = path.indexOf(current);
List<String> cycle = new ArrayList<>(path.subList(from, path.size()));
cycle.add(current);
return String.join(" -> ", cycle);
}
path.add(current);
current = dependents.get(current).stream()
.filter(remaining::contains).findFirst().orElse(null);
}
}
return "unresolved";
}
public ExecutionNode node(String id) {
ExecutionNode n = nodes.get(id);
if (n == null) {
throw new IllegalArgumentException("No such node: '" + id + "'");
}
return n;
}
public Collection<ExecutionNode> nodes() { return nodes.values(); }
public List<ExecutionEdge> edges() { return edges; }
public int size() { return nodes.size(); }
/** Ids this node waits for. */
public Set<String> dependenciesOf(String nodeId) {
return Set.copyOf(dependencies.getOrDefault(nodeId, Set.of()));
}
/** Ids waiting on this node. */
public Set<String> dependentsOf(String nodeId) {
return Set.copyOf(dependents.getOrDefault(nodeId, Set.of()));
}
/** Nodes with no dependencies - the initial frontier. */
public List<String> roots() {
return nodes.keySet().stream().filter(id -> dependencies.get(id).isEmpty()).toList();
}
/** A valid execution order. Stable for a given graph. */
public List<String> topologicalOrder() { return topologicalOrder; }
/**
* Every node reachable downstream of the given node, transitively.
*
* <p>Used when a node fails terminally: this is the set to mark SKIPPED. Computing it by
* traversal rather than by repeated re-scanning keeps failure handling O(affected subgraph)
* instead of O(graph) per failure.
*/
public Set<String> transitiveDependentsOf(String nodeId) {
Set<String> seen = new LinkedHashSet<>();
Deque<String> stack = new ArrayDeque<>(dependents.getOrDefault(nodeId, Set.of()));
while (!stack.isEmpty()) {
String id = stack.pop();
if (seen.add(id)) {
stack.addAll(dependents.getOrDefault(id, Set.of()));
}
}
return seen;
}
@Override
public String toString() {
return "ExecutionGraph[nodes=" + nodes.size() + ", edges=" + edges.size() + "]";
}
}