Skip to content
Open
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
89 changes: 70 additions & 19 deletions lib/src/main/java/growthbook/sdk/java/GrowthBook.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import java.util.List;
import java.util.Objects;
import javax.annotation.Nullable;

import com.google.gson.JsonObject;
import growthbook.sdk.java.callback.ExperimentRunCallback;
import growthbook.sdk.java.evaluators.ConditionEvaluator;
Expand All @@ -15,6 +16,7 @@
import growthbook.sdk.java.model.Experiment;
import growthbook.sdk.java.model.ExperimentResult;
import growthbook.sdk.java.model.FeatureResult;
import growthbook.sdk.java.model.FeatureResultSource;
import growthbook.sdk.java.model.GBContext;
import growthbook.sdk.java.util.GrowthBookJsonUtils;
import growthbook.sdk.java.util.GrowthBookUtils;
Expand Down Expand Up @@ -48,12 +50,17 @@ public class GrowthBook implements IGrowthBook {
private final GrowthBookJsonUtils jsonUtils = GrowthBookJsonUtils.getInstance();

private List<ExperimentRunCallback> callbacks;
@Getter @Setter private JsonObject attributeOverrides;
@Getter
@Setter
private JsonObject attributeOverrides;

public EvaluationContext evaluationContext = null;
private final Map<String, AssignedExperiment> assigned;

@Getter @Setter private Map<String, Object> forcedFeatureValues;
@Getter
@Setter
private Map<String, Object> forcedFeatureValues;

/**
* Initialize the GrowthBook SDK with a provided {@link GBContext}
*
Expand Down Expand Up @@ -158,12 +165,13 @@ private EvaluationContext getEvaluationContext() {
* There are a few ordered steps to evaluate a feature
* <p>
* 1. If the key doesn't exist in context.getFeatures()
* 1.1 Return getFeatureResult(null, "unknownFeature")
* 1.1 Return getFeatureResult(null, "unknownFeature")
* 2. Loop through the feature rules (if any)
* 2.1 If the rule has parentConditions (prerequisites) defined, loop through each one:
* 2.1.1 Call evalFeature on the parent condition
* 2.1.1.1 If a cycle is detected, break out of feature evaluation and return getFeatureResult(null, "cyclicPrerequisite")
* 2.1.2 Using the evaluated parent's result, create an object
* 2.1 If the rule has parentConditions (prerequisites) defined, loop through each one:
* 2.1.1 Call evalFeature on the parent condition
* 2.1.1.1 If a cycle is detected, break out of feature evaluation and return getFeatureResult(null, "cyclicPrerequisite")
* 2.1.2 Using the evaluated parent's result, create an object
*
* @param key name of the feature
* @param valueTypeClass the class of the generic, e.g. MyFeature.class
* @param <ValueType> Gson deserializable type
Expand Down Expand Up @@ -195,28 +203,28 @@ public void setAttributes(String attributesJsonString) {
* 4. Return if forced via context
* 5. If experiment.active is set to false, return getExperimentResult(experiment)
* 6. Get the user hash value and return if empty
* 6.1 If sticky bucketing is permitted, check to see if a sticky bucket value exists. If so, skip steps 7-8.
* 6.1 If sticky bucketing is permitted, check to see if a sticky bucket value exists. If so, skip steps 7-8.
* 7. Apply filters and namespace
* 7.1 If experiment.filters is set
* 7.2 Else if experiment.namespace is set, return if not in range
* 7.1 If experiment.filters is set
* 7.2 Else if experiment.namespace is set, return if not in range
* 8. Return if any conditions are not met, return
* 8.1 If experiment.condition is set, return if it evaluates to false
* 8.2 If experiment.parentConditions is set (prerequisites), return if any of them evaluate to false. See the corresponding logic in evalFeature for more details. (Note that the gate flag should not be set in an experiment)
* 8.3 Apply any url targeting based on experiment.urlPatterns, return if no match
* 8.1 If experiment.condition is set, return if it evaluates to false
* 8.2 If experiment.parentConditions is set (prerequisites), return if any of them evaluate to false. See the corresponding logic in evalFeature for more details. (Note that the gate flag should not be set in an experiment)
* 8.3 Apply any url targeting based on experiment.urlPatterns, return if no match
* 9. Choose a variation
* 9.1 If a sticky bucket value exists, use it.
* 9.1.1 If the found sticky bucket version is blocked (doesn't exceed experiment.minBucketVersion), then skip enrollment
* 9.2 Else, calculate bucket ranges for the variations and choose one
* 9.1 If a sticky bucket value exists, use it.
* 9.1.1 If the found sticky bucket version is blocked (doesn't exceed experiment.minBucketVersion), then skip enrollment
* 9.2 Else, calculate bucket ranges for the variations and choose one
* 10. If assigned == -1, return getExperimentResult(experiment)
* 11. If experiment has a forced variation, return
* 12. If context.qaMode, return getExperimentResult(experiment)
* 13. Build the result object
* 14. Fire context.trackingCallback if set and the combination of hashAttribute, hashValue, experiment.key, and variationId has not been tracked before
* 15. Return result
*
* @param experiment Experiment object
* @return ExperimentResult instance
* @param experiment Experiment object
* @param <ValueType> Gson deserializable type
* @return ExperimentResult instance
*/
@Override
public <ValueType> ExperimentResult<ValueType> run(Experiment<ValueType> experiment) {
Expand Down Expand Up @@ -445,8 +453,9 @@ public <ValueType> ValueType getFeatureValue(String featureKey, ValueType defaul
* 4. If condition key is $not, check if !evalCondition(attributes, condition["$not"]) is false. If so, break out of the loop and return false
* 5. Otherwise, check if evalConditionValue(value, getPath(attributes, key)) is false. If so, break out of the loop and return false
* If none of the entries failed their checks, evalCondition returns true
*
* @param attributesJsonString A JsonObject of the user attributes to evaluate
* @param conditionJsonString A JsonObject of the condition
* @param conditionJsonString A JsonObject of the condition
* @return Whether the condition should be true for the user
*/
@Override
Expand Down Expand Up @@ -503,6 +512,7 @@ public void destroy() {

/**
* This method add new calback to list of ExperimentRunCallback
*
* @param callback ExperimentRunCallback interface
*/
@Override
Expand All @@ -514,6 +524,7 @@ public void subscribe(ExperimentRunCallback callback) {
* Update sticky bucketing configuration
* Method that get cached assignments
* and set it to Context's Sticky Bucket Assignments documents
*
* @param featuresDataModel Json in format of String. See info how it looks like here <a href="https://docs.growthbook.io/app/api#sdk-connection-endpoints">...</a>
*/
@Override
Expand All @@ -523,6 +534,7 @@ public void featuresAPIModelSuccessfully(String featuresDataModel) {

/**
* This method return boolean result if feature enabled by environment it would be present in context
*
* @param featureKey Feature name
* @return Whether feature is present in GBContext
*/
Expand All @@ -531,6 +543,45 @@ public Boolean isFeatureEnabled(String featureKey) {
return context.getFeatures() != null && context.getFeatures().containsKey(featureKey);
}

/**
* Evaluates a batch of features using a shared EvaluationContext.
* This optimizes memory usage by reusing the same context for multiple features.
*
* @param featureKeys List of feature keys to evaluate
* @param valueTypeClass The expected type of the values (usually Object.class for mixed types)
* @param <ValueType> The type of the result value
* @return A map where keys are feature keys and values are FeatureResult objects
*/
public <ValueType> Map<String, FeatureResult<ValueType>> evalFeatures(
List<String> featureKeys,
Class<ValueType> valueTypeClass
) {
EvaluationContext optimizedContext = getEvaluationContext();
Map<String, FeatureResult<ValueType>> results = new HashMap<>(featureKeys.size());

for (String key : featureKeys) {
try {
FeatureResult<ValueType> result = featureEvaluator.evaluateFeature(
key,
optimizedContext,
valueTypeClass
);
results.put(key, result);
optimizedContext.setStack(new EvaluationContext.StackContext());
} catch (Exception e) {
log.error("Error evaluating feature in batch: {}", key, e);
results.put(
key,
FeatureResult.<ValueType>builder()
.value(null)
.source(FeatureResultSource.UNKNOWN_FEATURE)
.build());
}
}

return results;
}

private void refreshStickyBucketService(@Nullable String featuresDataModel) {
if (context.getStickyBucketService() != null) {
GrowthBookUtils.refreshStickyBuckets(context, featuresDataModel, attributeOverrides);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import growthbook.sdk.java.model.Experiment;
import growthbook.sdk.java.model.ExperimentResult;
import growthbook.sdk.java.model.FeatureResult;
import growthbook.sdk.java.model.FeatureResultSource;
import growthbook.sdk.java.model.RequestBodyForRemoteEval;
import growthbook.sdk.java.multiusermode.configurations.EvaluationContext;
import growthbook.sdk.java.multiusermode.configurations.GlobalContext;
Expand Down Expand Up @@ -64,7 +65,7 @@ public boolean initialize() {
GbCacheManager cm = this.options.getCacheManager() != null
? this.options.getCacheManager()
: CacheManagerFactory.create(this.options.getCacheMode(), this.options.getCacheDirectory()
);
);

repository = GBFeaturesRepository.builder()
.apiHost(this.options.getApiHost())
Expand Down Expand Up @@ -193,6 +194,62 @@ public void shutdown() {
}
}

/**
* Evaluates a batch of features using a shared EvaluationContext.
* This optimizes memory usage by parsing attributes and merging context only once.
*
* @param featureKeys List of feature keys to evaluate
* @param valueTypeClass The expected type of the values (usually Object.class for mixed types)
* @param userContext The user context (will be processed once)
* @param <ValueType> The type of the result value
* @return A map where keys are feature keys and values are FeatureResult objects
*/

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Memory Issue: EvaluationContext created per feature

The current implementation creates a new EvaluationContext inside the loop for every feature evaluation. Each EvaluationContext holds a reference to GlobalContext, which contains the entire parsed features map and saved groups (potentially hundreds of MBs). When evaluating thousands of features in parallel batches, this results in thousands of EvaluationContext copies being held in memory simultaneously (10GB+ observed), causing excessive memory pressure and GC overhead.

Root cause: EvaluationContext is created per feature instead of being reused across all features in the batch.

Solution: Create EvaluationContext once outside the loop and reuse it for all feature evaluations in the batch. Since we've already merged attributes once into optimizedUserContext, the same EvaluationContext can safely be reused for evaluating all features, reducing memory allocation from O(n * size(GlobalContext)) to O(size(GlobalContext)) per batch.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review. I have moved EvaluationContext out of the loop and reuse it, but after each evaluation I set a new StackContext to prevent circular dependency.

public <ValueType> Map<String, FeatureResult<ValueType>> evalFeatures(
List<String> featureKeys,
Class<ValueType> valueTypeClass,
UserContext userContext
) {
EvaluationContext optimizedContext = getEvalContext(userContext);
Map<String, FeatureResult<ValueType>> results = new HashMap<>();

for (String key : featureKeys) {
try {
FeatureResult<ValueType> result = featureEvaluator.evaluateFeature(
key,
optimizedContext,
valueTypeClass
);
results.put(key, result);
optimizedContext.setStack(new EvaluationContext.StackContext());
} catch (Exception e) {
log.error("Error evaluating feature in batch: {}", key, e);
results.put(
key,
FeatureResult.<ValueType>builder()
.value(null)
.source(FeatureResultSource.UNKNOWN_FEATURE)
.build());
}
}

return results;
}

private JsonObject mergeAttributes(UserContext userContext) {
JsonObject merged = new JsonObject();
if (this.options.getGlobalAttributes() != null) {
merged = GrowthBookJsonUtils.getInstance().gson.fromJson(this.options.getGlobalAttributes(), JsonObject.class);
if (merged == null) merged = new JsonObject();
}
JsonObject userAttrs = userContext.getAttributes();
if (userAttrs != null) {
for (Map.Entry<String, JsonElement> e : userAttrs.entrySet()) {
merged.add(e.getKey(), e.getValue());
}
}
return merged;
}

private <ValueType> void fireSubscriptions(Experiment<ValueType> experiment, ExperimentResult<ValueType> result) {
String key = experiment.getKey();
// If assigned variation has changed, fire subscriptions
Expand Down Expand Up @@ -247,17 +304,7 @@ public void onError(Throwable throwable) {

private EvaluationContext getEvalContext(UserContext userContext) {
// Merge attributes using JsonObject to avoid parse/serialize churn
JsonObject merged = new JsonObject();
if (this.options.getGlobalAttributes() != null) {
merged = GrowthBookJsonUtils.getInstance().gson.fromJson(this.options.getGlobalAttributes(), JsonObject.class);
if (merged == null) merged = new JsonObject();
}
JsonObject userAttrs = userContext.getAttributes();
if (userAttrs != null) {
for (Map.Entry<String, JsonElement> e : userAttrs.entrySet()) {
merged.add(e.getKey(), e.getValue());
}
}
JsonObject merged = mergeAttributes(userContext);
UserContext updatedUserContext = userContext.withAttributes(merged);
return new EvaluationContext(this.globalContext, updatedUserContext, new EvaluationContext.StackContext(), this.options);
}
Expand Down
Loading