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
15 changes: 13 additions & 2 deletions lib/optimizely/decision_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,12 @@ 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
# 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.
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
unless should_ignore_user_profile_service && user_profile_tracker || is_cmab_experiment
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 All @@ -111,6 +115,12 @@ def get_variation(project_config, experiment_id, user_context, user_profile_trac
end
end

if is_cmab_experiment && user_profile_tracker && !should_ignore_user_profile_service
message = "Skipping user profile service for CMAB experiment '#{experiment_key}'."
@logger.log(Logger::INFO, message)
decide_reasons.push(message)
end

# Check audience conditions
user_meets_audience_conditions, reasons_received = Audience.user_meets_audience_conditions?(project_config, experiment, user_context, @logger)
decide_reasons.push(*reasons_received)
Expand Down Expand Up @@ -155,7 +165,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 && user_profile_tracker || is_cmab_experiment
VariationResult.new(cmab_uuid, false, decide_reasons, variation_id)
end

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

describe 'UPS exclusion for CMAB experiments' 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'}
],
'trafficAllocation' => [
{'entityId' => '111151', 'endOfRange' => 10_000}
],
'cmab' => {'trafficAllocation' => 5000}
}
user_context = project_instance.create_user_context('test_user', {})

# Set up a user profile tracker with a stored variation
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: {
'111150' => { variation_id: '111151' }
}
})

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)

# Verify the decision reason for UPS exclusion is present
expect(variation_result.reasons).to include(
"Skipping user profile service for CMAB experiment 'cmab_experiment'."
)

# Verify CMAB service was still called (not short-circuited by UPS)
expect(spy_cmab_service).to have_received(:get_decision).once
end

it 'should skip saving to UPS for CMAB experiments' do
cmab_experiment = {
'id' => '111150',
'key' => 'cmab_experiment',
'status' => 'Running',
'layerId' => '111150',
'audienceIds' => [],
'forcedVariations' => {},
'variations' => [
{'id' => '111151', 'key' => 'variation_1'}
],
'trafficAllocation' => [
{'entityId' => '111151', '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'})

# Spy on the user_profile_tracker to verify update_user_profile is NOT called
allow(user_profile_tracker).to receive(:update_user_profile).and_call_original

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

# Verify UPS save was NOT called for CMAB experiment
expect(user_profile_tracker).not_to have_received(:update_user_profile)

# Verify we still got the correct variation
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)
end

it 'should still use UPS for non-CMAB experiments' do
# Use a real experiment from the datafile (no cmab key)
experiment = config.experiment_id_map['111127']
experiment_key = experiment['key']
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: {
'111127' => { variation_id: '111128' }
}
})

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

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

# Verify we got the stored variation from UPS
expect(variation_result.variation_id).to eq('111128')
expect(variation_result.error).to eq(false)
expect(variation_result.reasons).to include(
"Returning previously activated variation ID 111128 of experiment '#{experiment_key}' for user 'test_user' from user profile."
)

# Verify the UPS exclusion message is NOT in reasons
variation_result.reasons.each do |reason|
expect(reason).not_to include('Skipping user profile service')
end
end
end
end
end
Loading