Skip to content

Commit 6ba99e0

Browse files
committed
Traverse full graph when searching for cycles
This fixes a bug where some edges would be skipped for certain graph configurations resulting in missed cycles. This would eventually result in runtime Stack Overflow exceptions when attempting to operate on the graph. This also improves the error reporting in the case of a cycle. Previously we would just report whatever was on the path when a cycle was detected. Now we report only the exact cycle path.
1 parent d9249d9 commit 6ba99e0

File tree

2 files changed

+70
-23
lines changed

2 files changed

+70
-23
lines changed

packages/kmono-core/src/k16/kmono/core/graph.clj

Lines changed: 56 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,32 +7,68 @@
77
(def ^:no-doc ?ExecOrder
88
[:vector [:vector :symbol]])
99

10-
(defn find-cycles
11-
"Try find cyclic dependencies between packages in a given `packages` map"
10+
(defn- cycle-path
11+
[parent-map from-node to-node]
12+
(loop [node from-node
13+
acc [from-node]]
14+
(if (= node to-node)
15+
(conj (vec (reverse acc)) to-node)
16+
(recur (get parent-map node)
17+
(conj acc (get parent-map node))))))
18+
19+
(defn- find-cycle-path-from
20+
[packages node visiting visited parent-map]
21+
(let [visiting (conj visiting node)
22+
neighbors (sort (get-in packages [node :depends-on]))]
23+
(loop [neighbors neighbors
24+
visited visited
25+
parent-map parent-map]
26+
(if-let [neighbor (first neighbors)]
27+
(cond
28+
(contains? visiting neighbor)
29+
[(cycle-path parent-map node neighbor) visited parent-map]
30+
31+
(contains? visited neighbor)
32+
(recur (rest neighbors) visited parent-map)
33+
34+
:else
35+
(let [[cycle visited parent-map]
36+
(find-cycle-path-from packages
37+
neighbor
38+
visiting
39+
visited
40+
(assoc parent-map neighbor node))]
41+
(if cycle
42+
[cycle visited parent-map]
43+
(recur (rest neighbors) visited parent-map))))
44+
[nil (conj visited node) parent-map]))))
45+
46+
(defn find-cycle
47+
"Find a dependency cycle in `packages` and returns it as a closed path.
48+
49+
Returns a vector like `[a b c a]` for a cycle `a -> b -> c -> a`, or nil if
50+
no cycle is found."
1251
[packages]
13-
(loop [nodes (keys packages)
14-
node (first nodes)
15-
path #{}]
16-
(when node
17-
(let [package (get packages node)]
18-
(if (seq (:depends-on package))
19-
(if (contains? path node)
20-
(conj (set path) node)
21-
(recur (:depends-on package)
22-
(first (:depends-on package))
23-
(conj path node)))
24-
(recur (rest nodes)
25-
(first (rest nodes))
26-
(disj path node)))))))
52+
(loop [nodes (sort (keys packages))
53+
visited #{}
54+
parent-map {}]
55+
(when-let [node (first nodes)]
56+
(if (contains? visited node)
57+
(recur (rest nodes) visited parent-map)
58+
(let [[cycle visited parent-map]
59+
(find-cycle-path-from packages node #{} visited parent-map)]
60+
(if cycle
61+
cycle
62+
(recur (rest nodes) visited parent-map)))))))
2763

2864
(defn ensure-no-cycles!
2965
"Same as [[find-cycles]] but will throw an exception if any cycles are found."
3066
[packages]
31-
(let [cycles (find-cycles packages)]
32-
(when (seq cycles)
33-
(throw (ex-info "Cicrlar dependencies found"
67+
(let [cycle (find-cycle packages)]
68+
(when cycle
69+
(throw (ex-info "Circular dependencies found"
3470
{:type :kmono/circular-dependencies
35-
:cycles cycles}))))
71+
:cycles [cycle]}))))
3672
packages)
3773

3874
(defn parallel-topo-sort

packages/kmono-core/test/k16/kmono/core/graph_test.clj

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
(ns k16.kmono.core.graph-test
22
(:require
33
[babashka.fs :as fs]
4-
[clojure.test :refer [deftest is use-fixtures]]
4+
[clojure.test :refer [deftest is testing use-fixtures]]
55
[k16.kmono.core.config :as core.config]
66
[k16.kmono.core.graph :as core.graph]
77
[k16.kmono.core.packages :as core.packages]
@@ -14,7 +14,7 @@
1414
(let [config (core.config/resolve-workspace-config *repo*)
1515
packages (core.packages/resolve-packages *repo* config)]
1616

17-
(is (not (core.graph/find-cycles packages)))))
17+
(is (not (core.graph/find-cycle packages)))))
1818

1919
(deftest finds-cycles-test
2020
(fs/write-bytes (fs/file *repo* "packages/a/deps.edn")
@@ -26,9 +26,20 @@
2626
(is (thrown-match?
2727
clojure.lang.ExceptionInfo
2828
{:type :kmono/circular-dependencies
29-
:cycles #{'com.kepler16/a 'com.kepler16/b}}
29+
:cycles '[[com.kepler16/a com.kepler16/b com.kepler16/a]]}
3030
(core.packages/resolve-packages *repo* config)))))
3131

32+
(deftest no-cycles-multi-paths-test
33+
(testing "reproducing a bug where cycles were missed"
34+
(is (= '[com.kepler16/a com.kepler16/b com.kepler16/c com.kepler16/a]
35+
(core.graph/find-cycle
36+
'{com.kepler16/a {:depends-on #{com.kepler16/b}}
37+
com.kepler16/b {:depends-on #{com.kepler16/d
38+
com.kepler16/c}}
39+
com.kepler16/c {:depends-on #{com.kepler16/a}}
40+
41+
com.kepler16/d {:depends-on #{com.kepler16/e}}})))))
42+
3243
(deftest topological-sort-test
3344
(fs/create-dirs (fs/file *repo* "packages/c"))
3445
(fs/write-bytes (fs/file *repo* "packages/c/deps.edn")

0 commit comments

Comments
 (0)