Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions lib/optimizely/decision_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,17 @@ def get_variation(project_config, experiment_id, user_context, user_profile_trac
return VariationResult.new(nil, false, decide_reasons, whitelisted_variation_id) if whitelisted_variation_id

should_ignore_user_profile_service = decide_options.include? Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE
is_cmab_experiment = experiment.key?('cmab')

# Check for saved bucketing decisions if decide_options do not include ignoreUserProfileService
unless should_ignore_user_profile_service && user_profile_tracker
# CMAB experiments are excluded from UPS because UPS maintains decisions
# across the experiment lifetime without considering TTL or user attributes,
# which contradicts CMAB's dynamic nature.
if is_cmab_experiment && !should_ignore_user_profile_service && user_profile_tracker
message = "Skipping user profile service for CMAB experiment '#{experiment_key}'."
@logger.log(Logger::INFO, message)
decide_reasons.push(message)
elsif !should_ignore_user_profile_service && user_profile_tracker
saved_variation_id, reasons_received = get_saved_variation_id(project_config, experiment_id, user_profile_tracker.user_profile)
decide_reasons.push(*reasons_received)
if saved_variation_id
Expand Down Expand Up @@ -155,7 +164,8 @@ def get_variation(project_config, experiment_id, user_context, user_profile_trac
decide_reasons.push(message) if message

# Persist bucketing decision
user_profile_tracker.update_user_profile(experiment_id, variation_id) unless should_ignore_user_profile_service && user_profile_tracker
# CMAB experiments are excluded from UPS to preserve dynamic decision-making.
user_profile_tracker.update_user_profile(experiment_id, variation_id) unless should_ignore_user_profile_service || is_cmab_experiment
VariationResult.new(cmab_uuid, false, decide_reasons, variation_id)
end

Expand Down
125 changes: 125 additions & 0 deletions spec/decision_service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1166,5 +1166,130 @@
expect(spy_cmab_service).not_to have_received(:get_decision)
end
end

describe 'CMAB UPS exclusion' do
it 'should skip UPS lookup for CMAB experiments' do
cmab_experiment = {
'id' => '111150',
'key' => 'cmab_experiment',
'status' => 'Running',
'layerId' => '111150',
'audienceIds' => [],
'forcedVariations' => {},
'variations' => [
{'id' => '111151', 'key' => 'variation_1'},
{'id' => '111152', 'key' => 'variation_2'}
],
'trafficAllocation' => [
{'entityId' => '111151', 'endOfRange' => 5000},
{'entityId' => '111152', 'endOfRange' => 10_000}
],
'cmab' => {'trafficAllocation' => 5000}
}
user_context = project_instance.create_user_context('test_user', {})
user_profile_tracker = Optimizely::UserProfileTracker.new('test_user', spy_user_profile_service, spy_logger)

allow(config).to receive(:get_experiment_from_id).with('111150').and_return(cmab_experiment)
allow(config).to receive(:experiment_running?).with(cmab_experiment).and_return(true)
allow(Optimizely::Audience).to receive(:user_meets_audience_conditions?).and_return([true, []])
allow(decision_service.bucketer).to receive(:bucket_to_entity_id)
.with(config, cmab_experiment, 'test_user', 'test_user')
.and_return(['$', []])
allow(spy_cmab_service).to receive(:get_decision)
.with(config, user_context, '111150', [])
.and_return(Optimizely::CmabDecision.new(variation_id: '111151', cmab_uuid: 'test-cmab-uuid'))
allow(config).to receive(:get_variation_from_id_by_experiment_id)
.with('111150', '111151')
.and_return({'id' => '111151', 'key' => 'variation_1'})

variation_result = decision_service.get_variation(config, '111150', user_context, user_profile_tracker)

# UPS lookup should NOT be called for CMAB experiments
expect(decision_service).not_to have_received(:get_saved_variation_id)

# Should still get a valid variation from CMAB service
expect(variation_result.variation_id).to eq('111151')
expect(variation_result.cmab_uuid).to eq('test-cmab-uuid')
expect(variation_result.error).to eq(false)

# Should log that UPS was skipped for CMAB
expect(spy_logger).to have_received(:log).with(
Logger::INFO,
"Skipping user profile service for CMAB experiment 'cmab_experiment'."
)
expect(variation_result.reasons).to include(
"Skipping user profile service for CMAB experiment 'cmab_experiment'."
)
end

it 'should skip UPS save for CMAB experiments' do
cmab_experiment = {
'id' => '111150',
'key' => 'cmab_experiment',
'status' => 'Running',
'layerId' => '111150',
'audienceIds' => [],
'forcedVariations' => {},
'variations' => [
{'id' => '111151', 'key' => 'variation_1'},
{'id' => '111152', 'key' => 'variation_2'}
],
'trafficAllocation' => [
{'entityId' => '111151', 'endOfRange' => 5000},
{'entityId' => '111152', 'endOfRange' => 10_000}
],
'cmab' => {'trafficAllocation' => 5000}
}
user_context = project_instance.create_user_context('test_user', {})
user_profile_tracker = Optimizely::UserProfileTracker.new('test_user', spy_user_profile_service, spy_logger)

allow(config).to receive(:get_experiment_from_id).with('111150').and_return(cmab_experiment)
allow(config).to receive(:experiment_running?).with(cmab_experiment).and_return(true)
allow(Optimizely::Audience).to receive(:user_meets_audience_conditions?).and_return([true, []])
allow(decision_service.bucketer).to receive(:bucket_to_entity_id)
.with(config, cmab_experiment, 'test_user', 'test_user')
.and_return(['$', []])
allow(spy_cmab_service).to receive(:get_decision)
.with(config, user_context, '111150', [])
.and_return(Optimizely::CmabDecision.new(variation_id: '111151', cmab_uuid: 'test-cmab-uuid'))
allow(config).to receive(:get_variation_from_id_by_experiment_id)
.with('111150', '111151')
.and_return({'id' => '111151', 'key' => 'variation_1'})

variation_result = decision_service.get_variation(config, '111150', user_context, user_profile_tracker)

# UPS save should NOT be called for CMAB experiments
expect(user_profile_tracker).not_to have_received(:update_user_profile)

# Should still return a valid variation
expect(variation_result.variation_id).to eq('111151')
expect(variation_result.cmab_uuid).to eq('test-cmab-uuid')
end

it 'should still use UPS for non-CMAB experiments (regression test)' do
experiment = config.get_experiment_from_key('test_experiment')
experiment_id = experiment['id']
user_context = project_instance.create_user_context('test_user', {})
user_profile_tracker = Optimizely::UserProfileTracker.new('test_user', spy_user_profile_service, spy_logger)
user_profile_tracker.instance_variable_set(:@user_profile, {
user_id: 'test_user',
experiment_bucket_map: {
experiment_id => { variation_id: '111129' }
}
})

allow(config).to receive(:experiment_running?).with(experiment).and_return(true)
allow(config).to receive(:variation_id_exists?).with(experiment_id, '111129').and_return(true)

variation_result = decision_service.get_variation(config, experiment_id, user_context, user_profile_tracker)

# Should return the stored variation from UPS
expect(variation_result.variation_id).to eq('111129')
expect(variation_result.error).to eq(false)
expect(variation_result.reasons).to include(
a_string_matching(/Returning previously activated variation ID/)
)
end
end
end
end
Loading