Skip to content

Commit dbbc051

Browse files
Refact: Refactored Forced decision (#365)
* project config refactor * use existing loop to generate flag_variation_map * get_variation_from_experiment_rule and get_variation_from_delivery_rule removed * fsc test fix * comment addressed * commented code removed * comments from main forced decision PR resolved Co-authored-by: ozayr-zaviar <uzairzaviar@gmail.com>
1 parent 795b41a commit dbbc051

File tree

3 files changed

+116
-184
lines changed

3 files changed

+116
-184
lines changed

optimizely/decision_service.py

Lines changed: 88 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -349,153 +349,94 @@ def get_variation_for_rollout(self, project_config, feature, user):
349349
array of log messages representing decision making.
350350
"""
351351
decide_reasons = []
352+
user_id = user.user_id
353+
attributes = user.get_user_attributes()
352354

353355
if not feature or not feature.rolloutId:
354356
return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons
355357

356358
rollout = project_config.get_rollout_from_id(feature.rolloutId)
357-
rollout_rules = project_config.get_rollout_experiments(rollout)
358359

359-
if not rollout or not rollout_rules:
360+
if not rollout:
360361
message = 'There is no rollout of feature {}.'.format(feature.key)
361362
self.logger.debug(message)
362363
decide_reasons.append(message)
363364
return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons
364365

366+
rollout_rules = project_config.get_rollout_experiments(rollout)
367+
368+
if not rollout_rules:
369+
message = 'Rollout {} has no experiments.'.format(rollout.id)
370+
self.logger.debug(message)
371+
decide_reasons.append(message)
372+
return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons
373+
365374
index = 0
366375
while index < len(rollout_rules):
367-
decision_response, reasons_received = self.get_variation_from_delivery_rule(project_config,
368-
feature,
369-
rollout_rules, index, user)
376+
skip_to_everyone_else = False
370377

378+
# check forced decision first
379+
rule = rollout_rules[index]
380+
optimizely_decision_context = OptimizelyUserContext.OptimizelyDecisionContext(feature.key, rule.key)
381+
forced_decision_variation, reasons_received = user.find_validated_forced_decision(
382+
optimizely_decision_context)
371383
decide_reasons += reasons_received
372384

373-
variation, skip_to_everyone_else = decision_response
374-
375-
if variation:
376-
rule = rollout_rules[index]
377-
feature_decision = Decision(experiment=rule, variation=variation,
378-
source=enums.DecisionSources.ROLLOUT)
379-
380-
return feature_decision, decide_reasons
381-
382-
# the last rule is special for "Everyone Else"
383-
index = len(rollout_rules) - 1 if skip_to_everyone_else else index + 1
384-
385-
return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons
386-
387-
def get_variation_from_experiment_rule(self, config, flag_key, rule, user, options):
388-
""" Checks for experiment rule if decision is forced and returns it.
389-
Otherwise returns a regular decision.
390-
391-
Args:
392-
config: Instance of ProjectConfig.
393-
flag_key: Key of the flag.
394-
rule: Experiment rule.
395-
user: ID and attributes for user.
396-
options: Decide options.
397-
398-
Returns:
399-
Decision namedtuple consisting of experiment and variation for the user and
400-
array of log messages representing decision making.
401-
"""
402-
decide_reasons = []
403-
404-
# check forced decision first
405-
optimizely_decision_context = OptimizelyUserContext.OptimizelyDecisionContext(flag_key, rule.key)
406-
407-
forced_decision_variation, reasons_received = user.find_validated_forced_decision(
408-
optimizely_decision_context)
409-
decide_reasons += reasons_received
410-
411-
if forced_decision_variation:
412-
return forced_decision_variation, decide_reasons
413-
414-
# regular decision
415-
decision_variation, variation_reasons = self.get_variation(config, rule, user, options)
416-
decide_reasons += variation_reasons
417-
return decision_variation, decide_reasons
418-
419-
def get_variation_from_delivery_rule(self, config, feature, rules, rule_index, user):
420-
""" Checks for delivery rule if decision is forced and returns it.
421-
Otherwise returns a regular decision.
385+
if forced_decision_variation:
386+
return Decision(experiment=rule, variation=forced_decision_variation,
387+
source=enums.DecisionSources.ROLLOUT), decide_reasons
422388

423-
Args:
424-
config: Instance of ProjectConfig.
425-
flag_key: Key of the flag.
426-
rules: Experiment rule.
427-
rule_index: integer index of the rule in the list.
428-
user: ID and attributes for user.
389+
bucketing_id, bucket_reasons = self._get_bucketing_id(user_id, attributes)
390+
decide_reasons += bucket_reasons
429391

430-
Returns:
431-
If forced decision, it returns namedtuple consisting of forced_decision_variation and skip_to_everyone_else
432-
and decision reason log messages.
392+
everyone_else = (index == len(rollout_rules) - 1)
393+
logging_key = "Everyone Else" if everyone_else else str(index + 1)
433394

434-
If regular decision it returns a tuple of bucketed_variation and skip_to_everyone_else
435-
and decision reason log messages
436-
"""
437-
decide_reasons = []
438-
skip_to_everyone_else = False
439-
bucketed_variation = None
395+
rollout_rule = project_config.get_experiment_from_id(rule.id)
396+
audience_conditions = rollout_rule.get_audience_conditions_or_ids()
440397

441-
# check forced decision first
442-
rule = rules[rule_index]
443-
optimizely_decision_context = OptimizelyUserContext.OptimizelyDecisionContext(feature.key, rule.key)
444-
forced_decision_variation, reasons_received = user.find_validated_forced_decision(optimizely_decision_context)
398+
audience_decision_response, reasons_received_audience = audience_helper.does_user_meet_audience_conditions(
399+
project_config, audience_conditions, enums.RolloutRuleAudienceEvaluationLogs,
400+
logging_key, attributes, self.logger)
445401

446-
decide_reasons += reasons_received
447-
448-
if forced_decision_variation:
449-
return (forced_decision_variation, skip_to_everyone_else), decide_reasons
402+
decide_reasons += reasons_received_audience
450403

451-
# regular decision
452-
user_id = user.user_id
453-
attributes = user.get_user_attributes()
454-
455-
bucketing_id, bucket_reasons = self._get_bucketing_id(user_id, attributes)
456-
decide_reasons += bucket_reasons
457-
458-
everyone_else = (rule_index == len(rules) - 1)
459-
logging_key = "Everyone Else" if everyone_else else str(rule_index + 1)
460-
461-
rollout_rule = config.get_experiment_from_id(rule.id)
462-
audience_conditions = rollout_rule.get_audience_conditions_or_ids()
463-
464-
audience_decision_response, reasons_received_audience = audience_helper.does_user_meet_audience_conditions(
465-
config, audience_conditions, enums.RolloutRuleAudienceEvaluationLogs, logging_key, attributes, self.logger)
466-
467-
decide_reasons += reasons_received_audience
404+
if audience_decision_response:
405+
message = 'User "{}" meets audience conditions for targeting rule {}.'.format(user_id, logging_key)
406+
self.logger.debug(message)
407+
decide_reasons.append(message)
468408

469-
if audience_decision_response:
470-
message = 'User "{}" meets audience conditions for targeting rule {}.'.format(user_id, logging_key)
471-
self.logger.debug(message)
472-
decide_reasons.append(message)
409+
bucketed_variation, bucket_reasons = self.bucketer.bucket(project_config, rollout_rule, user_id,
410+
bucketing_id)
411+
decide_reasons.extend(bucket_reasons)
473412

474-
bucketed_variation, bucket_reasons = self.bucketer.bucket(config, rollout_rule, user_id,
475-
bucketing_id)
476-
decide_reasons.extend(bucket_reasons)
413+
if bucketed_variation:
414+
message = 'User "{}" bucketed into a targeting rule {}.'.format(user_id, logging_key)
415+
self.logger.debug(message)
416+
decide_reasons.append(message)
417+
return Decision(experiment=rule, variation=bucketed_variation,
418+
source=enums.DecisionSources.ROLLOUT), decide_reasons
419+
420+
elif not everyone_else:
421+
# skip this logging for EveryoneElse since this has a message not for everyone_else
422+
message = 'User "{}" not bucketed into a targeting rule {}. ' \
423+
'Checking "Everyone Else" rule now.'.format(user_id, logging_key)
424+
self.logger.debug(message)
425+
decide_reasons.append(message)
477426

478-
if bucketed_variation:
479-
message = 'User "{}" bucketed into a targeting rule {}.'.format(user_id, logging_key)
480-
self.logger.debug(message)
481-
decide_reasons.append(message)
427+
# skip the rest of rollout rules to the everyone-else rule if audience matches but not bucketed.
428+
skip_to_everyone_else = True
482429

483-
elif not everyone_else:
484-
# skip this logging for EveryoneElse since this has a message not for everyone_else
485-
message = 'User "{}" not bucketed into a targeting rule {}. ' \
486-
'Checking "Everyone Else" rule now.'.format(user_id, logging_key)
430+
else:
431+
message = 'User "{}" does not meet audience conditions for targeting rule {}.'.format(
432+
user_id, logging_key)
487433
self.logger.debug(message)
488434
decide_reasons.append(message)
489435

490-
# skip the rest of rollout rules to the everyone-else rule if audience matches but not bucketed.
491-
skip_to_everyone_else = True
492-
493-
else:
494-
message = 'User "{}" does not meet audience conditions for targeting rule {}.'.format(user_id, logging_key)
495-
self.logger.debug(message)
496-
decide_reasons.append(message)
436+
# the last rule is special for "Everyone Else"
437+
index = len(rollout_rules) - 1 if skip_to_everyone_else else index + 1
497438

498-
return (bucketed_variation, skip_to_everyone_else), decide_reasons
439+
return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons
499440

500441
def get_variation_for_feature(self, project_config, feature, user_context, options=None):
501442
""" Returns the experiment/variation the user is bucketed in for the given feature.
@@ -517,11 +458,34 @@ def get_variation_for_feature(self, project_config, feature, user_context, optio
517458
# Evaluate each experiment ID and return the first bucketed experiment variation
518459
for experiment in feature.experimentIds:
519460
experiment = project_config.get_experiment_from_id(experiment)
520-
if experiment:
521-
variation, variation_reasons = self.get_variation_from_experiment_rule(
522-
project_config, feature.key, experiment, user_context, options)
523-
decide_reasons += variation_reasons
524-
if variation:
525-
return Decision(experiment, variation, enums.DecisionSources.FEATURE_TEST), decide_reasons
461+
decision_variation = None
526462

527-
return self.get_variation_for_rollout(project_config, feature, user_context)
463+
if experiment:
464+
optimizely_decision_context = OptimizelyUserContext.OptimizelyDecisionContext(feature.key,
465+
experiment.key)
466+
467+
forced_decision_variation, reasons_received = user_context.find_validated_forced_decision(
468+
optimizely_decision_context)
469+
decide_reasons += reasons_received
470+
471+
if forced_decision_variation:
472+
decision_variation = forced_decision_variation
473+
else:
474+
decision_variation, variation_reasons = self.get_variation(project_config,
475+
experiment, user_context, options)
476+
decide_reasons += variation_reasons
477+
478+
if decision_variation:
479+
message = 'User "{}" bucketed into a experiment "{}" of feature "{}".'.format(
480+
user_context.user_id, experiment.key, feature.key)
481+
self.logger.debug(message)
482+
return Decision(experiment, decision_variation,
483+
enums.DecisionSources.FEATURE_TEST), decide_reasons
484+
485+
message = 'User "{}" is not bucketed into any of the experiments on the feature "{}".'.format(
486+
user_context.user_id, feature.key)
487+
self.logger.debug(message)
488+
variation, rollout_variation_reasons = self.get_variation_for_rollout(project_config, feature, user_context)
489+
if rollout_variation_reasons:
490+
decide_reasons += rollout_variation_reasons
491+
return variation, decide_reasons

optimizely/project_config.py

Lines changed: 26 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ def __init__(self, datafile, logger, error_handler):
100100
self.variation_variable_usage_map = {}
101101
self.variation_id_map_by_experiment_id = {}
102102
self.variation_key_map_by_experiment_id = {}
103+
self.flag_variations_map = {}
103104

104105
for experiment in self.experiment_id_map.values():
105106
self.experiment_key_map[experiment.key] = experiment
@@ -121,45 +122,38 @@ def __init__(self, datafile, logger, error_handler):
121122

122123
self.feature_key_map = self._generate_key_map(self.feature_flags, 'key', entities.FeatureFlag)
123124

124-
# As we cannot create json variables in datafile directly, here we convert
125-
# the variables of string type and json subType to json type
126-
# This is needed to fully support json variables
125+
# Dictionary containing dictionary of experiment ID to feature ID.
126+
# for checking that experiment is a feature experiment or not.
127127
self.experiment_feature_map = {}
128-
self.flag_rules_map = {}
129-
130-
for feature_key, feature_value in self.feature_key_map.items():
131-
for variable in self.feature_key_map[feature_key].variables:
128+
for feature in self.feature_key_map.values():
129+
# As we cannot create json variables in datafile directly, here we convert
130+
# the variables of string type and json subType to json type
131+
# This is needed to fully support json variables
132+
for variable in self.feature_key_map[feature.key].variables:
132133
sub_type = variable.get('subType', '')
133134
if variable['type'] == entities.Variable.Type.STRING and sub_type == entities.Variable.Type.JSON:
134135
variable['type'] = entities.Variable.Type.JSON
135136

136-
# loop over features=flags already happening
137-
# get feature variables for eacg flag/feature
138-
feature_value.variables = self._generate_key_map(feature_value.variables, 'key', entities.Variable)
139-
for exp_id in feature_value.experimentIds:
140-
# Add this experiment in experiment-feature map.
141-
self.experiment_feature_map[exp_id] = [feature_value.id]
142-
143-
# all rules(experiment rules and delivery rules) for each flag
144-
for flag in self.feature_flags:
145-
experiments = []
146-
if len(flag['experimentIds']) > 0:
147-
for exp_id in flag['experimentIds']:
148-
experiments.append(self.experiment_id_map[exp_id])
149-
if not flag['rolloutId'] == '':
150-
rollout = self.rollout_id_map[flag['rolloutId']]
151-
152-
rollout_experiments = self.get_rollout_experiments(rollout)
153-
154-
if rollout and rollout.experiments:
155-
experiments.extend(rollout_experiments)
137+
feature.variables = self._generate_key_map(feature.variables, 'key', entities.Variable)
156138

157-
self.flag_rules_map[flag['key']] = experiments
139+
rules = []
140+
variations = []
141+
for exp_id in feature.experimentIds:
142+
# Add this experiment in experiment-feature map.
143+
self.experiment_feature_map[exp_id] = [feature.id]
144+
rules.append(self.experiment_id_map[exp_id])
145+
rollout = None if len(feature.rolloutId) == 0 else self.rollout_id_map[feature.rolloutId]
146+
if rollout:
147+
for exp in rollout.experiments:
148+
rules.append(self.experiment_id_map[exp['id']])
158149

159-
# All variations for each flag
160-
# Datafile does not contain a separate entity for this.
161-
# We collect variations used in each rule (experiment rules and delivery rules)
162-
self.flag_variations_map = self.get_all_variations_for_each_rule(self.flag_rules_map)
150+
for rule in rules:
151+
# variation_id_map_by_experiment_id gives variation entity object while
152+
# experiment_id_map will give us dictionary
153+
for rule_variation in self.variation_id_map_by_experiment_id.get(rule.id).values():
154+
if len(list(filter(lambda variation: variation.id == rule_variation.id, variations))) == 0:
155+
variations.append(rule_variation)
156+
self.flag_variations_map[feature.key] = variations
163157

164158
@staticmethod
165159
def _generate_key_map(entity_list, key, entity_class):
@@ -639,32 +633,6 @@ def get_variation_from_key_by_experiment_id(self, experiment_id, variation_key):
639633

640634
return {}
641635

642-
def get_all_variations_for_each_rule(self, flag_rules_map):
643-
""" Helper method to get all variation objects from each flag.
644-
collects variations used in each rule (experiment rules and delivery rules).
645-
646-
Args:
647-
flag_rules_map: A dictionary. A map of all rules per flag.
648-
649-
Returns:
650-
Map of flag variations.
651-
"""
652-
flag_variations_map = {}
653-
654-
for flag_key, rules in flag_rules_map.items():
655-
variations = []
656-
for rule in rules:
657-
# get variations as objects (rule.variations gives list)
658-
variation_objects = self.variation_id_map_by_experiment_id[rule.id].values()
659-
for variation in variation_objects:
660-
# append variation if it's not already in the list
661-
if variation not in variations:
662-
variations.append(variation)
663-
664-
flag_variations_map[flag_key] = variations
665-
666-
return flag_variations_map
667-
668636
def get_flag_variation(self, flag_key, variation_attribute, target_value):
669637
"""
670638
Gets variation by specified variation attribute.

tests/test_decision_service.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1325,8 +1325,8 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_rollout(sel
13251325
)
13261326

13271327
# Assert no log messages were generated
1328-
self.assertEqual(0, mock_decision_service_logging.debug.call_count)
1329-
self.assertEqual(0, len(mock_decision_service_logging.method_calls))
1328+
self.assertEqual(1, mock_decision_service_logging.debug.call_count)
1329+
self.assertEqual(1, len(mock_decision_service_logging.method_calls))
13301330

13311331
def test_get_variation_for_feature__returns_variation_if_user_not_in_experiment_but_in_rollout(
13321332
self,

0 commit comments

Comments
 (0)