Skip to content

feat: Duplicate experiment key issue with multi feature flag #282

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Jul 26, 2021
15 changes: 9 additions & 6 deletions lib/optimizely.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# frozen_string_literal: true

#
# Copyright 2016-2020, Optimizely and contributors
# Copyright 2016-2021, Optimizely and contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -877,12 +877,14 @@ def get_variation_with_config(experiment_key, user_id, attributes, config)
experiment = config.get_experiment_from_key(experiment_key)
return nil if experiment.nil?

experiment_id = experiment['id']

return nil unless user_inputs_valid?(attributes)

variation_id, = @decision_service.get_variation(config, experiment_key, user_id, attributes)
variation_id, = @decision_service.get_variation(config, experiment_id, user_id, attributes)
variation = config.get_variation_from_id(experiment_key, variation_id) unless variation_id.nil?
variation_key = variation['key'] if variation
decision_notification_type = if config.feature_experiment?(experiment['id'])
decision_notification_type = if config.feature_experiment?(experiment_id)
Helpers::Constants::DECISION_NOTIFICATION_TYPES['FEATURE_TEST']
else
Helpers::Constants::DECISION_NOTIFICATION_TYPES['AB_TEST']
Expand Down Expand Up @@ -1078,10 +1080,11 @@ def send_impression(config, experiment, variation_key, flag_key, rule_key, enabl
}
end

experiment_id = experiment['id']
experiment_key = experiment['key']

variation_id = ''
variation_id = config.get_variation_id_from_key(experiment_key, variation_key) if experiment_key != ''
variation_id = config.get_variation_id_from_key_by_experiment_id(experiment_id, variation_key) if experiment_id != ''

