Skip to content

Commit 813d20c

Browse files
committed
[FSSDK-12094] Fix Ruby holdout implementation which related to FSC failures
1 parent 53421a6 commit 813d20c

File tree

7 files changed

+209
-155
lines changed

7 files changed

+209
-155
lines changed

optimizely/decision_service.py

Lines changed: 108 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -714,8 +714,8 @@ def get_decision_for_flag(
714714
reasons.extend(holdout_decision['reasons'])
715715

716716
decision = holdout_decision['decision']
717-
# Check if user was bucketed into holdout (has a variation)
718-
if decision.variation is None:
717+
# Check if user was bucketed into holdout (has a decision)
718+
if decision is None:
719719
continue
720720

721721
message = (
@@ -730,21 +730,102 @@ 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 has an experiment and the user is bucketed into that experiment
734+
if feature_flag.experimentIds:
735+
# Iterate through experiments to find a match
736+
for experiment_id in feature_flag.experimentIds:
737+
experiment = project_config.get_experiment_from_id(experiment_id)
738+
if not experiment:
739+
continue
740+
741+
# Check for forced decision
742+
optimizely_decision_context = OptimizelyUserContext.OptimizelyDecisionContext(
743+
feature_flag.key, experiment.key
744+
)
745+
forced_decision_variation, forced_reasons = self.validated_forced_decision(
746+
project_config, optimizely_decision_context, user_context
747+
)
748+
reasons.extend(forced_reasons)
749+
750+
if forced_decision_variation:
751+
decision = Decision(
752+
experiment, forced_decision_variation, enums.DecisionSources.FEATURE_TEST, None
753+
)
754+
return {
755+
'decision': decision,
756+
'error': False,
757+
'reasons': reasons
758+
}
759+
760+
# Get variation through normal bucketing
761+
variation_result = self.get_variation(
762+
project_config, experiment, user_context, user_profile_tracker, reasons, decide_options
763+
)
764+
cmab_uuid = variation_result['cmab_uuid']
765+
variation_reasons = variation_result['reasons']
766+
decision_variation = variation_result['variation']
767+
error = variation_result['error']
768+
reasons.extend(variation_reasons)
769+
770+
# If there's an error, return immediately
771+
if error:
772+
decision = Decision(experiment, None, enums.DecisionSources.FEATURE_TEST, cmab_uuid)
773+
return {
774+
'decision': decision,
775+
'error': True,
776+
'reasons': reasons
777+
}
778+
779+
# If user is bucketed into a variation, return the decision
780+
if decision_variation:
781+
self.logger.debug(
782+
f'User "{user_id}" '
783+
f'bucketed into experiment "{experiment.key}" of feature "{feature_flag.key}".'
784+
)
785+
decision = Decision(
786+
experiment, decision_variation, enums.DecisionSources.FEATURE_TEST, cmab_uuid
787+
)
788+
return {
789+
'decision': decision,
790+
'error': False,
791+
'reasons': reasons
792+
}
793+
794+
# Check if the feature flag has a rollout and the user is bucketed into that rollout
795+
rollout_decision, rollout_reasons = self.get_variation_for_rollout(
796+
project_config, feature_flag, user_context
797+
)
798+
reasons.extend(rollout_reasons)
799+
800+
if rollout_decision and rollout_decision.variation:
801+
# Check if this was a forced decision (last reason contains "forced decision map")
802+
is_forced_decision = reasons and 'forced decision map' in reasons[-1] if reasons else False
803+
804+
if not is_forced_decision:
805+
# Only add the "bucketed into rollout" message for normal bucketing
806+
message = (
807+
f"The user '{user_id}' is bucketed into a rollout "
808+
f"for feature flag '{feature_flag.key}'."
809+
)
810+
self.logger.info(message)
811+
reasons.append(message)
742812

743-
return {
744-
'decision': fallback_result['decision'],
745-
'error': fallback_result.get('error', False),
746-
'reasons': reasons
747-
}
813+
return {
814+
'decision': rollout_decision,
815+
'error': False,
816+
'reasons': reasons
817+
}
818+
else:
819+
message = (
820+
f"The user '{user_id}' is not bucketed into a rollout "
821+
f"for feature flag '{feature_flag.key}'."
822+
)
823+
self.logger.info(message)
824+
return {
825+
'decision': Decision(None, None, enums.DecisionSources.ROLLOUT, None),
826+
'error': False,
827+
'reasons': reasons
828+
}
748829

749830
def get_variation_for_holdout(
750831
self,
@@ -931,100 +1012,24 @@ def get_variations_for_feature_list(
9311012
- 'error': Boolean indicating if an error occurred during the decision process.
9321013
- 'reasons': List of log messages representing decision making for each feature.
9331014
"""
934-
decide_reasons: list[str] = []
935-
1015+
ignore_ups = False
9361016
if options:
9371017
ignore_ups = OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE in options
938-
else:
939-
ignore_ups = False
9401018

9411019
user_profile_tracker: Optional[UserProfileTracker] = None
942-
if self.user_profile_service is not None and not ignore_ups:
943-
user_profile_tracker = UserProfileTracker(user_context.user_id, self.user_profile_service, self.logger)
944-
user_profile_tracker.load_user_profile(decide_reasons, None)
1020+
if not ignore_ups and self.user_profile_service:
1021+
user_id = user_context.user_id
1022+
user_profile_tracker = UserProfileTracker(user_id, self.user_profile_service, self.logger)
1023+
user_profile_tracker.load_user_profile([], None)
9451024

9461025
decisions = []
1026+
for feature_flag in features:
1027+
decision = self.get_decision_for_flag(
1028+
feature_flag, user_context, project_config, options, user_profile_tracker
1029+
)
1030+
decisions.append(decision)
9471031

948-
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-
952-
# Check if the feature flag is under an experiment
953-
if feature.experimentIds:
954-
for experiment_id in feature.experimentIds:
955-
experiment = project_config.get_experiment_from_id(experiment_id)
956-
decision_variation: Optional[Union[entities.Variation, VariationDict]] = None
957-
958-
if experiment:
959-
optimizely_decision_context = OptimizelyUserContext.OptimizelyDecisionContext(
960-
feature.key, experiment.key)
961-
forced_decision_variation, reasons_received = self.validated_forced_decision(
962-
project_config, optimizely_decision_context, user_context)
963-
feature_reasons.extend(reasons_received)
964-
965-
if forced_decision_variation:
966-
decision_variation = forced_decision_variation
967-
cmab_uuid = None
968-
error = False
969-
else:
970-
variation_result = self.get_variation(
971-
project_config, experiment, user_context, user_profile_tracker, feature_reasons, options
972-
)
973-
cmab_uuid = variation_result['cmab_uuid']
974-
variation_reasons = variation_result['reasons']
975-
decision_variation = variation_result['variation']
976-
error = variation_result['error']
977-
feature_reasons.extend(variation_reasons)
978-
979-
if error:
980-
decision = Decision(experiment, None, enums.DecisionSources.FEATURE_TEST, cmab_uuid)
981-
decision_result: DecisionResult = {
982-
'decision': decision,
983-
'error': True,
984-
'reasons': feature_reasons
985-
}
986-
decisions.append(decision_result)
987-
experiment_decision_found = True
988-
break
989-
990-
if decision_variation:
991-
self.logger.debug(
992-
f'User "{user_context.user_id}" '
993-
f'bucketed into experiment "{experiment.key}" of feature "{feature.key}".'
994-
)
995-
decision = Decision(experiment, decision_variation,
996-
enums.DecisionSources.FEATURE_TEST, cmab_uuid)
997-
decision_result = {
998-
'decision': decision,
999-
'error': False,
1000-
'reasons': feature_reasons
1001-
}
1002-
decisions.append(decision_result)
1003-
experiment_decision_found = True # Mark that a decision was found
1004-
break # Stop after the first successful experiment decision
1005-
1006-
# Only process rollout if no experiment decision was found and no error
1007-
if not experiment_decision_found:
1008-
rollout_decision, rollout_reasons = self.get_variation_for_rollout(project_config,
1009-
feature,
1010-
user_context)
1011-
if rollout_reasons:
1012-
feature_reasons.extend(rollout_reasons)
1013-
if rollout_decision:
1014-
self.logger.debug(f'User "{user_context.user_id}" '
1015-
f'bucketed into rollout for feature "{feature.key}".')
1016-
else:
1017-
self.logger.debug(f'User "{user_context.user_id}" '
1018-
f'not bucketed into any rollout for feature "{feature.key}".')
1019-
1020-
decision_result = {
1021-
'decision': rollout_decision,
1022-
'error': False,
1023-
'reasons': feature_reasons
1024-
}
1025-
decisions.append(decision_result)
1026-
1027-
if self.user_profile_service is not None and user_profile_tracker is not None and ignore_ups is False:
1032+
if user_profile_tracker:
10281033
user_profile_tracker.save_user_profile()
10291034

10301035
return decisions

optimizely/optimizely.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -433,7 +433,7 @@ def _get_feature_variable_for_type(
433433
decision_result = self.decision_service.get_variation_for_feature(project_config, feature_flag, user_context)
434434
decision = decision_result['decision']
435435

436-
if decision.variation:
436+
if decision and decision.variation:
437437

438438
feature_enabled = self._get_feature_enabled(decision.variation)
439439
if feature_enabled:
@@ -792,23 +792,24 @@ def is_feature_enabled(self, feature_key: str, user_id: str, attributes: Optiona
792792
user_context = OptimizelyUserContext(self, self.logger, user_id, attributes, False)
793793

794794
decision = self.decision_service.get_variation_for_feature(project_config, feature, user_context)['decision']
795-
cmab_uuid = decision.cmab_uuid
795+
cmab_uuid = decision.cmab_uuid if decision else None
796796

797-
is_source_experiment = decision.source == enums.DecisionSources.FEATURE_TEST
798-
is_source_rollout = decision.source == enums.DecisionSources.ROLLOUT
797+
is_source_experiment = decision.source == enums.DecisionSources.FEATURE_TEST if decision else False
798+
is_source_rollout = decision.source == enums.DecisionSources.ROLLOUT if decision else False
799799

800-
if decision.variation:
800+
if decision and decision.variation:
801801
if self._get_feature_enabled(decision.variation) is True:
802802
feature_enabled = True
803803

804-
if (is_source_rollout or not decision.variation) and project_config.get_send_flag_decisions_value():
804+
if (decision and (is_source_rollout or not decision.variation) and
805+
project_config.get_send_flag_decisions_value()):
805806
self._send_impression_event(
806807
project_config, decision.experiment, decision.variation, feature.key, decision.experiment.key if
807808
decision.experiment else '', str(decision.source), feature_enabled, user_id, attributes, cmab_uuid
808809
)
809810

810811
# Send event if Decision came from an experiment.
811-
if is_source_experiment and decision.variation and decision.experiment:
812+
if decision and is_source_experiment and decision.variation and decision.experiment:
812813
source_info = {
813814
'experiment_key': decision.experiment.key,
814815
'variation_key': self._get_variation_key(decision.variation),
@@ -1244,25 +1245,27 @@ def _create_optimizely_decision(
12441245
) -> OptimizelyDecision:
12451246
user_id = user_context.user_id
12461247
feature_enabled = False
1247-
if flag_decision.variation is not None:
1248+
if flag_decision and flag_decision.variation is not None:
12481249
if self._get_feature_enabled(flag_decision.variation):
12491250
feature_enabled = True
12501251

12511252
self.logger.info(f'Feature {flag_key} is enabled for user {user_id} {feature_enabled}"')
12521253

12531254
# Create Optimizely Decision Result.
12541255
attributes = user_context.get_user_attributes()
1255-
rule_key = flag_decision.experiment.key if flag_decision.experiment else None
1256+
rule_key = flag_decision.experiment.key if (flag_decision and flag_decision.experiment) else None
12561257
all_variables = {}
1257-
decision_source = flag_decision.source
1258+
decision_source = flag_decision.source if flag_decision else None
12581259
decision_event_dispatched = False
12591260

12601261
feature_flag = project_config.feature_key_map.get(flag_key)
12611262

12621263
# Send impression event if Decision came from a feature
12631264
# test and decide options doesn't include disableDecisionEvent
12641265
if OptimizelyDecideOption.DISABLE_DECISION_EVENT not in decide_options:
1265-
if decision_source == DecisionSources.FEATURE_TEST or project_config.send_flag_decisions:
1266+
if (decision_source == DecisionSources.FEATURE_TEST or
1267+
decision_source == DecisionSources.HOLDOUT or
1268+
project_config.send_flag_decisions):
12661269
self._send_impression_event(project_config,
12671270
flag_decision.experiment,
12681271
flag_decision.variation,

0 commit comments

Comments
 (0)