diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index d041bfad3..c113ffafe 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -20,6 +20,7 @@ import com.optimizely.ab.bucketing.DecisionService; import com.optimizely.ab.bucketing.FeatureDecision; import com.optimizely.ab.bucketing.UserProfileService; +import com.optimizely.ab.cmab.service.CmabService; import com.optimizely.ab.config.AtomicProjectConfigManager; import com.optimizely.ab.config.DatafileProjectConfig; import com.optimizely.ab.config.EventType; @@ -141,8 +142,11 @@ public class Optimizely implements AutoCloseable { @Nullable private final ODPManager odpManager; + private final CmabService cmabService; + private final ReentrantLock lock = new ReentrantLock(); + private Optimizely(@Nonnull EventHandler eventHandler, @Nonnull EventProcessor eventProcessor, @Nonnull ErrorHandler errorHandler, @@ -152,8 +156,9 @@ private Optimizely(@Nonnull EventHandler eventHandler, @Nullable OptimizelyConfigManager optimizelyConfigManager, @Nonnull NotificationCenter notificationCenter, @Nonnull List defaultDecideOptions, - @Nullable ODPManager odpManager - ) { + @Nullable ODPManager odpManager, + @Nonnull CmabService cmabService + ) { this.eventHandler = eventHandler; this.eventProcessor = eventProcessor; this.errorHandler = errorHandler; @@ -164,6 +169,7 @@ private Optimizely(@Nonnull EventHandler eventHandler, this.notificationCenter = notificationCenter; this.defaultDecideOptions = defaultDecideOptions; this.odpManager = odpManager; + this.cmabService = cmabService; if (odpManager != null) { odpManager.getEventManager().start(); @@ -305,7 +311,7 @@ private void sendImpression(@Nonnull ProjectConfig projectConfig, @Nonnull Map filteredAttributes, @Nonnull Variation variation, @Nonnull String ruleType) { - sendImpression(projectConfig, experiment, userId, filteredAttributes, variation, "", ruleType, true); + sendImpression(projectConfig, experiment, userId, filteredAttributes, variation, "", ruleType, true, null); } /** @@ -318,6 +324,7 @@ private void sendImpression(@Nonnull ProjectConfig projectConfig, * @param variation the variation that was returned from activate. * @param flagKey It can either be empty if ruleType is experiment or it's feature key in case ruleType is feature-test or rollout * @param ruleType It can either be experiment in case impression event is sent from activate or it's feature-test or rollout + * @param cmabUUID The cmabUUID if the experiment is a cmab experiment. */ private boolean sendImpression(@Nonnull ProjectConfig projectConfig, @Nullable ExperimentCore experiment, @@ -326,7 +333,8 @@ private boolean sendImpression(@Nonnull ProjectConfig projectConfig, @Nullable Variation variation, @Nonnull String flagKey, @Nonnull String ruleType, - @Nonnull boolean enabled) { + @Nonnull boolean enabled, + @Nullable String cmabUUID) { UserEvent userEvent = UserEventFactory.createImpressionEvent( projectConfig, @@ -336,7 +344,8 @@ private boolean sendImpression(@Nonnull ProjectConfig projectConfig, filteredAttributes, flagKey, ruleType, - enabled); + enabled, + cmabUUID); if (userEvent == null) { return false; @@ -498,7 +507,7 @@ private Boolean isFeatureEnabled(@Nonnull ProjectConfig projectConfig, if (featureDecision.decisionSource != null) { decisionSource = featureDecision.decisionSource; } - + String cmabUUID = featureDecision.cmabUUID; if (featureDecision.variation != null) { // This information is only necessary for feature tests. // For rollouts experiments and variations are an implementation detail only. @@ -520,7 +529,8 @@ private Boolean isFeatureEnabled(@Nonnull ProjectConfig projectConfig, featureDecision.variation, featureKey, decisionSource.toString(), - featureEnabled); + featureEnabled, + cmabUUID); DecisionNotification decisionNotification = DecisionNotification.newFeatureDecisionNotificationBuilder() .withUserId(userId) @@ -1349,6 +1359,8 @@ private OptimizelyDecision createOptimizelyDecision( Map attributes = user.getAttributes(); Map copiedAttributes = new HashMap<>(attributes); + String cmabUUID = flagDecision.cmabUUID; + if (!allOptions.contains(OptimizelyDecideOption.DISABLE_DECISION_EVENT)) { decisionEventDispatched = sendImpression( projectConfig, @@ -1358,7 +1370,8 @@ private OptimizelyDecision createOptimizelyDecision( flagDecision.variation, flagKey, decisionSource.toString(), - flagEnabled); + flagEnabled, + cmabUUID); } DecisionNotification decisionNotification = DecisionNotification.newFlagDecisionNotificationBuilder() @@ -1444,7 +1457,21 @@ private Map decideForKeys(@Nonnull OptimizelyUserCon for (int i = 0; i < flagsWithoutForcedDecision.size(); i++) { DecisionResponse decision = decisionList.get(i); + boolean error = decision.isError(); + String experimentKey = null; + if (decision.getResult() != null && decision.getResult().experiment != null) { + experimentKey = decision.getResult().experiment.getKey(); + } String flagKey = flagsWithoutForcedDecision.get(i).getKey(); + + if (error) { + OptimizelyDecision optimizelyDecision = OptimizelyDecision.newErrorDecision(flagKey, user, DecisionMessage.CMAB_ERROR.reason(experimentKey)); + decisionMap.put(flagKey, optimizelyDecision); + if (validKeys.contains(flagKey)) { + validKeys.remove(flagKey); + } + } + flagDecisions.put(flagKey, decision.getResult()); decisionReasonsMap.get(flagKey).merge(decision.getReasons()); } @@ -1482,6 +1509,141 @@ Map decideAll(@Nonnull OptimizelyUserContext user, return decideForKeys(user, allFlagKeys, options); } + /** + * Returns a decision result ({@link OptimizelyDecision}) for a given flag key and a user context, + * skipping CMAB logic and using only traditional A/B testing. + * + * @param user An OptimizelyUserContext associated with this OptimizelyClient. + * @param key A flag key for which a decision will be made. + * @param options A list of options for decision-making. + * @return A decision result using traditional A/B testing logic only. + */ + OptimizelyDecision decideSync(@Nonnull OptimizelyUserContext user, + @Nonnull String key, + @Nonnull List options) { + ProjectConfig projectConfig = getProjectConfig(); + if (projectConfig == null) { + return OptimizelyDecision.newErrorDecision(key, user, DecisionMessage.SDK_NOT_READY.reason()); + } + + List allOptions = getAllOptions(options); + allOptions.remove(OptimizelyDecideOption.ENABLED_FLAGS_ONLY); + + return decideForKeysSync(user, Arrays.asList(key), allOptions, true).get(key); + } + + /** + * Returns decision results for multiple flag keys, skipping CMAB logic and using only traditional A/B testing. + * + * @param user An OptimizelyUserContext associated with this OptimizelyClient. + * @param keys A list of flag keys for which decisions will be made. + * @param options A list of options for decision-making. + * @return All decision results mapped by flag keys, using traditional A/B testing logic only. + */ + Map decideForKeysSync(@Nonnull OptimizelyUserContext user, + @Nonnull List keys, + @Nonnull List options) { + return decideForKeysSync(user, keys, options, false); + } + + /** + * Returns decision results for all active flag keys, skipping CMAB logic and using only traditional A/B testing. + * + * @param user An OptimizelyUserContext associated with this OptimizelyClient. + * @param options A list of options for decision-making. + * @return All decision results mapped by flag keys, using traditional A/B testing logic only. + */ + Map decideAllSync(@Nonnull OptimizelyUserContext user, + @Nonnull List options) { + Map decisionMap = new HashMap<>(); + + ProjectConfig projectConfig = getProjectConfig(); + if (projectConfig == null) { + logger.error("Optimizely instance is not valid, failing decideAllSync call."); + return decisionMap; + } + + List allFlags = projectConfig.getFeatureFlags(); + List allFlagKeys = new ArrayList<>(); + for (int i = 0; i < allFlags.size(); i++) allFlagKeys.add(allFlags.get(i).getKey()); + + return decideForKeysSync(user, allFlagKeys, options); + } + + private Map decideForKeysSync(@Nonnull OptimizelyUserContext user, + @Nonnull List keys, + @Nonnull List options, + boolean ignoreDefaultOptions) { + Map decisionMap = new HashMap<>(); + + ProjectConfig projectConfig = getProjectConfig(); + if (projectConfig == null) { + logger.error("Optimizely instance is not valid, failing decideForKeysSync call."); + return decisionMap; + } + + if (keys.isEmpty()) return decisionMap; + + List allOptions = ignoreDefaultOptions ? options : getAllOptions(options); + + Map flagDecisions = new HashMap<>(); + Map decisionReasonsMap = new HashMap<>(); + + List flagsWithoutForcedDecision = new ArrayList<>(); + + List validKeys = new ArrayList<>(); + + for (String key : keys) { + FeatureFlag flag = projectConfig.getFeatureKeyMapping().get(key); + if (flag == null) { + decisionMap.put(key, OptimizelyDecision.newErrorDecision(key, user, DecisionMessage.FLAG_KEY_INVALID.reason(key))); + continue; + } + + validKeys.add(key); + + DecisionReasons decisionReasons = DefaultDecisionReasons.newInstance(allOptions); + decisionReasonsMap.put(key, decisionReasons); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(key, null); + DecisionResponse forcedDecisionVariation = decisionService.validatedForcedDecision(optimizelyDecisionContext, projectConfig, user); + decisionReasons.merge(forcedDecisionVariation.getReasons()); + if (forcedDecisionVariation.getResult() != null) { + flagDecisions.put(key, + new FeatureDecision(null, forcedDecisionVariation.getResult(), FeatureDecision.DecisionSource.FEATURE_TEST)); + } else { + flagsWithoutForcedDecision.add(flag); + } + } + + // Use DecisionService method that skips CMAB logic + List> decisionList = + decisionService.getVariationsForFeatureList(flagsWithoutForcedDecision, user, projectConfig, allOptions, false); + + for (int i = 0; i < flagsWithoutForcedDecision.size(); i++) { + DecisionResponse decision = decisionList.get(i); + String flagKey = flagsWithoutForcedDecision.get(i).getKey(); + flagDecisions.put(flagKey, decision.getResult()); + decisionReasonsMap.get(flagKey).merge(decision.getReasons()); + } + + for (String key : validKeys) { + FeatureDecision flagDecision = flagDecisions.get(key); + DecisionReasons decisionReasons = decisionReasonsMap.get((key)); + + OptimizelyDecision optimizelyDecision = createOptimizelyDecision( + user, key, flagDecision, decisionReasons, allOptions, projectConfig + ); + + if (!allOptions.contains(OptimizelyDecideOption.ENABLED_FLAGS_ONLY) || optimizelyDecision.getEnabled()) { + decisionMap.put(key, optimizelyDecision); + } + } + + return decisionMap; + } + + private List getAllOptions(List options) { List copiedOptions = new ArrayList(defaultDecideOptions); if (options != null) { @@ -1731,6 +1893,7 @@ public static class Builder { private NotificationCenter notificationCenter; private List defaultDecideOptions; private ODPManager odpManager; + private CmabService cmabService; // For backwards compatibility private AtomicProjectConfigManager fallbackConfigManager = new AtomicProjectConfigManager(); @@ -1842,6 +2005,11 @@ public Builder withODPManager(ODPManager odpManager) { return this; } + public Builder withCmabService(CmabService cmabService) { + this.cmabService = cmabService; + return this; + } + // Helper functions for making testing easier protected Builder withBucketing(Bucketer bucketer) { this.bucketer = bucketer; @@ -1873,7 +2041,7 @@ public Optimizely build() { } if (decisionService == null) { - decisionService = new DecisionService(bucketer, errorHandler, userProfileService); + decisionService = new DecisionService(bucketer, errorHandler, userProfileService, cmabService); } if (projectConfig == null && datafile != null && !datafile.isEmpty()) { @@ -1916,7 +2084,7 @@ public Optimizely build() { defaultDecideOptions = Collections.emptyList(); } - return new Optimizely(eventHandler, eventProcessor, errorHandler, decisionService, userProfileService, projectConfigManager, optimizelyConfigManager, notificationCenter, defaultDecideOptions, odpManager); + return new Optimizely(eventHandler, eventProcessor, errorHandler, decisionService, userProfileService, projectConfigManager, optimizelyConfigManager, notificationCenter, defaultDecideOptions, odpManager, cmabService); } } } diff --git a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java index e2c03b147..728d3f6ed 100644 --- a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java +++ b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java @@ -16,18 +16,27 @@ */ package com.optimizely.ab; -import com.optimizely.ab.config.ProjectConfig; -import com.optimizely.ab.odp.ODPManager; -import com.optimizely.ab.odp.ODPSegmentCallback; -import com.optimizely.ab.odp.ODPSegmentOption; -import com.optimizely.ab.optimizelydecision.*; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import javax.annotation.Nonnull; import javax.annotation.Nullable; -import java.util.*; -import java.util.concurrent.ConcurrentHashMap; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.optimizely.ab.odp.ODPSegmentCallback; +import com.optimizely.ab.odp.ODPSegmentOption; +import com.optimizely.ab.optimizelydecision.AsyncDecisionFetcher; +import com.optimizely.ab.optimizelydecision.AsyncDecisionsFetcher; +import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; +import com.optimizely.ab.optimizelydecision.OptimizelyDecision; +import com.optimizely.ab.optimizelydecision.OptimizelyDecisionCallback; +import com.optimizely.ab.optimizelydecision.OptimizelyDecisionsCallback; public class OptimizelyUserContext { // OptimizelyForcedDecisionsKey mapped to variationKeys @@ -197,6 +206,134 @@ public Map decideAll() { return decideAll(Collections.emptyList()); } + /** + * Returns a decision result ({@link OptimizelyDecision}) for a given flag key and a user context, + * which contains all data required to deliver the flag. This method skips CMAB logic. + * @param key A flag key for which a decision will be made. + * @param options A list of options for decision-making. + * @return A decision result. + */ + public OptimizelyDecision decideSync(@Nonnull String key, + @Nonnull List options) { + return optimizely.decideSync(copy(), key, options); + } + + /** + * Returns a decision result ({@link OptimizelyDecision}) for a given flag key and a user context, + * which contains all data required to deliver the flag. This method skips CMAB logic. + * + * @param key A flag key for which a decision will be made. + * @return A decision result. + */ + public OptimizelyDecision decideSync(@Nonnull String key) { + return decideSync(key, Collections.emptyList()); + } + + /** + * Returns a key-map of decision results ({@link OptimizelyDecision}) for multiple flag keys and a user context. + * This method skips CMAB logic. + * @param keys A list of flag keys for which decisions will be made. + * @param options A list of options for decision-making. + * @return All decision results mapped by flag keys. + */ + public Map decideForKeysSync(@Nonnull List keys, + @Nonnull List options) { + return optimizely.decideForKeysSync(copy(), keys, options); + } + + /** + * Returns a key-map of decision results for multiple flag keys and a user context. + * This method skips CMAB logic. + * + * @param keys A list of flag keys for which decisions will be made. + * @return All decision results mapped by flag keys. + */ + public Map decideForKeysSync(@Nonnull List keys) { + return decideForKeysSync(keys, Collections.emptyList()); + } + + /** + * Returns a key-map of decision results ({@link OptimizelyDecision}) for all active flag keys. + * This method skips CMAB logic. + * + * @param options A list of options for decision-making. + * @return All decision results mapped by flag keys. + */ + public Map decideAllSync(@Nonnull List options) { + return optimizely.decideAllSync(copy(), options); + } + + /** + * Returns a key-map of decision results ({@link OptimizelyDecision}) for all active flag keys. + * This method skips CMAB logic. + * + * @return A dictionary of all decision results, mapped by flag keys. + */ + public Map decideAllSync() { + return decideAllSync(Collections.emptyList()); + } + + /** + * Returns a decision result asynchronously for a given flag key and a user context. + * + * @param key A flag key for which a decision will be made. + * @param callback A callback to invoke when the decision is available. + * @param options A list of options for decision-making. + */ + public void decideAsync(@Nonnull String key, + @Nonnull OptimizelyDecisionCallback callback, + @Nonnull List options) { + AsyncDecisionFetcher fetcher = new AsyncDecisionFetcher(this, key, options, callback); + fetcher.start(); + } + + /** + * Returns a decision result asynchronously for a given flag key and a user context. + * + * @param key A flag key for which a decision will be made. + * @param callback A callback to invoke when the decision is available. + */ + public void decideAsync(@Nonnull String key, @Nonnull OptimizelyDecisionCallback callback) { + decideAsync(key, callback, Collections.emptyList()); + } + + /** + * Returns decision results asynchronously for multiple flag keys. + * + * @param keys A list of flag keys for which decisions will be made. + * @param callback A callback to invoke when decisions are available. + * @param options A list of options for decision-making. + */ + public void decideForKeysAsync(@Nonnull List keys, + @Nonnull OptimizelyDecisionsCallback callback, + @Nonnull List options) { + AsyncDecisionsFetcher fetcher = new AsyncDecisionsFetcher(this, keys, options, callback); + fetcher.start(); + } + + /** + * Returns decision results asynchronously for multiple flag keys. + */ + public void decideForKeysAsync(@Nonnull List keys, @Nonnull OptimizelyDecisionsCallback callback) { + decideForKeysAsync(keys, callback, Collections.emptyList()); + } + + /** + * Returns decision results asynchronously for all active flag keys. + */ + public void decideAllAsync(@Nonnull OptimizelyDecisionsCallback callback, + @Nonnull List options) { + AsyncDecisionsFetcher fetcher = new AsyncDecisionsFetcher(this, null, options, callback, true); + fetcher.start(); + } + + /** + * Returns decision results asynchronously for all active flag keys. + */ + public void decideAllAsync(@Nonnull OptimizelyDecisionsCallback callback) { + decideAllAsync(callback, Collections.emptyList()); + } + /** * Track an event. * diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java b/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java index 35fa21c71..e9b694b16 100644 --- a/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java @@ -128,6 +128,49 @@ private DecisionResponse bucketToVariation(@Nonnull ExperimentCore ex return new DecisionResponse(null, reasons); } + /** + * Determines CMAB traffic allocation for a user based on hashed value from murmurhash3. + * This method handles bucketing users into CMAB (Contextual Multi-Armed Bandit) experiments. + */ + @Nonnull + private DecisionResponse bucketToEntityForCmab(@Nonnull Experiment experiment, + @Nonnull String bucketingId) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + + // "salt" the bucket id using the experiment id + String experimentId = experiment.getId(); + String experimentKey = experiment.getKey(); + String combinedBucketId = bucketingId + experimentId; + + // Handle CMAB traffic allocation + TrafficAllocation cmabTrafficAllocation = new TrafficAllocation("$", experiment.getCmab().getTrafficAllocation()); + List trafficAllocations = java.util.Collections.singletonList(cmabTrafficAllocation); + + String cmabMessage = reasons.addInfo("Using CMAB traffic allocation for experiment \"%s\".", experimentKey); + logger.debug(cmabMessage); + + int hashCode = MurmurHash3.murmurhash3_x86_32(combinedBucketId, 0, combinedBucketId.length(), MURMUR_HASH_SEED); + int bucketValue = generateBucketValue(hashCode); + logger.debug("Assigned bucket {} to user with bucketingId \"{}\" when bucketing to a variation.", bucketValue, bucketingId); + + String bucketedEntityId = bucketToEntity(bucketValue, trafficAllocations); + if (bucketedEntityId != null) { + if ("$".equals(bucketedEntityId)) { + String message = reasons.addInfo("User with bucketingId \"%s\" is bucketed into CMAB for experiment \"%s\".", bucketingId, experimentKey); + logger.info(message); + } else { + // This shouldn't happen in CMAB since we only have "$" entity, but handle gracefully + String message = reasons.addInfo("User with bucketingId \"%s\" is bucketed into entity \"%s\" for experiment \"%s\".", bucketingId, bucketedEntityId, experimentKey); + logger.info(message); + } + } else { + String message = reasons.addInfo("User with bucketingId \"%s\" is not bucketed into CMAB for experiment \"%s\".", bucketingId, experimentKey); + logger.info(message); + } + + return new DecisionResponse<>(bucketedEntityId, reasons); + } + /** * Assign a {@link Variation} of an {@link Experiment} to a user based on hashed value from murmurhash3. * @@ -177,6 +220,54 @@ public DecisionResponse bucket(@Nonnull ExperimentCore experiment, return new DecisionResponse<>(decisionResponse.getResult(), reasons); } + /** + * Assign a user to CMAB traffic for an experiment based on hashed value from murmurhash3. + * This method handles CMAB (Contextual Multi-Armed Bandit) traffic allocation. + * + * @param experiment The CMAB Experiment in which the user is to be bucketed. + * @param bucketingId string A customer-assigned value used to create the key for the murmur hash. + * @param projectConfig The current projectConfig + * @return A {@link DecisionResponse} including the entity ID ("$" if bucketed to CMAB, null otherwise) and decision reasons + */ + @Nonnull + public DecisionResponse bucketForCmab(@Nonnull Experiment experiment, + @Nonnull String bucketingId, + @Nonnull ProjectConfig projectConfig) { + + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + + // ---------- Handle Group Logic (same as regular bucket method) ---------- + String groupId = experiment.getGroupId(); + if (!groupId.isEmpty()) { + Group experimentGroup = projectConfig.getGroupIdMapping().get(groupId); + + if (experimentGroup.getPolicy().equals(Group.RANDOM_POLICY)) { + Experiment bucketedExperiment = bucketToExperiment(experimentGroup, bucketingId, projectConfig); + if (bucketedExperiment == null) { + String message = reasons.addInfo("User with bucketingId \"%s\" is not in any experiment of group %s.", bucketingId, experimentGroup.getId()); + logger.info(message); + return new DecisionResponse<>(null, reasons); + } + + if (!bucketedExperiment.getId().equals(experiment.getId())) { + String message = reasons.addInfo("User with bucketingId \"%s\" is not in experiment \"%s\" of group %s.", bucketingId, experiment.getKey(), + experimentGroup.getId()); + logger.info(message); + return new DecisionResponse<>(null, reasons); + } + + String message = reasons.addInfo("User with bucketingId \"%s\" is in experiment \"%s\" of group %s.", bucketingId, experiment.getKey(), + experimentGroup.getId()); + logger.info(message); + } + } + + // ---------- Use CMAB-aware bucketToEntity ---------- + DecisionResponse decisionResponse = bucketToEntityForCmab(experiment, bucketingId); + reasons.merge(decisionResponse.getReasons()); + return new DecisionResponse<>(decisionResponse.getResult(), reasons); + } + //======== Helper methods ========// /** diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java index b7536aab5..61e45f04c 100644 --- a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java @@ -26,6 +26,8 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; +import com.optimizely.ab.cmab.service.CmabDecision; +import com.optimizely.ab.cmab.service.CmabService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -58,12 +60,14 @@ * 3. Checking sticky bucketing * 4. Checking audience targeting * 5. Using Murmurhash3 to bucket the user. + * 6. Handling CMAB (Contextual Multi-Armed Bandit) experiments for dynamic variation selection */ public class DecisionService { private final Bucketer bucketer; private final ErrorHandler errorHandler; private final UserProfileService userProfileService; + private final CmabService cmabService; private static final Logger logger = LoggerFactory.getLogger(DecisionService.class); /** @@ -81,13 +85,16 @@ public class DecisionService { * @param bucketer Base bucketer to allocate new users to an experiment. * @param errorHandler The error handler of the Optimizely client. * @param userProfileService UserProfileService implementation for storing user info. + * @param cmabService Cmab Service for decision making. */ public DecisionService(@Nonnull Bucketer bucketer, @Nonnull ErrorHandler errorHandler, - @Nullable UserProfileService userProfileService) { + @Nullable UserProfileService userProfileService, + @Nonnull CmabService cmabService) { this.bucketer = bucketer; this.errorHandler = errorHandler; this.userProfileService = userProfileService; + this.cmabService = cmabService; } /** @@ -107,7 +114,8 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment, @Nonnull ProjectConfig projectConfig, @Nonnull List options, @Nullable UserProfileTracker userProfileTracker, - @Nullable DecisionReasons reasons) { + @Nullable DecisionReasons reasons, + @Nonnull boolean useCmab) { if (reasons == null) { reasons = DefaultDecisionReasons.newInstance(); } @@ -148,10 +156,27 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment, reasons.merge(decisionMeetAudience.getReasons()); if (decisionMeetAudience.getResult()) { String bucketingId = getBucketingId(user.getUserId(), user.getAttributes()); + String cmabUUID = null; + if (useCmab && isCmabExperiment(experiment)) { + DecisionResponse cmabDecision = getDecisionForCmabExperiment(projectConfig, experiment, user, bucketingId, options); + reasons.merge(cmabDecision.getReasons()); - decisionVariation = bucketer.bucket(experiment, bucketingId, projectConfig); - reasons.merge(decisionVariation.getReasons()); - variation = decisionVariation.getResult(); + if (cmabDecision.isError()) { + return new DecisionResponse<>(null, reasons, true, null); + } + + CmabDecision cmabResult = cmabDecision.getResult(); + if (cmabResult != null) { + String variationId = cmabResult.getVariationId(); + cmabUUID = cmabResult.getCmabUUID(); + variation = experiment.getVariationIdToVariationMap().get(variationId); + } + } else { + // Standard bucketing for non-CMAB experiments + decisionVariation = bucketer.bucket(experiment, bucketingId, projectConfig); + reasons.merge(decisionVariation.getReasons()); + variation = decisionVariation.getResult(); + } if (variation != null) { if (userProfileTracker != null) { @@ -161,7 +186,7 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment, } } - return new DecisionResponse(variation, reasons); + return new DecisionResponse<>(variation, reasons, false, cmabUUID); } String message = reasons.addInfo("User \"%s\" does not meet conditions to be in experiment \"%s\".", user.getUserId(), experiment.getKey()); @@ -176,13 +201,15 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment, * @param user The current OptimizelyUserContext * @param projectConfig The current projectConfig * @param options An array of decision options + * @param useCmab Boolean to check if cmab service is to be used. * @return A {@link DecisionResponse} including the {@link Variation} that user is bucketed into (or null) and the decision reasons */ @Nonnull public DecisionResponse getVariation(@Nonnull Experiment experiment, @Nonnull OptimizelyUserContext user, @Nonnull ProjectConfig projectConfig, - @Nonnull List options) { + @Nonnull List options, + @Nonnull boolean useCmab) { DecisionReasons reasons = DefaultDecisionReasons.newInstance(); // fetch the user profile map from the user profile service @@ -194,7 +221,7 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment, userProfileTracker.loadUserProfile(reasons, errorHandler); } - DecisionResponse response = getVariation(experiment, user, projectConfig, options, userProfileTracker, reasons); + DecisionResponse response = getVariation(experiment, user, projectConfig, options, userProfileTracker, reasons, useCmab); if(userProfileService != null && !ignoreUPS) { userProfileTracker.saveUserProfile(errorHandler); @@ -206,7 +233,7 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment, public DecisionResponse getVariation(@Nonnull Experiment experiment, @Nonnull OptimizelyUserContext user, @Nonnull ProjectConfig projectConfig) { - return getVariation(experiment, user, projectConfig, Collections.emptyList()); + return getVariation(experiment, user, projectConfig, Collections.emptyList(), true); } /** @@ -240,6 +267,25 @@ public List> getVariationsForFeatureList(@Non @Nonnull OptimizelyUserContext user, @Nonnull ProjectConfig projectConfig, @Nonnull List options) { + return getVariationsForFeatureList(featureFlags, user, projectConfig, options, true); + } + + /** + * Get the variations the user is bucketed into for the list of feature flags + * + * @param featureFlags The feature flag list the user wants to access. + * @param user The current OptimizelyuserContext + * @param projectConfig The current projectConfig + * @param options An array of decision options + * @param useCmab Boolean field that determines whether to use cmab service + * @return A {@link DecisionResponse} including a {@link FeatureDecision} and the decision reasons + */ + @Nonnull + public List> getVariationsForFeatureList(@Nonnull List featureFlags, + @Nonnull OptimizelyUserContext user, + @Nonnull ProjectConfig projectConfig, + @Nonnull List options, + @Nonnull boolean useCmab) { DecisionReasons upsReasons = DefaultDecisionReasons.newInstance(); boolean ignoreUPS = options.contains(OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE); @@ -268,12 +314,14 @@ public List> getVariationsForFeatureList(@Non } } - DecisionResponse decisionVariationResponse = getVariationFromExperiment(projectConfig, featureFlag, user, options, userProfileTracker); + DecisionResponse decisionVariationResponse = getVariationFromExperiment(projectConfig, featureFlag, user, options, userProfileTracker, useCmab); reasons.merge(decisionVariationResponse.getReasons()); FeatureDecision decision = decisionVariationResponse.getResult(); + boolean error = decisionVariationResponse.isError(); + if (decision != null) { - decisions.add(new DecisionResponse(decision, reasons)); + decisions.add(new DecisionResponse(decision, reasons, error, decision.cmabUUID)); continue; } @@ -321,21 +369,32 @@ DecisionResponse getVariationFromExperiment(@Nonnull ProjectCon @Nonnull FeatureFlag featureFlag, @Nonnull OptimizelyUserContext user, @Nonnull List options, - @Nullable UserProfileTracker userProfileTracker) { + @Nullable UserProfileTracker userProfileTracker, + @Nonnull boolean useCmab) { DecisionReasons reasons = DefaultDecisionReasons.newInstance(); if (!featureFlag.getExperimentIds().isEmpty()) { for (String experimentId : featureFlag.getExperimentIds()) { Experiment experiment = projectConfig.getExperimentIdMapping().get(experimentId); DecisionResponse decisionVariation = - getVariationFromExperimentRule(projectConfig, featureFlag.getKey(), experiment, user, options, userProfileTracker); + getVariationFromExperimentRule(projectConfig, featureFlag.getKey(), experiment, user, options, userProfileTracker, useCmab); reasons.merge(decisionVariation.getReasons()); Variation variation = decisionVariation.getResult(); - + String cmabUUID = decisionVariation.getCmabUUID(); + boolean error = decisionVariation.isError(); + if (error) { + return new DecisionResponse( + new FeatureDecision(experiment, variation, FeatureDecision.DecisionSource.FEATURE_TEST, cmabUUID), + reasons, + decisionVariation.isError(), + cmabUUID); + } if (variation != null) { return new DecisionResponse( - new FeatureDecision(experiment, variation, FeatureDecision.DecisionSource.FEATURE_TEST), - reasons); + new FeatureDecision(experiment, variation, FeatureDecision.DecisionSource.FEATURE_TEST, cmabUUID), + reasons, + decisionVariation.isError(), + cmabUUID); } } } else { @@ -749,7 +808,8 @@ private DecisionResponse getVariationFromExperimentRule(@Nonnull Proj @Nonnull Experiment rule, @Nonnull OptimizelyUserContext user, @Nonnull List options, - @Nullable UserProfileTracker userProfileTracker) { + @Nullable UserProfileTracker userProfileTracker, + @Nonnull boolean useCmab) { DecisionReasons reasons = DefaultDecisionReasons.newInstance(); String ruleKey = rule != null ? rule.getKey() : null; @@ -764,12 +824,12 @@ private DecisionResponse getVariationFromExperimentRule(@Nonnull Proj return new DecisionResponse(variation, reasons); } //regular decision - DecisionResponse decisionResponse = getVariation(rule, user, projectConfig, options, userProfileTracker, null); + DecisionResponse decisionResponse = getVariation(rule, user, projectConfig, options, userProfileTracker, null, useCmab); reasons.merge(decisionResponse.getReasons()); variation = decisionResponse.getResult(); - return new DecisionResponse(variation, reasons); + return new DecisionResponse<>(variation, reasons, decisionResponse.isError(), decisionResponse.getCmabUUID()); } /** @@ -859,4 +919,66 @@ DecisionResponse getVariationFromDeliveryRule(@Nonnull return new DecisionResponse(variationToSkipToEveryoneElsePair, reasons); } + /** + * Retrieves a decision for a contextual multi-armed bandit (CMAB) + * experiment. + * + * @param projectConfig Instance of ProjectConfig. + * @param experiment The experiment object for which the decision is to be + * made. + * @param userContext The user context containing user id and attributes. + * @param bucketingId The bucketing ID to use for traffic allocation. + * @param options Optional list of decide options. + * @return A CmabDecisionResult containing error status, result, and + * reasons. + */ + private DecisionResponse getDecisionForCmabExperiment(@Nonnull ProjectConfig projectConfig, + @Nonnull Experiment experiment, + @Nonnull OptimizelyUserContext userContext, + @Nonnull String bucketingId, + @Nonnull List options) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + + // Check if user is in CMAB traffic allocation + DecisionResponse bucketResponse = bucketer.bucketForCmab(experiment, bucketingId, projectConfig); + reasons.merge(bucketResponse.getReasons()); + + String bucketedEntityId = bucketResponse.getResult(); + + if (bucketedEntityId == null) { + String message = String.format("User \"%s\" not in CMAB experiment \"%s\" due to traffic allocation.", + userContext.getUserId(), experiment.getKey()); + logger.info(message); + reasons.addInfo(message); + + return new DecisionResponse<>(null, reasons); + } + + // User is in CMAB allocation, proceed to CMAB decision + try { + CmabDecision cmabDecision = cmabService.getDecision(projectConfig, userContext, experiment.getId(), options); + + return new DecisionResponse<>(cmabDecision, reasons); + } catch (Exception e) { + String errorMessage = String.format("CMAB fetch failed for experiment \"%s\"", experiment.getKey()); + reasons.addInfo(errorMessage); + logger.error("{} {}", errorMessage, e.getMessage()); + + return new DecisionResponse<>(null, reasons, true, null); + } + } + + /** + * Checks whether an experiment is a contextual multi-armed bandit (CMAB) + * experiment. + * + * @param experiment The experiment to check + * @return true if the experiment is a CMAB experiment, false otherwise + */ + private boolean isCmabExperiment(@Nonnull Experiment experiment) { + if (cmabService == null){ + return false; + } + return experiment.getCmab() != null; + } } diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/FeatureDecision.java b/core-api/src/main/java/com/optimizely/ab/bucketing/FeatureDecision.java index e53172e0a..35bde3d7a 100644 --- a/core-api/src/main/java/com/optimizely/ab/bucketing/FeatureDecision.java +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/FeatureDecision.java @@ -39,6 +39,12 @@ public class FeatureDecision { @Nullable public DecisionSource decisionSource; + /** + * The CMAB UUID for Contextual Multi-Armed Bandit experiments. + */ + @Nullable + public String cmabUUID; + public enum DecisionSource { FEATURE_TEST("feature-test"), ROLLOUT("rollout"), @@ -68,6 +74,23 @@ public FeatureDecision(@Nullable ExperimentCore experiment, @Nullable Variation this.experiment = experiment; this.variation = variation; this.decisionSource = decisionSource; + this.cmabUUID = null; + } + + /** + * Initialize a FeatureDecision object with CMAB UUID. + * + * @param experiment The {@link ExperimentCore} the Feature is associated with. + * @param variation The {@link Variation} the user was bucketed into. + * @param decisionSource The source of the variation. + * @param cmabUUID The CMAB UUID for Contextual Multi-Armed Bandit experiments. + */ + public FeatureDecision(@Nullable ExperimentCore experiment, @Nullable Variation variation, + @Nullable DecisionSource decisionSource, @Nullable String cmabUUID) { + this.experiment = experiment; + this.variation = variation; + this.decisionSource = decisionSource; + this.cmabUUID = cmabUUID; } @Override @@ -79,13 +102,15 @@ public boolean equals(Object o) { if (variation != null ? !variation.equals(that.variation) : that.variation != null) return false; - return decisionSource == that.decisionSource; + if (decisionSource != that.decisionSource) return false; + return cmabUUID != null ? cmabUUID.equals(that.cmabUUID) : that.cmabUUID == null; } @Override public int hashCode() { int result = variation != null ? variation.hashCode() : 0; result = 31 * result + (decisionSource != null ? decisionSource.hashCode() : 0); + result = 31 * result + (cmabUUID != null ? cmabUUID.hashCode() : 0); return result; } } diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/UserEventFactory.java b/core-api/src/main/java/com/optimizely/ab/event/internal/UserEventFactory.java index c8687f7a6..93f0f1f8b 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/UserEventFactory.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/UserEventFactory.java @@ -41,7 +41,8 @@ public static ImpressionEvent createImpressionEvent(@Nonnull ProjectConfig proje @Nonnull Map attributes, @Nonnull String flagKey, @Nonnull String ruleType, - @Nonnull boolean enabled) { + @Nonnull boolean enabled, + @Nullable String cmabUUID) { if ((FeatureDecision.DecisionSource.ROLLOUT.toString().equals(ruleType) || variation == null) && !projectConfig.getSendFlagDecisions()) { @@ -68,13 +69,18 @@ public static ImpressionEvent createImpressionEvent(@Nonnull ProjectConfig proje .withProjectConfig(projectConfig) .build(); - DecisionMetadata metadata = new DecisionMetadata.Builder() + DecisionMetadata.Builder metadataBuilder = new DecisionMetadata.Builder() .setFlagKey(flagKey) .setRuleKey(experimentKey) .setRuleType(ruleType) .setVariationKey(variationKey) - .setEnabled(enabled) - .build(); + .setEnabled(enabled); + + if (cmabUUID != null) { + metadataBuilder.setCmabUUID(cmabUUID); + } + + DecisionMetadata metadata = metadataBuilder.build(); return new ImpressionEvent.Builder() .withUserContext(userContext) diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/DecisionMetadata.java b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/DecisionMetadata.java index aec6cdce2..5765efc64 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/DecisionMetadata.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/DecisionMetadata.java @@ -16,11 +16,11 @@ */ package com.optimizely.ab.event.internal.payload; +import java.util.StringJoiner; + import com.fasterxml.jackson.annotation.JsonProperty; import com.optimizely.ab.annotations.VisibleForTesting; -import java.util.StringJoiner; - public class DecisionMetadata { @JsonProperty("flag_key") @@ -33,17 +33,20 @@ public class DecisionMetadata { String variationKey; @JsonProperty("enabled") boolean enabled; + @JsonProperty("cmab_uuid") + String cmabUUID; @VisibleForTesting public DecisionMetadata() { } - public DecisionMetadata(String flagKey, String ruleKey, String ruleType, String variationKey, boolean enabled) { + public DecisionMetadata(String flagKey, String ruleKey, String ruleType, String variationKey, boolean enabled, String cmabUUID) { this.flagKey = flagKey; this.ruleKey = ruleKey; this.ruleType = ruleType; this.variationKey = variationKey; this.enabled = enabled; + this.cmabUUID = cmabUUID; } public String getRuleType() { @@ -66,6 +69,10 @@ public String getVariationKey() { return variationKey; } + public String getCmabUUID() { + return cmabUUID; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -77,6 +84,7 @@ public boolean equals(Object o) { if (!ruleKey.equals(that.ruleKey)) return false; if (!flagKey.equals(that.flagKey)) return false; if (enabled != that.enabled) return false; + if (!java.util.Objects.equals(cmabUUID, that.cmabUUID)) return false; return variationKey.equals(that.variationKey); } @@ -86,6 +94,7 @@ public int hashCode() { result = 31 * result + flagKey.hashCode(); result = 31 * result + ruleKey.hashCode(); result = 31 * result + variationKey.hashCode(); + result = 31 * result + (cmabUUID != null ? cmabUUID.hashCode() : 0); return result; } @@ -97,6 +106,7 @@ public String toString() { .add("ruleType='" + ruleType + "'") .add("variationKey='" + variationKey + "'") .add("enabled=" + enabled) + .add("cmabUUID='" + cmabUUID + "'") .toString(); } @@ -108,6 +118,7 @@ public static class Builder { private String flagKey; private String variationKey; private boolean enabled; + private String cmabUUID; public Builder setEnabled(boolean enabled) { this.enabled = enabled; @@ -134,8 +145,13 @@ public Builder setVariationKey(String variationKey) { return this; } + public Builder setCmabUUID(String cmabUUID){ + this.cmabUUID = cmabUUID; + return this; + } + public DecisionMetadata build() { - return new DecisionMetadata(flagKey, ruleKey, ruleType, variationKey, enabled); + return new DecisionMetadata(flagKey, ruleKey, ruleType, variationKey, enabled, cmabUUID); } } } diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/AsyncDecisionFetcher.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/AsyncDecisionFetcher.java new file mode 100644 index 000000000..703b20bbd --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/AsyncDecisionFetcher.java @@ -0,0 +1,87 @@ +/** + * Copyright 2025, Optimizely and contributors + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelydecision; + +import com.optimizely.ab.OptimizelyUserContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nonnull; +import java.util.List; + +/** + * AsyncDecisionFetcher handles asynchronous decision fetching for a single flag key. + * This class follows the same pattern as ODP's async segment fetching. + */ +public class AsyncDecisionFetcher extends Thread { + private static final Logger logger = LoggerFactory.getLogger(AsyncDecisionFetcher.class); + + private final String key; + private final List options; + private final OptimizelyDecisionCallback callback; + private final OptimizelyUserContext userContext; + + /** + * Constructor for async decision fetching. + * + * @param userContext The user context to make decisions for + * @param key The flag key to decide on + * @param options Decision options + * @param callback Callback to invoke when decision is ready + */ + public AsyncDecisionFetcher(@Nonnull OptimizelyUserContext userContext, + @Nonnull String key, + @Nonnull List options, + @Nonnull OptimizelyDecisionCallback callback) { + this.userContext = userContext; + this.key = key; + this.options = options; + this.callback = callback; + + // Set thread name for debugging + setName("AsyncDecisionFetcher-" + key); + + // Set as daemon thread so it doesn't prevent JVM shutdown + setDaemon(true); + } + + @Override + public void run() { + try { + OptimizelyDecision decision = userContext.decide(key, options); + callback.onCompleted(decision); + } catch (Exception e) { + logger.error("Error in async decision fetching for key: " + key, e); + // Create an error decision and pass it to the callback + OptimizelyDecision errorDecision = createErrorDecision(key, e.getMessage()); + callback.onCompleted(errorDecision); + } + } + + /** + * Creates an error decision when async operation fails. + * This follows the same pattern as sync methods - return a decision with error info. + * + * @param key The flag key that failed + * @param errorMessage The error message + * @return An OptimizelyDecision with error information + */ + private OptimizelyDecision createErrorDecision(String key, String errorMessage) { + // We'll create a decision with null variation and include the error in reasons + // This mirrors how the sync methods handle errors + return OptimizelyDecision.newErrorDecision(key, userContext, "Async decision error: " + errorMessage); + } +} \ No newline at end of file diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/AsyncDecisionsFetcher.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/AsyncDecisionsFetcher.java new file mode 100644 index 000000000..270a027da --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/AsyncDecisionsFetcher.java @@ -0,0 +1,100 @@ +/** + * Copyright 2025, Optimizely and contributors + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelydecision; + +import com.optimizely.ab.OptimizelyUserContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * AsyncDecisionsFetcher handles asynchronous decision fetching for multiple flag keys. + * This class follows the same pattern as ODP's async segment fetching. + */ +public class AsyncDecisionsFetcher extends Thread { + private static final Logger logger = LoggerFactory.getLogger(AsyncDecisionsFetcher.class); + + private final List keys; + private final List options; + private final OptimizelyDecisionsCallback callback; + private final OptimizelyUserContext userContext; + private final boolean decideAll; + + /** + * Constructor for deciding on specific keys. + * + * @param userContext The user context to make decisions for + * @param keys List of flag keys to decide on + * @param options Decision options + * @param callback Callback to invoke when decisions are ready + */ + public AsyncDecisionsFetcher(@Nonnull OptimizelyUserContext userContext, + @Nonnull List keys, + @Nonnull List options, + @Nonnull OptimizelyDecisionsCallback callback) { + this(userContext, keys, options, callback, false); + } + + /** + * Constructor for deciding on all flags or specific keys. + * + * @param userContext The user context to make decisions for + * @param keys List of flag keys to decide on (null for decideAll) + * @param options Decision options + * @param callback Callback to invoke when decisions are ready + * @param decideAll Whether to decide for all active flags + */ + public AsyncDecisionsFetcher(@Nonnull OptimizelyUserContext userContext, + @Nullable List keys, + @Nonnull List options, + @Nonnull OptimizelyDecisionsCallback callback, + boolean decideAll) { + this.userContext = userContext; + this.keys = keys; + this.options = options; + this.callback = callback; + this.decideAll = decideAll; + + // Set thread name for debugging + String threadName = decideAll ? "AsyncDecisionsFetcher-all" : "AsyncDecisionsFetcher-keys"; + setName(threadName); + + // Set as daemon thread so it doesn't prevent JVM shutdown + setDaemon(true); + } + + @Override + public void run() { + try { + Map decisions; + if (decideAll) { + decisions = userContext.decideAll(options); + } else { + decisions = userContext.decideForKeys(keys, options); + } + callback.onCompleted(decisions); + } catch (Exception e) { + logger.error("Error in async decisions fetching", e); + // Return empty map on error - this follows the pattern of sync methods + callback.onCompleted(Collections.emptyMap()); + } + } +} \ No newline at end of file diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionMessage.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionMessage.java index c66be6bee..0c0a1b523 100644 --- a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionMessage.java +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionMessage.java @@ -20,7 +20,8 @@ public enum DecisionMessage { SDK_NOT_READY("Optimizely SDK not configured properly yet."), FLAG_KEY_INVALID("No flag was found for key \"%s\"."), - VARIABLE_VALUE_INVALID("Variable value for key \"%s\" is invalid or wrong type."); + VARIABLE_VALUE_INVALID("Variable value for key \"%s\" is invalid or wrong type."), + CMAB_ERROR("Failed to fetch CMAB data for experiment %s."); private String format; diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionResponse.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionResponse.java index fee8aa32b..c67c7f95a 100644 --- a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionResponse.java +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionResponse.java @@ -22,18 +22,26 @@ public class DecisionResponse { private T result; private DecisionReasons reasons; + private boolean error; + private String cmabUUID; - public DecisionResponse(@Nullable T result, @Nonnull DecisionReasons reasons) { + public DecisionResponse(@Nullable T result, @Nonnull DecisionReasons reasons, @Nonnull boolean error, @Nullable String cmabUUID) { this.result = result; this.reasons = reasons; + this.error = error; + this.cmabUUID = cmabUUID; } - public static DecisionResponse responseNoReasons(@Nullable E result) { - return new DecisionResponse(result, DefaultDecisionReasons.newInstance()); + public DecisionResponse(@Nullable T result, @Nonnull DecisionReasons reasons) { + this(result, reasons, false, null); } - public static DecisionResponse nullNoReasons() { - return new DecisionResponse(null, DefaultDecisionReasons.newInstance()); + public static DecisionResponse responseNoReasons(@Nullable E result) { + return new DecisionResponse<>(result, DefaultDecisionReasons.newInstance(), false, null); + } + + public static DecisionResponse nullNoReasons() { + return new DecisionResponse<>(null, DefaultDecisionReasons.newInstance(), false, null); } @Nullable @@ -45,4 +53,14 @@ public T getResult() { public DecisionReasons getReasons() { return reasons; } + + @Nonnull + public boolean isError(){ + return error; + } + + @Nullable + public String getCmabUUID() { + return cmabUUID; + } } diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecisionCallback.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecisionCallback.java new file mode 100644 index 000000000..17a0f5afc --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecisionCallback.java @@ -0,0 +1,29 @@ +/** + * + * Copyright 2025, Optimizely and contributors + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelydecision; + +import javax.annotation.Nonnull; + +@FunctionalInterface +public interface OptimizelyDecisionCallback { + /** + * Called when an async decision operation completes. + * + * @param decision The decision result + */ + void onCompleted(@Nonnull OptimizelyDecision decision); +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecisionsCallback.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecisionsCallback.java new file mode 100644 index 000000000..2f6305e10 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecisionsCallback.java @@ -0,0 +1,32 @@ +/** + * Copyright 2024, Optimizely and contributors + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelydecision; + +import javax.annotation.Nonnull; +import java.util.Map; + +/** + * Callback interface for async multiple decisions operations. + */ +@FunctionalInterface +public interface OptimizelyDecisionsCallback { + /** + * Called when an async multiple decisions operation completes. + * + * @param decisions Map of flag keys to decision results + */ + void onCompleted(@Nonnull Map decisions); +} \ No newline at end of file diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java index b444dbc26..1f0b35b5e 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -37,7 +37,10 @@ import com.optimizely.ab.odp.ODPEvent; import com.optimizely.ab.odp.ODPEventManager; import com.optimizely.ab.odp.ODPManager; +import com.optimizely.ab.optimizelydecision.DecisionReasons; import com.optimizely.ab.optimizelydecision.DecisionResponse; +import com.optimizely.ab.optimizelydecision.DefaultDecisionReasons; +import com.optimizely.ab.optimizelydecision.OptimizelyDecision; import com.optimizely.ab.optimizelyjson.OptimizelyJSON; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.junit.Before; @@ -4993,4 +4996,55 @@ public void identifyUser() { optimizely.identifyUser("the-user"); Mockito.verify(mockODPEventManager, times(1)).identifyUser("the-user"); } + + @Test + public void testDecideReturnsErrorDecisionWhenDecisionServiceFails() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + // Use the CMAB datafile + Optimizely optimizely = Optimizely.builder() + .withDatafile(validConfigJsonCMAB()) + .withDecisionService(mockDecisionService) + .build(); + + // Mock decision service to return an error from CMAB + DecisionReasons reasons = new DefaultDecisionReasons(); + FeatureDecision errorFeatureDecision = new FeatureDecision(new Experiment("123", "exp-cmab", "123"), null, FeatureDecision.DecisionSource.ROLLOUT); + DecisionResponse errorDecisionResponse = new DecisionResponse<>( + errorFeatureDecision, + reasons, + true, + null + ); + + // Mock validatedForcedDecision to return no forced decision (but not null!) + DecisionResponse noForcedDecision = new DecisionResponse<>(null, new DefaultDecisionReasons()); + when(mockDecisionService.validatedForcedDecision( + any(OptimizelyDecisionContext.class), + any(ProjectConfig.class), + any(OptimizelyUserContext.class) + )).thenReturn(noForcedDecision); + + // Mock getVariationsForFeatureList to return the error decision + when(mockDecisionService.getVariationsForFeatureList( + any(List.class), + any(OptimizelyUserContext.class), + any(ProjectConfig.class), + any(List.class) + )).thenReturn(Arrays.asList(errorDecisionResponse)); + + + // Use the feature flag from your CMAB config + OptimizelyUserContext userContext = optimizely.createUserContext("test_user"); + OptimizelyDecision decision = userContext.decide("feature_1"); // This is the feature flag key from cmab-config.json + + System.out.println("reasons: " + decision.getReasons()); + // Verify the decision contains the error information + assertFalse(decision.getEnabled()); + assertNull(decision.getVariationKey()); + assertNull(decision.getRuleKey()); + assertEquals("feature_1", decision.getFlagKey()); + assertTrue(decision.getReasons().contains("Failed to fetch CMAB data for experiment exp-cmab.")); + } + } diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java index a0b555d66..8070073c7 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java @@ -57,6 +57,9 @@ import static junit.framework.TestCase.assertEquals; import static junit.framework.TestCase.assertTrue; import static org.junit.Assert.*; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyObject; +import static org.mockito.Matchers.eq; import static org.mockito.Mockito.*; public class OptimizelyUserContextTest { @@ -2267,4 +2270,458 @@ public void decide_all_with_holdout() throws Exception { assertEquals("Expected exactly the included flags to be in holdout", includedInHoldout.size(), holdoutCount); logbackVerifier.expectMessage(Level.INFO, expectedReason); } + + @Test + public void decideSync_featureTest() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + String flagKey = "feature_2"; + String experimentKey = "exp_no_audience"; + String variationKey = "variation_with_traffic"; + String experimentId = "10420810910"; + String variationId = "10418551353"; + OptimizelyJSON variablesExpected = optimizely.getAllFeatureVariables(flagKey, userId); + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decideSync(flagKey); + + assertEquals(decision.getVariationKey(), variationKey); + assertTrue(decision.getEnabled()); + assertEquals(decision.getVariables().toMap(), variablesExpected.toMap()); + assertEquals(decision.getRuleKey(), experimentKey); + assertEquals(decision.getFlagKey(), flagKey); + assertEquals(decision.getUserContext(), user); + assertTrue(decision.getReasons().isEmpty()); + + DecisionMetadata metadata = new DecisionMetadata.Builder() + .setFlagKey(flagKey) + .setRuleKey(experimentKey) + .setRuleType(FeatureDecision.DecisionSource.FEATURE_TEST.toString()) + .setVariationKey(variationKey) + .setEnabled(true) + .build(); + eventHandler.expectImpression(experimentId, variationId, userId, Collections.emptyMap(), metadata); + } + + @Test + public void decideForKeysSync_multipleFlags() { + String flagKey1 = "feature_1"; + String flagKey2 = "feature_2"; + + List flagKeys = Arrays.asList(flagKey1, flagKey2); + OptimizelyJSON variablesExpected1 = optimizely.getAllFeatureVariables(flagKey1, userId); + OptimizelyJSON variablesExpected2 = optimizely.getAllFeatureVariables(flagKey2, userId); + + OptimizelyUserContext user = optimizely.createUserContext(userId, Collections.singletonMap("gender", "f")); + Map decisions = user.decideForKeysSync(flagKeys); + + assertEquals(decisions.size(), 2); + + assertEquals( + decisions.get(flagKey1), + new OptimizelyDecision("a", + true, + variablesExpected1, + "exp_with_audience", + flagKey1, + user, + Collections.emptyList())); + assertEquals( + decisions.get(flagKey2), + new OptimizelyDecision("variation_with_traffic", + true, + variablesExpected2, + "exp_no_audience", + flagKey2, + user, + Collections.emptyList())); + } + + @Test + public void decideForKeysSync_withOptions() { + String flagKey1 = "feature_1"; + String flagKey2 = "feature_2"; + + List flagKeys = Arrays.asList(flagKey1, flagKey2); + List options = Arrays.asList(OptimizelyDecideOption.EXCLUDE_VARIABLES); + + OptimizelyUserContext user = optimizely.createUserContext(userId, Collections.singletonMap("gender", "f")); + Map decisions = user.decideForKeysSync(flagKeys, options); + + assertEquals(decisions.size(), 2); + + // Both decisions should have empty variables due to EXCLUDE_VARIABLES option + OptimizelyDecision decision1 = decisions.get(flagKey1); + OptimizelyDecision decision2 = decisions.get(flagKey2); + + assertTrue(decision1.getVariables().toMap().isEmpty()); + assertTrue(decision2.getVariables().toMap().isEmpty()); + assertEquals(decision1.getVariationKey(), "a"); + assertEquals(decision2.getVariationKey(), "variation_with_traffic"); + } + + @Test + public void decideAllSync_allFlags() { + EventProcessor mockEventProcessor = mock(EventProcessor.class); + + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(mockEventProcessor) + .build(); + + String flagKey1 = "feature_1"; + String flagKey2 = "feature_2"; + String flagKey3 = "feature_3"; + Map attributes = Collections.singletonMap("gender", "f"); + + OptimizelyJSON variablesExpected1 = optimizely.getAllFeatureVariables(flagKey1, userId); + OptimizelyJSON variablesExpected2 = optimizely.getAllFeatureVariables(flagKey2, userId); + OptimizelyJSON variablesExpected3 = new OptimizelyJSON(Collections.emptyMap()); + + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + Map decisions = user.decideAllSync(); + assertEquals(decisions.size(), 3); + + assertEquals( + decisions.get(flagKey1), + new OptimizelyDecision( + "a", + true, + variablesExpected1, + "exp_with_audience", + flagKey1, + user, + Collections.emptyList())); + assertEquals( + decisions.get(flagKey2), + new OptimizelyDecision( + "variation_with_traffic", + true, + variablesExpected2, + "exp_no_audience", + flagKey2, + user, + Collections.emptyList())); + assertEquals( + decisions.get(flagKey3), + new OptimizelyDecision( + null, + false, + variablesExpected3, + null, + flagKey3, + user, + Collections.emptyList())); + + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(ImpressionEvent.class); + verify(mockEventProcessor, times(3)).process(argumentCaptor.capture()); + + List sentEvents = argumentCaptor.getAllValues(); + assertEquals(sentEvents.size(), 3); + + assertEquals(sentEvents.get(0).getExperimentKey(), "exp_with_audience"); + assertEquals(sentEvents.get(0).getVariationKey(), "a"); + assertEquals(sentEvents.get(0).getUserContext().getUserId(), userId); + + assertEquals(sentEvents.get(1).getExperimentKey(), "exp_no_audience"); + assertEquals(sentEvents.get(1).getVariationKey(), "variation_with_traffic"); + assertEquals(sentEvents.get(1).getUserContext().getUserId(), userId); + + assertEquals(sentEvents.get(2).getExperimentKey(), ""); + assertEquals(sentEvents.get(2).getUserContext().getUserId(), userId); + } + + @Test + public void decideAllSync_withOptions() { + String flagKey1 = "feature_1"; + OptimizelyJSON variablesExpected1 = optimizely.getAllFeatureVariables(flagKey1, userId); + + OptimizelyUserContext user = optimizely.createUserContext(userId, Collections.singletonMap("gender", "f")); + Map decisions = user.decideAllSync(Arrays.asList(OptimizelyDecideOption.ENABLED_FLAGS_ONLY)); + + assertEquals(decisions.size(), 2); // Only enabled flags + + assertEquals( + decisions.get(flagKey1), + new OptimizelyDecision( + "a", + true, + variablesExpected1, + "exp_with_audience", + flagKey1, + user, + Collections.emptyList())); + } + + @Test + public void decideAllSync_ups_batching() throws Exception { + UserProfileService ups = mock(UserProfileService.class); + + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withUserProfileService(ups) + .build(); + + Map attributes = Collections.singletonMap("gender", "f"); + + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + Map decisions = user.decideAllSync(); + + assertEquals(decisions.size(), 3); + + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(Map.class); + + verify(ups, times(1)).lookup(userId); + verify(ups, times(1)).save(argumentCaptor.capture()); + + Map savedUps = argumentCaptor.getValue(); + UserProfile savedProfile = UserProfileUtils.convertMapToUserProfile(savedUps); + + assertEquals(savedProfile.userId, userId); + } + + @Test + public void decideSync_sdkNotReady() { + String flagKey = "feature_1"; + + Optimizely optimizely = new Optimizely.Builder().build(); + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decideSync(flagKey); + + assertNull(decision.getVariationKey()); + assertFalse(decision.getEnabled()); + assertTrue(decision.getVariables().isEmpty()); + assertEquals(decision.getFlagKey(), flagKey); + assertEquals(decision.getUserContext(), user); + + assertEquals(decision.getReasons().size(), 1); + assertEquals(decision.getReasons().get(0), DecisionMessage.SDK_NOT_READY.reason()); + } + + @Test + public void decideForKeysSync_sdkNotReady() { + List flagKeys = Arrays.asList("feature_1"); + + Optimizely optimizely = new Optimizely.Builder().build(); + OptimizelyUserContext user = optimizely.createUserContext(userId); + Map decisions = user.decideForKeysSync(flagKeys); + + assertEquals(decisions.size(), 0); + } + @Test + public void decideSync_bypassUPS() throws Exception { + String flagKey = "feature_2"; // embedding experiment: "exp_no_audience" + String experimentId = "10420810910"; // "exp_no_audience" + String variationId1 = "10418551353"; + String variationId2 = "10418510624"; + String variationKey1 = "variation_with_traffic"; + String variationKey2 = "variation_no_traffic"; + + UserProfileService ups = mock(UserProfileService.class); + when(ups.lookup(userId)).thenReturn(createUserProfileMap(experimentId, variationId2)); + + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withUserProfileService(ups) + .build(); + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decideSync(flagKey); + // should return variationId2 set by UPS + assertEquals(decision.getVariationKey(), variationKey2); + + decision = user.decideSync(flagKey, Arrays.asList(OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE)); + // should ignore variationId2 set by UPS and return variationId1 + assertEquals(decision.getVariationKey(), variationKey1); + // also should not save either + verify(ups, never()).save(anyObject()); + } + + @Test + public void decideAsync_featureTest() throws InterruptedException { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + String flagKey = "feature_2"; + String experimentKey = "exp_no_audience"; + String variationKey = "variation_with_traffic"; + String experimentId = "10420810910"; + String variationId = "10418551353"; + OptimizelyJSON variablesExpected = optimizely.getAllFeatureVariables(flagKey, userId); + + OptimizelyUserContext user = optimizely.createUserContext(userId); + + CountDownLatch latch = new CountDownLatch(1); + final OptimizelyDecision[] result = new OptimizelyDecision[1]; + + user.decideAsync(flagKey, decision -> { + result[0] = decision; + latch.countDown(); + }); + + assertTrue(latch.await(1, java.util.concurrent.TimeUnit.SECONDS)); + OptimizelyDecision decision = result[0]; + + assertEquals(decision.getVariationKey(), variationKey); + assertTrue(decision.getEnabled()); + assertEquals(decision.getVariables().toMap(), variablesExpected.toMap()); + assertEquals(decision.getRuleKey(), experimentKey); + assertEquals(decision.getFlagKey(), flagKey); + assertEquals(decision.getUserContext(), user); + assertTrue(decision.getReasons().isEmpty()); + + DecisionMetadata metadata = new DecisionMetadata.Builder() + .setFlagKey(flagKey) + .setRuleKey(experimentKey) + .setRuleType(FeatureDecision.DecisionSource.FEATURE_TEST.toString()) + .setVariationKey(variationKey) + .setEnabled(true) + .build(); + eventHandler.expectImpression(experimentId, variationId, userId, Collections.emptyMap(), metadata); + } + + @Test + public void decideAsync_sdkNotReady() throws InterruptedException { + String flagKey = "feature_1"; + + Optimizely optimizely = new Optimizely.Builder().build(); + OptimizelyUserContext user = optimizely.createUserContext(userId); + + CountDownLatch latch = new CountDownLatch(1); + final OptimizelyDecision[] result = new OptimizelyDecision[1]; + + user.decideAsync(flagKey, decision -> { + result[0] = decision; + latch.countDown(); + }); + + assertTrue(latch.await(1, java.util.concurrent.TimeUnit.SECONDS)); + OptimizelyDecision decision = result[0]; + + assertNull(decision.getVariationKey()); + assertFalse(decision.getEnabled()); + assertTrue(decision.getVariables().isEmpty()); + assertEquals(decision.getFlagKey(), flagKey); + assertEquals(decision.getUserContext(), user); + } + + @Test + public void decideForKeysAsync_multipleFlags() throws InterruptedException { + String flagKey1 = "feature_1"; + String flagKey2 = "feature_2"; + + List flagKeys = Arrays.asList(flagKey1, flagKey2); + OptimizelyJSON variablesExpected1 = optimizely.getAllFeatureVariables(flagKey1, userId); + OptimizelyJSON variablesExpected2 = optimizely.getAllFeatureVariables(flagKey2, userId); + + OptimizelyUserContext user = optimizely.createUserContext(userId, Collections.singletonMap("gender", "f")); + + CountDownLatch latch = new CountDownLatch(1); + final Map[] result = new Map[1]; + + user.decideForKeysAsync(flagKeys, decisions -> { + result[0] = decisions; + latch.countDown(); + }); + + assertTrue(latch.await(1, java.util.concurrent.TimeUnit.SECONDS)); + Map decisions = result[0]; + + assertEquals(decisions.size(), 2); + + assertEquals( + decisions.get(flagKey1), + new OptimizelyDecision("a", + true, + variablesExpected1, + "exp_with_audience", + flagKey1, + user, + Collections.emptyList())); + assertEquals( + decisions.get(flagKey2), + new OptimizelyDecision("variation_with_traffic", + true, + variablesExpected2, + "exp_no_audience", + flagKey2, + user, + Collections.emptyList())); + } + + @Test + public void decideForKeysAsync_withOptions() throws InterruptedException { + String flagKey1 = "feature_1"; + String flagKey2 = "feature_2"; + + List flagKeys = Arrays.asList(flagKey1, flagKey2); + List options = Arrays.asList(OptimizelyDecideOption.EXCLUDE_VARIABLES); + + OptimizelyUserContext user = optimizely.createUserContext(userId, Collections.singletonMap("gender", "f")); + + CountDownLatch latch = new CountDownLatch(1); + final Map[] result = new Map[1]; + + user.decideForKeysAsync(flagKeys, decisions -> { + result[0] = decisions; + latch.countDown(); + }, options); + + assertTrue(latch.await(1, java.util.concurrent.TimeUnit.SECONDS)); + Map decisions = result[0]; + + assertEquals(decisions.size(), 2); + + // Both decisions should have empty variables due to EXCLUDE_VARIABLES option + OptimizelyDecision decision1 = decisions.get(flagKey1); + OptimizelyDecision decision2 = decisions.get(flagKey2); + + assertTrue(decision1.getVariables().toMap().isEmpty()); + assertTrue(decision2.getVariables().toMap().isEmpty()); + assertEquals(decision1.getVariationKey(), "a"); + assertEquals(decision2.getVariationKey(), "variation_with_traffic"); + } + + @Test + public void decideForKeysAsync_sdkNotReady() throws InterruptedException { + List flagKeys = Arrays.asList("feature_1"); + + Optimizely optimizely = new Optimizely.Builder().build(); + OptimizelyUserContext user = optimizely.createUserContext(userId); + + CountDownLatch latch = new CountDownLatch(1); + final Map[] result = new Map[1]; + + user.decideForKeysAsync(flagKeys, decisions -> { + result[0] = decisions; + latch.countDown(); + }); + + assertTrue(latch.await(1, java.util.concurrent.TimeUnit.SECONDS)); + Map decisions = result[0]; + + assertEquals(decisions.size(), 0); + } + + @Test + public void decideAllAsync_callback_exception() throws InterruptedException { + OptimizelyUserContext user = optimizely.createUserContext(userId, Collections.singletonMap("gender", "f")); + + CountDownLatch latch = new CountDownLatch(1); + final boolean[] callbackExecuted = new boolean[1]; + + user.decideAllAsync(decisions -> { + callbackExecuted[0] = true; + latch.countDown(); + throw new RuntimeException("Test exception in callback"); + }); + + assertTrue(latch.await(1, java.util.concurrent.TimeUnit.SECONDS)); + assertTrue(callbackExecuted[0]); + } } diff --git a/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java b/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java index 220a62efa..e07296384 100644 --- a/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java +++ b/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java @@ -34,6 +34,7 @@ import org.junit.Rule; import org.junit.Test; import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyBoolean; import static org.mockito.Matchers.anyMapOf; import static org.mockito.Matchers.anyObject; import static org.mockito.Matchers.anyString; @@ -55,6 +56,9 @@ import com.optimizely.ab.OptimizelyDecisionContext; import com.optimizely.ab.OptimizelyForcedDecision; import com.optimizely.ab.OptimizelyUserContext; +import com.optimizely.ab.cmab.service.CmabService; +import com.optimizely.ab.cmab.service.CmabDecision; +import com.optimizely.ab.config.Cmab; import com.optimizely.ab.config.DatafileProjectConfigTestUtils; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.noAudienceProjectConfigV3; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV3; @@ -109,6 +113,9 @@ public class DecisionServiceTest { @Mock private ErrorHandler mockErrorHandler; + @Mock + private CmabService mockCmabService; + private ProjectConfig noAudienceProjectConfig; private ProjectConfig v4ProjectConfig; private ProjectConfig validProjectConfig; @@ -129,7 +136,7 @@ public void setUp() throws Exception { whitelistedExperiment = validProjectConfig.getExperimentIdMapping().get("223"); whitelistedVariation = whitelistedExperiment.getVariationKeyToVariationMap().get("vtag1"); Bucketer bucketer = new Bucketer(); - decisionService = spy(new DecisionService(bucketer, mockErrorHandler, null)); + decisionService = spy(new DecisionService(bucketer, mockErrorHandler, null, mockCmabService)); this.optimizely = Optimizely.builder().build(); } @@ -224,7 +231,8 @@ public void getVariationForcedBeforeUserProfile() throws Exception { UserProfileService userProfileService = mock(UserProfileService.class); when(userProfileService.lookup(userProfileId)).thenReturn(userProfile.toMap()); - DecisionService decisionService = spy(new DecisionService(new Bucketer(), mockErrorHandler, userProfileService)); + CmabService cmabService = mock(CmabService.class); + DecisionService decisionService = spy(new DecisionService(new Bucketer(), mockErrorHandler, userProfileService, cmabService)); // ensure that normal users still get excluded from the experiment when they fail audience evaluation assertNull(decisionService.getVariation(experiment, optimizely.createUserContext(genericUserId, Collections.emptyMap()), validProjectConfig).getResult()); @@ -255,7 +263,8 @@ public void getVariationEvaluatesUserProfileBeforeAudienceTargeting() throws Exc UserProfileService userProfileService = mock(UserProfileService.class); when(userProfileService.lookup(userProfileId)).thenReturn(userProfile.toMap()); - DecisionService decisionService = spy(new DecisionService(new Bucketer(), mockErrorHandler, userProfileService)); + CmabService cmabService = mock(CmabService.class); + DecisionService decisionService = spy(new DecisionService(new Bucketer(), mockErrorHandler, userProfileService, cmabService)); // ensure that normal users still get excluded from the experiment when they fail audience evaluation assertNull(decisionService.getVariation(experiment, optimizely.createUserContext(genericUserId, Collections.emptyMap()), validProjectConfig).getResult()); @@ -351,7 +360,8 @@ public void getVariationForFeatureReturnsNullWhenItGetsNoVariationsForExperiment any(ProjectConfig.class), anyObject(), anyObject(), - any(DecisionReasons.class) + any(DecisionReasons.class), + anyBoolean() ); // do not bucket to any rollouts doReturn(DecisionResponse.responseNoReasons(new FeatureDecision(null, null, null))).when(decisionService).getVariationForFeatureInRollout( @@ -390,14 +400,16 @@ public void getVariationForFeatureReturnsVariationReturnedFromGetVariation() { eq(ValidProjectConfigV4.EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1), any(OptimizelyUserContext.class), any(ProjectConfig.class), - anyObject() + anyObject(), + anyBoolean() ); doReturn(DecisionResponse.responseNoReasons(ValidProjectConfigV4.VARIATION_MUTEX_GROUP_EXP_2_VAR_1)).when(decisionService).getVariation( eq(ValidProjectConfigV4.EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2), any(OptimizelyUserContext.class), any(ProjectConfig.class), - anyObject() + anyObject(), + anyBoolean() ); FeatureDecision featureDecision = decisionService.getVariationForFeature( @@ -437,7 +449,8 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() any(ProjectConfig.class), anyObject(), anyObject(), - any(DecisionReasons.class) + any(DecisionReasons.class), + anyBoolean() ); // return variation for rollout @@ -471,7 +484,8 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() any(ProjectConfig.class), anyObject(), anyObject(), - any(DecisionReasons.class) + any(DecisionReasons.class), + anyBoolean() ); } @@ -498,7 +512,8 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails any(ProjectConfig.class), anyObject(), anyObject(), - any(DecisionReasons.class) + any(DecisionReasons.class), + anyBoolean() ); // return variation for rollout @@ -532,7 +547,8 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails any(ProjectConfig.class), anyObject(), anyObject(), - any(DecisionReasons.class) + any(DecisionReasons.class), + anyBoolean() ); logbackVerifier.expectMessage( @@ -550,7 +566,7 @@ public void getVariationsForFeatureListBatchesUpsLoadAndSave() throws Exception ErrorHandler mockErrorHandler = mock(ErrorHandler.class); UserProfileService mockUserProfileService = mock(UserProfileService.class); - DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, mockUserProfileService); + DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, mockUserProfileService, null); FeatureFlag featureFlag1 = FEATURE_FLAG_MULTI_VARIATE_FEATURE; FeatureFlag featureFlag2 = FEATURE_FLAG_MULTI_VARIATE_FUTURE_FEATURE; @@ -609,6 +625,7 @@ public void getVariationForFeatureInRolloutReturnsNullWhenUserIsExcludedFromAllT DecisionService decisionService = new DecisionService( mockBucketer, mockErrorHandler, + null, null ); @@ -636,7 +653,7 @@ public void getVariationForFeatureInRolloutReturnsNullWhenUserFailsAllAudiencesA Bucketer mockBucketer = mock(Bucketer.class); when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class))).thenReturn(DecisionResponse.nullNoReasons()); - DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null); + DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null, null); FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( FEATURE_FLAG_MULTI_VARIATE_FEATURE, @@ -666,6 +683,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsAllAudie DecisionService decisionService = new DecisionService( mockBucketer, mockErrorHandler, + null, null ); @@ -707,6 +725,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficI DecisionService decisionService = new DecisionService( mockBucketer, mockErrorHandler, + null, null ); @@ -747,6 +766,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficI DecisionService decisionService = new DecisionService( mockBucketer, mockErrorHandler, + null, null ); @@ -786,7 +806,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTargetin when(mockBucketer.bucket(eq(everyoneElseRule), anyString(), any(ProjectConfig.class))).thenReturn(DecisionResponse.responseNoReasons(everyoneElseVariation)); when(mockBucketer.bucket(eq(englishCitizensRule), anyString(), any(ProjectConfig.class))).thenReturn(DecisionResponse.responseNoReasons(englishCitizenVariation)); - DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null); + DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null, null); FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( FEATURE_FLAG_MULTI_VARIATE_FEATURE, @@ -939,7 +959,7 @@ public void bucketReturnsVariationStoredInUserProfile() throws Exception { when(userProfileService.lookup(userProfileId)).thenReturn(userProfile.toMap()); Bucketer bucketer = new Bucketer(); - DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, userProfileService); + DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, userProfileService, null); logbackVerifier.expectMessage(Level.INFO, "Returning previously activated variation \"" + variation.getKey() + "\" of experiment \"" + experiment.getKey() + "\"" @@ -965,7 +985,7 @@ public void getStoredVariationLogsWhenLookupReturnsNull() throws Exception { UserProfile userProfile = new UserProfile(userProfileId, Collections.emptyMap()); when(userProfileService.lookup(userProfileId)).thenReturn(userProfile.toMap()); - DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, userProfileService); + DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, userProfileService, null); logbackVerifier.expectMessage(Level.INFO, "No previously activated variation of experiment " + "\"" + experiment.getKey() + "\" for user \"" + userProfileId + "\" found in user profile."); @@ -992,7 +1012,7 @@ public void getStoredVariationReturnsNullWhenVariationIsNoLongerInConfig() throw UserProfileService userProfileService = mock(UserProfileService.class); when(userProfileService.lookup(userProfileId)).thenReturn(storedUserProfile.toMap()); - DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, userProfileService); + DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, userProfileService, null); logbackVerifier.expectMessage(Level.INFO, "User \"" + userProfileId + "\" was previously bucketed into variation with ID \"" + storedVariationId + "\" for " + @@ -1023,7 +1043,7 @@ public void getVariationSavesBucketedVariationIntoUserProfile() throws Exception Bucketer mockBucketer = mock(Bucketer.class); when(mockBucketer.bucket(eq(experiment), eq(userProfileId), eq(noAudienceProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(variation)); - DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, userProfileService); + DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, userProfileService, null); assertEquals(variation, decisionService.getVariation( experiment, optimizely.createUserContext(userProfileId, Collections.emptyMap()), noAudienceProjectConfig).getResult() @@ -1058,7 +1078,8 @@ public void bucketLogsCorrectlyWhenUserProfileFailsToSave() throws Exception { UserProfile saveUserProfile = new UserProfile(userProfileId, new HashMap()); - DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, userProfileService); + CmabService cmabService = mock(CmabService.class); + DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, userProfileService, cmabService); decisionService.saveVariation(experiment, variation, saveUserProfile); @@ -1084,7 +1105,7 @@ public void getVariationSavesANewUserProfile() throws Exception { Bucketer bucketer = mock(Bucketer.class); UserProfileService userProfileService = mock(UserProfileService.class); - DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, userProfileService); + DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, userProfileService, null); when(bucketer.bucket(eq(experiment), eq(userProfileId), eq(noAudienceProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(variation)); when(userProfileService.lookup(userProfileId)).thenReturn(null); @@ -1096,7 +1117,7 @@ public void getVariationSavesANewUserProfile() throws Exception { @Test public void getVariationBucketingId() throws Exception { Bucketer bucketer = mock(Bucketer.class); - DecisionService decisionService = spy(new DecisionService(bucketer, mockErrorHandler, null)); + DecisionService decisionService = spy(new DecisionService(bucketer, mockErrorHandler, null, null)); Experiment experiment = validProjectConfig.getExperiments().get(0); Variation expectedVariation = experiment.getVariations().get(0); @@ -1130,6 +1151,7 @@ public void getVariationForRolloutWithBucketingId() { DecisionService decisionService = spy(new DecisionService( bucketer, mockErrorHandler, + null, null )); @@ -1285,7 +1307,7 @@ public void getVariationForFeatureReturnHoldoutDecisionForGlobalHoldout() { Bucketer mockBucketer = new Bucketer(); - DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null); + DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null, null); Map attributes = new HashMap<>(); attributes.put("$opt_bucketing_id", "ppid160000"); @@ -1307,8 +1329,8 @@ public void includedFlagsHoldoutOnlyAppliestoSpecificFlags() { ProjectConfig holdoutProjectConfig = generateValidProjectConfigV4_holdout(); Bucketer mockBucketer = new Bucketer(); - - DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null); + CmabService cmabService = mock(CmabService.class); + DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null, cmabService); Map attributes = new HashMap<>(); attributes.put("$opt_bucketing_id", "ppid120000"); @@ -1331,7 +1353,7 @@ public void excludedFlagsHoldoutAppliesToAllExceptSpecified() { Bucketer mockBucketer = new Bucketer(); - DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null); + DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null, null); Map attributes = new HashMap<>(); attributes.put("$opt_bucketing_id", "ppid300002"); @@ -1362,7 +1384,7 @@ public void userMeetsHoldoutAudienceConditions() { Bucketer mockBucketer = new Bucketer(); - DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null); + DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null, null); Map attributes = new HashMap<>(); attributes.put("$opt_bucketing_id", "ppid543400"); @@ -1381,4 +1403,324 @@ public void userMeetsHoldoutAudienceConditions() { logbackVerifier.expectMessage(Level.INFO, "User (user123) is in variation (ho_off_key) of holdout (typed_audience_holdout)."); } + + /** + * Verify that whitelisted variations take precedence over CMAB service decisions + * in CMAB experiments. + */ + @Test + public void getVariationCmabExperimentWhitelistedPrecedesCmabService() { + // Create a CMAB experiment with whitelisting + Experiment cmabExperiment = createMockCmabExperiment(); + Variation whitelistedVariation = cmabExperiment.getVariations().get(0); + + // Setup whitelisting for the test user + Map userIdToVariationKeyMap = new HashMap<>(); + userIdToVariationKeyMap.put(whitelistedUserId, whitelistedVariation.getKey()); + + // Create mock Cmab object + Cmab mockCmab = mock(Cmab.class); + + // Create experiment with whitelisting and CMAB config + Experiment experimentWithWhitelisting = new Experiment( + cmabExperiment.getId(), + cmabExperiment.getKey(), + cmabExperiment.getStatus(), + cmabExperiment.getLayerId(), + cmabExperiment.getAudienceIds(), + cmabExperiment.getAudienceConditions(), + cmabExperiment.getVariations(), + userIdToVariationKeyMap, + cmabExperiment.getTrafficAllocation(), + mockCmab // This makes it a CMAB experiment + ); + + // Mock CmabService.getDecision to return a different variation (should be ignored) + // Note: We don't need to mock anything since the user is whitelisted + + // Call getVariation + DecisionResponse result = decisionService.getVariation( + experimentWithWhitelisting, + optimizely.createUserContext(whitelistedUserId, Collections.emptyMap()), + v4ProjectConfig + ); + + // Verify whitelisted variation is returned + assertEquals(whitelistedVariation, result.getResult()); + + // Verify CmabService was never called since user is whitelisted + verify(mockCmabService, never()).getDecision(any(), any(), any(), any()); + + // Verify appropriate logging + logbackVerifier.expectMessage(Level.INFO, + "User \"" + whitelistedUserId + "\" is forced in variation \"" + + whitelistedVariation.getKey() + "\"."); + } + + /** + * Verify that forced variations take precedence over CMAB service decisions + * in CMAB experiments. + */ + @Test + public void getVariationCmabExperimentForcedPrecedesCmabService() { + // Create a CMAB experiment + Experiment cmabExperiment = createMockCmabExperiment(); + Variation forcedVariation = cmabExperiment.getVariations().get(0); + Variation cmabServiceVariation = cmabExperiment.getVariations().get(1); + + // Create mock Cmab object + Cmab mockCmab = mock(Cmab.class); + + // Create experiment with CMAB config (no whitelisting) + Experiment experiment = new Experiment( + cmabExperiment.getId(), + cmabExperiment.getKey(), + cmabExperiment.getStatus(), + cmabExperiment.getLayerId(), + cmabExperiment.getAudienceIds(), + cmabExperiment.getAudienceConditions(), + cmabExperiment.getVariations(), + Collections.emptyMap(), // No whitelisting + cmabExperiment.getTrafficAllocation(), + mockCmab // This makes it a CMAB experiment + ); + + // Set forced variation for the user + decisionService.setForcedVariation(experiment, genericUserId, forcedVariation.getKey()); + + // Mock CmabService.getDecision to return a different variation (should be ignored) + CmabDecision mockCmabDecision = mock(CmabDecision.class); + when(mockCmabDecision.getVariationId()).thenReturn(cmabServiceVariation.getId()); + when(mockCmabService.getDecision(any(), any(), any(), any())) + .thenReturn(mockCmabDecision); + + // Call getVariation + DecisionResponse result = decisionService.getVariation( + experiment, + optimizely.createUserContext(genericUserId, Collections.emptyMap()), + v4ProjectConfig + ); + + // Verify forced variation is returned (not CMAB service result) + assertEquals(forcedVariation, result.getResult()); + + // Verify CmabService was never called since user has forced variation + verify(mockCmabService, never()).getDecision(any(), any(), any(), any()); + } + + /** + * Verify that getVariation handles CMAB service errors gracefully + * and falls back appropriately when CmabService throws an exception. + */ + @Test + public void getVariationCmabExperimentServiceError() { + // Create a CMAB experiment + Experiment cmabExperiment = createMockCmabExperiment(); + + // Create mock Cmab object + Cmab mockCmab = mock(Cmab.class); + when(mockCmab.getTrafficAllocation()).thenReturn(10000); + + // Create experiment with CMAB config (no whitelisting, no forced variations) + Experiment experiment = new Experiment( + cmabExperiment.getId(), + cmabExperiment.getKey(), + cmabExperiment.getStatus(), + cmabExperiment.getLayerId(), + cmabExperiment.getAudienceIds(), + cmabExperiment.getAudienceConditions(), + cmabExperiment.getVariations(), + Collections.emptyMap(), // No whitelisting + cmabExperiment.getTrafficAllocation(), + mockCmab // This makes it a CMAB experiment + ); + + Bucketer bucketer = new Bucketer(); + DecisionService decisionServiceWithMockCmabService = new DecisionService( + bucketer, + mockErrorHandler, + null, + mockCmabService + ); + + // Mock CmabService.getDecision to throw an exception + RuntimeException cmabException = new RuntimeException("CMAB service unavailable"); + when(mockCmabService.getDecision(any(), any(), any(), any())) + .thenThrow(cmabException); + + // Call getVariation + DecisionResponse result = decisionServiceWithMockCmabService.getVariation( + experiment, + optimizely.createUserContext(genericUserId, Collections.emptyMap()), + v4ProjectConfig + ); + + // Verify that the method handles the error gracefully + // The result depends on whether the real bucketer allocates the user to CMAB traffic or not + // If user is not in CMAB traffic: result should be null + // If user is in CMAB traffic but CMAB service fails: result should be null + assertNull(result.getResult()); + + // Verify that the error is not propagated (no exception thrown) + assertTrue(result.isError()); + + // Assert that CmabService.getDecision was called exactly once + verify(mockCmabService, times(1)).getDecision(any(), any(), any(), any()); + } + + /** + * Verify that getVariation returns the variation from CMAB service + * when user is bucketed into CMAB traffic and service returns a valid decision. + */ + @Test + public void getVariationCmabExperimentServiceSuccess() { + // Create a CMAB experiment + Experiment cmabExperiment = createMockCmabExperiment(); + Variation expectedVariation = cmabExperiment.getVariations().get(1); // Use second variation + + // Create mock Cmab object + Cmab mockCmab = mock(Cmab.class); + when(mockCmab.getTrafficAllocation()).thenReturn(4000); + + // Create experiment with CMAB config (no whitelisting, no forced variations) + Experiment experiment = new Experiment( + cmabExperiment.getId(), + cmabExperiment.getKey(), + cmabExperiment.getStatus(), + cmabExperiment.getLayerId(), + cmabExperiment.getAudienceIds(), + cmabExperiment.getAudienceConditions(), + cmabExperiment.getVariations(), + Collections.emptyMap(), // No whitelisting + cmabExperiment.getTrafficAllocation(), + mockCmab // This makes it a CMAB experiment + ); + + Bucketer mockBucketer = mock(Bucketer.class); + when(mockBucketer.bucketForCmab(any(Experiment.class), anyString(), any(ProjectConfig.class))) + .thenReturn(DecisionResponse.responseNoReasons("$")); + DecisionService decisionServiceWithMockCmabService = new DecisionService( + mockBucketer, + mockErrorHandler, + null, + mockCmabService + ); + + // Mock CmabService.getDecision to return a valid decision + CmabDecision mockCmabDecision = mock(CmabDecision.class); + when(mockCmabDecision.getVariationId()).thenReturn(expectedVariation.getId()); + when(mockCmabService.getDecision(any(), any(), any(), any())) + .thenReturn(mockCmabDecision); + + // Call getVariation + DecisionResponse result = decisionServiceWithMockCmabService.getVariation( + experiment, + optimizely.createUserContext(genericUserId, Collections.emptyMap()), + v4ProjectConfig + ); + + // Verify that CMAB service decision is returned + assertEquals(expectedVariation, result.getResult()); + + // Verify that the result is not an error + assertFalse(result.isError()); + + // Assert that CmabService.getDecision was called exactly once + verify(mockCmabService, times(1)).getDecision(any(), any(), any(), any()); + + // Verify that the correct parameters were passed to CMAB service + verify(mockCmabService).getDecision( + eq(v4ProjectConfig), + any(OptimizelyUserContext.class), + eq(experiment.getId()), + any(List.class) + ); + } + + /** + * Verify that getVariation returns null when user is not bucketed into CMAB traffic + * by mocking the bucketer to return null for CMAB allocation. + */ + @Test + public void getVariationCmabExperimentUserNotInTrafficAllocation() { + // Create a CMAB experiment + Experiment cmabExperiment = createMockCmabExperiment(); + + // Create mock Cmab object + Cmab mockCmab = mock(Cmab.class); + when(mockCmab.getTrafficAllocation()).thenReturn(5000); // 50% traffic allocation + + // Create experiment with CMAB config (no whitelisting, no forced variations) + Experiment experiment = new Experiment( + cmabExperiment.getId(), + cmabExperiment.getKey(), + cmabExperiment.getStatus(), + cmabExperiment.getLayerId(), + cmabExperiment.getAudienceIds(), + cmabExperiment.getAudienceConditions(), + cmabExperiment.getVariations(), + Collections.emptyMap(), // No whitelisting + cmabExperiment.getTrafficAllocation(), + mockCmab // This makes it a CMAB experiment + ); + + // Mock bucketer to return null for CMAB allocation (user not in CMAB traffic) + Bucketer mockBucketer = mock(Bucketer.class); + when(mockBucketer.bucketForCmab(any(Experiment.class), anyString(), any(ProjectConfig.class))) + .thenReturn(DecisionResponse.nullNoReasons()); + + DecisionService decisionServiceWithMockCmabService = new DecisionService( + mockBucketer, + mockErrorHandler, + null, + mockCmabService + ); + + // Call getVariation + DecisionResponse result = decisionServiceWithMockCmabService.getVariation( + experiment, + optimizely.createUserContext(genericUserId, Collections.emptyMap()), + v4ProjectConfig + ); + + // Verify that no variation is returned (user not in CMAB traffic) + assertNull(result.getResult()); + + // Verify that the result is not an error + assertFalse(result.isError()); + + // Assert that CmabService.getDecision was never called (user not in CMAB traffic) + verify(mockCmabService, never()).getDecision(any(), any(), any(), any()); + + // Verify that bucketer was called for CMAB allocation + verify(mockBucketer, times(1)).bucketForCmab(any(Experiment.class), anyString(), any(ProjectConfig.class)); + } + + private Experiment createMockCmabExperiment() { + List variations = Arrays.asList( + new Variation("111151", "variation_1"), + new Variation("111152", "variation_2") + ); + + List trafficAllocations = Arrays.asList( + new TrafficAllocation("111151", 5000), + new TrafficAllocation("111152", 4000) + ); + + // Mock CMAB configuration + Cmab mockCmab = mock(Cmab.class); + + return new Experiment( + "111150", + "cmab_experiment", + "Running", + "111150", + Collections.emptyList(), // No audience IDs + null, // No audience conditions + variations, + Collections.emptyMap(), // No whitelisting initially + trafficAllocations, + mockCmab // This makes it a CMAB experiment + ); + } } diff --git a/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTestUtils.java b/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTestUtils.java index ef9a8ccc2..6908623b0 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTestUtils.java +++ b/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTestUtils.java @@ -424,6 +424,10 @@ public static String nullFeatureEnabledConfigJsonV4() throws IOException { return Resources.toString(Resources.getResource("config/null-featureEnabled-config-v4.json"), Charsets.UTF_8); } + public static String validConfigJsonCMAB() throws IOException { + return Resources.toString(Resources.getResource("config/cmab-config.json"), Charsets.UTF_8); + } + /** * @return the expected {@link DatafileProjectConfig} for the json produced by {@link #validConfigJsonV2()} ()} */ diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java b/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java index 08a8b7da9..ed9d32979 100644 --- a/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java +++ b/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java @@ -104,7 +104,7 @@ public void createImpressionEventPassingUserAgentAttribute() throws Exception { Map attributeMap = new HashMap(); attributeMap.put(attribute.getKey(), "value"); attributeMap.put(ControlAttribute.USER_AGENT_ATTRIBUTE.toString(), "Chrome"); - DecisionMetadata metadata = new DecisionMetadata(activatedExperiment.getKey(), activatedExperiment.getKey(), ruleType, "variationKey", true); + DecisionMetadata metadata = new DecisionMetadata(activatedExperiment.getKey(), activatedExperiment.getKey(), ruleType, "variationKey", true, null); Decision expectedDecision = new Decision.Builder() .setCampaignId(activatedExperiment.getLayerId()) .setExperimentId(activatedExperiment.getId()) @@ -1064,7 +1064,8 @@ public static LogEvent createImpressionEvent(ProjectConfig projectConfig, attributes, activatedExperiment.getKey(), "experiment", - true); + true, + null); return EventFactory.createLogEvent(userEvent); diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/UserEventFactoryTest.java b/core-api/src/test/java/com/optimizely/ab/event/internal/UserEventFactoryTest.java index a7739bb73..fc1e6a6f0 100644 --- a/core-api/src/test/java/com/optimizely/ab/event/internal/UserEventFactoryTest.java +++ b/core-api/src/test/java/com/optimizely/ab/event/internal/UserEventFactoryTest.java @@ -16,22 +16,28 @@ */ package com.optimizely.ab.event.internal; -import com.google.common.collect.ImmutableMap; -import com.optimizely.ab.config.Experiment; -import com.optimizely.ab.config.ProjectConfig; -import com.optimizely.ab.config.Variation; -import com.optimizely.ab.event.internal.payload.DecisionMetadata; -import com.optimizely.ab.internal.ReservedEventKey; +import java.util.Collections; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import org.mockito.runners.MockitoJUnitRunner; -import java.util.Collections; -import java.util.Map; - -import static org.junit.Assert.*; +import com.google.common.collect.ImmutableMap; +import com.optimizely.ab.config.Experiment; +import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.config.Variation; +import com.optimizely.ab.event.internal.payload.DecisionMetadata; +import com.optimizely.ab.internal.ReservedEventKey; @RunWith(MockitoJUnitRunner.class) @@ -67,7 +73,7 @@ public class UserEventFactoryTest { public void setUp() { experiment = new Experiment(EXPERIMENT_ID, EXPERIMENT_KEY, LAYER_ID); variation = new Variation(VARIATION_ID, VARIATION_KEY); - decisionMetadata = new DecisionMetadata("", EXPERIMENT_KEY, "experiment", VARIATION_KEY, true); + decisionMetadata = new DecisionMetadata("", EXPERIMENT_KEY, "experiment", VARIATION_KEY, true, null); } @Test @@ -81,7 +87,8 @@ public void createImpressionEventNull() { ATTRIBUTES, EXPERIMENT_KEY, "rollout", - false + false, + null ); assertNull(actual); } @@ -96,7 +103,8 @@ public void createImpressionEvent() { ATTRIBUTES, "", "experiment", - true + true, + null ); assertTrue(actual.getTimestamp() > 0); @@ -140,4 +148,102 @@ public void createConversionEvent() { assertEquals(VALUE, actual.getValue()); assertEquals(TAGS, actual.getTags()); } + @Test + public void createImpressionEventWithCmabUuid() { + // Arrange + String userId = "testUser"; + String flagKey = "testFlag"; + String ruleType = "experiment"; + boolean enabled = true; + String cmabUUID = "test-cmab-uuid-123"; + Map attributes = Collections.emptyMap(); + + // Create mock objects + ProjectConfig mockProjectConfig = mock(ProjectConfig.class); + Experiment mockExperiment = mock(Experiment.class); + Variation mockVariation = mock(Variation.class); + + // Setup mock behavior + when(mockProjectConfig.getSendFlagDecisions()).thenReturn(true); + when(mockExperiment.getLayerId()).thenReturn("layer123"); + when(mockExperiment.getId()).thenReturn("experiment123"); + when(mockExperiment.getKey()).thenReturn("experimentKey"); + when(mockVariation.getKey()).thenReturn("variationKey"); + when(mockVariation.getId()).thenReturn("variation123"); + + // Act + ImpressionEvent result = UserEventFactory.createImpressionEvent( + mockProjectConfig, + mockExperiment, + mockVariation, + userId, + attributes, + flagKey, + ruleType, + enabled, + cmabUUID + ); + + // Assert + assertNotNull(result); + + // Verify DecisionMetadata contains cmabUUID + DecisionMetadata metadata = result.getMetadata(); + assertNotNull(metadata); + assertEquals(cmabUUID, metadata.getCmabUUID()); + assertEquals(flagKey, metadata.getFlagKey()); + assertEquals("experimentKey", metadata.getRuleKey()); + assertEquals(ruleType, metadata.getRuleType()); + assertEquals("variationKey", metadata.getVariationKey()); + assertEquals(enabled, metadata.getEnabled()); + + // Verify other fields + assertEquals("layer123", result.getLayerId()); + assertEquals("experiment123", result.getExperimentId()); + assertEquals("experimentKey", result.getExperimentKey()); + assertEquals("variation123", result.getVariationId()); + assertEquals("variationKey", result.getVariationKey()); + } + + @Test + public void createImpressionEventWithNullCmabUuid() { + // Arrange + String userId = "testUser"; + String flagKey = "testFlag"; + String ruleType = "experiment"; + boolean enabled = true; + String cmabUUID = null; + Map attributes = Collections.emptyMap(); + + // Create mock objects (same setup as above) + ProjectConfig mockProjectConfig = mock(ProjectConfig.class); + Experiment mockExperiment = mock(Experiment.class); + Variation mockVariation = mock(Variation.class); + + when(mockProjectConfig.getSendFlagDecisions()).thenReturn(true); + when(mockExperiment.getLayerId()).thenReturn("layer123"); + when(mockExperiment.getId()).thenReturn("experiment123"); + when(mockExperiment.getKey()).thenReturn("experimentKey"); + when(mockVariation.getKey()).thenReturn("variationKey"); + when(mockVariation.getId()).thenReturn("variation123"); + + // Act + ImpressionEvent result = UserEventFactory.createImpressionEvent( + mockProjectConfig, + mockExperiment, + mockVariation, + userId, + attributes, + flagKey, + ruleType, + enabled, + cmabUUID + ); + + // Assert + assertNotNull(result); + DecisionMetadata metadata = result.getMetadata(); + assertNotNull(metadata); + assertNull(metadata.getCmabUUID()); + } } diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java index f26851375..1cc4080b6 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java @@ -16,22 +16,29 @@ */ package com.optimizely.ab; +import java.util.concurrent.TimeUnit; + +import org.apache.http.impl.client.CloseableHttpClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.optimizely.ab.cmab.DefaultCmabClient; +import com.optimizely.ab.cmab.client.CmabClientConfig; +import com.optimizely.ab.cmab.service.CmabCacheValue; +import com.optimizely.ab.cmab.service.CmabServiceOptions; +import com.optimizely.ab.cmab.service.DefaultCmabService; import com.optimizely.ab.config.HttpProjectConfigManager; import com.optimizely.ab.config.ProjectConfig; import com.optimizely.ab.config.ProjectConfigManager; import com.optimizely.ab.event.AsyncEventHandler; import com.optimizely.ab.event.BatchEventProcessor; import com.optimizely.ab.event.EventHandler; +import com.optimizely.ab.internal.DefaultLRUCache; import com.optimizely.ab.internal.PropertyUtils; import com.optimizely.ab.notification.NotificationCenter; import com.optimizely.ab.odp.DefaultODPApiManager; import com.optimizely.ab.odp.ODPApiManager; import com.optimizely.ab.odp.ODPManager; -import org.apache.http.impl.client.CloseableHttpClient; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.concurrent.TimeUnit; /** * OptimizelyFactory is a utility class to instantiate an {@link Optimizely} client with a minimal @@ -369,11 +376,19 @@ public static Optimizely newDefaultInstance(ProjectConfigManager configManager, .withApiManager(odpApiManager != null ? odpApiManager : new DefaultODPApiManager()) .build(); + DefaultCmabClient defaultCmabClient = new DefaultCmabClient(CmabClientConfig.withDefaultRetry()); + int DEFAULT_MAX_SIZE = 1000; + int DEFAULT_CMAB_CACHE_TIMEOUT = 30 * 60 * 1000; + DefaultLRUCache cmabCache = new DefaultLRUCache<>(DEFAULT_MAX_SIZE, DEFAULT_CMAB_CACHE_TIMEOUT); + CmabServiceOptions cmabServiceOptions = new CmabServiceOptions(logger, cmabCache, defaultCmabClient); + DefaultCmabService cmabService = new DefaultCmabService(cmabServiceOptions); + return Optimizely.builder() .withEventProcessor(eventProcessor) .withConfigManager(configManager) .withNotificationCenter(notificationCenter) .withODPManager(odpManager) + .withCmabService(cmabService) .build(); } }