Skip to content

Commit 6fd65f6

Browse files
committed
Add BDD-based rules engine trait
This commit updates the smithy-rules-engine package to support binary decision diagrams (BDD) to more efficiently resolve endpoints. We create the BDD by converting the decision tree into a control flow graph (CFG), then compile the CFG to a BDD. The CFG canonicalizes conditions for better sharing (e.g., sorts commutative functions, expands simple string templates, etc), and strips all conditions from results and hash-conses them as well. Later, we'll migrate to emitting the BDD directly in order to shave off many conditions and results that can be simplified. Our decision-tree based rules engine requires deep branching logic to find results. When evaluating the path to a result based on given input, decision trees require descending into a branch, and if at any point a condition in the branch fails, you bail out and go back up to the next branch. This can cause pathological searches of a tree (e.g., 60+ repeated checks on things like isset and booleanEquals to resolve S3 endpoints). In fact, there are currently ~73,000 unique paths through the current decision tree for S3 rules. Using a BDD (a fully reduced one at least) guarantees that we only evaluate any given condition at most once, and only when that condition actually discriminates the result. This is achieved by recursively converting the CFG into BDD nodes using ITE (if-then-else) operations, choosing a variable ordering that honors dependencies between conditions and variable bindings. The BDD builder applies Shannon expansion during ITE operations and uses hash-consing to share common subgraphs. The "bdd" trait has most of the same information as the endpointRuleset trait, but doesn't include "rules". Instead it contains a base64 encoded "nodes" value that contains the zig-zag variable-length encoded node triples, one after the other (this is much more compact and efficient to decode than 1000+ JSON array nodes). The BDD implementation uses CUDD-style complement edges where negative node references represent logical NOT, further reducing BDD size.
1 parent 309c8bd commit 6fd65f6

File tree

104 files changed

+8888
-77
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

104 files changed

+8888
-77
lines changed

config/spotbugs/filter.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,4 +209,10 @@
209209
<Bug code="CT"/>
210210
</Match>
211211

212+
<!-- This is a false positive. Yeah, we have a terminal node that's a singleton, but the ctor is still valid. -->
213+
<Match>
214+
<Class name="software.amazon.smithy.rulesengine.logic.cfg.ResultNode" />
215+
<Bug pattern="SING_SINGLETON_HAS_NONPRIVATE_CONSTRUCTOR" />
216+
</Match>
217+
212218
</FindBugsFilter>

smithy-aws-endpoints/src/main/java/software/amazon/smithy/rulesengine/aws/language/functions/AwsArn.java

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55
package software.amazon.smithy.rulesengine.aws.language.functions;
66

