Skip to content

Commit af5713d

Browse files
authored
Merge pull request #859 from jcristau/loop
fix infinite loop in cached_tasks transforms
2 parents ee9db51 + 62af730 commit af5713d

File tree

4 files changed

+56
-31
lines changed

4 files changed

+56
-31
lines changed

src/taskgraph/graph.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,14 +97,18 @@ def _visit(self, reverse):
9797
indegree[dependent] -= 1
9898
if indegree[dependent] == 0:
9999
queue.append(dependent)
100+
loopy_nodes = {node for node, degree in indegree.items() if degree > 0}
101+
if loopy_nodes:
102+
raise Exception(
103+
f"Dependency loop detected involving the following nodes: {loopy_nodes}"
104+
)
100105

101106
def visit_postorder(self):
102107
"""
103108
Generate a sequence of nodes in postorder, such that every node is
104109
visited *after* any nodes it links to.
105110
106-
Behavior is undefined (read: it will hang) if the graph contains a
107-
cycle.
111+
Raises an exception if the graph contains a cycle.
108112
"""
109113
return self._visit(False)
110114

src/taskgraph/transforms/cached_tasks.py

Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@
33
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
44

55

6-
from collections import deque
7-
86
import taskgraph
7+
from taskgraph.graph import Graph
98
from taskgraph.transforms.base import TransformSequence
109
from taskgraph.util.cached_tasks import add_optimization
1110

@@ -15,25 +14,19 @@
1514
def order_tasks(config, tasks):
1615
"""Iterate image tasks in an order where parent tasks come first."""
1716
kind_prefix = config.kind + "-"
18-
19-
pending = deque(tasks)
20-
task_labels = {task["label"] for task in pending}
21-
emitted = set()
22-
while True:
23-
try:
24-
task = pending.popleft()
25-
except IndexError:
26-
break
27-
parents = {
28-
task
29-
for task in task.get("dependencies", {}).values()
30-
if task.startswith(kind_prefix)
31-
}
32-
if parents and not emitted.issuperset(parents & task_labels):
33-
pending.append(task)
34-
continue
35-
emitted.add(task["label"])
36-
yield task
17+
pending = {task["label"]: task for task in tasks}
18+
nodes = set(pending)
19+
edges = {
20+
(label, dep, "")
21+
for label in pending
22+
for dep in pending[label].get("dependencies", {}).values()
23+
if dep.startswith(kind_prefix)
24+
}
25+
graph = Graph(nodes, edges)
26+
27+
for label in graph.visit_postorder():
28+
yield pending.pop(label)
29+
assert not pending
3730

3831

3932
def format_task_digest(cached_task):

test/test_graph.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
import unittest
77

8+
import pytest
9+
810
from taskgraph.graph import Graph
911

1012

@@ -63,6 +65,11 @@ class TestGraph(unittest.TestCase):
6365
},
6466
)
6567

68+
loopy = Graph(
69+
{"A", "B", "C"},
70+
{tuple(x) for x in "ABL BCL CAL".split()}, # codespell:ignore
71+
)
72+
6673
def test_transitive_closure_empty(self):
6774
"transitive closure of an empty set is an empty graph"
6875
g = Graph({"a", "b", "c"}, {("a", "b", "L"), ("a", "c", "L")})
@@ -123,6 +130,10 @@ def test_transitive_closure_linear(self):
123130
"transitive closure of a linear graph includes all nodes in the line"
124131
self.assertEqual(self.linear.transitive_closure({"1"}), self.linear)
125132

133+
def test_transitive_closure_loopy(self):
134+
"transitive closure of a loop is the whole loop"
135+
self.assertEqual(self.loopy.transitive_closure({"A"}), self.loopy)
136+
126137
def test_visit_postorder_empty(self):
127138
"postorder visit of an empty graph is empty"
128139
self.assertEqual(list(Graph(set(), set()).visit_postorder()), [])
@@ -154,6 +165,11 @@ def test_visit_postorder_disjoint(self):
154165
"postorder visit of a disjoint graph satisfies invariant"
155166
self.assert_postorder(self.disjoint.visit_postorder(), self.disjoint.nodes)
156167

