Skip to content

Commit fe450dd

Browse files
[FSSDK-12094] Fix Ruby holdout implementation which related to FSC failures (#382)
* Fix FSC tests * Update holdout login on datafile_project_config * Fix lint * Update decision service * Update optimizely * Fix unit tests * Fix unit tests * Fix unit test failures * Clear the duplicate code * Fix unit and FSC errors * Fix test
1 parent 9fd228c commit fe450dd

File tree

5 files changed

+83
-55
lines changed

5 files changed

+83
-55
lines changed

lib/optimizely.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ def create_optimizely_decision(user_context, flag_key, decision, reasons, decide
220220
decision_source = decision.source
221221
end
222222

223-
if !decide_options.include?(OptimizelyDecideOption::DISABLE_DECISION_EVENT) && (decision_source == Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] || config.send_flag_decisions)
223+
if !decide_options.include?(OptimizelyDecideOption::DISABLE_DECISION_EVENT) && (decision_source == Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] || decision_source == Optimizely::DecisionService::DECISION_SOURCES['HOLDOUT'] || config.send_flag_decisions)
224224
send_impression(config, experiment, variation_key || '', flag_key, rule_key || '', feature_enabled, decision_source, user_id, attributes, decision&.cmab_uuid)
225225
decision_event_dispatched = true
226226
end

lib/optimizely/config/datafile_project_config.rb

Lines changed: 50 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -115,31 +115,42 @@ def initialize(datafile, logger, error_handler)
115115
@variation_id_to_experiment_map = {}
116116
@flag_variation_map = {}
117117
@holdout_id_map = {}
118-
@global_holdouts = {}
118+
@global_holdouts = []
119119
@included_holdouts = {}
120120
@excluded_holdouts = {}
121121
@flag_holdouts_map = {}
122122

123123
@holdouts.each do |holdout|
124124
next unless holdout['status'] == 'Running'
125125

126+
# Ensure holdout has layerId field (holdouts don't have campaigns)
127+
holdout['layerId'] ||= ''
128+
126129
@holdout_id_map[holdout['id']] = holdout
127130

128-
if holdout['includedFlags'].nil? || holdout['includedFlags'].empty?
129-
@global_holdouts[holdout['id']] = holdout
131+
included_flags = holdout['includedFlags'] || []
132+
excluded_flags = holdout['excludedFlags'] || []
130133

131-
excluded_flags = holdout['excludedFlags']
132-
if excluded_flags && !excluded_flags.empty?
133-
excluded_flags.each do |flag_id|
134-
@excluded_holdouts[flag_id] ||= []
135-
@excluded_holdouts[flag_id] << holdout
136-
end
137-
end
138-
else
139-
holdout['includedFlags'].each do |flag_id|
134+
case [included_flags.empty?, excluded_flags.empty?]
135+
when [true, true]
136+
# No included or excluded flags - this is a global holdout
137+
@global_holdouts << holdout
138+
139+
when [false, true], [false, false]
140+
# Has included flags - add to included_holdouts map
141+
included_flags.each do |flag_id|
140142
@included_holdouts[flag_id] ||= []
141143
@included_holdouts[flag_id] << holdout
142144
end
145+
146+
when [true, false]
147+
# No included flags but has excluded flags - global with exclusions
148+
@global_holdouts << holdout
149+
150+
excluded_flags.each do |flag_id|
151+
@excluded_holdouts[flag_id] ||= []
152+
@excluded_holdouts[flag_id] << holdout
153+
end
143154
end
144155
end
145156

@@ -194,18 +205,6 @@ def initialize(datafile, logger, error_handler)
194205
feature_flag['experimentIds'].each do |experiment_id|
195206
@experiment_feature_map[experiment_id] = [feature_flag['id']]
196207
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?
209208
end
210209

211210
# Adding Holdout variations in variation id and key maps
@@ -645,6 +644,33 @@ def get_holdouts_for_flag(flag_id)
645644

646645
return [] if @holdouts.nil? || @holdouts.empty?
647646

