Skip to main content

waypoint_core/
dependency.rs

1//! Migration dependency graph with topological sort.
2//!
3//! Supports `-- waypoint:depends V3,V5` directives for non-linear
4//! migration ordering using Kahn's algorithm.
5
6use std::collections::{HashMap, HashSet};
7
8use crate::error::{Result, WaypointError};
9use crate::migration::ResolvedMigration;
10
11/// Whether `from` depends on `target`, directly or through other versions.
12///
13/// Plain DFS over the `depends-on` edges; migration counts are small enough
14/// that the repeated traversal during graph construction is not worth caching.
15fn depends_transitively(
16    edges: &HashMap<String, HashSet<String>>,
17    from: &str,
18    target: &str,
19) -> bool {
20    let mut stack = vec![from];
21    let mut seen: HashSet<&str> = HashSet::new();
22    while let Some(node) = stack.pop() {
23        if node == target {
24            return true;
25        }
26        if !seen.insert(node) {
27            continue;
28        }
29        if let Some(deps) = edges.get(node) {
30            stack.extend(deps.iter().map(|s| s.as_str()));
31        }
32    }
33    false
34}
35
36/// A directed acyclic graph of migration dependencies.
37pub struct DependencyGraph {
38    /// version -> set of versions it depends on
39    edges: HashMap<String, HashSet<String>>,
40    /// version -> set of versions that depend on it
41    reverse_edges: HashMap<String, HashSet<String>>,
42    /// All known versions
43    all_versions: Vec<String>,
44}
45
46impl DependencyGraph {
47    /// Build a dependency graph from resolved migrations.
48    ///
49    /// If `implicit_chain` is true, each versioned migration implicitly depends
50    /// on the previous version in sort order (backward-compatible default).
51    pub fn build(migrations: &[&ResolvedMigration], implicit_chain: bool) -> Result<Self> {
52        let mut edges: HashMap<String, HashSet<String>> = HashMap::new();
53        let mut reverse_edges: HashMap<String, HashSet<String>> = HashMap::new();
54        let mut all_versions: Vec<String> = Vec::new();
55
56        // Collect all versioned migrations sorted by version
57        let mut versioned: Vec<&ResolvedMigration> = migrations
58            .iter()
59            .filter(|m| m.is_versioned())
60            .copied()
61            .collect();
62        versioned.sort_by(|a, b| a.version().unwrap().cmp(b.version().unwrap()));
63
64        for m in &versioned {
65            let version = m.version().unwrap().raw.clone();
66            edges.entry(version.clone()).or_default();
67            reverse_edges.entry(version.clone()).or_default();
68            all_versions.push(version);
69        }
70
71        // Add explicit dependencies from directives
72        for m in &versioned {
73            let version = &m.version().unwrap().raw;
74            for dep in &m.directives.depends {
75                if !edges.contains_key(dep) {
76                    return Err(WaypointError::MissingDependency {
77                        version: version.clone(),
78                        dependency: dep.clone(),
79                    });
80                }
81                edges.get_mut(version.as_str()).unwrap().insert(dep.clone());
82                reverse_edges
83                    .get_mut(dep.as_str())
84                    .unwrap()
85                    .insert(version.clone());
86            }
87        }
88
89        // Add implicit chain dependencies (each version depends on previous).
90        //
91        // An implicit edge must never contradict an explicit one. If `previous`
92        // already depends — directly or transitively — on `current`, then
93        // adding `current -> previous` closes a cycle, and the graph would be
94        // rejected even though the migrations are perfectly orderable. That
95        // happens whenever a lower version declares `depends` on a higher one:
96        //
97        //   V2 `-- waypoint:depends 3`   explicit: 2 -> 3
98        //   V3 has no directives         implicit: 3 -> 2   ← false cycle
99        //
100        // Skipping the implicit edge in that case leaves the explicit
101        // dependency to do the ordering, which is what the author asked for.
102        if implicit_chain {
103            for i in 1..all_versions.len() {
104                let current = all_versions[i].clone();
105                let previous = all_versions[i - 1].clone();
106
107                let has_explicit_deps = edges.get(&current).is_some_and(|deps| !deps.is_empty());
108                if has_explicit_deps {
109                    continue;
110                }
111                if depends_transitively(&edges, &previous, &current) {
112                    log::debug!(
113                        "Skipping implicit dependency {} -> {}: {} already depends on {}",
114                        current,
115                        previous,
116                        previous,
117                        current
118                    );
119                    continue;
120                }
121
122                edges
123                    .entry(current.clone())
124                    .or_default()
125                    .insert(previous.clone());
126                reverse_edges.entry(previous).or_default().insert(current);
127            }
128        }
129
130        Ok(DependencyGraph {
131            edges,
132            reverse_edges,
133            all_versions,
134        })
135    }
136
137    /// Produce a topologically sorted order of versions using Kahn's algorithm.
138    ///
139    /// Ties — migrations that become runnable at the same moment — are broken
140    /// by **version order**, which makes the result deterministic.
141    ///
142    /// This matters more than it looks. The ready set used to be a `VecDeque`
143    /// fed from `reverse_edges`, a `HashSet` whose iteration order is randomly
144    /// seeded per process. Five independent migrations therefore applied in a
145    /// different order on almost every run: the order `explain` previewed was
146    /// not the order `migrate` used, and staging and production disagreed on
147    /// `installed_rank`. Any topological order is *correct*, but only a
148    /// reproducible one is honest.
149    ///
150    /// Uses borrowed `&str` references internally to avoid cloning during
151    /// the sort; only clones into owned `String`s for the output.
152    pub fn topological_sort(&self) -> Result<Vec<String>> {
153        // Rank each version by its position in `all_versions`, which `build`
154        // sorted by parsed version — so "10" ranks after "2", not before it as
155        // a string comparison would have it.
156        let rank: HashMap<&str, usize> = self
157            .all_versions
158            .iter()
159            .enumerate()
160            .map(|(i, v)| (v.as_str(), i))
161            .collect();
162
163        // Compute in-degree for each node using borrowed keys
164        let mut in_degree: HashMap<&str, usize> = HashMap::new();
165        for v in &self.all_versions {
166            in_degree.insert(v, self.edges.get(v).map_or(0, |deps| deps.len()));
167        }
168
169        // Ready set, ordered by version. `BTreeSet` keeps it sorted as nodes
170        // are added, so the next migration is always the lowest-versioned one
171        // whose dependencies are satisfied.
172        let mut ready: std::collections::BTreeSet<(usize, &str)> = self
173            .all_versions
174            .iter()
175            .filter(|v| *in_degree.get(v.as_str()).unwrap_or(&0) == 0)
176            .map(|v| (rank[v.as_str()], v.as_str()))
177            .collect();
178
179        let mut sorted = Vec::new();
180
181        while let Some(&(_, node)) = ready.iter().next() {
182            ready.remove(&(rank[node], node));
183            sorted.push(node.to_string());
184
185            // For each node that depends on this one, decrement in-degree
186            if let Some(dependents) = self.reverse_edges.get(node) {
187                for dep in dependents {
188                    let deg = in_degree.get_mut(dep.as_str()).unwrap();
189                    *deg -= 1;
190                    if *deg == 0 {
191                        ready.insert((rank[dep.as_str()], dep.as_str()));
192                    }
193                }
194            }
195        }
196
197        if sorted.len() != self.all_versions.len() {
198            // Trace an actual cycle path — convert in_degree to owned keys for trace_cycle
199            let owned_in_degree: HashMap<String, usize> = in_degree
200                .iter()
201                .map(|(&k, &v)| (k.to_string(), v))
202                .collect();
203            let cycle_path = self.trace_cycle(&owned_in_degree);
204            return Err(WaypointError::DependencyCycle { path: cycle_path });
205        }
206
207        Ok(sorted)
208    }
209
210    /// Trace an actual cycle path for error reporting.
211    fn trace_cycle(&self, in_degree: &HashMap<String, usize>) -> String {
212        // Start from any node still in the cycle
213        let start = self
214            .all_versions
215            .iter()
216            .find(|v| *in_degree.get(*v).unwrap_or(&0) > 0);
217
218        let Some(start) = start else {
219            return "unknown cycle".to_string();
220        };
221
222        // Follow dependency edges to trace the cycle
223        let mut path = vec![start.clone()];
224        let mut current = start.clone();
225        let mut visited = std::collections::HashSet::new();
226        visited.insert(current.clone());
227
228        loop {
229            // Find a dependency of `current` that is also in the cycle
230            let next = self
231                .edges
232                .get(&current)
233                .and_then(|deps| deps.iter().find(|d| *in_degree.get(*d).unwrap_or(&0) > 0));
234
235            match next {
236                Some(n) => {
237                    if !visited.insert(n.clone()) {
238                        // We've come back to a visited node — complete the cycle
239                        path.push(n.clone());
240                        // Trim path to start from the cycle entry point
241                        if let Some(pos) = path.iter().position(|v| v == n) {
242                            let cycle: Vec<String> = path[pos..].to_vec();
243                            return cycle.join(" -> ");
244                        }
245                        return path.join(" -> ");
246                    }
247                    path.push(n.clone());
248                    current = n.clone();
249                }
250                None => {
251                    // Fallback: list all nodes in cycle
252                    let in_cycle: Vec<String> = self
253                        .all_versions
254                        .iter()
255                        .filter(|v| *in_degree.get(*v).unwrap_or(&0) > 0)
256                        .cloned()
257                        .collect();
258                    return format!("cycle involving: {}", in_cycle.join(", "));
259                }
260            }
261        }
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use crate::directive::MigrationDirectives;
269    use crate::migration::{MigrationKind, MigrationVersion, ResolvedMigration};
270
271    fn make_migration(version: &str, depends: Vec<&str>) -> ResolvedMigration {
272        ResolvedMigration {
273            kind: MigrationKind::Versioned(MigrationVersion::parse(version).unwrap()),
274            description: format!("V{}", version),
275            script: format!("V{}__test.sql", version),
276            checksum: 0,
277            sql: String::new(),
278            directives: MigrationDirectives {
279                depends: depends.into_iter().map(String::from).collect(),
280                env: vec![],
281                ..Default::default()
282            },
283        }
284    }
285
286    #[test]
287    fn test_simple_chain() {
288        let m1 = make_migration("1", vec![]);
289        let m2 = make_migration("2", vec![]);
290        let m3 = make_migration("3", vec![]);
291        let migrations: Vec<&ResolvedMigration> = vec![&m1, &m2, &m3];
292
293        let graph = DependencyGraph::build(&migrations, true).unwrap();
294        let order = graph.topological_sort().unwrap();
295        assert_eq!(order, vec!["1", "2", "3"]);
296    }
297
298    #[test]
299    fn test_explicit_dependency() {
300        let m1 = make_migration("1", vec![]);
301        let m2 = make_migration("2", vec![]);
302        let m3 = make_migration("3", vec!["1"]); // V3 depends on V1, skipping V2
303        let migrations: Vec<&ResolvedMigration> = vec![&m1, &m2, &m3];
304
305        let graph = DependencyGraph::build(&migrations, false).unwrap();
306        let order = graph.topological_sort().unwrap();
307        // V1 must come before V3, V2 has no deps so can be anywhere
308        let pos1 = order.iter().position(|v| v == "1").unwrap();
309        let pos3 = order.iter().position(|v| v == "3").unwrap();
310        assert!(pos1 < pos3);
311    }
312
313    #[test]
314    fn test_cycle_detection() {
315        let m1 = make_migration("1", vec!["2"]);
316        let m2 = make_migration("2", vec!["1"]);
317        let migrations: Vec<&ResolvedMigration> = vec![&m1, &m2];
318
319        let graph = DependencyGraph::build(&migrations, false).unwrap();
320        assert!(graph.topological_sort().is_err());
321    }
322
323    #[test]
324    fn test_missing_dependency() {
325        let m1 = make_migration("1", vec!["99"]);
326        let migrations: Vec<&ResolvedMigration> = vec![&m1];
327
328        assert!(DependencyGraph::build(&migrations, false).is_err());
329    }
330
331    #[test]
332    fn test_cycle_error_shows_path() {
333        let m1 = make_migration("1", vec!["3"]);
334        let m2 = make_migration("2", vec!["1"]);
335        let m3 = make_migration("3", vec!["2"]);
336        let migrations: Vec<&ResolvedMigration> = vec![&m1, &m2, &m3];
337
338        let graph = DependencyGraph::build(&migrations, false).unwrap();
339        let err = graph.topological_sort().unwrap_err();
340        let msg = err.to_string();
341        // The error should contain cycle path information
342        assert!(msg.contains("->"), "Cycle error should show path: {}", msg);
343    }
344
345    #[test]
346    fn test_empty_migrations() {
347        let migrations: Vec<&ResolvedMigration> = vec![];
348        let graph = DependencyGraph::build(&migrations, true).unwrap();
349        let order = graph.topological_sort().unwrap();
350        assert!(order.is_empty());
351    }
352
353    #[test]
354    fn test_single_migration() {
355        let m1 = make_migration("1", vec![]);
356        let migrations: Vec<&ResolvedMigration> = vec![&m1];
357        let graph = DependencyGraph::build(&migrations, true).unwrap();
358        let order = graph.topological_sort().unwrap();
359        assert_eq!(order, vec!["1"]);
360    }
361
362    #[test]
363    fn test_diamond_dependency() {
364        let m1 = make_migration("1", vec![]);
365        let m2 = make_migration("2", vec!["1"]);
366        let m3 = make_migration("3", vec!["1"]);
367        let m4 = make_migration("4", vec!["2", "3"]);
368        let migrations: Vec<&ResolvedMigration> = vec![&m1, &m2, &m3, &m4];
369
370        let graph = DependencyGraph::build(&migrations, false).unwrap();
371        let order = graph.topological_sort().unwrap();
372
373        // V1 must be first, V4 must be last
374        assert_eq!(order[0], "1");
375        assert_eq!(order[3], "4");
376    }
377
378    #[test]
379    fn test_self_referencing_cycle() {
380        let m1 = make_migration("1", vec!["1"]);
381        let migrations: Vec<&ResolvedMigration> = vec![&m1];
382
383        let graph = DependencyGraph::build(&migrations, false).unwrap();
384        assert!(graph.topological_sort().is_err());
385    }
386
387    #[test]
388    fn test_topological_sort_is_deterministic_for_independent_migrations() {
389        // V2..V6 all depend only on V1, so all five become ready at once.
390        // Kahn's ready-queue is fed from `reverse_edges`, a HashSet, whose
391        // iteration order is randomly seeded per process — so the apply order
392        // could differ between the `explain` preview and the real run, and
393        // between staging and production.
394        let migs: Vec<ResolvedMigration> = std::iter::once(make_migration("1", vec![]))
395            .chain((2..=6).map(|v| make_migration(&v.to_string(), vec!["1"])))
396            .collect();
397        let refs: Vec<&ResolvedMigration> = migs.iter().collect();
398        let graph = DependencyGraph::build(&refs, true).unwrap();
399        let order = graph.topological_sort().unwrap();
400        assert_eq!(
401            order,
402            vec!["1", "2", "3", "4", "5", "6"],
403            "independent migrations must apply in version order, not hash order"
404        );
405    }
406}