Skip to content

Commit 45bf19c

Browse files
committed
[AI-FSSDK] [FSSDK-12958] Continue rule evaluation after local holdout hit
1 parent 2dc0ac1 commit 45bf19c

4 files changed

Lines changed: 215 additions & 96 deletions

File tree

‎optimizely/decision_service.py‎

Lines changed: 53 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,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,11 @@ def get_variation(
552552
}
553553

554554
def get_variation_for_rollout(
555-
self, project_config: ProjectConfig, feature: entities.FeatureFlag, user_context: OptimizelyUserContext
555+
self,
556+
project_config: ProjectConfig,
557+
feature: entities.FeatureFlag,
558+
user_context: OptimizelyUserContext,
559+
holdout_decisions: Optional[list[Decision]] = None
556560
) -> tuple[Decision, list[str]]:
557561
""" Determine which experiment/variation the user is in for a given rollout.
558562
Returns the variation of the first experiment the user qualifies for.
@@ -562,7 +566,7 @@ def get_variation_for_rollout(
562566
flagKey: Feature key.
563567
rollout: Rollout for which we are getting the variation.
564568
user: ID and attributes for user.
565-
options: Decide options.
569+
holdout_decisions: If provided, local holdout decisions for skipped rules are appended to it.
566570
567571
Returns:
568572
Decision namedtuple consisting of experiment and variation for the user and
@@ -606,22 +610,14 @@ def get_variation_for_rollout(
606610
return Decision(experiment=rule, variation=forced_decision_variation,
607611
source=enums.DecisionSources.ROLLOUT, cmab_uuid=None), decide_reasons
608612

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

626622
bucketing_id, bucket_reasons = self._get_bucketing_id(user_id, attributes)
627623
decide_reasons += bucket_reasons
@@ -748,6 +744,7 @@ def get_decision_for_flag(
748744

749745
global_holdout_result: DecisionResult | None = None
750746
global_holdout_key: str | None = None
747+
holdout_decisions: list[Decision] = []
751748

752749
# Check global holdouts (flag level — before any rules are evaluated)
753750
global_holdouts = project_config.get_global_holdouts()
@@ -781,6 +778,7 @@ def get_decision_for_flag(
781778
reasons.append(message)
782779
global_holdout_result = holdout_decision
783780
global_holdout_key = holdout.key
781+
holdout_decisions.append(holdout_decision['decision'])
784782
break
785783

786784
# Check experiments then rollouts
@@ -807,30 +805,16 @@ def get_decision_for_flag(
807805
'error': False,
808806
'reasons': reasons
809807
}
810-
if global_holdout_result is not None:
811-
result['holdout_decision'] = global_holdout_result['decision']
808+
if holdout_decisions:
809+
result['holdout_decisions'] = holdout_decisions
812810
return result
813811

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

835819
# Get variation for experiment
836820
variation_result = self.get_variation(
@@ -856,8 +840,8 @@ def get_decision_for_flag(
856840
'error': False,
857841
'reasons': reasons
858842
}
859-
if global_holdout_result is not None:
860-
result['holdout_decision'] = global_holdout_result['decision']
843+
if holdout_decisions:
844+
result['holdout_decisions'] = holdout_decisions
861845
return result
862846

863847
if global_holdout_result is not None:
@@ -870,7 +854,7 @@ def get_decision_for_flag(
870854

871855
# If no experiment decision, check rollouts
872856
rollout_decision, rollout_reasons = self.get_variation_for_rollout(
873-
project_config, feature_flag, user_context
857+
project_config, feature_flag, user_context, holdout_decisions
874858
)
875859
if rollout_reasons:
876860
reasons.extend(rollout_reasons)
@@ -893,10 +877,32 @@ def get_decision_for_flag(
893877
'error': False,
894878
'reasons': reasons
895879
}
896-
if global_holdout_result is not None:
897-
final_result['holdout_decision'] = global_holdout_result['decision']
880+
if holdout_decisions:
881+
final_result['holdout_decisions'] = holdout_decisions
898882
return final_result
899883

884+
def _get_local_holdout_decision(
885+
self,
886+
project_config: ProjectConfig,
887+
rule: entities.Experiment,
888+
user_context: OptimizelyUserContext,
889+
rule_type: str,
890+
reasons: list[str]
891+
) -> Optional[Decision]:
892+
"""Returns the first local holdout decision the user is bucketed into for the rule, if any."""
893+
for holdout in project_config.get_holdouts_for_rule(rule.id):
894+
holdout_result = self.get_variation_for_holdout(holdout, user_context, project_config)
895+
reasons.extend(holdout_result['reasons'])
896+
if holdout_result['decision'].variation is not None:
897+
message = (
898+
f"The user '{user_context.user_id}' is bucketed into local holdout '{holdout.key}' "
899+
f"for {rule_type} rule '{rule.key}'. Skipping the rule."
900+
)
901+
self.logger.info(message)
902+
reasons.append(message)
903+
return holdout_result['decision']
904+
return None
905+
900906
def get_variation_for_holdout(
901907
self,
902908
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)