From 43ba52752cdcaaa81f5becd876e5ee766140892c Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Sat, 15 Aug 2026 15:10:45 -0700 Subject: [PATCH] perf(feature-flagging): reduce rule evaluation allocations --- .../FlagEvaluationRuleBenchmark.java | 120 ++++++++++++++++++ .../trace/api/openfeature/DDEvaluator.java | 70 +++++----- .../api/openfeature/DDEvaluatorTest.java | 11 ++ .../ufc/v1/ConditionConfiguration.java | 36 ++++++ .../trace/api/featureflag/ufc/v1/Shard.java | 19 +++ .../UniversalFlagConfigParser.java | 48 +++++++ .../JsonApiUfcResponseParserTest.java | 43 +++++++ 7 files changed, 306 insertions(+), 41 deletions(-) create mode 100644 products/feature-flagging/feature-flagging-api/src/jmh/java/datadog/trace/api/openfeature/FlagEvaluationRuleBenchmark.java diff --git a/products/feature-flagging/feature-flagging-api/src/jmh/java/datadog/trace/api/openfeature/FlagEvaluationRuleBenchmark.java b/products/feature-flagging/feature-flagging-api/src/jmh/java/datadog/trace/api/openfeature/FlagEvaluationRuleBenchmark.java new file mode 100644 index 00000000000..ce5d1de8aad --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/jmh/java/datadog/trace/api/openfeature/FlagEvaluationRuleBenchmark.java @@ -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. + * + *

The static case is the control. The regex and shard cases differ only in the rule work that + * selects the same boolean variation. + * + *

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 rules, final Split split) { + return new Allocation( + "benchmark-allocation", rules, null, null, singletonList(split), Boolean.FALSE); + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java index e74074a640a..c1f7d749fb2 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java @@ -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 { @@ -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 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 configuration = new AtomicReference<>(); private final CountDownLatch initializationLatch = new CountDownLatch(1); @@ -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: @@ -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) { @@ -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; @@ -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 ProviderEvaluation resolveVariant( diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java index aeddda0bfd2..d78bd211304 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java @@ -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; @@ -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 { @@ -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 = new ArrayList<>(); arguments.add(Arguments.of(emptyMap(), emptyMap())); diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionConfiguration.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionConfiguration.java index ae693674acc..efcdaef3d07 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionConfiguration.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionConfiguration.java @@ -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; @@ -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}"); + } } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Shard.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Shard.java index b94fead2581..bd8c37d2f8d 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Shard.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Shard.java @@ -1,5 +1,7 @@ package datadog.trace.api.featureflag.ufc.v1; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.util.List; public class Shard { @@ -7,9 +9,26 @@ public class Shard { public final List 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 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)); } } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java index 8044160e459..bb9054b5a2f 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java @@ -13,6 +13,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 java.io.ByteArrayInputStream; import java.io.IOException; @@ -24,6 +25,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.regex.PatternSyntaxException; import javax.annotation.Nonnull; import javax.annotation.Nullable; import okio.BufferedSource; @@ -105,9 +107,55 @@ private static void validateFlag(final String flagKey, final Flag flag) { } } } + cacheEvaluationData(flag); validateAndCacheSemverComparands(flagKey, flag); } + /** Caches immutable rule data used by each evaluation. */ + private static void cacheEvaluationData(final Flag flag) { + for (final Allocation allocation : flag.allocations) { + if (allocation == null) { + continue; + } + if (allocation.rules != null) { + for (final Rule rule : allocation.rules) { + if (rule == null || rule.conditions == null) { + continue; + } + for (final ConditionConfiguration condition : rule.conditions) { + if (condition == null || condition.operator == null) { + continue; + } + switch (condition.operator) { + case MATCHES: + case NOT_MATCHES: + try { + condition.cacheRegexPattern(); + } catch (final PatternSyntaxException ignored) { + // Keep the flag. Evaluation maps this same invalid expression to PARSE_ERROR. + } + break; + default: + break; + } + } + } + } + if (allocation.splits != null) { + for (final Split split : allocation.splits) { + if (split == null || split.shards == null) { + continue; + } + for (final Shard shard : split.shards) { + if (shard != null) { + shard.cacheSaltPrefix(); + } + } + } + } + } + } + /** * Validates and caches SemVer comparands for all SEMVER_* conditions in a flag. Throws {@link * InvalidSemverComparandException} if any condition has an invalid or non-string comparand value. diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/JsonApiUfcResponseParserTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/JsonApiUfcResponseParserTest.java index 61332a13520..20524d1775d 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/JsonApiUfcResponseParserTest.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/JsonApiUfcResponseParserTest.java @@ -97,6 +97,11 @@ void preprocessesSemverComparandsAndDropsMalformedFlags() throws Exception { allocation( "non-semver", "[{\"conditions\":[{\"attribute\":\"version\",\"operator\":\"MATCHES\",\"value\":\"1\"}]}]")), + booleanFlag( + "invalid-regex", + allocation( + "invalid-regex", + "[{\"conditions\":[{\"attribute\":\"version\",\"operator\":\"MATCHES\",\"value\":\"[\"}]}]")), booleanFlag( "valid-semver", allocation( @@ -122,6 +127,7 @@ void preprocessesSemverComparandsAndDropsMalformedFlags() throws Exception { assertTrue(configuration.flags.containsKey("no-conditions")); assertTrue(configuration.flags.containsKey("no-operator")); assertTrue(configuration.flags.containsKey("non-semver")); + assertTrue(configuration.flags.containsKey("invalid-regex")); assertTrue(configuration.flags.containsKey("valid-semver")); assertFalse(configuration.flags.containsKey("invalid-semver")); assertFalse(configuration.flags.containsKey("non-string-semver")); @@ -130,6 +136,29 @@ void preprocessesSemverComparandsAndDropsMalformedFlags() throws Exception { assertEquals("invalid_semver_comparand", configuration.invalidFlags.get("invalid-semver")); assertEquals("invalid_semver_comparand", configuration.invalidFlags.get("non-string-semver")); + assertTrue( + configuration + .flags + .get("non-semver") + .allocations + .get(0) + .rules + .get(0) + .conditions + .get(0) + .hasCachedRegexPattern()); + assertFalse( + configuration + .flags + .get("invalid-regex") + .allocations + .get(0) + .rules + .get(0) + .conditions + .get(0) + .hasCachedRegexPattern()); + assertNotNull( configuration .flags @@ -160,6 +189,20 @@ void dropsFlagWithMissingSplitShards() throws Exception { assertEquals("invalid_flag", configuration.invalidFlags.get("missing-shards")); } + @Test + void preprocessesValidShardData() throws Exception { + final ServerConfiguration configuration = + parse( + wrap( + configWithFlags( + booleanFlag( + "valid-shard", + ",\"allocations\":[{\"key\":\"valid-shard\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"test-salt\",\"ranges\":[],\"totalShards\":10000}]}]}]")))); + + assertNotNull(configuration); + assertTrue(configuration.flags.containsKey("valid-shard")); + } + @Test void nullAttributesAreRejectedWithoutInvokingTheFlagParser() throws Exception { assertNull(parse("{\"data\":{\"type\":\"universal-flag-configuration\",\"attributes\":null}}"));