Skip to content

Commit e8f280e

Browse files
[FSSDK-12265] Add experiments field and mapping to Holdout data model
- Added experiments field to holdout hash with default empty array - Added experiment_holdouts_map to build experiment-to-holdout mappings - Added get_holdouts_for_experiment(experiment_id) method - Added local_holdout?(holdout) helper method - Added comprehensive unit tests covering: * Experiments field parsing and defaults * Experiment-to-holdout mapping functionality * local_holdout? helper method * Backward compatibility with legacy datafiles * Edge cases and multiple holdout scenarios - Ensured backward compatibility (experiments field defaults to empty array) - All 1042 tests pass successfully
1 parent 684fdac commit e8f280e

2 files changed

Lines changed: 328 additions & 1 deletion

File tree

lib/optimizely/config/datafile_project_config.rb

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ class DatafileProjectConfig < ProjectConfig
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,
3636
:public_key_for_odp, :host_for_odp, :all_segments, :region, :holdouts, :holdout_id_map,
37-
:global_holdouts, :included_holdouts, :excluded_holdouts, :flag_holdouts_map
37+
:global_holdouts, :included_holdouts, :excluded_holdouts, :flag_holdouts_map,
38+
:experiment_holdouts_map
3839
# Boolean - denotes if Optimizely should remove the last block of visitors' IP address before storing event data
3940
attr_reader :anonymize_ip
4041

@@ -119,13 +120,17 @@ def initialize(datafile, logger, error_handler)
119120
@included_holdouts = {}
120121
@excluded_holdouts = {}
121122
@flag_holdouts_map = {}
123+
@experiment_holdouts_map = {}
122124

123125
@holdouts.each do |holdout|
124126
next unless holdout['status'] == 'Running'
125127

126128
# Ensure holdout has layerId field (holdouts don't have campaigns)
127129
holdout['layerId'] ||= ''
128130

131+
# Ensure experiments field defaults to empty array
132+
holdout['experiments'] ||= []
133+
129134
@holdout_id_map[holdout['id']] = holdout
130135

131136
included_flags = holdout['includedFlags'] || []
@@ -152,6 +157,13 @@ def initialize(datafile, logger, error_handler)
152157
@excluded_holdouts[flag_id] << holdout
153158
end
154159
end
160+
161+
# Build experiment-to-holdout mapping
162+
experiments = holdout['experiments'] || []
163+
experiments.each do |experiment_id|
164+
@experiment_holdouts_map[experiment_id] ||= []
165+
@experiment_holdouts_map[experiment_id] << holdout
166+
end
155167
end
156168

157169
@experiment_id_map.each_value do |exp|
@@ -688,6 +700,27 @@ def get_holdout(holdout_id)
688700
nil
689701
end
690702

703+
def get_holdouts_for_experiment(experiment_id)
704+
# Returns the holdouts applicable to the given experiment ID
705+
#
706+
# experiment_id - String experiment ID
707+
#
708+
# Returns Array of holdouts targeting this experiment
709+
710+
@experiment_holdouts_map[experiment_id] || []
711+
end
712+
713+
def local_holdout?(holdout)
714+
# Determines if a holdout is local (targets specific experiments)
715+
#
716+
# holdout - Holdout hash
717+
#
718+
# Returns true if holdout has experiments array and is not empty
719+
720+
experiments = holdout['experiments'] || []
721+
!experiments.empty?
722+
end
723+
691724
private
692725

693726
def generate_feature_variation_map(feature_flags)

