Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 44 additions & 4 deletions optimizely/decision_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,24 @@ def get_variation_for_rollout(
return Decision(experiment=rule, variation=forced_decision_variation,
source=enums.DecisionSources.ROLLOUT, cmab_uuid=None), decide_reasons

# Check local holdouts targeting this specific delivery rule (FSSDK-12369)
local_holdouts = project_config.get_holdouts_for_rule(rule.id)
for holdout in local_holdouts:
local_holdout_decision = self.get_variation_for_holdout(
holdout, user_context, project_config
)
decide_reasons.extend(local_holdout_decision['reasons'])

local_decision = local_holdout_decision['decision']
if local_decision.variation is not None:
message = (
f"The user '{user_id}' is bucketed into local holdout '{holdout.key}' "
f"for delivery rule '{rule.key}'."
)
self.logger.info(message)
decide_reasons.append(message)
return local_decision, decide_reasons

bucketing_id, bucket_reasons = self._get_bucketing_id(user_id, attributes)
decide_reasons += bucket_reasons

Expand Down Expand Up @@ -733,9 +751,9 @@ def get_decision_for_flag(
reasons = decide_reasons.copy() if decide_reasons else []
user_id = user_context.user_id

# Check holdouts
holdouts = project_config.get_holdouts_for_flag(feature_flag.key)
for holdout in holdouts:
# Check global holdouts (flag level — before any rules are evaluated)
global_holdouts = project_config.get_global_holdouts()
for holdout in global_holdouts:
holdout_decision = self.get_variation_for_holdout(holdout, user_context, project_config)
reasons.extend(holdout_decision['reasons'])

Expand All @@ -756,7 +774,7 @@ def get_decision_for_flag(
'reasons': reasons
}

# If no holdout decision, check experiments then rollouts
# If no global holdout decision, check experiments then rollouts
if feature_flag.experimentIds:
for experiment_id in feature_flag.experimentIds:
experiment = project_config.get_experiment_from_id(experiment_id)
Expand All @@ -778,6 +796,28 @@ def get_decision_for_flag(
'reasons': reasons
}

# Check local holdouts targeting this specific experiment rule (FSSDK-12369)
local_holdouts = project_config.get_holdouts_for_rule(experiment.id)
for holdout in local_holdouts:
local_holdout_decision = self.get_variation_for_holdout(
holdout, user_context, project_config
)
reasons.extend(local_holdout_decision['reasons'])

local_decision = local_holdout_decision['decision']
if local_decision.variation is not None:
message = (
f"The user '{user_id}' is bucketed into local holdout '{holdout.key}' "
f"for experiment rule '{experiment.key}'."
)
self.logger.info(message)
reasons.append(message)
return {
'decision': local_holdout_decision['decision'],
'error': False,
'reasons': reasons
}

# Get variation for experiment
variation_result = self.get_variation(
project_config, experiment, user_context, user_profile_tracker, reasons, decide_options
Expand Down
15 changes: 15 additions & 0 deletions optimizely/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ def __init__(
trafficAllocation: list[TrafficAllocation],
audienceIds: list[str],
audienceConditions: Optional[Sequence[str | list[str]]] = None,
includedRules: Optional[list[str]] = None,
**kwargs: Any
):
self.id = id
Expand All @@ -232,6 +233,8 @@ def __init__(
self.trafficAllocation = trafficAllocation
self.audienceIds = audienceIds
self.audienceConditions = audienceConditions
# None = global holdout (applies to all rules); list of rule IDs = local holdout
self.included_rules: Optional[list[str]] = includedRules

def get_audience_conditions_or_ids(self) -> Sequence[str | list[str]]:
"""Returns audienceConditions if present, otherwise audienceIds.
Expand All @@ -241,6 +244,18 @@ def get_audience_conditions_or_ids(self) -> Sequence[str | list[str]]:
"""
return self.audienceConditions if self.audienceConditions is not None else self.audienceIds

@property
def is_global(self) -> bool:
"""Check if this is a global holdout (applies to all rules across all flags).

A holdout is global when includedRules is None (absent from datafile).
An empty list [] is a local holdout that targets no rules (different from global).

Returns:
True if included_rules is None (global), False otherwise (local).
"""
return self.included_rules is None

@property
def is_activated(self) -> bool:
"""Check if the holdout is activated (running).
Expand Down
1 change: 1 addition & 0 deletions optimizely/helpers/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,4 @@ class HoldoutDict(ExperimentDict):
Extends ExperimentDict with holdout-specific properties.
"""
holdoutStatus: HoldoutStatus
includedRules: Optional[list[str]] # None = global holdout; list of rule IDs = local holdout
47 changes: 33 additions & 14 deletions optimizely/project_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,10 @@ def __init__(self, datafile: str | bytes, logger: Logger, error_handler: Any):
holdouts_data: list[types.HoldoutDict] = config.get('holdouts', [])
self.holdouts: list[entities.Holdout] = []
self.holdout_id_map: dict[str, entities.Holdout] = {}
self.flag_holdouts_map: dict[str, list[entities.Holdout]] = {}
# Global holdouts (includedRules is None) — evaluated at flag level before any rule
self.global_holdouts: list[entities.Holdout] = []
# Rule-level holdouts — map from rule ID to holdouts targeting that rule
self.rule_holdouts_map: dict[str, list[entities.Holdout]] = {}

# Convert holdout dicts to Holdout entities
for holdout_data in holdouts_data:
Expand All @@ -108,6 +111,16 @@ def __init__(self, datafile: str | bytes, logger: Logger, error_handler: Any):
# Map by ID for quick lookup
self.holdout_id_map[holdout.id] = holdout

# Classify holdout as global or local based on includedRules
if holdout.is_global:
self.global_holdouts.append(holdout)
else:
# Local holdout — register for each targeted rule ID
for rule_id in (holdout.included_rules or []):
if rule_id not in self.rule_holdouts_map:
self.rule_holdouts_map[rule_id] = []
self.rule_holdouts_map[rule_id].append(holdout)

# Utility maps for quick lookup
self.group_id_map: dict[str, entities.Group] = self._generate_key_map(self.groups, 'id', entities.Group)
self.experiment_id_map: dict[str, entities.Experiment] = self._generate_key_map(
Expand Down Expand Up @@ -240,11 +253,6 @@ def __init__(self, datafile: str | bytes, logger: Logger, error_handler: Any):
everyone_else_variation.variables, 'id', entities.Variation.VariableUsage
)

# Map all running holdouts to this flag
applicable_holdouts = list(self.holdout_id_map.values())
if applicable_holdouts:
self.flag_holdouts_map[feature.key] = applicable_holdouts

rollout = None if len(feature.rolloutId) == 0 else self.rollout_id_map[feature.rolloutId]
if rollout:
for exp in rollout.experiments:
Expand Down Expand Up @@ -878,19 +886,30 @@ def get_flag_variation(

return None

def get_holdouts_for_flag(self, flag_key: str) -> list[entities.Holdout]:
""" Helper method to get holdouts from an applied feature flag.
def get_global_holdouts(self) -> list[entities.Holdout]:
"""Return all global holdouts (includedRules is None).

Args:
flag_key: Key of the feature flag.
Global holdouts are evaluated at flag level before any rule is checked.

Returns:
The holdouts that apply for a specific flag as Holdout entity objects.
List of global Holdout entities that are currently running.
"""
if not self.holdouts:
return []
return self.global_holdouts

def get_holdouts_for_rule(self, rule_id: str) -> list[entities.Holdout]:
"""Return local holdouts that target a specific rule.

return self.flag_holdouts_map.get(flag_key, [])
Local holdouts are evaluated per-rule, before the rule's audience and
traffic allocation checks. A rule ID not present in any holdout's
includedRules simply returns an empty list — silently skipped.

Args:
rule_id: The experiment or delivery rule ID to look up.

Returns:
List of local Holdout entities targeting the given rule ID.
"""
return self.rule_holdouts_map.get(rule_id, [])

def get_holdout(self, holdout_id: str) -> Optional[entities.Holdout]:
""" Helper method to get holdout from holdout ID.
Expand Down
Loading
Loading