Skip to content

Commit 3732db6

Browse files
[AI-FSSDK] [FSSDK-12958] Continue rule evaluation after local holdout hit (#525)
* [AI-FSSDK] [FSSDK-12958] Continue rule evaluation after local holdout hit * [AI-FSSDK] [FSSDK-12958] Add tests for disabled decision events and two experiment rules * [AI-FSSDK] [FSSDK-12958] Add test for one local holdout hit on two rules * [AI-FSSDK] [FSSDK-12958] Keep deprecated holdout_decision key alongside list * [AI-FSSDK] [FSSDK-12958] Add test for holdout hit then CMAB decision error * [AI-FSSDK] [FSSDK-12958] Re-run CI --------- Co-authored-by: Farhan Anjum <Farhan.Anjum@optimizely.com>
1 parent eff999b commit 3732db6

4 files changed

Lines changed: 345 additions & 96 deletions

File tree

‎optimizely/decision_service.py‎

Lines changed: 57 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2017-2022, Optimizely
1+
# Copyright 2017-2022, 2026, Optimizely
22
# Licensed under the Apache License, Version 2.0 (the "License");
33
# you may not use this file except in compliance with the License.
44
# You may obtain a copy of the License at
@@ -65,7 +65,8 @@ class VariationResult(TypedDict):
6565

6666

6767
class _DecisionResultOptional(TypedDict, total=False):
68-
holdout_decision: Decision
68+
holdout_decision: Decision # Deprecated: first entry of holdout_decisions.
69+
holdout_decisions: List[Decision]
6970

7071

7172
class DecisionResult(_DecisionResultOptional):
@@ -552,7 +553,11 @@ def get_variation(
552553
}
553554

554555
def get_variation_for_rollout(
555-
self, project_config: ProjectConfig, feature: entities.FeatureFlag, user_context: OptimizelyUserContext
556+
self,
557+
project_config: ProjectConfig,
558+
feature: entities.FeatureFlag,
559+
user_context: OptimizelyUserContext,
560+
holdout_decisions: Optional[list[Decision]] = None
556561
) -> tuple[Decision, list[str]]:
557562
""" Determine which experiment/variation the user is in for a given rollout.
558563
Returns the variation of the first experiment the user qualifies for.
@@ -562,7 +567,7 @@ def get_variation_for_rollout(
562567
flagKey: Feature key.
563568
rollout: Rollout for which we are getting the variation.
564569
user: ID and attributes for user.
565-
options: Decide options.
570+
holdout_decisions: If provided, local holdout decisions for skipped rules are appended to it.
566571
567572
Returns:
568573
Decision namedtuple consisting of experiment and variation for the user and
@@ -606,22 +611,14 @@ def get_variation_for_rollout(
606611
return Decision(experiment=rule, variation=forced_decision_variation,
607612
source=enums.DecisionSources.ROLLOUT, cmab_uuid=None), decide_reasons
608613

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
614+
local_holdout = self._get_local_holdout_decision(
615+
project_config, rule, user_context, 'delivery', decide_reasons
616+
)
617+
if local_holdout is not None:
618+
if holdout_decisions is not None:
619+
holdout_decisions.append(local_holdout)
620+
index += 1
621+
continue
625622

626623
bucketing_id, bucket_reasons = self._get_bucketing_id(user_id, attributes)
627624
decide_reasons += bucket_reasons
@@ -748,6 +745,7 @@ def get_decision_for_flag(
748745

749746
global_holdout_result: DecisionResult | None = None
750747
global_holdout_key: str | None = None
748+
holdout_decisions: list[Decision] = []
751749

752750
# Check global holdouts (flag level — before any rules are evaluated)
753751
global_holdouts = project_config.get_global_holdouts()
@@ -781,6 +779,7 @@ def get_decision_for_flag(
781779
reasons.append(message)
782780
global_holdout_result = holdout_decision
783781
global_holdout_key = holdout.key
782+
holdout_decisions.append(holdout_decision['decision'])
784783
break
785784

786785
# Check experiments then rollouts
@@ -807,30 +806,17 @@ def get_decision_for_flag(
807806
'error': False,
808807
'reasons': reasons
809808
}
810-
if global_holdout_result is not None:
811-
result['holdout_decision'] = global_holdout_result['decision']
809+
if holdout_decisions:
810+
result['holdout_decisions'] = holdout_decisions
811+
result['holdout_decision'] = holdout_decisions[0]
812812
return result
813813

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-
}
814+
local_holdout = self._get_local_holdout_decision(
815+
project_config, experiment, user_context, 'experiment', reasons
816+
)
817+
if local_holdout is not None:
818+
holdout_decisions.append(local_holdout)
819+
continue
834820

835821
# Get variation for experiment
836822
variation_result = self.get_variation(
@@ -856,8 +842,9 @@ def get_decision_for_flag(
856842
'error': False,
857843
'reasons': reasons
858844
}
859-
if global_holdout_result is not None:
860-
result['holdout_decision'] = global_holdout_result['decision']
845+
if holdout_decisions:
846+
result['holdout_decisions'] = holdout_decisions
847+
result['holdout_decision'] = holdout_decisions[0]
861848
return result
862849

863850
if global_holdout_result is not None:
@@ -870,7 +857,7 @@ def get_decision_for_flag(
870857

871858
# If no experiment decision, check rollouts
872859
rollout_decision, rollout_reasons = self.get_variation_for_rollout(
873-
project_config, feature_flag, user_context
860+
project_config, feature_flag, user_context, holdout_decisions
874861
)
875862
if rollout_reasons:
876863
reasons.extend(rollout_reasons)
@@ -893,10 +880,33 @@ def get_decision_for_flag(
893880
'error': False,
894881
'reasons': reasons
895882
}
896-
if global_holdout_result is not None:
897-
final_result['holdout_decision'] = global_holdout_result['decision']
883+
if holdout_decisions:
884+
final_result['holdout_decisions'] = holdout_decisions
885+
final_result['holdout_decision'] = holdout_decisions[0]
898886
return final_result
899887

888+
def _get_local_holdout_decision(
889+
self,
890+
project_config: ProjectConfig,
891+
rule: entities.Experiment,
892+
user_context: OptimizelyUserContext,
893+
rule_type: str,
894+
reasons: list[str]
895+
) -> Optional[Decision]:
896+
"""Returns the first local holdout decision the user is bucketed into for the rule, if any."""
897+
for holdout in project_config.get_holdouts_for_rule(rule.id):
898+
holdout_result = self.get_variation_for_holdout(holdout, user_context, project_config)
899+
reasons.extend(holdout_result['reasons'])
900+
if holdout_result['decision'].variation is not None:
901+
message = (
902+
f"The user '{user_context.user_id}' is bucketed into local holdout '{holdout.key}' "
903+
f"for {rule_type} rule '{rule.key}'. Skipping the rule."
904+
)
905+
self.logger.info(message)
906+
reasons.append(message)
907+
return holdout_result['decision']
908+
return None
909+
900910
def get_variation_for_holdout(
901911
self,
902912
holdout: entities.Holdout,

‎optimizely/optimizely.py‎

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2016-2023, Optimizely
1+
# Copyright 2016-2023, 2026, Optimizely
22
# Licensed under the Apache License, Version 2.0 (the "License");
33
# you may not use this file except in compliance with the License.
44
# You may obtain a copy of the License at
@@ -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,25 @@ 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
1268+
# Skipped local holdouts and global holdouts bypassed by exclude_targeted_deliveries
1269+
if (holdout_decisions
12701270
and decision_source != DecisionSources.HOLDOUT
12711271
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-
)
1272+
for holdout_decision in holdout_decisions:
1273+
holdout_enabled = self._get_feature_enabled(holdout_decision.variation)
1274+
holdout_rule_key = holdout_decision.experiment.key if holdout_decision.experiment else ''
1275+
self._send_impression_event(
1276+
project_config,
1277+
holdout_decision.experiment,
1278+
holdout_decision.variation,
1279+
flag_key,
1280+
holdout_rule_key,
1281+
str(DecisionSources.HOLDOUT),
1282+
holdout_enabled,
1283+
user_id,
1284+
attributes,
1285+
holdout_decision.cmab_uuid
1286+
)
12861287
decision_event_dispatched = True
12871288

12881289
# Send impression event if Decision came from a feature
@@ -1469,13 +1470,13 @@ def _decide_for_keys(
14691470
user_context,
14701471
merged_decide_options
14711472
)
1472-
holdout_decisions: dict[str, Optional[Decision]] = {}
1473+
holdout_decisions: dict[str, Optional[list[Decision]]] = {}
14731474
for i in range(0, len(flags_without_forced_decision)):
14741475
decision = decision_list[i]['decision']
14751476
reasons = decision_list[i]['reasons']
14761477
error = decision_list[i]['error']
14771478
flag_key = flags_without_forced_decision[i].key
1478-
holdout_decisions[flag_key] = decision_list[i].get('holdout_decision')
1479+
holdout_decisions[flag_key] = decision_list[i].get('holdout_decisions')
14791480
# store error decision against key and remove key from valid keys
14801481
if error:
14811482
optimizely_decision = OptimizelyDecision.new_error_decision(flags_without_forced_decision[i].key,
@@ -1496,7 +1497,7 @@ def _decide_for_keys(
14961497
decision_reasons,
14971498
merged_decide_options,
14981499
project_config,
1499-
holdout_decision=holdout_decisions.get(key)
1500+
holdout_decisions=holdout_decisions.get(key)
15001501
)
15011502
enabled_flags_only_missing = OptimizelyDecideOption.ENABLED_FLAGS_ONLY not in merged_decide_options
15021503
is_enabled = optimizely_decision.enabled

‎tests/test_decision_service.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2017-2021, Optimizely
1+
# Copyright 2017-2021, 2026, Optimizely
22
# Licensed under the Apache License, Version 2.0 (the "License");
33
# you may not use this file except in compliance with the License.
44
# You may obtain a copy of the License at
@@ -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)