647+
# Check cache first (before validation, so we cache the validation result too)
648+
return @flag_holdouts_map[flag_id] if @flag_holdouts_map.key?(flag_id)
649+
650+
# Validate that the flag exists in the datafile
651+
flag_exists = @feature_flags.any? { |flag| flag['id'] == flag_id }
652+
unless flag_exists
653+
# Cache the empty result for non-existent flags
654+
@flag_holdouts_map[flag_id] = []
655+
return []
656+
end
657+
658+
# Prioritize global holdouts first
659+
excluded = @excluded_holdouts[flag_id] || []
660+
661+
active_holdouts = if excluded.any?
662+
@global_holdouts.reject { |holdout| excluded.include?(holdout) }
663+
else
664+
@global_holdouts.dup
665+
end
666+
667+
# Append included holdouts
668+
included = @included_holdouts[flag_id] || []
669+
active_holdouts.concat(included)
670+
671+
# Cache the result
672+
@flag_holdouts_map[flag_id] = active_holdouts
673+
648674
@flag_holdouts_map[flag_id] || []
649675
end
650676

lib/optimizely/decision_service.rb

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,9 @@ def get_decision_for_flag(feature_flag, user_context, project_config, decide_opt
214214

215215
return DecisionResult.new(experiment_decision.decision, experiment_decision.error, reasons) if experiment_decision.decision
216216

217+
# If there's an error (e.g., CMAB error), return immediately without falling back to rollout
218+
return DecisionResult.new(nil, experiment_decision.error, reasons) if experiment_decision.error
219+
217220
# Check if the feature flag has a rollout and the user is bucketed into that rollout
218221
rollout_decision = get_variation_for_feature_rollout(project_config, feature_flag, user_context)
219222
reasons.push(*rollout_decision.reasons)
@@ -312,15 +315,8 @@ def get_variations_for_feature_list(project_config, feature_flags, user_context,
312315

313316
decisions = []
314317
feature_flags.each do |feature_flag|
315-
# check if the feature is being experiment on and whether the user is bucketed into the experiment
316-
decision_result = get_variation_for_feature_experiment(project_config, feature_flag, user_context, user_profile_tracker, decide_options)
317-
# Only process rollout if no experiment decision was found and no error
318-
if decision_result.decision.nil? && !decision_result.error
319-
decision_result_rollout = get_variation_for_feature_rollout(project_config, feature_flag, user_context) unless decision_result.decision
320-
decision_result.decision = decision_result_rollout.decision
321-
decision_result.reasons.push(*decision_result_rollout.reasons)
322-
end
323-
decisions << decision_result
318+
decision = get_decision_for_flag(feature_flag, user_context, project_config, decide_options, user_profile_tracker)
319+
decisions << decision
324320
end
325321
user_profile_tracker&.save_user_profile
326322
decisions

spec/config/datafile_project_config_spec.rb

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1250,7 +1250,8 @@
12501250
end
12511251

12521252
it 'should return global holdouts that do not exclude the flag' do
1253-
holdouts = config_with_holdouts.get_holdouts_for_flag('multi_variate_feature')
1253+
multi_variate_feature_id = '155559'
1254+
holdouts = config_with_holdouts.get_holdouts_for_flag(multi_variate_feature_id)
12541255
expect(holdouts.length).to eq(3)
12551256

12561257
global_holdout = holdouts.find { |h| h['key'] == 'global_holdout' }
@@ -1263,22 +1264,25 @@
12631264
end
12641265

12651266
it 'should not return global holdouts that exclude the flag' do
1266-
holdouts = config_with_holdouts.get_holdouts_for_flag('boolean_single_variable_feature')
1267+
boolean_feature_id = '155554'
1268+
holdouts = config_with_holdouts.get_holdouts_for_flag(boolean_feature_id)
12671269
expect(holdouts.length).to eq(1)
12681270

12691271
global_holdout = holdouts.find { |h| h['key'] == 'global_holdout' }
12701272
expect(global_holdout).to be_nil
12711273
end
12721274

12731275
it 'should cache results for subsequent calls' do
1274-
holdouts1 = config_with_holdouts.get_holdouts_for_flag('multi_variate_feature')
1275-
holdouts2 = config_with_holdouts.get_holdouts_for_flag('multi_variate_feature')
1276+
multi_variate_feature_id = '155559'
1277+
holdouts1 = config_with_holdouts.get_holdouts_for_flag(multi_variate_feature_id)
1278+
holdouts2 = config_with_holdouts.get_holdouts_for_flag(multi_variate_feature_id)
12761279
expect(holdouts1).to equal(holdouts2)
12771280
expect(holdouts1.length).to eq(3)
12781281
end
12791282

12801283
it 'should return only global holdouts for flags not specifically targeted' do
1281-
holdouts = config_with_holdouts.get_holdouts_for_flag('string_single_variable_feature')
1284+
string_feature_id = '155557'
1285+
holdouts = config_with_holdouts.get_holdouts_for_flag(string_feature_id)
12821286

12831287
# Should only include global holdout (not excluded and no specific targeting)
12841288
expect(holdouts.length).to eq(2)
@@ -1362,8 +1366,8 @@
13621366
# Use the correct feature flag IDs from the debug output
13631367
boolean_feature_id = '155554'
13641368
multi_variate_feature_id = '155559'
1365-
empty_feature_id = '594032'
1366-
string_feature_id = '594060'
1369+
empty_feature_id = '155564'
1370+
string_feature_id = '155557'
13671371

13681372
config_body_with_holdouts['holdouts'] = [
13691373
{
@@ -1394,13 +1398,13 @@
13941398

13951399
it 'should properly categorize holdouts during initialization' do
13961400
expect(config_with_complex_holdouts.holdout_id_map.keys).to contain_exactly('global_holdout', 'specific_holdout')
1397-
expect(config_with_complex_holdouts.global_holdouts.keys).to contain_exactly('global_holdout')
1401+
expect(config_with_complex_holdouts.global_holdouts.map { |h| h['id'] }).to contain_exactly('global_holdout')
13981402

13991403
# Use the correct feature flag IDs
14001404
boolean_feature_id = '155554'
14011405
multi_variate_feature_id = '155559'
1402-
empty_feature_id = '594032'
1403-
string_feature_id = '594060'
1406+
empty_feature_id = '155564'
1407+
string_feature_id = '155557'
14041408

14051409
expect(config_with_complex_holdouts.included_holdouts[multi_variate_feature_id]).not_to be_nil
14061410
expect(config_with_complex_holdouts.included_holdouts[multi_variate_feature_id]).not_to be_empty
@@ -1416,7 +1420,7 @@
14161420

14171421
it 'should only process running holdouts during initialization' do
14181422
expect(config_with_complex_holdouts.holdout_id_map['inactive_holdout']).to be_nil
1419-
expect(config_with_complex_holdouts.global_holdouts['inactive_holdout']).to be_nil
1423+
expect(config_with_complex_holdouts.global_holdouts.find { |h| h['id'] == 'inactive_holdout' }).to be_nil
14201424

14211425
boolean_feature_id = '155554'
14221426
included_for_boolean = config_with_complex_holdouts.included_holdouts[boolean_feature_id]
@@ -1470,7 +1474,7 @@
14701474
feature_flag = config_with_holdouts.feature_flag_key_map['boolean_feature']
14711475
expect(feature_flag).not_to be_nil
14721476

1473-
holdouts_for_flag = config_with_holdouts.get_holdouts_for_flag('boolean_feature')
1477+
holdouts_for_flag = config_with_holdouts.get_holdouts_for_flag(feature_flag['id'])
14741478
expect(holdouts_for_flag).to be_an(Array)
14751479
end
14761480
end
@@ -1481,7 +1485,7 @@
14811485
feature_flag = config_with_holdouts.feature_flag_key_map['boolean_feature']
14821486

14831487
if feature_flag
1484-
holdouts_for_flag = config_with_holdouts.get_holdouts_for_flag('boolean_feature')
1488+
holdouts_for_flag = config_with_holdouts.get_holdouts_for_flag(feature_flag['id'])
14851489

14861490
# Should not include holdouts that exclude this flag
14871491
excluded_holdout = holdouts_for_flag.find { |h| h['key'] == 'excluded_holdout' }
@@ -1493,7 +1497,7 @@
14931497
feature_flag = config_with_holdouts.feature_flag_key_map['boolean_feature']
14941498
expect(feature_flag).not_to be_nil
14951499

1496-
holdouts_for_flag = config_with_holdouts.get_holdouts_for_flag('boolean_feature')
1500+
holdouts_for_flag = config_with_holdouts.get_holdouts_for_flag(feature_flag['id'])
14971501
expect(holdouts_for_flag).to be_an(Array)
14981502
end
14991503
end
@@ -1519,8 +1523,9 @@
15191523
end
15201524

15211525
it 'should properly cache holdout lookups' do
1522-
holdouts_1 = config_with_holdouts.get_holdouts_for_flag('boolean_feature')
1523-
holdouts_2 = config_with_holdouts.get_holdouts_for_flag('boolean_feature')
1526+
feature_flag = config_with_holdouts.feature_flag_key_map['boolean_feature']
1527+
holdouts_1 = config_with_holdouts.get_holdouts_for_flag(feature_flag['id'])
1528+
holdouts_2 = config_with_holdouts.get_holdouts_for_flag(feature_flag['id'])
15241529

15251530
expect(holdouts_1).to equal(holdouts_2)
15261531
end
@@ -1594,7 +1599,7 @@
15941599

15951600
it 'should handle mixed holdout configurations' do
15961601
# Verify the config has properly categorized holdouts
1597-
expect(config_with_holdouts.global_holdouts).to be_a(Hash)
1602+
expect(config_with_holdouts.global_holdouts).to be_a(Array)
15981603
expect(config_with_holdouts.included_holdouts).to be_a(Hash)
15991604
expect(config_with_holdouts.excluded_holdouts).to be_a(Hash)
16001605
end
@@ -1699,7 +1704,7 @@
16991704
feature_flag = config_with_holdouts.feature_flag_key_map['boolean_feature']
17001705
expect(feature_flag).not_to be_nil
17011706

1702-
holdouts_for_flag = config_with_holdouts.get_holdouts_for_flag('boolean_feature')
1707+
holdouts_for_flag = config_with_holdouts.get_holdouts_for_flag(feature_flag['id'])
17031708

17041709
holdouts_for_flag.each do |holdout|
17051710
# Each holdout should have necessary info for decision reasoning
@@ -1754,7 +1759,8 @@
17541759
error_handler
17551760
)
17561761

1757-
holdouts_for_flag = config_without_holdouts.get_holdouts_for_flag('boolean_feature')
1762+
feature_flag = config_without_holdouts.feature_flag_key_map['boolean_feature']
1763+
holdouts_for_flag = config_without_holdouts.get_holdouts_for_flag(feature_flag['id'])
17581764
expect(holdouts_for_flag).to eq([])
17591765
end
17601766

@@ -1774,7 +1780,7 @@
17741780
config = Optimizely::DatafileProjectConfig.new(config_json, logger, error_handler)
17751781

17761782
# Should treat as global holdout
1777-
expect(config.global_holdouts['holdout_nil']).not_to be_nil
1783+
expect(config.global_holdouts.find { |h| h['id'] == 'holdout_nil' }).not_to be_nil
17781784
end
17791785

17801786
it 'should only include running holdouts in maps' do

spec/decision_service_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -775,7 +775,7 @@
775775

776776
decision_result = decision_service.get_variation_for_feature(config, feature_flag, user_context)
777777
expect(decision_result.decision).to eq(expected_decision)
778-
expect(decision_result.reasons).to eq([])
778+
expect(decision_result.reasons).to eq(["The user 'user_1' is bucketed into a rollout for feature flag 'string_single_variable_feature'."])
779779
end
780780
end
781781

0 commit comments

Comments
 (0)