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
43 changes: 40 additions & 3 deletions optimizely/decision_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -599,8 +599,25 @@ def get_variation_for_rollout(
while index < len(rollout_rules):
skip_to_everyone_else = False

# check forced decision first
rule = rollout_rules[index]

# Check local holdouts targeting this delivery rule (NEW - local holdouts)
local_holdouts = project_config.get_holdouts_for_rule(rule.id)
for local_holdout in local_holdouts:
local_holdout_decision = self.get_variation_for_holdout(
local_holdout, user_context, project_config
)
decide_reasons.extend(local_holdout_decision['reasons'])
if local_holdout_decision['decision'].variation is not None:
message = (
f"The user '{user_id}' is bucketed into local holdout "
f"'{local_holdout.key}' for rollout rule '{rule.key}'."
)
self.logger.info(message)
decide_reasons.append(message)
return local_holdout_decision['decision'], decide_reasons

# check forced decision first
optimizely_decision_context = OptimizelyUserContext.OptimizelyDecisionContext(feature.key, rule.key)
forced_decision_variation, reasons_received = self.validated_forced_decision(
project_config, optimizely_decision_context, user_context)
Expand Down Expand Up @@ -733,8 +750,8 @@ 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)
# Check global holdouts (evaluated at flag level, before forced decisions)
holdouts = project_config.get_global_holdouts()
for holdout in holdouts:
holdout_decision = self.get_variation_for_holdout(holdout, user_context, project_config)
reasons.extend(holdout_decision['reasons'])
Expand Down Expand Up @@ -762,6 +779,26 @@ def get_decision_for_flag(
experiment = project_config.get_experiment_from_id(experiment_id)

if experiment:
# Check local holdouts targeting this experiment rule (NEW - local holdouts)
local_holdouts = project_config.get_holdouts_for_rule(experiment.id)
for local_holdout in local_holdouts:
local_holdout_decision = self.get_variation_for_holdout(
local_holdout, user_context, project_config
)
reasons.extend(local_holdout_decision['reasons'])
if local_holdout_decision['decision'].variation is not None:
message = (
f"The user '{user_id}' is bucketed into local holdout "
f"'{local_holdout.key}' for experiment rule '{experiment.key}'."
)
self.logger.info(message)
reasons.append(message)
return {
'decision': local_holdout_decision['decision'],
'error': False,
'reasons': reasons
}

# Check for forced decision
optimizely_decision_context = OptimizelyUserContext.OptimizelyDecisionContext(
feature_flag.key, experiment.key)
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 @@ -253,6 +256,18 @@ def is_activated(self) -> bool:
"""
return self.status == self.Status.RUNNING

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

A holdout is global when includedRules is None.
An empty list [] means a local holdout with no matching rules (not global).

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

def __str__(self) -> str:
return self.key

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
49 changes: 44 additions & 5 deletions optimizely/project_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,12 @@ 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] = {}
# Legacy flag-level map kept for backward compatibility
self.flag_holdouts_map: dict[str, list[entities.Holdout]] = {}
# Global holdouts (includedRules is None) — evaluated at flag level
self.global_holdouts: list[entities.Holdout] = []
# Rule-level holdouts map: rule_id -> [Holdout] for local holdouts
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 +113,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

# Categorize holdout as global or local
if holdout.is_global:
self.global_holdouts.append(holdout)
else:
# Local holdout: map each included rule ID to this holdout
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,10 +255,9 @@ 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
# Map global holdouts to this flag (for legacy flag-level access)
if self.global_holdouts:
self.flag_holdouts_map[feature.key] = list(self.global_holdouts)

rollout = None if len(feature.rolloutId) == 0 else self.rollout_id_map[feature.rolloutId]
if rollout:
Expand Down Expand Up @@ -881,17 +895,42 @@ def get_flag_variation(
def get_holdouts_for_flag(self, flag_key: str) -> list[entities.Holdout]:
""" Helper method to get holdouts from an applied feature flag.

Returns global holdouts for the given flag (backward-compatible).

Args:
flag_key: Key of the feature flag.

Returns:
The holdouts that apply for a specific flag as Holdout entity objects.
The global holdouts that apply for a specific flag as Holdout entity objects.
"""
if not self.holdouts:
return []

return self.flag_holdouts_map.get(flag_key, [])

def get_global_holdouts(self) -> list[entities.Holdout]:
"""Return all global holdouts (holdouts with includedRules == None).

Global holdouts are evaluated at flag level before forced decisions.

Returns:
List of global Holdout entities.
"""
return self.global_holdouts

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

Local holdouts are evaluated per-rule before audience and traffic checks.

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

Returns:
List of 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