168+
def test_visit_postorder_loopy(self):
169+
with pytest.raises(Exception) as excinfo:
170+
list(self.loopy.visit_postorder())
171+
assert "Dependency loop detected" in str(excinfo.value)
172+
157173
def assert_preorder(self, seq, all_nodes):
158174
seen = set()
159175
for e in seq:
@@ -179,6 +195,11 @@ def test_visit_preorder_disjoint(self):
179195
"preorder visit of a disjoint graph satisfies invariant"
180196
self.assert_preorder(self.disjoint.visit_preorder(), self.disjoint.nodes)
181197

198+
def test_visit_preorder_loopy(self):
199+
with pytest.raises(Exception) as excinfo:
200+
list(self.loopy.visit_preorder())
201+
assert "Dependency loop detected" in str(excinfo.value)
202+
182203
def test_links_dict(self):
183204
"link dict for a graph with multiple edges is correct"
184205
self.assertEqual(

test/test_transforms_cached_tasks.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ def assert_cache_basic(tasks):
5555
def assert_cache_with_dependency(tasks):
5656
handle_exception(tasks)
5757
assert len(tasks) == 2
58+
tasks = sorted(tasks, key=lambda t: t["label"])
5859
assert tasks[1] == {
5960
"attributes": {
6061
"cached_task": {
@@ -65,7 +66,7 @@ def assert_cache_with_dependency(tasks):
6566
},
6667
"dependencies": {"edge": "dep-cached"},
6768
"description": "description",
68-
"label": "cached-task",
69+
"label": "cached-with-dep",
6970
"optimization": {
7071
"index-search": [
7172
"test-domain.cache.level-3.cached-task.v2.cache-foo.hash.db201e53944fccbb16736c8153a14de39748c0d290de84bd976c11ddcc413089",
@@ -92,15 +93,16 @@ def assert_cache_with_non_cached_dependency(e):
9293

9394
def assert_chain_of_trust_influences_digest(tasks):
9495
assert len(tasks) == 3
95-
# The first two tasks are chain-of-trust unspecified, and chain-of-trust: False
96+
tasks = sorted(tasks, key=lambda t: t["label"]) # cot-false, cot-true, no-cot
97+
# The first and third tasks are chain-of-trust: false, and chain-of-trust unspecified
9698
# which should result in the same digest.
9799
digest_0 = tasks[0]["attributes"]["cached_task"]["digest"]
98-
digest_1 = tasks[1]["attributes"]["cached_task"]["digest"]
99-
assert digest_0 == digest_1
100-
101-
# The third task is chain-of-trust: True, and should have a different digest
102100
digest_2 = tasks[2]["attributes"]["cached_task"]["digest"]
103-
assert digest_0 != digest_2
101+
assert digest_0 == digest_2
102+
103+
# The second task is chain-of-trust: True, and should have a different digest
104+
digest_1 = tasks[1]["attributes"]["cached_task"]["digest"]
105+
assert digest_0 != digest_1
104106

105107

106108
@pytest.mark.parametrize(
@@ -141,7 +143,8 @@ def assert_chain_of_trust_influences_digest(tasks):
141143
"type": "cached-task.v2",
142144
"name": "cache-foo",
143145
"digest-data": ["abc"],
144-
}
146+
},
147+
"label": "cached-no-dep",
145148
},
146149
# This task has same digest-data, but a dependency on a cached task.
147150
{
@@ -151,6 +154,7 @@ def assert_chain_of_trust_influences_digest(tasks):
151154
"digest-data": ["abc"],
152155
},
153156
"dependencies": {"edge": "dep-cached"},
157+
"label": "cached-with-dep",
154158
},
155159
],
156160
# kind config
@@ -198,6 +202,7 @@ def assert_chain_of_trust_influences_digest(tasks):
198202
"name": "cache-foo",
199203
"digest-data": ["abc"],
200204
},
205+
"label": "no-cot",
201206
# no explicit chain of trust configuration; should be the
202207
# same as when it is set to False
203208
},
@@ -207,6 +212,7 @@ def assert_chain_of_trust_influences_digest(tasks):
207212
"name": "cache-foo",
208213
"digest-data": ["abc"],
209214
},
215+
"label": "cot-false",
210216
"worker": {
211217
"chain-of-trust": False,
212218
},
@@ -217,6 +223,7 @@ def assert_chain_of_trust_influences_digest(tasks):
217223
"name": "cache-foo",
218224
"digest-data": ["abc"],
219225
},
226+
"label": "cot-true",
220227
"worker": {"chain-of-trust": True},
221228
},
222229
],

0 commit comments

Comments
 (0)