3636import com .optimizely .ab .OptimizelyRuntimeException ;
3737import com .optimizely .ab .OptimizelyUserContext ;
3838import com .optimizely .ab .config .Experiment ;
39+ import com .optimizely .ab .config .ExperimentCore ;
3940import com .optimizely .ab .config .FeatureFlag ;
4041import com .optimizely .ab .config .Holdout ;
4142import 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 /**
0 commit comments