Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package datadog.trace.api.openfeature;

import static java.util.Collections.emptyList;
import static java.util.Collections.singletonList;
import static java.util.Collections.singletonMap;
import static java.util.concurrent.TimeUnit.NANOSECONDS;
import static java.util.concurrent.TimeUnit.SECONDS;

import datadog.trace.api.featureflag.ufc.v1.Allocation;
import datadog.trace.api.featureflag.ufc.v1.ConditionConfiguration;
import datadog.trace.api.featureflag.ufc.v1.ConditionOperator;
import datadog.trace.api.featureflag.ufc.v1.Flag;
import datadog.trace.api.featureflag.ufc.v1.Rule;
import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration;
import datadog.trace.api.featureflag.ufc.v1.Shard;
import datadog.trace.api.featureflag.ufc.v1.ShardRange;
import datadog.trace.api.featureflag.ufc.v1.Split;
import datadog.trace.api.featureflag.ufc.v1.ValueType;
import datadog.trace.api.featureflag.ufc.v1.Variant;
import dev.openfeature.sdk.MutableContext;
import java.util.List;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;
import org.openjdk.jmh.infra.Blackhole;

/**
* Measures evaluator rule costs that run on the application thread.
*
* <p>The static case is the control. The regex and shard cases differ only in the rule work that
* selects the same boolean variation.
*
* <p>Run: {@code ./gradlew :products:feature-flagging:feature-flagging-api:jmh
* -PjmhIncludes=FlagEvaluationRuleBenchmark -PjmhProf=gc}.
*/
@State(Scope.Benchmark)
@Warmup(iterations = 3, time = 2, timeUnit = SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = SECONDS)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(NANOSECONDS)
@Fork(1)
public class FlagEvaluationRuleBenchmark {

private DDEvaluator staticEvaluator;
private DDEvaluator regexEvaluator;
private DDEvaluator shardEvaluator;
private MutableContext context;

@Setup
public void setUp() {
context = new MutableContext("benchmark-subject-0123456789");
context.add("email", "benchmark@datadoghq.com");

staticEvaluator = evaluator(staticAllocation());
regexEvaluator = evaluator(regexAllocation());
shardEvaluator = evaluator(shardAllocation());
}

@Benchmark
public void staticRule(final Blackhole blackhole) {
blackhole.consume(staticEvaluator.evaluate(Boolean.class, "bench", false, context));
}

@Benchmark
public void regexRule(final Blackhole blackhole) {
blackhole.consume(regexEvaluator.evaluate(Boolean.class, "bench", false, context));
}

@Benchmark
public void shardRule(final Blackhole blackhole) {
blackhole.consume(shardEvaluator.evaluate(Boolean.class, "bench", false, context));
}

private static DDEvaluator evaluator(final Allocation allocation) {
final Flag flag =
new Flag(
"bench",
true,
ValueType.BOOLEAN,
singletonMap("on", new Variant("on", true)),
singletonList(allocation));
final DDEvaluator evaluator = new DDEvaluator(() -> {});
evaluator.accept(new ServerConfiguration("", "", false, null, singletonMap("bench", flag)));
return evaluator;
}

private static Allocation staticAllocation() {
return allocation(emptyList(), new Split(emptyList(), "on", null, null));
}

private static Allocation regexAllocation() {
final ConditionConfiguration condition =
new ConditionConfiguration(
ConditionOperator.MATCHES, "email", "^[[:alnum:]._%+-]+@datadoghq[.]com$");
condition.cacheRegexPattern();
return allocation(singletonList(new Rule(singletonList(condition))), staticSplit());
}

private static Allocation shardAllocation() {
final Shard shard =
new Shard("benchmark-allocation-salt", singletonList(new ShardRange(0, 100_000)), 100_000);
return allocation(emptyList(), new Split(singletonList(shard), "on", null, null));
}

private static Split staticSplit() {
return new Split(emptyList(), "on", null, null);
}

private static Allocation allocation(final List<Rule> rules, final Split split) {
return new Allocation(
"benchmark-allocation", rules, null, null, singletonList(split), Boolean.FALSE);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;

class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener {
Expand Down Expand Up @@ -110,6 +109,18 @@ class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener {
// change at runtime, and this class is loaded lazily (well after startup) so config is ready.
private static final boolean SPAN_ENRICHMENT_ENABLED = SpanEnrichmentGate.isEnabled();

// MessageDigest is mutable. One instance per evaluation thread avoids shared mutation and the
// provider lookup and digest allocation on every shard evaluation.
private static final ThreadLocal<MessageDigest> MD5 =
ThreadLocal.withInitial(
() -> {
try {
return MessageDigest.getInstance("MD5");
} catch (final NoSuchAlgorithmException e) {
throw new IllegalStateException("MD5 algorithm not available", e);
}
});

private final Runnable configCallback;
private final AtomicReference<ServerConfiguration> configuration = new AtomicReference<>();
private final CountDownLatch initializationLatch = new CountDownLatch(1);
Expand Down Expand Up @@ -361,9 +372,9 @@ private static boolean evaluateCondition(

switch (condition.operator) {
case MATCHES:
return matchesRegex(attributeValue, condition.value);
return matchesRegex(attributeValue, condition);
case NOT_MATCHES:
return !matchesRegex(attributeValue, condition.value);
return !matchesRegex(attributeValue, condition);
case ONE_OF:
return isOneOf(attributeValue, condition.value);
case NOT_ONE_OF:
Expand Down Expand Up @@ -393,21 +404,11 @@ private static boolean evaluateCondition(
}
}

private static boolean matchesRegex(final Object attributeValue, final Object conditionValue) {
private static boolean matchesRegex(
final Object attributeValue, final ConditionConfiguration condition) {
// PatternSyntaxException is intentionally not caught here so it propagates to evaluate(),
// which maps it to ErrorCode.PARSE_ERROR.
final Pattern pattern = Pattern.compile(normalizeRegex(String.valueOf(conditionValue)));
return pattern.matcher(String.valueOf(attributeValue)).find();
}

private static String normalizeRegex(final String regex) {
return regex
.replace("[:alnum:]", "\\p{Alnum}")
.replace("[:alpha:]", "\\p{Alpha}")
.replace("[:digit:]", "\\p{Digit}")
.replace("[:lower:]", "\\p{Lower}")
.replace("[:upper:]", "\\p{Upper}")
.replace("[:space:]", "\\p{Space}");
return condition.regexPattern().matcher(String.valueOf(attributeValue)).find();
}

private static boolean isOneOf(final Object attributeValue, final Object conditionValue) {
Expand Down Expand Up @@ -461,7 +462,7 @@ private static boolean evaluateSemverCondition(
}

private static boolean matchesShard(final Shard shard, final String targetingKey) {
final int assignedShard = getShard(shard.salt, targetingKey, shard.totalShards);
final int assignedShard = getShard(shard, targetingKey);
for (final ShardRange range : shard.ranges) {
if (assignedShard >= range.start && assignedShard < range.end) {
return true;
Expand All @@ -470,30 +471,17 @@ private static boolean matchesShard(final Shard shard, final String targetingKey
return false;
}

private static int getShard(final String salt, final String targetingKey, final int totalShards) {
final String hashKey = salt + "-" + targetingKey;
final String md5Hash = getMD5Hash(hashKey);
final String first8Chars = md5Hash.substring(0, Math.min(8, md5Hash.length()));
final long intFromHash = Long.parseLong(first8Chars, 16);
return (int) (intFromHash % totalShards);
}

private static String getMD5Hash(final String input) {
try {
final MessageDigest md = MessageDigest.getInstance("MD5");
final byte[] hashBytes = md.digest(input.getBytes(StandardCharsets.UTF_8));
final StringBuilder hexString = new StringBuilder();
for (byte b : hashBytes) {
final String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) {
hexString.append('0');
}
hexString.append(hex);
}
return hexString.toString();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("MD5 algorithm not available", e);
}
static int getShard(final Shard shard, final String targetingKey) {
final MessageDigest digest = MD5.get();
digest.reset();
shard.updateDigest(digest);
final byte[] hash = digest.digest(targetingKey.getBytes(StandardCharsets.UTF_8));
final long firstFourBytes =
((hash[0] & 0xffL) << 24)
| ((hash[1] & 0xffL) << 16)
| ((hash[2] & 0xffL) << 8)
| (hash[3] & 0xffL);
return (int) (firstFourBytes % shard.totalShards);
}

private static <T> ProviderEvaluation<T> resolveVariant(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import datadog.trace.api.featureflag.ufc.v1.ParsedSemver;
import datadog.trace.api.featureflag.ufc.v1.Rule;
import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration;
import datadog.trace.api.featureflag.ufc.v1.Shard;
import datadog.trace.api.featureflag.ufc.v1.Split;
import datadog.trace.api.featureflag.ufc.v1.ValueType;
import datadog.trace.api.featureflag.ufc.v1.Variant;
Expand Down Expand Up @@ -62,6 +63,7 @@
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.MethodSource;

public class DDEvaluatorTest {
Expand Down Expand Up @@ -616,6 +618,15 @@ public void testEvaluateSemverConditionInvalidComparandReturnsParseError() {
assertThat(details.getErrorCode(), equalTo(ErrorCode.PARSE_ERROR));
}

@ParameterizedTest
@CsvSource({"eve,732", "user-1,2895", "alice,9136", "bob,8956"})
public void testShardCalculationMatchesGoAndEppoFixtures(
final String targetingKey, final int expectedShard) {
final Shard shard = new Shard("split-numeric-flag-some-allocation", emptyList(), 10_000);

assertThat(DDEvaluator.getShard(shard, targetingKey), equalTo(expectedShard));
}

private static Arguments[] flatteningTestCases() {
final List<Arguments> arguments = new ArrayList<>();
arguments.add(Arguments.of(emptyMap(), emptyMap()));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package datadog.trace.api.featureflag.ufc.v1;

import java.util.regex.Pattern;

public class ConditionConfiguration {
public final ConditionOperator operator;
public final String attribute;
Expand All @@ -9,10 +11,44 @@ public class ConditionConfiguration {
// (not from JSON) when the operator is a SEMVER_* operator.
public transient ParsedSemver semverComparand;

// The compiled MATCHES or NOT_MATCHES value. Set during configuration preprocessing. Pattern is
// immutable and safe to share between concurrent evaluation threads.
private transient Pattern regexPattern;

public ConditionConfiguration(
final ConditionOperator operator, final String attribute, final Object value) {
this.operator = operator;
this.attribute = attribute;
this.value = value;
}

/** Compiles and caches this condition's normalized regular expression. */
public void cacheRegexPattern() {
regexPattern = compileRegex();
}

/** Returns the cached pattern, or compiles one for a condition created outside the UFC parser. */
public Pattern regexPattern() {
final Pattern cached = regexPattern;
return cached != null ? cached : compileRegex();
}

/** Returns true when configuration preprocessing cached this condition's pattern. */
public boolean hasCachedRegexPattern() {
return regexPattern != null;
}

private Pattern compileRegex() {
return Pattern.compile(normalizeRegex(String.valueOf(value)));
}

private static String normalizeRegex(final String regex) {
return regex
.replace("[:alnum:]", "\\p{Alnum}")
.replace("[:alpha:]", "\\p{Alpha}")
.replace("[:digit:]", "\\p{Digit}")
.replace("[:lower:]", "\\p{Lower}")
.replace("[:upper:]", "\\p{Upper}")
.replace("[:space:]", "\\p{Space}");
}
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,34 @@
package datadog.trace.api.featureflag.ufc.v1;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.List;

public class Shard {
public final String salt;
public final List<ShardRange> ranges;
public final int totalShards;

// The immutable UTF-8 bytes before the targeting key in the shard hash input. Set during
// configuration preprocessing. The array is private and is never exposed or mutated.
private transient byte[] saltPrefix;

public Shard(final String salt, final List<ShardRange> ranges, final int totalShards) {
this.salt = salt;
this.ranges = ranges;
this.totalShards = totalShards;
cacheSaltPrefix();
}

/** Caches the UTF-8 bytes for {@code salt + "-"}. */
public void cacheSaltPrefix() {
saltPrefix = (String.valueOf(salt) + "-").getBytes(StandardCharsets.UTF_8);
}

/** Adds the cached salt prefix to a digest without exposing the mutable byte array. */
public void updateDigest(final MessageDigest digest) {
final byte[] cached = saltPrefix;
digest.update(
cached != null ? cached : (String.valueOf(salt) + "-").getBytes(StandardCharsets.UTF_8));
}
}
Loading
Loading