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: 12 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 for CMAB experiment '#{experiment_key}'. CMAB decisions are excluded from UPS."
@logger.log(Logger::INFO, 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,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
133 changes: 133 additions & 0 deletions spec/decision_service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1121,6 +1121,139 @@
end
end

describe 'when user profile service is available' 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', {})

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's NOT called
allow(decision_service).to receive(:get_saved_variation_id).and_call_original

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

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

# Should still return valid variation from CMAB
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)

# Should include UPS exclusion message in reasons
expect(variation_result.reasons).to include(
"Skipping user profile service for CMAB experiment 'cmab_experiment'. CMAB decisions are excluded from UPS."
)
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', {})

# Create a user profile tracker to verify it's NOT called for save
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-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
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)

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

# Should still return valid variation
expect(variation_result.variation_id).to eq('111151')
expect(variation_result.error).to eq(false)
end

it 'should still use UPS for non-CMAB experiments' do
experiment = config.get_experiment_from_key('test_experiment')
user_context = project_instance.create_user_context('test_user', {})

# Set up saved variation in UPS
saved_user_profile = {
user_id: 'test_user',
experiment_bucket_map: {
'111127' => {
variation_id: '111128'
}
}
}
allow(spy_user_profile_service).to receive(:lookup).and_return(saved_user_profile)

user_profile_tracker = Optimizely::UserProfileTracker.new('test_user', spy_user_profile_service, spy_logger)
user_profile_tracker.load_user_profile

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)

# Should return stored variation for non-CMAB experiment
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 'test_experiment' for user 'test_user' from user profile."
)
end
end

describe 'when user has whitelisted variation' do
it 'should return whitelisted variation and skip CMAB service call' do
# Create a CMAB experiment with whitelisted users
Expand Down
Loading