Skip to content

Commit 368b5fa

Browse files
[FSSDK-11571] Python: Add holdout support and refactor decision logic in DefaultDecisionService (#467)
* [FSSDK-11571] Python: Add holdout support and refactor decision logic in DefaultDecisionService * Fix all lint issues * Fix type issues * Fix type check error * Implement suggested comments * Implement suggested comments * Fix lint
1 parent 194fa14 commit 368b5fa

File tree

8 files changed

+1325
-87
lines changed

8 files changed

+1325
-87
lines changed

optimizely/bucketer.py

Lines changed: 62 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -119,16 +119,41 @@ def bucket(
119119
and array of log messages representing decision making.
120120
*/.
121121
"""
122+
# Check if experiment is None first
123+
if not experiment:
124+
message = 'Invalid entity key provided for bucketing. Returning nil.'
125+
project_config.logger.debug(message)
126+
return None, []
127+
128+
if isinstance(experiment, dict):
129+
# This is a holdout dictionary
130+
experiment_key = experiment.get('key', '')
131+
experiment_id = experiment.get('id', '')
132+
else:
133+
# This is an Experiment object
134+
experiment_key = experiment.key
135+
experiment_id = experiment.id
136+
137+
if not experiment_key or not experiment_key.strip():
138+
message = 'Invalid entity key provided for bucketing. Returning nil.'
139+
project_config.logger.debug(message)
140+
return None, []
141+
122142
variation_id, decide_reasons = self.bucket_to_entity_id(project_config, experiment, user_id, bucketing_id)
123143
if variation_id:
124-
variation = project_config.get_variation_from_id_by_experiment_id(experiment.id, variation_id)
144+
if isinstance(experiment, dict):
145+
# For holdouts, find the variation in the holdout's variations array
146+
variations = experiment.get('variations', [])
147+
variation = next((v for v in variations if v.get('id') == variation_id), None)
148+
else:
149+
# For experiments, use the existing method
150+
variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id)
125151
return variation, decide_reasons
126152

127-
else:
128-
message = 'Bucketed into an empty traffic range. Returning nil.'
129-
project_config.logger.info(message)
130-
decide_reasons.append(message)
131-
153+
# No variation found - log message for empty traffic range
154+
message = 'Bucketed into an empty traffic range. Returning nil.'
155+
project_config.logger.info(message)
156+
decide_reasons.append(message)
132157
return None, decide_reasons
133158

134159
def bucket_to_entity_id(
@@ -151,9 +176,25 @@ def bucket_to_entity_id(
151176
if not experiment:
152177
return None, decide_reasons
153178

179+
# Handle both Experiment objects and holdout dictionaries
180+
if isinstance(experiment, dict):
181+
# This is a holdout dictionary - holdouts don't have groups
182+
experiment_key = experiment.get('key', '')
183+
experiment_id = experiment.get('id', '')
184+
traffic_allocations = experiment.get('trafficAllocation', [])
185+
has_cmab = False
186+
group_policy = None
187+
else:
188+
# This is an Experiment object
189+
experiment_key = experiment.key
190+
experiment_id = experiment.id
191+
traffic_allocations = experiment.trafficAllocation
192+
has_cmab = bool(experiment.cmab)
193+
group_policy = getattr(experiment, 'groupPolicy', None)
194+
154195
# Determine if experiment is in a mutually exclusive group.
155-
# This will not affect evaluation of rollout rules.
156-
if experiment.groupPolicy in GROUP_POLICIES:
196+
# This will not affect evaluation of rollout rules or holdouts.
197+
if group_policy and group_policy in GROUP_POLICIES:
157198
group = project_config.get_group(experiment.groupId)
158199

159200
if not group:
@@ -169,26 +210,27 @@ def bucket_to_entity_id(
169210
decide_reasons.append(message)
170211
return None, decide_reasons
171212

172-
if user_experiment_id != experiment.id:
173-
message = f'User "{user_id}" is not in experiment "{experiment.key}" of group {experiment.groupId}.'
213+
if user_experiment_id != experiment_id:
214+
message = f'User "{user_id}" is not in experiment "{experiment_key}" of group {experiment.groupId}.'
174215
project_config.logger.info(message)
175216
decide_reasons.append(message)
176217
return None, decide_reasons
177218

178-
message = f'User "{user_id}" is in experiment {experiment.key} of group {experiment.groupId}.'
219+
message = f'User "{user_id}" is in experiment {experiment_key} of group {experiment.groupId}.'
179220
project_config.logger.info(message)
180221
decide_reasons.append(message)
181222

182-
traffic_allocations: list[TrafficAllocation] = experiment.trafficAllocation
183-
if experiment.cmab:
184-
traffic_allocations = [
185-
{
186-
"entityId": "$",
187-
"endOfRange": experiment.cmab['trafficAllocation']
188-
}
189-
]
223+
if has_cmab:
224+
if experiment.cmab:
225+
traffic_allocations = [
226+
{
227+
"entityId": "$",
228+
"endOfRange": experiment.cmab['trafficAllocation']
229+
}
230+
]
231+
190232
# Bucket user if not in white-list and in group (if any)
191233
variation_id = self.find_bucket(project_config, bucketing_id,
192-
experiment.id, traffic_allocations)
234+
experiment_id, traffic_allocations)
193235

194236
return variation_id, decide_reasons

optimizely/decision_service.py

Lines changed: 179 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
# limitations under the License.
1313

1414
from __future__ import annotations
15-
from typing import TYPE_CHECKING, NamedTuple, Optional, Sequence, List, TypedDict
15+
from typing import TYPE_CHECKING, NamedTuple, Optional, Sequence, List, TypedDict, Union
16+
17+
from optimizely.helpers.types import HoldoutDict, VariationDict
1618

1719
from . import bucketer
1820
from . import entities
@@ -59,7 +61,7 @@ class VariationResult(TypedDict):
5961
cmab_uuid: Optional[str]
6062
error: bool
6163
reasons: List[str]
62-
variation: Optional[entities.Variation]
64+
variation: Optional[Union[entities.Variation, VariationDict]]
6365

6466

6567
class DecisionResult(TypedDict):
@@ -80,7 +82,7 @@ class Decision(NamedTuple):
8082
"""Named tuple containing selected experiment, variation, source and cmab_uuid.
8183
None if no experiment/variation was selected."""
8284
experiment: Optional[entities.Experiment]
83-
variation: Optional[entities.Variation]
85+
variation: Optional[Union[entities.Variation, VariationDict]]
8486
source: Optional[str]
8587
cmab_uuid: Optional[str]
8688

@@ -670,7 +672,179 @@ def get_variation_for_feature(
670672
- 'error': Boolean indicating if an error occurred during the decision process.
671673
- 'reasons': List of log messages representing decision making for the feature.
672674
"""
673-
return self.get_variations_for_feature_list(project_config, [feature], user_context, options)[0]
675+
holdouts = project_config.get_holdouts_for_flag(feature.key)
676+
677+
if holdouts:
678+
# Has holdouts - use get_decision_for_flag which checks holdouts first
679+
return self.get_decision_for_flag(feature, user_context, project_config, options)
680+
else:
681+
return self.get_variations_for_feature_list(project_config, [feature], user_context, options)[0]
682+
683+
def get_decision_for_flag(
684+
self,
685+
feature_flag: entities.FeatureFlag,
686+
user_context: OptimizelyUserContext,
687+
project_config: ProjectConfig,
688+
decide_options: Optional[Sequence[str]] = None,
689+
user_profile_tracker: Optional[UserProfileTracker] = None,
690+
decide_reasons: Optional[list[str]] = None
691+
) -> DecisionResult:
692+
"""
693+
Get the decision for a single feature flag.
694+
Processes holdouts, experiments, and rollouts in that order.
695+
696+
Args:
697+
feature_flag: The feature flag to get a decision for.
698+
user_context: The user context.
699+
project_config: The project config.
700+
decide_options: Sequence of decide options.
701+
user_profile_tracker: The user profile tracker.
702+
decide_reasons: List of decision reasons to merge.
703+
704+
Returns:
705+
A DecisionResult for the feature flag.
706+
"""
707+
reasons = decide_reasons.copy() if decide_reasons else []
708+
user_id = user_context.user_id
709+
710+
# Check holdouts
711+
holdouts = project_config.get_holdouts_for_flag(feature_flag.key)
712+
for holdout in holdouts:
713+
holdout_decision = self.get_variation_for_holdout(holdout, user_context, project_config)
714+
reasons.extend(holdout_decision['reasons'])
715+
716+
decision = holdout_decision['decision']
717+
# Check if user was bucketed into holdout (has a variation)
718+
if decision.variation is None:
719+
continue
720+
721+
message = (
722+
f"The user '{user_id}' is bucketed into holdout '{holdout['key']}' "
723+
f"for feature flag '{feature_flag.key}'."
724+
)
725+
self.logger.info(message)
726+
reasons.append(message)
727+
return {
728+
'decision': holdout_decision['decision'],
729+
'error': False,
730+
'reasons': reasons
731+
}
732+
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'])
742+
743+
return {
744+
'decision': fallback_result['decision'],
745+
'error': fallback_result.get('error', False),
746+
'reasons': reasons
747+
}
748+
749+
def get_variation_for_holdout(
750+
self,
751+
holdout: HoldoutDict,
752+
user_context: OptimizelyUserContext,
753+
project_config: ProjectConfig
754+
) -> DecisionResult:
755+
"""
756+
Get the variation for holdout.
757+
758+
Args:
759+
holdout: The holdout configuration (HoldoutDict).
760+
user_context: The user context.
761+
project_config: The project config.
762+
763+
Returns:
764+
A DecisionResult for the holdout.
765+
"""
766+
from optimizely.helpers.enums import ExperimentAudienceEvaluationLogs
767+
768+
decide_reasons: list[str] = []
769+
user_id = user_context.user_id
770+
attributes = user_context.get_user_attributes()
771+
772+
if not holdout or not holdout.get('status') or holdout.get('status') != 'Running':
773+
key = holdout.get('key') if holdout else 'unknown'
774+
message = f"Holdout '{key}' is not running."
775+
self.logger.info(message)
776+
decide_reasons.append(message)
777+
return {
778+
'decision': Decision(None, None, enums.DecisionSources.HOLDOUT, None),
779+
'error': False,
780+
'reasons': decide_reasons
781+
}
782+
783+
bucketing_id, bucketing_id_reasons = self._get_bucketing_id(user_id, attributes)
784+
decide_reasons.extend(bucketing_id_reasons)
785+
786+
# Check audience conditions
787+
audience_conditions = holdout.get('audienceIds')
788+
user_meets_audience_conditions, reasons_received = audience_helper.does_user_meet_audience_conditions(
789+
project_config,
790+
audience_conditions,
791+
ExperimentAudienceEvaluationLogs,
792+
holdout.get('key', 'unknown'),
793+
user_context,
794+
self.logger
795+
)
796+
decide_reasons.extend(reasons_received)
797+
798+
if not user_meets_audience_conditions:
799+
message = (
800+
f"User '{user_id}' does not meet the conditions for holdout "
801+
f"'{holdout['key']}'."
802+
)
803+
self.logger.debug(message)
804+
decide_reasons.append(message)
805+
return {
806+
'decision': Decision(None, None, enums.DecisionSources.HOLDOUT, None),
807+
'error': False,
808+
'reasons': decide_reasons
809+
}
810+
811+
# Bucket user into holdout variation
812+
variation, bucket_reasons = self.bucketer.bucket(
813+
project_config, holdout, user_id, bucketing_id # type: ignore[arg-type]
814+
)
815+
decide_reasons.extend(bucket_reasons)
816+
817+
if variation:
818+
# For holdouts, variation is a dict, not a Variation entity
819+
variation_key = variation['key'] if isinstance(variation, dict) else variation.key
820+
message = (
821+
f"The user '{user_id}' is bucketed into variation '{variation_key}' "
822+
f"of holdout '{holdout['key']}'."
823+
)
824+
self.logger.info(message)
825+
decide_reasons.append(message)
826+
827+
# Create Decision for holdout - experiment is None, source is HOLDOUT
828+
holdout_decision: Decision = Decision(
829+
experiment=None,
830+
variation=variation,
831+
source=enums.DecisionSources.HOLDOUT,
832+
cmab_uuid=None
833+
)
834+
return {
835+
'decision': holdout_decision,
836+
'error': False,
837+
'reasons': decide_reasons
838+
}
839+
840+
message = f"User '{user_id}' is not bucketed into any variation for holdout '{holdout['key']}'."
841+
self.logger.info(message)
842+
decide_reasons.append(message)
843+
return {
844+
'decision': Decision(None, None, enums.DecisionSources.HOLDOUT, None),
845+
'error': False,
846+
'reasons': decide_reasons
847+
}
674848

675849
def validated_forced_decision(
676850
self,
@@ -779,7 +953,7 @@ def get_variations_for_feature_list(
779953
if feature.experimentIds:
780954
for experiment_id in feature.experimentIds:
781955
experiment = project_config.get_experiment_from_id(experiment_id)
782-
decision_variation = None
956+
decision_variation: Optional[Union[entities.Variation, VariationDict]] = None
783957

784958
if experiment:
785959
optimizely_decision_context = OptimizelyUserContext.OptimizelyDecisionContext(

optimizely/helpers/enums.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ class DecisionSources:
9999
EXPERIMENT: Final = 'experiment'
100100
FEATURE_TEST: Final = 'feature-test'
101101
ROLLOUT: Final = 'rollout'
102+
HOLDOUT: Final = 'holdout'
102103

103104

104105
class Errors:

optimizely/helpers/types.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
# limitations under the License.
1313
from __future__ import annotations
1414

15-
from typing import Optional, Any
15+
from typing import Literal, Optional, Any
1616
from sys import version_info
1717

1818

@@ -115,3 +115,16 @@ class CmabDict(BaseEntity):
115115
"""Cmab dict from parsed datafile json."""
116116
attributeIds: list[str]
117117
trafficAllocation: int
118+
119+
120+
HoldoutStatus = Literal['Draft', 'Running', 'Concluded', 'Archived']
121+
122+
123+
class HoldoutDict(ExperimentDict):
124+
"""Holdout dict from parsed datafile json.
125+
126+
Extends ExperimentDict with holdout-specific properties.
127+
"""
128+
holdoutStatus: HoldoutStatus
129+
includedFlags: list[str]
130+
excludedFlags: list[str]

0 commit comments

Comments
 (0)