Skip to content

Commit

Permalink
Implement cycle detection in transitive_targets, and tolerate cycles …
Browse files Browse the repository at this point in the history
…in file-addresses.

[ci skip-rust-tests]
  • Loading branch information
stuhood committed Jul 20, 2020
1 parent 669b5f3 commit 4ffa262
Show file tree
Hide file tree
Showing 2 changed files with 139 additions and 36 deletions.
89 changes: 62 additions & 27 deletions src/python/pants/engine/internals/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import itertools
import logging
import os.path
from collections import defaultdict, deque
from collections import defaultdict
from dataclasses import dataclass
from pathlib import PurePath
from typing import DefaultDict, Dict, Iterable, List, NamedTuple, Sequence, Set, Tuple, Type, Union
Expand Down Expand Up @@ -61,7 +61,6 @@
TargetsToValidFieldSetsRequest,
TargetsWithOrigins,
TargetWithOrigin,
TransitiveTarget,
TransitiveTargets,
UnrecognizedTargetTypeException,
WrappedTarget,
Expand Down Expand Up @@ -135,37 +134,74 @@ async def resolve_targets_with_origins(
# -----------------------------------------------------------------------------------------------


@rule
async def transitive_target(wrapped_root: WrappedTarget) -> TransitiveTarget:
root = wrapped_root.target
if not root.has_field(Dependencies):
return TransitiveTarget(root, ())
dependency_addresses = await Get(Addresses, DependenciesRequest(root[Dependencies]))
dependencies = await MultiGet(Get(TransitiveTarget, Address, d) for d in dependency_addresses)
return TransitiveTarget(root, dependencies)
class CycleException(Exception):
def __init__(self, subject: Address, path: Tuple[Address, ...]) -> None:
path_string = "\n".join((f"-> {a}" if a == subject else f" {a}") for a in path)
super().__init__(f"Dependency graph contained a cycle:\n{path_string}")
self.subject = subject
self.path = path


def _detect_cycles(
roots: Tuple[Address, ...], dependencies: Dict[Address, Tuple[Address, ...]]
) -> None:
path_stack: OrderedSet[Address] = OrderedSet()
visited: Set[Address] = set()

def visit(address: Address):
if address in visited:
# NB: File-level dependencies are cycle tolerant.
if not address.generated_base_target_name and address in path_stack:
raise CycleException(address, tuple(path_stack) + (address,))
return
path_stack.add(address)
visited.add(address)

for dep_address in dependencies[address]:
visit(dep_address)

path_stack.remove(address)

for root in roots:
visit(root)
if path_stack:
raise AssertionError(
f"The stack of visited nodes should have been empty at the end of recursion, "
f"but it still contained: {path_stack}"
)


@rule
async def transitive_targets(addresses: Addresses) -> TransitiveTargets:
"""Given Addresses, kicks off recursion on expansion of TransitiveTargets.
async def transitive_targets(targets: Targets) -> TransitiveTargets:
"""Find all the targets transitively depended upon by the target roots.
The TransitiveTarget dataclass represents a structure-shared graph, which we walk and flatten
here. The engine memoizes the computation of TransitiveTarget, so when multiple
TransitiveTargets objects are being constructed for multiple roots, their structure will be
shared.
This uses iteration, rather than recursion, so that we can tolerate dependency cycles. Unlike a
traditional BFS algorithm, we batch each round of traversals via `MultiGet` for improved
performance / concurrency.
"""
tts = await MultiGet(Get(TransitiveTarget, Address, a) for a in addresses)
visited: OrderedSet[Target] = OrderedSet()
queued = FrozenOrderedSet(targets)
dependency_mapping: Dict[Address, Tuple[Address, ...]] = {}
while queued:
direct_dependencies = await MultiGet(
Get(Targets, DependenciesRequest(tgt.get(Dependencies))) for tgt in queued
)

dependencies: OrderedSet[Target] = OrderedSet()
to_visit = deque(itertools.chain.from_iterable(tt.dependencies for tt in tts))
while to_visit:
tt = to_visit.popleft()
if tt.root in dependencies:
continue
dependencies.add(tt.root)
to_visit.extend(tt.dependencies)
dependency_mapping.update(
zip(
(t.address for t in queued),
(tuple(t.address for t in deps) for deps in direct_dependencies),
)
)

queued = FrozenOrderedSet(itertools.chain.from_iterable(direct_dependencies)).difference(
visited
)
visited.update(queued)

return TransitiveTargets(tuple(tt.root for tt in tts), FrozenOrderedSet(dependencies))
transitive_targets = TransitiveTargets(tuple(targets), FrozenOrderedSet(visited))
_detect_cycles(tuple(t.address for t in targets), dependency_mapping)
return transitive_targets


# -----------------------------------------------------------------------------------------------
Expand Down Expand Up @@ -912,7 +948,6 @@ def rules():
resolve_target_with_origin,
resolve_targets_with_origins,
# TransitiveTargets
transitive_target,
transitive_targets,
# Owners
find_owners,
Expand Down
86 changes: 77 additions & 9 deletions src/python/pants/engine/internals/graph_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@
TargetsToValidFieldSetsRequest,
TargetsWithOrigins,
TargetWithOrigin,
TransitiveTarget,
TransitiveTargets,
WrappedTarget,
)
Expand Down Expand Up @@ -116,14 +115,6 @@ def get_target(name: str) -> Target:
)
assert direct_deps == Targets([d1, d2, d3])

transitive_target = self.request_single_product(
TransitiveTarget, Params(WrappedTarget(root), create_options_bootstrapper())
)
assert transitive_target.root == root
assert {
dep_transitive_target.root for dep_transitive_target in transitive_target.dependencies
} == {d1, d2, d3}

transitive_targets = self.request_single_product(
TransitiveTargets,
Params(Addresses([root.address, d2.address]), create_options_bootstrapper()),
Expand All @@ -133,6 +124,83 @@ def get_target(name: str) -> Target:
assert transitive_targets.dependencies == FrozenOrderedSet([d1, d2, d3, t2, t1])
assert transitive_targets.closure == FrozenOrderedSet([root, d2, d1, d3, t2, t1])

def test_transitive_targets_tolerates_subtarget_cycles(self) -> None:
"""For generated subtargets, we should tolerate cycles between targets.
This only works with generated subtargets, so we use explicit file dependencies in this
test.
"""
self.create_files("", ["dep.txt", "t1.txt", "t2.txt"])
self.add_to_build_file(
"",
dedent(
"""\
target(name='dep', sources=['dep.txt'])
target(name='t1', sources=['t1.txt'], dependencies=['dep.txt', 't2.txt'])
target(name='t2', sources=['t2.txt'], dependencies=['t1.txt'])
"""
),
)
result = self.request_single_product(
TransitiveTargets,
Params(Addresses([Address.parse("//:t2")]), create_options_bootstrapper()),
)
assert len(result.roots) == 1
assert result.roots[0].address == Address.parse("//:t2")
assert [tgt.address for tgt in result.dependencies] == [
Address("", target_name="t1.txt", generated_base_target_name="t1"),
Address("", target_name="dep.txt", generated_base_target_name="dep"),
Address("", target_name="t2.txt", generated_base_target_name="t2"),
]

def do_test_failed_cycle(self, address_str: str, cyclic_address_str: str) -> None:
with self.assertRaisesRegex(
Exception,
f"(?ms)Dependency graph contained a cycle:.*-> {cyclic_address_str}.*-> {cyclic_address_str}.*",
):
self.request_single_product(
TransitiveTargets,
Params(Addresses([Address.parse(address_str)]), create_options_bootstrapper()),
)

def test_cycle_self(self) -> None:
self.add_to_build_file(
"",
dedent(
"""\
target(name='t1', dependencies=[':t1'])
"""
),
)
self.do_test_failed_cycle("//:t1", "//:t1")

def test_cycle_direct(self) -> None:
self.add_to_build_file(
"",
dedent(
"""\
target(name='t1', dependencies=[':t2'])
target(name='t2', dependencies=[':t1'])
"""
),
)
self.do_test_failed_cycle("//:t1", "//:t1")
self.do_test_failed_cycle("//:t2", "//:t2")

def test_cycle_indirect(self) -> None:
self.add_to_build_file(
"",
dedent(
"""\
target(name='t1', dependencies=[':t2'])
target(name='t2', dependencies=[':t3'])
target(name='t3', dependencies=[':t2'])
"""
),
)
self.do_test_failed_cycle("//:t1", "//:t2")
self.do_test_failed_cycle("//:t2", "//:t2")

def test_resolve_generated_subtarget(self) -> None:
self.add_to_build_file("demo", "target(sources=['f1.txt', 'f2.txt'])")
generated_target_addresss = Address(
Expand Down

0 comments on commit 4ffa262

Please sign in to comment.