Skip to main content

stackless_core/def/
graph.rs

1//! The derived dependency graph (ARCHITECTURE.md §1).
2//!
3//! Wiring is interpolation, and the graph is derived from it — never
4//! declared separately. Two edge classes fall out of the namespace:
5//!
6//! - `${integrations.X.output}` is an **ordering** edge: outputs exist
7//!   only after the integration is provisioned.
8//! - `${services.X.origin}` is **wiring only**: origins are derivable
9//!   from the instance name alone on every substrate, so mutual
10//!   references (api ↔ web CORS) are recorded but never order startup —
11//!   which is exactly why they are not cycles.
12//!
13//! Representation, ordering, and cycle detection all live in oxgraph: the
14//! nodes and ordering edges feed a `GraphBuilder`, `topological_sort` yields
15//! the startup order, and on the rare cycle `strongly_connected_components`
16//! names the members for the error.
17
18use std::collections::BTreeSet;
19
20use oxgraph::algo::{strongly_connected_components, topological_sort};
21use oxgraph::graph_build::{GraphBuildError, GraphBuilder};
22use serde::Serialize;
23
24use super::error::DefError;
25use super::interp::{self, Reference};
26use super::model::StackDef;
27
28#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
29#[serde(tag = "kind", content = "name", rename_all = "snake_case")]
30pub enum Node {
31    Integration(String),
32    Service(String),
33}
34
35impl Node {
36    pub fn name(&self) -> &str {
37        match self {
38            Self::Integration(name) | Self::Service(name) => name,
39        }
40    }
41}
42
43/// The graph derived from one validated definition.
44#[derive(Debug, Serialize)]
45pub struct DependencyGraph {
46    nodes: Vec<Node>,
47    /// Dependencies before dependents; also the `prepare` order.
48    startup_order: Vec<Node>,
49    /// Every reference edge (referencing service → target), origins
50    /// included — the seam later phases derive egress policy from.
51    wiring: Vec<(Node, Node)>,
52}
53
54impl DependencyGraph {
55    /// Derive edges from the definition's interpolation references.
56    ///
57    /// Call on a validated definition: undeclared references have
58    /// already been rejected, so lookups here cannot miss.
59    pub fn derive(def: &StackDef) -> Result<Self, DefError> {
60        // Dense indices: integrations, then services — all in the
61        // definition's (sorted) order for deterministic ties.
62        let nodes: Vec<Node> = def
63            .integrations
64            .keys()
65            .map(|name| Node::Integration(name.clone()))
66            .chain(def.services.keys().map(|name| Node::Service(name.clone())))
67            .collect();
68        let index_of =
69            |node: &Node| -> Option<u32> { nodes.iter().position(|n| n == node).map(|i| i as u32) };
70
71        let mut ordering_edges: BTreeSet<(u32, u32)> = BTreeSet::new();
72        let mut wiring: BTreeSet<(u32, u32)> = BTreeSet::new();
73        for (service_name, service) in &def.services {
74            let service_node = Node::Service(service_name.clone());
75            let Some(service_idx) = index_of(&service_node) else {
76                continue;
77            };
78            let mut values: Vec<(String, String)> = service
79                .env
80                .iter()
81                .map(|(k, v)| (format!("services.{service_name}.env.{k}"), v.clone()))
82                .collect();
83            for substrate in service.substrates.keys() {
84                for (k, v) in service.substrate_env(service_name, substrate)? {
85                    values.push((format!("services.{service_name}.{substrate}.env.{k}"), v));
86                }
87            }
88            for (location, value) in &values {
89                for reference in interp::references(value, location)? {
90                    let target = match reference {
91                        Reference::ServiceOrigin(name) => Node::Service(name),
92                        Reference::IntegrationOutput { integration, .. } => {
93                            Node::Integration(integration)
94                        }
95                        // Legacy datastore refs are resume-only wiring; the
96                        // provision step is gone from the plan, so they do
97                        // not contribute ordering edges.
98                        Reference::StackName
99                        | Reference::InstanceName
100                        | Reference::Secret(_)
101                        | Reference::DatastoreUrl(_) => {
102                            continue;
103                        }
104                    };
105                    let Some(target_idx) = index_of(&target) else {
106                        continue;
107                    };
108                    wiring.insert((service_idx, target_idx));
109                    if matches!(target, Node::Integration(_)) {
110                        // Edge points dependency → dependent so Kahn
111                        // emits dependencies first.
112                        ordering_edges.insert((target_idx, service_idx));
113                    }
114                }
115            }
116        }
117
118        if let Some(verify) = &def.stack.verify {
119            for (key, value) in &verify.env {
120                let location = format!("stack.verify.env.{key}");
121                for reference in interp::references(value, &location)? {
122                    let Reference::IntegrationOutput { integration, .. } = reference else {
123                        continue;
124                    };
125                    let target = Node::Integration(integration);
126                    let Some(target_idx) = index_of(&target) else {
127                        continue;
128                    };
129                    let _ = target_idx;
130                }
131            }
132            for (tier, spec) in &verify.tiers {
133                for (key, value) in &spec.env {
134                    let location = format!("stack.verify.tiers.{tier}.env.{key}");
135                    for reference in interp::references(value, &location)? {
136                        let Reference::IntegrationOutput { integration, .. } = reference else {
137                            continue;
138                        };
139                        let target = Node::Integration(integration);
140                        let Some(target_idx) = index_of(&target) else {
141                            continue;
142                        };
143                        let _ = target_idx;
144                    }
145                }
146            }
147        }
148
149        let mut builder = GraphBuilder::<u32, u32>::new();
150        let mut node_ids = Vec::with_capacity(nodes.len());
151        for _ in &nodes {
152            node_ids.push(builder.add_node().map_err(internal_layout_error)?);
153        }
154        for &(from, to) in &ordering_edges {
155            builder
156                .add_edge(node_ids[from as usize], node_ids[to as usize])
157                .map_err(internal_layout_error)?;
158        }
159        let graph = builder.freeze().map_err(internal_layout_error)?;
160
161        let order = match topological_sort(&graph, &node_ids) {
162            Ok(order) => order,
163            // Toposort only flags that no order exists; SCC names the cycle
164            // members so the error points at the wiring that closed it.
165            Err(_) => {
166                let cycle = strongly_connected_components(&graph, &node_ids)
167                    .into_iter()
168                    .filter(|component| component.len() > 1)
169                    .flatten()
170                    .map(|id| nodes[id.get() as usize].name().to_owned())
171                    .collect::<Vec<_>>()
172                    .join(" -> ");
173                return Err(DefError::WiringCycle { nodes: cycle });
174            }
175        };
176
177        let startup_order = order
178            .into_iter()
179            .map(|id| nodes[id.get() as usize].clone())
180            .collect();
181        let wiring = wiring
182            .into_iter()
183            .map(|(from, to)| (nodes[from as usize].clone(), nodes[to as usize].clone()))
184            .collect();
185        Ok(Self {
186            nodes,
187            startup_order,
188            wiring,
189        })
190    }
191
192    pub fn nodes(&self) -> &[Node] {
193        &self.nodes
194    }
195
196    pub fn startup_order(&self) -> &[Node] {
197        &self.startup_order
198    }
199
200    pub fn wiring(&self) -> &[(Node, Node)] {
201        &self.wiring
202    }
203}
204
205/// Builder/freeze failures are unreachable for the dense edge set we just
206/// built; surface honestly rather than panic if oxgraph rejects the layout.
207fn internal_layout_error(err: GraphBuildError<u32, u32>) -> DefError {
208    DefError::WiringCycle {
209        nodes: format!("internal graph layout error: {err:?}"),
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use oxgraph::algo::ToposortError;
217    use oxgraph::graph_build::{FrozenGraph, GraphNodeId};
218
219    fn build(
220        node_count: u32,
221        edges: &[(u32, u32)],
222    ) -> (FrozenGraph<u32, u32>, Vec<GraphNodeId<u32>>) {
223        let mut builder = GraphBuilder::<u32, u32>::new();
224        let ids: Vec<_> = (0..node_count)
225            .map(|_| builder.add_node().unwrap())
226            .collect();
227        for &(from, to) in edges {
228            builder
229                .add_edge(ids[from as usize], ids[to as usize])
230                .unwrap();
231        }
232        (builder.freeze().unwrap(), ids)
233    }
234
235    #[test]
236    fn topological_sort_orders_a_dag() {
237        // 0 -> 1 -> 3, 0 -> 2 -> 3
238        let (graph, ids) = build(4, &[(0, 1), (0, 2), (1, 3), (2, 3)]);
239        let order: Vec<u32> = topological_sort(&graph, &ids)
240            .unwrap()
241            .into_iter()
242            .map(|id| id.get())
243            .collect();
244        assert_eq!(order, vec![0, 1, 2, 3]);
245    }
246
247    #[test]
248    fn scc_reports_the_cycle_members() {
249        // 0 -> 1 -> 2 -> 1, 3 isolated
250        let (graph, ids) = build(4, &[(0, 1), (1, 2), (2, 1)]);
251        assert_eq!(
252            topological_sort(&graph, &ids).unwrap_err(),
253            ToposortError::Cycle
254        );
255        let mut cycle: Vec<u32> = strongly_connected_components(&graph, &ids)
256            .into_iter()
257            .filter(|component| component.len() > 1)
258            .flatten()
259            .map(|id| id.get())
260            .collect();
261        cycle.sort_unstable();
262        assert_eq!(cycle, vec![1, 2]);
263    }
264
265    #[test]
266    fn topological_sort_handles_no_edges() {
267        let (graph, ids) = build(3, &[]);
268        assert_eq!(topological_sort(&graph, &ids).unwrap().len(), 3);
269    }
270}