Skip to content

Commit f794a27

Browse files
Throw CycleException when cycles are detected
This changes the DependencyGraph's topological sort to throw a dedicated exception class so that it can be better handled.
1 parent 2529ebd commit f794a27

File tree

10 files changed

+136
-9
lines changed

10 files changed

+136
-9
lines changed

smithy-build/src/main/java/software/amazon/smithy/build/SmithyBuildImpl.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import software.amazon.smithy.model.node.ObjectNode;
3434
import software.amazon.smithy.model.transform.ModelTransformer;
3535
import software.amazon.smithy.model.validation.ValidatedResult;
36+
import software.amazon.smithy.utils.CycleException;
3637
import software.amazon.smithy.utils.DependencyGraph;
3738
import software.amazon.smithy.utils.Pair;
3839
import software.amazon.smithy.utils.SmithyBuilder;
@@ -242,7 +243,7 @@ private List<ResolvedPlugin> resolvePlugins(String projectionName, ProjectionCon
242243
List<PluginId> sorted;
243244
try {
244245
sorted = dependencyGraph.toSortedList();
245-
} catch (IllegalStateException e) {
246+
} catch (CycleException e) {
246247
throw new SmithyBuildException(e.getMessage(), e);
247248
}
248249

smithy-build/src/test/java/software/amazon/smithy/build/SmithyBuildTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -624,7 +624,7 @@ public void detectsPluginCycles() throws Exception {
624624
.build());
625625

626626
SmithyBuildException e = assertThrows(SmithyBuildException.class, builder::build);
627-
assertThat(e.getMessage(), containsString("Cycle(s) detected in dependency graph"));
627+
assertThat(e.getMessage(), containsString("Cycle(s) detected"));
628628
}
629629

630630
@Test

smithy-build/src/test/java/software/amazon/smithy/build/TimestampPlugin1.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ public void execute(PluginContext context) {
2020
} catch (InterruptedException ignored) {}
2121
}
2222

