com.indeed.proctor.common.TestDependencies Maven / Gradle / Ivy
package com.indeed.proctor.common;
import com.google.common.collect.HashMultimap;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Multimap;
import com.indeed.proctor.common.model.ConsumableTestDefinition;
import com.indeed.proctor.common.model.TestDependency;
import java.util.ArrayDeque;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Queue;
import java.util.Set;
import java.util.stream.Collectors;
/** A collection of utility functions for test dependencies */
public class TestDependencies {
private TestDependencies() {}
/**
* Returns test names and reasons of all tests with invalid dependency relationship. It's
* invalid when a test directly or indirectly depends on
*
*
* - an unknown test, or
*
- a test with circular dependency (depending on itself), or
*
- a test with a different test type, or
*
- a test with the same salt, or
*
- a bucket undefined in the test
*
*
* It's expected to be used in test matrix loading time to filter out invalid test definitions.
*/
public static Map validateDependenciesAndReturnReasons(
final Map testDefinitions) {
final Set traversed = new HashSet<>();
final Map errorReasonMap = new HashMap<>();
for (final String testName :
traverseDependencyTreesBFS(
testDefinitions,
testsWithoutDependencyOrUnknownDependency(testDefinitions))) {
final TestDependency dependsOn = testDefinitions.get(testName).getDependsOn();
final boolean isParentInvalid =
(dependsOn != null) && errorReasonMap.containsKey(dependsOn.getTestName());
final Optional errorReason =
validateDependencyAndReturnReason(
testName, testDefinitions.get(testName), testDefinitions);
traversed.add(testName);
if (errorReason.isPresent()) {
errorReasonMap.put(testName, errorReason.get());
} else if (isParentInvalid) {
errorReasonMap.put(
testName,
"A test "
+ testName
+ " directly or indirectly depends on an invalid test");
}
}
for (final String testName : testDefinitions.keySet()) {
if (!traversed.contains(testName)) {
errorReasonMap.put(
testName,
"A test " + testName + " depends on a test with circular dependency");
}
}
return ImmutableMap.copyOf(errorReasonMap);
}
/**
* Validate a direct dependency relationship of the given test and returns reason if the
* dependency is invalid.
*
* It's expected to be used by Proctor Webapp to validate a test modification or deletion.
*/
public static Optional validateDependencyAndReturnReason(
final String testName,
final ConsumableTestDefinition definition,
final Map testDefinitions) {
final TestDependency dependsOn = definition.getDependsOn();
if (dependsOn == null) {
return Optional.empty();
}
final String parentName = dependsOn.getTestName();
if (testName.equals(parentName)) {
return Optional.of("A test " + testName + " depends on itself");
}
final ConsumableTestDefinition parentDefinition = testDefinitions.get(parentName);
if (parentDefinition == null) {
return Optional.of(
"A test "
+ testName
+ " depends on an unknown or incompatible test "
+ dependsOn.getTestName());
}
/*
Using different test type could cause bias. However, Some specific cases allow using different test types.
*/
if (!definition.getTestType().isAllowedDependency(parentDefinition.getTestType())) {
return Optional.of(
"A test "
+ testName
+ " depends on "
+ parentName
+ " with different test type: expected "
+ definition.getTestType().allowedDependenciesToString()
+ " but "
+ parentDefinition.getTestType());
}
/*
Using the same salt will cause confusing behavior
E.g. If test X and Y has 50% control / 50% active and shares the same salt,
Y will be 100% active when Y depends on X's active.
*/
if (Objects.equals(definition.getSalt(), parentDefinition.getSalt())) {
// FIXME: This has to check indirect parents to cover more invalid cases
return Optional.of(
"A test "
+ testName
+ " depends on "
+ parentName
+ " with the same salt: "
+ parentDefinition.getSalt());
}
/*
Depending on negative bucket value is prohibited to avoid potential issues with fallback or logging behavior
*/
if (dependsOn.getBucketValue() < 0) {
return Optional.of(
"A test "
+ testName
+ " depends on negative bucket value "
+ dependsOn.getBucketValue()
+ " of "
+ dependsOn.getTestName());
}
final boolean isBucketDefined =
parentDefinition.getBuckets().stream()
.anyMatch(x -> x.getValue() == dependsOn.getBucketValue());
if (!isBucketDefined) {
return Optional.of(
"A test "
+ testName
+ " depends on "
+ "an undefined bucket "
+ dependsOn.getBucketValue());
}
return Optional.empty();
}
/**
* Returns a list of all test names in arbitrary order satisfying the following condition - If
* test X depends on test Y, test Y comes earlier than X in the list.
*
* It's expected to be used in test matrix loading time to precompute evaluation order.
*
* @throws IllegalArgumentException when it detects circular dependency or dependency to unknown
* test
*/
public static List determineEvaluationOrder(
final Map testDefinitions) {
final List evaluationOrder =
traverseDependencyTreesBFS(
testDefinitions, testsWithoutDependency(testDefinitions));
if (evaluationOrder.size() != testDefinitions.size()) {
throw new IllegalArgumentException(
"Detected invalid dependency links. Unable to determine order");
}
return evaluationOrder;
}
/**
* Returns maximum length (number of edges) of dependency chains through the given test.
*
* It's expected to be used by Proctor Webapp to limit the allowed depth in edit.
*/
public static int computeMaximumDependencyChains(
final Map testDefinitions, final String testName) {
final Map depthMap = new HashMap<>();
for (final String name :
traverseDependencyTreesBFS(testDefinitions, ImmutableSet.of(testName))) {
final ConsumableTestDefinition definition = testDefinitions.get(name);
if (testName.equals(name) || (definition.getDependsOn() == null)) {
depthMap.put(name, 0);
} else {
depthMap.put(name, depthMap.get(definition.getDependsOn().getTestName()) + 1);
}
}
final int childDepth = depthMap.values().stream().max(Comparator.naturalOrder()).orElse(0);
final int parentNums =
computeTransitiveDependencies(testDefinitions, ImmutableSet.of(testName)).size()
- 1;
return parentNums + childDepth;
}
/**
* Returns all test names required to evaluate all the given tests. It runs in linear time to
* the size of response instead of all tests.
*
* @throws IllegalArgumentException when it detects dependency on an unknown name
*/
public static Set computeTransitiveDependencies(
final Map testDefinitions,
final Set testNames) {
testNames.stream()
.filter(testName -> !testDefinitions.containsKey(testName))
.findAny()
.ifPresent(
(testName) -> {
throw new IllegalArgumentException(
"BUG: unknown test name " + testName + " is given");
});
final Queue testNameQueue = new ArrayDeque<>(testNames);
final Set transitiveDependencies = new HashSet<>(testNames);
while (!testNameQueue.isEmpty()) {
final String testName = testNameQueue.poll();
final ConsumableTestDefinition testDefinition = testDefinitions.get(testName);
if (testDefinition == null) {
throw new IllegalArgumentException(
"Detected dependency on an unknown test " + testName);
}
final TestDependency dependsOn = testDefinition.getDependsOn();
if (dependsOn == null) {
continue;
}
final String parentTestName = dependsOn.getTestName();
if (transitiveDependencies.contains(parentTestName)) {
continue;
}
testNameQueue.add(parentTestName);
transitiveDependencies.add(parentTestName);
}
return ImmutableSet.copyOf(transitiveDependencies);
}
/**
* Returns visiting order of proctor tests by breadth-first search in a graph where a edge from
* X to Y is added if Y depends on X starting from given tests
*/
private static List traverseDependencyTreesBFS(
final Map testDefinitions,
final Set sourceTestNames) {
sourceTestNames.stream()
.filter(testName -> !testDefinitions.containsKey(testName))
.findAny()
.ifPresent(
(testName) -> {
throw new IllegalArgumentException(
"BUG: unknown test name " + testName + " is given");
});
final Multimap parentToChildrenMap = HashMultimap.create();
testDefinitions.forEach(
(testName, definition) -> {
if (definition.getDependsOn() != null) {
parentToChildrenMap.put(definition.getDependsOn().getTestName(), testName);
}
});
final Queue testNameQueue = new ArrayDeque<>(sourceTestNames);
final Set testNameSet = new HashSet<>();
final ImmutableList.Builder builder = ImmutableList.builder();
while (!testNameQueue.isEmpty()) {
final String testName = testNameQueue.poll();
if (!testNameSet.add(testName)) {
throw new IllegalArgumentException(
"BUG: circular dependency detect around " + testName);
}
builder.add(testName);
testNameQueue.addAll(parentToChildrenMap.get(testName));
}
return builder.build();
}
private static Set testsWithoutDependency(
final Map testDefinitions) {
return testDefinitions.entrySet().stream()
.filter(entry -> entry.getValue().getDependsOn() == null)
.map(Map.Entry::getKey)
.collect(Collectors.toSet());
}
private static Set testsWithoutDependencyOrUnknownDependency(
final Map testDefinitions) {
return testDefinitions.entrySet().stream()
.filter(
entry ->
(entry.getValue().getDependsOn() == null)
|| !testDefinitions.containsKey(
entry.getValue().getDependsOn().getTestName()))
.map(Map.Entry::getKey)
.collect(Collectors.toSet());
}
}