7-
import java.util.Arrays;
7+
import java.util.ArrayList;
88
import java.util.List;
99
import java.util.Objects;
1010
import java.util.Optional;
@@ -39,28 +39,67 @@ private AwsArn(Builder builder) {
3939
* @return the optional ARN.
4040
*/
4141
public static Optional<AwsArn> parse(String arn) {
42-
String[] base = arn.split(":", 6);
43-
if (base.length != 6) {
42+
if (arn == null || arn.length() < 8 || !arn.startsWith("arn:")) {
4443
return Optional.empty();
4544
}
46-
// First section must be "arn".
47-
if (!base[0].equals("arn")) {
45+
46+
// find each of the first five ':' positions
47+
int p0 = 3; // after "arn"
48+
int p1 = arn.indexOf(':', p0 + 1);
49+
if (p1 < 0) {
50+
return Optional.empty();
51+
}
52+
53+
int p2 = arn.indexOf(':', p1 + 1);
54+
if (p2 < 0) {
55+
return Optional.empty();
56+
}
57+
58+
int p3 = arn.indexOf(':', p2 + 1);
59+
if (p3 < 0) {
4860
return Optional.empty();
4961
}
50-
// Sections for partition, service, and resource type must not be empty.
51-
if (base[1].isEmpty() || base[2].isEmpty() || base[5].isEmpty()) {
62+
63+
int p4 = arn.indexOf(':', p3 + 1);
64+
if (p4 < 0) {
65+
return Optional.empty();
66+
}
67+
68+
// extract and validate mandatory parts
69+
String partition = arn.substring(p0 + 1, p1);
70+
String service = arn.substring(p1 + 1, p2);
71+
String region = arn.substring(p2 + 1, p3);
72+
String accountId = arn.substring(p3 + 1, p4);
73+
String resource = arn.substring(p4 + 1);
74+
75+
if (partition.isEmpty() || service.isEmpty() || resource.isEmpty()) {
5276
return Optional.empty();
5377
}
5478

5579
return Optional.of(builder()
56-
.partition(base[1])
57-
.service(base[2])
58-
.region(base[3])
59-
.accountId(base[4])
60-
.resource(Arrays.asList(base[5].split("[:/]", -1)))
80+
.partition(partition)
81+
.service(service)
82+
.region(region)
83+
.accountId(accountId)
84+
.resource(splitResource(resource))
6185
.build());
6286
}
6387

88+
private static List<String> splitResource(String resource) {
89+
List<String> result = new ArrayList<>();
90+
int start = 0;
91+
int length = resource.length();
92+
for (int i = 0; i < length; i++) {
93+
char c = resource.charAt(i);
94+
if (c == ':' || c == '/') {
95+
result.add(resource.substring(start, i));
96+
start = i + 1;
97+
}
98+
}
99+
result.add(resource.substring(start));
100+
return result;
101+
}
102+
64103
/**
65104
* Builder to create an {@link AwsArn} instance.
66105
*

smithy-aws-endpoints/src/main/java/software/amazon/smithy/rulesengine/aws/language/functions/AwsPartition.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,11 @@ public Value evaluate(List<Value> arguments) {
184184
public AwsPartition createFunction(FunctionNode functionNode) {
185185
return new AwsPartition(functionNode);
186186
}
187+
188+
@Override
189+
public int getCostHeuristic() {
190+
return 6;
191+
}
187192
}
188193

189194
/**

smithy-aws-endpoints/src/main/java/software/amazon/smithy/rulesengine/aws/language/functions/IsVirtualHostableS3Bucket.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,11 @@ public Value evaluate(List<Value> arguments) {
9797
public IsVirtualHostableS3Bucket createFunction(FunctionNode functionNode) {
9898
return new IsVirtualHostableS3Bucket(functionNode);
9999
}
100+
101+
@Override
102+
public int getCostHeuristic() {
103+
return 8;
104+
}
100105
}
101106

102107
/**

smithy-aws-endpoints/src/main/java/software/amazon/smithy/rulesengine/aws/language/functions/ParseArn.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,5 +125,10 @@ public Value evaluate(List<Value> arguments) {
125125
public ParseArn createFunction(FunctionNode functionNode) {
126126
return new ParseArn(functionNode);
127127
}
128+
129+
@Override
130+
public int getCostHeuristic() {
131+
return 9;
132+
}
128133
}
129134
}

smithy-rules-engine/src/main/java/software/amazon/smithy/rulesengine/language/Endpoint.java

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -206,21 +206,21 @@ public int hashCode() {
206206
@Override
207207
public String toString() {
208208
StringBuilder sb = new StringBuilder();
209-
sb.append("url: ").append(url).append("\n");
209+
sb.append("url: ").append(url);
210210

211211
if (!headers.isEmpty()) {
212-
sb.append("headers:\n");
212+
sb.append("\nheaders:");
213213
for (Map.Entry<String, List<Expression>> entry : headers.entrySet()) {
214-
sb.append(StringUtils.indent(String.format("%s: %s", entry.getKey(), entry.getValue()), 2))
215-
.append("\n");
214+
sb.append("\n");
215+
sb.append(StringUtils.indent(String.format("%s: %s", entry.getKey(), entry.getValue()), 2));
216216
}
217217
}
218218

219219
if (!properties.isEmpty()) {
220-
sb.append("properties:\n");
220+
sb.append("\nproperties:");
221221
for (Map.Entry<Identifier, Literal> entry : properties.entrySet()) {
222-
sb.append(StringUtils.indent(String.format("%s: %s", entry.getKey(), entry.getValue()), 2))
223-
.append("\n");
222+
sb.append("\n");
223+
sb.append(StringUtils.indent(String.format("%s: %s", entry.getKey(), entry.getValue()), 2));
224224
}
225225
}
226226

smithy-rules-engine/src/main/java/software/amazon/smithy/rulesengine/language/evaluation/RuleEvaluator.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import software.amazon.smithy.rulesengine.language.syntax.expressions.functions.GetAttr;
2020
import software.amazon.smithy.rulesengine.language.syntax.expressions.literal.Literal;
2121
import software.amazon.smithy.rulesengine.language.syntax.parameters.Parameter;
22+
import software.amazon.smithy.rulesengine.language.syntax.parameters.Parameters;
2223
import software.amazon.smithy.rulesengine.language.syntax.rule.Condition;
2324
import software.amazon.smithy.rulesengine.language.syntax.rule.Rule;
2425
import software.amazon.smithy.rulesengine.language.syntax.rule.RuleValueVisitor;
@@ -70,6 +71,21 @@ public Value evaluateRuleSet(EndpointRuleSet ruleset, Map<Identifier, Value> par
7071
});
7172
}
7273

74+
/**
75+
* Configure the rule evaluator with the given parameters and parameter values for manual evaluation.
76+
*
77+
* @param parameters Parameters of the ruleset to evaluate.
78+
* @param parameterArguments Parameter values to evaluate the ruleset against.
79+
* @return the updated evaluator.
80+
*/
81+
public RuleEvaluator withParameters(Parameters parameters, Map<Identifier, Value> parameterArguments) {
82+
for (Parameter parameter : parameters) {
83+
parameter.getDefault().ifPresent(value -> scope.insert(parameter.getName(), value));
84+
}
85+
parameterArguments.forEach(scope::insert);
86+
return this;
87+
}
88+
7389
/**
7490
* Evaluates the given condition in the current scope.
7591
*

smithy-rules-engine/src/main/java/software/amazon/smithy/rulesengine/language/evaluation/value/ArrayValue.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,4 +101,13 @@ public String toString() {
101101
}
102102
return "[" + String.join(", ", valueStrings) + "]";
103103
}
104+
105+
@Override
106+
public Object toObject() {
107+
List<Object> result = new ArrayList<>(values.size());
108+
for (Value value : values) {
109+
result.add(value.toObject());
110+
}
111+
return result;
112+
}
104113
}

smithy-rules-engine/src/main/java/software/amazon/smithy/rulesengine/language/evaluation/value/BooleanValue.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,4 +68,9 @@ public int hashCode() {
6868
public String toString() {
6969
return String.valueOf(value);
7070
}
71+
72+
@Override
73+
public Object toObject() {
74+
return value;
75+
}
7176
}

smithy-rules-engine/src/main/java/software/amazon/smithy/rulesengine/language/evaluation/value/EmptyValue.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,9 @@ public Node toNode() {
3535
public String toString() {
3636
return "<empty>";
3737
}
38+
39+
@Override
40+
public Object toObject() {
41+
return null;
42+
}
3843
}

0 commit comments

Comments
 (0)