Skip to content
Open
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
17 changes: 14 additions & 3 deletions lib/optimizely/decision_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,16 @@ 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
# 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.
is_cmab_experiment = experiment.key?('cmab')
if is_cmab_experiment && !should_ignore_user_profile_service && user_profile_tracker
message = "Skipping user profile service lookup for CMAB experiment '#{experiment_key}'. CMAB decisions are excluded from UPS."
@logger.log(Logger::DEBUG, message)
decide_reasons.push(message)
elsif !should_ignore_user_profile_service && user_profile_tracker
# Check for saved bucketing decisions if decide_options do not include ignoreUserProfileService
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 +163,10 @@ 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
unless (should_ignore_user_profile_service && user_profile_tracker) || is_cmab_experiment
user_profile_tracker.update_user_profile(experiment_id, variation_id)
end
VariationResult.new(cmab_uuid, false, decide_reasons, variation_id)
end

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

describe 'CMAB UPS exclusion' do
it 'should skip user profile service 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(user_context.user_id, spy_user_profile_service, spy_logger)
user_profile_tracker.load_user_profile

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-123'))
allow(config).to receive(:get_variation_from_id_by_experiment_id)
.with('111150', '111151')
.and_return({'id' => '111151', 'key' => 'variation_1'})

# Spy on get_saved_variation_id to verify it is NOT called
allow(decision_service).to receive(:get_saved_variation_id).and_call_original

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

# Verify get_saved_variation_id was NOT called for CMAB experiment
expect(decision_service).not_to have_received(:get_saved_variation_id)

# Verify UPS exclusion reason is in decide_reasons
expect(variation_result.reasons).to include(
"Skipping user profile service lookup for CMAB experiment 'cmab_experiment'. CMAB decisions are excluded from UPS."
)

# Verify variation is still returned correctly
expect(variation_result.variation_id).to eq('111151')
expect(variation_result.cmab_uuid).to eq('test-cmab-uuid-123')
expect(variation_result.error).to eq(false)
end

it 'should skip user profile 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(user_context.user_id, spy_user_profile_service, spy_logger)
user_profile_tracker.load_user_profile

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-123'))
allow(config).to receive(:get_variation_from_id_by_experiment_id)
.with('111150', '111151')
.and_return({'id' => '111151', 'key' => 'variation_1'})

# Spy on update_user_profile to verify it 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 update_user_profile was NOT called for CMAB experiment
expect(user_profile_tracker).not_to have_received(:update_user_profile)

# Verify variation is still returned correctly
expect(variation_result.variation_id).to eq('111151')
expect(variation_result.cmab_uuid).to eq('test-cmab-uuid-123')
end

it 'should still use user profile service for non-CMAB experiments' do
user_context = project_instance.create_user_context('test_user', {})
experiment_id = '111127'
variation_id = '111128'

# Set up user profile with a stored decision
allow(spy_user_profile_service).to receive(:lookup).and_return(
user_id: 'test_user',
experiment_bucket_map: {
experiment_id => { variation_id: variation_id }
}
)

user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id, spy_user_profile_service, spy_logger)
user_profile_tracker.load_user_profile

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

# Verify stored variation is returned for non-CMAB experiment
expect(variation_result.variation_id).to eq(variation_id)
expect(variation_result.reasons).to include(
"Returning previously activated variation ID #{variation_id} of experiment 'test_experiment' for user 'test_user' from user profile."
)
end
end
end
end
Loading