lightshuttle_runtime/lifecycle/plan.rs
1//! Resolved execution plan: topologically sorted nodes plus the dependency graph.
2//!
3//! The entry point is [`LifecyclePlan::from_manifest`], which converts a
4//! parsed [`lightshuttle_manifest::Manifest`] into a [`LifecyclePlan`]. The
5//! conversion resolves every resource to a [`lightshuttle_spec::ContainerSpec`]
6//! and sorts the nodes using Kahn's topological sort algorithm. The resulting
7//! order guarantees that a resource is always listed after all of its
8//! dependencies, which allows the [`crate::LifecycleManager`] to start
9//! independent branches in parallel.
10
11use std::collections::{HashMap, HashSet};
12
13use lightshuttle_manifest::Manifest;
14
15use crate::lifecycle::error::LifecycleError;
16use lightshuttle_spec::{ContainerSpec, ResolvedResource, ResourceOutputs, from_resource};
17
18/// A single resource to manage, with its resolved [`ContainerSpec`],
19/// its exposed outputs and its explicit dependencies.
20#[derive(Debug, Clone)]
21pub struct PlanNode {
22 /// Resource name as declared in the manifest.
23 pub name: String,
24 /// Resource kind discriminant (`postgres`, `redis`, `container`,
25 /// `dockerfile`), mirrored from the manifest.
26 pub kind: String,
27 /// Container specification derived from the manifest.
28 pub spec: ContainerSpec,
29 /// Outputs the resource exposes to its dependents (host, port,
30 /// password, url, ...).
31 pub outputs: ResourceOutputs,
32 /// Names of resources this one depends on.
33 pub depends_on: Vec<String>,
34}
35
36/// Topologically sorted execution plan.
37///
38/// Built by [`LifecyclePlan::from_manifest`] and consumed by
39/// [`crate::LifecycleManager`]. The node order guarantees that every resource
40/// appears after all of its direct and transitive dependencies.
41///
42/// # Example
43///
44/// ```rust,no_run
45/// use lightshuttle_manifest::Manifest;
46/// use lightshuttle_runtime::LifecyclePlan;
47///
48/// # fn example() -> Result<(), lightshuttle_runtime::LifecycleError> {
49/// let yaml = r#"
50/// project:
51/// name: myapp
52/// resources:
53/// db:
54/// postgres:
55/// version: "16"
56/// api:
57/// container:
58/// image: myapp:latest
59/// depends_on: [db]
60/// "#;
61///
62/// let manifest = Manifest::parse(yaml).expect("valid manifest");
63/// let plan = LifecyclePlan::from_manifest(&manifest)?;
64///
65/// // Nodes are sorted: "db" appears before "api".
66/// for node in plan.nodes() {
67/// println!("{} ({}) -> {:?}", node.name, node.kind, node.depends_on);
68/// }
69/// # Ok(())
70/// # }
71/// ```
72#[derive(Debug, Clone)]
73pub struct LifecyclePlan {
74 nodes: Vec<PlanNode>,
75 edges: HashMap<String, Vec<String>>,
76}
77
78impl LifecyclePlan {
79 /// Build a plan from a parsed manifest.
80 ///
81 /// Resolves the dependency graph, performs a topological sort (Kahn's
82 /// algorithm) and converts every resource to a [`ContainerSpec`].
83 ///
84 /// # Errors
85 ///
86 /// Returns [`crate::LifecycleError::Cycle`] when the dependency graph
87 /// contains a cycle, [`crate::LifecycleError::ResourceNotFound`] when a
88 /// resource references an unknown dependency, and
89 /// [`crate::LifecycleError::SpecBuild`] when a resource cannot be
90 /// converted to a [`ContainerSpec`].
91 pub fn from_manifest(manifest: &Manifest) -> Result<Self, LifecycleError> {
92 let project = manifest.project.name.as_str();
93
94 // Build edges and collect spec + outputs for every resource.
95 let mut resolved: HashMap<String, ResolvedResource> = HashMap::new();
96 let mut deps: HashMap<String, Vec<String>> = HashMap::new();
97 let mut kinds: HashMap<String, &'static str> = HashMap::new();
98 for (name, kind) in &manifest.resources {
99 let r =
100 from_resource(project, name, kind).map_err(|source| LifecycleError::SpecBuild {
101 resource: name.clone(),
102 source,
103 })?;
104 resolved.insert(name.clone(), r);
105 deps.insert(name.clone(), kind.merged_dependencies(name));
106 kinds.insert(name.clone(), kind.kind_name());
107 }
108
109 // Verify every dependency points to an existing resource.
110 for (name, dependencies) in &deps {
111 for dependency in dependencies {
112 if !resolved.contains_key(dependency) {
113 return Err(LifecycleError::ResourceNotFound(format!(
114 "`{dependency}` (depended on by `{name}`)"
115 )));
116 }
117 }
118 }
119
120 // Kahn's algorithm for topological sort.
121 let mut in_degree: HashMap<String, usize> = resolved
122 .keys()
123 .map(|name| (name.clone(), 0_usize))
124 .collect();
125 for dependencies in deps.values() {
126 for dependency in dependencies {
127 *in_degree.entry(dependency.clone()).or_insert(0) += 1;
128 }
129 }
130
131 // Build reverse adjacency: dependency → resources that depend on it.
132 let mut reverse: HashMap<String, Vec<String>> = HashMap::new();
133 for (name, dependencies) in &deps {
134 for dependency in dependencies {
135 reverse
136 .entry(dependency.clone())
137 .or_default()
138 .push(name.clone());
139 }
140 }
141
142 // Start with nodes that no one depends on (in_degree == 0 in the
143 // reverse graph means "no dependent waits on this one"). For a
144 // dependency edge dep → res, res is added to nodes that depend
145 // on dep; topo order should yield deps first.
146 //
147 // We invert: deps come before their dependents.
148 // in_degree counts how many incoming dependency edges (= how
149 // many of my own dependencies) each node has.
150 let mut in_count: HashMap<String, usize> = resolved
151 .keys()
152 .map(|name| (name.clone(), deps.get(name).map_or(0, Vec::len)))
153 .collect();
154
155 let mut ready: Vec<String> = in_count
156 .iter()
157 .filter(|(_, count)| **count == 0)
158 .map(|(name, _)| name.clone())
159 .collect();
160 // Deterministic order: sort by name.
161 ready.sort();
162
163 let mut sorted: Vec<String> = Vec::with_capacity(resolved.len());
164 while let Some(node) = ready.pop() {
165 sorted.push(node.clone());
166 if let Some(dependents) = reverse.get(&node) {
167 let mut newly_ready: Vec<String> = Vec::new();
168 for dependent in dependents {
169 let count = in_count.get_mut(dependent).expect("dependent indexed");
170 *count -= 1;
171 if *count == 0 {
172 newly_ready.push(dependent.clone());
173 }
174 }
175 newly_ready.sort();
176 ready.extend(newly_ready);
177 }
178 }
179
180 if sorted.len() != resolved.len() {
181 let unresolved: Vec<&String> = resolved
182 .keys()
183 .filter(|name| !sorted.contains(name))
184 .collect();
185 return Err(LifecycleError::Cycle(format!(
186 "{unresolved:?} involved in a cycle"
187 )));
188 }
189
190 let _ = in_degree; // silence unused warning for the alternate counter
191 let _ = HashSet::<&str>::new();
192
193 // Snapshot the edges before draining deps into the nodes.
194 let edges = deps.clone();
195
196 let nodes: Vec<PlanNode> = sorted
197 .into_iter()
198 .map(|name| {
199 let ResolvedResource { spec, outputs } =
200 resolved.remove(&name).expect("spec indexed by name");
201 let dependencies = deps.remove(&name).unwrap_or_default();
202 let kind = kinds
203 .remove(&name)
204 .expect("kind indexed by name")
205 .to_owned();
206 PlanNode {
207 name,
208 kind,
209 spec,
210 outputs,
211 depends_on: dependencies,
212 }
213 })
214 .collect();
215
216 Ok(Self { nodes, edges })
217 }
218
219 /// Returns the nodes in topological order.
220 ///
221 /// A node is guaranteed to appear after every node it depends on.
222 /// The [`crate::LifecycleManager`] iterates this slice to start resources
223 /// in order, spawning independent branches concurrently.
224 #[must_use]
225 pub fn nodes(&self) -> &[PlanNode] {
226 &self.nodes
227 }
228
229 /// Returns the names of resources that directly depend on `name`,
230 /// sorted alphabetically.
231 ///
232 /// Used during teardown to determine which downstream resources must be
233 /// stopped before their dependency can be removed.
234 #[must_use]
235 pub fn dependents_of(&self, name: &str) -> Vec<&str> {
236 let mut out: Vec<&str> = Vec::new();
237 for (resource, deps) in &self.edges {
238 if deps.iter().any(|d| d == name) {
239 out.push(resource.as_str());
240 }
241 }
242 out.sort_unstable();
243 out
244 }
245}