Skip to content

Commit 0f0c440

Browse files
chrfwowtoddbaert
andauthored
feat!: fractional bucketing improvements (#1740)
Signed-off-by: christian.lutnik <christian.lutnik@dynatrace.com> Signed-off-by: Todd Baert <todd.baert@dynatrace.com> Co-authored-by: Todd Baert <todd.baert@dynatrace.com>
1 parent 8f6b6c2 commit 0f0c440

21 files changed

Lines changed: 361 additions & 46 deletions

File tree

providers/flagd/schemas

providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFileTest.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,17 @@
2828
@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "dev.openfeature.contrib.providers.flagd.e2e.steps")
2929
@ConfigurationParameter(key = OBJECT_FACTORY_PROPERTY_NAME, value = "io.cucumber.picocontainer.PicoFactory")
3030
@IncludeTags("file")
31-
@ExcludeTags({"unixsocket", "targetURI", "reconnect", "customCert", "events", "contextEnrichment", "deprecated"})
31+
@ExcludeTags({
32+
"unixsocket",
33+
"targetURI",
34+
"reconnect",
35+
"customCert",
36+
"events",
37+
"contextEnrichment",
38+
"fractional-v1",
39+
"deprecated",
40+
"operator-errors"
41+
})
3242
@Testcontainers
3343
public class RunFileTest {
3444

providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunInProcessTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "dev.openfeature.contrib.providers.flagd.e2e.steps")
2929
@ConfigurationParameter(key = OBJECT_FACTORY_PROPERTY_NAME, value = "io.cucumber.picocontainer.PicoFactory")
3030
@IncludeTags("in-process")
31-
@ExcludeTags({"unixsocket", "deprecated"})
31+
@ExcludeTags({"unixsocket", "fractional-v1", "deprecated", "operator-errors"})
3232
@Testcontainers
3333
public class RunInProcessTest {
3434

providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunRpcTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "dev.openfeature.contrib.providers.flagd.e2e.steps")
2929
@ConfigurationParameter(key = OBJECT_FACTORY_PROPERTY_NAME, value = "io.cucumber.picocontainer.PicoFactory")
3030
@IncludeTags({"rpc"})
31-
@ExcludeTags({"unixsocket", "deprecated"})
31+
@ExcludeTags({"unixsocket", "fractional-v1", "deprecated", "operator-errors"})
3232
@Testcontainers
3333
public class RunRpcTest {
3434

providers/flagd/test-harness

tools/flagd-core/schemas

tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java

Lines changed: 55 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,16 @@
1717
@Slf4j
1818
class Fractional implements PreEvaluatedArgumentsExpression {
1919

20+
static final int MAX_WEIGHT = Integer.MAX_VALUE;
21+
2022
@Override
2123
public String key() {
2224
return "fractional";
2325
}
2426

2527
@Override
2628
public Object evaluate(List arguments, Object data, String jsonPath) throws JsonLogicEvaluationException {
27-
if (arguments.size() < 2) {
29+
if (arguments.size() < 1) {
2830
return null;
2931
}
3032

@@ -39,7 +41,6 @@ public Object evaluate(List arguments, Object data, String jsonPath) throws Json
3941
if (arg1 instanceof String) {
4042
// first arg is a String, use for bucketing
4143
bucketBy = (String) arg1;
42-
4344
Object[] source = arguments.toArray();
4445
distributions = Arrays.copyOfRange(source, 1, source.length);
4546
} else {
@@ -54,7 +55,7 @@ public Object evaluate(List arguments, Object data, String jsonPath) throws Json
5455
}
5556

5657
final List<FractionProperty> propertyList = new ArrayList<>();
57-
int totalWeight = 0;
58+
long totalWeight = 0;
5859

5960
try {
6061
for (Object dist : distributions) {
@@ -67,34 +68,54 @@ public Object evaluate(List arguments, Object data, String jsonPath) throws Json
6768
return null;
6869
}
6970

71+
if (totalWeight > MAX_WEIGHT) {
72+
log.debug("Total weight {} exceeds maximum allowed value {}", totalWeight, MAX_WEIGHT);
73+
return null;
74+
}
75+
76+
if (totalWeight == 0) {
77+
log.debug("Total weight is 0, no valid distribution possible");
78+
return null;
79+
}
80+
7081
// find distribution
71-
return distributeValue(bucketBy, propertyList, totalWeight, jsonPath);
82+
return distributeValue(bucketBy, propertyList, (int) totalWeight, jsonPath);
7283
}
7384

74-
private static String distributeValue(
75-
final String hashKey, final List<FractionProperty> propertyList, int totalWeight, String jsonPath)
85+
private static Object distributeValue(
86+
final String hashKey,
87+
final List<FractionProperty> propertyList,
88+
final int totalWeight,
89+
final String jsonPath)
7690
throws JsonLogicEvaluationException {
7791
byte[] bytes = hashKey.getBytes(StandardCharsets.UTF_8);
7892
int mmrHash = MurmurHash3.hash32x86(bytes, 0, bytes.length, 0);
79-
float bucket = Math.abs(mmrHash) * 1.0f / Integer.MAX_VALUE * 100;
93+
return distributeValueFromHash(mmrHash, propertyList, totalWeight, jsonPath);
94+
}
8095

81-
float bucketSum = 0;
96+
static Object distributeValueFromHash(
97+
final int hash, final List<FractionProperty> propertyList, final int totalWeight, final String jsonPath)
98+
throws JsonLogicEvaluationException {
99+
long longHash = Integer.toUnsignedLong(hash);
100+
int bucket = (int) ((longHash * totalWeight) >>> 32);
101+
102+
int bucketSum = 0;
82103
for (FractionProperty p : propertyList) {
83-
bucketSum += p.getPercentage(totalWeight);
104+
bucketSum += p.weight;
84105

85106
if (bucket < bucketSum) {
86107
return p.getVariant();
87108
}
88109
}
89110

90111
// this shall not be reached
91-
throw new JsonLogicEvaluationException("Unable to find a correct bucket", jsonPath);
112+
throw new JsonLogicEvaluationException("Unable to find a correct bucket for hash " + hash, jsonPath);
92113
}
93114

94115
@Getter
95116
@SuppressWarnings({"checkstyle:NoFinalizer"})
96-
private static class FractionProperty {
97-
private final String variant;
117+
static class FractionProperty {
118+
private final Object variant;
98119
private final int weight;
99120

100121
protected final void finalize() {
@@ -112,29 +133,38 @@ protected final void finalize() {
112133
throw new JsonLogicException("Fraction property needs at least one element", jsonPath);
113134
}
114135

115-
// first must be a string
116-
if (!(array.get(0) instanceof String)) {
136+
// variant must be a primitive (string, number, boolean) or null;
137+
// nested JSONLogic expressions are pre-evaluated to these types
138+
Object first = array.get(0);
139+
if (first instanceof String || first instanceof Number || first instanceof Boolean || first == null) {
140+
variant = first;
141+
} else {
117142
throw new JsonLogicException(
118-
"First element of the fraction property is not a string variant", jsonPath);
143+
"First element of the fraction property must resolve to a string, number, boolean, or null",
144+
jsonPath);
119145
}
120146

121-
variant = (String) array.get(0);
122147
if (array.size() >= 2) {
123-
// second element must be a number
148+
// weight must be a number
124149
if (!(array.get(1) instanceof Number)) {
125150
throw new JsonLogicException("Second element of the fraction property is not a number", jsonPath);
126151
}
127-
weight = ((Number) array.get(1)).intValue();
152+
Number rawWeight = (Number) array.get(1);
153+
154+
// weights must be integers
155+
double weightDouble = rawWeight.doubleValue();
156+
if (Double.isInfinite(weightDouble)
157+
|| Double.isNaN(weightDouble)
158+
|| weightDouble != Math.floor(weightDouble)) {
159+
throw new JsonLogicException("Weights must be integers", jsonPath);
160+
}
161+
162+
// negative weights can be the result of rollout calculations,
163+
// so we clamp to 0 rather than throwing an error
164+
weight = Math.max(0, (int) weightDouble);
128165
} else {
129166
weight = 1;
130167
}
131168
}
132-
133-
float getPercentage(int totalWeight) {
134-
if (weight == 0) {
135-
return 0;
136-
}
137-
return (float) (weight * 100) / totalWeight;
138-
}
139169
}
140170
}

tools/flagd-core/src/main/resources/flagd/schemas/targeting.json

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
"type": "string"
3636
},
3737
{
38-
"description": "When returned from rules, strings are used to as keys to retrieve the associated value from the \"variants\" object. Be sure that the returned string is present as a key in the variants!.",
38+
"description": "When returned from rules, the behavior of arrays is not defined.",
3939
"type": "array"
4040
}
4141
]
@@ -461,18 +461,26 @@
461461
"maxItems": 2,
462462
"items": [
463463
{
464-
"description": "If this bucket is randomly selected, this string is used to as a key to retrieve the associated value from the \"variants\" object.",
465-
"type": "string"
464+
"description": "If this bucket is randomly selected, this JSONLogic will be evaluated, and the result will be used as the variant key to return from the variants map.",
465+
"$ref": "#/definitions/args"
466466
},
467467
{
468-
"description": "Weighted distribution for this variant key.",
469-
"type": "number"
468+
"description": "Weighted distribution for this variant key. Must be a non-negative integer. Can be a JSONLogic expression that evaluates to a number (e.g. for time-based progressive rollouts); computed negative weights are clamped to 0 at evaluation time. The total weight sum across all variants must not exceed 2,147,483,647.",
469+
"oneOf": [
470+
{
471+
"type": "integer",
472+
"minimum": 0
473+
},
474+
{
475+
"$ref": "#/definitions/anyRule"
476+
}
477+
]
470478
}
471479
]
472480
},
473481
"fractionalOp": {
474482
"type": "array",
475-
"minItems": 3,
483+
"minItems": 1,
476484
"$comment": "there seems to be a bug here, where ajv gives a warning (not an error) because maxItems doesn't equal the number of entries in items, though this is valid in this case",
477485
"items": [
478486
{
@@ -492,7 +500,7 @@
492500
},
493501
"fractionalShorthandOp": {
494502
"type": "array",
495-
"minItems": 2,
503+
"minItems": 1,
496504
"items": {
497505
"$ref": "#/definitions/fractionalWeightArg"
498506
}

tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/e2e/FlagdCoreEvaluatorTest.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
import dev.openfeature.contrib.tools.flagd.api.Evaluator;
44
import dev.openfeature.contrib.tools.flagd.api.testkit.AbstractEvaluatorTest;
55
import dev.openfeature.contrib.tools.flagd.core.FlagdCore;
6+
import org.junit.platform.suite.api.ExcludeTags;
67

78
/**
89
* Compliance test suite for {@link FlagdCore} — the reference implementation of the
910
* flagd-api-testkit. Extends {@link AbstractEvaluatorTest} which provides all runner
1011
* configuration. Registered as an {@link dev.openfeature.contrib.tools.flagd.api.testkit.EvaluatorFactory}
1112
* via {@code META-INF/services}.
1213
*/
14+
@ExcludeTags({"fractional-v1"})
1315
public class FlagdCoreEvaluatorTest extends AbstractEvaluatorTest {
1416

1517
@Override

0 commit comments

Comments
 (0)