Skip to main content

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}