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..08e7b67f0 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -16,10 +16,10 @@ package com.optimizely.ab; import com.optimizely.ab.annotations.VisibleForTesting; -import com.optimizely.ab.bucketing.Bucketer; -import com.optimizely.ab.bucketing.DecisionService; -import com.optimizely.ab.bucketing.FeatureDecision; -import com.optimizely.ab.bucketing.UserProfileService; +import com.optimizely.ab.bucketing.*; +import com.optimizely.ab.cmab.service.CmabCacheValue; +import com.optimizely.ab.cmab.service.CmabService; +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 +45,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; @@ -62,19 +63,16 @@ import com.optimizely.ab.optimizelyconfig.OptimizelyConfig; import com.optimizely.ab.optimizelyconfig.OptimizelyConfigManager; import com.optimizely.ab.optimizelyconfig.OptimizelyConfigService; -import com.optimizely.ab.optimizelydecision.DecisionMessage; -import com.optimizely.ab.optimizelydecision.DecisionReasons; -import com.optimizely.ab.optimizelydecision.DecisionResponse; -import com.optimizely.ab.optimizelydecision.DefaultDecisionReasons; -import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; -import com.optimizely.ab.optimizelydecision.OptimizelyDecision; +import com.optimizely.ab.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 +82,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 +140,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 +154,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 +167,7 @@ private Optimizely(@Nonnull EventHandler eventHandler, this.notificationCenter = notificationCenter; this.defaultDecideOptions = defaultDecideOptions; this.odpManager = odpManager; + this.cmabService = cmabService; if (odpManager != null) { odpManager.getEventManager().start(); @@ -1395,7 +1399,8 @@ Map decideForKeys(@Nonnull OptimizelyUserContext use private Map decideForKeys(@Nonnull OptimizelyUserContext user, @Nonnull List keys, @Nonnull List options, - boolean ignoreDefaultOptions) { + boolean ignoreDefaultOptions, + DecisionPath decisionPath) { Map decisionMap = new HashMap<>(); ProjectConfig projectConfig = getProjectConfig(); @@ -1440,11 +1445,25 @@ private Map decideForKeys(@Nonnull OptimizelyUserCon } List> decisionList = - decisionService.getVariationsForFeatureList(flagsWithoutForcedDecision, user, projectConfig, allOptions); + decisionService.getVariationsForFeatureList(flagsWithoutForcedDecision, user, projectConfig, allOptions, decisionPath); 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()); } @@ -1465,6 +1484,13 @@ private Map decideForKeys(@Nonnull OptimizelyUserCon return decisionMap; } + private Map decideForKeys(@Nonnull OptimizelyUserContext user, + @Nonnull List keys, + @Nonnull List options, + boolean ignoreDefaultOptions) { + return decideForKeys(user, keys, options, ignoreDefaultOptions, DecisionPath.WITH_CMAB); + } + Map decideAll(@Nonnull OptimizelyUserContext user, @Nonnull List options) { Map decisionMap = new HashMap<>(); @@ -1482,6 +1508,125 @@ 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. + * This will be called by mobile apps which will make synchronous decisions only (for backward compatibility with android-sdk) + * + * @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. + * This will be called by mobile apps which will make synchronous decisions only (for backward compatibility with android-sdk) + * + * @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); + } + + private Map decideForKeysSync(@Nonnull OptimizelyUserContext user, + @Nonnull List keys, + @Nonnull List options, + boolean ignoreDefaultOptions) { + return decideForKeys(user, keys, options, ignoreDefaultOptions, DecisionPath.WITHOUT_CMAB); + } + + /** + * Returns decision results for all active flag keys, skipping CMAB logic and using only traditional A/B testing. + * This will be called by mobile apps which will make synchronous decisions only (for backward compatibility with android-sdk) + * + * @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); + } + + //============ decide async ============// + + /** + * Returns a decision result asynchronously for a given flag key and a user context. + * + * @param userContext The user context to make decisions for + * @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 + */ + void decideAsync(@Nonnull OptimizelyUserContext userContext, + @Nonnull String key, + @Nonnull List options, + @Nonnull OptimizelyDecisionCallback callback) { + AsyncDecisionFetcher fetcher = new AsyncDecisionFetcher(userContext, key, options, callback); + fetcher.start(); + } + + /** + * Returns decision results asynchronously for multiple flag keys. + * + * @param userContext The user context to make decisions for + * @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 + */ + void decideForKeysAsync(@Nonnull OptimizelyUserContext userContext, + @Nonnull List keys, + @Nonnull List options, + @Nonnull OptimizelyDecisionsCallback callback) { + AsyncDecisionFetcher fetcher = new AsyncDecisionFetcher(userContext, keys, options, callback); + fetcher.start(); + } + + /** + * Returns decision results asynchronously for all active flag keys. + * + * @param userContext The user context to make decisions for + * @param callback A callback to invoke when decisions are available + * @param options A list of options for decision-making + */ + void decideAllAsync(@Nonnull OptimizelyUserContext userContext, + @Nonnull List options, + @Nonnull OptimizelyDecisionsCallback callback) { + AsyncDecisionFetcher fetcher = new AsyncDecisionFetcher(userContext, options, callback); + fetcher.start(); + } + private List getAllOptions(List options) { List copiedOptions = new ArrayList(defaultDecideOptions); if (options != null) { @@ -1731,6 +1876,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 +1988,11 @@ public Builder withODPManager(ODPManager odpManager) { return this; } + public Builder withCmabService(CmabService cmabService) { + this.cmabService = cmabService; + return this; + } + // Helper functions for making testing easier protected Builder withBucketing(Bucketer bucketer) { this.bucketer = bucketer; @@ -1872,8 +2023,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 +2071,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..19c8b999f 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 com.optimizely.ab.annotations.VisibleForTesting; +import com.optimizely.ab.optimizelydecision.OptimizelyDecisionCallback; +import com.optimizely.ab.optimizelydecision.OptimizelyDecisionsCallback; +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.OptimizelyDecideOption; +import com.optimizely.ab.optimizelydecision.OptimizelyDecision; public class OptimizelyUserContext { // OptimizelyForcedDecisionsKey mapped to variationKeys @@ -42,7 +50,7 @@ public class OptimizelyUserContext { private List qualifiedSegments; @Nonnull - private final Optimizely optimizely; + final Optimizely optimizely; private static final Logger logger = LoggerFactory.getLogger(OptimizelyUserContext.class); @@ -390,4 +398,44 @@ public String toString() { ", attributes='" + attributes + '\'' + '}'; } + + // sync decision support for android-sdk backward compatibility only + + @VisibleForTesting // protected, open for testing only + public OptimizelyDecision decideSync(@Nonnull String key, + @Nonnull List options) { + return optimizely.decideSync(copy(), key, options); + } + + @VisibleForTesting // protected, open for testing only + public Map decideForKeysSync(@Nonnull List keys, + @Nonnull List options) { + return optimizely.decideForKeysSync(copy(), keys, options); + } + + @VisibleForTesting // protected, open for testing only + public Map decideAllSync(@Nonnull List options) { + return optimizely.decideAllSync(copy(), options); + } + + @VisibleForTesting // protected, open for testing only + public void decideAsync(@Nonnull String key, + @Nonnull List options, + @Nonnull OptimizelyDecisionCallback callback) { + optimizely.decideAsync(copy(), key, options, callback); + } + + @VisibleForTesting // protected, open for testing only + public void decideForKeysAsync(@Nonnull List keys, + @Nonnull List options, + @Nonnull OptimizelyDecisionsCallback callback) { + optimizely.decideForKeysAsync(copy(), keys, options, callback); + } + + @VisibleForTesting // protected, open for testing only + public void decideAllAsync(@Nonnull List options, + @Nonnull OptimizelyDecisionsCallback callback) { + optimizely.decideAllAsync(copy(), options, callback); + } + } 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..916bf4d15 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. * @@ -140,6 +183,24 @@ private DecisionResponse bucketToVariation(@Nonnull ExperimentCore ex public DecisionResponse bucket(@Nonnull ExperimentCore experiment, @Nonnull String bucketingId, @Nonnull ProjectConfig projectConfig) { + + return bucket(experiment, bucketingId, projectConfig, false); + } + + /** + * Assign a {@link Variation} of an {@link Experiment} to a user based on hashed value from murmurhash3. + * + * @param experiment The 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 + * @param useCmab boolean flag to decide whether to handle cmab experiments. + * @return A {@link DecisionResponse} including the {@link Variation} that user is bucketed into (or null) and the decision reasons + */ + @Nonnull + public DecisionResponse bucket(@Nonnull ExperimentCore experiment, + @Nonnull String bucketingId, + @Nonnull ProjectConfig projectConfig, + @Nonnull boolean useCmab) { DecisionReasons reasons = DefaultDecisionReasons.newInstance(); // ---------- Bucket User ---------- @@ -172,9 +233,26 @@ public DecisionResponse bucket(@Nonnull ExperimentCore experiment, } } - DecisionResponse decisionResponse = bucketToVariation(experiment, bucketingId); - reasons.merge(decisionResponse.getReasons()); - return new DecisionResponse<>(decisionResponse.getResult(), reasons); + if (useCmab){ + if (experiment instanceof Experiment) { + DecisionResponse decisionResponse = bucketToEntityForCmab((Experiment) experiment, bucketingId); + reasons.merge(decisionResponse.getReasons()); + String entityId = decisionResponse.getResult(); + if (entityId==null){ + return new DecisionResponse<>(null, reasons); + } + Variation variation = new Variation(entityId, entityId); //return dummy variation for cmab + return new DecisionResponse<>(variation, reasons); + } else { + String message = reasons.addInfo("ExperimentCore instance is not of type Experiment, cannot perform CMAB bucketing."); + logger.info(message); + return new DecisionResponse<>(null, reasons); + } + } else { + DecisionResponse decisionResponse = bucketToVariation(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/DecisionPath.java b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionPath.java new file mode 100644 index 000000000..42c80579d --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionPath.java @@ -0,0 +1,21 @@ +/**************************************************************************** + * Copyright 2025 Optimizely, Inc. 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.bucketing; + +public enum DecisionPath { + WITH_CMAB, // Use CMAB logic + WITHOUT_CMAB // Skip CMAB logic (traditional A/B testing) +} 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..a16ff3bf2 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 decisionPath An enum of paths for decision-making logic * @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 DecisionPath decisionPath) { 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 (decisionPath == DecisionPath.WITH_CMAB && 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 decisionPath An enum of paths for decision-making logic * @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 DecisionPath decisionPath) { 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, decisionPath); 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(), DecisionPath.WITH_CMAB); } /** @@ -240,6 +268,25 @@ public List> getVariationsForFeatureList(@Non @Nonnull OptimizelyUserContext user, @Nonnull ProjectConfig projectConfig, @Nonnull List options) { + return getVariationsForFeatureList(featureFlags, user, projectConfig, options, DecisionPath.WITH_CMAB); + } + + /** + * 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 decisionPath An enum of paths for decision-making logic + * @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 DecisionPath decisionPath) { 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, decisionPath); 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 DecisionPath decisionPath) { 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, decisionPath); 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 DecisionPath decisionPath) { 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, decisionPath); reasons.merge(decisionResponse.getReasons()); variation = decisionResponse.getResult(); - return new DecisionResponse(variation, reasons); + return new DecisionResponse<>(variation, reasons, decisionResponse.isError(), decisionResponse.getCmabUUID()); } /** @@ -859,4 +920,68 @@ 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.bucket(experiment, bucketingId, projectConfig, true); + // DecisionResponse bucketResponse = bucketer.bucketForCmab(experiment, bucketingId, projectConfig); + reasons.merge(bucketResponse.getReasons()); + + Variation bucketedVariation = bucketResponse.getResult(); + String bucketedEntityId = bucketedVariation != null ? bucketedVariation.getId() : null; + + 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..f534abb90 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/cmab/client/CmabClientHelper.java @@ -0,0 +1,105 @@ +/** + * 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_PREDICTION_ENDPOINT = "https://prediction.cmab.optimizely.com/predict/%s"; + 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..0f725b11d 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 @@ -52,23 +52,27 @@ public RetryConfig(int maxRetries, long backoffBaseMs, double backoffMultiplier, } /** - * Creates a RetryConfig with default backoff settings and timeout (1 second base, 2x multiplier, 10 second timeout). + * Creates a RetryConfig with default backoff settings and timeout (100 millisecond base, 2x multiplier, 10 second timeout). * * @param maxRetries Maximum number of retry attempts */ public RetryConfig(int maxRetries) { - this(maxRetries, 1000, 2.0, 10000); // Default: 1 second base, exponential backoff, 10 second timeout + this(maxRetries, 100, 2.0, 10000); } /** * Creates a default RetryConfig with 3 retries and exponential backoff. + * + * @return Retry config with default settings */ public static RetryConfig defaultConfig() { - return new RetryConfig(3); + return new RetryConfig(1); } /** * 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/cmab/service/CmabServiceOptions.java b/core-api/src/main/java/com/optimizely/ab/cmab/service/CmabServiceOptions.java deleted file mode 100644 index 5f17952d1..000000000 --- a/core-api/src/main/java/com/optimizely/ab/cmab/service/CmabServiceOptions.java +++ /dev/null @@ -1,49 +0,0 @@ -/** - * 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.service; - -import org.slf4j.Logger; - -import com.optimizely.ab.cmab.client.CmabClient; -import com.optimizely.ab.internal.DefaultLRUCache; - -public class CmabServiceOptions { - private final Logger logger; - private final DefaultLRUCache cmabCache; - private final CmabClient cmabClient; - - public CmabServiceOptions(DefaultLRUCache cmabCache, CmabClient cmabClient) { - this(null, cmabCache, cmabClient); - } - - public CmabServiceOptions(Logger logger, DefaultLRUCache cmabCache, CmabClient cmabClient) { - this.logger = logger; - this.cmabCache = cmabCache; - this.cmabClient = cmabClient; - } - - public Logger getLogger() { - return logger; - } - - public DefaultLRUCache getCmabCache() { - return cmabCache; - } - - public CmabClient getCmabClient() { - return cmabClient; - } -} \ No newline at end of file diff --git a/core-api/src/main/java/com/optimizely/ab/cmab/service/DefaultCmabService.java b/core-api/src/main/java/com/optimizely/ab/cmab/service/DefaultCmabService.java index 182d310a8..a68f9c5d1 100644 --- a/core-api/src/main/java/com/optimizely/ab/cmab/service/DefaultCmabService.java +++ b/core-api/src/main/java/com/optimizely/ab/cmab/service/DefaultCmabService.java @@ -22,6 +22,7 @@ import java.util.TreeMap; import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import com.optimizely.ab.OptimizelyUserContext; import com.optimizely.ab.bucketing.internal.MurmurHash3; @@ -29,19 +30,28 @@ import com.optimizely.ab.config.Attribute; import com.optimizely.ab.config.Experiment; import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.internal.Cache; import com.optimizely.ab.internal.DefaultLRUCache; import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; public class DefaultCmabService implements CmabService { - - private final DefaultLRUCache cmabCache; + public static final int DEFAULT_CMAB_CACHE_SIZE = 1000; + public static final int DEFAULT_CMAB_CACHE_TIMEOUT_SECS = 300; // 5 minutes + + private final Cache cmabCache; private final CmabClient cmabClient; private final Logger logger; - public DefaultCmabService(CmabServiceOptions options) { - this.cmabCache = options.getCmabCache(); - this.cmabClient = options.getCmabClient(); - this.logger = options.getLogger(); + // public DefaultCmabService(CmabClient cmabClient, DefaultLRUCache cmabCache, Logger logger) { + // this.cmabCache = cmabCache; + // this.cmabClient = cmabClient; + // this.logger = logger; + // } + + public DefaultCmabService(CmabClient cmabClient, Cache cmabCache, Logger logger) { + this.cmabCache = cmabCache; + this.cmabClient = cmabClient; + this.logger = logger; } @Override @@ -182,4 +192,97 @@ private String hashAttributes(Map attributes) { // Convert to hex string to match your existing pattern return Integer.toHexString(hash); } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private int cmabCacheSize = DEFAULT_CMAB_CACHE_SIZE; + private int cmabCacheTimeoutInSecs = DEFAULT_CMAB_CACHE_TIMEOUT_SECS; + private Cache customCache; + private CmabClient client; + private Logger logger; + + /** + * Set the maximum size of the CMAB cache. + * + * Default value is 1000 entries. + * + * @param cacheSize The maximum number of entries to store in the cache + * @return Builder instance + */ + public Builder withCmabCacheSize(int cacheSize) { + this.cmabCacheSize = cacheSize; + return this; + } + + /** + * Set the timeout duration for cached CMAB decisions. + * + * Default value is 300 seconds (5 minutes). + * + * @param timeoutInSecs The timeout in seconds before cached entries expire + * @return Builder instance + */ + public Builder withCmabCacheTimeoutInSecs(int timeoutInSecs) { + this.cmabCacheTimeoutInSecs = timeoutInSecs; + return this; + } + + /** + * Provide a custom {@link CmabClient} instance which makes HTTP calls to fetch CMAB decisions. + * + * A Default CmabClient implementation is required for CMAB functionality. + * + * @param client The implementation of {@link CmabClient} + * @return Builder instance + */ + public Builder withClient(CmabClient client) { + this.client = client; + return this; + } + + /** + * Provide a custom {@link Cache} instance for caching CMAB decisions. + * + * If provided, this will override the cache size and timeout settings. + * + * @param cache The custom cache instance implementing {@link Cache} + * @return Builder instance + */ + public Builder withCustomCache(Cache cache) { + this.customCache = cache; + return this; + } + + /** + * Provide a custom {@link Logger} instance for logging CMAB service operations. + * + * If not provided, a default SLF4J logger will be used. + * + * @param logger The logger instance + * @return Builder instance + */ + public Builder withLogger(Logger logger) { + this.logger = logger; + return this; + } + + public DefaultCmabService build() { + if (client == null) { + throw new IllegalStateException("CmabClient is required"); + } + + if (logger == null) { + logger = LoggerFactory.getLogger(DefaultCmabService.class); + } + + Cache cache = customCache != null ? customCache : + new DefaultLRUCache<>(cmabCacheSize, cmabCacheTimeoutInSecs); + + + return new DefaultCmabService(client, cache, logger); + } + } } diff --git a/core-api/src/main/java/com/optimizely/ab/internal/Cache.java b/core-api/src/main/java/com/optimizely/ab/internal/Cache.java index ba667ebd2..d741e316b 100644 --- a/core-api/src/main/java/com/optimizely/ab/internal/Cache.java +++ b/core-api/src/main/java/com/optimizely/ab/internal/Cache.java @@ -22,4 +22,5 @@ public interface Cache { void save(String key, T value); T lookup(String key); void reset(); + void remove(String key); } 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..3b066df21 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -15,62 +15,155 @@ ***************************************************************************/ package com.optimizely.ab; -import ch.qos.logback.classic.Level; +import java.io.IOException; +import java.util.Arrays; +import static java.util.Arrays.asList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assume.assumeTrue; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.rules.RuleChain; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.mockito.ArgumentCaptor; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyMapOf; +import static org.mockito.Matchers.anyString; +import static org.mockito.Matchers.eq; +import static org.mockito.Matchers.isNull; +import org.mockito.Mock; +import org.mockito.Mockito; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + import com.google.common.collect.ImmutableMap; import com.google.gson.Gson; import com.google.gson.JsonElement; import com.google.gson.JsonParser; import com.optimizely.ab.bucketing.Bucketer; +import com.optimizely.ab.bucketing.DecisionPath; import com.optimizely.ab.bucketing.DecisionService; import com.optimizely.ab.bucketing.FeatureDecision; -import com.optimizely.ab.config.*; +import com.optimizely.ab.config.Attribute; +import com.optimizely.ab.config.DatafileProjectConfig; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.invalidProjectConfigV5; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.noAudienceProjectConfigJsonV2; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.noAudienceProjectConfigJsonV3; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonCMAB; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV2; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV3; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV4; +import com.optimizely.ab.config.EventType; +import com.optimizely.ab.config.Experiment; +import com.optimizely.ab.config.FeatureFlag; +import com.optimizely.ab.config.FeatureVariable; +import com.optimizely.ab.config.FeatureVariableUsageInstance; +import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.config.ProjectConfigManager; +import com.optimizely.ab.config.TrafficAllocation; +import static com.optimizely.ab.config.ValidProjectConfigV4.ATTRIBUTE_BOOLEAN_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.ATTRIBUTE_DOUBLE_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.ATTRIBUTE_HOUSE_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.ATTRIBUTE_INTEGER_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.AUDIENCE_GRYFFINDOR_VALUE; +import static com.optimizely.ab.config.ValidProjectConfigV4.AUDIENCE_SLYTHERIN_VALUE; +import static com.optimizely.ab.config.ValidProjectConfigV4.EVENT_BASIC_EVENT_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.EVENT_LAUNCHED_EXPERIMENT_ONLY_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.EXPERIMENT_BASIC_EXPERIMENT_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.EXPERIMENT_DOUBLE_FEATURE_EXPERIMENT_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.EXPERIMENT_LAUNCHED_EXPERIMENT_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.EXPERIMENT_PAUSED_EXPERIMENT_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.EXPERIMENT_TYPEDAUDIENCE_EXPERIMENT_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.EXPERIMENT_TYPEDAUDIENCE_WITH_AND_EXPERIMENT_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.FEATURE_FLAG_MULTI_VARIATE_FEATURE; +import static com.optimizely.ab.config.ValidProjectConfigV4.FEATURE_FLAG_SINGLE_VARIABLE_DOUBLE; +import static com.optimizely.ab.config.ValidProjectConfigV4.FEATURE_FLAG_SINGLE_VARIABLE_INTEGER; +import static com.optimizely.ab.config.ValidProjectConfigV4.FEATURE_MULTI_VARIATE_FEATURE_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.FEATURE_SINGLE_VARIABLE_BOOLEAN_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.FEATURE_SINGLE_VARIABLE_DOUBLE_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.FEATURE_SINGLE_VARIABLE_INTEGER_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.FEATURE_SINGLE_VARIABLE_STRING_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.MULTIVARIATE_EXPERIMENT_FORCED_VARIATION_USER_ID_GRED; +import static com.optimizely.ab.config.ValidProjectConfigV4.PAUSED_EXPERIMENT_FORCED_VARIATION_USER_ID_CONTROL; +import static com.optimizely.ab.config.ValidProjectConfigV4.ROLLOUT_2_ID; +import static com.optimizely.ab.config.ValidProjectConfigV4.VARIABLE_BOOLEAN_VARIABLE_DEFAULT_VALUE; +import static com.optimizely.ab.config.ValidProjectConfigV4.VARIABLE_BOOLEAN_VARIABLE_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.VARIABLE_DOUBLE_DEFAULT_VALUE; +import static com.optimizely.ab.config.ValidProjectConfigV4.VARIABLE_DOUBLE_VARIABLE_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.VARIABLE_FIRST_LETTER_DEFAULT_VALUE; +import static com.optimizely.ab.config.ValidProjectConfigV4.VARIABLE_FIRST_LETTER_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.VARIABLE_INTEGER_VARIABLE_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.VARIABLE_JSON_PATCHED_TYPE_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.VARIABLE_STRING_VARIABLE_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.VARIATION_MULTIVARIATE_EXPERIMENT_GRED; +import static com.optimizely.ab.config.ValidProjectConfigV4.VARIATION_MULTIVARIATE_EXPERIMENT_GRED_KEY; +import com.optimizely.ab.config.Variation; import com.optimizely.ab.error.NoOpErrorHandler; import com.optimizely.ab.error.RaiseExceptionErrorHandler; import com.optimizely.ab.event.BatchEventProcessor; import com.optimizely.ab.event.EventHandler; import com.optimizely.ab.event.EventProcessor; import com.optimizely.ab.event.LogEvent; +import com.optimizely.ab.event.LogEvent.RequestMethod; import com.optimizely.ab.event.internal.UserEventFactory; import com.optimizely.ab.internal.ControlAttribute; import com.optimizely.ab.internal.LogbackVerifier; -import com.optimizely.ab.notification.*; +import com.optimizely.ab.notification.ActivateNotification; +import com.optimizely.ab.notification.ActivateNotificationListener; +import com.optimizely.ab.notification.DecisionNotification; +import static com.optimizely.ab.notification.DecisionNotification.ExperimentDecisionNotificationBuilder.EXPERIMENT_KEY; +import static com.optimizely.ab.notification.DecisionNotification.ExperimentDecisionNotificationBuilder.VARIATION_KEY; +import static com.optimizely.ab.notification.DecisionNotification.FeatureVariableDecisionNotificationBuilder.FEATURE_ENABLED; +import static com.optimizely.ab.notification.DecisionNotification.FeatureVariableDecisionNotificationBuilder.FEATURE_KEY; +import static com.optimizely.ab.notification.DecisionNotification.FeatureVariableDecisionNotificationBuilder.SOURCE; +import static com.optimizely.ab.notification.DecisionNotification.FeatureVariableDecisionNotificationBuilder.SOURCE_INFO; +import static com.optimizely.ab.notification.DecisionNotification.FeatureVariableDecisionNotificationBuilder.VARIABLE_KEY; +import static com.optimizely.ab.notification.DecisionNotification.FeatureVariableDecisionNotificationBuilder.VARIABLE_TYPE; +import static com.optimizely.ab.notification.DecisionNotification.FeatureVariableDecisionNotificationBuilder.VARIABLE_VALUE; +import static com.optimizely.ab.notification.DecisionNotification.FeatureVariableDecisionNotificationBuilder.VARIABLE_VALUES; +import com.optimizely.ab.notification.NotificationCenter; +import com.optimizely.ab.notification.NotificationHandler; +import com.optimizely.ab.notification.NotificationManager; +import com.optimizely.ab.notification.TrackNotification; +import com.optimizely.ab.notification.UpdateConfigNotification; 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; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.ExpectedException; -import org.junit.rules.RuleChain; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.Mockito; -import org.mockito.junit.MockitoJUnit; -import org.mockito.junit.MockitoRule; - -import java.io.IOException; -import java.util.*; -import java.util.function.Function; -import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.*; -import static com.optimizely.ab.config.ValidProjectConfigV4.*; -import static com.optimizely.ab.event.LogEvent.RequestMethod; -import static com.optimizely.ab.notification.DecisionNotification.ExperimentDecisionNotificationBuilder.EXPERIMENT_KEY; -import static com.optimizely.ab.notification.DecisionNotification.ExperimentDecisionNotificationBuilder.VARIATION_KEY; -import static com.optimizely.ab.notification.DecisionNotification.FeatureVariableDecisionNotificationBuilder.*; -import static java.util.Arrays.asList; +import ch.qos.logback.classic.Level; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import static junit.framework.TestCase.assertTrue; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.Assert.*; -import static org.junit.Assume.assumeTrue; -import static org.mockito.Matchers.*; -import static org.mockito.Mockito.*; /** * Tests for the top-level {@link Optimizely} class. @@ -4993,4 +5086,159 @@ 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), + eq(DecisionPath.WITH_CMAB) + )).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.")); + } + + @Test + public void decideAsyncReturnsDecision() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + ProjectConfigManager mockProjectConfigManager = mock(ProjectConfigManager.class); + Mockito.when(mockProjectConfigManager.getConfig()).thenReturn(validProjectConfig); + Optimizely optimizely = Optimizely.builder() + .withConfigManager(mockProjectConfigManager) + .build(); + OptimizelyUserContext userContext = optimizely.createUserContext(testUserId); + + final CountDownLatch latch = new CountDownLatch(1); + final AtomicReference decisionRef = new AtomicReference<>(); + final AtomicReference errorRef = new AtomicReference<>(); + + optimizely.decideAsync( + userContext, + FEATURE_MULTI_VARIATE_FEATURE_KEY, + Collections.emptyList(), + (OptimizelyDecision decision) -> { + try { + decisionRef.set(decision); + } catch (Throwable t) { + errorRef.set(t); + } finally { + latch.countDown(); + } + } + ); + + boolean completed = latch.await(5, TimeUnit.SECONDS); + + if (errorRef.get() != null) { + throw new AssertionError("Error in callback", errorRef.get()); + } + + assertTrue("Callback should be called within timeout", completed); + + OptimizelyDecision decision = decisionRef.get(); + assertNotNull("Decision should not be null", decision); + assertEquals("Flag key should match", FEATURE_MULTI_VARIATE_FEATURE_KEY, decision.getFlagKey()); + } + + @Test + public void decideForKeysAsyncReturnsDecisions() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + ProjectConfigManager mockProjectConfigManager = mock(ProjectConfigManager.class); + Mockito.when(mockProjectConfigManager.getConfig()).thenReturn(validProjectConfig); + Optimizely optimizely = Optimizely.builder() + .withConfigManager(mockProjectConfigManager) + .build(); + OptimizelyUserContext userContext = optimizely.createUserContext(testUserId); + + List flagKeys = Arrays.asList( + FEATURE_MULTI_VARIATE_FEATURE_KEY, + FEATURE_SINGLE_VARIABLE_STRING_KEY + ); + + final CountDownLatch latch = new CountDownLatch(1); + final AtomicReference> decisionsRef = new AtomicReference<>(); + + optimizely.decideForKeysAsync( + userContext, + flagKeys, + Collections.emptyList(), + (Map decisions) -> { + decisionsRef.set(decisions); + latch.countDown(); + } + ); + + assertTrue("Callback should be called within timeout", latch.await(5, TimeUnit.SECONDS)); + assertNotNull("Decisions should not be null", decisionsRef.get()); + assertEquals("Should return decisions for 2 keys", 2, decisionsRef.get().size()); + assertTrue("Should contain first flag key", decisionsRef.get().containsKey(FEATURE_MULTI_VARIATE_FEATURE_KEY)); + assertTrue("Should contain second flag key", decisionsRef.get().containsKey(FEATURE_SINGLE_VARIABLE_STRING_KEY)); + } + + @Test + public void decideAllAsyncReturnsAllDecisions() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + ProjectConfigManager mockProjectConfigManager = mock(ProjectConfigManager.class); + Mockito.when(mockProjectConfigManager.getConfig()).thenReturn(validProjectConfig); + Optimizely optimizely = Optimizely.builder() + .withConfigManager(mockProjectConfigManager) + .build(); + OptimizelyUserContext userContext = optimizely.createUserContext(testUserId); + + final CountDownLatch latch = new CountDownLatch(1); + final AtomicReference> decisionsRef = new AtomicReference<>(); + + optimizely.decideAllAsync( + userContext, + Collections.emptyList(), + (Map decisions) -> { + decisionsRef.set(decisions); + latch.countDown(); + } + ); + + assertTrue("Callback should be called within timeout", latch.await(5, TimeUnit.SECONDS)); + assertNotNull("Decisions should not be null", decisionsRef.get()); + assertFalse("Decisions should not be empty", decisionsRef.get().isEmpty()); + } } 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..bdfd6cdc1 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 { 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..2cb7e801c 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), + any(DecisionPath.class) ); // 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(), + any(DecisionPath.class) ); 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(), + any(DecisionPath.class) ); FeatureDecision featureDecision = decisionService.getVariationForFeature( @@ -437,7 +449,8 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() any(ProjectConfig.class), anyObject(), anyObject(), - any(DecisionReasons.class) + any(DecisionReasons.class), + any(DecisionPath.class) ); // return variation for rollout @@ -471,7 +484,8 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() any(ProjectConfig.class), anyObject(), anyObject(), - any(DecisionReasons.class) + any(DecisionReasons.class), + any(DecisionPath.class) ); } @@ -498,7 +512,8 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails any(ProjectConfig.class), anyObject(), anyObject(), - any(DecisionReasons.class) + any(DecisionReasons.class), + any(DecisionPath.class) ); // return variation for rollout @@ -532,7 +547,8 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails any(ProjectConfig.class), anyObject(), anyObject(), - any(DecisionReasons.class) + any(DecisionReasons.class), + any(DecisionPath.class) ); 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,328 @@ 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() { + // Use an existing experiment from v4ProjectConfig and modify it to be CMAB + Experiment baseExperiment = v4ProjectConfig.getExperiments().get(0); + Variation expectedVariation = baseExperiment.getVariations().get(0); + + // Create mock Cmab object + Cmab mockCmab = mock(Cmab.class); + when(mockCmab.getTrafficAllocation()).thenReturn(10000); // 100% allocation + + // Create CMAB experiment using existing experiment structure + Experiment cmabExperiment = new Experiment( + baseExperiment.getId(), + baseExperiment.getKey(), + baseExperiment.getStatus(), + baseExperiment.getLayerId(), + baseExperiment.getAudienceIds(), + baseExperiment.getAudienceConditions(), + baseExperiment.getVariations(), + Collections.emptyMap(), // No whitelisting + baseExperiment.getTrafficAllocation(), + mockCmab // This makes it a CMAB experiment + ); + + // Mock bucketer to return a variation (user is in CMAB traffic) + Variation bucketedVariation = new Variation("$", "$"); + Bucketer mockBucketer = mock(Bucketer.class); + when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), eq(true))) + .thenReturn(DecisionResponse.responseNoReasons(bucketedVariation)); + + DecisionService decisionServiceWithMockCmabService = new DecisionService( + mockBucketer, + mockErrorHandler, + null, + mockCmabService + ); + + // Mock CmabService.getDecision to return the expected variation ID + 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( + cmabExperiment, + optimizely.createUserContext(genericUserId, Collections.emptyMap()), + v4ProjectConfig + ); + + // Verify that CMAB service decision is returned + assertNotNull("Result should not be null", result.getResult()); + assertEquals(expectedVariation, result.getResult()); + + // Verify that the result is not an error + assertFalse(result.isError()); + + // Verify CmabService.getDecision was called + 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(cmabExperiment.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); + + // 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.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), eq(true))) + .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)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), eq(true)); + } + + 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/cmab/DefaultCmabServiceTest.java b/core-api/src/test/java/com/optimizely/ab/cmab/DefaultCmabServiceTest.java index fbdf94c66..60139bc8b 100644 --- a/core-api/src/test/java/com/optimizely/ab/cmab/DefaultCmabServiceTest.java +++ b/core-api/src/test/java/com/optimizely/ab/cmab/DefaultCmabServiceTest.java @@ -43,7 +43,6 @@ import com.optimizely.ab.cmab.client.CmabClient; import com.optimizely.ab.cmab.service.CmabCacheValue; import com.optimizely.ab.cmab.service.CmabDecision; -import com.optimizely.ab.cmab.service.CmabServiceOptions; import com.optimizely.ab.cmab.service.DefaultCmabService; import com.optimizely.ab.config.Attribute; import com.optimizely.ab.config.Cmab; @@ -83,9 +82,7 @@ public DefaultCmabServiceTest() { @Before public void setUp() { MockitoAnnotations.initMocks(this); - - CmabServiceOptions options = new CmabServiceOptions(mockLogger, mockCmabCache, mockCmabClient); - cmabService = new DefaultCmabService(options); + cmabService = new DefaultCmabService(mockCmabClient, mockCmabCache, mockLogger); // Setup mock user context when(mockUserContext.getUserId()).thenReturn("user123"); diff --git a/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTestUtils.java b/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTestUtils.java index ef9a8ccc2..6908623b0 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTestUtils.java +++ b/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTestUtils.java @@ -424,6 +424,10 @@ public static String nullFeatureEnabledConfigJsonV4() throws IOException { return Resources.toString(Resources.getResource("config/null-featureEnabled-config-v4.json"), Charsets.UTF_8); } + public static String validConfigJsonCMAB() throws IOException { + return Resources.toString(Resources.getResource("config/cmab-config.json"), Charsets.UTF_8); + } + /** * @return the expected {@link DatafileProjectConfig} for the json produced by {@link #validConfigJsonV2()} ()} */ diff --git a/core-api/src/test/java/com/optimizely/ab/internal/DefaultLRUCacheTest.java b/core-api/src/test/java/com/optimizely/ab/internal/DefaultLRUCacheTest.java index bc5a509f7..1cf3eca5f 100644 --- a/core-api/src/test/java/com/optimizely/ab/internal/DefaultLRUCacheTest.java +++ b/core-api/src/test/java/com/optimizely/ab/internal/DefaultLRUCacheTest.java @@ -16,12 +16,12 @@ */ package com.optimizely.ab.internal; -import org.junit.Test; - import java.util.Arrays; import java.util.List; -import static org.junit.Assert.*; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import org.junit.Test; public class DefaultLRUCacheTest { 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..d507f93c9 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java @@ -16,22 +16,28 @@ */ package com.optimizely.ab; +import java.util.concurrent.TimeUnit; + +import org.apache.http.impl.client.CloseableHttpClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.optimizely.ab.cmab.DefaultCmabClient; +import com.optimizely.ab.cmab.client.CmabClientConfig; +import com.optimizely.ab.cmab.service.CmabCacheValue; +import com.optimizely.ab.cmab.service.DefaultCmabService; import com.optimizely.ab.config.HttpProjectConfigManager; import com.optimizely.ab.config.ProjectConfig; import com.optimizely.ab.config.ProjectConfigManager; import com.optimizely.ab.event.AsyncEventHandler; import com.optimizely.ab.event.BatchEventProcessor; import com.optimizely.ab.event.EventHandler; +import com.optimizely.ab.internal.Cache; import com.optimizely.ab.internal.PropertyUtils; import com.optimizely.ab.notification.NotificationCenter; import com.optimizely.ab.odp.DefaultODPApiManager; import com.optimizely.ab.odp.ODPApiManager; import com.optimizely.ab.odp.ODPManager; -import org.apache.http.impl.client.CloseableHttpClient; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.concurrent.TimeUnit; /** * OptimizelyFactory is a utility class to instantiate an {@link Optimizely} client with a minimal @@ -54,6 +60,8 @@ public final class OptimizelyFactory { private static final Logger logger = LoggerFactory.getLogger(OptimizelyFactory.class); + private static Cache customCmabCache; + /** * Convenience method for setting the maximum number of events contained within a batch. * {@link AsyncEventHandler} @@ -202,6 +210,48 @@ public static void setDatafileAccessToken(String datafileAccessToken) { PropertyUtils.set(HttpProjectConfigManager.CONFIG_DATAFILE_AUTH_TOKEN, datafileAccessToken); } + /** + * Convenience method for setting the CMAB cache size. + * {@link DefaultCmabService.Builder#withCmabCacheSize(int)} + * + * @param cacheSize The maximum number of CMAB cache entries + */ + public static void setCmabCacheSize(int cacheSize) { + if (cacheSize <= 0) { + logger.warn("CMAB cache size cannot be <= 0. Reverting to default configuration."); + return; + } + PropertyUtils.set("optimizely.cmab.cache.size", Integer.toString(cacheSize)); + } + + /** + * Convenience method for setting the CMAB cache timeout. + * {@link DefaultCmabService.Builder#withCmabCacheTimeoutInSecs(int)} + * + * @param timeoutInSecs The timeout in seconds before CMAB cache entries expire + */ + public static void setCmabCacheTimeoutInSecs(int timeoutInSecs) { + if (timeoutInSecs <= 0) { + logger.warn("CMAB cache timeout cannot be <= 0. Reverting to default configuration."); + return; + } + PropertyUtils.set("optimizely.cmab.cache.timeout", Integer.toString(timeoutInSecs)); + } + + /** + * Convenience method for setting a custom CMAB cache implementation. + * {@link DefaultCmabService.Builder#withCustomCache(Cache)} + * + * @param cache The custom cache implementation + */ + public static void setCustomCmabCache(Cache cache) { + if (cache == null) { + logger.warn("Custom CMAB cache cannot be null. Reverting to default configuration."); + return; + } + customCmabCache = cache; + } + /** * Returns a new Optimizely instance based on preset configuration. * @@ -369,11 +419,44 @@ public static Optimizely newDefaultInstance(ProjectConfigManager configManager, .withApiManager(odpApiManager != null ? odpApiManager : new DefaultODPApiManager()) .build(); + DefaultCmabClient defaultCmabClient = new DefaultCmabClient(CmabClientConfig.withDefaultRetry()); + + DefaultCmabService.Builder cmabBuilder = DefaultCmabService.builder() + .withClient(defaultCmabClient); + + // Always apply cache size from properties if set + String cacheSizeStr = PropertyUtils.get("optimizely.cmab.cache.size"); + if (cacheSizeStr != null) { + try { + cmabBuilder.withCmabCacheSize(Integer.parseInt(cacheSizeStr)); + } catch (NumberFormatException e) { + logger.warn("Invalid CMAB cache size property value: {}", cacheSizeStr); + } + } + + // Always apply cache timeout from properties if set + String cacheTimeoutStr = PropertyUtils.get("optimizely.cmab.cache.timeout"); + if (cacheTimeoutStr != null) { + try { + cmabBuilder.withCmabCacheTimeoutInSecs(Integer.parseInt(cacheTimeoutStr)); + } catch (NumberFormatException e) { + logger.warn("Invalid CMAB cache timeout property value: {}", cacheTimeoutStr); + } + } + + // If custom cache is provided, it overrides the size/timeout settings + if (customCmabCache != null) { + cmabBuilder.withCustomCache(customCmabCache); + } + + DefaultCmabService defaultCmabService = cmabBuilder.build(); + return Optimizely.builder() .withEventProcessor(eventProcessor) .withConfigManager(configManager) .withNotificationCenter(notificationCenter) .withODPManager(odpManager) + .withCmabService(defaultCmabService) .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..c53a8cdb6 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,16 +36,12 @@ 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; private final RetryConfig retryConfig; @@ -80,8 +76,8 @@ private OptimizelyHttpClient createDefaultHttpClient() { @Override 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 url = String.format(CmabClientHelper.CMAB_PREDICTION_ENDPOINT, ruleId); + String requestBody = CmabClientHelper.buildRequestJson(userId, ruleId, attributes, cmabUuid); // Use retry logic if configured, otherwise single request if (retryConfig != null && retryConfig.getMaxRetries() > 0) { @@ -96,7 +92,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 +101,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 +112,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 +154,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 +168,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) {