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
20 changes: 17 additions & 3 deletions lib/optimizely/decision_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,15 @@ 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 decision-making nature.
if experiment.key?('cmab')
message = "Skipping UPS lookup for CMAB experiment '#{experiment_key}'. CMAB decisions are excluded from UserProfileService."
@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 +162,14 @@ 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.
if experiment.key?('cmab')
message = "Skipping UPS save for CMAB experiment '#{experiment_key}'. CMAB decisions are excluded from UserProfileService."
@logger.log(Logger::DEBUG, message)
decide_reasons.push(message)
elsif !should_ignore_user_profile_service && user_profile_tracker
user_profile_tracker.update_user_profile(experiment_id, variation_id)
end
VariationResult.new(cmab_uuid, false, decide_reasons, variation_id)
end

Expand Down
156 changes: 156 additions & 0 deletions spec/decision_service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1166,5 +1166,161 @@
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 should not use UPS for sticky bucketing because UPS maintains
# decisions without considering TTL or user attributes.
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
user_profile_tracker = Optimizely::UserProfileTracker.new('test_user', spy_user_profile_service, spy_logger)

# Mock experiment lookup to return our CMAB experiment
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)

# Mock audience evaluation to pass
allow(Optimizely::Audience).to receive(:user_meets_audience_conditions?).and_return([true, []])

# Mock bucketer to return a valid entity ID (user is in traffic allocation)
allow(decision_service.bucketer).to receive(:bucket_to_entity_id)
.with(config, cmab_experiment, 'test_user', 'test_user')
.and_return(['$', []])

# Mock CMAB service to return a decision
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'))

# Mock variation lookup
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 UPS lookup was NOT called for CMAB experiment
expect(decision_service).not_to have_received(:get_saved_variation_id)

# Verify the UPS skip reason is in the decision reasons
expect(variation_result.reasons).to include(
"Skipping UPS lookup for CMAB experiment 'cmab_experiment'. CMAB decisions are excluded from UserProfileService."
)

# Verify we 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-123')
end

it 'should skip UPS save for CMAB experiments' do
# CMAB decisions should not be saved to UPS to preserve dynamic decision-making.
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 and spy on update_user_profile
user_profile_tracker = Optimizely::UserProfileTracker.new('test_user', spy_user_profile_service, spy_logger)
allow(user_profile_tracker).to receive(:update_user_profile)

# Mock experiment lookup to return our CMAB experiment
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)

# Mock audience evaluation to pass
allow(Optimizely::Audience).to receive(:user_meets_audience_conditions?).and_return([true, []])

# Mock bucketer to return a valid entity ID (user is in traffic allocation)
allow(decision_service.bucketer).to receive(:bucket_to_entity_id)
.with(config, cmab_experiment, 'test_user', 'test_user')
.and_return(['$', []])

# Mock CMAB service to return a decision
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'))

# Mock variation lookup
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 UPS save was NOT called for CMAB experiment
expect(user_profile_tracker).not_to have_received(:update_user_profile)

# Verify the UPS save skip reason is in the decision reasons
expect(variation_result.reasons).to include(
"Skipping UPS save for CMAB experiment 'cmab_experiment'. CMAB decisions are excluded from UserProfileService."
)

# Verify we still get a valid variation
expect(variation_result.variation_id).to eq('111151')
end

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

# Create a user profile tracker with a stored variation
user_profile_tracker = Optimizely::UserProfileTracker.new('test_user', spy_user_profile_service, spy_logger)

# Spy on get_saved_variation_id to verify it IS called for non-CMAB
allow(decision_service).to receive(:get_saved_variation_id)
.and_return(['111129', nil])

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

# Verify UPS lookup WAS called for non-CMAB experiment
expect(decision_service).to have_received(:get_saved_variation_id)

# Verify we get the stored variation back
expect(variation_result.variation_id).to eq('111129')

# Verify UPS skip messages are NOT in reasons
variation_result.reasons.each do |reason|
next if reason.nil?

expect(reason).not_to include('excluded from UserProfileService')
end
end
end
end
end
Loading