Skip to content

Commit d789392

Browse files
[FSSDK-11577] Ruby: Add holdout support and refactor decision logic in DefaultDecisionService (#374)
* [FSSDK-11577] Ruby: Add holdout support and refactor decision logic in DefaultDecisionService * Fix lint issues * Fix lint problems * Fix last lint problems * Fix test errors related to test data * Fix test data issue * Fix data mismatch and audience errors * Fix lint * Fix test issues of bucketter and datafile config * Remove whitespace * Correct the bucket value test function * Fix all failed test cases * Fix lint issues * Implement suggested removal * Add holdout logic to get_decision_for_flag * Fix whitespace lint issues * Revert decision result update * Correct the assertion
1 parent 1a8881f commit d789392

File tree

8 files changed

+1484
-42
lines changed

8 files changed

+1484
-42
lines changed

lib/optimizely/audience.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ def user_meets_audience_conditions?(config, experiment, user_context, logger, lo
4949
logger.log(Logger::DEBUG, message)
5050

5151
# Return true if there are no audiences
52-
if audience_conditions.empty?
52+
if audience_conditions.nil? || audience_conditions.empty?
5353
message = format(logs_hash['AUDIENCE_EVALUATION_RESULT_COMBINED'], logging_key, 'TRUE')
5454
logger.log(Logger::INFO, message)
5555
decide_reasons.push(message)

lib/optimizely/bucketer.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ def bucket(project_config, experiment, bucketing_id, user_id)
4545
#
4646
# Returns variation in which visitor with ID user_id has been placed. Nil if no variation.
4747

48+
if experiment.nil? || experiment['key'].to_s.strip.empty?
49+
message = 'Invalid entity key provided for bucketing. Returning nil.'
50+
@logger.log(Logger::DEBUG, message)
51+
return nil, []
52+
end
53+
4854
variation_id, decide_reasons = bucket_to_entity_id(project_config, experiment, bucketing_id, user_id)
4955
if variation_id && variation_id != ''
5056
experiment_id = experiment['id']

lib/optimizely/config/datafile_project_config.rb

Lines changed: 37 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,41 @@ 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+
applicable_holdouts.concat(@included_holdouts[flag_id]) if @included_holdouts[flag_id]
202+
203+
@global_holdouts.each_value do |holdout|
204+
excluded_flag_ids = holdout['excludedFlags'] || []
205+
applicable_holdouts << holdout unless excluded_flag_ids.include?(flag_id)
206+
end
207+
208+
@flag_holdouts_map[key] = applicable_holdouts unless applicable_holdouts.empty?
209+
end
210+
211+
# Adding Holdout variations in variation id and key maps
212+
return unless @holdouts && !@holdouts.empty?
213+
214+
@holdouts.each do |holdout|
215+
holdout_key = holdout['key']
216+
holdout_id = holdout['id']
217+
218+
@variation_key_map[holdout_key] = {}
219+
@variation_id_map[holdout_key] = {}
220+
@variation_id_map_by_experiment_id[holdout_id] = {}
221+
@variation_key_map_by_experiment_id[holdout_id] = {}
222+
223+
variations = holdout['variations']
224+
next unless variations && !variations.empty?
225+
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
197232
end
198233
end
199234

@@ -605,38 +640,9 @@ def get_holdouts_for_flag(flag_key)
605640
#
606641
# Returns the holdouts that apply for a specific flag
607642

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
643+
return [] if @holdouts.nil? || @holdouts.empty?
638644

639-
holdouts
645+
@flag_holdouts_map[flag_key] || []
640646
end
641647

642648
def get_holdout(holdout_id)

lib/optimizely/decision_service.rb

Lines changed: 126 additions & 3 deletions
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)
@@ -166,7 +167,127 @@ def get_variation_for_feature(project_config, feature_flag, user_context, decide
166167
# user_context - Optimizely user context instance
167168
#
168169
# Returns DecisionResult struct.
169-
get_variations_for_feature_list(project_config, [feature_flag], user_context, decide_options).first
170+
holdouts = project_config.get_holdouts_for_flag(feature_flag['key'])
171+
172+
if holdouts && !holdouts.empty?
173+
# Has holdouts - use get_decision_for_flag which checks holdouts first
174+
get_decision_for_flag(feature_flag, user_context, project_config, decide_options)
175+
else
176+
get_variations_for_feature_list(project_config, [feature_flag], user_context, decide_options).first
177+
end
178+
end
179+
180+
def get_decision_for_flag(feature_flag, user_context, project_config, decide_options = [], user_profile_tracker = nil, decide_reasons = nil)
181+
# Get the decision for a single feature flag.
182+
# Processes holdouts, experiments, and rollouts in that order.
183+
#
184+
# feature_flag - The feature flag to get a decision for
185+
# user_context - The user context
186+
# project_config - The project config
187+
# decide_options - Array of decide options
188+
# user_profile_tracker - The user profile tracker
189+
# decide_reasons - Array of decision reasons to merge
190+
#
191+
# Returns a DecisionResult for the feature flag
192+
193+
reasons = decide_reasons ? decide_reasons.dup : []
194+
user_id = user_context.user_id
195+
196+
# Check holdouts
197+
holdouts = project_config.get_holdouts_for_flag(feature_flag['key'])
198+
holdouts.each do |holdout|
199+
holdout_decision = get_variation_for_holdout(holdout, user_context, project_config)
200+
reasons.push(*holdout_decision.reasons)
201+
202+
next unless holdout_decision.decision
203+
204+
message = "The user '#{user_id}' is bucketed into holdout '#{holdout['key']}' for feature flag '#{feature_flag['key']}'."
205+
@logger.log(Logger::INFO, message)
206+
reasons.push(message)
207+
return DecisionResult.new(holdout_decision.decision, false, reasons)
208+
end
209+
210+
# Check if the feature flag has an experiment and the user is bucketed into that experiment
211+
experiment_decision = get_variation_for_feature_experiment(project_config, feature_flag, user_context, user_profile_tracker, decide_options)
212+
reasons.push(*experiment_decision.reasons)
213+
214+
return DecisionResult.new(experiment_decision.decision, experiment_decision.error, reasons) if experiment_decision.decision
215+
216+
# Check if the feature flag has a rollout and the user is bucketed into that rollout
217+
rollout_decision = get_variation_for_feature_rollout(project_config, feature_flag, user_context)
218+
reasons.push(*rollout_decision.reasons)
219+
220+
if rollout_decision.decision
221+
# Check if this was a forced decision (last reason contains "forced decision map")
222+
is_forced_decision = reasons.last&.include?('forced decision map')
223+
224+
unless is_forced_decision
225+
# Only add the "bucketed into rollout" message for normal bucketing
226+
message = "The user '#{user_id}' is bucketed into a rollout for feature flag '#{feature_flag['key']}'."
227+
@logger.log(Logger::INFO, message)
228+
reasons.push(message)
229+
end
230+
231+
DecisionResult.new(rollout_decision.decision, rollout_decision.error, reasons)
232+
else
233+
message = "The user '#{user_id}' is not bucketed into a rollout for feature flag '#{feature_flag['key']}'."
234+
@logger.log(Logger::INFO, message)
235+
DecisionResult.new(nil, false, reasons)
236+
end
237+
end
238+
239+
def get_variation_for_holdout(holdout, user_context, project_config)
240+
# Get the variation for holdout
241+
#
242+
# holdout - The holdout configuration
243+
# user_context - The user context
244+
# project_config - The project config
245+
#
246+
# Returns a DecisionResult for the holdout
247+
248+
decide_reasons = []
249+
user_id = user_context.user_id
250+
attributes = user_context.user_attributes
251+
252+
if holdout.nil? || holdout['status'].nil? || holdout['status'] != 'Running'
253+
key = holdout && holdout['key'] ? holdout['key'] : 'unknown'
254+
message = "Holdout '#{key}' is not running."
255+
@logger.log(Logger::INFO, message)
256+
decide_reasons.push(message)
257+
return DecisionResult.new(nil, false, decide_reasons)
258+
end
259+
260+
bucketing_id, bucketing_id_reasons = get_bucketing_id(user_id, attributes)
261+
decide_reasons.push(*bucketing_id_reasons)
262+
263+
# Check audience conditions
264+
user_meets_audience_conditions, reasons_received = Audience.user_meets_audience_conditions?(project_config, holdout, user_context, @logger)
265+
decide_reasons.push(*reasons_received)
266+
267+
unless user_meets_audience_conditions
268+
message = "User '#{user_id}' does not meet the conditions for holdout '#{holdout['key']}'."
269+
@logger.log(Logger::DEBUG, message)
270+
decide_reasons.push(message)
271+
return DecisionResult.new(nil, false, decide_reasons)
272+
end
273+
274+
# Bucket user into holdout variation
275+
variation, bucket_reasons = @bucketer.bucket(project_config, holdout, bucketing_id, user_id)
276+
decide_reasons.push(*bucket_reasons)
277+
278+
if variation
279+
message = "The user '#{user_id}' is bucketed into variation '#{variation['key']}' of holdout '#{holdout['key']}'."
280+
@logger.log(Logger::INFO, message)
281+
decide_reasons.push(message)
282+
283+
holdout_decision = Decision.new(holdout, variation, DECISION_SOURCES['HOLDOUT'], nil)
284+
DecisionResult.new(holdout_decision, false, decide_reasons)
285+
else
286+
message = "The user '#{user_id}' is not bucketed into holdout '#{holdout['key']}'."
287+
@logger.log(Logger::DEBUG, message)
288+
decide_reasons.push(message)
289+
DecisionResult.new(nil, false, decide_reasons)
290+
end
170291
end
171292

172293
def get_variations_for_feature_list(project_config, feature_flags, user_context, decide_options = [])
@@ -183,9 +304,11 @@ def get_variations_for_feature_list(project_config, feature_flags, user_context,
183304
ignore_ups = decide_options.include? Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE
184305
user_profile_tracker = nil
185306
unless ignore_ups && @user_profile_service
186-
user_profile_tracker = UserProfileTracker.new(user_context.user_id, @user_profile_service, @logger)
307+
user_id = user_context.user_id
308+
user_profile_tracker = UserProfileTracker.new(user_id, @user_profile_service, @logger)
187309
user_profile_tracker.load_user_profile
188310
end
311+
189312
decisions = []
190313
feature_flags.each do |feature_flag|
191314
# check if the feature is being experiment on and whether the user is bucketed into the experiment

0 commit comments

Comments
 (0)