Skip to content

Commit ff49b61

Browse files
committed
[FSSDK-12958] Continue rule evaluation after local holdout hit
A local holdout hit now excludes the user from that rule only. The hit is recorded, the rule is skipped, and evaluation continues with the next rule (experiment and delivery rules alike). The decide path sends one holdout impression per hit in the existing format, regardless of sendFlagDecisions. The final decision and its events are unchanged.
1 parent 2dc0ac1 commit ff49b61

4 files changed

Lines changed: 194 additions & 97 deletions

File tree

‎optimizely/decision_service.py‎

Lines changed: 49 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ class VariationResult(TypedDict):
6565

6666

6767
class _DecisionResultOptional(TypedDict, total=False):
68-
holdout_decision: Decision
68+
holdout_decisions: List[Decision]
6969

7070

7171
class DecisionResult(_DecisionResultOptional):
@@ -552,7 +552,8 @@ def get_variation(
552552
}
553553

554554
def get_variation_for_rollout(
555-
self, project_config: ProjectConfig, feature: entities.FeatureFlag, user_context: OptimizelyUserContext
555+
self, project_config: ProjectConfig, feature: entities.FeatureFlag, user_context: OptimizelyUserContext,
556+
holdout_decisions: Optional[list[Decision]] = None
556557
) -> tuple[Decision, list[str]]:
557558
""" Determine which experiment/variation the user is in for a given rollout.
558559
Returns the variation of the first experiment the user qualifies for.
@@ -563,6 +564,7 @@ def get_variation_for_rollout(
563564
rollout: Rollout for which we are getting the variation.
564565
user: ID and attributes for user.
565566
options: Decide options.
567+
holdout_decisions: If given, local holdout hits are appended to it.
566568
567569
Returns:
568570
Decision namedtuple consisting of experiment and variation for the user and
@@ -606,22 +608,14 @@ def get_variation_for_rollout(
606608
return Decision(experiment=rule, variation=forced_decision_variation,
607609
source=enums.DecisionSources.ROLLOUT, cmab_uuid=None), decide_reasons
608610

609-
local_holdouts = project_config.get_holdouts_for_rule(rule.id)
610-
for holdout in local_holdouts:
611-
local_holdout_decision = self.get_variation_for_holdout(
612-
holdout, user_context, project_config
613-
)
614-
decide_reasons.extend(local_holdout_decision['reasons'])
615-
616-
local_decision = local_holdout_decision['decision']
617-
if local_decision.variation is not None:
618-
message = (
619-
f"The user '{user_id}' is bucketed into local holdout '{holdout.key}' "
620-
f"for delivery rule '{rule.key}'."
621-
)
622-
self.logger.info(message)
623-
decide_reasons.append(message)
624-
return local_decision, decide_reasons
611+
local_holdout_hit = self._find_local_holdout_hit(
612+
project_config, rule, 'delivery', user_context, decide_reasons
613+
)
614+
if local_holdout_hit is not None:
615+
if holdout_decisions is not None:
616+
holdout_decisions.append(local_holdout_hit)
617+
index += 1
618+
continue
625619

626620
bucketing_id, bucket_reasons = self._get_bucketing_id(user_id, attributes)
627621
decide_reasons += bucket_reasons
@@ -748,6 +742,7 @@ def get_decision_for_flag(
748742

749743
global_holdout_result: DecisionResult | None = None
750744
global_holdout_key: str | None = None
745+
holdout_decisions: list[Decision] = []
751746

752747
# Check global holdouts (flag level — before any rules are evaluated)
753748
global_holdouts = project_config.get_global_holdouts()
@@ -781,6 +776,7 @@ def get_decision_for_flag(
781776
reasons.append(message)
782777
global_holdout_result = holdout_decision
783778
global_holdout_key = holdout.key
779+
holdout_decisions.append(holdout_decision['decision'])
784780
break
785781

786782
# Check experiments then rollouts
@@ -807,30 +803,16 @@ def get_decision_for_flag(
807803
'error': False,
808804
'reasons': reasons
809805
}
810-
if global_holdout_result is not None:
811-
result['holdout_decision'] = global_holdout_result['decision']
806+
if holdout_decisions:
807+
result['holdout_decisions'] = holdout_decisions
812808
return result
813809

814-
local_holdouts = project_config.get_holdouts_for_rule(experiment.id)
815-
for holdout in local_holdouts:
816-
local_holdout_decision = self.get_variation_for_holdout(
817-
holdout, user_context, project_config
818-
)
819-
reasons.extend(local_holdout_decision['reasons'])
820-
821-
local_decision = local_holdout_decision['decision']
822-
if local_decision.variation is not None:
823-
message = (
824-
f"The user '{user_id}' is bucketed into local holdout '{holdout.key}' "
825-
f"for experiment rule '{experiment.key}'."
826-
)
827-
self.logger.info(message)
828-
reasons.append(message)
829-
return {
830-
'decision': local_holdout_decision['decision'],
831-
'error': False,
832-
'reasons': reasons
833-
}
810+
local_holdout_hit = self._find_local_holdout_hit(
811+
project_config, experiment, 'experiment', user_context, reasons
812+
)
813+
if local_holdout_hit is not None:
814+
holdout_decisions.append(local_holdout_hit)
815+
continue
834816

835817
# Get variation for experiment
836818
variation_result = self.get_variation(
@@ -856,8 +838,8 @@ def get_decision_for_flag(
856838
'error': False,
857839
'reasons': reasons
858840
}
859-
if global_holdout_result is not None:
860-
result['holdout_decision'] = global_holdout_result['decision']
841+
if holdout_decisions:
842+
result['holdout_decisions'] = holdout_decisions
861843
return result
862844

863845
if global_holdout_result is not None:
@@ -870,7 +852,7 @@ def get_decision_for_flag(
870852

871853
# If no experiment decision, check rollouts
872854
rollout_decision, rollout_reasons = self.get_variation_for_rollout(
873-
project_config, feature_flag, user_context
855+
project_config, feature_flag, user_context, holdout_decisions
874856
)
875857
if rollout_reasons:
876858
reasons.extend(rollout_reasons)
@@ -893,10 +875,32 @@ def get_decision_for_flag(
893875
'error': False,
894876
'reasons': reasons
895877
}
896-
if global_holdout_result is not None:
897-
final_result['holdout_decision'] = global_holdout_result['decision']
878+
if holdout_decisions:
879+
final_result['holdout_decisions'] = holdout_decisions
898880
return final_result
899881

882+
def _find_local_holdout_hit(
883+
self,
884+
project_config: ProjectConfig,
885+
rule: entities.Experiment,
886+
rule_type: str,
887+
user_context: OptimizelyUserContext,
888+
reasons: list[str]
889+
) -> Optional[Decision]:
890+
"""Returns the decision of the first local holdout on the rule that buckets the user, else None."""
891+
for holdout in project_config.get_holdouts_for_rule(rule.id):
892+
holdout_result = self.get_variation_for_holdout(holdout, user_context, project_config)
893+
reasons.extend(holdout_result['reasons'])
894+
if holdout_result['decision'].variation is not None:
895+
message = (
896+
f"The user '{user_context.user_id}' is bucketed into local holdout '{holdout.key}' "
897+
f"for {rule_type} rule '{rule.key}'. Skipping to the next rule."
898+
)
899+
self.logger.info(message)
900+
reasons.append(message)
901+
return holdout_result['decision']
902+
return None
903+
900904
def get_variation_for_holdout(
901905
self,
902906
holdout: entities.Holdout,

‎optimizely/optimizely.py‎

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1246,7 +1246,7 @@ def _create_optimizely_decision(
12461246
decision_reasons: Optional[list[str]],
12471247
decide_options: list[str],
12481248
project_config: ProjectConfig,
1249-
holdout_decision: Optional[Decision] = None
1249+
holdout_decisions: Optional[list[Decision]] = None
12501250
) -> OptimizelyDecision:
12511251
user_id = user_context.user_id
12521252
feature_enabled = False
@@ -1265,24 +1265,22 @@ def _create_optimizely_decision(
12651265

12661266
feature_flag = project_config.feature_key_map.get(flag_key)
12671267

1268-
# Send holdout impression when user was bucketed into a holdout bypassed due to exclude_targeted_deliveries
1269-
if (holdout_decision is not None
1270-
and decision_source != DecisionSources.HOLDOUT
1271-
and OptimizelyDecideOption.DISABLE_DECISION_EVENT not in decide_options):
1272-
holdout_enabled = self._get_feature_enabled(holdout_decision.variation)
1273-
holdout_rule_key = holdout_decision.experiment.key if holdout_decision.experiment else ''
1274-
self._send_impression_event(
1275-
project_config,
1276-
holdout_decision.experiment,
1277-
holdout_decision.variation,
1278-
flag_key,
1279-
holdout_rule_key,
1280-
str(DecisionSources.HOLDOUT),
1281-
holdout_enabled,
1282-
user_id,
1283-
attributes,
1284-
holdout_decision.cmab_uuid
1285-
)
1268+
# Holdouts the user was bucketed into without ending evaluation: local holdout hits and
1269+
# global holdouts bypassed due to exclude_targeted_deliveries. Sent regardless of send_flag_decisions.
1270+
if holdout_decisions and OptimizelyDecideOption.DISABLE_DECISION_EVENT not in decide_options:
1271+
for holdout_decision in holdout_decisions:
1272+
self._send_impression_event(
1273+
project_config,
1274+
holdout_decision.experiment,
1275+
holdout_decision.variation,
1276+
flag_key,
1277+
holdout_decision.experiment.key if holdout_decision.experiment else '',
1278+
str(DecisionSources.HOLDOUT),
1279+
self._get_feature_enabled(holdout_decision.variation),
1280+
user_id,
1281+
attributes,
1282+
holdout_decision.cmab_uuid
1283+
)
12861284
decision_event_dispatched = True
12871285

12881286
# Send impression event if Decision came from a feature
@@ -1469,13 +1467,13 @@ def _decide_for_keys(
14691467
user_context,
14701468
merged_decide_options
14711469
)
1472-
holdout_decisions: dict[str, Optional[Decision]] = {}
1470+
holdout_decisions: dict[str, list[Decision]] = {}
14731471
for i in range(0, len(flags_without_forced_decision)):
14741472
decision = decision_list[i]['decision']
14751473
reasons = decision_list[i]['reasons']
14761474
error = decision_list[i]['error']
14771475
flag_key = flags_without_forced_decision[i].key
1478-
holdout_decisions[flag_key] = decision_list[i].get('holdout_decision')
1476+
holdout_decisions[flag_key] = decision_list[i].get('holdout_decisions', [])
14791477
# store error decision against key and remove key from valid keys
14801478
if error:
14811479
optimizely_decision = OptimizelyDecision.new_error_decision(flags_without_forced_decision[i].key,
@@ -1496,7 +1494,7 @@ def _decide_for_keys(
14961494
decision_reasons,
14971495
merged_decide_options,
14981496
project_config,
1499-
holdout_decision=holdout_decisions.get(key)
1497+
holdout_decisions=holdout_decisions.get(key)
15001498
)
15011499
enabled_flags_only_missing = OptimizelyDecideOption.ENABLED_FLAGS_ONLY not in merged_decide_options
15021500
is_enabled = optimizely_decision.enabled

‎tests/test_decision_service.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1510,7 +1510,7 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_rollout(sel
15101510
)
15111511

15121512
mock_get_variation_for_rollout.assert_called_once_with(
1513-
self.project_config, feature, user
1513+
self.project_config, feature, user, []
15141514
)
15151515

15161516
# Assert no log messages were generated

0 commit comments

Comments
 (0)