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..9872d10f7 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,10 @@ import com.optimizely.ab.bucketing.DecisionService; import com.optimizely.ab.bucketing.FeatureDecision; import com.optimizely.ab.bucketing.UserProfileService; +import com.optimizely.ab.cmab.service.CmabCacheValue; +import com.optimizely.ab.cmab.service.CmabService; +import com.optimizely.ab.cmab.service.CmabServiceOptions; +import com.optimizely.ab.cmab.service.DefaultCmabService; import com.optimizely.ab.config.AtomicProjectConfigManager; import com.optimizely.ab.config.DatafileProjectConfig; import com.optimizely.ab.config.EventType; @@ -45,6 +49,7 @@ import com.optimizely.ab.event.internal.UserEvent; import com.optimizely.ab.event.internal.UserEventFactory; import com.optimizely.ab.event.internal.payload.EventBatch; +import com.optimizely.ab.internal.DefaultLRUCache; import com.optimizely.ab.internal.NotificationRegistry; import com.optimizely.ab.notification.ActivateNotification; import com.optimizely.ab.notification.DecisionNotification; @@ -69,12 +74,14 @@ import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; import com.optimizely.ab.optimizelydecision.OptimizelyDecision; import com.optimizely.ab.optimizelyjson.OptimizelyJSON; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.annotation.concurrent.ThreadSafe; + import java.io.Closeable; import java.util.ArrayList; import java.util.Arrays; @@ -84,6 +91,7 @@ import java.util.Map; import java.util.concurrent.locks.ReentrantLock; +import com.optimizely.ab.cmab.client.CmabClient; import static com.optimizely.ab.internal.SafetyUtils.tryClose; /** @@ -141,8 +149,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 +163,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 +176,7 @@ private Optimizely(@Nonnull EventHandler eventHandler, this.notificationCenter = notificationCenter; this.defaultDecideOptions = defaultDecideOptions; this.odpManager = odpManager; + this.cmabService = cmabService; if (odpManager != null) { odpManager.getEventManager().start(); @@ -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,16 @@ public Builder withODPManager(ODPManager odpManager) { return this; } + public Builder withCmabClient(CmabClient cmabClient) { + 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, cmabClient); + DefaultCmabService defaultCmabService = new DefaultCmabService(cmabServiceOptions); + this.cmabService = defaultCmabService; + return this; + } + // Helper functions for making testing easier protected Builder withBucketing(Bucketer bucketer) { this.bucketer = bucketer; @@ -1872,8 +2045,12 @@ public Optimizely build() { bucketer = new Bucketer(); } + if (cmabService == null) { + logger.warn("CMAB service is not initiated. CMAB functionality will not be available."); + } + 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 +2093,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..d576c2bd0 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,26 @@ */ 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.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 +205,142 @@ 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) { + AsyncDecisionFetcher fetcher = new AsyncDecisionFetcher(this, keys, options, callback); + fetcher.start(); + } + + /** + * 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. + */ + public void decideForKeysAsync(@Nonnull List keys, @Nonnull OptimizelyDecisionsCallback callback) { + decideForKeysAsync(keys, callback, Collections.emptyList()); + } + + /** + * Returns decision results asynchronously for all active flag keys. + * + * @param callback A callback to invoke when decisions are available. + * @param options A list of options for decision-making. + */ + public void decideAllAsync(@Nonnull OptimizelyDecisionsCallback callback, + @Nonnull List options) { + AsyncDecisionFetcher fetcher = new AsyncDecisionFetcher(this, options, callback); + fetcher.start(); + } + + /** + * Returns decision results asynchronously for all active flag keys. + * + * @param callback A callback to invoke when decisions are available. + */ + 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..9a6cae6e2 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; } /** @@ -99,6 +106,7 @@ public DecisionService(@Nonnull Bucketer bucketer, * @param options An array of decision options * @param userProfileTracker tracker for reading and updating user profile of the user * @param reasons Decision reasons + * @param useCmab Boolean flag to determine 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 @@ -107,7 +115,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 +157,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 +187,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 +202,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 +222,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 +234,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 +268,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 +315,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 +370,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 +809,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 +825,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 +920,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/cmab/client/CmabClientConfig.java b/core-api/src/main/java/com/optimizely/ab/cmab/client/CmabClientConfig.java index 90198d376..261b9ffad 100644 --- a/core-api/src/main/java/com/optimizely/ab/cmab/client/CmabClientConfig.java +++ b/core-api/src/main/java/com/optimizely/ab/cmab/client/CmabClientConfig.java @@ -35,6 +35,8 @@ public RetryConfig getRetryConfig() { /** * Creates a config with default retry settings. + * + * @return A default cmab client config */ public static CmabClientConfig withDefaultRetry() { return new CmabClientConfig(RetryConfig.defaultConfig()); @@ -42,6 +44,8 @@ public static CmabClientConfig withDefaultRetry() { /** * Creates a config with no retry. + * + * @return A cmab client config with no retry */ public static CmabClientConfig withNoRetry() { return new CmabClientConfig(null); diff --git a/core-api/src/main/java/com/optimizely/ab/cmab/client/CmabClientHelper.java b/core-api/src/main/java/com/optimizely/ab/cmab/client/CmabClientHelper.java new file mode 100644 index 000000000..f208a50b3 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/cmab/client/CmabClientHelper.java @@ -0,0 +1,104 @@ +/** + * Copyright 2025, Optimizely + * + * 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 + * + * https://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.cmab.client; + +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class CmabClientHelper { + public static final String CMAB_FETCH_FAILED = "CMAB decision fetch failed with status: %s"; + public static final String INVALID_CMAB_FETCH_RESPONSE = "Invalid CMAB fetch response"; + private static final Pattern VARIATION_ID_PATTERN = Pattern.compile("\"variation_id\"\\s*:\\s*\"?([^\"\\s,}]+)\"?"); + + public static String buildRequestJson(String userId, String ruleId, Map attributes, String cmabUuid) { + StringBuilder json = new StringBuilder(); + json.append("{\"instances\":[{"); + json.append("\"visitorId\":\"").append(escapeJson(userId)).append("\","); + json.append("\"experimentId\":\"").append(escapeJson(ruleId)).append("\","); + json.append("\"cmabUUID\":\"").append(escapeJson(cmabUuid)).append("\","); + json.append("\"attributes\":["); + + boolean first = true; + for (Map.Entry entry : attributes.entrySet()) { + if (!first) { + json.append(","); + } + json.append("{\"id\":\"").append(escapeJson(entry.getKey())).append("\","); + json.append("\"value\":").append(formatJsonValue(entry.getValue())).append(","); + json.append("\"type\":\"custom_attribute\"}"); + first = false; + } + + json.append("]}]}"); + return json.toString(); + } + + private static String escapeJson(String value) { + if (value == null) { + return ""; + } + return value.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } + + private static String formatJsonValue(Object value) { + if (value == null) { + return "null"; + } else if (value instanceof String) { + return "\"" + escapeJson((String) value) + "\""; + } else if (value instanceof Number || value instanceof Boolean) { + return value.toString(); + } else { + return "\"" + escapeJson(value.toString()) + "\""; + } + } + + public static String parseVariationId(String jsonResponse) { + // Simple regex to extract variation_id from predictions[0].variation_id + Pattern pattern = Pattern.compile("\"predictions\"\\s*:\\s*\\[\\s*\\{[^}]*\"variation_id\"\\s*:\\s*\"?([^\"\\s,}]+)\"?"); + Matcher matcher = pattern.matcher(jsonResponse); + if (matcher.find()) { + return matcher.group(1); + } + throw new CmabInvalidResponseException(INVALID_CMAB_FETCH_RESPONSE); + } + + private static String parseVariationIdForValidation(String jsonResponse) { + Matcher matcher = VARIATION_ID_PATTERN.matcher(jsonResponse); + if (matcher.find()) { + return matcher.group(1); + } + return null; + } + + public static boolean validateResponse(String responseBody) { + try { + return responseBody.contains("predictions") && + responseBody.contains("variation_id") && + parseVariationIdForValidation(responseBody) != null; + } catch (Exception e) { + return false; + } + } + + public static boolean isSuccessStatusCode(int statusCode) { + return statusCode >= 200 && statusCode < 300; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/cmab/client/RetryConfig.java b/core-api/src/main/java/com/optimizely/ab/cmab/client/RetryConfig.java index b5b04cfa3..632b760af 100644 --- a/core-api/src/main/java/com/optimizely/ab/cmab/client/RetryConfig.java +++ b/core-api/src/main/java/com/optimizely/ab/cmab/client/RetryConfig.java @@ -62,6 +62,8 @@ public RetryConfig(int maxRetries) { /** * Creates a default RetryConfig with 3 retries and exponential backoff. + * + * @return Retry config with default settings */ public static RetryConfig defaultConfig() { return new RetryConfig(3); @@ -69,6 +71,8 @@ public static RetryConfig defaultConfig() { /** * Creates a RetryConfig with no retries (single attempt only). + * + * @return Retry config with no retries */ public static RetryConfig noRetry() { return new RetryConfig(0, 0, 1.0, 0); 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..0d53014a7 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/AsyncDecisionFetcher.java @@ -0,0 +1,186 @@ +/** + * 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 java.util.Collections; +import java.util.List; +import java.util.Map; + +import javax.annotation.Nonnull; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.optimizely.ab.OptimizelyUserContext; + +/** + * AsyncDecisionFetcher handles asynchronous decision fetching for single or multiple flag keys. + * 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 singleKey; + private final List keys; + private final List options; + private final OptimizelyDecisionCallback singleCallback; + private final OptimizelyDecisionsCallback multipleCallback; + private final OptimizelyUserContext userContext; + private final boolean decideAll; + private final FetchType fetchType; + + private enum FetchType { + SINGLE_DECISION, + MULTIPLE_DECISIONS, + ALL_DECISIONS + } + + /** + * Constructor for async single 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.singleKey = key; + this.keys = null; + this.options = options; + this.singleCallback = callback; + this.multipleCallback = null; + this.decideAll = false; + this.fetchType = FetchType.SINGLE_DECISION; + + setName("AsyncDecisionFetcher-" + key); + setDaemon(true); + } + + /** + * 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 AsyncDecisionFetcher(@Nonnull OptimizelyUserContext userContext, + @Nonnull List keys, + @Nonnull List options, + @Nonnull OptimizelyDecisionsCallback callback) { + this.userContext = userContext; + this.singleKey = null; + this.keys = keys; + this.options = options; + this.singleCallback = null; + this.multipleCallback = callback; + this.decideAll = false; + this.fetchType = FetchType.MULTIPLE_DECISIONS; + + setName("AsyncDecisionFetcher-keys"); + setDaemon(true); + } + + /** + * Constructor for deciding on all flags. + * + * @param userContext The user context to make decisions for + * @param options Decision options + * @param callback Callback to invoke when decisions are ready + */ + public AsyncDecisionFetcher(@Nonnull OptimizelyUserContext userContext, + @Nonnull List options, + @Nonnull OptimizelyDecisionsCallback callback) { + this.userContext = userContext; + this.singleKey = null; + this.keys = null; + this.options = options; + this.singleCallback = null; + this.multipleCallback = callback; + this.decideAll = true; + this.fetchType = FetchType.ALL_DECISIONS; + + setName("AsyncDecisionFetcher-all"); + setDaemon(true); + } + + @Override + public void run() { + try { + switch (fetchType) { + case SINGLE_DECISION: + handleSingleDecision(); + break; + case MULTIPLE_DECISIONS: + handleMultipleDecisions(); + break; + case ALL_DECISIONS: + handleAllDecisions(); + break; + } + } catch (Exception e) { + logger.error("Error in async decision fetching", e); + handleError(e); + } + } + + private void handleSingleDecision() { + OptimizelyDecision decision = userContext.decide(singleKey, options); + singleCallback.onCompleted(decision); + } + + private void handleMultipleDecisions() { + Map decisions = userContext.decideForKeys(keys, options); + multipleCallback.onCompleted(decisions); + } + + private void handleAllDecisions() { + Map decisions = userContext.decideAll(options); + multipleCallback.onCompleted(decisions); + } + + private void handleError(Exception e) { + switch (fetchType) { + case SINGLE_DECISION: + OptimizelyDecision errorDecision = createErrorDecision(singleKey, e.getMessage()); + singleCallback.onCompleted(errorDecision); + break; + case MULTIPLE_DECISIONS: + case ALL_DECISIONS: + // Return empty map on error - this follows the pattern of sync methods + multipleCallback.onCompleted(Collections.emptyMap()); + break; + } + } + + /** + * 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/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..4603445ee 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, mockCmabService); FeatureFlag featureFlag1 = FEATURE_FLAG_MULTI_VARIATE_FEATURE; FeatureFlag featureFlag2 = FEATURE_FLAG_MULTI_VARIATE_FUTURE_FEATURE; @@ -609,7 +625,8 @@ public void getVariationForFeatureInRolloutReturnsNullWhenUserIsExcludedFromAllT DecisionService decisionService = new DecisionService( mockBucketer, mockErrorHandler, - null + null, + mockCmabService ); FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( @@ -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, mockCmabService); FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( FEATURE_FLAG_MULTI_VARIATE_FEATURE, @@ -666,7 +683,8 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsAllAudie DecisionService decisionService = new DecisionService( mockBucketer, mockErrorHandler, - null + null, + mockCmabService ); FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( @@ -707,7 +725,8 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficI DecisionService decisionService = new DecisionService( mockBucketer, mockErrorHandler, - null + null, + mockCmabService ); FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( @@ -747,7 +766,8 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficI DecisionService decisionService = new DecisionService( mockBucketer, mockErrorHandler, - null + null, + mockCmabService ); FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( @@ -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, mockCmabService); 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, mockCmabService); 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, mockCmabService); 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, mockCmabService); 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, mockCmabService); 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, mockCmabService); 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, mockCmabService)); Experiment experiment = validProjectConfig.getExperiments().get(0); Variation expectedVariation = experiment.getVariations().get(0); @@ -1130,7 +1151,8 @@ public void getVariationForRolloutWithBucketingId() { DecisionService decisionService = spy(new DecisionService( bucketer, mockErrorHandler, - null + null, + mockCmabService )); FeatureDecision expectedFeatureDecision = new FeatureDecision( @@ -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, mockCmabService); 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, mockCmabService); 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, mockCmabService); 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-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java index f26851375..16bbb3c7a 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,6 +16,14 @@ */ 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.config.HttpProjectConfigManager; import com.optimizely.ab.config.ProjectConfig; import com.optimizely.ab.config.ProjectConfigManager; @@ -27,11 +35,6 @@ 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 +372,14 @@ public static Optimizely newDefaultInstance(ProjectConfigManager configManager, .withApiManager(odpApiManager != null ? odpApiManager : new DefaultODPApiManager()) .build(); + DefaultCmabClient defaultCmabClient = new DefaultCmabClient(CmabClientConfig.withDefaultRetry()); + return Optimizely.builder() .withEventProcessor(eventProcessor) .withConfigManager(configManager) .withNotificationCenter(notificationCenter) .withODPManager(odpManager) + .withCmabClient(defaultCmabClient) .build(); } } diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/cmab/DefaultCmabClient.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/cmab/DefaultCmabClient.java index 6af4ac32a..e5f259759 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/cmab/DefaultCmabClient.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/cmab/DefaultCmabClient.java @@ -36,15 +36,13 @@ import com.optimizely.ab.cmab.client.CmabFetchException; import com.optimizely.ab.cmab.client.CmabInvalidResponseException; import com.optimizely.ab.cmab.client.RetryConfig; +import com.optimizely.ab.cmab.client.CmabClientHelper; public class DefaultCmabClient implements CmabClient { private static final Logger logger = LoggerFactory.getLogger(DefaultCmabClient.class); private static final int DEFAULT_TIMEOUT_MS = 10000; - // Update constants to match JS error messages format - private static final String CMAB_FETCH_FAILED = "CMAB decision fetch failed with status: %s"; - private static final String INVALID_CMAB_FETCH_RESPONSE = "Invalid CMAB fetch response"; - private static final Pattern VARIATION_ID_PATTERN = Pattern.compile("\"variation_id\"\\s*:\\s*\"?([^\"\\s,}]+)\"?"); + private static final String CMAB_PREDICTION_ENDPOINT = "https://prediction.cmab.optimizely.com/predict/%s"; private final OptimizelyHttpClient httpClient; @@ -81,7 +79,7 @@ private OptimizelyHttpClient createDefaultHttpClient() { public String fetchDecision(String ruleId, String userId, Map attributes, String cmabUuid) { // Implementation will use this.httpClient and this.retryConfig String url = String.format(CMAB_PREDICTION_ENDPOINT, ruleId); - String requestBody = buildRequestJson(userId, ruleId, attributes, cmabUuid); + String requestBody = CmabClientHelper.buildRequestJson(userId, ruleId, attributes, cmabUuid); // Use retry logic if configured, otherwise single request if (retryConfig != null && retryConfig.getMaxRetries() > 0) { @@ -96,7 +94,7 @@ private String doFetch(String url, String requestBody) { try { request.setEntity(new StringEntity(requestBody)); } catch (UnsupportedEncodingException e) { - String errorMessage = String.format(CMAB_FETCH_FAILED, e.getMessage()); + String errorMessage = String.format(CmabClientHelper.CMAB_FETCH_FAILED, e.getMessage()); logger.error(errorMessage); throw new CmabFetchException(errorMessage); } @@ -105,9 +103,9 @@ private String doFetch(String url, String requestBody) { try { response = httpClient.execute(request); - if (!isSuccessStatusCode(response.getStatusLine().getStatusCode())) { + if (!CmabClientHelper.isSuccessStatusCode(response.getStatusLine().getStatusCode())) { StatusLine statusLine = response.getStatusLine(); - String errorMessage = String.format(CMAB_FETCH_FAILED, statusLine.getReasonPhrase()); + String errorMessage = String.format(CmabClientHelper.CMAB_FETCH_FAILED, statusLine.getReasonPhrase()); logger.error(errorMessage); throw new CmabFetchException(errorMessage); } @@ -116,18 +114,18 @@ private String doFetch(String url, String requestBody) { try { responseBody = EntityUtils.toString(response.getEntity()); - if (!validateResponse(responseBody)) { - logger.error(INVALID_CMAB_FETCH_RESPONSE); - throw new CmabInvalidResponseException(INVALID_CMAB_FETCH_RESPONSE); + if (!CmabClientHelper.validateResponse(responseBody)) { + logger.error(CmabClientHelper.INVALID_CMAB_FETCH_RESPONSE); + throw new CmabInvalidResponseException(CmabClientHelper.INVALID_CMAB_FETCH_RESPONSE); } - return parseVariationId(responseBody); + return CmabClientHelper.parseVariationId(responseBody); } catch (IOException | ParseException e) { - logger.error(CMAB_FETCH_FAILED); - throw new CmabInvalidResponseException(INVALID_CMAB_FETCH_RESPONSE); + logger.error(CmabClientHelper.CMAB_FETCH_FAILED); + throw new CmabInvalidResponseException(CmabClientHelper.INVALID_CMAB_FETCH_RESPONSE); } } catch (IOException e) { - String errorMessage = String.format(CMAB_FETCH_FAILED, e.getMessage()); + String errorMessage = String.format(CmabClientHelper.CMAB_FETCH_FAILED, e.getMessage()); logger.error(errorMessage); throw new CmabFetchException(errorMessage); } finally { @@ -158,7 +156,7 @@ private String doFetchWithRetry(String url, String requestBody, int maxRetries) Thread.sleep((long) backoff); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); - String errorMessage = String.format(CMAB_FETCH_FAILED, "Request interrupted during retry"); + String errorMessage = String.format(CmabClientHelper.CMAB_FETCH_FAILED, "Request interrupted during retry"); logger.error(errorMessage); throw new CmabFetchException(errorMessage, ie); } @@ -172,94 +170,10 @@ private String doFetchWithRetry(String url, String requestBody, int maxRetries) } // If we get here, all retries were exhausted - String errorMessage = String.format(CMAB_FETCH_FAILED, "Exhausted all retries for CMAB request"); + String errorMessage = String.format(CmabClientHelper.CMAB_FETCH_FAILED, "Exhausted all retries for CMAB request"); logger.error(errorMessage); throw new CmabFetchException(errorMessage, lastException); } - - private String buildRequestJson(String userId, String ruleId, Map attributes, String cmabUuid) { - StringBuilder json = new StringBuilder(); - json.append("{\"instances\":[{"); - json.append("\"visitorId\":\"").append(escapeJson(userId)).append("\","); - json.append("\"experimentId\":\"").append(escapeJson(ruleId)).append("\","); - json.append("\"cmabUUID\":\"").append(escapeJson(cmabUuid)).append("\","); - json.append("\"attributes\":["); - - boolean first = true; - for (Map.Entry entry : attributes.entrySet()) { - if (!first) { - json.append(","); - } - json.append("{\"id\":\"").append(escapeJson(entry.getKey())).append("\","); - json.append("\"value\":").append(formatJsonValue(entry.getValue())).append(","); - json.append("\"type\":\"custom_attribute\"}"); - first = false; - } - - json.append("]}]}"); - return json.toString(); - } - - private String escapeJson(String value) { - if (value == null) { - return ""; - } - return value.replace("\\", "\\\\") - .replace("\"", "\\\"") - .replace("\n", "\\n") - .replace("\r", "\\r") - .replace("\t", "\\t"); - } - - private String formatJsonValue(Object value) { - if (value == null) { - return "null"; - } else if (value instanceof String) { - return "\"" + escapeJson((String) value) + "\""; - } else if (value instanceof Number || value instanceof Boolean) { - return value.toString(); - } else { - return "\"" + escapeJson(value.toString()) + "\""; - } - } - - // Helper methods - private boolean isSuccessStatusCode(int statusCode) { - return statusCode >= 200 && statusCode < 300; - } - - private boolean validateResponse(String responseBody) { - try { - return responseBody.contains("predictions") && - responseBody.contains("variation_id") && - parseVariationIdForValidation(responseBody) != null; - } catch (Exception e) { - return false; - } - } - - private boolean shouldRetry(Exception exception) { - return (exception instanceof CmabFetchException) || - (exception instanceof CmabInvalidResponseException); - } - - private String parseVariationIdForValidation(String jsonResponse) { - Matcher matcher = VARIATION_ID_PATTERN.matcher(jsonResponse); - if (matcher.find()) { - return matcher.group(1); - } - return null; - } - - private String parseVariationId(String jsonResponse) { - // Simple regex to extract variation_id from predictions[0].variation_id - Pattern pattern = Pattern.compile("\"predictions\"\\s*:\\s*\\[\\s*\\{[^}]*\"variation_id\"\\s*:\\s*\"?([^\"\\s,}]+)\"?"); - Matcher matcher = pattern.matcher(jsonResponse); - if (matcher.find()) { - return matcher.group(1); - } - throw new CmabInvalidResponseException(INVALID_CMAB_FETCH_RESPONSE); - } private static void closeHttpResponse(CloseableHttpResponse response) { if (response != null) {