23+
// This is made serial to protect against test failures if we decide later to
24+
// have plugins run in parallel. These plugins MUST run serially with respect
25+
// to each other to function.
2326
@Override
2427
public boolean isSerial() {
2528
return true;

smithy-build/src/test/java/software/amazon/smithy/build/TimestampPlugin2.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ public List<String> runBefore() {
2727
return ListUtils.of("timestampPlugin1");
2828
}
2929

30+
// This is made serial to protect against test failures if we decide later to
31+
// have plugins run in parallel. These plugins MUST run serially with respect
32+
// to each other to function.
3033
@Override
3134
public boolean isSerial() {
3235
return true;

smithy-build/src/test/java/software/amazon/smithy/build/TimestampPlugin3.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ public List<String> runAfter() {
2727
return ListUtils.of("timestampPlugin1");
2828
}
2929

30+
// This is made serial to protect against test failures if we decide later to
31+
// have plugins run in parallel. These plugins MUST run serially with respect
32+
// to each other to function.
3033
@Override
3134
public boolean isSerial() {
3235
return true;

smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/IntegrationTopologicalSort.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import java.util.List;
1111
import java.util.Map;
1212
import java.util.logging.Logger;
13+
import software.amazon.smithy.utils.CycleException;
1314
import software.amazon.smithy.utils.DependencyGraph;
1415

1516
final class IntegrationTopologicalSort<I extends SmithyIntegration<?, ?, ?>> {
@@ -46,7 +47,7 @@ List<I> sort() {
4647
List<String> sorted;
4748
try {
4849
sorted = dependencyGraph.toSortedList(this::compareIntegrations);
49-
} catch (IllegalStateException e) {
50+
} catch (CycleException e) {
5051
throw new IllegalArgumentException(e);
5152
}
5253
for (String name : sorted) {
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
package software.amazon.smithy.utils;
6+
7+
import java.util.List;
8+
import java.util.Set;
9+
import java.util.stream.Collectors;
10+
11+
/**
12+
* Signals that one or more cycles have been detected when attempting to topologically
13+
* sort shapes in a {@link DependencyGraph}.
14+
*/
15+
public class CycleException extends RuntimeException {
16+
private final List<?> sortedNodes;
17+
private final Set<?> cyclicNodes;
18+
private final Class<?> nodeType;
19+
20+
/**
21+
* Constructs a CycleException.
22+
*
23+
* @param sortedNodes A list of the nodes that were sorted successfully.
24+
* @param cyclicNodes A set of nodes that could not be sorted due to being part of a cycle.
25+
* @param <T> The type of the node.
26+
*/
27+
public <T> CycleException(List<T> sortedNodes, Set<T> cyclicNodes) {
28+
super(String.format("Cycle(s) detected amongst: [%s]",
29+
cyclicNodes.stream().map(Object::toString).collect(Collectors.joining(", "))));
30+
this.sortedNodes = ListUtils.copyOf(sortedNodes);
31+
this.cyclicNodes = SetUtils.orderedCopyOf(cyclicNodes);
32+
if (this.cyclicNodes.isEmpty()) {
33+
throw new IllegalArgumentException("Cyclic nodes cannot be empty");
34+
}
35+
this.nodeType = this.cyclicNodes.iterator().next().getClass();
36+
}
37+
38+
/**
39+
* Gets the set of nodes that are part of a cycle.
40+
*
41+
* <p>This contains all nodes that are a part of any cycles. To see a list of
42+
* individual cycles, use {@link DependencyGraph#findCycles()}.
43+
*
44+
* @param expectedNodeType The expected type of the node, which will be checked to
45+
* be compatible with the actual type. This is necessary because
46+
* exceptions can't be generic.
47+
* @return Returns a set of cyclic nodes.
48+
* @param <T> The type of the graph's nodes.
49+
*/
50+
@SuppressWarnings("unchecked")
51+
public <T> Set<T> getCyclicNodes(Class<T> expectedNodeType) {
52+
if (expectedNodeType.isAssignableFrom(this.nodeType)) {
53+
return (Set<T>) cyclicNodes;
54+
}
55+
throw new IllegalArgumentException(String.format(
56+
"Expected node type %s is not assignable from actual node type %s",
57+
expectedNodeType.getName(),
58+
this.nodeType.getName()));
59+
}
60+
61+
/**
62+
* Gets the list of nodes that could be sorted.
63+
*
64+
* @param expectedNodeType The expected type of the node, which will be checked to
65+
* be compatible with the actual type. This is necessary because
66+
* exceptions can't be generic.
67+
* @return Returns the sorted list of non-cyclic nodes.
68+
* @param <T> The type of the graph's nodes.
69+
*/
70+
@SuppressWarnings("unchecked")
71+
public <T> List<T> getSortedNodes(Class<T> expectedNodeType) {
72+
if (expectedNodeType.isAssignableFrom(this.nodeType)) {
73+
return (List<T>) sortedNodes;
74+
}
75+
throw new IllegalArgumentException(String.format(
76+
"Expected node type %s is not assignable from actual node type %s",
77+
expectedNodeType.getName(),
78+
this.nodeType.getName()));
79+
}
80+
}

smithy-utils/src/main/java/software/amazon/smithy/utils/DependencyGraph.java

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -349,15 +349,13 @@ private List<T> toSortedList(Queue<T> satisfied) {
349349

350350
// Check for cycles.
351351
if (result.size() != reverseDependencies.size()) {
352-
List<String> remaining = new ArrayList<>(reverseDependencies.size() - result.size());
352+
Set<T> remaining = new LinkedHashSet<>(reverseDependencies.size() - result.size());
353353
for (T node : reverseDependencies.keySet()) {
354354
if (!result.contains(node)) {
355-
remaining.add(node.toString());
355+
remaining.add(node);
356356
}
357357
}
358-
throw new IllegalStateException(
359-
String.format("Cycle(s) detected in dependency graph while attempting to sort among [%s]",
360-
String.join(", ", remaining)));
358+
throw new CycleException(result, remaining);
361359
}
362360

363361
return result;
@@ -402,4 +400,5 @@ public void clear() {
402400
forwardDependencies.clear();
403401
reverseDependencies.clear();
404402
}
403+
405404
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
package software.amazon.smithy.utils;
6+
7+
import static org.junit.jupiter.api.Assertions.assertThrows;
8+
9+
import java.util.Collections;
10+
import java.util.List;
11+
import java.util.Set;
12+
import org.junit.jupiter.api.Test;
13+
14+
public class CycleExceptionTest {
15+
@Test
16+
public void requiresCycles() {
17+
assertThrows(IllegalArgumentException.class,
18+
() -> new CycleException(Collections.emptyList(), Collections.emptySet()));
19+
}
20+
21+
@Test
22+
public void checksNodeTypes() {
23+
CycleException cycleException = new CycleException(ListUtils.of("foo"), SetUtils.of("bar"));
24+
List<String> sortedStrings = cycleException.getSortedNodes(String.class);
25+
Set<String> cyclicStrings = cycleException.getCyclicNodes(String.class);
26+
27+
List<Object> sortedObjects = cycleException.getSortedNodes(Object.class);
28+
Set<Object> cyclicObjects = cycleException.getCyclicNodes(Object.class);
29+
30+
assertThrows(IllegalArgumentException.class, () -> cycleException.getSortedNodes(Integer.class));
31+
assertThrows(IllegalArgumentException.class, () -> cycleException.getCyclicNodes(Integer.class));
32+
}
33+
}

smithy-utils/src/test/java/software/amazon/smithy/utils/DependencyGraphTest.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,10 +230,14 @@ public void topologicallySortsWithCustomComparator() {
230230
@Test()
231231
public void sortedListThrowsErrorOnCycle() {
232232
DependencyGraph<String> graph = new DependencyGraph<>();
233+
graph.add("foo");
234+
graph.addDependency("bar", "foo");
233235
graph.addDependency("spam", "eggs");
234236
graph.addDependency("eggs", "spam");
235237

236-
assertThrows(IllegalStateException.class, graph::toSortedList);
238+
CycleException exception = assertThrows(CycleException.class, graph::toSortedList);
239+
assertThat(exception.getSortedNodes(String.class), contains("foo", "bar"));
240+
assertThat(exception.getCyclicNodes(String.class), containsInAnyOrder("spam", "eggs"));
237241
}
238242

239243
@Test

0 commit comments

Comments
 (0)