diff --git a/lib/src/main/java/growthbook/sdk/java/GrowthBook.java b/lib/src/main/java/growthbook/sdk/java/GrowthBook.java index 5b24e09..9d56144 100644 --- a/lib/src/main/java/growthbook/sdk/java/GrowthBook.java +++ b/lib/src/main/java/growthbook/sdk/java/GrowthBook.java @@ -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; @@ -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; @@ -48,12 +50,17 @@ public class GrowthBook implements IGrowthBook { private final GrowthBookJsonUtils jsonUtils = GrowthBookJsonUtils.getInstance(); private List callbacks; - @Getter @Setter private JsonObject attributeOverrides; + @Getter + @Setter + private JsonObject attributeOverrides; public EvaluationContext evaluationContext = null; private final Map assigned; - @Getter @Setter private Map forcedFeatureValues; + @Getter + @Setter + private Map forcedFeatureValues; + /** * Initialize the GrowthBook SDK with a provided {@link GBContext} * @@ -158,12 +165,13 @@ private EvaluationContext getEvaluationContext() { * There are a few ordered steps to evaluate a feature *

* 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 Gson deserializable type @@ -195,18 +203,18 @@ 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) @@ -214,9 +222,9 @@ public void setAttributes(String attributesJsonString) { * 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 Gson deserializable type + * @return ExperimentResult instance */ @Override public ExperimentResult run(Experiment experiment) { @@ -445,8 +453,9 @@ public 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 @@ -503,6 +512,7 @@ public void destroy() { /** * This method add new calback to list of ExperimentRunCallback + * * @param callback ExperimentRunCallback interface */ @Override @@ -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 ... */ @Override @@ -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 */ @@ -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 The type of the result value + * @return A map where keys are feature keys and values are FeatureResult objects + */ + public Map> evalFeatures( + List featureKeys, + Class valueTypeClass + ) { + EvaluationContext optimizedContext = getEvaluationContext(); + Map> results = new HashMap<>(featureKeys.size()); + + for (String key : featureKeys) { + try { + FeatureResult 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.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); diff --git a/lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java b/lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java index bc6413a..ae68dc0 100644 --- a/lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java +++ b/lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java @@ -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; @@ -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()) @@ -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 The type of the result value + * @return A map where keys are feature keys and values are FeatureResult objects + */ + public Map> evalFeatures( + List featureKeys, + Class valueTypeClass, + UserContext userContext + ) { + EvaluationContext optimizedContext = getEvalContext(userContext); + Map> results = new HashMap<>(); + + for (String key : featureKeys) { + try { + FeatureResult 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.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 e : userAttrs.entrySet()) { + merged.add(e.getKey(), e.getValue()); + } + } + return merged; + } + private void fireSubscriptions(Experiment experiment, ExperimentResult result) { String key = experiment.getKey(); // If assigned variation has changed, fire subscriptions @@ -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 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); }