Skip to content

Commit 5e26ae6

Browse files
committed
[FSSDK-11577] Ruby: Add holdout support and refactor decision logic in DefaultDecisionService
1 parent 1a8881f commit 5e26ae6

File tree

6 files changed

+1456
-35
lines changed

6 files changed

+1456
-35
lines changed

lib/optimizely/config/datafile_project_config.rb

Lines changed: 39 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,43 @@ def initialize(datafile, logger, error_handler)
194194
feature_flag['experimentIds'].each do |experiment_id|
195195
@experiment_feature_map[experiment_id] = [feature_flag['id']]
196196
end
197+
198+
flag_id = feature_flag['id']
199+
applicable_holdouts = []
200+
201+
if @included_holdouts[flag_id]
202+
applicable_holdouts.concat(@included_holdouts[flag_id])
203+
end
204+
205+
@global_holdouts.each_value do |holdout|
206+
excluded_flag_ids = holdout['excludedFlags'] || []
207+
applicable_holdouts << holdout unless excluded_flag_ids.include?(flag_id)
208+
end
209+
210+
@flag_holdouts_map[key] = applicable_holdouts unless applicable_holdouts.empty?
211+
end
212+
213+
# Adding Holdout variations in variation id and key maps
214+
if @holdouts && !@holdouts.empty?
215+
@holdouts.each do |holdout|
216+
holdout_key = holdout['key']
217+
holdout_id = holdout['id']
218+
219+
@variation_key_map[holdout_key] = {}
220+
@variation_id_map[holdout_key] = {}
221+
@variation_id_map_by_experiment_id[holdout_id] = {}
222+
@variation_key_map_by_experiment_id[holdout_id] = {}
223+
224+
variations = holdout['variations']
225+
if variations && !variations.empty?
226+
variations.each do |variation|
227+
@variation_key_map[holdout_key][variation['key']] = variation
228+
@variation_id_map[holdout_key][variation['id']] = variation
229+
@variation_key_map_by_experiment_id[holdout_id][variation['key']] = variation
230+
@variation_id_map_by_experiment_id[holdout_id][variation['id']] = variation
231+
end
232+
end
233+
end
197234
end
198235
end
199236

@@ -605,38 +642,9 @@ def get_holdouts_for_flag(flag_key)
605642
#
606643
# Returns the holdouts that apply for a specific flag
607644

608-
feature_flag = @feature_flag_key_map[flag_key]
609-
return [] unless feature_flag
610-
611-
flag_id = feature_flag['id']
612-
613-
# Check catch first
614-
return @flag_holdouts_map[flag_id] if @flag_holdouts_map.key?(flag_id)
615-
616-
holdouts = []
617-
618-
# Add global holdouts that don't exclude this flag
619-
@global_holdouts.each_value do |holdout|
620-
is_excluded = false
621-
excluded_flags = holdout['excludedFlags']
622-
if excluded_flags && !excluded_flags.empty?
623-
excluded_flags.each do |excluded_flag_id|
624-
if excluded_flag_id == flag_id
625-
is_excluded = true
626-
break
627-
end
628-
end
629-
end
630-
holdouts << holdout unless is_excluded
631-
end
632-
633-
# Add holdouts that specifically include this flag
634-
holdouts.concat(@included_holdouts[flag_id]) if @included_holdouts.key?(flag_id)
635-
636-
# Cache the result
637-
@flag_holdouts_map[flag_id] = holdouts
645+
return [] if @holdouts.nil? || @holdouts.empty?
638646

639-
holdouts
647+
@flag_holdouts_map[flag_key] || []
640648
end
641649

642650
def get_holdout(holdout_id)

lib/optimizely/decision_service.rb

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ class DecisionService
4646
DECISION_SOURCES = {
4747
'EXPERIMENT' => 'experiment',
4848
'FEATURE_TEST' => 'feature-test',
49-
'ROLLOUT' => 'rollout'
49+
'ROLLOUT' => 'rollout',
50+
'HOLDOUT' => 'holdout'
5051
}.freeze
5152

