From 26e6393495881340b4cb88d9fb22a973fd4b30e4 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Fri, 19 Sep 2025 16:18:20 +0600 Subject: [PATCH 01/49] update: add CmabService to Optimizely class and builder --- .../java/com/optimizely/ab/Optimizely.java | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) 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..2c7716254 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -20,6 +20,7 @@ import com.optimizely.ab.bucketing.DecisionService; import com.optimizely.ab.bucketing.FeatureDecision; import com.optimizely.ab.bucketing.UserProfileService; +import com.optimizely.ab.cmab.service.CmabService; import com.optimizely.ab.config.AtomicProjectConfigManager; import com.optimizely.ab.config.DatafileProjectConfig; import com.optimizely.ab.config.EventType; @@ -141,8 +142,12 @@ public class Optimizely implements AutoCloseable { @Nullable private final ODPManager odpManager; + @Nullable + private final CmabService cmabService; + private final ReentrantLock lock = new ReentrantLock(); + private Optimizely(@Nonnull EventHandler eventHandler, @Nonnull EventProcessor eventProcessor, @Nonnull ErrorHandler errorHandler, @@ -152,8 +157,9 @@ private Optimizely(@Nonnull EventHandler eventHandler, @Nullable OptimizelyConfigManager optimizelyConfigManager, @Nonnull NotificationCenter notificationCenter, @Nonnull List defaultDecideOptions, - @Nullable ODPManager odpManager - ) { + @Nullable ODPManager odpManager, + @Nullable CmabService cmabService + ) { this.eventHandler = eventHandler; this.eventProcessor = eventProcessor; this.errorHandler = errorHandler; @@ -164,6 +170,7 @@ private Optimizely(@Nonnull EventHandler eventHandler, this.notificationCenter = notificationCenter; this.defaultDecideOptions = defaultDecideOptions; this.odpManager = odpManager; + this.cmabService = cmabService; if (odpManager != null) { odpManager.getEventManager().start(); @@ -1731,6 +1738,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 +1850,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; @@ -1916,7 +1929,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); } } } From ad63201c61750115495d0adb3038c19592a8a24e Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Fri, 19 Sep 2025 17:07:02 +0600 Subject: [PATCH 02/49] update: integrate CMAB service into OptimizelyFactory --- .../com/optimizely/ab/OptimizelyFactory.java | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java index f26851375..1cc4080b6 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java @@ -16,22 +16,29 @@ */ package com.optimizely.ab; +import java.util.concurrent.TimeUnit; + +import org.apache.http.impl.client.CloseableHttpClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.optimizely.ab.cmab.DefaultCmabClient; +import com.optimizely.ab.cmab.client.CmabClientConfig; +import com.optimizely.ab.cmab.service.CmabCacheValue; +import com.optimizely.ab.cmab.service.CmabServiceOptions; +import com.optimizely.ab.cmab.service.DefaultCmabService; import com.optimizely.ab.config.HttpProjectConfigManager; import com.optimizely.ab.config.ProjectConfig; import com.optimizely.ab.config.ProjectConfigManager; import com.optimizely.ab.event.AsyncEventHandler; import com.optimizely.ab.event.BatchEventProcessor; import com.optimizely.ab.event.EventHandler; +import com.optimizely.ab.internal.DefaultLRUCache; import com.optimizely.ab.internal.PropertyUtils; import com.optimizely.ab.notification.NotificationCenter; import com.optimizely.ab.odp.DefaultODPApiManager; import com.optimizely.ab.odp.ODPApiManager; import com.optimizely.ab.odp.ODPManager; -import org.apache.http.impl.client.CloseableHttpClient; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.concurrent.TimeUnit; /** * OptimizelyFactory is a utility class to instantiate an {@link Optimizely} client with a minimal @@ -369,11 +376,19 @@ public static Optimizely newDefaultInstance(ProjectConfigManager configManager, .withApiManager(odpApiManager != null ? odpApiManager : new DefaultODPApiManager()) .build(); + DefaultCmabClient defaultCmabClient = new DefaultCmabClient(CmabClientConfig.withDefaultRetry()); + int DEFAULT_MAX_SIZE = 1000; + int DEFAULT_CMAB_CACHE_TIMEOUT = 30 * 60 * 1000; + DefaultLRUCache cmabCache = new DefaultLRUCache<>(DEFAULT_MAX_SIZE, DEFAULT_CMAB_CACHE_TIMEOUT); + CmabServiceOptions cmabServiceOptions = new CmabServiceOptions(logger, cmabCache, defaultCmabClient); + DefaultCmabService cmabService = new DefaultCmabService(cmabServiceOptions); + return Optimizely.builder() .withEventProcessor(eventProcessor) .withConfigManager(configManager) .withNotificationCenter(notificationCenter) .withODPManager(odpManager) + .withCmabService(cmabService) .build(); } } From fbed3629f432addd86a275ded8dd92385f96a76b Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Fri, 19 Sep 2025 17:21:05 +0600 Subject: [PATCH 03/49] update: change CmabService field to non-nullable in Optimizely class --- core-api/src/main/java/com/optimizely/ab/Optimizely.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 2c7716254..0da2d9824 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -142,7 +142,6 @@ public class Optimizely implements AutoCloseable { @Nullable private final ODPManager odpManager; - @Nullable private final CmabService cmabService; private final ReentrantLock lock = new ReentrantLock(); @@ -158,7 +157,7 @@ private Optimizely(@Nonnull EventHandler eventHandler, @Nonnull NotificationCenter notificationCenter, @Nonnull List defaultDecideOptions, @Nullable ODPManager odpManager, - @Nullable CmabService cmabService + @Nonnull CmabService cmabService ) { this.eventHandler = eventHandler; this.eventProcessor = eventProcessor; From 53d754a25e989d11cba0d044a6e94b6b6bbb80fd Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Fri, 19 Sep 2025 20:44:05 +0600 Subject: [PATCH 04/49] update: add CmabService to DecisionService and its tests --- .../java/com/optimizely/ab/Optimizely.java | 2 +- .../ab/bucketing/DecisionService.java | 6 ++- .../ab/bucketing/DecisionServiceTest.java | 48 ++++++++++++------- 3 files changed, 36 insertions(+), 20 deletions(-) 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 0da2d9824..cf487a641 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -1885,7 +1885,7 @@ public Optimizely build() { } if (decisionService == null) { - decisionService = new DecisionService(bucketer, errorHandler, userProfileService); + decisionService = new DecisionService(bucketer, errorHandler, userProfileService, cmabService); } if (projectConfig == null && datafile != null && !datafile.isEmpty()) { 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..533670d39 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,7 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; +import com.optimizely.ab.cmab.service.CmabService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -64,6 +65,7 @@ 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); /** @@ -84,10 +86,12 @@ public class DecisionService { */ public DecisionService(@Nonnull Bucketer bucketer, @Nonnull ErrorHandler errorHandler, - @Nullable UserProfileService userProfileService) { + @Nullable UserProfileService userProfileService, + @Nullable CmabService cmabService) { this.bucketer = bucketer; this.errorHandler = errorHandler; this.userProfileService = userProfileService; + this.cmabService = cmabService; } /** 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..909d14fcc 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 @@ -55,6 +55,7 @@ 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.config.DatafileProjectConfigTestUtils; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.noAudienceProjectConfigV3; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV3; @@ -109,6 +110,9 @@ public class DecisionServiceTest { @Mock private ErrorHandler mockErrorHandler; + @Mock + private CmabService mockCmabService; + private ProjectConfig noAudienceProjectConfig; private ProjectConfig v4ProjectConfig; private ProjectConfig validProjectConfig; @@ -129,7 +133,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 +228,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 +260,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()); @@ -550,7 +556,7 @@ public void getVariationsForFeatureListBatchesUpsLoadAndSave() throws Exception ErrorHandler mockErrorHandler = mock(ErrorHandler.class); UserProfileService mockUserProfileService = mock(UserProfileService.class); - DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, mockUserProfileService); + DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, mockUserProfileService, null); FeatureFlag featureFlag1 = FEATURE_FLAG_MULTI_VARIATE_FEATURE; FeatureFlag featureFlag2 = FEATURE_FLAG_MULTI_VARIATE_FUTURE_FEATURE; @@ -609,6 +615,7 @@ public void getVariationForFeatureInRolloutReturnsNullWhenUserIsExcludedFromAllT DecisionService decisionService = new DecisionService( mockBucketer, mockErrorHandler, + null, null ); @@ -636,7 +643,7 @@ public void getVariationForFeatureInRolloutReturnsNullWhenUserFailsAllAudiencesA Bucketer mockBucketer = mock(Bucketer.class); when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class))).thenReturn(DecisionResponse.nullNoReasons()); - DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null); + DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null, null); FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( FEATURE_FLAG_MULTI_VARIATE_FEATURE, @@ -666,6 +673,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsAllAudie DecisionService decisionService = new DecisionService( mockBucketer, mockErrorHandler, + null, null ); @@ -707,6 +715,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficI DecisionService decisionService = new DecisionService( mockBucketer, mockErrorHandler, + null, null ); @@ -747,6 +756,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficI DecisionService decisionService = new DecisionService( mockBucketer, mockErrorHandler, + null, null ); @@ -786,7 +796,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTargetin when(mockBucketer.bucket(eq(everyoneElseRule), anyString(), any(ProjectConfig.class))).thenReturn(DecisionResponse.responseNoReasons(everyoneElseVariation)); when(mockBucketer.bucket(eq(englishCitizensRule), anyString(), any(ProjectConfig.class))).thenReturn(DecisionResponse.responseNoReasons(englishCitizenVariation)); - DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null); + DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null, null); FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( FEATURE_FLAG_MULTI_VARIATE_FEATURE, @@ -939,7 +949,7 @@ public void bucketReturnsVariationStoredInUserProfile() throws Exception { when(userProfileService.lookup(userProfileId)).thenReturn(userProfile.toMap()); Bucketer bucketer = new Bucketer(); - DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, userProfileService); + DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, userProfileService, null); logbackVerifier.expectMessage(Level.INFO, "Returning previously activated variation \"" + variation.getKey() + "\" of experiment \"" + experiment.getKey() + "\"" @@ -965,7 +975,7 @@ public void getStoredVariationLogsWhenLookupReturnsNull() throws Exception { UserProfile userProfile = new UserProfile(userProfileId, Collections.emptyMap()); when(userProfileService.lookup(userProfileId)).thenReturn(userProfile.toMap()); - DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, userProfileService); + DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, userProfileService, null); logbackVerifier.expectMessage(Level.INFO, "No previously activated variation of experiment " + "\"" + experiment.getKey() + "\" for user \"" + userProfileId + "\" found in user profile."); @@ -992,7 +1002,7 @@ public void getStoredVariationReturnsNullWhenVariationIsNoLongerInConfig() throw UserProfileService userProfileService = mock(UserProfileService.class); when(userProfileService.lookup(userProfileId)).thenReturn(storedUserProfile.toMap()); - DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, userProfileService); + DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, userProfileService, null); logbackVerifier.expectMessage(Level.INFO, "User \"" + userProfileId + "\" was previously bucketed into variation with ID \"" + storedVariationId + "\" for " + @@ -1023,7 +1033,7 @@ public void getVariationSavesBucketedVariationIntoUserProfile() throws Exception Bucketer mockBucketer = mock(Bucketer.class); when(mockBucketer.bucket(eq(experiment), eq(userProfileId), eq(noAudienceProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(variation)); - DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, userProfileService); + DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, userProfileService, null); assertEquals(variation, decisionService.getVariation( experiment, optimizely.createUserContext(userProfileId, Collections.emptyMap()), noAudienceProjectConfig).getResult() @@ -1058,7 +1068,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 +1095,7 @@ public void getVariationSavesANewUserProfile() throws Exception { Bucketer bucketer = mock(Bucketer.class); UserProfileService userProfileService = mock(UserProfileService.class); - DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, userProfileService); + DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, userProfileService, null); when(bucketer.bucket(eq(experiment), eq(userProfileId), eq(noAudienceProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(variation)); when(userProfileService.lookup(userProfileId)).thenReturn(null); @@ -1096,7 +1107,7 @@ public void getVariationSavesANewUserProfile() throws Exception { @Test public void getVariationBucketingId() throws Exception { Bucketer bucketer = mock(Bucketer.class); - DecisionService decisionService = spy(new DecisionService(bucketer, mockErrorHandler, null)); + DecisionService decisionService = spy(new DecisionService(bucketer, mockErrorHandler, null, null)); Experiment experiment = validProjectConfig.getExperiments().get(0); Variation expectedVariation = experiment.getVariations().get(0); @@ -1130,6 +1141,7 @@ public void getVariationForRolloutWithBucketingId() { DecisionService decisionService = spy(new DecisionService( bucketer, mockErrorHandler, + null, null )); @@ -1285,7 +1297,7 @@ public void getVariationForFeatureReturnHoldoutDecisionForGlobalHoldout() { Bucketer mockBucketer = new Bucketer(); - DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null); + DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null, null); Map attributes = new HashMap<>(); attributes.put("$opt_bucketing_id", "ppid160000"); @@ -1307,8 +1319,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 +1343,7 @@ public void excludedFlagsHoldoutAppliesToAllExceptSpecified() { Bucketer mockBucketer = new Bucketer(); - DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null); + DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null, null); Map attributes = new HashMap<>(); attributes.put("$opt_bucketing_id", "ppid300002"); @@ -1362,7 +1374,7 @@ public void userMeetsHoldoutAudienceConditions() { Bucketer mockBucketer = new Bucketer(); - DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null); + DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null, null); Map attributes = new HashMap<>(); attributes.put("$opt_bucketing_id", "ppid543400"); From 9905026f864c6d3ba9410f9417d3733a895e8f44 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 24 Sep 2025 05:04:58 +0600 Subject: [PATCH 05/49] update: implement CMAB traffic allocation in Bucketer and DecisionService --- .../com/optimizely/ab/bucketing/Bucketer.java | 91 +++++++++++++++++++ .../ab/bucketing/DecisionService.java | 85 ++++++++++++++++- .../optimizelydecision/DecisionResponse.java | 21 ++++- 3 files changed, 189 insertions(+), 8 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java b/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java index 35fa21c71..e9b694b16 100644 --- a/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java @@ -128,6 +128,49 @@ private DecisionResponse bucketToVariation(@Nonnull ExperimentCore ex return new DecisionResponse(null, reasons); } + /** + * Determines CMAB traffic allocation for a user based on hashed value from murmurhash3. + * This method handles bucketing users into CMAB (Contextual Multi-Armed Bandit) experiments. + */ + @Nonnull + private DecisionResponse bucketToEntityForCmab(@Nonnull Experiment experiment, + @Nonnull String bucketingId) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + + // "salt" the bucket id using the experiment id + String experimentId = experiment.getId(); + String experimentKey = experiment.getKey(); + String combinedBucketId = bucketingId + experimentId; + + // Handle CMAB traffic allocation + TrafficAllocation cmabTrafficAllocation = new TrafficAllocation("$", experiment.getCmab().getTrafficAllocation()); + List trafficAllocations = java.util.Collections.singletonList(cmabTrafficAllocation); + + String cmabMessage = reasons.addInfo("Using CMAB traffic allocation for experiment \"%s\".", experimentKey); + logger.debug(cmabMessage); + + int hashCode = MurmurHash3.murmurhash3_x86_32(combinedBucketId, 0, combinedBucketId.length(), MURMUR_HASH_SEED); + int bucketValue = generateBucketValue(hashCode); + logger.debug("Assigned bucket {} to user with bucketingId \"{}\" when bucketing to a variation.", bucketValue, bucketingId); + + String bucketedEntityId = bucketToEntity(bucketValue, trafficAllocations); + if (bucketedEntityId != null) { + if ("$".equals(bucketedEntityId)) { + String message = reasons.addInfo("User with bucketingId \"%s\" is bucketed into CMAB for experiment \"%s\".", bucketingId, experimentKey); + logger.info(message); + } else { + // This shouldn't happen in CMAB since we only have "$" entity, but handle gracefully + String message = reasons.addInfo("User with bucketingId \"%s\" is bucketed into entity \"%s\" for experiment \"%s\".", bucketingId, bucketedEntityId, experimentKey); + logger.info(message); + } + } else { + String message = reasons.addInfo("User with bucketingId \"%s\" is not bucketed into CMAB for experiment \"%s\".", bucketingId, experimentKey); + logger.info(message); + } + + return new DecisionResponse<>(bucketedEntityId, reasons); + } + /** * Assign a {@link Variation} of an {@link Experiment} to a user based on hashed value from murmurhash3. * @@ -177,6 +220,54 @@ public DecisionResponse bucket(@Nonnull ExperimentCore experiment, return new DecisionResponse<>(decisionResponse.getResult(), reasons); } + /** + * Assign a user to CMAB traffic for an experiment based on hashed value from murmurhash3. + * This method handles CMAB (Contextual Multi-Armed Bandit) traffic allocation. + * + * @param experiment The CMAB Experiment in which the user is to be bucketed. + * @param bucketingId string A customer-assigned value used to create the key for the murmur hash. + * @param projectConfig The current projectConfig + * @return A {@link DecisionResponse} including the entity ID ("$" if bucketed to CMAB, null otherwise) and decision reasons + */ + @Nonnull + public DecisionResponse bucketForCmab(@Nonnull Experiment experiment, + @Nonnull String bucketingId, + @Nonnull ProjectConfig projectConfig) { + + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + + // ---------- Handle Group Logic (same as regular bucket method) ---------- + String groupId = experiment.getGroupId(); + if (!groupId.isEmpty()) { + Group experimentGroup = projectConfig.getGroupIdMapping().get(groupId); + + if (experimentGroup.getPolicy().equals(Group.RANDOM_POLICY)) { + Experiment bucketedExperiment = bucketToExperiment(experimentGroup, bucketingId, projectConfig); + if (bucketedExperiment == null) { + String message = reasons.addInfo("User with bucketingId \"%s\" is not in any experiment of group %s.", bucketingId, experimentGroup.getId()); + logger.info(message); + return new DecisionResponse<>(null, reasons); + } + + if (!bucketedExperiment.getId().equals(experiment.getId())) { + String message = reasons.addInfo("User with bucketingId \"%s\" is not in experiment \"%s\" of group %s.", bucketingId, experiment.getKey(), + experimentGroup.getId()); + logger.info(message); + return new DecisionResponse<>(null, reasons); + } + + String message = reasons.addInfo("User with bucketingId \"%s\" is in experiment \"%s\" of group %s.", bucketingId, experiment.getKey(), + experimentGroup.getId()); + logger.info(message); + } + } + + // ---------- Use CMAB-aware bucketToEntity ---------- + DecisionResponse decisionResponse = bucketToEntityForCmab(experiment, bucketingId); + reasons.merge(decisionResponse.getReasons()); + return new DecisionResponse<>(decisionResponse.getResult(), reasons); + } + //======== Helper methods ========// /** diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java index 533670d39..4ca6dcdcd 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,7 @@ 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; @@ -153,9 +154,25 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment, if (decisionMeetAudience.getResult()) { String bucketingId = getBucketingId(user.getUserId(), user.getAttributes()); - decisionVariation = bucketer.bucket(experiment, bucketingId, projectConfig); - reasons.merge(decisionVariation.getReasons()); - variation = decisionVariation.getResult(); + if (isCmabExperiment(experiment)) { + DecisionResponse cmabDecision = getDecisionForCmabExperiment(projectConfig, experiment, user, bucketingId, options); + reasons.merge(cmabDecision.getReasons()); + + if (cmabDecision.isError()) { + return new DecisionResponse<>(null, reasons, true); + } + + CmabDecision cmabResult = cmabDecision.getResult(); + if (cmabResult != null) { + String variationId = cmabResult.getVariationId(); + 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) { @@ -863,4 +880,66 @@ DecisionResponse getVariationFromDeliveryRule(@Nonnull return new DecisionResponse(variationToSkipToEveryoneElsePair, reasons); } + /** + * Retrieves a decision for a contextual multi-armed bandit (CMAB) + * experiment. + * + * @param projectConfig Instance of ProjectConfig. + * @param experiment The experiment object for which the decision is to be + * made. + * @param userContext The user context containing user id and attributes. + * @param bucketingId The bucketing ID to use for traffic allocation. + * @param options Optional list of decide options. + * @return A CmabDecisionResult containing error status, result, and + * reasons. + */ + private DecisionResponse getDecisionForCmabExperiment(@Nonnull ProjectConfig projectConfig, + @Nonnull Experiment experiment, + @Nonnull OptimizelyUserContext userContext, + @Nonnull String bucketingId, + @Nonnull List options) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + + // Check if user is in CMAB traffic allocation + DecisionResponse bucketResponse = bucketer.bucketForCmab(experiment, bucketingId, projectConfig); + reasons.merge(bucketResponse.getReasons()); + + String bucketedEntityId = bucketResponse.getResult(); + + if (bucketedEntityId == null) { + String message = String.format("User \"%s\" not in CMAB experiment \"%s\" due to traffic allocation.", + userContext.getUserId(), experiment.getKey()); + logger.info(message); + reasons.addInfo(message); + + return new DecisionResponse<>(null, reasons); + } + + // User is in CMAB allocation, proceed to CMAB decision + try { + CmabDecision cmabDecision = cmabService.getDecision(projectConfig, userContext, experiment.getId(), options); + + return new DecisionResponse<>(cmabDecision, reasons); + } catch (Exception e) { + String errorMessage = String.format("CMAB fetch failed for experiment \"%s\"", experiment.getKey()); + reasons.addInfo(errorMessage); + logger.error("{} {}", errorMessage, e.getMessage()); + + return new DecisionResponse<>(null, reasons); + } + } + + /** + * 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/optimizelydecision/DecisionResponse.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionResponse.java index fee8aa32b..3660bdaac 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,24 @@ public class DecisionResponse { private T result; private DecisionReasons reasons; + private boolean error; - public DecisionResponse(@Nullable T result, @Nonnull DecisionReasons reasons) { + public DecisionResponse(@Nullable T result, @Nonnull DecisionReasons reasons, @Nonnull boolean error) { this.result = result; this.reasons = reasons; + this.error = error; + } + + public DecisionResponse(@Nullable T result, @Nonnull DecisionReasons reasons) { + this(result, reasons, false); } - public static DecisionResponse responseNoReasons(@Nullable E result) { - return new DecisionResponse(result, DefaultDecisionReasons.newInstance()); + public static DecisionResponse responseNoReasons(@Nullable E result) { + return new DecisionResponse<>(result, DefaultDecisionReasons.newInstance(), false); } - public static DecisionResponse nullNoReasons() { - return new DecisionResponse(null, DefaultDecisionReasons.newInstance()); + public static DecisionResponse nullNoReasons() { + return new DecisionResponse<>(null, DefaultDecisionReasons.newInstance(), false); } @Nullable @@ -45,4 +51,9 @@ public T getResult() { public DecisionReasons getReasons() { return reasons; } + + @Nonnull + public boolean isError(){ + return error; + } } From 78f45bfda620ffba27e7061596270c50a57f3881 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 24 Sep 2025 06:09:30 +0600 Subject: [PATCH 06/49] update: enhance DecisionService, FeatureDecision, and DecisionResponse to support CMAB UUID handling --- .../ab/bucketing/DecisionService.java | 8 +++--- .../ab/bucketing/FeatureDecision.java | 27 ++++++++++++++++++- .../optimizelydecision/DecisionResponse.java | 15 ++++++++--- 3 files changed, 42 insertions(+), 8 deletions(-) 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 4ca6dcdcd..290732b3e 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 @@ -60,6 +60,7 @@ * 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 { @@ -153,18 +154,19 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment, reasons.merge(decisionMeetAudience.getReasons()); if (decisionMeetAudience.getResult()) { String bucketingId = getBucketingId(user.getUserId(), user.getAttributes()); - + String cmabUUID = null; if (isCmabExperiment(experiment)) { DecisionResponse cmabDecision = getDecisionForCmabExperiment(projectConfig, experiment, user, bucketingId, options); reasons.merge(cmabDecision.getReasons()); if (cmabDecision.isError()) { - return new DecisionResponse<>(null, reasons, true); + 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 { @@ -182,7 +184,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()); 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/optimizelydecision/DecisionResponse.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionResponse.java index 3660bdaac..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 @@ -23,23 +23,25 @@ public class DecisionResponse { private T result; private DecisionReasons reasons; private boolean error; + private String cmabUUID; - public DecisionResponse(@Nullable T result, @Nonnull DecisionReasons reasons, @Nonnull boolean error) { + 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 DecisionResponse(@Nullable T result, @Nonnull DecisionReasons reasons) { - this(result, reasons, false); + this(result, reasons, false, null); } public static DecisionResponse responseNoReasons(@Nullable E result) { - return new DecisionResponse<>(result, DefaultDecisionReasons.newInstance(), false); + return new DecisionResponse<>(result, DefaultDecisionReasons.newInstance(), false, null); } public static DecisionResponse nullNoReasons() { - return new DecisionResponse<>(null, DefaultDecisionReasons.newInstance(), false); + return new DecisionResponse<>(null, DefaultDecisionReasons.newInstance(), false, null); } @Nullable @@ -56,4 +58,9 @@ public DecisionReasons getReasons() { public boolean isError(){ return error; } + + @Nullable + public String getCmabUUID() { + return cmabUUID; + } } From 9757d498cd3be154e02cfd022ea4616681225a9d Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 24 Sep 2025 06:44:24 +0600 Subject: [PATCH 07/49] update: enhance DecisionService and DecisionMessage to handle errors and include CMAB UUIDs in responses --- .../main/java/com/optimizely/ab/Optimizely.java | 10 ++++++++++ .../optimizely/ab/bucketing/DecisionService.java | 14 +++++++++----- .../ab/optimizelydecision/DecisionMessage.java | 3 ++- 3 files changed, 21 insertions(+), 6 deletions(-) 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 cf487a641..80a5021e0 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -1450,7 +1450,17 @@ private Map decideForKeys(@Nonnull OptimizelyUserCon for (int i = 0; i < flagsWithoutForcedDecision.size(); i++) { DecisionResponse decision = decisionList.get(i); + boolean error = decision.isError(); String flagKey = flagsWithoutForcedDecision.get(i).getKey(); + + if (error) { + OptimizelyDecision optimizelyDecision = OptimizelyDecision.newErrorDecision(flagKey, user, DecisionMessage.DECISION_ERROR.reason(flagKey)); + decisionMap.put(flagKey, optimizelyDecision); + if (validKeys.contains(flagKey)) { + validKeys.remove(flagKey); + } + } + flagDecisions.put(flagKey, decision.getResult()); decisionReasonsMap.get(flagKey).merge(decision.getReasons()); } 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 290732b3e..f7ec5ba56 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 @@ -295,8 +295,10 @@ public List> getVariationsForFeatureList(@Non 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; } @@ -354,11 +356,13 @@ DecisionResponse getVariationFromExperiment(@Nonnull ProjectCon getVariationFromExperimentRule(projectConfig, featureFlag.getKey(), experiment, user, options, userProfileTracker); reasons.merge(decisionVariation.getReasons()); Variation variation = decisionVariation.getResult(); - + String cmabUUID = decisionVariation.getCmabUUID(); 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 { @@ -792,7 +796,7 @@ private DecisionResponse getVariationFromExperimentRule(@Nonnull Proj variation = decisionResponse.getResult(); - return new DecisionResponse(variation, reasons); + return new DecisionResponse<>(variation, reasons, decisionResponse.isError(), decisionResponse.getCmabUUID()); } /** 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..5cae0c744 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."), + DECISION_ERROR("Decision service error occured for key \"%s\"."); private String format; From ecf9199dbbc2d8f864bdaf0289d99cb89f8a9f39 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 24 Sep 2025 08:46:17 +0600 Subject: [PATCH 08/49] update: add validConfigJsonCMAB method to DatafileProjectConfigTestUtils for CMAB configuration --- .../com/optimizely/ab/OptimizelyTest.java | 54 +++++++++++++++++++ .../DatafileProjectConfigTestUtils.java | 4 ++ 2 files changed, 58 insertions(+) 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..470947ed4 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -37,7 +37,10 @@ import com.optimizely.ab.odp.ODPEvent; import com.optimizely.ab.odp.ODPEventManager; import com.optimizely.ab.odp.ODPManager; +import com.optimizely.ab.optimizelydecision.DecisionReasons; import com.optimizely.ab.optimizelydecision.DecisionResponse; +import com.optimizely.ab.optimizelydecision.DefaultDecisionReasons; +import com.optimizely.ab.optimizelydecision.OptimizelyDecision; import com.optimizely.ab.optimizelyjson.OptimizelyJSON; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.junit.Before; @@ -4993,4 +4996,55 @@ public void identifyUser() { optimizely.identifyUser("the-user"); Mockito.verify(mockODPEventManager, times(1)).identifyUser("the-user"); } + + @Test + public void testDecideReturnsErrorDecisionWhenDecisionServiceFails() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + // Use the CMAB datafile + Optimizely optimizely = Optimizely.builder() + .withDatafile(validConfigJsonCMAB()) + .withDecisionService(mockDecisionService) + .build(); + + // Mock decision service to return an error from CMAB + DecisionReasons reasons = new DefaultDecisionReasons(); + FeatureDecision errorFeatureDecision = new FeatureDecision(null, null, FeatureDecision.DecisionSource.ROLLOUT); + DecisionResponse errorDecisionResponse = new DecisionResponse<>( + errorFeatureDecision, + reasons, + true, + null + ); + + // Mock validatedForcedDecision to return no forced decision (but not null!) + DecisionResponse noForcedDecision = new DecisionResponse<>(null, new DefaultDecisionReasons()); + when(mockDecisionService.validatedForcedDecision( + any(OptimizelyDecisionContext.class), + any(ProjectConfig.class), + any(OptimizelyUserContext.class) + )).thenReturn(noForcedDecision); + + // Mock getVariationsForFeatureList to return the error decision + when(mockDecisionService.getVariationsForFeatureList( + any(List.class), + any(OptimizelyUserContext.class), + any(ProjectConfig.class), + any(List.class) + )).thenReturn(Arrays.asList(errorDecisionResponse)); + + + // Use the feature flag from your CMAB config + OptimizelyUserContext userContext = optimizely.createUserContext("test_user"); + OptimizelyDecision decision = userContext.decide("feature_1"); // This is the feature flag key from cmab-config.json + + System.out.println("reasons: " + decision.getReasons()); + // Verify the decision contains the error information + assertFalse(decision.getEnabled()); + assertNull(decision.getVariationKey()); + assertNull(decision.getRuleKey()); + assertEquals("feature_1", decision.getFlagKey()); + assertTrue(decision.getReasons().contains("Decision service error occured for key \"feature_1\".")); + } + } 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()} ()} */ From 5e0808f22587924092b25fe88c4f54b281533c12 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 24 Sep 2025 16:31:19 +0600 Subject: [PATCH 09/49] update: add tests to verify precedence of whitelisted and forced variations over CMAB service decisions in DecisionService --- .../ab/bucketing/DecisionServiceTest.java | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) 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 909d14fcc..f2f09d302 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 @@ -56,6 +56,8 @@ 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; @@ -1393,4 +1395,136 @@ 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()); + } + + 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", 10000) + ); + + // 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 + ); + } } From 36d2b4c754a98c045d952de1df0da91a8f0b17c6 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 24 Sep 2025 17:00:16 +0600 Subject: [PATCH 10/49] update: add test to verify error handling in getVariation for CMAB service failures --- .../ab/bucketing/DecisionServiceTest.java | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) 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 f2f09d302..28bd76fd5 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 @@ -1500,6 +1500,66 @@ public void getVariationCmabExperimentForcedPrecedesCmabService() { 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) + assertFalse(result.isError()); + + // Assert that CmabService.getDecision was called exactly once + verify(mockCmabService, times(1)).getDecision(any(), any(), any(), any()); + } + private Experiment createMockCmabExperiment() { List variations = Arrays.asList( new Variation("111151", "variation_1"), From 5796cb717d7953ef2cb911a2262e0b4c910be8e9 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 24 Sep 2025 17:02:41 +0600 Subject: [PATCH 11/49] update: modify DecisionResponse to include additional error handling information --- .../main/java/com/optimizely/ab/bucketing/DecisionService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f7ec5ba56..9b5118221 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 @@ -931,7 +931,7 @@ private DecisionResponse getDecisionForCmabExperiment(@Nonnull Pro reasons.addInfo(errorMessage); logger.error("{} {}", errorMessage, e.getMessage()); - return new DecisionResponse<>(null, reasons); + return new DecisionResponse<>(null, reasons, true, null); } } From d8b0134c80ea4a6c9023ced77bbdf397b8495753 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 24 Sep 2025 17:04:05 +0600 Subject: [PATCH 12/49] update: fix error handling assertion in DecisionServiceTest to correctly verify error state --- .../java/com/optimizely/ab/bucketing/DecisionServiceTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 28bd76fd5..d9db770e0 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 @@ -1554,7 +1554,7 @@ public void getVariationCmabExperimentServiceError() { assertNull(result.getResult()); // Verify that the error is not propagated (no exception thrown) - assertFalse(result.isError()); + assertTrue(result.isError()); // Assert that CmabService.getDecision was called exactly once verify(mockCmabService, times(1)).getDecision(any(), any(), any(), any()); From b2f270f3195ad1112109523d65c19dbe97de2a6f Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 24 Sep 2025 17:24:12 +0600 Subject: [PATCH 13/49] update: add tests for CMAB experiment variations in DecisionService --- .../ab/bucketing/DecisionServiceTest.java | 130 +++++++++++++++++- 1 file changed, 129 insertions(+), 1 deletion(-) 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 d9db770e0..b7a95fb73 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 @@ -1560,6 +1560,134 @@ public void getVariationCmabExperimentServiceError() { verify(mockCmabService, times(1)).getDecision(any(), any(), any(), any()); } + /** + * Verify that getVariation returns the variation from CMAB service + * when user is bucketed into CMAB traffic and service returns a valid decision. + */ + @Test + public void getVariationCmabExperimentServiceSuccess() { + // Create a CMAB experiment + Experiment cmabExperiment = createMockCmabExperiment(); + Variation expectedVariation = cmabExperiment.getVariations().get(1); // Use second variation + + // Create mock Cmab object + Cmab mockCmab = mock(Cmab.class); + when(mockCmab.getTrafficAllocation()).thenReturn(4000); + + // Create experiment with CMAB config (no whitelisting, no forced variations) + Experiment experiment = new Experiment( + cmabExperiment.getId(), + cmabExperiment.getKey(), + cmabExperiment.getStatus(), + cmabExperiment.getLayerId(), + cmabExperiment.getAudienceIds(), + cmabExperiment.getAudienceConditions(), + cmabExperiment.getVariations(), + Collections.emptyMap(), // No whitelisting + cmabExperiment.getTrafficAllocation(), + mockCmab // This makes it a CMAB experiment + ); + + Bucketer mockBucketer = mock(Bucketer.class); + when(mockBucketer.bucketForCmab(any(Experiment.class), anyString(), any(ProjectConfig.class))) + .thenReturn(DecisionResponse.responseNoReasons("$")); + DecisionService decisionServiceWithMockCmabService = new DecisionService( + mockBucketer, + mockErrorHandler, + null, + mockCmabService + ); + + // Mock CmabService.getDecision to return a valid decision + CmabDecision mockCmabDecision = mock(CmabDecision.class); + when(mockCmabDecision.getVariationId()).thenReturn(expectedVariation.getId()); + when(mockCmabService.getDecision(any(), any(), any(), any())) + .thenReturn(mockCmabDecision); + + // Call getVariation + DecisionResponse result = decisionServiceWithMockCmabService.getVariation( + experiment, + optimizely.createUserContext(genericUserId, Collections.emptyMap()), + v4ProjectConfig + ); + + // Verify that CMAB service decision is returned + assertEquals(expectedVariation, result.getResult()); + + // Verify that the result is not an error + assertFalse(result.isError()); + + // Assert that CmabService.getDecision was called exactly once + verify(mockCmabService, times(1)).getDecision(any(), any(), any(), any()); + + // Verify that the correct parameters were passed to CMAB service + verify(mockCmabService).getDecision( + eq(v4ProjectConfig), + any(OptimizelyUserContext.class), + eq(experiment.getId()), + any(List.class) + ); + } + + /** + * Verify that getVariation returns null when user is not bucketed into CMAB traffic + * by mocking the bucketer to return null for CMAB allocation. + */ + @Test + public void getVariationCmabExperimentUserNotInTrafficAllocation() { + // Create a CMAB experiment + Experiment cmabExperiment = createMockCmabExperiment(); + + // Create mock Cmab object + Cmab mockCmab = mock(Cmab.class); + when(mockCmab.getTrafficAllocation()).thenReturn(5000); // 50% traffic allocation + + // Create experiment with CMAB config (no whitelisting, no forced variations) + Experiment experiment = new Experiment( + cmabExperiment.getId(), + cmabExperiment.getKey(), + cmabExperiment.getStatus(), + cmabExperiment.getLayerId(), + cmabExperiment.getAudienceIds(), + cmabExperiment.getAudienceConditions(), + cmabExperiment.getVariations(), + Collections.emptyMap(), // No whitelisting + cmabExperiment.getTrafficAllocation(), + mockCmab // This makes it a CMAB experiment + ); + + // Mock bucketer to return null for CMAB allocation (user not in CMAB traffic) + Bucketer mockBucketer = mock(Bucketer.class); + when(mockBucketer.bucketForCmab(any(Experiment.class), anyString(), any(ProjectConfig.class))) + .thenReturn(DecisionResponse.nullNoReasons()); + + DecisionService decisionServiceWithMockCmabService = new DecisionService( + mockBucketer, + mockErrorHandler, + null, + mockCmabService + ); + + // Call getVariation + DecisionResponse result = decisionServiceWithMockCmabService.getVariation( + experiment, + optimizely.createUserContext(genericUserId, Collections.emptyMap()), + v4ProjectConfig + ); + + // Verify that no variation is returned (user not in CMAB traffic) + assertNull(result.getResult()); + + // Verify that the result is not an error + assertFalse(result.isError()); + + // Assert that CmabService.getDecision was never called (user not in CMAB traffic) + verify(mockCmabService, never()).getDecision(any(), any(), any(), any()); + + // Verify that bucketer was called for CMAB allocation + verify(mockBucketer, times(1)).bucketForCmab(any(Experiment.class), anyString(), any(ProjectConfig.class)); + } + private Experiment createMockCmabExperiment() { List variations = Arrays.asList( new Variation("111151", "variation_1"), @@ -1568,7 +1696,7 @@ private Experiment createMockCmabExperiment() { List trafficAllocations = Arrays.asList( new TrafficAllocation("111151", 5000), - new TrafficAllocation("111152", 10000) + new TrafficAllocation("111152", 4000) ); // Mock CMAB configuration From a4c3f1c631b6a194714bbe53e571d549179f5181 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 24 Sep 2025 20:24:29 +0600 Subject: [PATCH 14/49] update: implement decision-making methods to skip CMAB logic in Optimizely and DecisionService --- .../java/com/optimizely/ab/Optimizely.java | 145 ++++++++++++++++++ .../ab/bucketing/DecisionService.java | 44 ++++-- .../ab/bucketing/DecisionServiceTest.java | 22 ++- 3 files changed, 194 insertions(+), 17 deletions(-) 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 80a5021e0..35aff8a81 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -1498,6 +1498,151 @@ Map decideAll(@Nonnull OptimizelyUserContext user, return decideForKeys(user, allFlagKeys, options); } + /** + * Returns a decision result ({@link OptimizelyDecision}) for a given flag key and a user context, + * skipping CMAB logic and using only traditional A/B testing. + * + * @param user An OptimizelyUserContext associated with this OptimizelyClient. + * @param key A flag key for which a decision will be made. + * @param options A list of options for decision-making. + * @return A decision result using traditional A/B testing logic only. + */ + OptimizelyDecision decideWithoutCmab(@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 decideForKeysWithoutCmab(user, Arrays.asList(key), allOptions, true).get(key); + } + + /** + * Returns decision results for multiple flag keys, skipping CMAB logic and using only traditional A/B testing. + * + * @param user An OptimizelyUserContext associated with this OptimizelyClient. + * @param keys A list of flag keys for which decisions will be made. + * @param options A list of options for decision-making. + * @return All decision results mapped by flag keys, using traditional A/B testing logic only. + */ + Map decideForKeysWithoutCmab(@Nonnull OptimizelyUserContext user, + @Nonnull List keys, + @Nonnull List options) { + return decideForKeysWithoutCmab(user, keys, options, false); + } + + /** + * Returns decision results for all active flag keys, skipping CMAB logic and using only traditional A/B testing. + * + * @param user An OptimizelyUserContext associated with this OptimizelyClient. + * @param options A list of options for decision-making. + * @return All decision results mapped by flag keys, using traditional A/B testing logic only. + */ + Map decideAllWithoutCmab(@Nonnull OptimizelyUserContext user, + @Nonnull List options) { + Map decisionMap = new HashMap<>(); + + ProjectConfig projectConfig = getProjectConfig(); + if (projectConfig == null) { + logger.error("Optimizely instance is not valid, failing decideAllWithoutCmab 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 decideForKeysWithoutCmab(user, allFlagKeys, options); + } + + private Map decideForKeysWithoutCmab(@Nonnull OptimizelyUserContext user, + @Nonnull List keys, + @Nonnull List options, + boolean ignoreDefaultOptions) { + Map decisionMap = new HashMap<>(); + + ProjectConfig projectConfig = getProjectConfig(); + if (projectConfig == null) { + logger.error("Optimizely instance is not valid, failing decideForKeysWithoutCmab call."); + return decisionMap; + } + + if (keys.isEmpty()) return decisionMap; + + List allOptions = ignoreDefaultOptions ? options : getAllOptions(options); + + Map flagDecisions = new HashMap<>(); + Map decisionReasonsMap = new HashMap<>(); + + List flagsWithoutForcedDecision = new ArrayList<>(); + + List validKeys = new ArrayList<>(); + + for (String key : keys) { + FeatureFlag flag = projectConfig.getFeatureKeyMapping().get(key); + if (flag == null) { + decisionMap.put(key, OptimizelyDecision.newErrorDecision(key, user, DecisionMessage.FLAG_KEY_INVALID.reason(key))); + continue; + } + + validKeys.add(key); + + DecisionReasons decisionReasons = DefaultDecisionReasons.newInstance(allOptions); + decisionReasonsMap.put(key, decisionReasons); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(key, null); + DecisionResponse forcedDecisionVariation = decisionService.validatedForcedDecision(optimizelyDecisionContext, projectConfig, user); + decisionReasons.merge(forcedDecisionVariation.getReasons()); + if (forcedDecisionVariation.getResult() != null) { + flagDecisions.put(key, + new FeatureDecision(null, forcedDecisionVariation.getResult(), FeatureDecision.DecisionSource.FEATURE_TEST)); + } else { + flagsWithoutForcedDecision.add(flag); + } + } + + // Use DecisionService method that skips CMAB logic + List> decisionList = + decisionService.getVariationsForFeatureList(flagsWithoutForcedDecision, user, projectConfig, allOptions, false); + + for (int i = 0; i < flagsWithoutForcedDecision.size(); i++) { + DecisionResponse decision = decisionList.get(i); + boolean error = decision.isError(); + String flagKey = flagsWithoutForcedDecision.get(i).getKey(); + + if (error) { + OptimizelyDecision optimizelyDecision = OptimizelyDecision.newErrorDecision(flagKey, user, DecisionMessage.DECISION_ERROR.reason(flagKey)); + decisionMap.put(flagKey, optimizelyDecision); + if (validKeys.contains(flagKey)) { + validKeys.remove(flagKey); + } + } + + flagDecisions.put(flagKey, decision.getResult()); + decisionReasonsMap.get(flagKey).merge(decision.getReasons()); + } + + for (String key : validKeys) { + FeatureDecision flagDecision = flagDecisions.get(key); + DecisionReasons decisionReasons = decisionReasonsMap.get((key)); + + OptimizelyDecision optimizelyDecision = createOptimizelyDecision( + user, key, flagDecision, decisionReasons, allOptions, projectConfig + ); + + if (!allOptions.contains(OptimizelyDecideOption.ENABLED_FLAGS_ONLY) || optimizelyDecision.getEnabled()) { + decisionMap.put(key, optimizelyDecision); + } + } + + return decisionMap; + } + + private List getAllOptions(List options) { List copiedOptions = new ArrayList(defaultDecideOptions); if (options != null) { 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 9b5118221..ae777eda5 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 @@ -113,7 +113,8 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment, @Nonnull ProjectConfig projectConfig, @Nonnull List options, @Nullable UserProfileTracker userProfileTracker, - @Nullable DecisionReasons reasons) { + @Nullable DecisionReasons reasons, + @Nonnull boolean useCmab) { if (reasons == null) { reasons = DefaultDecisionReasons.newInstance(); } @@ -155,7 +156,7 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment, if (decisionMeetAudience.getResult()) { String bucketingId = getBucketingId(user.getUserId(), user.getAttributes()); String cmabUUID = null; - if (isCmabExperiment(experiment)) { + if (useCmab && isCmabExperiment(experiment)) { DecisionResponse cmabDecision = getDecisionForCmabExperiment(projectConfig, experiment, user, bucketingId, options); reasons.merge(cmabDecision.getReasons()); @@ -205,7 +206,8 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment, public DecisionResponse getVariation(@Nonnull Experiment experiment, @Nonnull OptimizelyUserContext user, @Nonnull ProjectConfig projectConfig, - @Nonnull List options) { + @Nonnull List options, + @Nonnull boolean useCmab) { DecisionReasons reasons = DefaultDecisionReasons.newInstance(); // fetch the user profile map from the user profile service @@ -217,7 +219,7 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment, userProfileTracker.loadUserProfile(reasons, errorHandler); } - DecisionResponse response = getVariation(experiment, user, projectConfig, options, userProfileTracker, reasons); + DecisionResponse response = getVariation(experiment, user, projectConfig, options, userProfileTracker, reasons, useCmab); if(userProfileService != null && !ignoreUPS) { userProfileTracker.saveUserProfile(errorHandler); @@ -229,7 +231,7 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment, public DecisionResponse getVariation(@Nonnull Experiment experiment, @Nonnull OptimizelyUserContext user, @Nonnull ProjectConfig projectConfig) { - return getVariation(experiment, user, projectConfig, Collections.emptyList()); + return getVariation(experiment, user, projectConfig, Collections.emptyList(), true); } /** @@ -256,6 +258,7 @@ public DecisionResponse getVariationForFeature(@Nonnull Feature * @param user The current OptimizelyuserContext * @param projectConfig The current projectConfig * @param options An array of decision options + * @param useCmab Boolean field that determines whether to use cmab service * @return A {@link DecisionResponse} including a {@link FeatureDecision} and the decision reasons */ @Nonnull @@ -263,6 +266,25 @@ public List> getVariationsForFeatureList(@Non @Nonnull OptimizelyUserContext user, @Nonnull ProjectConfig projectConfig, @Nonnull List options) { + return getVariationsForFeatureList(featureFlags, user, projectConfig, options, true); + } + + /** + * Get the variations the user is bucketed into for the list of feature flags + * + * @param featureFlags The feature flag list the user wants to access. + * @param user The current OptimizelyuserContext + * @param projectConfig The current projectConfig + * @param options An array of decision options + * @param useCmab Boolean field that determines whether to use cmab service + * @return A {@link DecisionResponse} including a {@link FeatureDecision} and the decision reasons + */ + @Nonnull + public List> getVariationsForFeatureList(@Nonnull List featureFlags, + @Nonnull OptimizelyUserContext user, + @Nonnull ProjectConfig projectConfig, + @Nonnull List options, + @Nonnull boolean useCmab) { DecisionReasons upsReasons = DefaultDecisionReasons.newInstance(); boolean ignoreUPS = options.contains(OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE); @@ -291,7 +313,7 @@ public List> getVariationsForFeatureList(@Non } } - DecisionResponse decisionVariationResponse = getVariationFromExperiment(projectConfig, featureFlag, user, options, userProfileTracker); + DecisionResponse decisionVariationResponse = getVariationFromExperiment(projectConfig, featureFlag, user, options, userProfileTracker, useCmab); reasons.merge(decisionVariationResponse.getReasons()); FeatureDecision decision = decisionVariationResponse.getResult(); @@ -346,14 +368,15 @@ DecisionResponse getVariationFromExperiment(@Nonnull ProjectCon @Nonnull FeatureFlag featureFlag, @Nonnull OptimizelyUserContext user, @Nonnull List options, - @Nullable UserProfileTracker userProfileTracker) { + @Nullable UserProfileTracker userProfileTracker, + @Nonnull boolean useCmab) { DecisionReasons reasons = DefaultDecisionReasons.newInstance(); if (!featureFlag.getExperimentIds().isEmpty()) { for (String experimentId : featureFlag.getExperimentIds()) { Experiment experiment = projectConfig.getExperimentIdMapping().get(experimentId); DecisionResponse decisionVariation = - getVariationFromExperimentRule(projectConfig, featureFlag.getKey(), experiment, user, options, userProfileTracker); + getVariationFromExperimentRule(projectConfig, featureFlag.getKey(), experiment, user, options, userProfileTracker, useCmab); reasons.merge(decisionVariation.getReasons()); Variation variation = decisionVariation.getResult(); String cmabUUID = decisionVariation.getCmabUUID(); @@ -776,7 +799,8 @@ private DecisionResponse getVariationFromExperimentRule(@Nonnull Proj @Nonnull Experiment rule, @Nonnull OptimizelyUserContext user, @Nonnull List options, - @Nullable UserProfileTracker userProfileTracker) { + @Nullable UserProfileTracker userProfileTracker, + @Nonnull boolean useCmab) { DecisionReasons reasons = DefaultDecisionReasons.newInstance(); String ruleKey = rule != null ? rule.getKey() : null; @@ -791,7 +815,7 @@ private DecisionResponse getVariationFromExperimentRule(@Nonnull Proj return new DecisionResponse(variation, reasons); } //regular decision - DecisionResponse decisionResponse = getVariation(rule, user, projectConfig, options, userProfileTracker, null); + DecisionResponse decisionResponse = getVariation(rule, user, projectConfig, options, userProfileTracker, null, useCmab); reasons.merge(decisionResponse.getReasons()); variation = decisionResponse.getResult(); 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 b7a95fb73..e07296384 100644 --- a/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java +++ b/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java @@ -34,6 +34,7 @@ import org.junit.Rule; import org.junit.Test; import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyBoolean; import static org.mockito.Matchers.anyMapOf; import static org.mockito.Matchers.anyObject; import static org.mockito.Matchers.anyString; @@ -359,7 +360,8 @@ public void getVariationForFeatureReturnsNullWhenItGetsNoVariationsForExperiment any(ProjectConfig.class), anyObject(), anyObject(), - any(DecisionReasons.class) + any(DecisionReasons.class), + anyBoolean() ); // do not bucket to any rollouts doReturn(DecisionResponse.responseNoReasons(new FeatureDecision(null, null, null))).when(decisionService).getVariationForFeatureInRollout( @@ -398,14 +400,16 @@ public void getVariationForFeatureReturnsVariationReturnedFromGetVariation() { eq(ValidProjectConfigV4.EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1), any(OptimizelyUserContext.class), any(ProjectConfig.class), - anyObject() + anyObject(), + anyBoolean() ); doReturn(DecisionResponse.responseNoReasons(ValidProjectConfigV4.VARIATION_MUTEX_GROUP_EXP_2_VAR_1)).when(decisionService).getVariation( eq(ValidProjectConfigV4.EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2), any(OptimizelyUserContext.class), any(ProjectConfig.class), - anyObject() + anyObject(), + anyBoolean() ); FeatureDecision featureDecision = decisionService.getVariationForFeature( @@ -445,7 +449,8 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() any(ProjectConfig.class), anyObject(), anyObject(), - any(DecisionReasons.class) + any(DecisionReasons.class), + anyBoolean() ); // return variation for rollout @@ -479,7 +484,8 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() any(ProjectConfig.class), anyObject(), anyObject(), - any(DecisionReasons.class) + any(DecisionReasons.class), + anyBoolean() ); } @@ -506,7 +512,8 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails any(ProjectConfig.class), anyObject(), anyObject(), - any(DecisionReasons.class) + any(DecisionReasons.class), + anyBoolean() ); // return variation for rollout @@ -540,7 +547,8 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails any(ProjectConfig.class), anyObject(), anyObject(), - any(DecisionReasons.class) + any(DecisionReasons.class), + anyBoolean() ); logbackVerifier.expectMessage( From e4fe7880d8598853a4a1508e5689e298f04382d2 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 24 Sep 2025 20:52:48 +0600 Subject: [PATCH 15/49] update: add methods to OptimizelyUserContext for decision-making without CMAB logic --- .../optimizely/ab/OptimizelyUserContext.java | 90 +++++++++++++++++-- 1 file changed, 81 insertions(+), 9 deletions(-) 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..9649bb611 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,23 @@ */ package com.optimizely.ab; -import com.optimizely.ab.config.ProjectConfig; -import com.optimizely.ab.odp.ODPManager; -import com.optimizely.ab.odp.ODPSegmentCallback; -import com.optimizely.ab.odp.ODPSegmentOption; -import com.optimizely.ab.optimizelydecision.*; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import javax.annotation.Nonnull; import javax.annotation.Nullable; -import java.util.*; -import java.util.concurrent.ConcurrentHashMap; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.optimizely.ab.odp.ODPSegmentCallback; +import com.optimizely.ab.odp.ODPSegmentOption; +import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; +import com.optimizely.ab.optimizelydecision.OptimizelyDecision; public class OptimizelyUserContext { // OptimizelyForcedDecisionsKey mapped to variationKeys @@ -197,6 +202,73 @@ public Map decideAll() { return decideAll(Collections.emptyList()); } + /** + * Returns a decision result ({@link OptimizelyDecision}) for a given flag key and a user context, + * which contains all data required to deliver the flag. This method skips CMAB logic. + * @param key A flag key for which a decision will be made. + * @param options A list of options for decision-making. + * @return A decision result. + */ + public OptimizelyDecision decideWithoutCmab(@Nonnull String key, + @Nonnull List options) { + return optimizely.decideWithoutCmab(copy(), key, options); + } + + /** + * Returns a decision result ({@link OptimizelyDecision}) for a given flag key and a user context, + * which contains all data required to deliver the flag. This method skips CMAB logic. + * + * @param key A flag key for which a decision will be made. + * @return A decision result. + */ + public OptimizelyDecision decideWithoutCmab(@Nonnull String key) { + return decideWithoutCmab(key, Collections.emptyList()); + } + + /** + * Returns a key-map of decision results ({@link OptimizelyDecision}) for multiple flag keys and a user context. + * This method skips CMAB logic. + * @param keys A list of flag keys for which decisions will be made. + * @param options A list of options for decision-making. + * @return All decision results mapped by flag keys. + */ + public Map decideForKeysWithoutCmab(@Nonnull List keys, + @Nonnull List options) { + return optimizely.decideForKeysWithoutCmab(copy(), keys, options); + } + + /** + * Returns a key-map of decision results for multiple flag keys and a user context. + * This method skips CMAB logic. + * + * @param keys A list of flag keys for which decisions will be made. + * @return All decision results mapped by flag keys. + */ + public Map decideForKeysWithoutCmab(@Nonnull List keys) { + return decideForKeysWithoutCmab(keys, Collections.emptyList()); + } + + /** + * Returns a key-map of decision results ({@link OptimizelyDecision}) for all active flag keys. + * This method skips CMAB logic. + * + * @param options A list of options for decision-making. + * @return All decision results mapped by flag keys. + */ + public Map decideAllWithoutCmab(@Nonnull List options) { + return optimizely.decideAllWithoutCmab(copy(), options); + } + + /** + * Returns a key-map of decision results ({@link OptimizelyDecision}) for all active flag keys. + * This method skips CMAB logic. + * + * @return A dictionary of all decision results, mapped by flag keys. + */ + public Map decideAllWithoutCmab() { + return decideAllWithoutCmab(Collections.emptyList()); + } + /** * Track an event. * From e75693d9ca8fab39d8c7b7974406e3c03eca5c9d Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 24 Sep 2025 21:19:09 +0600 Subject: [PATCH 16/49] update: add asynchronous decision-making methods in OptimizelyUserContext and related fetcher classes --- .../optimizely/ab/OptimizelyUserContext.java | 65 ++++++++++++ .../AsyncDecisionFetcher.java | 87 +++++++++++++++ .../AsyncDecisionsFetcher.java | 100 ++++++++++++++++++ .../OptimizelyDecisionCallback.java | 29 +++++ .../OptimizelyDecisionsCallback.java | 32 ++++++ 5 files changed, 313 insertions(+) create mode 100644 core-api/src/main/java/com/optimizely/ab/optimizelydecision/AsyncDecisionFetcher.java create mode 100644 core-api/src/main/java/com/optimizely/ab/optimizelydecision/AsyncDecisionsFetcher.java create mode 100644 core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecisionCallback.java create mode 100644 core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecisionsCallback.java 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 9649bb611..1d09079fe 100644 --- a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java +++ b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java @@ -31,8 +31,12 @@ import com.optimizely.ab.odp.ODPSegmentCallback; import com.optimizely.ab.odp.ODPSegmentOption; +import com.optimizely.ab.optimizelydecision.AsyncDecisionFetcher; +import com.optimizely.ab.optimizelydecision.AsyncDecisionsFetcher; import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; import com.optimizely.ab.optimizelydecision.OptimizelyDecision; +import com.optimizely.ab.optimizelydecision.OptimizelyDecisionCallback; +import com.optimizely.ab.optimizelydecision.OptimizelyDecisionsCallback; public class OptimizelyUserContext { // OptimizelyForcedDecisionsKey mapped to variationKeys @@ -269,6 +273,67 @@ public Map decideAllWithoutCmab() { return decideAllWithoutCmab(Collections.emptyList()); } + /** + * Returns a decision result asynchronously for a given flag key and a user context. + * + * @param key A flag key for which a decision will be made. + * @param callback A callback to invoke when the decision is available. + * @param options A list of options for decision-making. + */ + public void decideAsync(@Nonnull String key, + @Nonnull OptimizelyDecisionCallback callback, + @Nonnull List options) { + AsyncDecisionFetcher fetcher = new AsyncDecisionFetcher(this, key, options, callback); + fetcher.start(); + } + + /** + * Returns a decision result asynchronously for a given flag key and a user context. + * + * @param key A flag key for which a decision will be made. + * @param callback A callback to invoke when the decision is available. + */ + public void decideAsync(@Nonnull String key, @Nonnull OptimizelyDecisionCallback callback) { + decideAsync(key, callback, Collections.emptyList()); + } + + /** + * Returns decision results asynchronously for multiple flag keys. + * + * @param keys A list of flag keys for which decisions will be made. + * @param callback A callback to invoke when decisions are available. + * @param options A list of options for decision-making. + */ + public void decideForKeysAsync(@Nonnull List keys, + @Nonnull OptimizelyDecisionsCallback callback, + @Nonnull List options) { + AsyncDecisionsFetcher fetcher = new AsyncDecisionsFetcher(this, keys, options, callback); + fetcher.start(); + } + + /** + * Returns decision results asynchronously for multiple flag keys. + */ + public void decideForKeysAsync(@Nonnull List keys, @Nonnull OptimizelyDecisionsCallback callback) { + decideForKeysAsync(keys, callback, Collections.emptyList()); + } + + /** + * Returns decision results asynchronously for all active flag keys. + */ + public void decideAllAsync(@Nonnull OptimizelyDecisionsCallback callback, + @Nonnull List options) { + AsyncDecisionsFetcher fetcher = new AsyncDecisionsFetcher(this, null, options, callback, true); + fetcher.start(); + } + + /** + * Returns decision results asynchronously for all active flag keys. + */ + public void decideAllAsync(@Nonnull OptimizelyDecisionsCallback callback) { + decideAllAsync(callback, Collections.emptyList()); + } + /** * Track an event. * diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/AsyncDecisionFetcher.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/AsyncDecisionFetcher.java new file mode 100644 index 000000000..703b20bbd --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/AsyncDecisionFetcher.java @@ -0,0 +1,87 @@ +/** + * Copyright 2025, Optimizely and contributors + *

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

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

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

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

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

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelydecision; + +import com.optimizely.ab.OptimizelyUserContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * AsyncDecisionsFetcher handles asynchronous decision fetching for multiple flag keys. + * This class follows the same pattern as ODP's async segment fetching. + */ +public class AsyncDecisionsFetcher extends Thread { + private static final Logger logger = LoggerFactory.getLogger(AsyncDecisionsFetcher.class); + + private final List keys; + private final List options; + private final OptimizelyDecisionsCallback callback; + private final OptimizelyUserContext userContext; + private final boolean decideAll; + + /** + * Constructor for deciding on specific keys. + * + * @param userContext The user context to make decisions for + * @param keys List of flag keys to decide on + * @param options Decision options + * @param callback Callback to invoke when decisions are ready + */ + public AsyncDecisionsFetcher(@Nonnull OptimizelyUserContext userContext, + @Nonnull List keys, + @Nonnull List options, + @Nonnull OptimizelyDecisionsCallback callback) { + this(userContext, keys, options, callback, false); + } + + /** + * Constructor for deciding on all flags or specific keys. + * + * @param userContext The user context to make decisions for + * @param keys List of flag keys to decide on (null for decideAll) + * @param options Decision options + * @param callback Callback to invoke when decisions are ready + * @param decideAll Whether to decide for all active flags + */ + public AsyncDecisionsFetcher(@Nonnull OptimizelyUserContext userContext, + @Nullable List keys, + @Nonnull List options, + @Nonnull OptimizelyDecisionsCallback callback, + boolean decideAll) { + this.userContext = userContext; + this.keys = keys; + this.options = options; + this.callback = callback; + this.decideAll = decideAll; + + // Set thread name for debugging + String threadName = decideAll ? "AsyncDecisionsFetcher-all" : "AsyncDecisionsFetcher-keys"; + setName(threadName); + + // Set as daemon thread so it doesn't prevent JVM shutdown + setDaemon(true); + } + + @Override + public void run() { + try { + Map decisions; + if (decideAll) { + decisions = userContext.decideAll(options); + } else { + decisions = userContext.decideForKeys(keys, options); + } + callback.onCompleted(decisions); + } catch (Exception e) { + logger.error("Error in async decisions fetching", e); + // Return empty map on error - this follows the pattern of sync methods + callback.onCompleted(Collections.emptyMap()); + } + } +} \ No newline at end of file diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/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 From af210d844699d82f34adc8303f011a8f64f19c4d Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 24 Sep 2025 21:41:14 +0600 Subject: [PATCH 17/49] update: add decision-making methods without CMAB logic in OptimizelyUserContextTest --- .../ab/OptimizelyUserContextTest.java | 457 ++++++++++++++++++ 1 file changed, 457 insertions(+) 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..1adb735d2 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java @@ -57,6 +57,9 @@ import static junit.framework.TestCase.assertEquals; import static junit.framework.TestCase.assertTrue; import static org.junit.Assert.*; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyObject; +import static org.mockito.Matchers.eq; import static org.mockito.Mockito.*; public class OptimizelyUserContextTest { @@ -2267,4 +2270,458 @@ public void decide_all_with_holdout() throws Exception { assertEquals("Expected exactly the included flags to be in holdout", includedInHoldout.size(), holdoutCount); logbackVerifier.expectMessage(Level.INFO, expectedReason); } + + @Test + public void decideWithoutCmab_featureTest() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + String flagKey = "feature_2"; + String experimentKey = "exp_no_audience"; + String variationKey = "variation_with_traffic"; + String experimentId = "10420810910"; + String variationId = "10418551353"; + OptimizelyJSON variablesExpected = optimizely.getAllFeatureVariables(flagKey, userId); + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decideWithoutCmab(flagKey); + + assertEquals(decision.getVariationKey(), variationKey); + assertTrue(decision.getEnabled()); + assertEquals(decision.getVariables().toMap(), variablesExpected.toMap()); + assertEquals(decision.getRuleKey(), experimentKey); + assertEquals(decision.getFlagKey(), flagKey); + assertEquals(decision.getUserContext(), user); + assertTrue(decision.getReasons().isEmpty()); + + DecisionMetadata metadata = new DecisionMetadata.Builder() + .setFlagKey(flagKey) + .setRuleKey(experimentKey) + .setRuleType(FeatureDecision.DecisionSource.FEATURE_TEST.toString()) + .setVariationKey(variationKey) + .setEnabled(true) + .build(); + eventHandler.expectImpression(experimentId, variationId, userId, Collections.emptyMap(), metadata); + } + + @Test + public void decideForKeysWithoutCmab_multipleFlags() { + String flagKey1 = "feature_1"; + String flagKey2 = "feature_2"; + + List flagKeys = Arrays.asList(flagKey1, flagKey2); + OptimizelyJSON variablesExpected1 = optimizely.getAllFeatureVariables(flagKey1, userId); + OptimizelyJSON variablesExpected2 = optimizely.getAllFeatureVariables(flagKey2, userId); + + OptimizelyUserContext user = optimizely.createUserContext(userId, Collections.singletonMap("gender", "f")); + Map decisions = user.decideForKeysWithoutCmab(flagKeys); + + assertEquals(decisions.size(), 2); + + assertEquals( + decisions.get(flagKey1), + new OptimizelyDecision("a", + true, + variablesExpected1, + "exp_with_audience", + flagKey1, + user, + Collections.emptyList())); + assertEquals( + decisions.get(flagKey2), + new OptimizelyDecision("variation_with_traffic", + true, + variablesExpected2, + "exp_no_audience", + flagKey2, + user, + Collections.emptyList())); + } + + @Test + public void decideForKeysWithoutCmab_withOptions() { + String flagKey1 = "feature_1"; + String flagKey2 = "feature_2"; + + List flagKeys = Arrays.asList(flagKey1, flagKey2); + List options = Arrays.asList(OptimizelyDecideOption.EXCLUDE_VARIABLES); + + OptimizelyUserContext user = optimizely.createUserContext(userId, Collections.singletonMap("gender", "f")); + Map decisions = user.decideForKeysWithoutCmab(flagKeys, options); + + assertEquals(decisions.size(), 2); + + // Both decisions should have empty variables due to EXCLUDE_VARIABLES option + OptimizelyDecision decision1 = decisions.get(flagKey1); + OptimizelyDecision decision2 = decisions.get(flagKey2); + + assertTrue(decision1.getVariables().toMap().isEmpty()); + assertTrue(decision2.getVariables().toMap().isEmpty()); + assertEquals(decision1.getVariationKey(), "a"); + assertEquals(decision2.getVariationKey(), "variation_with_traffic"); + } + + @Test + public void decideAllWithoutCmab_allFlags() { + EventProcessor mockEventProcessor = mock(EventProcessor.class); + + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(mockEventProcessor) + .build(); + + String flagKey1 = "feature_1"; + String flagKey2 = "feature_2"; + String flagKey3 = "feature_3"; + Map attributes = Collections.singletonMap("gender", "f"); + + OptimizelyJSON variablesExpected1 = optimizely.getAllFeatureVariables(flagKey1, userId); + OptimizelyJSON variablesExpected2 = optimizely.getAllFeatureVariables(flagKey2, userId); + OptimizelyJSON variablesExpected3 = new OptimizelyJSON(Collections.emptyMap()); + + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + Map decisions = user.decideAllWithoutCmab(); + assertEquals(decisions.size(), 3); + + assertEquals( + decisions.get(flagKey1), + new OptimizelyDecision( + "a", + true, + variablesExpected1, + "exp_with_audience", + flagKey1, + user, + Collections.emptyList())); + assertEquals( + decisions.get(flagKey2), + new OptimizelyDecision( + "variation_with_traffic", + true, + variablesExpected2, + "exp_no_audience", + flagKey2, + user, + Collections.emptyList())); + assertEquals( + decisions.get(flagKey3), + new OptimizelyDecision( + null, + false, + variablesExpected3, + null, + flagKey3, + user, + Collections.emptyList())); + + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(ImpressionEvent.class); + verify(mockEventProcessor, times(3)).process(argumentCaptor.capture()); + + List sentEvents = argumentCaptor.getAllValues(); + assertEquals(sentEvents.size(), 3); + + assertEquals(sentEvents.get(0).getExperimentKey(), "exp_with_audience"); + assertEquals(sentEvents.get(0).getVariationKey(), "a"); + assertEquals(sentEvents.get(0).getUserContext().getUserId(), userId); + + assertEquals(sentEvents.get(1).getExperimentKey(), "exp_no_audience"); + assertEquals(sentEvents.get(1).getVariationKey(), "variation_with_traffic"); + assertEquals(sentEvents.get(1).getUserContext().getUserId(), userId); + + assertEquals(sentEvents.get(2).getExperimentKey(), ""); + assertEquals(sentEvents.get(2).getUserContext().getUserId(), userId); + } + + @Test + public void decideAllWithoutCmab_withOptions() { + String flagKey1 = "feature_1"; + OptimizelyJSON variablesExpected1 = optimizely.getAllFeatureVariables(flagKey1, userId); + + OptimizelyUserContext user = optimizely.createUserContext(userId, Collections.singletonMap("gender", "f")); + Map decisions = user.decideAllWithoutCmab(Arrays.asList(OptimizelyDecideOption.ENABLED_FLAGS_ONLY)); + + assertEquals(decisions.size(), 2); // Only enabled flags + + assertEquals( + decisions.get(flagKey1), + new OptimizelyDecision( + "a", + true, + variablesExpected1, + "exp_with_audience", + flagKey1, + user, + Collections.emptyList())); + } + + @Test + public void decideAllWithoutCmab_ups_batching() throws Exception { + UserProfileService ups = mock(UserProfileService.class); + + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withUserProfileService(ups) + .build(); + + Map attributes = Collections.singletonMap("gender", "f"); + + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + Map decisions = user.decideAllWithoutCmab(); + + assertEquals(decisions.size(), 3); + + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(Map.class); + + verify(ups, times(1)).lookup(userId); + verify(ups, times(1)).save(argumentCaptor.capture()); + + Map savedUps = argumentCaptor.getValue(); + UserProfile savedProfile = UserProfileUtils.convertMapToUserProfile(savedUps); + + assertEquals(savedProfile.userId, userId); + } + + @Test + public void decideWithoutCmab_sdkNotReady() { + String flagKey = "feature_1"; + + Optimizely optimizely = new Optimizely.Builder().build(); + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decideWithoutCmab(flagKey); + + assertNull(decision.getVariationKey()); + assertFalse(decision.getEnabled()); + assertTrue(decision.getVariables().isEmpty()); + assertEquals(decision.getFlagKey(), flagKey); + assertEquals(decision.getUserContext(), user); + + assertEquals(decision.getReasons().size(), 1); + assertEquals(decision.getReasons().get(0), DecisionMessage.SDK_NOT_READY.reason()); + } + + @Test + public void decideForKeysWithoutCmab_sdkNotReady() { + List flagKeys = Arrays.asList("feature_1"); + + Optimizely optimizely = new Optimizely.Builder().build(); + OptimizelyUserContext user = optimizely.createUserContext(userId); + Map decisions = user.decideForKeysWithoutCmab(flagKeys); + + assertEquals(decisions.size(), 0); + } + @Test + public void decideWithoutCmab_bypassUPS() throws Exception { + String flagKey = "feature_2"; // embedding experiment: "exp_no_audience" + String experimentId = "10420810910"; // "exp_no_audience" + String variationId1 = "10418551353"; + String variationId2 = "10418510624"; + String variationKey1 = "variation_with_traffic"; + String variationKey2 = "variation_no_traffic"; + + UserProfileService ups = mock(UserProfileService.class); + when(ups.lookup(userId)).thenReturn(createUserProfileMap(experimentId, variationId2)); + + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withUserProfileService(ups) + .build(); + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decideWithoutCmab(flagKey); + // should return variationId2 set by UPS + assertEquals(decision.getVariationKey(), variationKey2); + + decision = user.decideWithoutCmab(flagKey, Arrays.asList(OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE)); + // should ignore variationId2 set by UPS and return variationId1 + assertEquals(decision.getVariationKey(), variationKey1); + // also should not save either + verify(ups, never()).save(anyObject()); + } + + @Test + public void decideAsync_featureTest() throws InterruptedException { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + String flagKey = "feature_2"; + String experimentKey = "exp_no_audience"; + String variationKey = "variation_with_traffic"; + String experimentId = "10420810910"; + String variationId = "10418551353"; + OptimizelyJSON variablesExpected = optimizely.getAllFeatureVariables(flagKey, userId); + + OptimizelyUserContext user = optimizely.createUserContext(userId); + + CountDownLatch latch = new CountDownLatch(1); + final OptimizelyDecision[] result = new OptimizelyDecision[1]; + + user.decideAsync(flagKey, decision -> { + result[0] = decision; + latch.countDown(); + }); + + assertTrue(latch.await(1, java.util.concurrent.TimeUnit.SECONDS)); + OptimizelyDecision decision = result[0]; + + assertEquals(decision.getVariationKey(), variationKey); + assertTrue(decision.getEnabled()); + assertEquals(decision.getVariables().toMap(), variablesExpected.toMap()); + assertEquals(decision.getRuleKey(), experimentKey); + assertEquals(decision.getFlagKey(), flagKey); + assertEquals(decision.getUserContext(), user); + assertTrue(decision.getReasons().isEmpty()); + + DecisionMetadata metadata = new DecisionMetadata.Builder() + .setFlagKey(flagKey) + .setRuleKey(experimentKey) + .setRuleType(FeatureDecision.DecisionSource.FEATURE_TEST.toString()) + .setVariationKey(variationKey) + .setEnabled(true) + .build(); + eventHandler.expectImpression(experimentId, variationId, userId, Collections.emptyMap(), metadata); + } + + @Test + public void decideAsync_sdkNotReady() throws InterruptedException { + String flagKey = "feature_1"; + + Optimizely optimizely = new Optimizely.Builder().build(); + OptimizelyUserContext user = optimizely.createUserContext(userId); + + CountDownLatch latch = new CountDownLatch(1); + final OptimizelyDecision[] result = new OptimizelyDecision[1]; + + user.decideAsync(flagKey, decision -> { + result[0] = decision; + latch.countDown(); + }); + + assertTrue(latch.await(1, java.util.concurrent.TimeUnit.SECONDS)); + OptimizelyDecision decision = result[0]; + + assertNull(decision.getVariationKey()); + assertFalse(decision.getEnabled()); + assertTrue(decision.getVariables().isEmpty()); + assertEquals(decision.getFlagKey(), flagKey); + assertEquals(decision.getUserContext(), user); + } + + @Test + public void decideForKeysAsync_multipleFlags() throws InterruptedException { + String flagKey1 = "feature_1"; + String flagKey2 = "feature_2"; + + List flagKeys = Arrays.asList(flagKey1, flagKey2); + OptimizelyJSON variablesExpected1 = optimizely.getAllFeatureVariables(flagKey1, userId); + OptimizelyJSON variablesExpected2 = optimizely.getAllFeatureVariables(flagKey2, userId); + + OptimizelyUserContext user = optimizely.createUserContext(userId, Collections.singletonMap("gender", "f")); + + CountDownLatch latch = new CountDownLatch(1); + final Map[] result = new Map[1]; + + user.decideForKeysAsync(flagKeys, decisions -> { + result[0] = decisions; + latch.countDown(); + }); + + assertTrue(latch.await(1, java.util.concurrent.TimeUnit.SECONDS)); + Map decisions = result[0]; + + assertEquals(decisions.size(), 2); + + assertEquals( + decisions.get(flagKey1), + new OptimizelyDecision("a", + true, + variablesExpected1, + "exp_with_audience", + flagKey1, + user, + Collections.emptyList())); + assertEquals( + decisions.get(flagKey2), + new OptimizelyDecision("variation_with_traffic", + true, + variablesExpected2, + "exp_no_audience", + flagKey2, + user, + Collections.emptyList())); + } + + @Test + public void decideForKeysAsync_withOptions() throws InterruptedException { + String flagKey1 = "feature_1"; + String flagKey2 = "feature_2"; + + List flagKeys = Arrays.asList(flagKey1, flagKey2); + List options = Arrays.asList(OptimizelyDecideOption.EXCLUDE_VARIABLES); + + OptimizelyUserContext user = optimizely.createUserContext(userId, Collections.singletonMap("gender", "f")); + + CountDownLatch latch = new CountDownLatch(1); + final Map[] result = new Map[1]; + + user.decideForKeysAsync(flagKeys, decisions -> { + result[0] = decisions; + latch.countDown(); + }, options); + + assertTrue(latch.await(1, java.util.concurrent.TimeUnit.SECONDS)); + Map decisions = result[0]; + + assertEquals(decisions.size(), 2); + + // Both decisions should have empty variables due to EXCLUDE_VARIABLES option + OptimizelyDecision decision1 = decisions.get(flagKey1); + OptimizelyDecision decision2 = decisions.get(flagKey2); + + assertTrue(decision1.getVariables().toMap().isEmpty()); + assertTrue(decision2.getVariables().toMap().isEmpty()); + assertEquals(decision1.getVariationKey(), "a"); + assertEquals(decision2.getVariationKey(), "variation_with_traffic"); + } + + @Test + public void decideForKeysAsync_sdkNotReady() throws InterruptedException { + List flagKeys = Arrays.asList("feature_1"); + + Optimizely optimizely = new Optimizely.Builder().build(); + OptimizelyUserContext user = optimizely.createUserContext(userId); + + CountDownLatch latch = new CountDownLatch(1); + final Map[] result = new Map[1]; + + user.decideForKeysAsync(flagKeys, decisions -> { + result[0] = decisions; + latch.countDown(); + }); + + assertTrue(latch.await(1, java.util.concurrent.TimeUnit.SECONDS)); + Map decisions = result[0]; + + assertEquals(decisions.size(), 0); + } + + @Test + public void decideAllAsync_callback_exception() throws InterruptedException { + OptimizelyUserContext user = optimizely.createUserContext(userId, Collections.singletonMap("gender", "f")); + + CountDownLatch latch = new CountDownLatch(1); + final boolean[] callbackExecuted = new boolean[1]; + + user.decideAllAsync(decisions -> { + callbackExecuted[0] = true; + latch.countDown(); + throw new RuntimeException("Test exception in callback"); + }); + + assertTrue(latch.await(1, java.util.concurrent.TimeUnit.SECONDS)); + assertTrue(callbackExecuted[0]); + } } From 42053e41b8f5f855eacebb86d419720b32b4dcf1 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 24 Sep 2025 22:16:35 +0600 Subject: [PATCH 18/49] update: remove unused parameter 'useCmab' from DecisionService method documentation --- .../main/java/com/optimizely/ab/bucketing/DecisionService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 ae777eda5..06c2b5807 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 @@ -85,6 +85,7 @@ 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, @@ -200,6 +201,7 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment, * @param user The current OptimizelyUserContext * @param projectConfig The current projectConfig * @param options An array of decision options + * @param useCmab Boolean to check if cmab service is to be used. * @return A {@link DecisionResponse} including the {@link Variation} that user is bucketed into (or null) and the decision reasons */ @Nonnull @@ -258,7 +260,6 @@ public DecisionResponse getVariationForFeature(@Nonnull Feature * @param user The current OptimizelyuserContext * @param projectConfig The current projectConfig * @param options An array of decision options - * @param useCmab Boolean field that determines whether to use cmab service * @return A {@link DecisionResponse} including a {@link FeatureDecision} and the decision reasons */ @Nonnull From 9a12d721e248211c0dd027dec5c2359588bcc56f Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Fri, 26 Sep 2025 03:00:32 +0600 Subject: [PATCH 19/49] update: rename methods to use 'Sync' suffix for clarity in decision-making logic --- .../java/com/optimizely/ab/Optimizely.java | 18 ++++----- .../optimizely/ab/OptimizelyUserContext.java | 24 ++++++------ .../ab/bucketing/DecisionService.java | 2 +- .../ab/OptimizelyUserContextTest.java | 38 +++++++++---------- 4 files changed, 41 insertions(+), 41 deletions(-) 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 35aff8a81..bbdf4f877 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -1507,7 +1507,7 @@ Map decideAll(@Nonnull OptimizelyUserContext user, * @param options A list of options for decision-making. * @return A decision result using traditional A/B testing logic only. */ - OptimizelyDecision decideWithoutCmab(@Nonnull OptimizelyUserContext user, + OptimizelyDecision decideSync(@Nonnull OptimizelyUserContext user, @Nonnull String key, @Nonnull List options) { ProjectConfig projectConfig = getProjectConfig(); @@ -1518,7 +1518,7 @@ OptimizelyDecision decideWithoutCmab(@Nonnull OptimizelyUserContext user, List allOptions = getAllOptions(options); allOptions.remove(OptimizelyDecideOption.ENABLED_FLAGS_ONLY); - return decideForKeysWithoutCmab(user, Arrays.asList(key), allOptions, true).get(key); + return decideForKeysSync(user, Arrays.asList(key), allOptions, true).get(key); } /** @@ -1529,10 +1529,10 @@ OptimizelyDecision decideWithoutCmab(@Nonnull OptimizelyUserContext user, * @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 decideForKeysWithoutCmab(@Nonnull OptimizelyUserContext user, + Map decideForKeysSync(@Nonnull OptimizelyUserContext user, @Nonnull List keys, @Nonnull List options) { - return decideForKeysWithoutCmab(user, keys, options, false); + return decideForKeysSync(user, keys, options, false); } /** @@ -1542,13 +1542,13 @@ Map decideForKeysWithoutCmab(@Nonnull OptimizelyUser * @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 decideAllWithoutCmab(@Nonnull OptimizelyUserContext user, + 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 decideAllWithoutCmab call."); + logger.error("Optimizely instance is not valid, failing decideAllSync call."); return decisionMap; } @@ -1556,10 +1556,10 @@ Map decideAllWithoutCmab(@Nonnull OptimizelyUserCont List allFlagKeys = new ArrayList<>(); for (int i = 0; i < allFlags.size(); i++) allFlagKeys.add(allFlags.get(i).getKey()); - return decideForKeysWithoutCmab(user, allFlagKeys, options); + return decideForKeysSync(user, allFlagKeys, options); } - private Map decideForKeysWithoutCmab(@Nonnull OptimizelyUserContext user, + private Map decideForKeysSync(@Nonnull OptimizelyUserContext user, @Nonnull List keys, @Nonnull List options, boolean ignoreDefaultOptions) { @@ -1567,7 +1567,7 @@ private Map decideForKeysWithoutCmab(@Nonnull Optimi ProjectConfig projectConfig = getProjectConfig(); if (projectConfig == null) { - logger.error("Optimizely instance is not valid, failing decideForKeysWithoutCmab call."); + logger.error("Optimizely instance is not valid, failing decideForKeysSync call."); return decisionMap; } 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 1d09079fe..728d3f6ed 100644 --- a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java +++ b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java @@ -213,9 +213,9 @@ public Map decideAll() { * @param options A list of options for decision-making. * @return A decision result. */ - public OptimizelyDecision decideWithoutCmab(@Nonnull String key, + public OptimizelyDecision decideSync(@Nonnull String key, @Nonnull List options) { - return optimizely.decideWithoutCmab(copy(), key, options); + return optimizely.decideSync(copy(), key, options); } /** @@ -225,8 +225,8 @@ public OptimizelyDecision decideWithoutCmab(@Nonnull String key, * @param key A flag key for which a decision will be made. * @return A decision result. */ - public OptimizelyDecision decideWithoutCmab(@Nonnull String key) { - return decideWithoutCmab(key, Collections.emptyList()); + public OptimizelyDecision decideSync(@Nonnull String key) { + return decideSync(key, Collections.emptyList()); } /** @@ -236,9 +236,9 @@ public OptimizelyDecision decideWithoutCmab(@Nonnull String key) { * @param options A list of options for decision-making. * @return All decision results mapped by flag keys. */ - public Map decideForKeysWithoutCmab(@Nonnull List keys, + public Map decideForKeysSync(@Nonnull List keys, @Nonnull List options) { - return optimizely.decideForKeysWithoutCmab(copy(), keys, options); + return optimizely.decideForKeysSync(copy(), keys, options); } /** @@ -248,8 +248,8 @@ public Map decideForKeysWithoutCmab(@Nonnull List decideForKeysWithoutCmab(@Nonnull List keys) { - return decideForKeysWithoutCmab(keys, Collections.emptyList()); + public Map decideForKeysSync(@Nonnull List keys) { + return decideForKeysSync(keys, Collections.emptyList()); } /** @@ -259,8 +259,8 @@ public Map decideForKeysWithoutCmab(@Nonnull List decideAllWithoutCmab(@Nonnull List options) { - return optimizely.decideAllWithoutCmab(copy(), options); + public Map decideAllSync(@Nonnull List options) { + return optimizely.decideAllSync(copy(), options); } /** @@ -269,8 +269,8 @@ public Map decideAllWithoutCmab(@Nonnull List decideAllWithoutCmab() { - return decideAllWithoutCmab(Collections.emptyList()); + public Map decideAllSync() { + return decideAllSync(Collections.emptyList()); } /** 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 06c2b5807..b3b78a3fe 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 @@ -90,7 +90,7 @@ public class DecisionService { public DecisionService(@Nonnull Bucketer bucketer, @Nonnull ErrorHandler errorHandler, @Nullable UserProfileService userProfileService, - @Nullable CmabService cmabService) { + @Nonnull CmabService cmabService) { this.bucketer = bucketer; this.errorHandler = errorHandler; this.userProfileService = userProfileService; 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 1adb735d2..8070073c7 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java @@ -2272,7 +2272,7 @@ public void decide_all_with_holdout() throws Exception { } @Test - public void decideWithoutCmab_featureTest() { + public void decideSync_featureTest() { optimizely = new Optimizely.Builder() .withDatafile(datafile) .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) @@ -2286,7 +2286,7 @@ public void decideWithoutCmab_featureTest() { OptimizelyJSON variablesExpected = optimizely.getAllFeatureVariables(flagKey, userId); OptimizelyUserContext user = optimizely.createUserContext(userId); - OptimizelyDecision decision = user.decideWithoutCmab(flagKey); + OptimizelyDecision decision = user.decideSync(flagKey); assertEquals(decision.getVariationKey(), variationKey); assertTrue(decision.getEnabled()); @@ -2307,7 +2307,7 @@ public void decideWithoutCmab_featureTest() { } @Test - public void decideForKeysWithoutCmab_multipleFlags() { + public void decideForKeysSync_multipleFlags() { String flagKey1 = "feature_1"; String flagKey2 = "feature_2"; @@ -2316,7 +2316,7 @@ public void decideForKeysWithoutCmab_multipleFlags() { OptimizelyJSON variablesExpected2 = optimizely.getAllFeatureVariables(flagKey2, userId); OptimizelyUserContext user = optimizely.createUserContext(userId, Collections.singletonMap("gender", "f")); - Map decisions = user.decideForKeysWithoutCmab(flagKeys); + Map decisions = user.decideForKeysSync(flagKeys); assertEquals(decisions.size(), 2); @@ -2341,7 +2341,7 @@ public void decideForKeysWithoutCmab_multipleFlags() { } @Test - public void decideForKeysWithoutCmab_withOptions() { + public void decideForKeysSync_withOptions() { String flagKey1 = "feature_1"; String flagKey2 = "feature_2"; @@ -2349,7 +2349,7 @@ public void decideForKeysWithoutCmab_withOptions() { List options = Arrays.asList(OptimizelyDecideOption.EXCLUDE_VARIABLES); OptimizelyUserContext user = optimizely.createUserContext(userId, Collections.singletonMap("gender", "f")); - Map decisions = user.decideForKeysWithoutCmab(flagKeys, options); + Map decisions = user.decideForKeysSync(flagKeys, options); assertEquals(decisions.size(), 2); @@ -2364,7 +2364,7 @@ public void decideForKeysWithoutCmab_withOptions() { } @Test - public void decideAllWithoutCmab_allFlags() { + public void decideAllSync_allFlags() { EventProcessor mockEventProcessor = mock(EventProcessor.class); optimizely = new Optimizely.Builder() @@ -2382,7 +2382,7 @@ public void decideAllWithoutCmab_allFlags() { OptimizelyJSON variablesExpected3 = new OptimizelyJSON(Collections.emptyMap()); OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); - Map decisions = user.decideAllWithoutCmab(); + Map decisions = user.decideAllSync(); assertEquals(decisions.size(), 3); assertEquals( @@ -2435,12 +2435,12 @@ public void decideAllWithoutCmab_allFlags() { } @Test - public void decideAllWithoutCmab_withOptions() { + public void decideAllSync_withOptions() { String flagKey1 = "feature_1"; OptimizelyJSON variablesExpected1 = optimizely.getAllFeatureVariables(flagKey1, userId); OptimizelyUserContext user = optimizely.createUserContext(userId, Collections.singletonMap("gender", "f")); - Map decisions = user.decideAllWithoutCmab(Arrays.asList(OptimizelyDecideOption.ENABLED_FLAGS_ONLY)); + Map decisions = user.decideAllSync(Arrays.asList(OptimizelyDecideOption.ENABLED_FLAGS_ONLY)); assertEquals(decisions.size(), 2); // Only enabled flags @@ -2457,7 +2457,7 @@ public void decideAllWithoutCmab_withOptions() { } @Test - public void decideAllWithoutCmab_ups_batching() throws Exception { + public void decideAllSync_ups_batching() throws Exception { UserProfileService ups = mock(UserProfileService.class); optimizely = new Optimizely.Builder() @@ -2468,7 +2468,7 @@ public void decideAllWithoutCmab_ups_batching() throws Exception { Map attributes = Collections.singletonMap("gender", "f"); OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); - Map decisions = user.decideAllWithoutCmab(); + Map decisions = user.decideAllSync(); assertEquals(decisions.size(), 3); @@ -2484,12 +2484,12 @@ public void decideAllWithoutCmab_ups_batching() throws Exception { } @Test - public void decideWithoutCmab_sdkNotReady() { + public void decideSync_sdkNotReady() { String flagKey = "feature_1"; Optimizely optimizely = new Optimizely.Builder().build(); OptimizelyUserContext user = optimizely.createUserContext(userId); - OptimizelyDecision decision = user.decideWithoutCmab(flagKey); + OptimizelyDecision decision = user.decideSync(flagKey); assertNull(decision.getVariationKey()); assertFalse(decision.getEnabled()); @@ -2502,17 +2502,17 @@ public void decideWithoutCmab_sdkNotReady() { } @Test - public void decideForKeysWithoutCmab_sdkNotReady() { + public void decideForKeysSync_sdkNotReady() { List flagKeys = Arrays.asList("feature_1"); Optimizely optimizely = new Optimizely.Builder().build(); OptimizelyUserContext user = optimizely.createUserContext(userId); - Map decisions = user.decideForKeysWithoutCmab(flagKeys); + Map decisions = user.decideForKeysSync(flagKeys); assertEquals(decisions.size(), 0); } @Test - public void decideWithoutCmab_bypassUPS() throws Exception { + public void decideSync_bypassUPS() throws Exception { String flagKey = "feature_2"; // embedding experiment: "exp_no_audience" String experimentId = "10420810910"; // "exp_no_audience" String variationId1 = "10418551353"; @@ -2529,11 +2529,11 @@ public void decideWithoutCmab_bypassUPS() throws Exception { .build(); OptimizelyUserContext user = optimizely.createUserContext(userId); - OptimizelyDecision decision = user.decideWithoutCmab(flagKey); + OptimizelyDecision decision = user.decideSync(flagKey); // should return variationId2 set by UPS assertEquals(decision.getVariationKey(), variationKey2); - decision = user.decideWithoutCmab(flagKey, Arrays.asList(OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE)); + decision = user.decideSync(flagKey, Arrays.asList(OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE)); // should ignore variationId2 set by UPS and return variationId1 assertEquals(decision.getVariationKey(), variationKey1); // also should not save either From a4419a4304d36166f5d25c4288bcf670f47a568a Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Fri, 26 Sep 2025 20:25:08 +0600 Subject: [PATCH 20/49] update: add cmabUUID parameter to impression event methods and related classes --- .../java/com/optimizely/ab/Optimizely.java | 19 ++- .../ab/event/internal/UserEventFactory.java | 14 +- .../internal/payload/DecisionMetadata.java | 24 +++- .../ab/event/internal/EventFactoryTest.java | 5 +- .../event/internal/UserEventFactoryTest.java | 132 ++++++++++++++++-- 5 files changed, 165 insertions(+), 29 deletions(-) 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 bbdf4f877..82f521f1d 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -311,7 +311,7 @@ private void sendImpression(@Nonnull ProjectConfig projectConfig, @Nonnull Map filteredAttributes, @Nonnull Variation variation, @Nonnull String ruleType) { - sendImpression(projectConfig, experiment, userId, filteredAttributes, variation, "", ruleType, true); + sendImpression(projectConfig, experiment, userId, filteredAttributes, variation, "", ruleType, true, null); } /** @@ -324,6 +324,7 @@ private void sendImpression(@Nonnull ProjectConfig projectConfig, * @param variation the variation that was returned from activate. * @param flagKey It can either be empty if ruleType is experiment or it's feature key in case ruleType is feature-test or rollout * @param ruleType It can either be experiment in case impression event is sent from activate or it's feature-test or rollout + * @param cmabUUID The cmabUUID if the experiment is a cmab experiment. */ private boolean sendImpression(@Nonnull ProjectConfig projectConfig, @Nullable ExperimentCore experiment, @@ -332,7 +333,8 @@ private boolean sendImpression(@Nonnull ProjectConfig projectConfig, @Nullable Variation variation, @Nonnull String flagKey, @Nonnull String ruleType, - @Nonnull boolean enabled) { + @Nonnull boolean enabled, + @Nullable String cmabUUID) { UserEvent userEvent = UserEventFactory.createImpressionEvent( projectConfig, @@ -342,7 +344,8 @@ private boolean sendImpression(@Nonnull ProjectConfig projectConfig, filteredAttributes, flagKey, ruleType, - enabled); + enabled, + cmabUUID); if (userEvent == null) { return false; @@ -504,7 +507,7 @@ private Boolean isFeatureEnabled(@Nonnull ProjectConfig projectConfig, if (featureDecision.decisionSource != null) { decisionSource = featureDecision.decisionSource; } - + String cmabUUID = featureDecision.cmabUUID; if (featureDecision.variation != null) { // This information is only necessary for feature tests. // For rollouts experiments and variations are an implementation detail only. @@ -526,7 +529,8 @@ private Boolean isFeatureEnabled(@Nonnull ProjectConfig projectConfig, featureDecision.variation, featureKey, decisionSource.toString(), - featureEnabled); + featureEnabled, + cmabUUID); DecisionNotification decisionNotification = DecisionNotification.newFeatureDecisionNotificationBuilder() .withUserId(userId) @@ -1355,6 +1359,8 @@ private OptimizelyDecision createOptimizelyDecision( Map attributes = user.getAttributes(); Map copiedAttributes = new HashMap<>(attributes); + String cmabUUID = flagDecision.cmabUUID; + if (!allOptions.contains(OptimizelyDecideOption.DISABLE_DECISION_EVENT)) { decisionEventDispatched = sendImpression( projectConfig, @@ -1364,7 +1370,8 @@ private OptimizelyDecision createOptimizelyDecision( flagDecision.variation, flagKey, decisionSource.toString(), - flagEnabled); + flagEnabled, + cmabUUID); } DecisionNotification decisionNotification = DecisionNotification.newFlagDecisionNotificationBuilder() diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/UserEventFactory.java b/core-api/src/main/java/com/optimizely/ab/event/internal/UserEventFactory.java index c8687f7a6..93f0f1f8b 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/UserEventFactory.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/UserEventFactory.java @@ -41,7 +41,8 @@ public static ImpressionEvent createImpressionEvent(@Nonnull ProjectConfig proje @Nonnull Map attributes, @Nonnull String flagKey, @Nonnull String ruleType, - @Nonnull boolean enabled) { + @Nonnull boolean enabled, + @Nullable String cmabUUID) { if ((FeatureDecision.DecisionSource.ROLLOUT.toString().equals(ruleType) || variation == null) && !projectConfig.getSendFlagDecisions()) { @@ -68,13 +69,18 @@ public static ImpressionEvent createImpressionEvent(@Nonnull ProjectConfig proje .withProjectConfig(projectConfig) .build(); - DecisionMetadata metadata = new DecisionMetadata.Builder() + DecisionMetadata.Builder metadataBuilder = new DecisionMetadata.Builder() .setFlagKey(flagKey) .setRuleKey(experimentKey) .setRuleType(ruleType) .setVariationKey(variationKey) - .setEnabled(enabled) - .build(); + .setEnabled(enabled); + + if (cmabUUID != null) { + metadataBuilder.setCmabUUID(cmabUUID); + } + + DecisionMetadata metadata = metadataBuilder.build(); return new ImpressionEvent.Builder() .withUserContext(userContext) diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/DecisionMetadata.java b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/DecisionMetadata.java index aec6cdce2..5765efc64 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/DecisionMetadata.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/DecisionMetadata.java @@ -16,11 +16,11 @@ */ package com.optimizely.ab.event.internal.payload; +import java.util.StringJoiner; + import com.fasterxml.jackson.annotation.JsonProperty; import com.optimizely.ab.annotations.VisibleForTesting; -import java.util.StringJoiner; - public class DecisionMetadata { @JsonProperty("flag_key") @@ -33,17 +33,20 @@ public class DecisionMetadata { String variationKey; @JsonProperty("enabled") boolean enabled; + @JsonProperty("cmab_uuid") + String cmabUUID; @VisibleForTesting public DecisionMetadata() { } - public DecisionMetadata(String flagKey, String ruleKey, String ruleType, String variationKey, boolean enabled) { + public DecisionMetadata(String flagKey, String ruleKey, String ruleType, String variationKey, boolean enabled, String cmabUUID) { this.flagKey = flagKey; this.ruleKey = ruleKey; this.ruleType = ruleType; this.variationKey = variationKey; this.enabled = enabled; + this.cmabUUID = cmabUUID; } public String getRuleType() { @@ -66,6 +69,10 @@ public String getVariationKey() { return variationKey; } + public String getCmabUUID() { + return cmabUUID; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -77,6 +84,7 @@ public boolean equals(Object o) { if (!ruleKey.equals(that.ruleKey)) return false; if (!flagKey.equals(that.flagKey)) return false; if (enabled != that.enabled) return false; + if (!java.util.Objects.equals(cmabUUID, that.cmabUUID)) return false; return variationKey.equals(that.variationKey); } @@ -86,6 +94,7 @@ public int hashCode() { result = 31 * result + flagKey.hashCode(); result = 31 * result + ruleKey.hashCode(); result = 31 * result + variationKey.hashCode(); + result = 31 * result + (cmabUUID != null ? cmabUUID.hashCode() : 0); return result; } @@ -97,6 +106,7 @@ public String toString() { .add("ruleType='" + ruleType + "'") .add("variationKey='" + variationKey + "'") .add("enabled=" + enabled) + .add("cmabUUID='" + cmabUUID + "'") .toString(); } @@ -108,6 +118,7 @@ public static class Builder { private String flagKey; private String variationKey; private boolean enabled; + private String cmabUUID; public Builder setEnabled(boolean enabled) { this.enabled = enabled; @@ -134,8 +145,13 @@ public Builder setVariationKey(String variationKey) { return this; } + public Builder setCmabUUID(String cmabUUID){ + this.cmabUUID = cmabUUID; + return this; + } + public DecisionMetadata build() { - return new DecisionMetadata(flagKey, ruleKey, ruleType, variationKey, enabled); + return new DecisionMetadata(flagKey, ruleKey, ruleType, variationKey, enabled, cmabUUID); } } } diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java b/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java index 08a8b7da9..ed9d32979 100644 --- a/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java +++ b/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java @@ -104,7 +104,7 @@ public void createImpressionEventPassingUserAgentAttribute() throws Exception { Map attributeMap = new HashMap(); attributeMap.put(attribute.getKey(), "value"); attributeMap.put(ControlAttribute.USER_AGENT_ATTRIBUTE.toString(), "Chrome"); - DecisionMetadata metadata = new DecisionMetadata(activatedExperiment.getKey(), activatedExperiment.getKey(), ruleType, "variationKey", true); + DecisionMetadata metadata = new DecisionMetadata(activatedExperiment.getKey(), activatedExperiment.getKey(), ruleType, "variationKey", true, null); Decision expectedDecision = new Decision.Builder() .setCampaignId(activatedExperiment.getLayerId()) .setExperimentId(activatedExperiment.getId()) @@ -1064,7 +1064,8 @@ public static LogEvent createImpressionEvent(ProjectConfig projectConfig, attributes, activatedExperiment.getKey(), "experiment", - true); + true, + null); return EventFactory.createLogEvent(userEvent); diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/UserEventFactoryTest.java b/core-api/src/test/java/com/optimizely/ab/event/internal/UserEventFactoryTest.java index a7739bb73..fc1e6a6f0 100644 --- a/core-api/src/test/java/com/optimizely/ab/event/internal/UserEventFactoryTest.java +++ b/core-api/src/test/java/com/optimizely/ab/event/internal/UserEventFactoryTest.java @@ -16,22 +16,28 @@ */ package com.optimizely.ab.event.internal; -import com.google.common.collect.ImmutableMap; -import com.optimizely.ab.config.Experiment; -import com.optimizely.ab.config.ProjectConfig; -import com.optimizely.ab.config.Variation; -import com.optimizely.ab.event.internal.payload.DecisionMetadata; -import com.optimizely.ab.internal.ReservedEventKey; +import java.util.Collections; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import org.mockito.runners.MockitoJUnitRunner; -import java.util.Collections; -import java.util.Map; - -import static org.junit.Assert.*; +import com.google.common.collect.ImmutableMap; +import com.optimizely.ab.config.Experiment; +import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.config.Variation; +import com.optimizely.ab.event.internal.payload.DecisionMetadata; +import com.optimizely.ab.internal.ReservedEventKey; @RunWith(MockitoJUnitRunner.class) @@ -67,7 +73,7 @@ public class UserEventFactoryTest { public void setUp() { experiment = new Experiment(EXPERIMENT_ID, EXPERIMENT_KEY, LAYER_ID); variation = new Variation(VARIATION_ID, VARIATION_KEY); - decisionMetadata = new DecisionMetadata("", EXPERIMENT_KEY, "experiment", VARIATION_KEY, true); + decisionMetadata = new DecisionMetadata("", EXPERIMENT_KEY, "experiment", VARIATION_KEY, true, null); } @Test @@ -81,7 +87,8 @@ public void createImpressionEventNull() { ATTRIBUTES, EXPERIMENT_KEY, "rollout", - false + false, + null ); assertNull(actual); } @@ -96,7 +103,8 @@ public void createImpressionEvent() { ATTRIBUTES, "", "experiment", - true + true, + null ); assertTrue(actual.getTimestamp() > 0); @@ -140,4 +148,102 @@ public void createConversionEvent() { assertEquals(VALUE, actual.getValue()); assertEquals(TAGS, actual.getTags()); } + @Test + public void createImpressionEventWithCmabUuid() { + // Arrange + String userId = "testUser"; + String flagKey = "testFlag"; + String ruleType = "experiment"; + boolean enabled = true; + String cmabUUID = "test-cmab-uuid-123"; + Map attributes = Collections.emptyMap(); + + // Create mock objects + ProjectConfig mockProjectConfig = mock(ProjectConfig.class); + Experiment mockExperiment = mock(Experiment.class); + Variation mockVariation = mock(Variation.class); + + // Setup mock behavior + when(mockProjectConfig.getSendFlagDecisions()).thenReturn(true); + when(mockExperiment.getLayerId()).thenReturn("layer123"); + when(mockExperiment.getId()).thenReturn("experiment123"); + when(mockExperiment.getKey()).thenReturn("experimentKey"); + when(mockVariation.getKey()).thenReturn("variationKey"); + when(mockVariation.getId()).thenReturn("variation123"); + + // Act + ImpressionEvent result = UserEventFactory.createImpressionEvent( + mockProjectConfig, + mockExperiment, + mockVariation, + userId, + attributes, + flagKey, + ruleType, + enabled, + cmabUUID + ); + + // Assert + assertNotNull(result); + + // Verify DecisionMetadata contains cmabUUID + DecisionMetadata metadata = result.getMetadata(); + assertNotNull(metadata); + assertEquals(cmabUUID, metadata.getCmabUUID()); + assertEquals(flagKey, metadata.getFlagKey()); + assertEquals("experimentKey", metadata.getRuleKey()); + assertEquals(ruleType, metadata.getRuleType()); + assertEquals("variationKey", metadata.getVariationKey()); + assertEquals(enabled, metadata.getEnabled()); + + // Verify other fields + assertEquals("layer123", result.getLayerId()); + assertEquals("experiment123", result.getExperimentId()); + assertEquals("experimentKey", result.getExperimentKey()); + assertEquals("variation123", result.getVariationId()); + assertEquals("variationKey", result.getVariationKey()); + } + + @Test + public void createImpressionEventWithNullCmabUuid() { + // Arrange + String userId = "testUser"; + String flagKey = "testFlag"; + String ruleType = "experiment"; + boolean enabled = true; + String cmabUUID = null; + Map attributes = Collections.emptyMap(); + + // Create mock objects (same setup as above) + ProjectConfig mockProjectConfig = mock(ProjectConfig.class); + Experiment mockExperiment = mock(Experiment.class); + Variation mockVariation = mock(Variation.class); + + when(mockProjectConfig.getSendFlagDecisions()).thenReturn(true); + when(mockExperiment.getLayerId()).thenReturn("layer123"); + when(mockExperiment.getId()).thenReturn("experiment123"); + when(mockExperiment.getKey()).thenReturn("experimentKey"); + when(mockVariation.getKey()).thenReturn("variationKey"); + when(mockVariation.getId()).thenReturn("variation123"); + + // Act + ImpressionEvent result = UserEventFactory.createImpressionEvent( + mockProjectConfig, + mockExperiment, + mockVariation, + userId, + attributes, + flagKey, + ruleType, + enabled, + cmabUUID + ); + + // Assert + assertNotNull(result); + DecisionMetadata metadata = result.getMetadata(); + assertNotNull(metadata); + assertNull(metadata.getCmabUUID()); + } } From 416bcbdf4499c70efada72f0fc83ad7af7d88a64 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 1 Oct 2025 03:43:00 +0600 Subject: [PATCH 21/49] update: return cmab error decision whenever found --- .../java/com/optimizely/ab/bucketing/DecisionService.java | 8 ++++++++ 1 file changed, 8 insertions(+) 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 b3b78a3fe..61e45f04c 100644 --- a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java @@ -381,6 +381,14 @@ DecisionResponse getVariationFromExperiment(@Nonnull ProjectCon 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, cmabUUID), From 64f378f8ac2227c120293513c9783e3b6c63f248 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 1 Oct 2025 07:50:27 +0600 Subject: [PATCH 22/49] update: enhance error handling by specifying CMAB error messages in decision responses --- .../src/main/java/com/optimizely/ab/Optimizely.java | 13 ++----------- .../ab/optimizelydecision/DecisionMessage.java | 2 +- 2 files changed, 3 insertions(+), 12 deletions(-) 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 bbdf4f877..cc99fd3f7 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -1451,10 +1451,11 @@ private Map decideForKeys(@Nonnull OptimizelyUserCon for (int i = 0; i < flagsWithoutForcedDecision.size(); i++) { DecisionResponse decision = decisionList.get(i); boolean error = decision.isError(); + String experimentKey = decision.getResult().experiment.getKey(); String flagKey = flagsWithoutForcedDecision.get(i).getKey(); if (error) { - OptimizelyDecision optimizelyDecision = OptimizelyDecision.newErrorDecision(flagKey, user, DecisionMessage.DECISION_ERROR.reason(flagKey)); + OptimizelyDecision optimizelyDecision = OptimizelyDecision.newErrorDecision(flagKey, user, DecisionMessage.CMAB_ERROR.reason(experimentKey)); decisionMap.put(flagKey, optimizelyDecision); if (validKeys.contains(flagKey)) { validKeys.remove(flagKey); @@ -1611,17 +1612,7 @@ private Map decideForKeysSync(@Nonnull OptimizelyUse for (int i = 0; i < flagsWithoutForcedDecision.size(); i++) { DecisionResponse decision = decisionList.get(i); - boolean error = decision.isError(); String flagKey = flagsWithoutForcedDecision.get(i).getKey(); - - if (error) { - OptimizelyDecision optimizelyDecision = OptimizelyDecision.newErrorDecision(flagKey, user, DecisionMessage.DECISION_ERROR.reason(flagKey)); - decisionMap.put(flagKey, optimizelyDecision); - if (validKeys.contains(flagKey)) { - validKeys.remove(flagKey); - } - } - flagDecisions.put(flagKey, decision.getResult()); decisionReasonsMap.get(flagKey).merge(decision.getReasons()); } 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 5cae0c744..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 @@ -21,7 +21,7 @@ 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."), - DECISION_ERROR("Decision service error occured for key \"%s\"."); + CMAB_ERROR("Failed to fetch CMAB data for experiment %s."); private String format; From 853916672c4fd05818b9de68db6b938860f1ac2d Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 1 Oct 2025 09:22:03 +0600 Subject: [PATCH 23/49] update: improve error handling by checking for null values in experiment key retrieval --- core-api/src/main/java/com/optimizely/ab/Optimizely.java | 5 ++++- core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) 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 cc99fd3f7..899f396a7 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -1451,7 +1451,10 @@ private Map decideForKeys(@Nonnull OptimizelyUserCon for (int i = 0; i < flagsWithoutForcedDecision.size(); i++) { DecisionResponse decision = decisionList.get(i); boolean error = decision.isError(); - String experimentKey = decision.getResult().experiment.getKey(); + String experimentKey = null; + if (decision.getResult() != null && decision.getResult().experiment != null) { + experimentKey = decision.getResult().experiment.getKey(); + } String flagKey = flagsWithoutForcedDecision.get(i).getKey(); if (error) { 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 470947ed4..257d717bb 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -5044,7 +5044,7 @@ public void testDecideReturnsErrorDecisionWhenDecisionServiceFails() throws Exce assertNull(decision.getVariationKey()); assertNull(decision.getRuleKey()); assertEquals("feature_1", decision.getFlagKey()); - assertTrue(decision.getReasons().contains("Decision service error occured for key \"feature_1\".")); + assertTrue(decision.getReasons().contains("Failed to fetch CMAB data for experiment exp-cmab.")); } } From 3cee65c85a893102ddcbb8a79974ff52601f45b1 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 1 Oct 2025 09:24:40 +0600 Subject: [PATCH 24/49] update: fix CMAB error handling by providing a valid Experiment in FeatureDecision --- core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 257d717bb..1f0b35b5e 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -5009,7 +5009,7 @@ public void testDecideReturnsErrorDecisionWhenDecisionServiceFails() throws Exce // Mock decision service to return an error from CMAB DecisionReasons reasons = new DefaultDecisionReasons(); - FeatureDecision errorFeatureDecision = new FeatureDecision(null, null, FeatureDecision.DecisionSource.ROLLOUT); + FeatureDecision errorFeatureDecision = new FeatureDecision(new Experiment("123", "exp-cmab", "123"), null, FeatureDecision.DecisionSource.ROLLOUT); DecisionResponse errorDecisionResponse = new DecisionResponse<>( errorFeatureDecision, reasons, From 47c65b5ffe32061b226f4aa2f0563b58b149e74e Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 1 Oct 2025 21:51:50 +0600 Subject: [PATCH 25/49] update: add Javadoc comments for async decision methods and config creation in CMAB client --- .../optimizely/ab/OptimizelyUserContext.java | 8 +++++ .../ab/bucketing/DecisionService.java | 1 + .../ab/cmab/client/CmabClientConfig.java | 4 +++ .../ab/cmab/client/RetryConfig.java | 4 +++ .../ab/bucketing/DecisionServiceTest.java | 34 +++++++++---------- 5 files changed, 34 insertions(+), 17 deletions(-) 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 728d3f6ed..456bab08f 100644 --- a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java +++ b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java @@ -313,6 +313,9 @@ public void decideForKeysAsync(@Nonnull List keys, /** * Returns decision results asynchronously for multiple flag keys. + * + * @param keys A list of flag keys for which decisions will be made. + * @param callback A callback to invoke when decisions are available. */ public void decideForKeysAsync(@Nonnull List keys, @Nonnull OptimizelyDecisionsCallback callback) { decideForKeysAsync(keys, callback, Collections.emptyList()); @@ -320,6 +323,9 @@ public void decideForKeysAsync(@Nonnull List keys, @Nonnull OptimizelyDe /** * Returns decision results asynchronously for all active flag keys. + * + * @param callback A callback to invoke when decisions are available. + * @param options A list of options for decision-making. */ public void decideAllAsync(@Nonnull OptimizelyDecisionsCallback callback, @Nonnull List options) { @@ -329,6 +335,8 @@ public void decideAllAsync(@Nonnull OptimizelyDecisionsCallback callback, /** * Returns decision results asynchronously for all active flag keys. + * + * @param callback A callback to invoke when decisions are available. */ public void decideAllAsync(@Nonnull OptimizelyDecisionsCallback callback) { decideAllAsync(callback, Collections.emptyList()); 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 61e45f04c..9a6cae6e2 100644 --- a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java @@ -106,6 +106,7 @@ public DecisionService(@Nonnull Bucketer bucketer, * @param options An array of decision options * @param userProfileTracker tracker for reading and updating user profile of the user * @param reasons Decision reasons + * @param useCmab Boolean flag to determine if cmab service is to be used * @return A {@link DecisionResponse} including the {@link Variation} that user is bucketed into (or null) and the decision reasons */ @Nonnull 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/RetryConfig.java b/core-api/src/main/java/com/optimizely/ab/cmab/client/RetryConfig.java index b5b04cfa3..632b760af 100644 --- a/core-api/src/main/java/com/optimizely/ab/cmab/client/RetryConfig.java +++ b/core-api/src/main/java/com/optimizely/ab/cmab/client/RetryConfig.java @@ -62,6 +62,8 @@ public RetryConfig(int maxRetries) { /** * Creates a default RetryConfig with 3 retries and exponential backoff. + * + * @return Retry config with default settings */ public static RetryConfig defaultConfig() { return new RetryConfig(3); @@ -69,6 +71,8 @@ public static RetryConfig defaultConfig() { /** * Creates a RetryConfig with no retries (single attempt only). + * + * @return Retry config with no retries */ public static RetryConfig noRetry() { return new RetryConfig(0, 0, 1.0, 0); diff --git a/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java b/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java index e07296384..4603445ee 100644 --- a/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java +++ b/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java @@ -566,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, null); + DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, mockUserProfileService, mockCmabService); FeatureFlag featureFlag1 = FEATURE_FLAG_MULTI_VARIATE_FEATURE; FeatureFlag featureFlag2 = FEATURE_FLAG_MULTI_VARIATE_FUTURE_FEATURE; @@ -626,7 +626,7 @@ public void getVariationForFeatureInRolloutReturnsNullWhenUserIsExcludedFromAllT mockBucketer, mockErrorHandler, null, - null + mockCmabService ); FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( @@ -653,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, null); + DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null, mockCmabService); FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( FEATURE_FLAG_MULTI_VARIATE_FEATURE, @@ -684,7 +684,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsAllAudie mockBucketer, mockErrorHandler, null, - null + mockCmabService ); FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( @@ -726,7 +726,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficI mockBucketer, mockErrorHandler, null, - null + mockCmabService ); FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( @@ -767,7 +767,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficI mockBucketer, mockErrorHandler, null, - null + mockCmabService ); FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( @@ -806,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, null); + DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null, mockCmabService); FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( FEATURE_FLAG_MULTI_VARIATE_FEATURE, @@ -959,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, null); + DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, userProfileService, mockCmabService); logbackVerifier.expectMessage(Level.INFO, "Returning previously activated variation \"" + variation.getKey() + "\" of experiment \"" + experiment.getKey() + "\"" @@ -985,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, null); + 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."); @@ -1012,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, null); + DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, userProfileService, mockCmabService); logbackVerifier.expectMessage(Level.INFO, "User \"" + userProfileId + "\" was previously bucketed into variation with ID \"" + storedVariationId + "\" for " + @@ -1043,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, null); + DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, userProfileService, mockCmabService); assertEquals(variation, decisionService.getVariation( experiment, optimizely.createUserContext(userProfileId, Collections.emptyMap()), noAudienceProjectConfig).getResult() @@ -1105,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, null); + 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); @@ -1117,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, null)); + DecisionService decisionService = spy(new DecisionService(bucketer, mockErrorHandler, null, mockCmabService)); Experiment experiment = validProjectConfig.getExperiments().get(0); Variation expectedVariation = experiment.getVariations().get(0); @@ -1152,7 +1152,7 @@ public void getVariationForRolloutWithBucketingId() { bucketer, mockErrorHandler, null, - null + mockCmabService )); FeatureDecision expectedFeatureDecision = new FeatureDecision( @@ -1307,7 +1307,7 @@ public void getVariationForFeatureReturnHoldoutDecisionForGlobalHoldout() { Bucketer mockBucketer = new Bucketer(); - DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null, null); + DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null, mockCmabService); Map attributes = new HashMap<>(); attributes.put("$opt_bucketing_id", "ppid160000"); @@ -1353,7 +1353,7 @@ public void excludedFlagsHoldoutAppliesToAllExceptSpecified() { Bucketer mockBucketer = new Bucketer(); - DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null, null); + DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null, mockCmabService); Map attributes = new HashMap<>(); attributes.put("$opt_bucketing_id", "ppid300002"); @@ -1384,7 +1384,7 @@ public void userMeetsHoldoutAudienceConditions() { Bucketer mockBucketer = new Bucketer(); - DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null, null); + DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null, mockCmabService); Map attributes = new HashMap<>(); attributes.put("$opt_bucketing_id", "ppid543400"); From fe75a85963d69548eaed36b9a8cc55d83bd14fc4 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Fri, 3 Oct 2025 20:42:20 +0600 Subject: [PATCH 26/49] update: refactor build to use cmabClient instead of default service --- .../java/com/optimizely/ab/Optimizely.java | 20 +++++++++++++++++-- .../com/optimizely/ab/OptimizelyFactory.java | 11 +--------- 2 files changed, 19 insertions(+), 12 deletions(-) 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 899f396a7..9872d10f7 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -20,7 +20,10 @@ import com.optimizely.ab.bucketing.DecisionService; import com.optimizely.ab.bucketing.FeatureDecision; import com.optimizely.ab.bucketing.UserProfileService; +import com.optimizely.ab.cmab.service.CmabCacheValue; import com.optimizely.ab.cmab.service.CmabService; +import com.optimizely.ab.cmab.service.CmabServiceOptions; +import com.optimizely.ab.cmab.service.DefaultCmabService; import com.optimizely.ab.config.AtomicProjectConfigManager; import com.optimizely.ab.config.DatafileProjectConfig; import com.optimizely.ab.config.EventType; @@ -46,6 +49,7 @@ import com.optimizely.ab.event.internal.UserEvent; import com.optimizely.ab.event.internal.UserEventFactory; import com.optimizely.ab.event.internal.payload.EventBatch; +import com.optimizely.ab.internal.DefaultLRUCache; import com.optimizely.ab.internal.NotificationRegistry; import com.optimizely.ab.notification.ActivateNotification; import com.optimizely.ab.notification.DecisionNotification; @@ -70,12 +74,14 @@ import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; import com.optimizely.ab.optimizelydecision.OptimizelyDecision; import com.optimizely.ab.optimizelyjson.OptimizelyJSON; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.annotation.concurrent.ThreadSafe; + import java.io.Closeable; import java.util.ArrayList; import java.util.Arrays; @@ -85,6 +91,7 @@ import java.util.Map; import java.util.concurrent.locks.ReentrantLock; +import com.optimizely.ab.cmab.client.CmabClient; import static com.optimizely.ab.internal.SafetyUtils.tryClose; /** @@ -1998,8 +2005,13 @@ public Builder withODPManager(ODPManager odpManager) { return this; } - public Builder withCmabService(CmabService cmabService) { - this.cmabService = cmabService; + public Builder withCmabClient(CmabClient cmabClient) { + int DEFAULT_MAX_SIZE = 1000; + int DEFAULT_CMAB_CACHE_TIMEOUT = 30 * 60 * 1000; + DefaultLRUCache cmabCache = new DefaultLRUCache<>(DEFAULT_MAX_SIZE, DEFAULT_CMAB_CACHE_TIMEOUT); + CmabServiceOptions cmabServiceOptions = new CmabServiceOptions(logger, cmabCache, cmabClient); + DefaultCmabService defaultCmabService = new DefaultCmabService(cmabServiceOptions); + this.cmabService = defaultCmabService; return this; } @@ -2033,6 +2045,10 @@ 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, cmabService); } 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 1cc4080b6..16bbb3c7a 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java @@ -24,16 +24,12 @@ import com.optimizely.ab.cmab.DefaultCmabClient; import com.optimizely.ab.cmab.client.CmabClientConfig; -import com.optimizely.ab.cmab.service.CmabCacheValue; -import com.optimizely.ab.cmab.service.CmabServiceOptions; -import com.optimizely.ab.cmab.service.DefaultCmabService; import com.optimizely.ab.config.HttpProjectConfigManager; import com.optimizely.ab.config.ProjectConfig; import com.optimizely.ab.config.ProjectConfigManager; import com.optimizely.ab.event.AsyncEventHandler; import com.optimizely.ab.event.BatchEventProcessor; import com.optimizely.ab.event.EventHandler; -import com.optimizely.ab.internal.DefaultLRUCache; import com.optimizely.ab.internal.PropertyUtils; import com.optimizely.ab.notification.NotificationCenter; import com.optimizely.ab.odp.DefaultODPApiManager; @@ -377,18 +373,13 @@ public static Optimizely newDefaultInstance(ProjectConfigManager configManager, .build(); DefaultCmabClient defaultCmabClient = new DefaultCmabClient(CmabClientConfig.withDefaultRetry()); - int DEFAULT_MAX_SIZE = 1000; - int DEFAULT_CMAB_CACHE_TIMEOUT = 30 * 60 * 1000; - DefaultLRUCache cmabCache = new DefaultLRUCache<>(DEFAULT_MAX_SIZE, DEFAULT_CMAB_CACHE_TIMEOUT); - CmabServiceOptions cmabServiceOptions = new CmabServiceOptions(logger, cmabCache, defaultCmabClient); - DefaultCmabService cmabService = new DefaultCmabService(cmabServiceOptions); return Optimizely.builder() .withEventProcessor(eventProcessor) .withConfigManager(configManager) .withNotificationCenter(notificationCenter) .withODPManager(odpManager) - .withCmabService(cmabService) + .withCmabClient(defaultCmabClient) .build(); } } From b0d509058dfc56416ffdafbd0e8d7aea8a09b5ef Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Fri, 3 Oct 2025 20:59:07 +0600 Subject: [PATCH 27/49] update: refactor DefaultCmabClient to utilize CmabClientHelper --- .../ab/cmab/client/CmabClientHelper.java | 89 ++++++++++++++ .../optimizely/ab/cmab/DefaultCmabClient.java | 116 +++--------------- 2 files changed, 104 insertions(+), 101 deletions(-) create mode 100644 core-api/src/main/java/com/optimizely/ab/cmab/client/CmabClientHelper.java 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..a969dcba1 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/cmab/client/CmabClientHelper.java @@ -0,0 +1,89 @@ +package com.optimizely.ab.cmab.client; + +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class CmabClientHelper { + public static final String CMAB_FETCH_FAILED = "CMAB decision fetch failed with status: %s"; + public static final String INVALID_CMAB_FETCH_RESPONSE = "Invalid CMAB fetch response"; + private static final Pattern VARIATION_ID_PATTERN = Pattern.compile("\"variation_id\"\\s*:\\s*\"?([^\"\\s,}]+)\"?"); + + public static String buildRequestJson(String userId, String ruleId, Map attributes, String cmabUuid) { + StringBuilder json = new StringBuilder(); + json.append("{\"instances\":[{"); + json.append("\"visitorId\":\"").append(escapeJson(userId)).append("\","); + json.append("\"experimentId\":\"").append(escapeJson(ruleId)).append("\","); + json.append("\"cmabUUID\":\"").append(escapeJson(cmabUuid)).append("\","); + json.append("\"attributes\":["); + + boolean first = true; + for (Map.Entry entry : attributes.entrySet()) { + if (!first) { + json.append(","); + } + json.append("{\"id\":\"").append(escapeJson(entry.getKey())).append("\","); + json.append("\"value\":").append(formatJsonValue(entry.getValue())).append(","); + json.append("\"type\":\"custom_attribute\"}"); + first = false; + } + + json.append("]}]}"); + return json.toString(); + } + + private static String escapeJson(String value) { + if (value == null) { + return ""; + } + return value.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } + + private static String formatJsonValue(Object value) { + if (value == null) { + return "null"; + } else if (value instanceof String) { + return "\"" + escapeJson((String) value) + "\""; + } else if (value instanceof Number || value instanceof Boolean) { + return value.toString(); + } else { + return "\"" + escapeJson(value.toString()) + "\""; + } + } + + public static String parseVariationId(String jsonResponse) { + // Simple regex to extract variation_id from predictions[0].variation_id + Pattern pattern = Pattern.compile("\"predictions\"\\s*:\\s*\\[\\s*\\{[^}]*\"variation_id\"\\s*:\\s*\"?([^\"\\s,}]+)\"?"); + Matcher matcher = pattern.matcher(jsonResponse); + if (matcher.find()) { + return matcher.group(1); + } + throw new CmabInvalidResponseException(INVALID_CMAB_FETCH_RESPONSE); + } + + private static String parseVariationIdForValidation(String jsonResponse) { + Matcher matcher = VARIATION_ID_PATTERN.matcher(jsonResponse); + if (matcher.find()) { + return matcher.group(1); + } + return null; + } + + public static boolean validateResponse(String responseBody) { + try { + return responseBody.contains("predictions") && + responseBody.contains("variation_id") && + parseVariationIdForValidation(responseBody) != null; + } catch (Exception e) { + return false; + } + } + + public static boolean isSuccessStatusCode(int statusCode) { + return statusCode >= 200 && statusCode < 300; + } +} diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/cmab/DefaultCmabClient.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/cmab/DefaultCmabClient.java index 6af4ac32a..e5f259759 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/cmab/DefaultCmabClient.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/cmab/DefaultCmabClient.java @@ -36,15 +36,13 @@ import com.optimizely.ab.cmab.client.CmabFetchException; import com.optimizely.ab.cmab.client.CmabInvalidResponseException; import com.optimizely.ab.cmab.client.RetryConfig; +import com.optimizely.ab.cmab.client.CmabClientHelper; public class DefaultCmabClient implements CmabClient { private static final Logger logger = LoggerFactory.getLogger(DefaultCmabClient.class); private static final int DEFAULT_TIMEOUT_MS = 10000; - // Update constants to match JS error messages format - private static final String CMAB_FETCH_FAILED = "CMAB decision fetch failed with status: %s"; - private static final String INVALID_CMAB_FETCH_RESPONSE = "Invalid CMAB fetch response"; - private static final Pattern VARIATION_ID_PATTERN = Pattern.compile("\"variation_id\"\\s*:\\s*\"?([^\"\\s,}]+)\"?"); + private static final String CMAB_PREDICTION_ENDPOINT = "https://prediction.cmab.optimizely.com/predict/%s"; private final OptimizelyHttpClient httpClient; @@ -81,7 +79,7 @@ private OptimizelyHttpClient createDefaultHttpClient() { public String fetchDecision(String ruleId, String userId, Map attributes, String cmabUuid) { // Implementation will use this.httpClient and this.retryConfig String url = String.format(CMAB_PREDICTION_ENDPOINT, ruleId); - String requestBody = buildRequestJson(userId, ruleId, attributes, cmabUuid); + String requestBody = CmabClientHelper.buildRequestJson(userId, ruleId, attributes, cmabUuid); // Use retry logic if configured, otherwise single request if (retryConfig != null && retryConfig.getMaxRetries() > 0) { @@ -96,7 +94,7 @@ private String doFetch(String url, String requestBody) { try { request.setEntity(new StringEntity(requestBody)); } catch (UnsupportedEncodingException e) { - String errorMessage = String.format(CMAB_FETCH_FAILED, e.getMessage()); + String errorMessage = String.format(CmabClientHelper.CMAB_FETCH_FAILED, e.getMessage()); logger.error(errorMessage); throw new CmabFetchException(errorMessage); } @@ -105,9 +103,9 @@ private String doFetch(String url, String requestBody) { try { response = httpClient.execute(request); - if (!isSuccessStatusCode(response.getStatusLine().getStatusCode())) { + if (!CmabClientHelper.isSuccessStatusCode(response.getStatusLine().getStatusCode())) { StatusLine statusLine = response.getStatusLine(); - String errorMessage = String.format(CMAB_FETCH_FAILED, statusLine.getReasonPhrase()); + String errorMessage = String.format(CmabClientHelper.CMAB_FETCH_FAILED, statusLine.getReasonPhrase()); logger.error(errorMessage); throw new CmabFetchException(errorMessage); } @@ -116,18 +114,18 @@ private String doFetch(String url, String requestBody) { try { responseBody = EntityUtils.toString(response.getEntity()); - if (!validateResponse(responseBody)) { - logger.error(INVALID_CMAB_FETCH_RESPONSE); - throw new CmabInvalidResponseException(INVALID_CMAB_FETCH_RESPONSE); + if (!CmabClientHelper.validateResponse(responseBody)) { + logger.error(CmabClientHelper.INVALID_CMAB_FETCH_RESPONSE); + throw new CmabInvalidResponseException(CmabClientHelper.INVALID_CMAB_FETCH_RESPONSE); } - return parseVariationId(responseBody); + return CmabClientHelper.parseVariationId(responseBody); } catch (IOException | ParseException e) { - logger.error(CMAB_FETCH_FAILED); - throw new CmabInvalidResponseException(INVALID_CMAB_FETCH_RESPONSE); + logger.error(CmabClientHelper.CMAB_FETCH_FAILED); + throw new CmabInvalidResponseException(CmabClientHelper.INVALID_CMAB_FETCH_RESPONSE); } } catch (IOException e) { - String errorMessage = String.format(CMAB_FETCH_FAILED, e.getMessage()); + String errorMessage = String.format(CmabClientHelper.CMAB_FETCH_FAILED, e.getMessage()); logger.error(errorMessage); throw new CmabFetchException(errorMessage); } finally { @@ -158,7 +156,7 @@ private String doFetchWithRetry(String url, String requestBody, int maxRetries) Thread.sleep((long) backoff); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); - String errorMessage = String.format(CMAB_FETCH_FAILED, "Request interrupted during retry"); + String errorMessage = String.format(CmabClientHelper.CMAB_FETCH_FAILED, "Request interrupted during retry"); logger.error(errorMessage); throw new CmabFetchException(errorMessage, ie); } @@ -172,94 +170,10 @@ private String doFetchWithRetry(String url, String requestBody, int maxRetries) } // If we get here, all retries were exhausted - String errorMessage = String.format(CMAB_FETCH_FAILED, "Exhausted all retries for CMAB request"); + String errorMessage = String.format(CmabClientHelper.CMAB_FETCH_FAILED, "Exhausted all retries for CMAB request"); logger.error(errorMessage); throw new CmabFetchException(errorMessage, lastException); } - - private String buildRequestJson(String userId, String ruleId, Map attributes, String cmabUuid) { - StringBuilder json = new StringBuilder(); - json.append("{\"instances\":[{"); - json.append("\"visitorId\":\"").append(escapeJson(userId)).append("\","); - json.append("\"experimentId\":\"").append(escapeJson(ruleId)).append("\","); - json.append("\"cmabUUID\":\"").append(escapeJson(cmabUuid)).append("\","); - json.append("\"attributes\":["); - - boolean first = true; - for (Map.Entry entry : attributes.entrySet()) { - if (!first) { - json.append(","); - } - json.append("{\"id\":\"").append(escapeJson(entry.getKey())).append("\","); - json.append("\"value\":").append(formatJsonValue(entry.getValue())).append(","); - json.append("\"type\":\"custom_attribute\"}"); - first = false; - } - - json.append("]}]}"); - return json.toString(); - } - - private String escapeJson(String value) { - if (value == null) { - return ""; - } - return value.replace("\\", "\\\\") - .replace("\"", "\\\"") - .replace("\n", "\\n") - .replace("\r", "\\r") - .replace("\t", "\\t"); - } - - private String formatJsonValue(Object value) { - if (value == null) { - return "null"; - } else if (value instanceof String) { - return "\"" + escapeJson((String) value) + "\""; - } else if (value instanceof Number || value instanceof Boolean) { - return value.toString(); - } else { - return "\"" + escapeJson(value.toString()) + "\""; - } - } - - // Helper methods - private boolean isSuccessStatusCode(int statusCode) { - return statusCode >= 200 && statusCode < 300; - } - - private boolean validateResponse(String responseBody) { - try { - return responseBody.contains("predictions") && - responseBody.contains("variation_id") && - parseVariationIdForValidation(responseBody) != null; - } catch (Exception e) { - return false; - } - } - - private boolean shouldRetry(Exception exception) { - return (exception instanceof CmabFetchException) || - (exception instanceof CmabInvalidResponseException); - } - - private String parseVariationIdForValidation(String jsonResponse) { - Matcher matcher = VARIATION_ID_PATTERN.matcher(jsonResponse); - if (matcher.find()) { - return matcher.group(1); - } - return null; - } - - private String parseVariationId(String jsonResponse) { - // Simple regex to extract variation_id from predictions[0].variation_id - Pattern pattern = Pattern.compile("\"predictions\"\\s*:\\s*\\[\\s*\\{[^}]*\"variation_id\"\\s*:\\s*\"?([^\"\\s,}]+)\"?"); - Matcher matcher = pattern.matcher(jsonResponse); - if (matcher.find()) { - return matcher.group(1); - } - throw new CmabInvalidResponseException(INVALID_CMAB_FETCH_RESPONSE); - } private static void closeHttpResponse(CloseableHttpResponse response) { if (response != null) { From 6db2e88659fc52ebf5b87745073e2e78e839793b Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Fri, 3 Oct 2025 21:09:12 +0600 Subject: [PATCH 28/49] update: refactor AsyncDecisionsFetcher to AsyncDecisionFetcher and enhance decision handling --- .../optimizely/ab/OptimizelyUserContext.java | 5 +- .../AsyncDecisionFetcher.java | 133 +++++++++++++++--- .../AsyncDecisionsFetcher.java | 100 ------------- 3 files changed, 118 insertions(+), 120 deletions(-) delete mode 100644 core-api/src/main/java/com/optimizely/ab/optimizelydecision/AsyncDecisionsFetcher.java 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 456bab08f..d576c2bd0 100644 --- a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java +++ b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java @@ -32,7 +32,6 @@ import com.optimizely.ab.odp.ODPSegmentCallback; import com.optimizely.ab.odp.ODPSegmentOption; import com.optimizely.ab.optimizelydecision.AsyncDecisionFetcher; -import com.optimizely.ab.optimizelydecision.AsyncDecisionsFetcher; import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; import com.optimizely.ab.optimizelydecision.OptimizelyDecision; import com.optimizely.ab.optimizelydecision.OptimizelyDecisionCallback; @@ -307,7 +306,7 @@ public void decideAsync(@Nonnull String key, @Nonnull OptimizelyDecisionCallback public void decideForKeysAsync(@Nonnull List keys, @Nonnull OptimizelyDecisionsCallback callback, @Nonnull List options) { - AsyncDecisionsFetcher fetcher = new AsyncDecisionsFetcher(this, keys, options, callback); + AsyncDecisionFetcher fetcher = new AsyncDecisionFetcher(this, keys, options, callback); fetcher.start(); } @@ -329,7 +328,7 @@ public void decideForKeysAsync(@Nonnull List keys, @Nonnull OptimizelyDe */ public void decideAllAsync(@Nonnull OptimizelyDecisionsCallback callback, @Nonnull List options) { - AsyncDecisionsFetcher fetcher = new AsyncDecisionsFetcher(this, null, options, callback, true); + AsyncDecisionFetcher fetcher = new AsyncDecisionFetcher(this, options, callback); fetcher.start(); } 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 index 703b20bbd..0d53014a7 100644 --- a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/AsyncDecisionFetcher.java +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/AsyncDecisionFetcher.java @@ -15,27 +15,41 @@ */ package com.optimizely.ab.optimizelydecision; -import com.optimizely.ab.OptimizelyUserContext; +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 javax.annotation.Nonnull; -import java.util.List; +import com.optimizely.ab.OptimizelyUserContext; /** - * AsyncDecisionFetcher handles asynchronous decision fetching for a single flag key. + * 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 key; + private final String singleKey; + private final List keys; private final List options; - private final OptimizelyDecisionCallback callback; + 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 decision fetching. + * Constructor for async single decision fetching. * * @param userContext The user context to make decisions for * @param key The flag key to decide on @@ -47,27 +61,112 @@ public AsyncDecisionFetcher(@Nonnull OptimizelyUserContext userContext, @Nonnull List options, @Nonnull OptimizelyDecisionCallback callback) { this.userContext = userContext; - this.key = key; + this.singleKey = key; + this.keys = null; this.options = options; - this.callback = callback; + this.singleCallback = callback; + this.multipleCallback = null; + this.decideAll = false; + this.fetchType = FetchType.SINGLE_DECISION; - // Set thread name for debugging 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; - // Set as daemon thread so it doesn't prevent JVM shutdown + 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 { - OptimizelyDecision decision = userContext.decide(key, options); - callback.onCompleted(decision); + 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 for key: " + key, e); - // Create an error decision and pass it to the callback - OptimizelyDecision errorDecision = createErrorDecision(key, e.getMessage()); - callback.onCompleted(errorDecision); + 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; } } diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/AsyncDecisionsFetcher.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/AsyncDecisionsFetcher.java deleted file mode 100644 index 270a027da..000000000 --- a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/AsyncDecisionsFetcher.java +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Copyright 2025, Optimizely and contributors - *

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

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

- * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.optimizely.ab.optimizelydecision; - -import com.optimizely.ab.OptimizelyUserContext; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import javax.annotation.Nonnull; -import javax.annotation.Nullable; -import java.util.Collections; -import java.util.List; -import java.util.Map; - -/** - * AsyncDecisionsFetcher handles asynchronous decision fetching for multiple flag keys. - * This class follows the same pattern as ODP's async segment fetching. - */ -public class AsyncDecisionsFetcher extends Thread { - private static final Logger logger = LoggerFactory.getLogger(AsyncDecisionsFetcher.class); - - private final List keys; - private final List options; - private final OptimizelyDecisionsCallback callback; - private final OptimizelyUserContext userContext; - private final boolean decideAll; - - /** - * Constructor for deciding on specific keys. - * - * @param userContext The user context to make decisions for - * @param keys List of flag keys to decide on - * @param options Decision options - * @param callback Callback to invoke when decisions are ready - */ - public AsyncDecisionsFetcher(@Nonnull OptimizelyUserContext userContext, - @Nonnull List keys, - @Nonnull List options, - @Nonnull OptimizelyDecisionsCallback callback) { - this(userContext, keys, options, callback, false); - } - - /** - * Constructor for deciding on all flags or specific keys. - * - * @param userContext The user context to make decisions for - * @param keys List of flag keys to decide on (null for decideAll) - * @param options Decision options - * @param callback Callback to invoke when decisions are ready - * @param decideAll Whether to decide for all active flags - */ - public AsyncDecisionsFetcher(@Nonnull OptimizelyUserContext userContext, - @Nullable List keys, - @Nonnull List options, - @Nonnull OptimizelyDecisionsCallback callback, - boolean decideAll) { - this.userContext = userContext; - this.keys = keys; - this.options = options; - this.callback = callback; - this.decideAll = decideAll; - - // Set thread name for debugging - String threadName = decideAll ? "AsyncDecisionsFetcher-all" : "AsyncDecisionsFetcher-keys"; - setName(threadName); - - // Set as daemon thread so it doesn't prevent JVM shutdown - setDaemon(true); - } - - @Override - public void run() { - try { - Map decisions; - if (decideAll) { - decisions = userContext.decideAll(options); - } else { - decisions = userContext.decideForKeys(keys, options); - } - callback.onCompleted(decisions); - } catch (Exception e) { - logger.error("Error in async decisions fetching", e); - // Return empty map on error - this follows the pattern of sync methods - callback.onCompleted(Collections.emptyMap()); - } - } -} \ No newline at end of file From 6fc64468a8f40418180e1d76220a3a8b8ec26c81 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Fri, 3 Oct 2025 21:33:48 +0600 Subject: [PATCH 29/49] update: add missing copyright notice and license information to CmabClientHelper --- .../ab/cmab/client/CmabClientHelper.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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 index a969dcba1..f208a50b3 100644 --- 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 @@ -1,3 +1,18 @@ +/** + * 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; From a80c0d33ec2d39cc5f427e59c924c281827a6617 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 15 Oct 2025 18:28:02 +0600 Subject: [PATCH 30/49] update: enhance CMAB handling in bucketing and decision services, add backward compatibility for mobile apps --- .../java/com/optimizely/ab/Optimizely.java | 7 +- .../com/optimizely/ab/bucketing/Bucketer.java | 79 ++++++++----------- .../ab/bucketing/DecisionService.java | 6 +- .../ab/bucketing/DecisionServiceTest.java | 52 ++++++------ 4 files changed, 70 insertions(+), 74 deletions(-) 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 9872d10f7..1b9ff03bc 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -1512,6 +1512,7 @@ Map decideAll(@Nonnull OptimizelyUserContext user, /** * 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 use non-blocking legacy ab-tests 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. @@ -1534,7 +1535,8 @@ OptimizelyDecision decideSync(@Nonnull OptimizelyUserContext user, /** * 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 use non-blocking legacy ab-tests 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. @@ -1548,7 +1550,8 @@ Map decideForKeysSync(@Nonnull OptimizelyUserContext /** * 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 use non-blocking legacy ab-tests 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. 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 e9b694b16..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 @@ -183,6 +183,24 @@ private DecisionResponse bucketToEntityForCmab(@Nonnull Experiment exper 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 ---------- @@ -215,57 +233,26 @@ public DecisionResponse bucket(@Nonnull ExperimentCore experiment, } } - DecisionResponse decisionResponse = bucketToVariation(experiment, bucketingId); - reasons.merge(decisionResponse.getReasons()); - return new DecisionResponse<>(decisionResponse.getResult(), reasons); - } - - /** - * Assign a user to CMAB traffic for an experiment based on hashed value from murmurhash3. - * This method handles CMAB (Contextual Multi-Armed Bandit) traffic allocation. - * - * @param experiment The CMAB Experiment in which the user is to be bucketed. - * @param bucketingId string A customer-assigned value used to create the key for the murmur hash. - * @param projectConfig The current projectConfig - * @return A {@link DecisionResponse} including the entity ID ("$" if bucketed to CMAB, null otherwise) and decision reasons - */ - @Nonnull - public DecisionResponse bucketForCmab(@Nonnull Experiment experiment, - @Nonnull String bucketingId, - @Nonnull ProjectConfig projectConfig) { - - DecisionReasons reasons = DefaultDecisionReasons.newInstance(); - - // ---------- Handle Group Logic (same as regular bucket method) ---------- - String groupId = experiment.getGroupId(); - if (!groupId.isEmpty()) { - Group experimentGroup = projectConfig.getGroupIdMapping().get(groupId); - - if (experimentGroup.getPolicy().equals(Group.RANDOM_POLICY)) { - Experiment bucketedExperiment = bucketToExperiment(experimentGroup, bucketingId, projectConfig); - if (bucketedExperiment == null) { - String message = reasons.addInfo("User with bucketingId \"%s\" is not in any experiment of group %s.", bucketingId, experimentGroup.getId()); - logger.info(message); - return new DecisionResponse<>(null, reasons); - } - - if (!bucketedExperiment.getId().equals(experiment.getId())) { - String message = reasons.addInfo("User with bucketingId \"%s\" is not in experiment \"%s\" of group %s.", bucketingId, experiment.getKey(), - experimentGroup.getId()); - logger.info(message); + 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); } - - String message = reasons.addInfo("User with bucketingId \"%s\" is in experiment \"%s\" of group %s.", bucketingId, experiment.getKey(), - experimentGroup.getId()); + 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); } - - // ---------- Use CMAB-aware bucketToEntity ---------- - DecisionResponse decisionResponse = bucketToEntityForCmab(experiment, bucketingId); - reasons.merge(decisionResponse.getReasons()); - return new DecisionResponse<>(decisionResponse.getResult(), reasons); } //======== Helper methods ========// diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java index 9a6cae6e2..0281a5195 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 @@ -941,10 +941,12 @@ private DecisionResponse getDecisionForCmabExperiment(@Nonnull Pro DecisionReasons reasons = DefaultDecisionReasons.newInstance(); // Check if user is in CMAB traffic allocation - DecisionResponse bucketResponse = bucketer.bucketForCmab(experiment, bucketingId, projectConfig); + DecisionResponse bucketResponse = bucketer.bucket(experiment, bucketingId, projectConfig, true); + // DecisionResponse bucketResponse = bucketer.bucketForCmab(experiment, bucketingId, projectConfig); reasons.merge(bucketResponse.getReasons()); - String bucketedEntityId = bucketResponse.getResult(); + 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.", 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 4603445ee..fdd7f6cac 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 @@ -1527,7 +1527,7 @@ public void getVariationCmabExperimentServiceError() { cmabExperiment.getKey(), cmabExperiment.getStatus(), cmabExperiment.getLayerId(), - cmabExperiment.getAudienceIds(), + cmabExperiment.getAudienceIds(), cmabExperiment.getAudienceConditions(), cmabExperiment.getVariations(), Collections.emptyMap(), // No whitelisting @@ -1574,31 +1574,34 @@ public void getVariationCmabExperimentServiceError() { */ @Test public void getVariationCmabExperimentServiceSuccess() { - // Create a CMAB experiment - Experiment cmabExperiment = createMockCmabExperiment(); - Variation expectedVariation = cmabExperiment.getVariations().get(1); // Use second variation + // 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(4000); + when(mockCmab.getTrafficAllocation()).thenReturn(10000); // 100% allocation - // Create experiment with CMAB config (no whitelisting, no forced variations) - Experiment experiment = new Experiment( - cmabExperiment.getId(), - cmabExperiment.getKey(), - cmabExperiment.getStatus(), - cmabExperiment.getLayerId(), - cmabExperiment.getAudienceIds(), - cmabExperiment.getAudienceConditions(), - cmabExperiment.getVariations(), + // 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 - cmabExperiment.getTrafficAllocation(), + 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.bucketForCmab(any(Experiment.class), anyString(), any(ProjectConfig.class))) - .thenReturn(DecisionResponse.responseNoReasons("$")); + when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), eq(true))) + .thenReturn(DecisionResponse.responseNoReasons(bucketedVariation)); + DecisionService decisionServiceWithMockCmabService = new DecisionService( mockBucketer, mockErrorHandler, @@ -1606,7 +1609,7 @@ public void getVariationCmabExperimentServiceSuccess() { mockCmabService ); - // Mock CmabService.getDecision to return a valid decision + // 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())) @@ -1614,25 +1617,26 @@ public void getVariationCmabExperimentServiceSuccess() { // Call getVariation DecisionResponse result = decisionServiceWithMockCmabService.getVariation( - experiment, + 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()); - // Assert that CmabService.getDecision was called exactly once + // 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(experiment.getId()), + eq(cmabExperiment.getId()), any(List.class) ); } @@ -1648,7 +1652,7 @@ public void getVariationCmabExperimentUserNotInTrafficAllocation() { // Create mock Cmab object Cmab mockCmab = mock(Cmab.class); - when(mockCmab.getTrafficAllocation()).thenReturn(5000); // 50% traffic allocation + when(mockCmab.getTrafficAllocation()).thenReturn(5000); // Create experiment with CMAB config (no whitelisting, no forced variations) Experiment experiment = new Experiment( @@ -1666,7 +1670,7 @@ public void getVariationCmabExperimentUserNotInTrafficAllocation() { // Mock bucketer to return null for CMAB allocation (user not in CMAB traffic) Bucketer mockBucketer = mock(Bucketer.class); - when(mockBucketer.bucketForCmab(any(Experiment.class), anyString(), any(ProjectConfig.class))) + when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), eq(true))) .thenReturn(DecisionResponse.nullNoReasons()); DecisionService decisionServiceWithMockCmabService = new DecisionService( @@ -1693,7 +1697,7 @@ public void getVariationCmabExperimentUserNotInTrafficAllocation() { verify(mockCmabService, never()).getDecision(any(), any(), any(), any()); // Verify that bucketer was called for CMAB allocation - verify(mockBucketer, times(1)).bucketForCmab(any(Experiment.class), anyString(), any(ProjectConfig.class)); + verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), eq(true)); } private Experiment createMockCmabExperiment() { From a9ae805bf14fff50b740bf0c7ddd7f9e04bd2dcf Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 15 Oct 2025 18:33:41 +0600 Subject: [PATCH 31/49] update: add backward compatibility support for Android sync and async decisions in OptimizelyUserContext --- .../optimizely/ab/OptimizelyUserContext.java | 70 ++----------------- 1 file changed, 6 insertions(+), 64 deletions(-) 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 d576c2bd0..da8c36f29 100644 --- a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java +++ b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java @@ -208,6 +208,7 @@ public Map decideAll() { /** * Returns a decision result ({@link OptimizelyDecision}) for a given flag key and a user context, * which contains all data required to deliver the flag. This method skips CMAB logic. + * backward compatibility support for android sync decisions * @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. @@ -217,20 +218,10 @@ public OptimizelyDecision decideSync(@Nonnull String key, return optimizely.decideSync(copy(), key, options); } - /** - * Returns a decision result ({@link OptimizelyDecision}) for a given flag key and a user context, - * which contains all data required to deliver the flag. This method skips CMAB logic. - * - * @param key A flag key for which a decision will be made. - * @return A decision result. - */ - public OptimizelyDecision decideSync(@Nonnull String key) { - return decideSync(key, Collections.emptyList()); - } - /** * Returns a key-map of decision results ({@link OptimizelyDecision}) for multiple flag keys and a user context. * This method skips CMAB logic. + * backward compatibility support for android sync decisions * @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. @@ -240,21 +231,10 @@ public Map decideForKeysSync(@Nonnull List k return optimizely.decideForKeysSync(copy(), keys, options); } - /** - * Returns a key-map of decision results for multiple flag keys and a user context. - * This method skips CMAB logic. - * - * @param keys A list of flag keys for which decisions will be made. - * @return All decision results mapped by flag keys. - */ - public Map decideForKeysSync(@Nonnull List keys) { - return decideForKeysSync(keys, Collections.emptyList()); - } - /** * Returns a key-map of decision results ({@link OptimizelyDecision}) for all active flag keys. * This method skips CMAB logic. - * + * backward compatibility support for android sync decisions * @param options A list of options for decision-making. * @return All decision results mapped by flag keys. */ @@ -262,19 +242,9 @@ public Map decideAllSync(@Nonnull List decideAllSync() { - return decideAllSync(Collections.emptyList()); - } - /** * Returns a decision result asynchronously for a given flag key and a user context. - * + * support for android async decisions * @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. @@ -286,19 +256,10 @@ public void decideAsync(@Nonnull String key, fetcher.start(); } - /** - * Returns a decision result asynchronously for a given flag key and a user context. - * - * @param key A flag key for which a decision will be made. - * @param callback A callback to invoke when the decision is available. - */ - public void decideAsync(@Nonnull String key, @Nonnull OptimizelyDecisionCallback callback) { - decideAsync(key, callback, Collections.emptyList()); - } /** * Returns decision results asynchronously for multiple flag keys. - * + * support for android async decisions * @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. @@ -310,19 +271,9 @@ public void decideForKeysAsync(@Nonnull List keys, fetcher.start(); } - /** - * Returns decision results asynchronously for multiple flag keys. - * - * @param keys A list of flag keys for which decisions will be made. - * @param callback A callback to invoke when decisions are available. - */ - public void decideForKeysAsync(@Nonnull List keys, @Nonnull OptimizelyDecisionsCallback callback) { - decideForKeysAsync(keys, callback, Collections.emptyList()); - } - /** * Returns decision results asynchronously for all active flag keys. - * + * support for android async decisions * @param callback A callback to invoke when decisions are available. * @param options A list of options for decision-making. */ @@ -332,15 +283,6 @@ public void decideAllAsync(@Nonnull OptimizelyDecisionsCallback callback, fetcher.start(); } - /** - * Returns decision results asynchronously for all active flag keys. - * - * @param callback A callback to invoke when decisions are available. - */ - public void decideAllAsync(@Nonnull OptimizelyDecisionsCallback callback) { - decideAllAsync(callback, Collections.emptyList()); - } - /** * Track an event. * From f25f82415eda31c1db06b7d791e438325beab619 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 15 Oct 2025 18:54:45 +0600 Subject: [PATCH 32/49] update: add empty list parameter to decision methods in OptimizelyUserContextTest for consistency --- .../ab/OptimizelyUserContextTest.java | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) 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 8070073c7..fa1dcd59d 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java @@ -2286,7 +2286,7 @@ public void decideSync_featureTest() { OptimizelyJSON variablesExpected = optimizely.getAllFeatureVariables(flagKey, userId); OptimizelyUserContext user = optimizely.createUserContext(userId); - OptimizelyDecision decision = user.decideSync(flagKey); + OptimizelyDecision decision = user.decideSync(flagKey, Collections.emptyList()); assertEquals(decision.getVariationKey(), variationKey); assertTrue(decision.getEnabled()); @@ -2316,7 +2316,7 @@ public void decideForKeysSync_multipleFlags() { OptimizelyJSON variablesExpected2 = optimizely.getAllFeatureVariables(flagKey2, userId); OptimizelyUserContext user = optimizely.createUserContext(userId, Collections.singletonMap("gender", "f")); - Map decisions = user.decideForKeysSync(flagKeys); + Map decisions = user.decideForKeysSync(flagKeys, Collections.emptyList()); assertEquals(decisions.size(), 2); @@ -2382,7 +2382,7 @@ public void decideAllSync_allFlags() { OptimizelyJSON variablesExpected3 = new OptimizelyJSON(Collections.emptyMap()); OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); - Map decisions = user.decideAllSync(); + Map decisions = user.decideAllSync(Collections.emptyList()); assertEquals(decisions.size(), 3); assertEquals( @@ -2468,7 +2468,7 @@ public void decideAllSync_ups_batching() throws Exception { Map attributes = Collections.singletonMap("gender", "f"); OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); - Map decisions = user.decideAllSync(); + Map decisions = user.decideAllSync(Collections.emptyList()); assertEquals(decisions.size(), 3); @@ -2489,7 +2489,7 @@ public void decideSync_sdkNotReady() { Optimizely optimizely = new Optimizely.Builder().build(); OptimizelyUserContext user = optimizely.createUserContext(userId); - OptimizelyDecision decision = user.decideSync(flagKey); + OptimizelyDecision decision = user.decideSync(flagKey, Collections.emptyList()); assertNull(decision.getVariationKey()); assertFalse(decision.getEnabled()); @@ -2507,7 +2507,7 @@ public void decideForKeysSync_sdkNotReady() { Optimizely optimizely = new Optimizely.Builder().build(); OptimizelyUserContext user = optimizely.createUserContext(userId); - Map decisions = user.decideForKeysSync(flagKeys); + Map decisions = user.decideForKeysSync(flagKeys, Collections.emptyList()); assertEquals(decisions.size(), 0); } @@ -2529,7 +2529,7 @@ public void decideSync_bypassUPS() throws Exception { .build(); OptimizelyUserContext user = optimizely.createUserContext(userId); - OptimizelyDecision decision = user.decideSync(flagKey); + OptimizelyDecision decision = user.decideSync(flagKey, Collections.emptyList()); // should return variationId2 set by UPS assertEquals(decision.getVariationKey(), variationKey2); @@ -2562,7 +2562,7 @@ public void decideAsync_featureTest() throws InterruptedException { user.decideAsync(flagKey, decision -> { result[0] = decision; latch.countDown(); - }); + }, null); assertTrue(latch.await(1, java.util.concurrent.TimeUnit.SECONDS)); OptimizelyDecision decision = result[0]; @@ -2598,7 +2598,7 @@ public void decideAsync_sdkNotReady() throws InterruptedException { user.decideAsync(flagKey, decision -> { result[0] = decision; latch.countDown(); - }); + }, Collections.emptyList()); assertTrue(latch.await(1, java.util.concurrent.TimeUnit.SECONDS)); OptimizelyDecision decision = result[0]; @@ -2627,7 +2627,7 @@ public void decideForKeysAsync_multipleFlags() throws InterruptedException { user.decideForKeysAsync(flagKeys, decisions -> { result[0] = decisions; latch.countDown(); - }); + }, Collections.emptyList()); assertTrue(latch.await(1, java.util.concurrent.TimeUnit.SECONDS)); Map decisions = result[0]; @@ -2700,7 +2700,7 @@ public void decideForKeysAsync_sdkNotReady() throws InterruptedException { user.decideForKeysAsync(flagKeys, decisions -> { result[0] = decisions; latch.countDown(); - }); + },Collections.emptyList()); assertTrue(latch.await(1, java.util.concurrent.TimeUnit.SECONDS)); Map decisions = result[0]; @@ -2719,7 +2719,7 @@ public void decideAllAsync_callback_exception() throws InterruptedException { callbackExecuted[0] = true; latch.countDown(); throw new RuntimeException("Test exception in callback"); - }); + }, Collections.emptyList()); assertTrue(latch.await(1, java.util.concurrent.TimeUnit.SECONDS)); assertTrue(callbackExecuted[0]); From 7363a2fd0ad7d1fc61843a1837ea7005774df1bc Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 15 Oct 2025 19:10:28 +0600 Subject: [PATCH 33/49] update: replace null with empty list parameter in async decision method for consistency --- .../test/java/com/optimizely/ab/OptimizelyUserContextTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 fa1dcd59d..834bd85b4 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java @@ -2562,7 +2562,7 @@ public void decideAsync_featureTest() throws InterruptedException { user.decideAsync(flagKey, decision -> { result[0] = decision; latch.countDown(); - }, null); + }, Collections.emptyList()); assertTrue(latch.await(1, java.util.concurrent.TimeUnit.SECONDS)); OptimizelyDecision decision = result[0]; From 1c52366f2874b6168a5eccfdb6e36c0ee45b0ed5 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 15 Oct 2025 20:00:14 +0600 Subject: [PATCH 34/49] update: add useCmab parameter to decideForKeys methods for enhanced decision handling --- .../java/com/optimizely/ab/Optimizely.java | 80 ++------- .../com/optimizely/ab/OptimizelyTest.java | 155 ++++++++++++++---- 2 files changed, 132 insertions(+), 103 deletions(-) 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 1b9ff03bc..6b439f3ff 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -1408,7 +1408,8 @@ Map decideForKeys(@Nonnull OptimizelyUserContext use private Map decideForKeys(@Nonnull OptimizelyUserContext user, @Nonnull List keys, @Nonnull List options, - boolean ignoreDefaultOptions) { + boolean ignoreDefaultOptions, + boolean useCmab) { Map decisionMap = new HashMap<>(); ProjectConfig projectConfig = getProjectConfig(); @@ -1453,7 +1454,7 @@ private Map decideForKeys(@Nonnull OptimizelyUserCon } List> decisionList = - decisionService.getVariationsForFeatureList(flagsWithoutForcedDecision, user, projectConfig, allOptions); + decisionService.getVariationsForFeatureList(flagsWithoutForcedDecision, user, projectConfig, allOptions, useCmab); for (int i = 0; i < flagsWithoutForcedDecision.size(); i++) { DecisionResponse decision = decisionList.get(i); @@ -1492,6 +1493,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, true); + } + Map decideAll(@Nonnull OptimizelyUserContext user, @Nonnull List options) { Map decisionMap = new HashMap<>(); @@ -1577,73 +1585,7 @@ private Map decideForKeysSync(@Nonnull OptimizelyUse @Nonnull List keys, @Nonnull List options, boolean ignoreDefaultOptions) { - Map decisionMap = new HashMap<>(); - - ProjectConfig projectConfig = getProjectConfig(); - if (projectConfig == null) { - logger.error("Optimizely instance is not valid, failing decideForKeysSync call."); - return decisionMap; - } - - if (keys.isEmpty()) return decisionMap; - - List allOptions = ignoreDefaultOptions ? options : getAllOptions(options); - - Map flagDecisions = new HashMap<>(); - Map decisionReasonsMap = new HashMap<>(); - - List flagsWithoutForcedDecision = new ArrayList<>(); - - List validKeys = new ArrayList<>(); - - for (String key : keys) { - FeatureFlag flag = projectConfig.getFeatureKeyMapping().get(key); - if (flag == null) { - decisionMap.put(key, OptimizelyDecision.newErrorDecision(key, user, DecisionMessage.FLAG_KEY_INVALID.reason(key))); - continue; - } - - validKeys.add(key); - - DecisionReasons decisionReasons = DefaultDecisionReasons.newInstance(allOptions); - decisionReasonsMap.put(key, decisionReasons); - - OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(key, null); - DecisionResponse forcedDecisionVariation = decisionService.validatedForcedDecision(optimizelyDecisionContext, projectConfig, user); - decisionReasons.merge(forcedDecisionVariation.getReasons()); - if (forcedDecisionVariation.getResult() != null) { - flagDecisions.put(key, - new FeatureDecision(null, forcedDecisionVariation.getResult(), FeatureDecision.DecisionSource.FEATURE_TEST)); - } else { - flagsWithoutForcedDecision.add(flag); - } - } - - // Use DecisionService method that skips CMAB logic - List> decisionList = - decisionService.getVariationsForFeatureList(flagsWithoutForcedDecision, user, projectConfig, allOptions, false); - - for (int i = 0; i < flagsWithoutForcedDecision.size(); i++) { - DecisionResponse decision = decisionList.get(i); - String flagKey = flagsWithoutForcedDecision.get(i).getKey(); - flagDecisions.put(flagKey, decision.getResult()); - decisionReasonsMap.get(flagKey).merge(decision.getReasons()); - } - - for (String key : validKeys) { - FeatureDecision flagDecision = flagDecisions.get(key); - DecisionReasons decisionReasons = decisionReasonsMap.get((key)); - - OptimizelyDecision optimizelyDecision = createOptimizelyDecision( - user, key, flagDecision, decisionReasons, allOptions, projectConfig - ); - - if (!allOptions.contains(OptimizelyDecideOption.ENABLED_FLAGS_ONLY) || optimizelyDecision.getEnabled()) { - decisionMap.put(key, optimizelyDecision); - } - } - - return decisionMap; + return decideForKeys(user, keys, options, ignoreDefaultOptions, false); } 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 1f0b35b5e..58fd19eed 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -15,7 +15,49 @@ ***************************************************************************/ 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.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; @@ -23,17 +65,89 @@ import com.optimizely.ab.bucketing.Bucketer; 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; @@ -42,38 +156,10 @@ 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. @@ -5030,7 +5116,8 @@ public void testDecideReturnsErrorDecisionWhenDecisionServiceFails() throws Exce any(List.class), any(OptimizelyUserContext.class), any(ProjectConfig.class), - any(List.class) + any(List.class), + eq(true) )).thenReturn(Arrays.asList(errorDecisionResponse)); From b2dcf9eaaefd18c063053237a7459b3bc70037c4 Mon Sep 17 00:00:00 2001 From: Farhan Anjum Date: Fri, 17 Oct 2025 22:06:57 +0600 Subject: [PATCH 35/49] Update core-api/src/main/java/com/optimizely/ab/Optimizely.java Co-authored-by: Jae Kim <45045038+jaeopt@users.noreply.github.com> --- core-api/src/main/java/com/optimizely/ab/Optimizely.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 6b439f3ff..44d2c9ab0 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -1558,7 +1558,7 @@ Map decideForKeysSync(@Nonnull OptimizelyUserContext /** * 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 use non-blocking legacy ab-tests only (for backward compatibility with android-sdk) + * 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. From 89771bc6eea3762ebd1c7ff6d88125df232dc206 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Thu, 23 Oct 2025 20:54:29 +0600 Subject: [PATCH 36/49] update: refactor decision-making logic to use DecisionPath enum for clarity and maintainability --- .../java/com/optimizely/ab/Optimizely.java | 13 ++++---- .../optimizely/ab/bucketing/DecisionPath.java | 6 ++++ .../ab/bucketing/DecisionService.java | 30 +++++++++---------- .../com/optimizely/ab/OptimizelyTest.java | 3 +- .../ab/bucketing/DecisionServiceTest.java | 14 ++++----- 5 files changed, 35 insertions(+), 31 deletions(-) create mode 100644 core-api/src/main/java/com/optimizely/ab/bucketing/DecisionPath.java 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 44d2c9ab0..d732d7933 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,7 @@ 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.CmabServiceOptions; @@ -1409,7 +1406,7 @@ private Map decideForKeys(@Nonnull OptimizelyUserCon @Nonnull List keys, @Nonnull List options, boolean ignoreDefaultOptions, - boolean useCmab) { + DecisionPath decisionPath) { Map decisionMap = new HashMap<>(); ProjectConfig projectConfig = getProjectConfig(); @@ -1454,7 +1451,7 @@ private Map decideForKeys(@Nonnull OptimizelyUserCon } List> decisionList = - decisionService.getVariationsForFeatureList(flagsWithoutForcedDecision, user, projectConfig, allOptions, useCmab); + decisionService.getVariationsForFeatureList(flagsWithoutForcedDecision, user, projectConfig, allOptions, decisionPath); for (int i = 0; i < flagsWithoutForcedDecision.size(); i++) { DecisionResponse decision = decisionList.get(i); @@ -1497,7 +1494,7 @@ private Map decideForKeys(@Nonnull OptimizelyUserCon @Nonnull List keys, @Nonnull List options, boolean ignoreDefaultOptions) { - return decideForKeys(user, keys, options, ignoreDefaultOptions, true); + return decideForKeys(user, keys, options, ignoreDefaultOptions, DecisionPath.WITH_CMAB); } Map decideAll(@Nonnull OptimizelyUserContext user, @@ -1585,7 +1582,7 @@ private Map decideForKeysSync(@Nonnull OptimizelyUse @Nonnull List keys, @Nonnull List options, boolean ignoreDefaultOptions) { - return decideForKeys(user, keys, options, ignoreDefaultOptions, false); + return decideForKeys(user, keys, options, ignoreDefaultOptions, DecisionPath.WITHOUT_CMAB); } 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..b468d514f --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionPath.java @@ -0,0 +1,6 @@ +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 0281a5195..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 @@ -106,7 +106,7 @@ public DecisionService(@Nonnull Bucketer bucketer, * @param options An array of decision options * @param userProfileTracker tracker for reading and updating user profile of the user * @param reasons Decision reasons - * @param useCmab Boolean flag to determine if cmab service is to be used + * @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 @@ -116,7 +116,7 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment, @Nonnull List options, @Nullable UserProfileTracker userProfileTracker, @Nullable DecisionReasons reasons, - @Nonnull boolean useCmab) { + @Nonnull DecisionPath decisionPath) { if (reasons == null) { reasons = DefaultDecisionReasons.newInstance(); } @@ -158,7 +158,7 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment, if (decisionMeetAudience.getResult()) { String bucketingId = getBucketingId(user.getUserId(), user.getAttributes()); String cmabUUID = null; - if (useCmab && isCmabExperiment(experiment)) { + if (decisionPath == DecisionPath.WITH_CMAB && isCmabExperiment(experiment)) { DecisionResponse cmabDecision = getDecisionForCmabExperiment(projectConfig, experiment, user, bucketingId, options); reasons.merge(cmabDecision.getReasons()); @@ -202,7 +202,7 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment, * @param user The current OptimizelyUserContext * @param projectConfig The current projectConfig * @param options An array of decision options - * @param useCmab Boolean to check if cmab service is to be used. + * @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 @@ -210,7 +210,7 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment, @Nonnull OptimizelyUserContext user, @Nonnull ProjectConfig projectConfig, @Nonnull List options, - @Nonnull boolean useCmab) { + @Nonnull DecisionPath decisionPath) { DecisionReasons reasons = DefaultDecisionReasons.newInstance(); // fetch the user profile map from the user profile service @@ -222,7 +222,7 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment, userProfileTracker.loadUserProfile(reasons, errorHandler); } - DecisionResponse response = getVariation(experiment, user, projectConfig, options, userProfileTracker, reasons, useCmab); + DecisionResponse response = getVariation(experiment, user, projectConfig, options, userProfileTracker, reasons, decisionPath); if(userProfileService != null && !ignoreUPS) { userProfileTracker.saveUserProfile(errorHandler); @@ -234,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(), true); + return getVariation(experiment, user, projectConfig, Collections.emptyList(), DecisionPath.WITH_CMAB); } /** @@ -268,7 +268,7 @@ public List> getVariationsForFeatureList(@Non @Nonnull OptimizelyUserContext user, @Nonnull ProjectConfig projectConfig, @Nonnull List options) { - return getVariationsForFeatureList(featureFlags, user, projectConfig, options, true); + return getVariationsForFeatureList(featureFlags, user, projectConfig, options, DecisionPath.WITH_CMAB); } /** @@ -278,7 +278,7 @@ public List> getVariationsForFeatureList(@Non * @param user The current OptimizelyuserContext * @param projectConfig The current projectConfig * @param options An array of decision options - * @param useCmab Boolean field that determines whether to use cmab service + * @param decisionPath An enum of paths for decision-making logic * @return A {@link DecisionResponse} including a {@link FeatureDecision} and the decision reasons */ @Nonnull @@ -286,7 +286,7 @@ public List> getVariationsForFeatureList(@Non @Nonnull OptimizelyUserContext user, @Nonnull ProjectConfig projectConfig, @Nonnull List options, - @Nonnull boolean useCmab) { + @Nonnull DecisionPath decisionPath) { DecisionReasons upsReasons = DefaultDecisionReasons.newInstance(); boolean ignoreUPS = options.contains(OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE); @@ -315,7 +315,7 @@ public List> getVariationsForFeatureList(@Non } } - DecisionResponse decisionVariationResponse = getVariationFromExperiment(projectConfig, featureFlag, user, options, userProfileTracker, useCmab); + DecisionResponse decisionVariationResponse = getVariationFromExperiment(projectConfig, featureFlag, user, options, userProfileTracker, decisionPath); reasons.merge(decisionVariationResponse.getReasons()); FeatureDecision decision = decisionVariationResponse.getResult(); @@ -371,14 +371,14 @@ DecisionResponse getVariationFromExperiment(@Nonnull ProjectCon @Nonnull OptimizelyUserContext user, @Nonnull List options, @Nullable UserProfileTracker userProfileTracker, - @Nonnull boolean useCmab) { + @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, useCmab); + getVariationFromExperimentRule(projectConfig, featureFlag.getKey(), experiment, user, options, userProfileTracker, decisionPath); reasons.merge(decisionVariation.getReasons()); Variation variation = decisionVariation.getResult(); String cmabUUID = decisionVariation.getCmabUUID(); @@ -810,7 +810,7 @@ private DecisionResponse getVariationFromExperimentRule(@Nonnull Proj @Nonnull OptimizelyUserContext user, @Nonnull List options, @Nullable UserProfileTracker userProfileTracker, - @Nonnull boolean useCmab) { + @Nonnull DecisionPath decisionPath) { DecisionReasons reasons = DefaultDecisionReasons.newInstance(); String ruleKey = rule != null ? rule.getKey() : null; @@ -825,7 +825,7 @@ private DecisionResponse getVariationFromExperimentRule(@Nonnull Proj return new DecisionResponse(variation, reasons); } //regular decision - DecisionResponse decisionResponse = getVariation(rule, user, projectConfig, options, userProfileTracker, null, useCmab); + DecisionResponse decisionResponse = getVariation(rule, user, projectConfig, options, userProfileTracker, null, decisionPath); reasons.merge(decisionResponse.getReasons()); variation = decisionResponse.getResult(); 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 58fd19eed..eeedbdca7 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -63,6 +63,7 @@ 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.Attribute; @@ -5117,7 +5118,7 @@ public void testDecideReturnsErrorDecisionWhenDecisionServiceFails() throws Exce any(OptimizelyUserContext.class), any(ProjectConfig.class), any(List.class), - eq(true) + eq(DecisionPath.WITH_CMAB) )).thenReturn(Arrays.asList(errorDecisionResponse)); 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 fdd7f6cac..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 @@ -361,7 +361,7 @@ public void getVariationForFeatureReturnsNullWhenItGetsNoVariationsForExperiment anyObject(), anyObject(), any(DecisionReasons.class), - anyBoolean() + any(DecisionPath.class) ); // do not bucket to any rollouts doReturn(DecisionResponse.responseNoReasons(new FeatureDecision(null, null, null))).when(decisionService).getVariationForFeatureInRollout( @@ -401,7 +401,7 @@ public void getVariationForFeatureReturnsVariationReturnedFromGetVariation() { any(OptimizelyUserContext.class), any(ProjectConfig.class), anyObject(), - anyBoolean() + any(DecisionPath.class) ); doReturn(DecisionResponse.responseNoReasons(ValidProjectConfigV4.VARIATION_MUTEX_GROUP_EXP_2_VAR_1)).when(decisionService).getVariation( @@ -409,7 +409,7 @@ public void getVariationForFeatureReturnsVariationReturnedFromGetVariation() { any(OptimizelyUserContext.class), any(ProjectConfig.class), anyObject(), - anyBoolean() + any(DecisionPath.class) ); FeatureDecision featureDecision = decisionService.getVariationForFeature( @@ -450,7 +450,7 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() anyObject(), anyObject(), any(DecisionReasons.class), - anyBoolean() + any(DecisionPath.class) ); // return variation for rollout @@ -485,7 +485,7 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() anyObject(), anyObject(), any(DecisionReasons.class), - anyBoolean() + any(DecisionPath.class) ); } @@ -513,7 +513,7 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails anyObject(), anyObject(), any(DecisionReasons.class), - anyBoolean() + any(DecisionPath.class) ); // return variation for rollout @@ -548,7 +548,7 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails anyObject(), anyObject(), any(DecisionReasons.class), - anyBoolean() + any(DecisionPath.class) ); logbackVerifier.expectMessage( From 73d5673fe6769c451f501e3f18ed48d7e20113c4 Mon Sep 17 00:00:00 2001 From: Farhan Anjum Date: Thu, 23 Oct 2025 21:00:23 +0600 Subject: [PATCH 37/49] Update core-api/src/main/java/com/optimizely/ab/Optimizely.java Co-authored-by: Jae Kim <45045038+jaeopt@users.noreply.github.com> --- core-api/src/main/java/com/optimizely/ab/Optimizely.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d732d7933..deda1260c 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -1517,7 +1517,7 @@ Map decideAll(@Nonnull OptimizelyUserContext user, /** * 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 use non-blocking legacy ab-tests only (for backward compatibility with android-sdk) + * 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. From 690379c530965ff0e4cf55ba21df8dd231797de8 Mon Sep 17 00:00:00 2001 From: Farhan Anjum Date: Thu, 23 Oct 2025 22:52:29 +0600 Subject: [PATCH 38/49] Update core-api/src/main/java/com/optimizely/ab/Optimizely.java Co-authored-by: Jae Kim <45045038+jaeopt@users.noreply.github.com> --- core-api/src/main/java/com/optimizely/ab/Optimizely.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 deda1260c..be07ecc44 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -1540,7 +1540,7 @@ OptimizelyDecision decideSync(@Nonnull OptimizelyUserContext user, /** * 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 use non-blocking legacy ab-tests only (for backward compatibility with android-sdk) + * 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. From 9c8dd8f987909d8769351821aec612c17d1810de Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Mon, 27 Oct 2025 16:50:57 +0600 Subject: [PATCH 39/49] update: modify OptimizelyUserContext to change optimizely field to package-private and add copyright notice to DecisionPath --- .../java/com/optimizely/ab/Optimizely.java | 2 +- .../optimizely/ab/OptimizelyUserContext.java | 38 +-- .../optimizely/ab/bucketing/DecisionPath.java | 15 + .../ab/OptimizelyUserContextTest.java | 269 ------------------ .../ab/internal/DefaultLRUCacheTest.java | 6 +- 5 files changed, 20 insertions(+), 310 deletions(-) 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 be07ecc44..f88e8f421 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -1949,7 +1949,7 @@ public Builder withODPManager(ODPManager odpManager) { public Builder withCmabClient(CmabClient cmabClient) { int DEFAULT_MAX_SIZE = 1000; - int DEFAULT_CMAB_CACHE_TIMEOUT = 30 * 60 * 1000; + int DEFAULT_CMAB_CACHE_TIMEOUT = 30 * 60; DefaultLRUCache cmabCache = new DefaultLRUCache<>(DEFAULT_MAX_SIZE, DEFAULT_CMAB_CACHE_TIMEOUT); CmabServiceOptions cmabServiceOptions = new CmabServiceOptions(logger, cmabCache, cmabClient); DefaultCmabService defaultCmabService = new DefaultCmabService(cmabServiceOptions); 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 da8c36f29..a5e004dfd 100644 --- a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java +++ b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java @@ -50,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); @@ -205,42 +205,6 @@ public Map decideAll() { return decideAll(Collections.emptyList()); } - /** - * Returns a decision result ({@link OptimizelyDecision}) for a given flag key and a user context, - * which contains all data required to deliver the flag. This method skips CMAB logic. - * backward compatibility support for android sync decisions - * @param key A flag key for which a decision will be made. - * @param options A list of options for decision-making. - * @return A decision result. - */ - public OptimizelyDecision decideSync(@Nonnull String key, - @Nonnull List options) { - return optimizely.decideSync(copy(), key, options); - } - - /** - * Returns a key-map of decision results ({@link OptimizelyDecision}) for multiple flag keys and a user context. - * This method skips CMAB logic. - * backward compatibility support for android sync decisions - * @param keys A list of flag keys for which decisions will be made. - * @param options A list of options for decision-making. - * @return All decision results mapped by flag keys. - */ - public Map decideForKeysSync(@Nonnull List keys, - @Nonnull List options) { - return optimizely.decideForKeysSync(copy(), keys, options); - } - - /** - * Returns a key-map of decision results ({@link OptimizelyDecision}) for all active flag keys. - * This method skips CMAB logic. - * backward compatibility support for android sync decisions - * @param options A list of options for decision-making. - * @return All decision results mapped by flag keys. - */ - public Map decideAllSync(@Nonnull List options) { - return optimizely.decideAllSync(copy(), options); - } /** * Returns a decision result asynchronously for a given flag key and a user context. 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 index b468d514f..42c80579d 100644 --- a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionPath.java +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionPath.java @@ -1,3 +1,18 @@ +/**************************************************************************** + * 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 { 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 834bd85b4..5a729aabd 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java @@ -2271,275 +2271,6 @@ public void decide_all_with_holdout() throws Exception { logbackVerifier.expectMessage(Level.INFO, expectedReason); } - @Test - public void decideSync_featureTest() { - optimizely = new Optimizely.Builder() - .withDatafile(datafile) - .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) - .build(); - - String flagKey = "feature_2"; - String experimentKey = "exp_no_audience"; - String variationKey = "variation_with_traffic"; - String experimentId = "10420810910"; - String variationId = "10418551353"; - OptimizelyJSON variablesExpected = optimizely.getAllFeatureVariables(flagKey, userId); - - OptimizelyUserContext user = optimizely.createUserContext(userId); - OptimizelyDecision decision = user.decideSync(flagKey, Collections.emptyList()); - - assertEquals(decision.getVariationKey(), variationKey); - assertTrue(decision.getEnabled()); - assertEquals(decision.getVariables().toMap(), variablesExpected.toMap()); - assertEquals(decision.getRuleKey(), experimentKey); - assertEquals(decision.getFlagKey(), flagKey); - assertEquals(decision.getUserContext(), user); - assertTrue(decision.getReasons().isEmpty()); - - DecisionMetadata metadata = new DecisionMetadata.Builder() - .setFlagKey(flagKey) - .setRuleKey(experimentKey) - .setRuleType(FeatureDecision.DecisionSource.FEATURE_TEST.toString()) - .setVariationKey(variationKey) - .setEnabled(true) - .build(); - eventHandler.expectImpression(experimentId, variationId, userId, Collections.emptyMap(), metadata); - } - - @Test - public void decideForKeysSync_multipleFlags() { - String flagKey1 = "feature_1"; - String flagKey2 = "feature_2"; - - List flagKeys = Arrays.asList(flagKey1, flagKey2); - OptimizelyJSON variablesExpected1 = optimizely.getAllFeatureVariables(flagKey1, userId); - OptimizelyJSON variablesExpected2 = optimizely.getAllFeatureVariables(flagKey2, userId); - - OptimizelyUserContext user = optimizely.createUserContext(userId, Collections.singletonMap("gender", "f")); - Map decisions = user.decideForKeysSync(flagKeys, Collections.emptyList()); - - assertEquals(decisions.size(), 2); - - assertEquals( - decisions.get(flagKey1), - new OptimizelyDecision("a", - true, - variablesExpected1, - "exp_with_audience", - flagKey1, - user, - Collections.emptyList())); - assertEquals( - decisions.get(flagKey2), - new OptimizelyDecision("variation_with_traffic", - true, - variablesExpected2, - "exp_no_audience", - flagKey2, - user, - Collections.emptyList())); - } - - @Test - public void decideForKeysSync_withOptions() { - String flagKey1 = "feature_1"; - String flagKey2 = "feature_2"; - - List flagKeys = Arrays.asList(flagKey1, flagKey2); - List options = Arrays.asList(OptimizelyDecideOption.EXCLUDE_VARIABLES); - - OptimizelyUserContext user = optimizely.createUserContext(userId, Collections.singletonMap("gender", "f")); - Map decisions = user.decideForKeysSync(flagKeys, options); - - assertEquals(decisions.size(), 2); - - // Both decisions should have empty variables due to EXCLUDE_VARIABLES option - OptimizelyDecision decision1 = decisions.get(flagKey1); - OptimizelyDecision decision2 = decisions.get(flagKey2); - - assertTrue(decision1.getVariables().toMap().isEmpty()); - assertTrue(decision2.getVariables().toMap().isEmpty()); - assertEquals(decision1.getVariationKey(), "a"); - assertEquals(decision2.getVariationKey(), "variation_with_traffic"); - } - - @Test - public void decideAllSync_allFlags() { - EventProcessor mockEventProcessor = mock(EventProcessor.class); - - optimizely = new Optimizely.Builder() - .withDatafile(datafile) - .withEventProcessor(mockEventProcessor) - .build(); - - String flagKey1 = "feature_1"; - String flagKey2 = "feature_2"; - String flagKey3 = "feature_3"; - Map attributes = Collections.singletonMap("gender", "f"); - - OptimizelyJSON variablesExpected1 = optimizely.getAllFeatureVariables(flagKey1, userId); - OptimizelyJSON variablesExpected2 = optimizely.getAllFeatureVariables(flagKey2, userId); - OptimizelyJSON variablesExpected3 = new OptimizelyJSON(Collections.emptyMap()); - - OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); - Map decisions = user.decideAllSync(Collections.emptyList()); - assertEquals(decisions.size(), 3); - - assertEquals( - decisions.get(flagKey1), - new OptimizelyDecision( - "a", - true, - variablesExpected1, - "exp_with_audience", - flagKey1, - user, - Collections.emptyList())); - assertEquals( - decisions.get(flagKey2), - new OptimizelyDecision( - "variation_with_traffic", - true, - variablesExpected2, - "exp_no_audience", - flagKey2, - user, - Collections.emptyList())); - assertEquals( - decisions.get(flagKey3), - new OptimizelyDecision( - null, - false, - variablesExpected3, - null, - flagKey3, - user, - Collections.emptyList())); - - ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(ImpressionEvent.class); - verify(mockEventProcessor, times(3)).process(argumentCaptor.capture()); - - List sentEvents = argumentCaptor.getAllValues(); - assertEquals(sentEvents.size(), 3); - - assertEquals(sentEvents.get(0).getExperimentKey(), "exp_with_audience"); - assertEquals(sentEvents.get(0).getVariationKey(), "a"); - assertEquals(sentEvents.get(0).getUserContext().getUserId(), userId); - - assertEquals(sentEvents.get(1).getExperimentKey(), "exp_no_audience"); - assertEquals(sentEvents.get(1).getVariationKey(), "variation_with_traffic"); - assertEquals(sentEvents.get(1).getUserContext().getUserId(), userId); - - assertEquals(sentEvents.get(2).getExperimentKey(), ""); - assertEquals(sentEvents.get(2).getUserContext().getUserId(), userId); - } - - @Test - public void decideAllSync_withOptions() { - String flagKey1 = "feature_1"; - OptimizelyJSON variablesExpected1 = optimizely.getAllFeatureVariables(flagKey1, userId); - - OptimizelyUserContext user = optimizely.createUserContext(userId, Collections.singletonMap("gender", "f")); - Map decisions = user.decideAllSync(Arrays.asList(OptimizelyDecideOption.ENABLED_FLAGS_ONLY)); - - assertEquals(decisions.size(), 2); // Only enabled flags - - assertEquals( - decisions.get(flagKey1), - new OptimizelyDecision( - "a", - true, - variablesExpected1, - "exp_with_audience", - flagKey1, - user, - Collections.emptyList())); - } - - @Test - public void decideAllSync_ups_batching() throws Exception { - UserProfileService ups = mock(UserProfileService.class); - - optimizely = new Optimizely.Builder() - .withDatafile(datafile) - .withUserProfileService(ups) - .build(); - - Map attributes = Collections.singletonMap("gender", "f"); - - OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); - Map decisions = user.decideAllSync(Collections.emptyList()); - - assertEquals(decisions.size(), 3); - - ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(Map.class); - - verify(ups, times(1)).lookup(userId); - verify(ups, times(1)).save(argumentCaptor.capture()); - - Map savedUps = argumentCaptor.getValue(); - UserProfile savedProfile = UserProfileUtils.convertMapToUserProfile(savedUps); - - assertEquals(savedProfile.userId, userId); - } - - @Test - public void decideSync_sdkNotReady() { - String flagKey = "feature_1"; - - Optimizely optimizely = new Optimizely.Builder().build(); - OptimizelyUserContext user = optimizely.createUserContext(userId); - OptimizelyDecision decision = user.decideSync(flagKey, Collections.emptyList()); - - assertNull(decision.getVariationKey()); - assertFalse(decision.getEnabled()); - assertTrue(decision.getVariables().isEmpty()); - assertEquals(decision.getFlagKey(), flagKey); - assertEquals(decision.getUserContext(), user); - - assertEquals(decision.getReasons().size(), 1); - assertEquals(decision.getReasons().get(0), DecisionMessage.SDK_NOT_READY.reason()); - } - - @Test - public void decideForKeysSync_sdkNotReady() { - List flagKeys = Arrays.asList("feature_1"); - - Optimizely optimizely = new Optimizely.Builder().build(); - OptimizelyUserContext user = optimizely.createUserContext(userId); - Map decisions = user.decideForKeysSync(flagKeys, Collections.emptyList()); - - assertEquals(decisions.size(), 0); - } - @Test - public void decideSync_bypassUPS() throws Exception { - String flagKey = "feature_2"; // embedding experiment: "exp_no_audience" - String experimentId = "10420810910"; // "exp_no_audience" - String variationId1 = "10418551353"; - String variationId2 = "10418510624"; - String variationKey1 = "variation_with_traffic"; - String variationKey2 = "variation_no_traffic"; - - UserProfileService ups = mock(UserProfileService.class); - when(ups.lookup(userId)).thenReturn(createUserProfileMap(experimentId, variationId2)); - - optimizely = new Optimizely.Builder() - .withDatafile(datafile) - .withUserProfileService(ups) - .build(); - - OptimizelyUserContext user = optimizely.createUserContext(userId); - OptimizelyDecision decision = user.decideSync(flagKey, Collections.emptyList()); - // should return variationId2 set by UPS - assertEquals(decision.getVariationKey(), variationKey2); - - decision = user.decideSync(flagKey, Arrays.asList(OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE)); - // should ignore variationId2 set by UPS and return variationId1 - assertEquals(decision.getVariationKey(), variationKey1); - // also should not save either - verify(ups, never()).save(anyObject()); - } - @Test public void decideAsync_featureTest() throws InterruptedException { optimizely = new Optimizely.Builder() 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 { From a17becd3a7aca98ff445709510e711e1e6ce9e3b Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Mon, 27 Oct 2025 17:48:33 +0600 Subject: [PATCH 40/49] update: implement asynchronous decision-making methods in Optimizely and OptimizelyUserContext with corresponding tests --- .../java/com/optimizely/ab/Optimizely.java | 74 +++++-- .../optimizely/ab/OptimizelyUserContext.java | 45 ----- .../com/optimizely/ab/OptimizelyTest.java | 100 ++++++++++ .../ab/OptimizelyUserContextTest.java | 185 ------------------ 4 files changed, 158 insertions(+), 246 deletions(-) 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 f88e8f421..8e78bb66e 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -64,12 +64,7 @@ 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; @@ -1525,8 +1520,8 @@ Map decideAll(@Nonnull OptimizelyUserContext user, * @return A decision result using traditional A/B testing logic only. */ OptimizelyDecision decideSync(@Nonnull OptimizelyUserContext user, - @Nonnull String key, - @Nonnull List options) { + @Nonnull String key, + @Nonnull List options) { ProjectConfig projectConfig = getProjectConfig(); if (projectConfig == null) { return OptimizelyDecision.newErrorDecision(key, user, DecisionMessage.SDK_NOT_READY.reason()); @@ -1541,28 +1536,28 @@ OptimizelyDecision decideSync(@Nonnull OptimizelyUserContext user, /** * 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) { + @Nonnull List keys, + @Nonnull List options) { return decideForKeysSync(user, keys, options, false); } /** * Returns decision results for all active flag keys, skipping CMAB logic and using only traditional A/B testing. * 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) { + @Nonnull List options) { Map decisionMap = new HashMap<>(); ProjectConfig projectConfig = getProjectConfig(); @@ -1579,12 +1574,59 @@ Map decideAllSync(@Nonnull OptimizelyUserContext use } private Map decideForKeysSync(@Nonnull OptimizelyUserContext user, - @Nonnull List keys, - @Nonnull List options, - boolean ignoreDefaultOptions) { + @Nonnull List keys, + @Nonnull List options, + boolean ignoreDefaultOptions) { return decideForKeys(user, keys, options, ignoreDefaultOptions, DecisionPath.WITHOUT_CMAB); } + //============ 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 + */ + public void decideAsync(@Nonnull OptimizelyUserContext userContext, + @Nonnull String key, + @Nonnull OptimizelyDecisionCallback callback, + @Nonnull List options) { + 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 + */ + public void decideForKeysAsync(@Nonnull OptimizelyUserContext userContext, + @Nonnull List keys, + @Nonnull OptimizelyDecisionsCallback callback, + @Nonnull List options) { + 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 + */ + public void decideAllAsync(@Nonnull OptimizelyUserContext userContext, + @Nonnull OptimizelyDecisionsCallback callback, + @Nonnull List options) { + AsyncDecisionFetcher fetcher = new AsyncDecisionFetcher(userContext, options, callback); + fetcher.start(); + } private List getAllOptions(List options) { List copiedOptions = new ArrayList(defaultDecideOptions); 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 a5e004dfd..a36d7e77d 100644 --- a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java +++ b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java @@ -31,11 +31,8 @@ import com.optimizely.ab.odp.ODPSegmentCallback; import com.optimizely.ab.odp.ODPSegmentOption; -import com.optimizely.ab.optimizelydecision.AsyncDecisionFetcher; import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; import com.optimizely.ab.optimizelydecision.OptimizelyDecision; -import com.optimizely.ab.optimizelydecision.OptimizelyDecisionCallback; -import com.optimizely.ab.optimizelydecision.OptimizelyDecisionsCallback; public class OptimizelyUserContext { // OptimizelyForcedDecisionsKey mapped to variationKeys @@ -205,48 +202,6 @@ public Map decideAll() { return decideAll(Collections.emptyList()); } - - /** - * Returns a decision result asynchronously for a given flag key and a user context. - * support for android async decisions - * @param key A flag key for which a decision will be made. - * @param callback A callback to invoke when the decision is available. - * @param options A list of options for decision-making. - */ - public void decideAsync(@Nonnull String key, - @Nonnull OptimizelyDecisionCallback callback, - @Nonnull List options) { - AsyncDecisionFetcher fetcher = new AsyncDecisionFetcher(this, key, options, callback); - fetcher.start(); - } - - - /** - * Returns decision results asynchronously for multiple flag keys. - * support for android async decisions - * @param keys A list of flag keys for which decisions will be made. - * @param callback A callback to invoke when decisions are available. - * @param options A list of options for decision-making. - */ - public void decideForKeysAsync(@Nonnull List keys, - @Nonnull OptimizelyDecisionsCallback callback, - @Nonnull List options) { - AsyncDecisionFetcher fetcher = new AsyncDecisionFetcher(this, keys, options, callback); - fetcher.start(); - } - - /** - * Returns decision results asynchronously for all active flag keys. - * support for android async decisions - * @param callback A callback to invoke when decisions are available. - * @param options A list of options for decision-making. - */ - public void decideAllAsync(@Nonnull OptimizelyDecisionsCallback callback, - @Nonnull List options) { - AsyncDecisionFetcher fetcher = new AsyncDecisionFetcher(this, options, callback); - fetcher.start(); - } - /** * Track an event. * 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 eeedbdca7..f76510da9 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -23,6 +23,9 @@ 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; @@ -5135,4 +5138,101 @@ public void testDecideReturnsErrorDecisionWhenDecisionServiceFails() throws Exce 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, (OptimizelyDecision decision) -> { + try { + decisionRef.set(decision); + } catch (Throwable t) { + errorRef.set(t); + } finally { + latch.countDown(); + } + }, + Collections.emptyList() + ); + + 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, (Map decisions) -> { + decisionsRef.set(decisions); + latch.countDown(); + }, + Collections.emptyList() + ); + + 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, (Map decisions) -> { + decisionsRef.set(decisions); + latch.countDown(); + }, + Collections.emptyList() + ); + + 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 5a729aabd..bdfd6cdc1 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java @@ -2270,189 +2270,4 @@ public void decide_all_with_holdout() throws Exception { assertEquals("Expected exactly the included flags to be in holdout", includedInHoldout.size(), holdoutCount); logbackVerifier.expectMessage(Level.INFO, expectedReason); } - - @Test - public void decideAsync_featureTest() throws InterruptedException { - optimizely = new Optimizely.Builder() - .withDatafile(datafile) - .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) - .build(); - - String flagKey = "feature_2"; - String experimentKey = "exp_no_audience"; - String variationKey = "variation_with_traffic"; - String experimentId = "10420810910"; - String variationId = "10418551353"; - OptimizelyJSON variablesExpected = optimizely.getAllFeatureVariables(flagKey, userId); - - OptimizelyUserContext user = optimizely.createUserContext(userId); - - CountDownLatch latch = new CountDownLatch(1); - final OptimizelyDecision[] result = new OptimizelyDecision[1]; - - user.decideAsync(flagKey, decision -> { - result[0] = decision; - latch.countDown(); - }, Collections.emptyList()); - - assertTrue(latch.await(1, java.util.concurrent.TimeUnit.SECONDS)); - OptimizelyDecision decision = result[0]; - - assertEquals(decision.getVariationKey(), variationKey); - assertTrue(decision.getEnabled()); - assertEquals(decision.getVariables().toMap(), variablesExpected.toMap()); - assertEquals(decision.getRuleKey(), experimentKey); - assertEquals(decision.getFlagKey(), flagKey); - assertEquals(decision.getUserContext(), user); - assertTrue(decision.getReasons().isEmpty()); - - DecisionMetadata metadata = new DecisionMetadata.Builder() - .setFlagKey(flagKey) - .setRuleKey(experimentKey) - .setRuleType(FeatureDecision.DecisionSource.FEATURE_TEST.toString()) - .setVariationKey(variationKey) - .setEnabled(true) - .build(); - eventHandler.expectImpression(experimentId, variationId, userId, Collections.emptyMap(), metadata); - } - - @Test - public void decideAsync_sdkNotReady() throws InterruptedException { - String flagKey = "feature_1"; - - Optimizely optimizely = new Optimizely.Builder().build(); - OptimizelyUserContext user = optimizely.createUserContext(userId); - - CountDownLatch latch = new CountDownLatch(1); - final OptimizelyDecision[] result = new OptimizelyDecision[1]; - - user.decideAsync(flagKey, decision -> { - result[0] = decision; - latch.countDown(); - }, Collections.emptyList()); - - assertTrue(latch.await(1, java.util.concurrent.TimeUnit.SECONDS)); - OptimizelyDecision decision = result[0]; - - assertNull(decision.getVariationKey()); - assertFalse(decision.getEnabled()); - assertTrue(decision.getVariables().isEmpty()); - assertEquals(decision.getFlagKey(), flagKey); - assertEquals(decision.getUserContext(), user); - } - - @Test - public void decideForKeysAsync_multipleFlags() throws InterruptedException { - String flagKey1 = "feature_1"; - String flagKey2 = "feature_2"; - - List flagKeys = Arrays.asList(flagKey1, flagKey2); - OptimizelyJSON variablesExpected1 = optimizely.getAllFeatureVariables(flagKey1, userId); - OptimizelyJSON variablesExpected2 = optimizely.getAllFeatureVariables(flagKey2, userId); - - OptimizelyUserContext user = optimizely.createUserContext(userId, Collections.singletonMap("gender", "f")); - - CountDownLatch latch = new CountDownLatch(1); - final Map[] result = new Map[1]; - - user.decideForKeysAsync(flagKeys, decisions -> { - result[0] = decisions; - latch.countDown(); - }, Collections.emptyList()); - - assertTrue(latch.await(1, java.util.concurrent.TimeUnit.SECONDS)); - Map decisions = result[0]; - - assertEquals(decisions.size(), 2); - - assertEquals( - decisions.get(flagKey1), - new OptimizelyDecision("a", - true, - variablesExpected1, - "exp_with_audience", - flagKey1, - user, - Collections.emptyList())); - assertEquals( - decisions.get(flagKey2), - new OptimizelyDecision("variation_with_traffic", - true, - variablesExpected2, - "exp_no_audience", - flagKey2, - user, - Collections.emptyList())); - } - - @Test - public void decideForKeysAsync_withOptions() throws InterruptedException { - String flagKey1 = "feature_1"; - String flagKey2 = "feature_2"; - - List flagKeys = Arrays.asList(flagKey1, flagKey2); - List options = Arrays.asList(OptimizelyDecideOption.EXCLUDE_VARIABLES); - - OptimizelyUserContext user = optimizely.createUserContext(userId, Collections.singletonMap("gender", "f")); - - CountDownLatch latch = new CountDownLatch(1); - final Map[] result = new Map[1]; - - user.decideForKeysAsync(flagKeys, decisions -> { - result[0] = decisions; - latch.countDown(); - }, options); - - assertTrue(latch.await(1, java.util.concurrent.TimeUnit.SECONDS)); - Map decisions = result[0]; - - assertEquals(decisions.size(), 2); - - // Both decisions should have empty variables due to EXCLUDE_VARIABLES option - OptimizelyDecision decision1 = decisions.get(flagKey1); - OptimizelyDecision decision2 = decisions.get(flagKey2); - - assertTrue(decision1.getVariables().toMap().isEmpty()); - assertTrue(decision2.getVariables().toMap().isEmpty()); - assertEquals(decision1.getVariationKey(), "a"); - assertEquals(decision2.getVariationKey(), "variation_with_traffic"); - } - - @Test - public void decideForKeysAsync_sdkNotReady() throws InterruptedException { - List flagKeys = Arrays.asList("feature_1"); - - Optimizely optimizely = new Optimizely.Builder().build(); - OptimizelyUserContext user = optimizely.createUserContext(userId); - - CountDownLatch latch = new CountDownLatch(1); - final Map[] result = new Map[1]; - - user.decideForKeysAsync(flagKeys, decisions -> { - result[0] = decisions; - latch.countDown(); - },Collections.emptyList()); - - assertTrue(latch.await(1, java.util.concurrent.TimeUnit.SECONDS)); - Map decisions = result[0]; - - assertEquals(decisions.size(), 0); - } - - @Test - public void decideAllAsync_callback_exception() throws InterruptedException { - OptimizelyUserContext user = optimizely.createUserContext(userId, Collections.singletonMap("gender", "f")); - - CountDownLatch latch = new CountDownLatch(1); - final boolean[] callbackExecuted = new boolean[1]; - - user.decideAllAsync(decisions -> { - callbackExecuted[0] = true; - latch.countDown(); - throw new RuntimeException("Test exception in callback"); - }, Collections.emptyList()); - - assertTrue(latch.await(1, java.util.concurrent.TimeUnit.SECONDS)); - assertTrue(callbackExecuted[0]); - } } From ba575ce1c1df4bed6786641939c1c3a04ac4fefc Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Mon, 27 Oct 2025 18:00:23 +0600 Subject: [PATCH 41/49] update: refactor DefaultCmabService to remove CmabServiceOptions dependency and adjust related tests --- .../java/com/optimizely/ab/Optimizely.java | 4 +- .../ab/cmab/service/CmabServiceOptions.java | 49 ------------------- .../ab/cmab/service/DefaultCmabService.java | 8 +-- .../ab/cmab/DefaultCmabServiceTest.java | 5 +- 4 files changed, 6 insertions(+), 60 deletions(-) delete mode 100644 core-api/src/main/java/com/optimizely/ab/cmab/service/CmabServiceOptions.java 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 8e78bb66e..d0d8b9b68 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -19,7 +19,6 @@ 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.CmabServiceOptions; import com.optimizely.ab.cmab.service.DefaultCmabService; import com.optimizely.ab.config.AtomicProjectConfigManager; import com.optimizely.ab.config.DatafileProjectConfig; @@ -1993,8 +1992,7 @@ public Builder withCmabClient(CmabClient cmabClient) { int DEFAULT_MAX_SIZE = 1000; int DEFAULT_CMAB_CACHE_TIMEOUT = 30 * 60; DefaultLRUCache cmabCache = new DefaultLRUCache<>(DEFAULT_MAX_SIZE, DEFAULT_CMAB_CACHE_TIMEOUT); - CmabServiceOptions cmabServiceOptions = new CmabServiceOptions(logger, cmabCache, cmabClient); - DefaultCmabService defaultCmabService = new DefaultCmabService(cmabServiceOptions); + DefaultCmabService defaultCmabService = new DefaultCmabService(cmabClient, cmabCache, logger); this.cmabService = defaultCmabService; return this; } 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..f1df07c88 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 @@ -38,10 +38,10 @@ public class DefaultCmabService implements CmabService { 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; } @Override 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"); From ad474c8a15bcd7a2d934477aabbcf77b1f6af7ed Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Mon, 27 Oct 2025 20:47:53 +0600 Subject: [PATCH 42/49] update: refactor DefaultCmabService to use a generic Cache interface and enhance builder methods for cache configuration --- .../java/com/optimizely/ab/Optimizely.java | 8 +- .../ab/cmab/client/RetryConfig.java | 6 +- .../ab/cmab/service/DefaultCmabService.java | 109 +++++++++++++++++- .../com/optimizely/ab/internal/Cache.java | 1 + .../com/optimizely/ab/OptimizelyFactory.java | 79 ++++++++++++- 5 files changed, 190 insertions(+), 13 deletions(-) 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 d0d8b9b68..e609b11d8 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -1988,12 +1988,8 @@ public Builder withODPManager(ODPManager odpManager) { return this; } - public Builder withCmabClient(CmabClient cmabClient) { - int DEFAULT_MAX_SIZE = 1000; - int DEFAULT_CMAB_CACHE_TIMEOUT = 30 * 60; - DefaultLRUCache cmabCache = new DefaultLRUCache<>(DEFAULT_MAX_SIZE, DEFAULT_CMAB_CACHE_TIMEOUT); - DefaultCmabService defaultCmabService = new DefaultCmabService(cmabClient, cmabCache, logger); - this.cmabService = defaultCmabService; + public Builder withCmabService(CmabService cmabService) { + this.cmabService = cmabService; return this; } 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 632b760af..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,12 +52,12 @@ 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); } /** @@ -66,7 +66,7 @@ public RetryConfig(int maxRetries) { * @return Retry config with default settings */ public static RetryConfig defaultConfig() { - return new RetryConfig(3); + return new RetryConfig(1); } /** 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 f1df07c88..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,16 +30,25 @@ 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(CmabClient cmabClient, DefaultLRUCache cmabCache, Logger logger) { + // 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; @@ -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-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java index 16bbb3c7a..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 @@ -24,12 +24,15 @@ 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; @@ -57,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} @@ -205,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. * @@ -373,13 +420,43 @@ public static Optimizely newDefaultInstance(ProjectConfigManager configManager, .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) - .withCmabClient(defaultCmabClient) + .withCmabService(defaultCmabService) .build(); } } From 9f7ea599c830f3d38a179a23227576cd4d342ef7 Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Thu, 30 Oct 2025 14:04:21 -0700 Subject: [PATCH 43/49] fix to support android-sdk --- .../java/com/optimizely/ab/Optimizely.java | 37 ++++++++-------- .../optimizely/ab/OptimizelyUserContext.java | 43 +++++++++++++++++++ .../ab/cmab/client/CmabClientHelper.java | 1 + .../com/optimizely/ab/OptimizelyTest.java | 32 ++++++++------ .../optimizely/ab/cmab/DefaultCmabClient.java | 4 +- 5 files changed, 83 insertions(+), 34 deletions(-) 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 e609b11d8..fec7324cf 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -1518,6 +1518,7 @@ Map decideAll(@Nonnull OptimizelyUserContext user, * @param options A list of options for decision-making. * @return A decision result using traditional A/B testing logic only. */ + @VisibleForTesting() OptimizelyDecision decideSync(@Nonnull OptimizelyUserContext user, @Nonnull String key, @Nonnull List options) { @@ -1547,6 +1548,13 @@ Map decideForKeysSync(@Nonnull OptimizelyUserContext 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) @@ -1572,13 +1580,6 @@ Map decideAllSync(@Nonnull OptimizelyUserContext use return decideForKeysSync(user, allFlagKeys, options); } - private Map decideForKeysSync(@Nonnull OptimizelyUserContext user, - @Nonnull List keys, - @Nonnull List options, - boolean ignoreDefaultOptions) { - return decideForKeys(user, keys, options, ignoreDefaultOptions, DecisionPath.WITHOUT_CMAB); - } - //============ decide async ============// /** @@ -1589,10 +1590,10 @@ private Map decideForKeysSync(@Nonnull OptimizelyUse * @param callback A callback to invoke when the decision is available * @param options A list of options for decision-making */ - public void decideAsync(@Nonnull OptimizelyUserContext userContext, - @Nonnull String key, - @Nonnull OptimizelyDecisionCallback callback, - @Nonnull List options) { + void decideAsync(@Nonnull OptimizelyUserContext userContext, + @Nonnull String key, + @Nonnull List options, + @Nonnull OptimizelyDecisionCallback callback) { AsyncDecisionFetcher fetcher = new AsyncDecisionFetcher(userContext, key, options, callback); fetcher.start(); } @@ -1605,10 +1606,10 @@ public void decideAsync(@Nonnull OptimizelyUserContext userContext, * @param callback A callback to invoke when decisions are available * @param options A list of options for decision-making */ - public void decideForKeysAsync(@Nonnull OptimizelyUserContext userContext, - @Nonnull List keys, - @Nonnull OptimizelyDecisionsCallback callback, - @Nonnull List options) { + void decideForKeysAsync(@Nonnull OptimizelyUserContext userContext, + @Nonnull List keys, + @Nonnull List options, + @Nonnull OptimizelyDecisionsCallback callback) { AsyncDecisionFetcher fetcher = new AsyncDecisionFetcher(userContext, keys, options, callback); fetcher.start(); } @@ -1620,9 +1621,9 @@ public void decideForKeysAsync(@Nonnull OptimizelyUserContext userContext, * @param callback A callback to invoke when decisions are available * @param options A list of options for decision-making */ - public void decideAllAsync(@Nonnull OptimizelyUserContext userContext, - @Nonnull OptimizelyDecisionsCallback callback, - @Nonnull List options) { + void decideAllAsync(@Nonnull OptimizelyUserContext userContext, + @Nonnull List options, + @Nonnull OptimizelyDecisionsCallback callback) { AsyncDecisionFetcher fetcher = new AsyncDecisionFetcher(userContext, options, callback); fetcher.start(); } 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 a36d7e77d..19c8b999f 100644 --- a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java +++ b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java @@ -26,6 +26,9 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; +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; @@ -395,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/cmab/client/CmabClientHelper.java b/core-api/src/main/java/com/optimizely/ab/cmab/client/CmabClientHelper.java index f208a50b3..f534abb90 100644 --- 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 @@ -20,6 +20,7 @@ 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,}]+)\"?"); 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 f76510da9..3b066df21 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -5152,8 +5152,11 @@ public void decideAsyncReturnsDecision() throws Exception { final AtomicReference decisionRef = new AtomicReference<>(); final AtomicReference errorRef = new AtomicReference<>(); - optimizely.decideAsync(userContext, - FEATURE_MULTI_VARIATE_FEATURE_KEY, (OptimizelyDecision decision) -> { + optimizely.decideAsync( + userContext, + FEATURE_MULTI_VARIATE_FEATURE_KEY, + Collections.emptyList(), + (OptimizelyDecision decision) -> { try { decisionRef.set(decision); } catch (Throwable t) { @@ -5161,8 +5164,7 @@ public void decideAsyncReturnsDecision() throws Exception { } finally { latch.countDown(); } - }, - Collections.emptyList() + } ); boolean completed = latch.await(5, TimeUnit.SECONDS); @@ -5196,12 +5198,14 @@ public void decideForKeysAsyncReturnsDecisions() throws Exception { final CountDownLatch latch = new CountDownLatch(1); final AtomicReference> decisionsRef = new AtomicReference<>(); - optimizely.decideForKeysAsync(userContext, - flagKeys, (Map decisions) -> { + optimizely.decideForKeysAsync( + userContext, + flagKeys, + Collections.emptyList(), + (Map decisions) -> { decisionsRef.set(decisions); latch.countDown(); - }, - Collections.emptyList() + } ); assertTrue("Callback should be called within timeout", latch.await(5, TimeUnit.SECONDS)); @@ -5224,11 +5228,13 @@ public void decideAllAsyncReturnsAllDecisions() throws Exception { final CountDownLatch latch = new CountDownLatch(1); final AtomicReference> decisionsRef = new AtomicReference<>(); - optimizely.decideAllAsync(userContext, (Map decisions) -> { - decisionsRef.set(decisions); - latch.countDown(); - }, - Collections.emptyList() + optimizely.decideAllAsync( + userContext, + Collections.emptyList(), + (Map decisions) -> { + decisionsRef.set(decisions); + latch.countDown(); + } ); assertTrue("Callback should be called within timeout", latch.await(5, TimeUnit.SECONDS)); 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 e5f259759..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 @@ -43,8 +43,6 @@ public class DefaultCmabClient implements CmabClient { private static final Logger logger = LoggerFactory.getLogger(DefaultCmabClient.class); private static final int DEFAULT_TIMEOUT_MS = 10000; - private static final String CMAB_PREDICTION_ENDPOINT = "https://prediction.cmab.optimizely.com/predict/%s"; - private final OptimizelyHttpClient httpClient; private final RetryConfig retryConfig; @@ -78,7 +76,7 @@ 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 url = String.format(CmabClientHelper.CMAB_PREDICTION_ENDPOINT, ruleId); String requestBody = CmabClientHelper.buildRequestJson(userId, ruleId, attributes, cmabUuid); // Use retry logic if configured, otherwise single request From 93cdaba1d31bd88c2da422ddafa4aae6ab6598a4 Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Thu, 30 Oct 2025 14:41:18 -0700 Subject: [PATCH 44/49] clean up --- core-api/src/main/java/com/optimizely/ab/Optimizely.java | 1 - 1 file changed, 1 deletion(-) 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 fec7324cf..08e7b67f0 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -1518,7 +1518,6 @@ Map decideAll(@Nonnull OptimizelyUserContext user, * @param options A list of options for decision-making. * @return A decision result using traditional A/B testing logic only. */ - @VisibleForTesting() OptimizelyDecision decideSync(@Nonnull OptimizelyUserContext user, @Nonnull String key, @Nonnull List options) { From e459321f2f124179d234ddb94c6dfca1b4a2be4b Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 5 Nov 2025 22:41:05 +0600 Subject: [PATCH 45/49] update: refactor bucketing logic to remove CMAB handling from DecisionService and adjust tests accordingly --- .../com/optimizely/ab/bucketing/Bucketer.java | 86 +------------------ .../ab/bucketing/DecisionService.java | 23 +---- .../ab/bucketing/DecisionServiceTest.java | 14 +-- 3 files changed, 17 insertions(+), 106 deletions(-) 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 916bf4d15..f627741b0 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,49 +128,6 @@ 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. * @@ -183,24 +140,6 @@ private DecisionResponse bucketToEntityForCmab(@Nonnull Experiment exper 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 ---------- @@ -233,26 +172,9 @@ public DecisionResponse bucket(@Nonnull ExperimentCore experiment, } } - 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); - } + DecisionResponse decisionResponse = bucketToVariation(experiment, bucketingId); + reasons.merge(decisionResponse.getReasons()); + return new DecisionResponse<>(decisionResponse.getResult(), reasons); } //======== Helper methods ========// @@ -270,4 +192,4 @@ int generateBucketValue(int hashCode) { return (int) Math.floor(MAX_TRAFFIC_VALUE * ratio); } -} +} \ No newline at end of file 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 a16ff3bf2..13efeadd8 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 @@ -158,7 +158,10 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment, if (decisionMeetAudience.getResult()) { String bucketingId = getBucketingId(user.getUserId(), user.getAttributes()); String cmabUUID = null; - if (decisionPath == DecisionPath.WITH_CMAB && isCmabExperiment(experiment)) { + decisionVariation = bucketer.bucket(experiment, bucketingId, projectConfig); + if (decisionPath == DecisionPath.WITH_CMAB && isCmabExperiment(experiment) && decisionVariation.getResult() != null) { + // group-allocation and traffic-allocation checking passed for cmab + // we need server decision overruling local bucketing for cmab DecisionResponse cmabDecision = getDecisionForCmabExperiment(projectConfig, experiment, user, bucketingId, options); reasons.merge(cmabDecision.getReasons()); @@ -174,7 +177,6 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment, } } else { // Standard bucketing for non-CMAB experiments - decisionVariation = bucketer.bucket(experiment, bucketingId, projectConfig); reasons.merge(decisionVariation.getReasons()); variation = decisionVariation.getResult(); } @@ -940,23 +942,6 @@ private DecisionResponse getDecisionForCmabExperiment(@Nonnull Pro @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); 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 2cb7e801c..ff451edbe 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 @@ -1535,9 +1535,13 @@ public void getVariationCmabExperimentServiceError() { mockCmab // This makes it a CMAB experiment ); - Bucketer bucketer = new Bucketer(); + // Bucketer bucketer = new Bucketer(); + Bucketer mockBucketer = mock(Bucketer.class); + Variation bucketedVariation = new Variation("$", "$"); + when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class))) + .thenReturn(DecisionResponse.responseNoReasons(bucketedVariation)); DecisionService decisionServiceWithMockCmabService = new DecisionService( - bucketer, + mockBucketer, mockErrorHandler, null, mockCmabService @@ -1599,7 +1603,7 @@ public void getVariationCmabExperimentServiceSuccess() { // 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))) + when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class))) .thenReturn(DecisionResponse.responseNoReasons(bucketedVariation)); DecisionService decisionServiceWithMockCmabService = new DecisionService( @@ -1670,7 +1674,7 @@ public void getVariationCmabExperimentUserNotInTrafficAllocation() { // 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))) + when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class))) .thenReturn(DecisionResponse.nullNoReasons()); DecisionService decisionServiceWithMockCmabService = new DecisionService( @@ -1697,7 +1701,7 @@ public void getVariationCmabExperimentUserNotInTrafficAllocation() { 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)); + verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); } private Experiment createMockCmabExperiment() { From 8c9622219daf9686a099fea4922b2fade089811c Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Thu, 6 Nov 2025 09:50:55 +0600 Subject: [PATCH 46/49] update: introduce CacheWithRemove interface and refactor DefaultCmabService to utilize it --- .../ab/cmab/service/DefaultCmabService.java | 12 +++++------ .../com/optimizely/ab/internal/Cache.java | 1 - .../ab/internal/CacheWithRemove.java | 21 +++++++++++++++++++ .../ab/internal/DefaultLRUCache.java | 8 ++++--- .../com/optimizely/ab/OptimizelyFactory.java | 6 +++--- 5 files changed, 35 insertions(+), 13 deletions(-) create mode 100644 core-api/src/main/java/com/optimizely/ab/internal/CacheWithRemove.java 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 a68f9c5d1..2246dab81 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 @@ -30,7 +30,7 @@ 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.CacheWithRemove; import com.optimizely.ab.internal.DefaultLRUCache; import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; @@ -38,7 +38,7 @@ public class DefaultCmabService implements CmabService { 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 CacheWithRemove cmabCache; private final CmabClient cmabClient; private final Logger logger; @@ -48,7 +48,7 @@ public class DefaultCmabService implements CmabService { // this.logger = logger; // } - public DefaultCmabService(CmabClient cmabClient, Cache cmabCache, Logger logger) { + public DefaultCmabService(CmabClient cmabClient, CacheWithRemove cmabCache, Logger logger) { this.cmabCache = cmabCache; this.cmabClient = cmabClient; this.logger = logger; @@ -200,7 +200,7 @@ public static Builder builder() { public static class Builder { private int cmabCacheSize = DEFAULT_CMAB_CACHE_SIZE; private int cmabCacheTimeoutInSecs = DEFAULT_CMAB_CACHE_TIMEOUT_SECS; - private Cache customCache; + private CacheWithRemove customCache; private CmabClient client; private Logger logger; @@ -251,7 +251,7 @@ public Builder withClient(CmabClient client) { * @param cache The custom cache instance implementing {@link Cache} * @return Builder instance */ - public Builder withCustomCache(Cache cache) { + public Builder withCustomCache(CacheWithRemove cache) { this.customCache = cache; return this; } @@ -278,7 +278,7 @@ public DefaultCmabService build() { logger = LoggerFactory.getLogger(DefaultCmabService.class); } - Cache cache = customCache != null ? customCache : + CacheWithRemove cache = customCache != null ? customCache : new DefaultLRUCache<>(cmabCacheSize, cmabCacheTimeoutInSecs); 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 d741e316b..ba667ebd2 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,5 +22,4 @@ 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/internal/CacheWithRemove.java b/core-api/src/main/java/com/optimizely/ab/internal/CacheWithRemove.java new file mode 100644 index 000000000..07b2b7deb --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/internal/CacheWithRemove.java @@ -0,0 +1,21 @@ +/** + * + * 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 + * + * 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.internal; + +public interface CacheWithRemove extends Cache { + void remove(String key); +} diff --git a/core-api/src/main/java/com/optimizely/ab/internal/DefaultLRUCache.java b/core-api/src/main/java/com/optimizely/ab/internal/DefaultLRUCache.java index 6d1fb4e50..294a94018 100644 --- a/core-api/src/main/java/com/optimizely/ab/internal/DefaultLRUCache.java +++ b/core-api/src/main/java/com/optimizely/ab/internal/DefaultLRUCache.java @@ -16,11 +16,13 @@ */ package com.optimizely.ab.internal; -import com.optimizely.ab.annotations.VisibleForTesting; - -import java.util.*; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.concurrent.locks.ReentrantLock; +import com.optimizely.ab.annotations.VisibleForTesting; + public class DefaultLRUCache implements Cache { private final ReentrantLock lock = new ReentrantLock(); 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 d507f93c9..c37418f8c 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 @@ -32,7 +32,7 @@ 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.CacheWithRemove; import com.optimizely.ab.internal.PropertyUtils; import com.optimizely.ab.notification.NotificationCenter; import com.optimizely.ab.odp.DefaultODPApiManager; @@ -60,7 +60,7 @@ public final class OptimizelyFactory { private static final Logger logger = LoggerFactory.getLogger(OptimizelyFactory.class); - private static Cache customCmabCache; + private static CacheWithRemove customCmabCache; /** * Convenience method for setting the maximum number of events contained within a batch. @@ -244,7 +244,7 @@ public static void setCmabCacheTimeoutInSecs(int timeoutInSecs) { * * @param cache The custom cache implementation */ - public static void setCustomCmabCache(Cache cache) { + public static void setCustomCmabCache(CacheWithRemove cache) { if (cache == null) { logger.warn("Custom CMAB cache cannot be null. Reverting to default configuration."); return; From 7c97734b054e50740ddbe8af3683790bc90e184d Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Thu, 6 Nov 2025 09:55:25 +0600 Subject: [PATCH 47/49] update: implement CacheWithRemove interface in DefaultLRUCache class --- .../main/java/com/optimizely/ab/internal/DefaultLRUCache.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core-api/src/main/java/com/optimizely/ab/internal/DefaultLRUCache.java b/core-api/src/main/java/com/optimizely/ab/internal/DefaultLRUCache.java index 294a94018..81508b200 100644 --- a/core-api/src/main/java/com/optimizely/ab/internal/DefaultLRUCache.java +++ b/core-api/src/main/java/com/optimizely/ab/internal/DefaultLRUCache.java @@ -23,7 +23,7 @@ import com.optimizely.ab.annotations.VisibleForTesting; -public class DefaultLRUCache implements Cache { +public class DefaultLRUCache implements CacheWithRemove { private final ReentrantLock lock = new ReentrantLock(); From 4feb8574584617bb3d7517d4049ef02a24b8e4bb Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Thu, 6 Nov 2025 10:04:38 +0600 Subject: [PATCH 48/49] update: refactor OptimizelyFactory to remove CMAB cache methods and adjust instance creation logic --- .../com/optimizely/ab/OptimizelyFactory.java | 98 ++++--------------- 1 file changed, 21 insertions(+), 77 deletions(-) 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 c37418f8c..97924c4b6 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 @@ -24,7 +24,6 @@ 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; @@ -32,7 +31,6 @@ import com.optimizely.ab.event.AsyncEventHandler; import com.optimizely.ab.event.BatchEventProcessor; import com.optimizely.ab.event.EventHandler; -import com.optimizely.ab.internal.CacheWithRemove; import com.optimizely.ab.internal.PropertyUtils; import com.optimizely.ab.notification.NotificationCenter; import com.optimizely.ab.odp.DefaultODPApiManager; @@ -60,8 +58,6 @@ public final class OptimizelyFactory { private static final Logger logger = LoggerFactory.getLogger(OptimizelyFactory.class); - private static CacheWithRemove customCmabCache; - /** * Convenience method for setting the maximum number of events contained within a batch. * {@link AsyncEventHandler} @@ -210,48 +206,6 @@ 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(CacheWithRemove 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. * @@ -406,6 +360,20 @@ public static Optimizely newDefaultInstance(ProjectConfigManager configManager, * @return A new Optimizely instance * */ public static Optimizely newDefaultInstance(ProjectConfigManager configManager, NotificationCenter notificationCenter, EventHandler eventHandler, ODPApiManager odpApiManager) { + return newDefaultInstance(configManager, notificationCenter, eventHandler, odpApiManager, null); + } + + /** + * Returns a new Optimizely instance based on preset configuration. + * + * @param configManager The {@link ProjectConfigManager} supplied to Optimizely instance. + * @param notificationCenter The {@link NotificationCenter} supplied to Optimizely instance. + * @param eventHandler The {@link EventHandler} supplied to Optimizely instance. + * @param odpApiManager The {@link ODPApiManager} supplied to Optimizely instance. + * @param cmabService The {@link DefaultCmabService} supplied to Optimizely instance. + * @return A new Optimizely instance + * */ + public static Optimizely newDefaultInstance(ProjectConfigManager configManager, NotificationCenter notificationCenter, EventHandler eventHandler, ODPApiManager odpApiManager, DefaultCmabService cmabService) { if (notificationCenter == null) { notificationCenter = new NotificationCenter(); } @@ -419,44 +387,20 @@ 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); - } + // If no cmabService provided, create default one + if (cmabService == null) { + DefaultCmabClient defaultCmabClient = new DefaultCmabClient(CmabClientConfig.withDefaultRetry()); + cmabService = DefaultCmabService.builder() + .withClient(defaultCmabClient) + .build(); } - - // 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) + .withCmabService(cmabService) .build(); } } From 4b35768784e20cff58f4cd52a0d24ad55107d3b1 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Thu, 6 Nov 2025 11:29:43 +0600 Subject: [PATCH 49/49] update: refactor DefaultCmabService to streamline logger initialization and enhance cache handling logging --- .../ab/cmab/service/DefaultCmabService.java | 58 ++++++------------- 1 file changed, 19 insertions(+), 39 deletions(-) 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 2246dab81..a43efccb3 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 @@ -42,16 +42,14 @@ public class DefaultCmabService implements CmabService { private final CmabClient cmabClient; private final Logger logger; - // public DefaultCmabService(CmabClient cmabClient, DefaultLRUCache cmabCache, Logger logger) { - // this.cmabCache = cmabCache; - // this.cmabClient = cmabClient; - // this.logger = logger; - // } + public DefaultCmabService(CmabClient cmabClient, CacheWithRemove cmabCache) { + this(cmabClient, cmabCache, null); + } public DefaultCmabService(CmabClient cmabClient, CacheWithRemove cmabCache, Logger logger) { this.cmabCache = cmabCache; this.cmabClient = cmabClient; - this.logger = logger; + this.logger = logger != null ? logger : LoggerFactory.getLogger(DefaultCmabService.class); } @Override @@ -61,15 +59,18 @@ public CmabDecision getDecision(ProjectConfig projectConfig, OptimizelyUserConte Map filteredAttributes = filterAttributes(projectConfig, userContext, ruleId); if (options.contains(OptimizelyDecideOption.IGNORE_CMAB_CACHE)) { + logger.debug("Ignoring CMAB cache for user '{}' and rule '{}'", userId, ruleId); return fetchDecision(ruleId, userId, filteredAttributes); } if (options.contains(OptimizelyDecideOption.RESET_CMAB_CACHE)) { + logger.debug("Resetting CMAB cache for user '{}' and rule '{}'", userId, ruleId); cmabCache.reset(); } String cacheKey = getCacheKey(userContext.getUserId(), ruleId); if (options.contains(OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE)) { + logger.debug("Invalidating CMAB cache for user '{}' and rule '{}'", userId, ruleId); cmabCache.remove(cacheKey); } @@ -79,13 +80,19 @@ public CmabDecision getDecision(ProjectConfig projectConfig, OptimizelyUserConte if (cachedValue != null) { if (cachedValue.getAttributesHash().equals(attributesHash)) { + logger.debug("CMAB cache hit for user '{}' and rule '{}'", userId, ruleId); return new CmabDecision(cachedValue.getVariationId(), cachedValue.getCmabUuid()); } else { + logger.debug("CMAB cache attributes mismatch for user '{}' and rule '{}', fetching new decision", userId, ruleId); cmabCache.remove(cacheKey); } + } else { + logger.debug("CMAB cache miss for user '{}' and rule '{}'", userId, ruleId); } CmabDecision cmabDecision = fetchDecision(ruleId, userId, filteredAttributes); + logger.debug("CMAB decision is {}", cmabDecision); + cmabCache.save(cacheKey, new CmabCacheValue(attributesHash, cmabDecision.getVariationId(), cmabDecision.getCmabUUID())); return cmabDecision; @@ -104,18 +111,13 @@ private Map filterAttributes(ProjectConfig projectConfig, Optimi // Get experiment by rule ID Experiment experiment = projectConfig.getExperimentIdMapping().get(ruleId); if (experiment == null) { - if (logger != null) { - logger.debug("Experiment not found for rule ID: {}", ruleId); - } + logger.debug("Experiment not found for rule ID: {}", ruleId); return filteredAttributes; } // Check if experiment has CMAB configuration - // Add null check for getCmab() if (experiment.getCmab() == null) { - if (logger != null) { - logger.debug("No CMAB configuration found for experiment: {}", ruleId); - } + logger.debug("No CMAB configuration found for experiment: {}", ruleId); return filteredAttributes; } @@ -125,11 +127,8 @@ private Map filterAttributes(ProjectConfig projectConfig, Optimi } Map attributeIdMapping = projectConfig.getAttributeIdMapping(); - // Add null check for attributeIdMapping if (attributeIdMapping == null) { - if (logger != null) { - logger.debug("No attribute mapping found in project config for rule ID: {}", ruleId); - } + logger.debug("No attribute mapping found in project config for rule ID: {}", ruleId); return filteredAttributes; } @@ -139,10 +138,10 @@ private Map filterAttributes(ProjectConfig projectConfig, Optimi if (attribute != null) { if (userAttributes.containsKey(attribute.getKey())) { filteredAttributes.put(attribute.getKey(), userAttributes.get(attribute.getKey())); - } else if (logger != null) { + } else { logger.debug("User attribute '{}' not found for attribute ID '{}'", attribute.getKey(), attributeId); } - } else if (logger != null) { + } else { logger.debug("Attribute configuration not found for ID: {}", attributeId); } } @@ -202,7 +201,6 @@ public static class Builder { private int cmabCacheTimeoutInSecs = DEFAULT_CMAB_CACHE_TIMEOUT_SECS; private CacheWithRemove customCache; private CmabClient client; - private Logger logger; /** * Set the maximum size of the CMAB cache. @@ -256,33 +254,15 @@ public Builder withCustomCache(CacheWithRemove 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); - } - CacheWithRemove cache = customCache != null ? customCache : new DefaultLRUCache<>(cmabCacheSize, cmabCacheTimeoutInSecs); - - return new DefaultCmabService(client, cache, logger); + return new DefaultCmabService(client, cache); } } }