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
61 changes: 60 additions & 1 deletion lib/src/main/java/growthbook/sdk/java/FeatureEvaluator.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
import java.util.List;
import java.util.HashSet;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicReference;

/**
* <b>INTERNAL</b>: Implementation of feature evaluation.
Expand All @@ -21,6 +23,63 @@
class FeatureEvaluator implements IFeatureEvaluator {

private final GrowthBookJsonUtils jsonUtils = GrowthBookJsonUtils.getInstance();

/**
* Deserialized feature definitions, shared across evaluators.
*
* <p>Deserializing a feature definition is the dominant cost of evaluation — profiling a caller
* that evaluates ~50 features per request found 44% of process CPU in {@code evaluateFeature},
* three quarters of it inside {@code Gson.fromJson} — and the result depends only on the feature
* JSON, never on the context or attributes. Callers that build a GrowthBook instance per
* evaluation (a common pattern, since the context carries per-request attributes) otherwise
* re-parse the same definitions on every call.
*
* <p>The cache is static because a {@link FeatureEvaluator} is created per {@link GrowthBook},
* so a per-instance cache would never be reused by those callers. It is keyed by feature name
* and scoped to one features document, identified by reference: when the SDK is handed a
* different features object — a refresh from the API, or a call to
* {@link GBContext#setFeatures} — the whole map is replaced rather than invalidated entry by
* entry. Reference identity is deliberate; {@code JsonObject.equals} would walk the entire tree
* and cost as much as the parse it is meant to avoid.
*
* <p>Entries are safe to share: {@link Feature} exposes only final fields and nothing in
* evaluation mutates a feature or its rules.
*/
private static final AtomicReference<ParsedFeatures> PARSED_FEATURES = new AtomicReference<>();

private static final class ParsedFeatures {
private final JsonObject source;
private final ConcurrentHashMap<String, Feature<?>> byKey = new ConcurrentHashMap<>();

private ParsedFeatures(JsonObject source) {
this.source = source;
}
}

/**
* Returns the deserialized definition for {@code key}, parsing it only the first time it is seen
* for this features document.
*/
@SuppressWarnings("unchecked")
private <ValueType> Feature<ValueType> parseFeature(
JsonObject featuresJson,
String key,
JsonElement featureJson
) {
ParsedFeatures parsed = PARSED_FEATURES.get();
if (parsed == null || parsed.source != featuresJson) {
parsed = new ParsedFeatures(featuresJson);
PARSED_FEATURES.set(parsed);
}

// computeIfAbsent stores nothing when the mapper returns null, so an unparseable definition
// falls through to the caller's existing null handling instead of being memoized.
return (Feature<ValueType>) parsed.byKey.computeIfAbsent(
key,
unused -> jsonUtils.gson.fromJson(featureJson, Feature.class)
);
}

private final ConditionEvaluator conditionEvaluator = new ConditionEvaluator();
private final ExperimentEvaluator experimentEvaluator = new ExperimentEvaluator();
private final FeatureEvalContext featureEvalContext = new FeatureEvalContext(null, new HashSet<>());
Expand Down Expand Up @@ -112,7 +171,7 @@ public <ValueType> FeatureResult<ValueType> evaluateFeature(
return defaultValueFeature;
}

Feature<ValueType> feature = jsonUtils.gson.fromJson(featureJson, Feature.class);
Feature<ValueType> feature = parseFeature(featuresJson, key, featureJson);
if (feature == null) {
// When key exists but there is no value, should be default value with null value
if (featureUsageCallback != null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package growthbook.sdk.java;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;

import com.google.gson.JsonObject;
import org.junit.jupiter.api.Test;

/**
* Covers the deserialized-feature cache in {@link FeatureEvaluator}.
*
* <p>Deserializing a feature definition dominates evaluation cost, and the result depends only on
* the feature JSON. The cache exists so that a caller building a GrowthBook per evaluation — which
* is what you do when the context carries per-request attributes — does not re-parse the same
* definitions on every call. These tests pin the two properties that make it safe: a changed
* features document must not be served from the old cache, and caching must not change what
* evaluation returns.
*/
class FeatureEvaluatorParsedFeatureCacheTest {

private static final String FEATURE_KEY = "cached_feature";

private GBContext contextReturning(String defaultValue) {
return GBContext
.builder()
.featuresJson("{\"" + FEATURE_KEY + "\":{\"defaultValue\":\"" + defaultValue + "\"}}")
.attributesJson("{\"id\":\"user-1\"}")
.build();
}

@Test
void repeatedEvaluationsAgainstOneFeaturesDocumentReturnTheSameValue() {
FeatureEvaluator evaluator = new FeatureEvaluator();
GBContext context = contextReturning("hello");

for (int i = 0; i < 5; i++) {
FeatureResult<String> result =
evaluator.evaluateFeature(FEATURE_KEY, context, String.class, new JsonObject());
assertNotNull(result);
assertEquals("hello", result.getValue());
}
}

/**
* The cache is scoped to a features document by reference, so replacing the document has to
* replace the cache. If it did not, a feature refresh would keep serving stale definitions for
* the lifetime of the process — the worst possible failure for a feature flag system.
*/
@Test
void replacingTheFeaturesDocumentReplacesTheCachedDefinition() {
FeatureEvaluator evaluator = new FeatureEvaluator();

FeatureResult<String> before = evaluator.evaluateFeature(
FEATURE_KEY, contextReturning("old"), String.class, new JsonObject());
FeatureResult<String> after = evaluator.evaluateFeature(
FEATURE_KEY, contextReturning("new"), String.class, new JsonObject());

assertEquals("old", before.getValue());
assertEquals("new", after.getValue());
}

/**
* Callers that construct a GrowthBook per evaluation get a fresh FeatureEvaluator each time,
* which is exactly the case the cache is static for.
*/
@Test
void separateEvaluatorsShareTheCacheWithoutAffectingResults() {
GBContext context = contextReturning("shared");

FeatureResult<String> first = new FeatureEvaluator()
.evaluateFeature(FEATURE_KEY, context, String.class, new JsonObject());
FeatureResult<String> second = new FeatureEvaluator()
.evaluateFeature(FEATURE_KEY, context, String.class, new JsonObject());

assertEquals("shared", first.getValue());
assertEquals("shared", second.getValue());
}

@Test
void anUnknownKeyIsStillUnknownAfterAnotherKeyHasBeenCached() {
FeatureEvaluator evaluator = new FeatureEvaluator();
GBContext context = contextReturning("hello");

evaluator.evaluateFeature(FEATURE_KEY, context, String.class, new JsonObject());
FeatureResult<String> unknown =
evaluator.evaluateFeature("no_such_feature", context, String.class, new JsonObject());

assertEquals(FeatureResultSource.UNKNOWN_FEATURE, unknown.getSource());
}
}