Skip to content

Commit 9422468

Browse files
committed
Implement holdout correctly and fix tests
1 parent 129c049 commit 9422468

File tree

3 files changed

+88
-129
lines changed

3 files changed

+88
-129
lines changed

optimizely/decision_service.py

Lines changed: 82 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -707,7 +707,7 @@ def get_decision_for_flag(
707707
reasons = decide_reasons.copy() if decide_reasons else []
708708
user_id = user_context.user_id
709709

710-
# Check holdouts
710+
# Check holdouts first (they take precedence)
711711
holdouts = project_config.get_holdouts_for_flag(feature_flag.key)
712712
for holdout in holdouts:
713713
holdout_decision = self.get_variation_for_holdout(holdout, user_context, project_config)
@@ -730,21 +730,84 @@ def get_decision_for_flag(
730730
'reasons': reasons
731731
}
732732

733-
# If no holdout decision, fall back to existing experiment/rollout logic
734-
# Use get_variations_for_feature_list which handles experiments and rollouts
735-
fallback_result = self.get_variations_for_feature_list(
736-
project_config, [feature_flag], user_context, decide_options
737-
)[0]
738-
739-
# Merge reasons
740-
if fallback_result.get('reasons'):
741-
reasons.extend(fallback_result['reasons'])
733+
# Check if the feature flag is under an experiment
734+
if feature_flag.experimentIds:
735+
for experiment_id in feature_flag.experimentIds:
736+
experiment = project_config.get_experiment_from_id(experiment_id)
737+
decision_variation: Optional[Union[entities.Variation, VariationDict]] = None
738+
739+
if experiment:
740+
optimizely_decision_context = OptimizelyUserContext.OptimizelyDecisionContext(
741+
feature_flag.key, experiment.key)
742+
forced_decision_variation, reasons_received = self.validated_forced_decision(
743+
project_config, optimizely_decision_context, user_context)
744+
reasons.extend(reasons_received)
745+
746+
if forced_decision_variation:
747+
decision_variation = forced_decision_variation
748+
cmab_uuid = None
749+
error = False
750+
else:
751+
variation_result = self.get_variation(
752+
project_config, experiment, user_context, user_profile_tracker, reasons, decide_options
753+
)
754+
cmab_uuid = variation_result['cmab_uuid']
755+
variation_reasons = variation_result['reasons']
756+
decision_variation = variation_result['variation']
757+
error = variation_result['error']
758+
reasons.extend(variation_reasons)
759+
760+
if error:
761+
# If there's an error (e.g., CMAB error), return immediately without falling back to rollout
762+
decision = Decision(experiment, None, enums.DecisionSources.FEATURE_TEST, cmab_uuid)
763+
return {
764+
'decision': decision,
765+
'error': True,
766+
'reasons': reasons
767+
}
768+
769+
if decision_variation:
770+
self.logger.debug(
771+
f'User "{user_context.user_id}" '
772+
f'bucketed into experiment "{experiment.key}" of feature "{feature_flag.key}".'
773+
)
774+
decision = Decision(experiment, decision_variation,
775+
enums.DecisionSources.FEATURE_TEST, cmab_uuid)
776+
return {
777+
'decision': decision,
778+
'error': False,
779+
'reasons': reasons
780+
}
781+
782+
# Fall back to rollout
783+
rollout_decision, rollout_reasons = self.get_variation_for_rollout(project_config,
784+
feature_flag,
785+
user_context)
786+
reasons.extend(rollout_reasons)
787+
788+
if rollout_decision and rollout_decision.variation:
789+
# Check if this was a forced decision (last reason contains "forced decision map")
790+
is_forced_decision = reasons and 'forced decision map' in reasons[-1] if reasons else False
791+
792+
if not is_forced_decision:
793+
# Only add the "bucketed into rollout" message for normal bucketing
794+
message = f"The user '{user_id}' is bucketed into a rollout for feature flag '{feature_flag.key}'."
795+
self.logger.info(message)
796+
reasons.append(message)
742797

743-
return {
744-
'decision': fallback_result['decision'],
745-
'error': fallback_result.get('error', False),
746-
'reasons': reasons
747-
}
798+
return {
799+
'decision': rollout_decision,
800+
'error': False,
801+
'reasons': reasons
802+
}
803+
else:
804+
message = f"The user '{user_id}' is not bucketed into a rollout for feature flag '{feature_flag.key}'."
805+
self.logger.info(message)
806+
return {
807+
'decision': Decision(None, None, enums.DecisionSources.ROLLOUT, None),
808+
'error': False,
809+
'reasons': reasons
810+
}
748811

749812
def get_variation_for_holdout(
750813
self,
@@ -946,116 +1009,10 @@ def get_variations_for_feature_list(
9461009
decisions = []
9471010

9481011
for feature in features:
949-
feature_reasons = decide_reasons.copy()
950-
experiment_decision_found = False # Track if an experiment decision was made for the feature
951-
holdout_decision_found = False # Track if a holdout decision was made for the feature
952-
user_id = user_context.user_id
953-
954-
# Check holdouts first (they take precedence over experiments and rollouts)
955-
holdouts = project_config.get_holdouts_for_flag(feature.key)
956-
for holdout in holdouts:
957-
holdout_decision_result = self.get_variation_for_holdout(holdout, user_context, project_config)
958-
feature_reasons.extend(holdout_decision_result['reasons'])
959-
960-
decision = holdout_decision_result['decision']
961-
# Check if user was bucketed into holdout (has a variation)
962-
if decision.variation is None:
963-
continue
964-
965-
message = (
966-
f"The user '{user_id}' is bucketed into holdout '{holdout['key']}' "
967-
f"for feature flag '{feature.key}'."
968-
)
969-
self.logger.info(message)
970-
feature_reasons.append(message)
971-
972-
decision_result: DecisionResult = {
973-
'decision': holdout_decision_result['decision'],
974-
'error': False,
975-
'reasons': feature_reasons
976-
}
977-
decisions.append(decision_result)
978-
holdout_decision_found = True
979-
break
980-
981-
# If holdout decision found, skip experiment and rollout evaluation
982-
if holdout_decision_found:
983-
continue
984-
985-
# Check if the feature flag is under an experiment
986-
if feature.experimentIds:
987-
for experiment_id in feature.experimentIds:
988-
experiment = project_config.get_experiment_from_id(experiment_id)
989-
decision_variation: Optional[Union[entities.Variation, VariationDict]] = None
990-
991-
if experiment:
992-
optimizely_decision_context = OptimizelyUserContext.OptimizelyDecisionContext(
993-
feature.key, experiment.key)
994-
forced_decision_variation, reasons_received = self.validated_forced_decision(
995-
project_config, optimizely_decision_context, user_context)
996-
feature_reasons.extend(reasons_received)
997-
998-
if forced_decision_variation:
999-
decision_variation = forced_decision_variation
1000-
cmab_uuid = None
1001-
error = False
1002-
else:
1003-
variation_result = self.get_variation(
1004-
project_config, experiment, user_context, user_profile_tracker, feature_reasons, options
1005-
)
1006-
cmab_uuid = variation_result['cmab_uuid']
1007-
variation_reasons = variation_result['reasons']
1008-
decision_variation = variation_result['variation']
1009-
error = variation_result['error']
1010-
feature_reasons.extend(variation_reasons)
1011-
1012-
if error:
1013-
decision = Decision(experiment, None, enums.DecisionSources.FEATURE_TEST, cmab_uuid)
1014-
decision_result = {
1015-
'decision': decision,
1016-
'error': True,
1017-
'reasons': feature_reasons
1018-
}
1019-
decisions.append(decision_result)
1020-
experiment_decision_found = True
1021-
break
1022-
1023-
if decision_variation:
1024-
self.logger.debug(
1025-
f'User "{user_context.user_id}" '
1026-
f'bucketed into experiment "{experiment.key}" of feature "{feature.key}".'
1027-
)
1028-
decision = Decision(experiment, decision_variation,
1029-
enums.DecisionSources.FEATURE_TEST, cmab_uuid)
1030-
decision_result = {
1031-
'decision': decision,
1032-
'error': False,
1033-
'reasons': feature_reasons
1034-
}
1035-
decisions.append(decision_result)
1036-
experiment_decision_found = True # Mark that a decision was found
1037-
break # Stop after the first successful experiment decision
1038-
1039-
# Only process rollout if no experiment decision was found and no error
1040-
if not experiment_decision_found:
1041-
rollout_decision, rollout_reasons = self.get_variation_for_rollout(project_config,
1042-
feature,
1043-
user_context)
1044-
if rollout_reasons:
1045-
feature_reasons.extend(rollout_reasons)
1046-
if rollout_decision:
1047-
self.logger.debug(f'User "{user_context.user_id}" '
1048-
f'bucketed into rollout for feature "{feature.key}".')
1049-
else:
1050-
self.logger.debug(f'User "{user_context.user_id}" '
1051-
f'not bucketed into any rollout for feature "{feature.key}".')
1052-
1053-
decision_result = {
1054-
'decision': rollout_decision,
1055-
'error': False,
1056-
'reasons': feature_reasons
1057-
}
1058-
decisions.append(decision_result)
1012+
decision = self.get_decision_for_flag(
1013+
feature, user_context, project_config, options, user_profile_tracker, decide_reasons
1014+
)
1015+
decisions.append(decision)
10591016

10601017
if self.user_profile_service is not None and user_profile_tracker is not None and ignore_ups is False:
10611018
user_profile_tracker.save_user_profile()

tests/test_decision_service.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1399,8 +1399,8 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_rollout(sel
13991399
self.project_config, feature, user
14001400
)
14011401

1402-
# Assert debug log messages were generated (rollout decisions log at debug level)
1403-
self.assertEqual(1, mock_decision_service_logging.debug.call_count)
1402+
# Assert info log message was generated for rollout bucketing
1403+
self.assertEqual(1, mock_decision_service_logging.info.call_count)
14041404

14051405
def test_get_variation_for_feature__returns_variation_if_user_not_in_experiment_but_in_rollout(
14061406
self,

tests/test_user_context.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -872,7 +872,8 @@ def test_decide__option__include_reasons__feature_rollout(self):
872872
'Evaluating audiences for rule 1: ["11154"].',
873873
'Audiences for rule 1 collectively evaluated to TRUE.',
874874
'User "test_user" meets audience conditions for targeting rule 1.',
875-
'User "test_user" bucketed into a targeting rule 1.'
875+
'User "test_user" bucketed into a targeting rule 1.',
876+
"The user 'test_user' is bucketed into a rollout for feature flag 'test_feature_in_rollout'."
876877
]
877878

878879
self.assertEqual(expected_reasons, actual.reasons)
@@ -1270,7 +1271,8 @@ def test_decide_reasons__hit_everyone_else_rule(self):
12701271
'Evaluating audiences for rule Everyone Else: [].',
12711272
'Audiences for rule Everyone Else collectively evaluated to TRUE.',
12721273
'User "abcde" meets audience conditions for targeting rule Everyone Else.',
1273-
'User "abcde" bucketed into a targeting rule Everyone Else.'
1274+
'User "abcde" bucketed into a targeting rule Everyone Else.',
1275+
"The user 'abcde' is bucketed into a rollout for feature flag 'test_feature_in_rollout'."
12741276
]
12751277

12761278
self.assertEqual(expected_reasons, actual.reasons)

0 commit comments

Comments
 (0)