From 8f3121d1f16402bfcc33c7e66ba830cc78e7f1e5 Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Tue, 17 Feb 2026 10:59:30 -0800 Subject: [PATCH] [AI-FSSDK] [FSSDK-12262] Exclude CMAB from UserProfileService --- lib/optimizely/decision_service.rb | 15 +++- spec/decision_service_spec.rb | 133 +++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 3 deletions(-) diff --git a/lib/optimizely/decision_service.rb b/lib/optimizely/decision_service.rb index 051a8b6..084422f 100644 --- a/lib/optimizely/decision_service.rb +++ b/lib/optimizely/decision_service.rb @@ -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 @@ -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 diff --git a/spec/decision_service_spec.rb b/spec/decision_service_spec.rb index 30ad7d2..fdee689 100644 --- a/spec/decision_service_spec.rb +++ b/spec/decision_service_spec.rb @@ -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