spec/config/datafile_project_config_spec.rb

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1801,4 +1801,298 @@
18011801
end
18021802
end
18031803
end
1804+
1805+
describe 'holdout experiments field and mapping' do
1806+
let(:config_with_experiment_holdouts) do
1807+
config_body_with_experiments = config_body.dup
1808+
config_body_with_experiments['holdouts'] = [
1809+
{
1810+
'id' => 'holdout_exp_1',
1811+
'key' => 'local_holdout_1',
1812+
'status' => 'Running',
1813+
'audiences' => [],
1814+
'includedFlags' => [],
1815+
'excludedFlags' => [],
1816+
'experiments' => ['exp_123', 'exp_456'],
1817+
'variations' => [],
1818+
'trafficAllocation' => []
1819+
},
1820+
{
1821+
'id' => 'holdout_exp_2',
1822+
'key' => 'local_holdout_2',
1823+
'status' => 'Running',
1824+
'audiences' => [],
1825+
'includedFlags' => [],
1826+
'excludedFlags' => [],
1827+
'experiments' => ['exp_789'],
1828+
'variations' => [],
1829+
'trafficAllocation' => []
1830+
},
1831+
{
1832+
'id' => 'holdout_global',
1833+
'key' => 'global_holdout',
1834+
'status' => 'Running',
1835+
'audiences' => [],
1836+
'includedFlags' => [],
1837+
'excludedFlags' => [],
1838+
'variations' => [],
1839+
'trafficAllocation' => []
1840+
}
1841+
]
1842+
1843+
Optimizely::DatafileProjectConfig.new(
1844+
JSON.dump(config_body_with_experiments),
1845+
logger,
1846+
error_handler
1847+
)
1848+
end
1849+
1850+
describe '#get_holdouts_for_experiment' do
1851+
it 'should return holdouts targeting a specific experiment' do
1852+
holdouts = config_with_experiment_holdouts.get_holdouts_for_experiment('exp_123')
1853+
expect(holdouts.length).to eq(1)
1854+
expect(holdouts.first['id']).to eq('holdout_exp_1')
1855+
expect(holdouts.first['key']).to eq('local_holdout_1')
1856+
end
1857+
1858+
it 'should return multiple holdouts if experiment is targeted by multiple holdouts' do
1859+
# Add another holdout targeting exp_123
1860+
config_body_multi = config_body.dup
1861+
config_body_multi['holdouts'] = [
1862+
{
1863+
'id' => 'holdout_1',
1864+
'key' => 'local_holdout_1',
1865+
'status' => 'Running',
1866+
'audiences' => [],
1867+
'includedFlags' => [],
1868+
'excludedFlags' => [],
1869+
'experiments' => ['exp_shared'],
1870+
'variations' => [],
1871+
'trafficAllocation' => []
1872+
},
1873+
{
1874+
'id' => 'holdout_2',
1875+
'key' => 'local_holdout_2',
1876+
'status' => 'Running',
1877+
'audiences' => [],
1878+
'includedFlags' => [],
1879+
'excludedFlags' => [],
1880+
'experiments' => ['exp_shared'],
1881+
'variations' => [],
1882+
'trafficAllocation' => []
1883+
}
1884+
]
1885+
1886+
config = Optimizely::DatafileProjectConfig.new(
1887+
JSON.dump(config_body_multi),
1888+
logger,
1889+
error_handler
1890+
)
1891+
1892+
holdouts = config.get_holdouts_for_experiment('exp_shared')
1893+
expect(holdouts.length).to eq(2)
1894+
expect(holdouts.map { |h| h['id'] }).to contain_exactly('holdout_1', 'holdout_2')
1895+
end
1896+
1897+
it 'should return empty array for experiment not targeted by any holdout' do
1898+
holdouts = config_with_experiment_holdouts.get_holdouts_for_experiment('exp_not_exists')
1899+
expect(holdouts).to eq([])
1900+
end
1901+
1902+
it 'should not return global holdouts (without experiments field)' do
1903+
holdouts = config_with_experiment_holdouts.get_holdouts_for_experiment('exp_random')
1904+
expect(holdouts).to eq([])
1905+
1906+
# Verify global holdout exists but is not returned
1907+
global_holdout = config_with_experiment_holdouts.get_holdout('holdout_global')
1908+
expect(global_holdout).not_to be_nil
1909+
expect(global_holdout['experiments']).to eq([])
1910+
end
1911+
1912+
it 'should handle holdout targeting multiple experiments' do
1913+
holdouts_exp1 = config_with_experiment_holdouts.get_holdouts_for_experiment('exp_123')
1914+
holdouts_exp2 = config_with_experiment_holdouts.get_holdouts_for_experiment('exp_456')
1915+
1916+
expect(holdouts_exp1.length).to eq(1)
1917+
expect(holdouts_exp2.length).to eq(1)
1918+
expect(holdouts_exp1.first['id']).to eq('holdout_exp_1')
1919+
expect(holdouts_exp2.first['id']).to eq('holdout_exp_1')
1920+
expect(holdouts_exp1.first['id']).to eq(holdouts_exp2.first['id'])
1921+
end
1922+
end
1923+
1924+
describe '#local_holdout?' do
1925+
it 'should return true for holdouts with experiments array' do
1926+
holdout = config_with_experiment_holdouts.get_holdout('holdout_exp_1')
1927+
expect(config_with_experiment_holdouts.local_holdout?(holdout)).to be true
1928+
end
1929+
1930+
it 'should return false for holdouts without experiments' do
1931+
holdout = config_with_experiment_holdouts.get_holdout('holdout_global')
1932+
expect(config_with_experiment_holdouts.local_holdout?(holdout)).to be false
1933+
end
1934+
1935+
it 'should return false for holdouts with empty experiments array' do
1936+
config_body_empty = config_body.dup
1937+
config_body_empty['holdouts'] = [
1938+
{
1939+
'id' => 'holdout_empty',
1940+
'key' => 'empty_holdout',
1941+
'status' => 'Running',
1942+
'audiences' => [],
1943+
'includedFlags' => [],
1944+
'excludedFlags' => [],
1945+
'experiments' => [],
1946+
'variations' => [],
1947+
'trafficAllocation' => []
1948+
}
1949+
]
1950+
1951+
config = Optimizely::DatafileProjectConfig.new(
1952+
JSON.dump(config_body_empty),
1953+
logger,
1954+
error_handler
1955+
)
1956+
1957+
holdout = config.get_holdout('holdout_empty')
1958+
expect(config.local_holdout?(holdout)).to be false
1959+
end
1960+
end
1961+
1962+
describe 'experiments field parsing and defaults' do
1963+
it 'should parse experiments field from datafile' do
1964+
holdout = config_with_experiment_holdouts.get_holdout('holdout_exp_1')
1965+
expect(holdout['experiments']).to eq(['exp_123', 'exp_456'])
1966+
end
1967+
1968+
it 'should default experiments to empty array if not provided' do
1969+
config_body_no_experiments = config_body.dup
1970+
config_body_no_experiments['holdouts'] = [
1971+
{
1972+
'id' => 'holdout_no_exp',
1973+
'key' => 'holdout_without_experiments',
1974+
'status' => 'Running',
1975+
'audiences' => [],
1976+
'includedFlags' => [],
1977+
'excludedFlags' => [],
1978+
'variations' => [],
1979+
'trafficAllocation' => []
1980+
}
1981+
]
1982+
1983+
config = Optimizely::DatafileProjectConfig.new(
1984+
JSON.dump(config_body_no_experiments),
1985+
logger,
1986+
error_handler
1987+
)
1988+
1989+
holdout = config.get_holdout('holdout_no_exp')
1990+
expect(holdout['experiments']).to eq([])
1991+
end
1992+
1993+
it 'should build experiment_holdouts_map correctly' do
1994+
expect(config_with_experiment_holdouts.experiment_holdouts_map).to be_a(Hash)
1995+
expect(config_with_experiment_holdouts.experiment_holdouts_map.keys).to include('exp_123', 'exp_456', 'exp_789')
1996+
expect(config_with_experiment_holdouts.experiment_holdouts_map['exp_123'].first['id']).to eq('holdout_exp_1')
1997+
end
1998+
1999+
it 'should not include non-running holdouts in experiment mapping' do
2000+
config_body_inactive = config_body.dup
2001+
config_body_inactive['holdouts'] = [
2002+
{
2003+
'id' => 'holdout_inactive',
2004+
'key' => 'inactive_holdout',
2005+
'status' => 'Paused',
2006+
'audiences' => [],
2007+
'includedFlags' => [],
2008+
'excludedFlags' => [],
2009+
'experiments' => ['exp_inactive'],
2010+
'variations' => [],
2011+
'trafficAllocation' => []
2012+
}
2013+
]
2014+
2015+
config = Optimizely::DatafileProjectConfig.new(
2016+
JSON.dump(config_body_inactive),
2017+
logger,
2018+
error_handler
2019+
)
2020+
2021+
holdouts = config.get_holdouts_for_experiment('exp_inactive')
2022+
expect(holdouts).to eq([])
2023+
end
2024+
end
2025+
2026+
describe 'backward compatibility' do
2027+
it 'should work with datafiles without experiments field' do
2028+
config_body_legacy = config_body.dup
2029+
config_body_legacy['holdouts'] = [
2030+
{
2031+
'id' => 'holdout_legacy',
2032+
'key' => 'legacy_holdout',
2033+
'status' => 'Running',
2034+
'audiences' => [],
2035+
'includedFlags' => [],
2036+
'excludedFlags' => [],
2037+
'variations' => [],
2038+
'trafficAllocation' => []
2039+
}
2040+
]
2041+
2042+
config = Optimizely::DatafileProjectConfig.new(
2043+
JSON.dump(config_body_legacy),
2044+
logger,
2045+
error_handler
2046+
)
2047+
2048+
holdout = config.get_holdout('holdout_legacy')
2049+
expect(holdout).not_to be_nil
2050+
expect(holdout['experiments']).to eq([])
2051+
expect(config.local_holdout?(holdout)).to be false
2052+
end
2053+
2054+
it 'should handle mix of holdouts with and without experiments field' do
2055+
config_body_mixed = config_body.dup
2056+
config_body_mixed['holdouts'] = [
2057+
{
2058+
'id' => 'holdout_with_exp',
2059+
'key' => 'holdout_1',
2060+
'status' => 'Running',
2061+
'audiences' => [],
2062+
'includedFlags' => [],
2063+
'excludedFlags' => [],
2064+
'experiments' => ['exp_1'],
2065+
'variations' => [],
2066+
'trafficAllocation' => []
2067+
},
2068+
{
2069+
'id' => 'holdout_without_exp',
2070+
'key' => 'holdout_2',
2071+
'status' => 'Running',
2072+
'audiences' => [],
2073+
'includedFlags' => [],
2074+
'excludedFlags' => [],
2075+
'variations' => [],
2076+
'trafficAllocation' => []
2077+
}
2078+
]
2079+
2080+
config = Optimizely::DatafileProjectConfig.new(
2081+
JSON.dump(config_body_mixed),
2082+
logger,
2083+
error_handler
2084+
)
2085+
2086+
holdout1 = config.get_holdout('holdout_with_exp')
2087+
holdout2 = config.get_holdout('holdout_without_exp')
2088+
2089+
expect(config.local_holdout?(holdout1)).to be true
2090+
expect(config.local_holdout?(holdout2)).to be false
2091+
2092+
holdouts = config.get_holdouts_for_experiment('exp_1')
2093+
expect(holdouts.length).to eq(1)
2094+
expect(holdouts.first['id']).to eq('holdout_with_exp')
2095+
end
2096+
end
2097+
end
18042098
end

0 commit comments

Comments
 (0)