Skip to content

Commit 1a8881f

Browse files
[FSSDK-11575][FSSDK-11576] Ruby: Parsing holdout to datafile and update project config (#373)
* [FSSDK-11575][FSSDK-11576] Ruby: Parsing holdout to datafile and update project config * Fix lint issues * Fix lint issues * Fix lint * Fix lint * Move holdout before the private * Fix tests * Fix lint * Fix test and add debug * Fix lint * Fix lint * Fix lint whitespace * Fix failed tests * Update the schema * Implement comment
1 parent f4967e4 commit 1a8881f

File tree

5 files changed

+334
-2
lines changed

5 files changed

+334
-2
lines changed

lib/optimizely/config/datafile_project_config.rb

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ class DatafileProjectConfig < ProjectConfig
3333
:group_id_map, :rollout_id_map, :rollout_experiment_id_map, :variation_id_map,
3434
:variation_id_to_variable_usage_map, :variation_key_map, :variation_id_map_by_experiment_id,
3535
:variation_key_map_by_experiment_id, :flag_variation_map, :integration_key_map, :integrations,
36-
:public_key_for_odp, :host_for_odp, :all_segments, :region
36+
:public_key_for_odp, :host_for_odp, :all_segments, :region, :holdouts, :holdout_id_map,
37+
:global_holdouts, :included_holdouts, :excluded_holdouts, :flag_holdouts_map
3738
# Boolean - denotes if Optimizely should remove the last block of visitors' IP address before storing event data
3839
attr_reader :anonymize_ip
3940

@@ -70,6 +71,7 @@ def initialize(datafile, logger, error_handler)
7071
@send_flag_decisions = config.fetch('sendFlagDecisions', false)
7172
@integrations = config.fetch('integrations', [])
7273
@region = config.fetch('region', 'US')
74+
@holdouts = config.fetch('holdouts', [])
7375

7476
# Default to US region if not specified
7577
@region = 'US' if @region.nil? || @region.empty?
@@ -112,6 +114,34 @@ def initialize(datafile, logger, error_handler)
112114
@variation_id_to_variable_usage_map = {}
113115
@variation_id_to_experiment_map = {}
114116
@flag_variation_map = {}
117+
@holdout_id_map = {}
118+
@global_holdouts = {}
119+
@included_holdouts = {}
120+
@excluded_holdouts = {}
121+
@flag_holdouts_map = {}
122+
123+
@holdouts.each do |holdout|
124+
next unless holdout['status'] == 'Running'
125+
126+
@holdout_id_map[holdout['id']] = holdout
127+
128+
if holdout['includedFlags'].nil? || holdout['includedFlags'].empty?
129+
@global_holdouts[holdout['id']] = holdout
130+
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|
140+
@included_holdouts[flag_id] ||= []
141+
@included_holdouts[flag_id] << holdout
142+
end
143+
end
144+
end
115145

116146
@experiment_id_map.each_value do |exp|
117147
# Excludes experiments from rollouts
@@ -568,6 +598,61 @@ def rollout_experiment?(experiment_id)
568598
@rollout_experiment_id_map.key?(experiment_id)
569599
end
570600

601+
def get_holdouts_for_flag(flag_key)
602+
# Helper method to get holdouts from an applied feature flag
603+
#
604+
# flag_key - Key of the feature flag
605+
#
606+
# Returns the holdouts that apply for a specific flag
607+
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
638+
639+
holdouts
640+
end
641+
642+
def get_holdout(holdout_id)
643+
# Helper method to get holdout from holdout ID
644+
#
645+
# holdout_id - ID of the holdout
646+
#
647+
# Returns the holdout
648+
649+
holdout = @holdout_id_map[holdout_id]
650+
return holdout if holdout
651+
652+
@logger.log Logger::ERROR, "Holdout with ID '#{holdout_id}' not found."
653+
nil
654+
end
655+
571656
private
572657

573658
def generate_feature_variation_map(feature_flags)

lib/optimizely/config_manager/http_project_config_manager.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,7 @@ def get_datafile_url(sdk_key, url, url_template)
326326
unless url
327327
url_template ||= @access_token.nil? ? Helpers::Constants::CONFIG_MANAGER['DATAFILE_URL_TEMPLATE'] : Helpers::Constants::CONFIG_MANAGER['AUTHENTICATED_DATAFILE_URL_TEMPLATE']
328328
begin
329-
return (url_template % sdk_key)
329+
return url_template % sdk_key
330330
rescue
331331
error_msg = "Invalid url_template #{url_template} provided."
332332
@logger.log(Logger::ERROR, error_msg)

lib/optimizely/helpers/constants.rb

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,9 @@ module Constants
204204
},
205205
'cmab' => {
206206
'type' => 'object'
207+
},
208+
'holdouts' => {
209+
'type' => 'array'
207210
}
208211
},
209212
'required' => %w[
@@ -318,6 +321,31 @@ module Constants
318321
'type' => 'integer'
319322
}
320323
}
324+
},
325+
'holdouts' => {
326+
'type' => 'array',
327+
'items' => {
328+
'type' => 'object',
329+
'properties' => {
330+
'id' => {
331+
'type' => 'string'
332+
},
333+
'key' => {
334+
'type' => 'string'
335+
},
336+
'status' => {
337+
'type' => 'string'
338+
},
339+
'includedFlags' => {
340+
'type' => 'array',
341+
'items' => {'type' => 'string'}
342+
},
343+
'excludedFlags' => {
344+
'type' => 'array',
345+
'items' => {'type' => 'string'}
346+
}
347+
}
348+
}
321349
}
322350
},
323351
'required' => %w[

spec/config/datafile_project_config_spec.rb

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1234,4 +1234,193 @@
12341234
expect(config.send(:generate_feature_variation_map, config.feature_flags)).to eq(expected_feature_variation_map)
12351235
end
12361236
end
1237+
1238+
describe '#get_holdouts_for_flag' do
1239+
let(:config_with_holdouts) do
1240+
Optimizely::DatafileProjectConfig.new(
1241+
OptimizelySpec::CONFIG_BODY_WITH_HOLDOUTS_JSON,
1242+
logger,
1243+
error_handler
1244+
)
1245+
end
1246+
1247+
it 'should return empty array for non-existent flag' do
1248+
holdouts = config_with_holdouts.get_holdouts_for_flag('non_existent_flag')
1249+
expect(holdouts).to eq([])
1250+
end
1251+
1252+
it 'should return global holdouts that do not exclude the flag' do
1253+
holdouts = config_with_holdouts.get_holdouts_for_flag('multi_variate_feature')
1254+
expect(holdouts.length).to eq(2)
1255+
1256+
global_holdout = holdouts.find { |h| h['key'] == 'global_holdout' }
1257+
expect(global_holdout).not_to be_nil
1258+
expect(global_holdout['id']).to eq('holdout_1')
1259+
1260+
specific_holdout = holdouts.find { |h| h['key'] == 'specific_holdout' }
1261+
expect(specific_holdout).not_to be_nil
1262+
expect(specific_holdout['id']).to eq('holdout_2')
1263+
end
1264+
1265+
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+
expect(holdouts.length).to eq(0)
1268+
1269+
global_holdout = holdouts.find { |h| h['key'] == 'global_holdout' }
1270+
expect(global_holdout).to be_nil
1271+
end
1272+
1273+
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+
expect(holdouts1).to equal(holdouts2)
1277+
expect(holdouts1.length).to eq(2)
1278+
end
1279+
1280+
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')
1282+
1283+
# Should only include global holdout (not excluded and no specific targeting)
1284+
expect(holdouts.length).to eq(1)
1285+
expect(holdouts.first['key']).to eq('global_holdout')
1286+
end
1287+
end
1288+
1289+
describe '#get_holdout' do
1290+
let(:config_with_holdouts) do
1291+
Optimizely::DatafileProjectConfig.new(
1292+
OptimizelySpec::CONFIG_BODY_WITH_HOLDOUTS_JSON,
1293+
logger,
1294+
error_handler
1295+
)
1296+
end
1297+
1298+
it 'should return holdout when valid ID is provided' do
1299+
holdout = config_with_holdouts.get_holdout('holdout_1')
1300+
expect(holdout).not_to be_nil
1301+
expect(holdout['id']).to eq('holdout_1')
1302+
expect(holdout['key']).to eq('global_holdout')
1303+
expect(holdout['status']).to eq('Running')
1304+
end
1305+
1306+
it 'should return holdout regardless of status when valid ID is provided' do
1307+
holdout = config_with_holdouts.get_holdout('holdout_2')
1308+
expect(holdout).not_to be_nil
1309+
expect(holdout['id']).to eq('holdout_2')
1310+
expect(holdout['key']).to eq('specific_holdout')
1311+
expect(holdout['status']).to eq('Running')
1312+
end
1313+
1314+
it 'should return nil for non-existent holdout ID' do
1315+
holdout = config_with_holdouts.get_holdout('non_existent_holdout')
1316+
expect(holdout).to be_nil
1317+
end
1318+
end
1319+
1320+
describe '#get_holdout with logging' do
1321+
let(:spy_logger) { spy('logger') }
1322+
let(:config_with_holdouts) do
1323+
config_body_with_holdouts = config_body.dup
1324+
config_body_with_holdouts['holdouts'] = [
1325+
{
1326+
'id' => 'holdout_1',
1327+
'key' => 'test_holdout',
1328+
'status' => 'Running',
1329+
'includedFlags' => [],
1330+
'excludedFlags' => []
1331+
}
1332+
]
1333+
config_json = JSON.dump(config_body_with_holdouts)
1334+
Optimizely::DatafileProjectConfig.new(config_json, spy_logger, error_handler)
1335+
end
1336+
1337+
it 'should log error when holdout is not found' do
1338+
result = config_with_holdouts.get_holdout('invalid_holdout_id')
1339+
1340+
expect(result).to be_nil
1341+
expect(spy_logger).to have_received(:log).with(
1342+
Logger::ERROR,
1343+
"Holdout with ID 'invalid_holdout_id' not found."
1344+
)
1345+
end
1346+
1347+
it 'should not log when holdout is found' do
1348+
result = config_with_holdouts.get_holdout('holdout_1')
1349+
1350+
expect(result).not_to be_nil
1351+
expect(spy_logger).not_to have_received(:log).with(
1352+
Logger::ERROR,
1353+
anything
1354+
)
1355+
end
1356+
end
1357+
1358+
describe 'holdout initialization' do
1359+
let(:config_with_complex_holdouts) do
1360+
config_body_with_holdouts = config_body.dup
1361+
1362+
# Use the correct feature flag IDs from the debug output
1363+
boolean_feature_id = '155554'
1364+
multi_variate_feature_id = '155559'
1365+
empty_feature_id = '594032'
1366+
string_feature_id = '594060'
1367+
1368+
config_body_with_holdouts['holdouts'] = [
1369+
{
1370+
'id' => 'global_holdout',
1371+
'key' => 'global',
1372+
'status' => 'Running',
1373+
'includedFlags' => [],
1374+
'excludedFlags' => [boolean_feature_id, string_feature_id]
1375+
},
1376+
{
1377+
'id' => 'specific_holdout',
1378+
'key' => 'specific',
1379+
'status' => 'Running',
1380+
'includedFlags' => [multi_variate_feature_id, empty_feature_id],
1381+
'excludedFlags' => []
1382+
},
1383+
{
1384+
'id' => 'inactive_holdout',
1385+
'key' => 'inactive',
1386+
'status' => 'Inactive',
1387+
'includedFlags' => [boolean_feature_id],
1388+
'excludedFlags' => []
1389+
}
1390+
]
1391+
config_json = JSON.dump(config_body_with_holdouts)
1392+
Optimizely::DatafileProjectConfig.new(config_json, logger, error_handler)
1393+
end
1394+
1395+
it 'should properly categorize holdouts during initialization' do
1396+
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')
1398+
1399+
# Use the correct feature flag IDs
1400+
boolean_feature_id = '155554'
1401+
multi_variate_feature_id = '155559'
1402+
empty_feature_id = '594032'
1403+
string_feature_id = '594060'
1404+
1405+
expect(config_with_complex_holdouts.included_holdouts[multi_variate_feature_id]).not_to be_nil
1406+
expect(config_with_complex_holdouts.included_holdouts[multi_variate_feature_id]).not_to be_empty
1407+
expect(config_with_complex_holdouts.included_holdouts[empty_feature_id]).not_to be_nil
1408+
expect(config_with_complex_holdouts.included_holdouts[empty_feature_id]).not_to be_empty
1409+
expect(config_with_complex_holdouts.included_holdouts[boolean_feature_id]).to be_nil
1410+
1411+
expect(config_with_complex_holdouts.excluded_holdouts[boolean_feature_id]).not_to be_nil
1412+
expect(config_with_complex_holdouts.excluded_holdouts[boolean_feature_id]).not_to be_empty
1413+
expect(config_with_complex_holdouts.excluded_holdouts[string_feature_id]).not_to be_nil
1414+
expect(config_with_complex_holdouts.excluded_holdouts[string_feature_id]).not_to be_empty
1415+
end
1416+
1417+
it 'should only process running holdouts during initialization' do
1418+
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
1420+
1421+
boolean_feature_id = '155554'
1422+
included_for_boolean = config_with_complex_holdouts.included_holdouts[boolean_feature_id]
1423+
expect(included_for_boolean).to be_nil
1424+
end
1425+
end
12371426
end

spec/spec_params.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1939,6 +1939,36 @@ module OptimizelySpec
19391939

19401940
CONFIG_DICT_WITH_INTEGRATIONS_JSON = JSON.dump(CONFIG_DICT_WITH_INTEGRATIONS)
19411941

1942+
CONFIG_BODY_WITH_HOLDOUTS = VALID_CONFIG_BODY.merge(
1943+
{
1944+
'holdouts' => [
1945+
{
1946+
'id' => 'holdout_1',
1947+
'key' => 'global_holdout',
1948+
'status' => 'Running',
1949+
'includedFlags' => [],
1950+
'excludedFlags' => ['155554']
1951+
},
1952+
{
1953+
'id' => 'holdout_2',
1954+
'key' => 'specific_holdout',
1955+
'status' => 'Running',
1956+
'includedFlags' => ['155559'],
1957+
'excludedFlags' => []
1958+
},
1959+
{
1960+
'id' => 'holdout_3',
1961+
'key' => 'inactive_holdout',
1962+
'status' => 'Inactive',
1963+
'includedFlags' => ['155554'],
1964+
'excludedFlags' => []
1965+
}
1966+
]
1967+
}
1968+
).freeze
1969+
1970+
CONFIG_BODY_WITH_HOLDOUTS_JSON = JSON.dump(CONFIG_BODY_WITH_HOLDOUTS).freeze
1971+
19421972
def self.deep_clone(obj)
19431973
obj.dup.tap do |new_obj|
19441974
case new_obj

0 commit comments

Comments
 (0)