5253
def initialize(logger, cmab_service, user_profile_service = nil)
@@ -169,6 +170,107 @@ def get_variation_for_feature(project_config, feature_flag, user_context, decide
169170
get_variations_for_feature_list(project_config, [feature_flag], user_context, decide_options).first
170171
end
171172

173+
def get_decision_for_flag(feature_flag, user_context, project_config, decide_options = [], user_profile_tracker = nil, decide_reasons = nil)
174+
# Get the decision for a single feature flag.
175+
# Processes holdouts, experiments, and rollouts in that order.
176+
#
177+
# feature_flag - The feature flag to get a decision for
178+
# user_context - The user context
179+
# project_config - The project config
180+
# decide_options - Array of decide options
181+
# user_profile_tracker - The user profile tracker
182+
# decide_reasons - Array of decision reasons to merge
183+
#
184+
# Returns a DecisionResult for the feature flag
185+
186+
reasons = decide_reasons ? decide_reasons.dup : []
187+
user_id = user_context.user_id
188+
189+
# Check holdouts
190+
holdouts = project_config.get_holdouts_for_flag(feature_flag['key'])
191+
holdouts.each do |holdout|
192+
holdout_decision = get_variation_for_holdout(holdout, user_context, project_config)
193+
reasons.push(*holdout_decision.reasons)
194+
195+
if holdout_decision.decision
196+
message = "The user '#{user_id}' is bucketed into holdout '#{holdout['key']}' for feature flag '#{feature_flag['key']}'."
197+
@logger.log(Logger::INFO, message)
198+
reasons.push(message)
199+
return DecisionResult.new(holdout_decision.decision, false, reasons)
200+
end
201+
end
202+
203+
# Check if the feature flag has an experiment and the user is bucketed into that experiment
204+
experiment_decision = get_variation_for_feature_experiment(project_config, feature_flag, user_context, user_profile_tracker, decide_options)
205+
reasons.push(*experiment_decision.reasons)
206+
207+
if experiment_decision.decision
208+
return DecisionResult.new(experiment_decision.decision, experiment_decision.error, reasons)
209+
end
210+
211+
# Check if the feature flag has a rollout and the user is bucketed into that rollout
212+
rollout_decision = get_variation_for_feature_rollout(project_config, feature_flag, user_context)
213+
reasons.push(*rollout_decision.reasons)
214+
215+
if rollout_decision.decision
216+
message = "The user '#{user_id}' is bucketed into a rollout for feature flag '#{feature_flag['key']}'."
217+
@logger.log(Logger::INFO, message)
218+
reasons.push(message)
219+
return DecisionResult.new(rollout_decision.decision, rollout_decision.error, reasons)
220+
else
221+
message = "The user '#{user_id}' is not bucketed into a rollout for feature flag '#{feature_flag['key']}'."
222+
@logger.log(Logger::INFO, message)
223+
reasons.push(message)
224+
default_decision = Decision.new(nil, nil, DECISION_SOURCES['ROLLOUT'], nil)
225+
return DecisionResult.new(nil, false, reasons)
226+
end
227+
end
228+
229+
def get_variation_for_holdout(holdout, user_context, project_config)
230+
# Get the variation for holdout
231+
#
232+
# holdout - The holdout configuration
233+
# user_context - The user context
234+
# project_config - The project config
235+
#
236+
# Returns a DecisionResult for the holdout
237+
238+
decide_reasons = []
239+
user_id = user_context.user_id
240+
attributes = user_context.user_attributes
241+
bucketing_id, bucketing_id_reasons = get_bucketing_id(user_id, attributes)
242+
decide_reasons.push(*bucketing_id_reasons)
243+
244+
# Check audience conditions
245+
user_meets_audience_conditions, reasons_received = Audience.user_meets_audience_conditions?(project_config, holdout, user_context, @logger)
246+
decide_reasons.push(*reasons_received)
247+
248+
unless user_meets_audience_conditions
249+
message = "User '#{user_id}' does not meet the conditions for holdout '#{holdout['key']}'."
250+
@logger.log(Logger::DEBUG, message)
251+
decide_reasons.push(message)
252+
return DecisionResult.new(nil, false, decide_reasons)
253+
end
254+
255+
# Bucket user into holdout variation
256+
variation, bucket_reasons = @bucketer.bucket(project_config, holdout, bucketing_id, user_id)
257+
decide_reasons.push(*bucket_reasons)
258+
259+
if variation
260+
message = "The user '#{user_id}' is bucketed into variation '#{variation['key']}' of holdout '#{holdout['key']}'."
261+
@logger.log(Logger::INFO, message)
262+
decide_reasons.push(message)
263+
264+
holdout_decision = Decision.new(holdout, variation, DECISION_SOURCES['HOLDOUT'], nil)
265+
return DecisionResult.new(holdout_decision, false, decide_reasons)
266+
else
267+
message = "The user '#{user_id}' is not bucketed into holdout '#{holdout['key']}'."
268+
@logger.log(Logger::DEBUG, message)
269+
decide_reasons.push(message)
270+
return DecisionResult.new(nil, false, decide_reasons)
271+
end
272+
end
273+
172274
def get_variations_for_feature_list(project_config, feature_flags, user_context, decide_options = [])
173275
# Returns the list of experiment/variation the user is bucketed in for the given list of features.
174276
#

0 commit comments

Comments
 (0)