Skip to content

Commit 18746fc

Browse files
jaeoptclaude
andcommitted
[FSSDK-12369] Refactor: deduplicate forcedDecision/localHoldout logic in DecisionService
Move validatedForcedDecision and localHoldout checks from getVariationFromExperiment and getVariationForFeatureInRollout into getVariationFromExperimentRule and getVariationFromDeliveryRule, eliminating redundant evaluation. Extract shared local holdout logic into evaluateLocalHoldouts() and add unit tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d055117 commit 18746fc

2 files changed

Lines changed: 121 additions & 94 deletions

File tree

core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java

Lines changed: 64 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import com.optimizely.ab.OptimizelyRuntimeException;
3737
import com.optimizely.ab.OptimizelyUserContext;
3838
import com.optimizely.ab.config.Experiment;
39+
import com.optimizely.ab.config.ExperimentCore;
3940
import com.optimizely.ab.config.FeatureFlag;
4041
import com.optimizely.ab.config.Holdout;
4142
import com.optimizely.ab.config.ProjectConfig;
@@ -402,55 +403,12 @@ DecisionResponse<FeatureDecision> getVariationFromExperiment(@Nonnull ProjectCon
402403
for (String experimentId : featureFlag.getExperimentIds()) {
403404
Experiment experiment = projectConfig.getExperimentIdMapping().get(experimentId);
404405

405-
// Step 1: Check forced decision for this experiment rule first (highest priority).
406-
// We must do this before the local holdout check so forced decisions win.
407-
if (experiment != null) {
408-
String ruleKey = experiment.getKey();
409-
OptimizelyDecisionContext fdContext = new OptimizelyDecisionContext(flagKey, ruleKey);
410-
DecisionResponse<Variation> fdResponse = validatedForcedDecision(fdContext, projectConfig, user);
411-
reasons.merge(fdResponse.getReasons());
412-
if (fdResponse.getResult() != null) {
413-
return new DecisionResponse<>(
414-
new FeatureDecision(experiment, fdResponse.getResult(), FeatureDecision.DecisionSource.FEATURE_TEST),
415-
reasons);
416-
}
417-
418-
// Step 2: Check local holdouts targeting this experiment rule.
419-
// Local holdouts run after forced decisions but before regular rule evaluation.
420-
List<Holdout> localHoldouts = projectConfig.getHoldoutsForRule(experiment.getId());
421-
for (Holdout holdout : localHoldouts) {
422-
DecisionResponse<Variation> holdoutDecision = getVariationForHoldout(holdout, user, projectConfig);
423-
reasons.merge(holdoutDecision.getReasons());
424-
if (holdoutDecision.getResult() != null) {
425-
return new DecisionResponse<>(
426-
new FeatureDecision(holdout, holdoutDecision.getResult(), FeatureDecision.DecisionSource.HOLDOUT),
427-
reasons);
428-
}
429-
}
430-
}
431-
432-
// Step 3: Regular rule evaluation (getVariationFromExperimentRule also checks
433-
// forced decisions internally but it will find no forced decision since we already
434-
// checked above; the duplicate check is harmless).
435-
DecisionResponse<Variation> decisionVariation =
406+
DecisionResponse<FeatureDecision> decisionVariation =
436407
getVariationFromExperimentRule(projectConfig, flagKey, experiment, user, options, userProfileTracker, decisionPath);
437408
reasons.merge(decisionVariation.getReasons());
438-
Variation variation = decisionVariation.getResult();
439-
String cmabUuid = decisionVariation.getCmabUuid();
440-
boolean error = decisionVariation.isError();
441-
if (error) {
442-
return new DecisionResponse(
443-
new FeatureDecision(experiment, variation, FeatureDecision.DecisionSource.FEATURE_TEST, cmabUuid),
444-
reasons,
445-
decisionVariation.isError(),
446-
cmabUuid);
447-
}
448-
if (variation != null) {
449-
return new DecisionResponse(
450-
new FeatureDecision(experiment, variation, FeatureDecision.DecisionSource.FEATURE_TEST, cmabUuid),
451-
reasons,
452-
decisionVariation.isError(),
453-
cmabUuid);
409+
FeatureDecision featureDecision = decisionVariation.getResult();
410+
if (decisionVariation.isError() || (featureDecision != null && featureDecision.variation != null)) {
411+
return new DecisionResponse(featureDecision, reasons, decisionVariation.isError(), decisionVariation.getCmabUuid());
454412
}
455413
}
456414
} else {
@@ -503,31 +461,6 @@ DecisionResponse<FeatureDecision> getVariationForFeatureInRollout(@Nonnull Featu
503461
while (index < rolloutRulesLength) {
504462
Experiment rolloutRule = rollout.getExperiments().get(index);
505463

506-
// Step 1: Check forced decision for this delivery rule (highest priority).
507-
String rolloutRuleKey = rolloutRule.getKey();
508-
OptimizelyDecisionContext rolloutFdContext = new OptimizelyDecisionContext(featureFlag.getKey(), rolloutRuleKey);
509-
DecisionResponse<Variation> rolloutFdResponse = validatedForcedDecision(rolloutFdContext, projectConfig, user);
510-
reasons.merge(rolloutFdResponse.getReasons());
511-
if (rolloutFdResponse.getResult() != null) {
512-
FeatureDecision featureDecision = new FeatureDecision(rolloutRule, rolloutFdResponse.getResult(), FeatureDecision.DecisionSource.ROLLOUT);
513-
return new DecisionResponse<>(featureDecision, reasons);
514-
}
515-
516-
// Step 2: Check local holdouts targeting this delivery rule.
517-
// Local holdouts run after forced decisions but before regular delivery rule evaluation.
518-
List<Holdout> rolloutLocalHoldouts = projectConfig.getHoldoutsForRule(rolloutRule.getId());
519-
for (Holdout holdout : rolloutLocalHoldouts) {
520-
DecisionResponse<Variation> holdoutDecision = getVariationForHoldout(holdout, user, projectConfig);
521-
reasons.merge(holdoutDecision.getReasons());
522-
if (holdoutDecision.getResult() != null) {
523-
return new DecisionResponse<>(
524-
new FeatureDecision(holdout, holdoutDecision.getResult(), FeatureDecision.DecisionSource.HOLDOUT),
525-
reasons);
526-
}
527-
}
528-
529-
// Step 3: Regular delivery rule evaluation (getVariationFromDeliveryRule also checks
530-
// forced decisions internally; the duplicate check is harmless).
531464
DecisionResponse<AbstractMap.SimpleEntry> decisionVariationResponse = getVariationFromDeliveryRule(
532465
projectConfig,
533466
featureFlag.getKey(),
@@ -537,12 +470,10 @@ DecisionResponse<FeatureDecision> getVariationForFeatureInRollout(@Nonnull Featu
537470
);
538471
reasons.merge(decisionVariationResponse.getReasons());
539472

540-
AbstractMap.SimpleEntry<Variation, Boolean> response = decisionVariationResponse.getResult();
541-
Variation variation = response.getKey();
473+
AbstractMap.SimpleEntry<FeatureDecision, Boolean> response = decisionVariationResponse.getResult();
474+
FeatureDecision featureDecision = response.getKey();
542475
Boolean skipToEveryoneElse = response.getValue();
543-
if (variation != null) {
544-
Experiment rule = rollout.getExperiments().get(index);
545-
FeatureDecision featureDecision = new FeatureDecision(rule, variation, FeatureDecision.DecisionSource.ROLLOUT);
476+
if (featureDecision != null) {
546477
return new DecisionResponse(featureDecision, reasons);
547478
}
548479

@@ -773,6 +704,23 @@ public DecisionResponse<Variation> validatedForcedDecision(@Nonnull OptimizelyDe
773704
return new DecisionResponse<>(null, reasons);
774705
}
775706

707+
DecisionResponse<FeatureDecision> evaluateLocalHoldouts(@Nonnull ExperimentCore rule,
708+
@Nonnull ProjectConfig projectConfig,
709+
@Nonnull OptimizelyUserContext user) {
710+
DecisionReasons reasons = DefaultDecisionReasons.newInstance();
711+
List<Holdout> localHoldouts = projectConfig.getHoldoutsForRule(rule.getId());
712+
for (Holdout holdout : localHoldouts) {
713+
DecisionResponse<Variation> holdoutDecision = getVariationForHoldout(holdout, user, projectConfig);
714+
reasons.merge(holdoutDecision.getReasons());
715+
if (holdoutDecision.getResult() != null) {
716+
return new DecisionResponse<>(
717+
new FeatureDecision(holdout, holdoutDecision.getResult(), FeatureDecision.DecisionSource.HOLDOUT),
718+
reasons);
719+
}
720+
}
721+
return new DecisionResponse<>(null, reasons);
722+
}
723+
776724
public ConcurrentHashMap<String, ConcurrentHashMap<String, String>> getForcedVariationMapping() {
777725
return forcedVariationMapping;
778726
}
@@ -885,7 +833,7 @@ public DecisionResponse<Variation> getForcedVariation(@Nonnull Experiment experi
885833
}
886834

887835

888-
private DecisionResponse<Variation> getVariationFromExperimentRule(@Nonnull ProjectConfig projectConfig,
836+
private DecisionResponse<FeatureDecision> getVariationFromExperimentRule(@Nonnull ProjectConfig projectConfig,
889837
@Nonnull String flagKey,
890838
@Nonnull Experiment rule,
891839
@Nonnull OptimizelyUserContext user,
@@ -903,17 +851,29 @@ private DecisionResponse<Variation> getVariationFromExperimentRule(@Nonnull Proj
903851

904852
Variation variation = forcedDecisionResponse.getResult();
905853
if (variation != null) {
906-
return new DecisionResponse(variation, reasons);
854+
return new DecisionResponse(
855+
new FeatureDecision(rule, variation, FeatureDecision.DecisionSource.FEATURE_TEST),
856+
reasons);
907857
}
908858

909-
// Regular rule decision (local holdouts for experiment rules are checked by the caller
910-
// getVariationFromExperiment, where the FeatureDecision source can be set to HOLDOUT)
859+
// Step 2: Check local holdouts
860+
if (rule != null) {
861+
DecisionResponse<FeatureDecision> holdoutResponse = evaluateLocalHoldouts(rule, projectConfig, user);
862+
reasons.merge(holdoutResponse.getReasons());
863+
if (holdoutResponse.getResult() != null) {
864+
return new DecisionResponse<>(holdoutResponse.getResult(), reasons);
865+
}
866+
}
867+
868+
// Step 3: Regular rule decision
911869
DecisionResponse<Variation> decisionResponse = getVariation(rule, user, projectConfig, options, userProfileTracker, null, decisionPath);
912870
reasons.merge(decisionResponse.getReasons());
913871

914872
variation = decisionResponse.getResult();
915873

916-
return new DecisionResponse<>(variation, reasons, decisionResponse.isError(), decisionResponse.getCmabUuid());
874+
return new DecisionResponse<>(
875+
new FeatureDecision(rule, variation, FeatureDecision.DecisionSource.FEATURE_TEST, decisionResponse.getCmabUuid()),
876+
reasons, decisionResponse.isError(), decisionResponse.getCmabUuid());
917877
}
918878

919879
/**
@@ -933,8 +893,8 @@ private boolean validateUserId(String userId) {
933893
* @param rules The experiments belonging to a rollout
934894
* @param ruleIndex The index of the rule
935895
* @param user The OptimizelyUserContext
936-
* @return Returns a DecisionResponse Object containing a AbstractMap.SimpleEntry<Variation, Boolean>
937-
* where the Variation is the result and the Boolean is the skipToEveryoneElse.
896+
* @return Returns a DecisionResponse Object containing a AbstractMap.SimpleEntry<FeatureDecision, Boolean>
897+
* where the FeatureDecision is the result and the Boolean is the skipToEveryoneElse.
938898
*/
939899
DecisionResponse<AbstractMap.SimpleEntry> getVariationFromDeliveryRule(@Nonnull ProjectConfig projectConfig,
940900
@Nonnull String flagKey,
@@ -944,20 +904,30 @@ DecisionResponse<AbstractMap.SimpleEntry> getVariationFromDeliveryRule(@Nonnull
944904
DecisionReasons reasons = DefaultDecisionReasons.newInstance();
945905

946906
Boolean skipToEveryoneElse = false;
947-
AbstractMap.SimpleEntry<Variation, Boolean> variationToSkipToEveryoneElsePair;
948-
// Check forced-decisions first
907+
AbstractMap.SimpleEntry<FeatureDecision, Boolean> resultPair;
949908
Experiment rule = rules.get(ruleIndex);
909+
910+
// Step 1: Check Forced-Decision
950911
OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, rule.getKey());
951912
DecisionResponse<Variation> forcedDecisionResponse = validatedForcedDecision(optimizelyDecisionContext, projectConfig, user);
952913
reasons.merge(forcedDecisionResponse.getReasons());
953914

954915
Variation variation = forcedDecisionResponse.getResult();
955916
if (variation != null) {
956-
variationToSkipToEveryoneElsePair = new AbstractMap.SimpleEntry<>(variation, false);
957-
return new DecisionResponse(variationToSkipToEveryoneElsePair, reasons);
917+
resultPair = new AbstractMap.SimpleEntry<>(
918+
new FeatureDecision(rule, variation, FeatureDecision.DecisionSource.ROLLOUT), false);
919+
return new DecisionResponse(resultPair, reasons);
920+
}
921+
922+
// Step 2: Check local holdouts
923+
DecisionResponse<FeatureDecision> holdoutResponse = evaluateLocalHoldouts(rule, projectConfig, user);
924+
reasons.merge(holdoutResponse.getReasons());
925+
if (holdoutResponse.getResult() != null) {
926+
resultPair = new AbstractMap.SimpleEntry<>(holdoutResponse.getResult(), false);
927+
return new DecisionResponse(resultPair, reasons);
958928
}
959929

960-
// Handle a regular decision
930+
// Step 3: Regular rule decision
961931
String bucketingId = getBucketingId(user.getUserId(), user.getAttributes());
962932
Boolean everyoneElse = (ruleIndex == rules.size() - 1);
963933
String loggingKey = everyoneElse ? "Everyone Else" : String.valueOf(ruleIndex + 1);
@@ -999,8 +969,11 @@ DecisionResponse<AbstractMap.SimpleEntry> getVariationFromDeliveryRule(@Nonnull
999969
reasons.addInfo(message);
1000970
logger.debug(message);
1001971
}
1002-
variationToSkipToEveryoneElsePair = new AbstractMap.SimpleEntry<>(bucketedVariation, skipToEveryoneElse);
1003-
return new DecisionResponse(variationToSkipToEveryoneElsePair, reasons);
972+
FeatureDecision featureDecision = bucketedVariation != null
973+
? new FeatureDecision(rule, bucketedVariation, FeatureDecision.DecisionSource.ROLLOUT)
974+
: null;
975+
resultPair = new AbstractMap.SimpleEntry<>(featureDecision, skipToEveryoneElse);
976+
return new DecisionResponse(resultPair, reasons);
1004977
}
1005978

1006979
/**

core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -841,12 +841,12 @@ public void getVariationFromDeliveryRuleTest() {
841841
optimizely.createUserContext(genericUserId, Collections.singletonMap(ATTRIBUTE_NATIONALITY_KEY, AUDIENCE_ENGLISH_CITIZENS_VALUE))
842842
);
843843

844-
Variation variation = (Variation) decisionResponse.getResult().getKey();
844+
FeatureDecision featureDecision = (FeatureDecision) decisionResponse.getResult().getKey();
845845
Boolean skipToEveryoneElse = (Boolean) decisionResponse.getResult().getValue();
846846
assertNotNull(decisionResponse.getResult());
847-
assertNotNull(variation);
847+
assertNotNull(featureDecision);
848848
assertNotNull(expectedVariation);
849-
assertEquals(expectedVariation, variation);
849+
assertEquals(expectedVariation, featureDecision.variation);
850850
assertFalse(skipToEveryoneElse);
851851
}
852852

@@ -1747,6 +1747,60 @@ public void getVariationStandardExperimentSavesUserProfile() throws Exception {
17471747
}
17481748

17491749
// ===================================================================
1750+
//========= evaluateLocalHoldouts tests =========/
1751+
1752+
@Test
1753+
public void evaluateLocalHoldouts_returnsHoldoutDecisionWhenUserBucketed() {
1754+
ProjectConfig localHoldoutConfig = ValidProjectConfigV4.generateValidProjectConfigV4_localHoldout();
1755+
Experiment targetedRule = localHoldoutConfig.getExperimentIdMapping().get("1323241596");
1756+
1757+
Bucketer bucketer = new Bucketer();
1758+
DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, null, mockCmabService);
1759+
1760+
DecisionResponse<FeatureDecision> response = decisionService.evaluateLocalHoldouts(
1761+
targetedRule, localHoldoutConfig,
1762+
optimizely.createUserContext("any_user", Collections.<String, Object>emptyMap())
1763+
);
1764+
1765+
assertNotNull(response.getResult());
1766+
assertEquals(FeatureDecision.DecisionSource.HOLDOUT, response.getResult().decisionSource);
1767+
assertEquals(ValidProjectConfigV4.HOLDOUT_LOCAL_FOR_BASIC_EXPERIMENT, response.getResult().experiment);
1768+
assertEquals(VARIATION_HOLDOUT_VARIATION_OFF, response.getResult().variation);
1769+
}
1770+
1771+
@Test
1772+
public void evaluateLocalHoldouts_returnsNullWhenNoHoldoutsForRule() {
1773+
ProjectConfig localHoldoutConfig = ValidProjectConfigV4.generateValidProjectConfigV4_localHoldout();
1774+
// EXPERIMENT_MULTIVARIATE_EXPERIMENT is not targeted by any local holdout
1775+
Experiment untargetedRule = localHoldoutConfig.getExperimentIdMapping().get("3262035800");
1776+
1777+
Bucketer bucketer = new Bucketer();
1778+
DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, null, mockCmabService);
1779+
1780+
DecisionResponse<FeatureDecision> response = decisionService.evaluateLocalHoldouts(
1781+
untargetedRule, localHoldoutConfig,
1782+
optimizely.createUserContext("any_user", Collections.<String, Object>emptyMap())
1783+
);
1784+
1785+
assertNull(response.getResult());
1786+
}
1787+
1788+
@Test
1789+
public void evaluateLocalHoldouts_returnsNullWhenConfigHasNoHoldouts() {
1790+
ProjectConfig noHoldoutConfig = validProjectConfigV4();
1791+
Experiment rule = noHoldoutConfig.getExperimentIdMapping().get("1323241596");
1792+
1793+
Bucketer bucketer = new Bucketer();
1794+
DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, null, mockCmabService);
1795+
1796+
DecisionResponse<FeatureDecision> response = decisionService.evaluateLocalHoldouts(
1797+
rule, noHoldoutConfig,
1798+
optimizely.createUserContext("any_user", Collections.<String, Object>emptyMap())
1799+
);
1800+
1801+
assertNull(response.getResult());
1802+
}
1803+
17501804
// Local holdout decision service tests (FSSDK-12369)
17511805
// ===================================================================
17521806

0 commit comments

Comments
 (0)