metadata = {
flag_key: flag_key,
Expand All @@ -1097,9 +1100,9 @@ def send_impression(config, experiment, variation_key, flag_key, rule_key, enabl

@logger.log(Logger::INFO, "Activating user '#{user_id}' in experiment '#{experiment_key}'.")

experiment = nil if experiment_key == ''
experiment = nil if experiment_id == ''
variation = nil
variation = config.get_variation_from_id(experiment_key, variation_id) unless experiment.nil?
variation = config.get_variation_from_id_by_experiment_id(experiment_id, variation_id) unless experiment.nil?
log_event = EventFactory.create_log_event(user_event, @logger)
@notification_center.send_notifications(
NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE],
Expand Down
4 changes: 2 additions & 2 deletions lib/optimizely/bucketer.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# frozen_string_literal: true

#
# Copyright 2016-2017, 2019-2020 Optimizely and contributors
# Copyright 2016-2017, 2019-2021 Optimizely and contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -88,7 +88,7 @@ def bucket(project_config, experiment, bucketing_id, user_id)
decide_reasons.push(*find_bucket_reasons)

if variation_id && variation_id != ''
variation = project_config.get_variation_from_id(experiment_key, variation_id)
variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id)
return variation, decide_reasons
end

Expand Down
93 changes: 80 additions & 13 deletions lib/optimizely/config/datafile_project_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,12 @@ class DatafileProjectConfig < ProjectConfig
attr_reader :feature_variable_key_map
attr_reader :group_id_map
attr_reader :rollout_id_map
attr_reader :rollout_experiment_key_map
attr_reader :rollout_experiment_id_map
attr_reader :variation_id_map
attr_reader :variation_id_to_variable_usage_map
attr_reader :variation_key_map
attr_reader :variation_id_map_by_experiment_id
attr_reader :variation_key_map_by_experiment_id

def initialize(datafile, logger, error_handler)
# ProjectConfig init method to fetch and set project config data
Expand Down Expand Up @@ -117,9 +119,11 @@ def initialize(datafile, logger, error_handler)
@audience_id_map = @audience_id_map.merge(generate_key_map(@typed_audiences, 'id')) unless @typed_audiences.empty?
@variation_id_map = {}
@variation_key_map = {}
@variation_id_map_by_experiment_id = {}
@variation_key_map_by_experiment_id = {}
@variation_id_to_variable_usage_map = {}
@variation_id_to_experiment_map = {}
@experiment_key_map.each_value do |exp|
@experiment_id_map.each_value do |exp|
# Excludes experiments from rollouts
variations = exp.fetch('variations')
variations.each do |variation|
Expand All @@ -129,13 +133,13 @@ def initialize(datafile, logger, error_handler)
end
@rollout_id_map = generate_key_map(@rollouts, 'id')
# split out the experiment key map for rollouts
@rollout_experiment_key_map = {}
@rollout_experiment_id_map = {}
@rollout_id_map.each_value do |rollout|
exps = rollout.fetch('experiments')
@rollout_experiment_key_map = @rollout_experiment_key_map.merge(generate_key_map(exps, 'key'))
@rollout_experiment_id_map = @rollout_experiment_id_map.merge(generate_key_map(exps, 'id'))
end
@all_experiments = @experiment_key_map.merge(@rollout_experiment_key_map)
@all_experiments.each do |key, exp|
@all_experiments = @experiment_id_map.merge(@rollout_experiment_id_map)
@all_experiments.each do |id, exp|
variations = exp.fetch('variations')
variations.each do |variation|
variation_id = variation['id']
Expand All @@ -145,8 +149,10 @@ def initialize(datafile, logger, error_handler)

@variation_id_to_variable_usage_map[variation_id] = generate_key_map(variation_variables, 'id')
end
@variation_id_map[key] = generate_key_map(variations, 'id')
@variation_key_map[key] = generate_key_map(variations, 'key')
@variation_id_map[exp['key']] = generate_key_map(variations, 'id')
@variation_key_map[exp['key']] = generate_key_map(variations, 'key')
@variation_id_map_by_experiment_id[id] = generate_key_map(variations, 'id')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need it by experiment id? i assume variation ids are unique? if yes, then a simple map with variation ids as keys and variations as values would suffice? It was probably required in case of variation keys because keys can be reused across the experiments and you needed to specify which experiment to look into, but for variation id, i am not sure if experiment info is needed at all?

@variation_key_map_by_experiment_id[id] = generate_key_map(variations, 'key')
Comment on lines +152 to +155
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These variable names are really confusing. Looking at variation_id_map, it looks like its a map of variations with variation ids as keys but looks like the key is the experiment id which contains all the variations for that experiment. We might want to consider refactoring it later for better and more intuitive naming. Or if possible and if it makes sense to you , can you rename these variables to reflect more of what they actually are. variation_id_map can probably be variation_map_by_experiment_id etc. You can certainly think of a better variable name or can also leave it like that to refactor it later, i wont block the review on this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's talk offline.

end
@feature_flag_key_map = generate_key_map(@feature_flags, 'key')
@experiment_feature_map = {}
Expand Down Expand Up @@ -213,6 +219,21 @@ def get_experiment_from_key(experiment_key)
nil
end

def get_experiment_from_id(experiment_id)
# Retrieves experiment ID for a given key
#
# experiment_id - String id representing the experiment
#
# Returns Experiment or nil if not found

experiment = @experiment_id_map[experiment_id]
return experiment if experiment

@logger.log Logger::ERROR, "Experiment id '#{experiment_id}' is not in datafile."
@error_handler.handle_error InvalidExperimentError
nil
end

def get_experiment_key(experiment_id)
# Retrieves experiment key for a given ID.
#
Expand Down Expand Up @@ -281,6 +302,52 @@ def get_variation_from_id(experiment_key, variation_id)
nil
end

def get_variation_from_id_by_experiment_id(experiment_id, variation_id)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can variation ids duplicate across experiments? why do we need experiment_id if we have variation_id. shouldn't variation_id be enought to uniquely identify and fetch a variation?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes as far as I know variation Ids are unique but I was following this method def get_variation_from_id(experiment_key, variation_id)

# Get variation given experiment ID and variation ID
#
# experiment_id - ID representing parent experiment of variation
# variation_id - ID of the variation
#
# Returns the variation or nil if not found

variation_id_map_by_experiment_id = @variation_id_map_by_experiment_id[experiment_id]
if variation_id_map_by_experiment_id
variation = variation_id_map_by_experiment_id[variation_id]
return variation if variation

@logger.log Logger::ERROR, "Variation id '#{variation_id}' is not in datafile."
@error_handler.handle_error InvalidVariationError
return nil
end

@logger.log Logger::ERROR, "Experiment id '#{experiment_id}' is not in datafile."
@error_handler.handle_error InvalidExperimentError
nil
end

def get_variation_id_from_key_by_experiment_id(experiment_id, variation_key)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes sense because variation_key can probably duplicate across experiments.

# Get variation given experiment ID and variation key
#
# experiment_id - ID representing parent experiment of variation
# variation_key - Key of the variation
#
# Returns the variation or nil if not found

variation_key_map = @variation_key_map_by_experiment_id[experiment_id]
if variation_key_map
variation = variation_key_map[variation_key]
return variation['id'] if variation

@logger.log Logger::ERROR, "Variation key '#{variation_key}' is not in datafile."
@error_handler.handle_error InvalidVariationError
return nil
end

@logger.log Logger::ERROR, "Experiment id '#{experiment_id}' is not in datafile."
@error_handler.handle_error InvalidExperimentError
nil
end

def get_variation_id_from_key(experiment_key, variation_key)
# Get variation ID given experiment key and variation key
#
Expand All @@ -304,17 +371,17 @@ def get_variation_id_from_key(experiment_key, variation_key)
nil
end

def get_whitelisted_variations(experiment_key)
# Retrieves whitelisted variations for a given experiment Key
def get_whitelisted_variations(experiment_id)
# Retrieves whitelisted variations for a given experiment id
#
# experiment_key - String Key representing the experiment
# experiment_id - String id representing the experiment
#
# Returns whitelisted variations for the experiment or nil

experiment = @experiment_key_map[experiment_key]
experiment = @experiment_id_map[experiment_id]
return experiment['forcedVariations'] if experiment

@logger.log Logger::ERROR, "Experiment key '#{experiment_key}' is not in datafile."
@logger.log Logger::ERROR, "Experiment ID '#{experiment_id}' is not in datafile."
@error_handler.handle_error InvalidExperimentError
end

Expand Down
40 changes: 20 additions & 20 deletions lib/optimizely/decision_service.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# frozen_string_literal: true

#
# Copyright 2017-2020, Optimizely and contributors
# Copyright 2017-2021, Optimizely and contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -52,11 +52,11 @@ def initialize(logger, user_profile_service = nil)
@forced_variation_map = {}
end

def get_variation(project_config, experiment_key, user_id, attributes = nil, decide_options = [])
def get_variation(project_config, experiment_id, user_id, attributes = nil, decide_options = [])
# Determines variation into which user will be bucketed.
#
# project_config - project_config - Instance of ProjectConfig
# experiment_key - Experiment for which visitor variation needs to be determined
# experiment_id - Experiment for which visitor variation needs to be determined
# user_id - String ID for user
# attributes - Hash representing user attributes
#
Expand All @@ -68,10 +68,10 @@ def get_variation(project_config, experiment_key, user_id, attributes = nil, dec
bucketing_id, bucketing_id_reasons = get_bucketing_id(user_id, attributes)
decide_reasons.push(*bucketing_id_reasons)
# Check to make sure experiment is active
experiment = project_config.get_experiment_from_key(experiment_key)
experiment = project_config.get_experiment_from_id(experiment_id)
return nil, decide_reasons if experiment.nil?

experiment_id = experiment['id']
experiment_key = experiment['key']
unless project_config.experiment_running?(experiment)
message = "Experiment '#{experiment_key}' is not running."
@logger.log(Logger::INFO, message)
Expand All @@ -80,12 +80,12 @@ def get_variation(project_config, experiment_key, user_id, attributes = nil, dec
end

# Check if a forced variation is set for the user
forced_variation, reasons_received = get_forced_variation(project_config, experiment_key, user_id)
forced_variation, reasons_received = get_forced_variation(project_config, experiment['key'], user_id)
decide_reasons.push(*reasons_received)
return forced_variation['id'], decide_reasons if forced_variation

# Check if user is in a white-listed variation
whitelisted_variation_id, reasons_received = get_whitelisted_variation_id(project_config, experiment_key, user_id)
whitelisted_variation_id, reasons_received = get_whitelisted_variation_id(project_config, experiment_id, user_id)
decide_reasons.push(*reasons_received)
return whitelisted_variation_id, decide_reasons if whitelisted_variation_id

Expand Down Expand Up @@ -122,7 +122,7 @@ def get_variation(project_config, experiment_key, user_id, attributes = nil, dec
message = ''
if variation_id
variation_key = variation['key']
message = "User '#{user_id}' is in variation '#{variation_key}' of experiment '#{experiment_key}'."
message = "User '#{user_id}' is in variation '#{variation_key}' of experiment '#{experiment_id}'."
else
message = "User '#{user_id}' is in no variation."
end
Expand Down Expand Up @@ -186,13 +186,13 @@ def get_variation_for_feature_experiment(project_config, feature_flag, user_id,
return nil, decide_reasons
end

experiment_key = experiment['key']
variation_id, reasons_received = get_variation(project_config, experiment_key, user_id, attributes, decide_options)
experiment_id = experiment['id']
variation_id, reasons_received = get_variation(project_config, experiment_id, user_id, attributes, decide_options)
decide_reasons.push(*reasons_received)

next unless variation_id

variation = project_config.variation_id_map[experiment_key][variation_id]
variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id)

return Decision.new(experiment, variation, DECISION_SOURCES['FEATURE_TEST']), decide_reasons
end
Expand Down Expand Up @@ -315,7 +315,7 @@ def set_forced_variation(project_config, experiment_key, user_id, variation_key)
return true
end

variation_id = project_config.get_variation_id_from_key(experiment_key, variation_key)
variation_id = project_config.get_variation_id_from_key_by_experiment_id(experiment_id, variation_key)

# check if the variation exists in the datafile
unless variation_id
Expand All @@ -334,7 +334,7 @@ def get_forced_variation(project_config, experiment_key, user_id)
# Gets the forced variation for the given user and experiment.
#
# project_config - Instance of ProjectConfig
# experiment_key - String Key for experiment
# experiment_key - String key for experiment
# user_id - String ID for user
#
# Returns Variation The variation which the given user and experiment should be forced into
Expand All @@ -354,22 +354,22 @@ def get_forced_variation(project_config, experiment_key, user_id)
return nil, decide_reasons if experiment_id.nil? || experiment_id.empty?

unless experiment_to_variation_map.key? experiment_id
message = "No experiment '#{experiment_key}' mapped to user '#{user_id}' in the forced variation map."
message = "No experiment '#{experiment_id}' mapped to user '#{user_id}' in the forced variation map."
@logger.log(Logger::DEBUG, message)
decide_reasons.push(message)
return nil, decide_reasons
end

variation_id = experiment_to_variation_map[experiment_id]
variation_key = ''
variation = project_config.get_variation_from_id(experiment_key, variation_id)
variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id)
variation_key = variation['key'] if variation

# check if the variation exists in the datafile
# this case is logged in get_variation_from_id
return nil, decide_reasons if variation_key.empty?

message = "Variation '#{variation_key}' is mapped to experiment '#{experiment_key}' and user '#{user_id}' in the forced variation map"
message = "Variation '#{variation_key}' is mapped to experiment '#{experiment_id}' and user '#{user_id}' in the forced variation map"
@logger.log(Logger::DEBUG, message)
decide_reasons.push(message)

Expand All @@ -378,7 +378,7 @@ def get_forced_variation(project_config, experiment_key, user_id)

private

def get_whitelisted_variation_id(project_config, experiment_key, user_id)
def get_whitelisted_variation_id(project_config, experiment_id, user_id)
# Determine if a user is whitelisted into a variation for the given experiment and return the ID of that variation
#
# project_config - project_config - Instance of ProjectConfig
Expand All @@ -387,23 +387,23 @@ def get_whitelisted_variation_id(project_config, experiment_key, user_id)
#
# Returns variation ID into which user_id is whitelisted (nil if no variation)

whitelisted_variations = project_config.get_whitelisted_variations(experiment_key)
whitelisted_variations = project_config.get_whitelisted_variations(experiment_id)

return nil, nil unless whitelisted_variations

whitelisted_variation_key = whitelisted_variations[user_id]

return nil, nil unless whitelisted_variation_key

whitelisted_variation_id = project_config.get_variation_id_from_key(experiment_key, whitelisted_variation_key)
whitelisted_variation_id = project_config.get_variation_id_from_key_by_experiment_id(experiment_id, whitelisted_variation_key)

unless whitelisted_variation_id
message = "User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}', which is not in the datafile."
@logger.log(Logger::INFO, message)
return nil, message
end

message = "User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}' of experiment '#{experiment_key}'."
message = "User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}' of experiment '#{experiment_id}'."
@logger.log(Logger::INFO, message)

[whitelisted_variation_id, message]
Expand Down
Loading