diff --git a/lib/src/main/java/growthbook/sdk/java/GrowthBook.java b/lib/src/main/java/growthbook/sdk/java/GrowthBook.java index 5932f2a4..632b0a21 100644 --- a/lib/src/main/java/growthbook/sdk/java/GrowthBook.java +++ b/lib/src/main/java/growthbook/sdk/java/GrowthBook.java @@ -261,6 +261,23 @@ public FeatureResult evalFeature(String key, Class the result value type + * @return a map from feature key to its {@link FeatureResult} + */ + @Override + public Map> evalFeatures( + List featureKeys, + Class valueTypeClass + ) { + return featureEvaluator.evaluateFeatures(featureKeys, getEvaluationContext(), valueTypeClass); + } + /** * Update the user's attributes * diff --git a/lib/src/main/java/growthbook/sdk/java/IGrowthBook.java b/lib/src/main/java/growthbook/sdk/java/IGrowthBook.java index ec88ff23..0e7f968f 100644 --- a/lib/src/main/java/growthbook/sdk/java/IGrowthBook.java +++ b/lib/src/main/java/growthbook/sdk/java/IGrowthBook.java @@ -6,6 +6,8 @@ import growthbook.sdk.java.model.FeatureResult; import growthbook.sdk.java.stickyBucketing.StickyBucketService; import javax.annotation.Nullable; +import java.util.List; +import java.util.Map; interface IGrowthBook { @@ -20,6 +22,16 @@ interface IGrowthBook { FeatureResult evalFeature(String key, Class valueTypeClass); + /** + * Evaluates a batch of features for the current user, reusing a single evaluation context. + * + * @param featureKeys the feature keys to evaluate + * @param valueTypeClass the expected value type (typically {@code Object.class} for mixed types) + * @param the result value type + * @return a map from feature key to its {@link FeatureResult} + */ + Map> evalFeatures(List featureKeys, Class valueTypeClass); + /** * Update the user's attributes * diff --git a/lib/src/main/java/growthbook/sdk/java/evaluators/FeatureEvaluator.java b/lib/src/main/java/growthbook/sdk/java/evaluators/FeatureEvaluator.java index e7c3deb8..77595b56 100644 --- a/lib/src/main/java/growthbook/sdk/java/evaluators/FeatureEvaluator.java +++ b/lib/src/main/java/growthbook/sdk/java/evaluators/FeatureEvaluator.java @@ -10,7 +10,6 @@ import growthbook.sdk.java.model.FeatureResult; import growthbook.sdk.java.model.FeatureResultSource; import growthbook.sdk.java.model.FeatureRule; -import growthbook.sdk.java.model.Filter; import growthbook.sdk.java.multiusermode.configurations.EvaluationContext; import growthbook.sdk.java.multiusermode.usage.FeatureUsageCallbackWithUser; import lombok.extern.slf4j.Slf4j; @@ -21,392 +20,408 @@ /** * INTERNAL: Implementation of feature evaluation. - * Takes Context and Feature Key. - * Returns Calculated Feature Result against that key */ @Slf4j public class FeatureEvaluator implements IFeatureEvaluator { + private static final Object NO_FORCED_FEATURE_VALUE = new Object(); - private final GrowthBookJsonUtils jsonUtils = GrowthBookJsonUtils.getInstance(); private final ConditionEvaluator conditionEvaluator = new ConditionEvaluator(); + private final GrowthBookJsonUtils jsonUtils = GrowthBookJsonUtils.getInstance(); private final ExperimentEvaluator experimentEvaluator = new ExperimentEvaluator(); - // Takes Context and Feature Key - // Returns Calculated Feature Result against that key + /** + * Evaluates a batch of features against a single, shared {@link EvaluationContext}. + * The context (and the {@code GlobalContext} it references) is reused for every key + * instead of being rebuilt per feature, and the per-evaluation stack is reset between + * features so evaluation state does not leak. A feature whose evaluation throws is + * recorded as {@link FeatureResultSource#UNKNOWN_FEATURE} rather than aborting the batch. + */ + @Override + public Map> evaluateFeatures( + List featureKeys, + EvaluationContext context, + Class valueTypeClass + ) { + Map> results = new HashMap<>(); + if (featureKeys == null || featureKeys.isEmpty()) { + return results; + } + for (String key : featureKeys) { + try { + results.put(key, evaluateFeature(key, context, valueTypeClass)); + } catch (RuntimeException e) { + log.error("Error evaluating feature '{}' in batch", key, e); + results.put(key, FeatureResult.builder() + .value(null) + .source(FeatureResultSource.UNKNOWN_FEATURE) + .build()); + } finally { + context.setStack(new EvaluationContext.StackContext()); + } + } + return results; + } + @Override - public FeatureResult evaluateFeature( + public FeatureResult evaluateFeature( String key, EvaluationContext context, - Class valueTypeClass + Class valueTypeClass ) throws ClassCastException { - // This callback serves for listening for feature usage events - FeatureUsageCallbackWithUser featureUsageCallbackWithUser = context.getOptions() - .getFeatureUsageCallbackWithUser(); - - FeatureResult unknownFeatureResult = FeatureResult - .builder() + FeatureResult unknownFeatureResult = FeatureResult + .builder() .value(null) .source(FeatureResultSource.UNKNOWN_FEATURE) .build(); try { if (context.getStack().getEvaluatedFeatures().contains(key)) { - // block that handles recursion - log.info( - "evaluateFeature: circular dependency detected: {} -> {}. { from: {}, to: {} }", - context.getStack().getId(), key, - context.getStack().getId(), key - ); - - FeatureResult featureResultWhenCircularDependencyDetected = FeatureResult - .builder() - .value(null) - .source(FeatureResultSource.CYCLIC_PREREQUISITE) - .build(); - if (featureUsageCallbackWithUser != null) { - featureUsageCallbackWithUser.onFeatureUsage(key, featureResultWhenCircularDependencyDetected, context.getUser()); - } - - leaveCircularLoop(context); - return featureResultWhenCircularDependencyDetected; + return handleCircularDependency(key, context); } FeatureResult memoizedResult = context.getStack().getMemoizedResults().get(key); if (memoizedResult != null) { - return (FeatureResult) memoizedResult; + return (FeatureResult) memoizedResult; } - - // Add the current feature being evaluated to the stack addFeatureToEvalStack(key, context); - // Global override - Object forcedFeatureValue = getForcedFeatureValue(key, context); - if (forcedFeatureValue != NO_FORCED_FEATURE_VALUE) { - ValueType unwrapForceFeatureValue = (ValueType) GrowthBookJsonUtils.unwrap(forcedFeatureValue); - log.info("Forced feature override with key: {} and value {}", key, forcedFeatureValue); - - FeatureResult overrideResult = FeatureResult - .builder() - .value(unwrapForceFeatureValue) - .source(FeatureResultSource.OVERRIDE) - .build(); - return cacheResult(key, overrideResult, context); + FeatureResult override = resolveForcedOverride(key, context); + if (override != null) { + return cacheResult(key, override, context); } - // Check for feature values forced by URL - if (context.getOptions().getAllowUrlOverrides()) { - ValueType forcedValue = evaluateForcedFeatureValueFromUrl(key, context.getOptions().getUrl(), valueTypeClass); - if (forcedValue != null) { - FeatureResult urlFeatureResult = FeatureResult - .builder() - .value(forcedValue) - .source(FeatureResultSource.URL_OVERRIDE) - .build(); - - if (featureUsageCallbackWithUser != null) { - featureUsageCallbackWithUser.onFeatureUsage(key, urlFeatureResult, context.getUser()); - } - - return cacheResult(key, urlFeatureResult, context); - } + FeatureResult urlOverride = resolveUrlOverride(key, context, valueTypeClass); + if (urlOverride != null) { + return cacheResult(key, urlOverride, context); } - // Unknown key, return empty feature Map> features = context.getGlobal().getFeatures(); if (features == null || features.isEmpty() || !features.containsKey(key)) { - if (featureUsageCallbackWithUser != null) { - featureUsageCallbackWithUser.onFeatureUsage(key, unknownFeatureResult, context.getUser()); - } - + dispatchFeatureUsage(context, key, unknownFeatureResult); return cacheResult(key, unknownFeatureResult, context); } - // The key exists - Feature feature = (Feature) features.get(key); - FeatureResult defaultValueFeature = FeatureResult - .builder() - .value(null) - .source(FeatureResultSource.DEFAULT_VALUE) - .build(); - + Feature feature = (Feature) features.get(key); if (feature == null) { - // When key exists but there is no value, should be default value with null value - if (featureUsageCallbackWithUser != null) { - featureUsageCallbackWithUser.onFeatureUsage(key, defaultValueFeature, context.getUser()); - } - return cacheResult(key, defaultValueFeature, context); + FeatureResult nullFeatureResult = FeatureResult + .builder() + .value(null) + .source(FeatureResultSource.DEFAULT_VALUE) + .build(); + dispatchFeatureUsage(context, key, nullFeatureResult); + return cacheResult(key, nullFeatureResult, context); } - // If empty rule set, use the default value if (feature.getRules() == null || feature.getRules().isEmpty()) { - ValueType value = (ValueType) GrowthBookJsonUtils.unwrap(feature.getDefaultValue()); - FeatureResult defaultValueFeatureForRules = FeatureResult - .builder() - .source(FeatureResultSource.DEFAULT_VALUE) - .value(value) - .build(); - if (featureUsageCallbackWithUser != null) { - featureUsageCallbackWithUser.onFeatureUsage(key, defaultValueFeatureForRules, context.getUser()); - } - return cacheResult(key, defaultValueFeatureForRules, context); + return cacheResult(key, defaultValueResult(feature, key, context), context); } - // Loop through the feature rules (if any) - List> featureRules = feature.getRules(); - final Set evaluatedFeatures = new HashSet<>(context.getStack().getEvaluatedFeatures()); - - outer: - for (FeatureRule rule : featureRules) { - // If there are prerequisite flag(s), evaluate them - if (rule.getParentConditions() != null) { - for (ParentCondition parentCondition : rule.getParentConditions()) { - context.getStack().setEvaluatedFeatures(new HashSet<>(evaluatedFeatures)); - //enterCircularLoop(key, context); - FeatureResult parentResult = evaluateFeature( - parentCondition.getId(), - context, - valueTypeClass); - - // break out for cyclic prerequisites - if (FeatureResultSource.CYCLIC_PREREQUISITE.equals(parentResult.getSource())) { - FeatureResult featureResultWhenCircularDependencyDetected = - FeatureResult - .builder() - .value(null) - .source(FeatureResultSource.CYCLIC_PREREQUISITE) - .build(); - - if (featureUsageCallbackWithUser != null) { - featureUsageCallbackWithUser.onFeatureUsage(key, - featureResultWhenCircularDependencyDetected, - context.getUser()); - } - return cacheResult(key, featureResultWhenCircularDependencyDetected, context); - } - - Map evalObj = new HashMap<>(); - if (parentResult.getValue() != null) { - evalObj.put("value", parentResult.getValue()); - } - JsonObject parentAttributesJson = GrowthBookJsonUtils.getInstance().gson.toJsonTree(evalObj).getAsJsonObject(); - - boolean evalCondition = conditionEvaluator.evaluateCondition( - parentAttributesJson, - parentCondition.getCondition(), - context.getGlobal().getSavedGroups() - ); - - // blocking prerequisite eval failed: feature evaluation fails - if (!evalCondition) { - // blocking prerequisite eval failed: feature evaluation fails - if (Boolean.TRUE.equals(parentCondition.getGate())) { - log.info("Feature blocked by prerequisite"); - - FeatureResult featureResultWhenBlockedByPrerequisite = - FeatureResult - .builder() - .value(null) - .source(FeatureResultSource.PREREQUISITE) - .build(); - - if (featureUsageCallbackWithUser != null) { - featureUsageCallbackWithUser.onFeatureUsage(key, - featureResultWhenBlockedByPrerequisite, - context.getUser()); - } - return cacheResult(key, featureResultWhenBlockedByPrerequisite, context); - } - // non-blocking prerequisite eval failed: break out - // of parentConditions loop, jump to the next rule - continue outer; - } - } - } + FeatureResult ruleResult = evaluateRules(feature, key, context, valueTypeClass); + if (ruleResult != null) { + return cacheResult(key, ruleResult, context); + } - // If there are filters for who is included (e.g. namespaces) - List filters = rule.getFilters(); - if (GrowthBookUtils.isFilteredOut(filters, context.getUser().getAttributes())) { + return cacheResult(key, defaultValueResult(feature, key, context), context); + } catch (Exception e) { + log.error(e.getMessage(), e); + return cacheResult(key, unknownFeatureResult, context); + } + } - // Skip rule because of filters - continue; - } + private FeatureResult handleCircularDependency(String key, EvaluationContext context) { + log.info( + "evaluateFeature: circular dependency detected: {} -> {}. { from: {}, to: {} }", + context.getStack().getId(), key, + context.getStack().getId(), key + ); + FeatureResult cyclicResult = FeatureResult + .builder() + .value(null) + .source(FeatureResultSource.CYCLIC_PREREQUISITE) + .build(); + dispatchFeatureUsage(context, key, cyclicResult); + leaveCircularLoop(context); + return cyclicResult; + } - // Feature value is being forced - if (rule.getForce() != null && rule.getForce().isPresent()) { - - // If the rule has a condition, and it evaluates to false, skip this rule and continue to the next one - if (rule.getCondition() != null) { - if (!conditionEvaluator.evaluateCondition(context.getUser().getAttributes(), - rule.getCondition(), context.getGlobal().getSavedGroups())) { - - // Skip rule because of condition - continue; - } - } - - boolean gate1 = context.getOptions().getStickyBucketService() != null; - boolean gate2 = !Boolean.TRUE.equals(rule.getDisableStickyBucketing()); - boolean shouldFallbackAttributeBePassed = gate1 && gate2; - - // Pass fallback attribute if sticky bucketing is enabled. - String fallback = shouldFallbackAttributeBePassed ? rule.getFallbackAttribute() : null; - - String ruleKey = rule.getHashAttribute(); - if (ruleKey == null) { - ruleKey = "id"; - } - - String seed = rule.getSeed(); - if (seed == null) { - seed = key; - } - - // If this is a percentage rollout, skip if not included - if ( - !GrowthBookUtils.isIncludedInRollout( - context.getUser().getAttributes(), - seed, - ruleKey, - fallback, - rule.getRange(), - rule.getCoverage(), - rule.getHashVersion() - ) - ) { - - // Skip rule because user not included in rollout - continue; - } - - // Fire tracking callbacks for remotely evaluated experiments, de-duplicated per - // assignment so repeated cached evaluations don't re-fire exposures. - experimentEvaluator.fireRemoteEvaluationTracks(rule.getTracks(), context); - - ValueType value = (ValueType) GrowthBookJsonUtils.unwrap(rule.getForce().getValue()); - - // Apply the force rule - FeatureResult forcedRuleFeatureValue = FeatureResult - .builder() - .value(value) // TODO: Check this. - This is not right - .source(FeatureResultSource.FORCE) - .ruleId(rule.getId()) - .build(); + @Nullable + private FeatureResult resolveForcedOverride(String key, EvaluationContext context) { + Object forcedFeatureValue = getForcedFeatureValue(key, context); + if (forcedFeatureValue == NO_FORCED_FEATURE_VALUE) { + return null; + } + T value = (T) GrowthBookJsonUtils.unwrap(forcedFeatureValue); + log.info("Forced feature override with key: {} and value {}", key, forcedFeatureValue); + return FeatureResult + .builder() + .value(value) + .source(FeatureResultSource.OVERRIDE) + .build(); + } - if (featureUsageCallbackWithUser != null) { - featureUsageCallbackWithUser.onFeatureUsage(key, forcedRuleFeatureValue, context.getUser()); - } - - return cacheResult(key, forcedRuleFeatureValue, context); - } else { - - ArrayList variations = rule.getVariations(); - if (variations != null) { - - // Experiment rule - String experimentKey = rule.getKey(); - if (experimentKey == null) { - experimentKey = key; - } - - // For experiment rules, run an experiment - Experiment experiment = Experiment - .builder() - .key(experimentKey) - .variations(variations) - .coverage(rule.getCoverage()) - .weights(rule.getWeights()) - .hashAttribute(rule.getHashAttribute()) - .fallbackAttribute(rule.getFallbackAttribute()) - .disableStickyBucketing(rule.getDisableStickyBucketing()) - .bucketVersion(rule.getBucketVersion()) - .minBucketVersion(rule.getMinBucketVersion()) - .namespace(rule.getNamespace()) - .meta(rule.getMeta()) - .ranges(rule.getRanges()) - .name(rule.getName()) - .phase(rule.getPhase()) - .seed(rule.getSeed()) - .hashVersion(rule.getHashVersion()) - .filters(rule.getFilters()) - .conditionJson(rule.getCondition()) - .customFields(rule.getCustomFields()) - .build(); - - // Only return a value if the user is part of the experiment - ExperimentResult result = experimentEvaluator.evaluateExperiment(experiment, context, key); - if (result.getInExperiment() && (result.getPassThrough() == null || !result.getPassThrough())) { - ValueType value = (ValueType) GrowthBookJsonUtils.unwrap(result.getValue()); - - FeatureResult experimentFeatureResult = FeatureResult - .builder() - .value(value) - .ruleId(rule.getId()) - .source(FeatureResultSource.EXPERIMENT) - .experiment(experiment) - .experimentResult(result) - .build(); - - if (featureUsageCallbackWithUser != null) { - featureUsageCallbackWithUser.onFeatureUsage(key, experimentFeatureResult, context.getUser()); - } - return cacheResult(key, experimentFeatureResult, context); - } - } else { - continue; - } - } + @Nullable + private FeatureResult resolveUrlOverride(String key, EvaluationContext context, Class valueTypeClass) { + if (Boolean.FALSE.equals(context.getOptions().getAllowUrlOverrides())) { + return null; + } + T forcedValue = evaluateForcedFeatureValueFromUrl(key, context.getOptions().getUrl(), valueTypeClass); + if (forcedValue == null) { + return null; + } + FeatureResult urlFeatureResult = FeatureResult + .builder() + .value(forcedValue) + .source(FeatureResultSource.URL_OVERRIDE) + .build(); + dispatchFeatureUsage(context, key, urlFeatureResult); + return urlFeatureResult; + } + + /** + * Walks the feature's rules in order. Returns the first terminal result a rule produces, + * or {@code null} if no rule applied (the caller then falls back to the default value). + */ + @Nullable + private FeatureResult evaluateRules(Feature feature, String key, EvaluationContext context, Class valueTypeClass) { + Set evaluatedFeatures = new HashSet<>(context.getStack().getEvaluatedFeatures()); + for (FeatureRule rule : feature.getRules()) { + FeatureResult result = evaluateRule(rule, key, context, valueTypeClass, evaluatedFeatures); + if (result != null) { + return result; } + } + return null; + } + + /** + * Evaluates a single rule. Returns the terminal result the rule produces, or {@code null} + * when the rule does not apply and evaluation should continue with the next rule. + */ + @Nullable + private FeatureResult evaluateRule( + FeatureRule rule, + String key, + EvaluationContext context, + Class valueTypeClass, + Set evaluatedFeatures + ) { + PrerequisiteOutcome prerequisites = evaluatePrerequisites(rule, key, context, valueTypeClass, evaluatedFeatures); + if (prerequisites.terminalResult != null) { + return prerequisites.terminalResult; + } + if (prerequisites.skipRule) { + return null; + } + if (Boolean.TRUE.equals(GrowthBookUtils.isFilteredOut(rule.getFilters(), context.getUser().getAttributes()))) { + return null; + } + if (isForcedRule(rule)) { + return evaluateForcedRule(rule, key, context); + } + return evaluateExperimentRule(rule, key, context); + } - // endregion Rules + private PrerequisiteOutcome evaluatePrerequisites( + FeatureRule rule, + String key, + EvaluationContext context, + Class valueTypeClass, + Set evaluatedFeatures + ) { + if (rule.getParentConditions() == null) { + return PrerequisiteOutcome.pass(); + } - ValueType value = (ValueType) GrowthBookJsonUtils.unwrap(feature.getDefaultValue()); + for (ParentCondition parentCondition : rule.getParentConditions()) { + context.getStack().setEvaluatedFeatures(new HashSet<>(evaluatedFeatures)); + FeatureResult parentResult = evaluateFeature(parentCondition.getId(), context, valueTypeClass); - FeatureResult defaultValueFeatureResult = FeatureResult - .builder() - .source(FeatureResultSource.DEFAULT_VALUE) - .value(value) - .build(); + if (FeatureResultSource.CYCLIC_PREREQUISITE.equals(parentResult.getSource())) { + FeatureResult cyclicResult = FeatureResult + .builder() + .value(null) + .source(FeatureResultSource.CYCLIC_PREREQUISITE) + .build(); + dispatchFeatureUsage(context, key, cyclicResult); + return PrerequisiteOutcome.terminal(cyclicResult); + } - if (featureUsageCallbackWithUser != null) { - featureUsageCallbackWithUser.onFeatureUsage(key, defaultValueFeatureResult, context.getUser()); + Map evalObj = new HashMap<>(); + if (parentResult.getValue() != null) { + evalObj.put("value", parentResult.getValue()); } + JsonObject parentAttributesJson = GrowthBookJsonUtils.getInstance().gson.toJsonTree(evalObj).getAsJsonObject(); + + boolean evalCondition = conditionEvaluator.evaluateCondition( + parentAttributesJson, + parentCondition.getCondition(), + context.getGlobal().getSavedGroups() + ); + + if (!evalCondition) { + if (Boolean.TRUE.equals(parentCondition.getGate())) { + log.info("Feature blocked by prerequisite"); + FeatureResult blockedResult = FeatureResult + .builder() + .value(null) + .source(FeatureResultSource.PREREQUISITE) + .build(); + dispatchFeatureUsage(context, key, blockedResult); + return PrerequisiteOutcome.terminal(blockedResult); + } + return PrerequisiteOutcome.skip(); + } + } + return PrerequisiteOutcome.pass(); + } - // Return (value = defaultValue or null, source = defaultValue) - return cacheResult(key, defaultValueFeatureResult, context); - } catch (Exception e) { - log.error(e.getMessage(), e); + private boolean isForcedRule(FeatureRule rule) { + return rule.getForce() != null && rule.getForce().isPresent(); + } - // If the key doesn't exist in context.features, return immediately - // (value = null, source = unknownFeature). - return cacheResult(key, unknownFeatureResult, context); + /** + * Evaluates a force rule. Returns the forced result, or {@code null} when the rule is + * skipped (its condition didn't match or the user isn't in the rollout). Remote-eval + * tracking callbacks are fired via {@link ExperimentEvaluator#fireRemoteEvaluationTracks} + * (de-duplicated per assignment) before the forced value is returned. + */ + @Nullable + private FeatureResult evaluateForcedRule(FeatureRule rule, String key, EvaluationContext context) { + if (rule.getCondition() != null + && !conditionEvaluator.evaluateCondition(context.getUser().getAttributes(), + rule.getCondition(), context.getGlobal().getSavedGroups())) { + return null; } + + boolean stickyBucketingEnabled = context.getOptions().getStickyBucketService() != null + && !Boolean.TRUE.equals(rule.getDisableStickyBucketing()); + String fallback = stickyBucketingEnabled ? rule.getFallbackAttribute() : null; + + String ruleKey = rule.getHashAttribute() != null ? rule.getHashAttribute() : "id"; + String seed = rule.getSeed() != null ? rule.getSeed() : key; + + if (Boolean.FALSE.equals(GrowthBookUtils.isIncludedInRollout( + context.getUser().getAttributes(), + seed, + ruleKey, + fallback, + rule.getRange(), + rule.getCoverage(), + rule.getHashVersion() + ))) { + return null; + } + + experimentEvaluator.fireRemoteEvaluationTracks(rule.getTracks(), context); + + T value = (T) GrowthBookJsonUtils.unwrap(rule.getForce().getValue()); + FeatureResult forcedRuleFeatureValue = FeatureResult.builder() + .value(value) + .source(FeatureResultSource.FORCE) + .ruleId(rule.getId()) + .build(); + dispatchFeatureUsage(context, key, forcedRuleFeatureValue); + return forcedRuleFeatureValue; + } + + /** + * Evaluates an experiment rule. Returns the experiment result when the user is bucketed + * in (and not passed through), or {@code null} when the rule doesn't apply. + */ + @Nullable + private FeatureResult evaluateExperimentRule(FeatureRule rule, String key, EvaluationContext context) { + ArrayList variations = rule.getVariations(); + if (variations == null) { + return null; + } + + String experimentKey = rule.getKey() != null ? rule.getKey() : key; + Experiment experiment = Experiment + .builder() + .key(experimentKey) + .variations(variations) + .coverage(rule.getCoverage()) + .weights(rule.getWeights()) + .hashAttribute(rule.getHashAttribute()) + .fallbackAttribute(rule.getFallbackAttribute()) + .disableStickyBucketing(rule.getDisableStickyBucketing()) + .bucketVersion(rule.getBucketVersion()) + .minBucketVersion(rule.getMinBucketVersion()) + .namespace(rule.getNamespace()) + .meta(rule.getMeta()) + .ranges(rule.getRanges()) + .name(rule.getName()) + .phase(rule.getPhase()) + .seed(rule.getSeed()) + .hashVersion(rule.getHashVersion()) + .filters(rule.getFilters()) + .conditionJson(rule.getCondition()) + .customFields(rule.getCustomFields()) + .build(); + + ExperimentResult result = experimentEvaluator.evaluateExperiment(experiment, context, key); + boolean inExperiment = result.getInExperiment() + && (result.getPassThrough() == null || !result.getPassThrough()); + if (!inExperiment) { + return null; + } + + T value = (T) GrowthBookJsonUtils.unwrap(result.getValue()); + FeatureResult experimentFeatureResult = FeatureResult + .builder() + .value(value) + .ruleId(rule.getId()) + .source(FeatureResultSource.EXPERIMENT) + .experiment(experiment) + .experimentResult(result) + .build(); + dispatchFeatureUsage(context, key, experimentFeatureResult); + return experimentFeatureResult; + } + + private FeatureResult defaultValueResult(Feature feature, String key, EvaluationContext context) { + T value = (T) GrowthBookJsonUtils.unwrap(feature.getDefaultValue()); + FeatureResult defaultValueFeatureResult = FeatureResult + .builder() + .source(FeatureResultSource.DEFAULT_VALUE) + .value(value) + .build(); + dispatchFeatureUsage(context, key, defaultValueFeatureResult); + return defaultValueFeatureResult; } - private @Nullable ValueType evaluateForcedFeatureValueFromUrl(String key, @Nullable String urlString, Class valueTypeClass) { + private void dispatchFeatureUsage(EvaluationContext context, String key, FeatureResult result) { + FeatureUsageCallbackWithUser featureUsageCallbackWithUser = context.getOptions().getFeatureUsageCallbackWithUser(); + if (featureUsageCallbackWithUser != null) { + featureUsageCallbackWithUser.onFeatureUsage(key, result, context.getUser()); + } + } + + private @Nullable T evaluateForcedFeatureValueFromUrl(String key, @Nullable String urlString, Class valueTypeClass) { if (urlString == null) return null; try { URL url = new URL(urlString); if (valueTypeClass.equals(Boolean.class)) { - return (ValueType) GrowthBookUtils.getForcedBooleanValueFromUrl(key, url); + return (T) GrowthBookUtils.getForcedBooleanValueFromUrl(key, url); } if (valueTypeClass.equals(String.class)) { - return (ValueType) GrowthBookUtils.getForcedStringValueFromUrl(key, url); + return (T) GrowthBookUtils.getForcedStringValueFromUrl(key, url); } if (valueTypeClass.equals(Integer.class)) { - return (ValueType) GrowthBookUtils.getForcedIntegerValueFromUrl(key, url); + return (T) GrowthBookUtils.getForcedIntegerValueFromUrl(key, url); } if (valueTypeClass.equals(Float.class)) { - return (ValueType) GrowthBookUtils.getForcedFloatValueFromUrl(key, url); + return (T) GrowthBookUtils.getForcedFloatValueFromUrl(key, url); } if (valueTypeClass.equals(Double.class)) { - return (ValueType) GrowthBookUtils.getForcedDoubleValueFromUrl(key, url); + return (T) GrowthBookUtils.getForcedDoubleValueFromUrl(key, url); } return GrowthBookUtils.getForcedSerializableValueFromUrl(key, url, valueTypeClass, jsonUtils.gson); @@ -427,7 +442,7 @@ private void addFeatureToEvalStack(String featureKey, EvaluationContext context) context.getStack().getEvaluatedFeatures().add(featureKey); } - private FeatureResult cacheResult(String key, FeatureResult result, EvaluationContext context) { + private FeatureResult cacheResult(String key, FeatureResult result, EvaluationContext context) { context.getStack().getMemoizedResults().putIfAbsent(key, result); return result; } @@ -449,4 +464,33 @@ private Object getForcedFeatureValue(String key, EvaluationContext evaluationCon return NO_FORCED_FEATURE_VALUE; } + + /** + * Outcome of evaluating a rule's prerequisites: either produce a terminal result, skip + * this rule (non-blocking prerequisite failed), or pass (proceed with the rule). + */ + private static final class PrerequisiteOutcome { + + private final boolean skipRule; + + @Nullable + private final FeatureResult terminalResult; + + private PrerequisiteOutcome(@Nullable FeatureResult terminalResult, boolean skipRule) { + this.terminalResult = terminalResult; + this.skipRule = skipRule; + } + + static PrerequisiteOutcome pass() { + return new PrerequisiteOutcome<>(null, false); + } + + static PrerequisiteOutcome skip() { + return new PrerequisiteOutcome<>(null, true); + } + + static PrerequisiteOutcome terminal(FeatureResult result) { + return new PrerequisiteOutcome<>(result, false); + } + } } diff --git a/lib/src/main/java/growthbook/sdk/java/evaluators/IFeatureEvaluator.java b/lib/src/main/java/growthbook/sdk/java/evaluators/IFeatureEvaluator.java index 3cfdc910..6990ca34 100644 --- a/lib/src/main/java/growthbook/sdk/java/evaluators/IFeatureEvaluator.java +++ b/lib/src/main/java/growthbook/sdk/java/evaluators/IFeatureEvaluator.java @@ -3,6 +3,9 @@ import growthbook.sdk.java.model.FeatureResult; import growthbook.sdk.java.multiusermode.configurations.EvaluationContext; +import java.util.List; +import java.util.Map; + interface IFeatureEvaluator { /** * Evaluate a feature. @@ -14,4 +17,15 @@ interface IFeatureEvaluator { * @throws ClassCastException When a value type fails to cast to the provided type, this can throw an exception */ FeatureResult evaluateFeature(String key, EvaluationContext context, Class valueTypeClass); + + /** + * Evaluate a batch of features against a single shared {@link EvaluationContext}. + * + * @param featureKeys the feature keys to evaluate + * @param context the shared evaluation context, reused for every key + * @param valueTypeClass the expected value type + * @param the type of value for the variations + * @return a map from feature key to its feature result + */ + Map> evaluateFeatures(List featureKeys, EvaluationContext context, Class valueTypeClass); } 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 07d7adf7..352194a7 100644 --- a/lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java +++ b/lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java @@ -255,6 +255,26 @@ public FeatureResult evalFeature(String key, return featureEvaluator.evaluateFeature(key, getEvalContext(userContext), valueTypeClass); } + /** + * Evaluates a batch of features for the same user using a single shared + * {@link EvaluationContext}. Attribute merging, global-context references and any + * sticky-bucket preload happen once for the whole batch instead of once per feature, + * so memory stays O(size(context)) rather than O(features × size(context)). + * + * @param featureKeys the feature keys to evaluate + * @param valueTypeClass the expected value type (typically {@code Object.class} for mixed types) + * @param userContext the user context, processed once + * @param the result value type + * @return a map from feature key to its {@link FeatureResult} + */ + public Map> evalFeatures( + List featureKeys, + Class valueTypeClass, + UserContext userContext + ) { + return featureEvaluator.evaluateFeatures(featureKeys, getEvalContext(userContext), valueTypeClass); + } + public Boolean isOn(String featureKey, UserContext userContext) { return this.featureEvaluator.evaluateFeature(featureKey, getEvalContext(userContext), Object.class).isOn(); } diff --git a/lib/src/test/java/growthbook/sdk/java/GrowthBookEvalFeaturesTest.java b/lib/src/test/java/growthbook/sdk/java/GrowthBookEvalFeaturesTest.java new file mode 100644 index 00000000..70c18d25 --- /dev/null +++ b/lib/src/test/java/growthbook/sdk/java/GrowthBookEvalFeaturesTest.java @@ -0,0 +1,81 @@ +package growthbook.sdk.java; + +import growthbook.sdk.java.model.FeatureResult; +import growthbook.sdk.java.model.FeatureResultSource; +import growthbook.sdk.java.model.GBContext; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GrowthBookEvalFeaturesTest { + + private static final String FEATURES_JSON = + "{" + + "\"f_bool\":{\"defaultValue\":true}," + + "\"f_str\":{\"defaultValue\":\"hello\"}," + + "\"f_num\":{\"defaultValue\":42}," + + "\"f_forced\":{\"defaultValue\":0,\"rules\":[{\"condition\":{\"employee\":true},\"force\":100}]}" + + "}"; + + private GrowthBook newSubject() { + GBContext context = GBContext.builder() + .featuresJson(FEATURES_JSON) + .attributesJson("{\"id\":\"user-1\",\"employee\":true}") + .build(); + return new GrowthBook(context); + } + + @Test + void evalFeatures_returnsOneResultPerKey_matchingIndividualEvaluation() { + GrowthBook subject = newSubject(); + List keys = Arrays.asList("f_bool", "f_str", "f_num", "f_forced"); + + Map> batch = subject.evalFeatures(keys, Object.class); + + assertEquals(keys.size(), batch.size()); + for (String key : keys) { + FeatureResult single = subject.evalFeature(key, Object.class); + FeatureResult batched = batch.get(key); + assertNotNull(batched, "missing result for " + key); + assertEquals(single.getValue(), batched.getValue(), "value mismatch for " + key); + assertEquals(single.getSource(), batched.getSource(), "source mismatch for " + key); + } + } + + @Test + void evalFeatures_forcedRuleIsApplied() { + GrowthBook subject = newSubject(); + + Map> batch = subject.evalFeatures( + Collections.singletonList("f_forced"), Object.class); + + assertEquals(FeatureResultSource.FORCE, batch.get("f_forced").getSource()); + } + + @Test + void evalFeatures_unknownKeyIsIncludedAsUnknownFeature() { + GrowthBook subject = newSubject(); + + Map> batch = subject.evalFeatures( + Collections.singletonList("does_not_exist"), Object.class); + + assertEquals(1, batch.size()); + assertNotNull(batch.get("does_not_exist")); + assertEquals(FeatureResultSource.UNKNOWN_FEATURE, batch.get("does_not_exist").getSource()); + } + + @Test + void evalFeatures_emptyOrNullKeys_returnEmptyMap() { + GrowthBook subject = newSubject(); + + assertTrue(subject.evalFeatures(Collections.emptyList(), Object.class).isEmpty()); + assertTrue(subject.evalFeatures(null, Object.class).isEmpty()); + } +}