From cd572a39f1bb852557bb91907632799d6decddd7 Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Thu, 5 Feb 2026 13:09:07 -0800 Subject: [PATCH 1/3] Revert "[FSSDK-12150] [Ruby] Add Event Retries (#385)" This reverts commit 684fdac4fa7dcdfb0ed5de4452bdc40e1eb280bb. --- lib/optimizely/event/batch_event_processor.rb | 52 +++-------- lib/optimizely/helpers/constants.rb | 9 +- lib/optimizely/odp/odp_event_manager.rb | 18 +--- spec/event/batch_event_processor_spec.rb | 93 +------------------ spec/event_dispatcher_spec.rb | 25 ++--- spec/odp/odp_event_manager_spec.rb | 67 ++----------- 6 files changed, 31 insertions(+), 233 deletions(-) diff --git a/lib/optimizely/event/batch_event_processor.rb b/lib/optimizely/event/batch_event_processor.rb index 428c4abf..52ec0533 100644 --- a/lib/optimizely/event/batch_event_processor.rb +++ b/lib/optimizely/event/batch_event_processor.rb @@ -172,35 +172,20 @@ def flush_queue! return if @current_batch.empty? log_event = Optimizely::EventFactory.create_log_event(@current_batch, @logger) - @logger.log( - Logger::INFO, - 'Flushing Queue.' - ) - - retry_count = 0 - max_retries = Optimizely::Helpers::Constants::EVENT_DISPATCH_CONFIG[:MAX_RETRIES] - - while retry_count < max_retries - begin - @event_dispatcher.dispatch_event(log_event) - @notification_center&.send_notifications( - NotificationCenter::NOTIFICATION_TYPES[:LOG_EVENT], - log_event - ) - # Success - break out of retry loop - break - rescue StandardError => e - @logger.log(Logger::ERROR, "Error dispatching event: #{log_event} #{e.message}.") - retry_count += 1 - - if retry_count < max_retries - delay = calculate_retry_interval(retry_count - 1) - @logger.log(Logger::DEBUG, "Retrying event dispatch (attempt #{retry_count + 1} of #{max_retries}) after #{delay}s") - sleep(delay) - end - end + begin + @logger.log( + Logger::INFO, + 'Flushing Queue.' + ) + + @event_dispatcher.dispatch_event(log_event) + @notification_center&.send_notifications( + NotificationCenter::NOTIFICATION_TYPES[:LOG_EVENT], + log_event + ) + rescue StandardError => e + @logger.log(Logger::ERROR, "Error dispatching event: #{log_event} #{e.message}.") end - @current_batch = [] end @@ -246,16 +231,5 @@ def positive_number?(value) # false otherwise. Helpers::Validator.finite_number?(value) && value.positive? end - - # Calculate exponential backoff interval: 200ms, 400ms, 800ms, ... capped at 1s - # - # @param retry_count - Zero-based retry count - # @return [Float] - Delay in seconds - def calculate_retry_interval(retry_count) - initial_interval = Helpers::Constants::EVENT_DISPATCH_CONFIG[:INITIAL_RETRY_INTERVAL] - max_interval = Helpers::Constants::EVENT_DISPATCH_CONFIG[:MAX_RETRY_INTERVAL] - interval = initial_interval * (2**retry_count) - [interval, max_interval].min - end end end diff --git a/lib/optimizely/helpers/constants.rb b/lib/optimizely/helpers/constants.rb index 4334f56d..db042acd 100644 --- a/lib/optimizely/helpers/constants.rb +++ b/lib/optimizely/helpers/constants.rb @@ -459,10 +459,7 @@ module Constants }.freeze EVENT_DISPATCH_CONFIG = { - REQUEST_TIMEOUT: 10, - MAX_RETRIES: 3, - INITIAL_RETRY_INTERVAL: 0.2, # 200ms in seconds - MAX_RETRY_INTERVAL: 1.0 # 1 second + REQUEST_TIMEOUT: 10 }.freeze ODP_GRAPHQL_API_CONFIG = { @@ -493,9 +490,7 @@ module Constants DEFAULT_QUEUE_CAPACITY: 10_000, DEFAULT_BATCH_SIZE: 10, DEFAULT_FLUSH_INTERVAL_SECONDS: 1, - DEFAULT_RETRY_COUNT: 3, - INITIAL_RETRY_INTERVAL: 0.2, # 200ms in seconds - MAX_RETRY_INTERVAL: 1.0 # 1 second + DEFAULT_RETRY_COUNT: 3 }.freeze HTTP_HEADERS = { diff --git a/lib/optimizely/odp/odp_event_manager.rb b/lib/optimizely/odp/odp_event_manager.rb index ef199d91..fc9084a1 100644 --- a/lib/optimizely/odp/odp_event_manager.rb +++ b/lib/optimizely/odp/odp_event_manager.rb @@ -239,12 +239,7 @@ def flush_batch! end break unless should_retry - if i < @retry_count - 1 - # Exponential backoff: 200ms, 400ms, 800ms, ... capped at 1s - delay = calculate_retry_interval(i) - @logger.log(Logger::DEBUG, "Error dispatching ODP events, retrying (attempt #{i + 2} of #{@retry_count}) after #{delay}s") - sleep(delay) - end + @logger.log(Logger::DEBUG, 'Error dispatching ODP events, scheduled to retry.') if i < @retry_count i += 1 end @@ -287,16 +282,5 @@ def process_config_update @api_key = @odp_config&.api_key @api_host = @odp_config&.api_host end - - # Calculate exponential backoff interval: 200ms, 400ms, 800ms, ... capped at 1s - # - # @param retry_count - Zero-based retry count - # @return [Float] - Delay in seconds - def calculate_retry_interval(retry_count) - initial_interval = Helpers::Constants::ODP_EVENT_MANAGER[:INITIAL_RETRY_INTERVAL] - max_interval = Helpers::Constants::ODP_EVENT_MANAGER[:MAX_RETRY_INTERVAL] - interval = initial_interval * (2**retry_count) - [interval, max_interval].min - end end end diff --git a/spec/event/batch_event_processor_spec.rb b/spec/event/batch_event_processor_spec.rb index 604784d6..14e0d01c 100644 --- a/spec/event/batch_event_processor_spec.rb +++ b/spec/event/batch_event_processor_spec.rb @@ -293,11 +293,9 @@ @event_processor.flush # Wait until other thread has processed the event. sleep 0.1 until @event_processor.current_batch.empty? - sleep 0.7 # Wait for retries to complete (200ms + 400ms + processing time) expect(@notification_center).not_to have_received(:send_notifications) - # With retries, error will be logged 3 times (once per attempt) - expect(spy_logger).to have_received(:log).exactly(3).times.with( + expect(spy_logger).to have_received(:log).once.with( Logger::ERROR, "Error dispatching event: #{log_event} Timeout::Error." ) @@ -379,93 +377,4 @@ expect(@event_processor.event_queue.length).to eq(0) expect(spy_logger).to have_received(:log).with(Logger::WARN, 'Executor shutdown, not accepting tasks.').once end - - context 'retry logic with exponential backoff' do - it 'should retry on dispatch errors with exponential backoff' do - @event_processor = Optimizely::BatchEventProcessor.new( - event_dispatcher: @event_dispatcher, - batch_size: 1, - flush_interval: 10_000, - logger: spy_logger - ) - - user_event = Optimizely::UserEventFactory.create_conversion_event(project_config, event, 'test_user', nil, nil) - log_event = Optimizely::EventFactory.create_log_event(user_event, spy_logger) - - # Simulate dispatch failure twice, then success - call_count = 0 - allow(@event_dispatcher).to receive(:dispatch_event) do - call_count += 1 - raise StandardError, 'Network error' if call_count < 3 - end - - start_time = Time.now - @event_processor.process(user_event) - - # Wait for processing to complete - sleep 0.1 until @event_processor.event_queue.empty? - sleep 0.7 # Wait for retries to complete (200ms + 400ms + processing time) - - elapsed_time = Time.now - start_time - - # Should make 3 attempts total (1 initial + 2 retries) - expect(@event_dispatcher).to have_received(:dispatch_event).with(log_event).exactly(3).times - - # Should have delays: 200ms + 400ms = 600ms minimum - expect(elapsed_time).to be >= 0.6 - - # Should log retry attempts - expect(spy_logger).to have_received(:log).with( - Logger::DEBUG, /Retrying event dispatch/ - ).at_least(:twice) - end - - it 'should give up after max retries' do - @event_processor = Optimizely::BatchEventProcessor.new( - event_dispatcher: @event_dispatcher, - batch_size: 1, - flush_interval: 10_000, - logger: spy_logger - ) - - user_event = Optimizely::UserEventFactory.create_conversion_event(project_config, event, 'test_user', nil, nil) - log_event = Optimizely::EventFactory.create_log_event(user_event, spy_logger) - - # Simulate dispatch failure every time - allow(@event_dispatcher).to receive(:dispatch_event).and_raise(StandardError, 'Network error') - - @event_processor.process(user_event) - - # Wait for processing to complete - sleep 0.1 until @event_processor.event_queue.empty? - sleep 0.7 # Wait for all retries to complete - - # Should make 3 attempts total (1 initial + 2 retries) - expect(@event_dispatcher).to have_received(:dispatch_event).with(log_event).exactly(3).times - - # Should log error for each attempt - expect(spy_logger).to have_received(:log).with( - Logger::ERROR, /Error dispatching event/ - ).exactly(3).times - end - - it 'should calculate correct exponential backoff intervals' do - processor = Optimizely::BatchEventProcessor.new - - # First retry: 200ms - expect(processor.send(:calculate_retry_interval, 0)).to eq(0.2) - - # Second retry: 400ms - expect(processor.send(:calculate_retry_interval, 1)).to eq(0.4) - - # Third retry: 800ms - expect(processor.send(:calculate_retry_interval, 2)).to eq(0.8) - - # Fourth retry: capped at 1s - expect(processor.send(:calculate_retry_interval, 3)).to eq(1.0) - - # Fifth retry: still capped at 1s - expect(processor.send(:calculate_retry_interval, 4)).to eq(1.0) - end - end end diff --git a/spec/event_dispatcher_spec.rb b/spec/event_dispatcher_spec.rb index b061bcc0..193f584d 100644 --- a/spec/event_dispatcher_spec.rb +++ b/spec/event_dispatcher_spec.rb @@ -47,27 +47,16 @@ it 'should pass the proxy_config to the HttpUtils helper class' do event = Optimizely::Event.new(:post, @url, @params, @post_headers) - # Allow the method to be called (potentially multiple times due to retries) - allow(Optimizely::Helpers::HttpUtils).to receive(:make_request).with( + expect(Optimizely::Helpers::HttpUtils).to receive(:make_request).with( event.url, event.http_verb, event.params.to_json, event.headers, Optimizely::Helpers::Constants::EVENT_DISPATCH_CONFIG[:REQUEST_TIMEOUT], proxy_config - ).and_return(double(code: '200')) + ) @customized_event_dispatcher.dispatch_event(event) - - # Verify it was called at least once with the correct parameters - expect(Optimizely::Helpers::HttpUtils).to have_received(:make_request).with( - event.url, - event.http_verb, - event.params.to_json, - event.headers, - Optimizely::Helpers::Constants::EVENT_DISPATCH_CONFIG[:REQUEST_TIMEOUT], - proxy_config - ).at_least(:once) end end @@ -182,9 +171,10 @@ stub_request(:post, @url).to_return(status: 399) event = Optimizely::Event.new(:post, @url, @params, @post_headers) - @customized_event_dispatcher.dispatch_event(event) + response = @customized_event_dispatcher.dispatch_event(event) - expect(spy_logger).to have_received(:log).with(Logger::DEBUG, 'event successfully sent with response code 399') + expect(response).to have_received(:log) + expect(spy_logger).to have_received(:log) expect(error_handler).to_not have_received(:handle_error) end @@ -192,9 +182,10 @@ stub_request(:post, @url).to_return(status: 600) event = Optimizely::Event.new(:post, @url, @params, @post_headers) - @customized_event_dispatcher.dispatch_event(event) + response = @customized_event_dispatcher.dispatch_event(event) - expect(spy_logger).to have_received(:log).with(Logger::DEBUG, 'event successfully sent with response code 600') + expect(response).to have_received(:log) + expect(spy_logger).to have_received(:log) expect(error_handler).not_to have_received(:handle_error) end end diff --git a/spec/odp/odp_event_manager_spec.rb b/spec/odp/odp_event_manager_spec.rb index 6729bea5..57402887 100644 --- a/spec/odp/odp_event_manager_spec.rb +++ b/spec/odp/odp_event_manager_spec.rb @@ -260,20 +260,16 @@ allow(SecureRandom).to receive(:uuid).and_return(test_uuid) event_manager = Optimizely::OdpEventManager.new(logger: spy_logger) retry_count = event_manager.instance_variable_get('@retry_count') - allow(event_manager.api_manager).to receive(:send_odp_events).exactly(retry_count).times.with(api_key, api_host, odp_events).and_return(true) + allow(event_manager.api_manager).to receive(:send_odp_events).exactly(retry_count + 1).times.with(api_key, api_host, odp_events).and_return(true) event_manager.start!(odp_config) event_manager.send_event(**events[0]) event_manager.send_event(**events[1]) event_manager.flush - # Need to wait longer for retries with exponential backoff (200ms + 400ms = 600ms) - sleep(1) until event_manager.instance_variable_get('@event_queue').empty? + sleep(0.1) until event_manager.instance_variable_get('@event_queue').empty? expect(event_manager.instance_variable_get('@current_batch').length).to eq 0 - # Updated log message includes retry attempt and delay info - expect(spy_logger).to have_received(:log).with( - Logger::DEBUG, /Error dispatching ODP events, retrying/ - ).exactly(retry_count - 1).times + expect(spy_logger).to have_received(:log).exactly(retry_count).times.with(Logger::DEBUG, 'Error dispatching ODP events, scheduled to retry.') expect(spy_logger).to have_received(:log).once.with(Logger::ERROR, "ODP event send failed (Failed after 3 retries: #{processed_events.to_json}).") event_manager.stop! @@ -282,20 +278,16 @@ it 'should retry on network failure' do allow(SecureRandom).to receive(:uuid).and_return(test_uuid) event_manager = Optimizely::OdpEventManager.new(logger: spy_logger) - allow(event_manager.api_manager).to receive(:send_odp_events).with(api_key, api_host, odp_events).and_return(true, true, false) + allow(event_manager.api_manager).to receive(:send_odp_events).once.with(api_key, api_host, odp_events).and_return(true, true, false) event_manager.start!(odp_config) event_manager.send_event(**events[0]) event_manager.send_event(**events[1]) event_manager.flush - # Need to wait longer for retries with exponential backoff (200ms + 400ms = 600ms) - sleep(1) until event_manager.instance_variable_get('@event_queue').empty? + sleep(0.1) until event_manager.instance_variable_get('@event_queue').empty? expect(event_manager.instance_variable_get('@current_batch').length).to eq 0 - # Updated log message includes retry attempt and delay info - expect(spy_logger).to have_received(:log).with( - Logger::DEBUG, /Error dispatching ODP events, retrying/ - ).twice + expect(spy_logger).to have_received(:log).twice.with(Logger::DEBUG, 'Error dispatching ODP events, scheduled to retry.') expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything) expect(event_manager.running?).to be true event_manager.stop! @@ -547,52 +539,5 @@ expect(spy_logger).to have_received(:log).once.with(Logger::DEBUG, 'ODP event queue: cannot send before config has been set.') expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything) end - - it 'should use exponential backoff between retries' do - allow(SecureRandom).to receive(:uuid).and_return(test_uuid) - event_manager = Optimizely::OdpEventManager.new(logger: spy_logger) - - # All requests fail to trigger retries - allow(event_manager.api_manager).to receive(:send_odp_events).with(api_key, api_host, odp_events).and_return(true) - event_manager.start!(odp_config) - - start_time = Time.now - event_manager.send_event(**events[0]) - event_manager.send_event(**events[1]) - event_manager.flush - - # Wait for all retries to complete (need at least 600ms for 200ms + 400ms delays) - sleep(1) until event_manager.instance_variable_get('@event_queue').empty? - elapsed_time = Time.now - start_time - - # Should have delays: 200ms + 400ms = 600ms minimum for 3 total attempts - expect(elapsed_time).to be >= 0.5 # Allow some tolerance - - # Should log retry attempts with delay info - expect(spy_logger).to have_received(:log).with( - Logger::DEBUG, /retrying \(attempt \d+ of \d+\) after/ - ).at_least(:once) - - event_manager.stop! - end - - it 'should calculate correct exponential backoff intervals' do - event_manager = Optimizely::OdpEventManager.new - - # First retry: 200ms - expect(event_manager.send(:calculate_retry_interval, 0)).to eq(0.2) - - # Second retry: 400ms - expect(event_manager.send(:calculate_retry_interval, 1)).to eq(0.4) - - # Third retry: 800ms - expect(event_manager.send(:calculate_retry_interval, 2)).to eq(0.8) - - # Fourth retry: capped at 1s - expect(event_manager.send(:calculate_retry_interval, 3)).to eq(1.0) - - # Fifth retry: still capped at 1s - expect(event_manager.send(:calculate_retry_interval, 4)).to eq(1.0) - end end end From 4829b042d4fc40a4a084dc7ac6175b2e9698f5c0 Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Thu, 5 Feb 2026 13:09:11 -0800 Subject: [PATCH 2/3] Revert "[FSSDK-12034] Update: Exclude CMAB from UserProfileService (#384)" This reverts commit 15c3d5942893e0d74efb99ec672e5a278ec664c4. --- lib/optimizely/decision_service.rb | 2 - spec/decision_service_spec.rb | 96 ------------------------------ 2 files changed, 98 deletions(-) diff --git a/lib/optimizely/decision_service.rb b/lib/optimizely/decision_service.rb index 17a97358..051a8b66 100644 --- a/lib/optimizely/decision_service.rb +++ b/lib/optimizely/decision_service.rb @@ -132,8 +132,6 @@ def get_variation(project_config, experiment_id, user_context, user_profile_trac return VariationResult.new(nil, true, decide_reasons, nil) end - @logger.log(Logger::DEBUG, "Skipping user profile service for CMAB experiment '#{experiment_key}'. CMAB decisions are dynamic and not stored for sticky bucketing.") - should_ignore_user_profile_service = true cmab_decision = cmab_decision_result.result variation_id = cmab_decision&.variation_id cmab_uuid = cmab_decision&.cmab_uuid diff --git a/spec/decision_service_spec.rb b/spec/decision_service_spec.rb index eb70a9c9..30ad7d2e 100644 --- a/spec/decision_service_spec.rb +++ b/spec/decision_service_spec.rb @@ -1166,101 +1166,5 @@ expect(spy_cmab_service).not_to have_received(:get_decision) end end - - describe 'user profile service behavior' do - it 'should not save user profile for CMAB experiments' do - # Create a CMAB experiment configuration - 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(user_context.user_id, 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 update_user_profile method - allow(user_profile_tracker).to receive(:update_user_profile).and_call_original - - # Call get_variation with the CMAB experiment and user profile tracker - variation_result = decision_service.get_variation(config, '111150', user_context, user_profile_tracker) - - # Verify the variation and cmab_uuid are returned - expect(variation_result.variation_id).to eq('111151') - expect(variation_result.cmab_uuid).to eq('test-cmab-uuid-123') - - # Verify user profile was NOT updated for CMAB experiment - expect(user_profile_tracker).not_to have_received(:update_user_profile) - - # Verify debug log was called to explain CMAB exclusion - expect(spy_logger).to have_received(:log).with( - Logger::DEBUG, - "Skipping user profile service for CMAB experiment 'cmab_experiment'. CMAB decisions are dynamic and not stored for sticky bucketing." - ) - end - - it 'should save user profile for standard (non-CMAB) experiments' do - # Use a standard (non-CMAB) experiment - config.get_experiment_from_key('test_experiment') - user_context = project_instance.create_user_context('test_user', {}) - - # Create a user profile tracker - user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id, spy_user_profile_service, spy_logger) - - # Mock audience evaluation to pass - allow(Optimizely::Audience).to receive(:user_meets_audience_conditions?).and_return([true, []]) - - # Mock bucketer to return a variation - allow(decision_service.bucketer).to receive(:bucket) - .and_return([{'id' => '111129', 'key' => 'variation'}, []]) - - # Spy on update_user_profile method - allow(user_profile_tracker).to receive(:update_user_profile).and_call_original - - # Call get_variation with standard experiment and user profile tracker - variation_result = decision_service.get_variation(config, '111127', user_context, user_profile_tracker) - - # Verify variation was returned - expect(variation_result.variation_id).to eq('111129') - - # Verify user profile WAS updated for standard experiment - expect(user_profile_tracker).to have_received(:update_user_profile).with('111127', '111129') - end - end end end From 9e1116bdf158c6a27934919cd803df110d2e68f8 Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Mon, 9 Feb 2026 14:29:58 -0800 Subject: [PATCH 3/3] [FSSDK-12262] Exclude CMAB from UserProfileService - Skip UPS lookup for CMAB experiments to ensure dynamic decisions - Skip UPS save for CMAB experiments to prevent sticky bucketing - Add decision reason explaining UPS exclusion for CMAB - Add test to verify UPS is not used for CMAB experiments Co-Authored-By: Claude Sonnet 4.5 --- lib/optimizely/decision_service.rb | 14 +++++- spec/decision_service_spec.rb | 69 ++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/lib/optimizely/decision_service.rb b/lib/optimizely/decision_service.rb index 051a8b66..23db175a 100644 --- a/lib/optimizely/decision_service.rb +++ b/lib/optimizely/decision_service.rb @@ -100,7 +100,9 @@ def get_variation(project_config, experiment_id, user_context, user_profile_trac 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 + # Skip UPS for CMAB experiments as they require dynamic decisions + is_cmab_experiment = experiment.key?('cmab') + unless should_ignore_user_profile_service || is_cmab_experiment || !user_profile_tracker 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 @@ -111,6 +113,13 @@ def get_variation(project_config, experiment_id, user_context, user_profile_trac end end + # Add decision reason when UPS is skipped for CMAB + if is_cmab_experiment && !should_ignore_user_profile_service && user_profile_tracker + message = 'User Profile Service is not used for CMAB experiments.' + @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) @@ -155,7 +164,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 + # Skip UPS for CMAB experiments as they require dynamic decisions + user_profile_tracker.update_user_profile(experiment_id, variation_id) unless should_ignore_user_profile_service || is_cmab_experiment || !user_profile_tracker 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 30ad7d2e..610c23c8 100644 --- a/spec/decision_service_spec.rb +++ b/spec/decision_service_spec.rb @@ -1166,5 +1166,74 @@ expect(spy_cmab_service).not_to have_received(:get_decision) end end + + describe 'when user profile service is enabled' do + it 'should skip user profile service lookup and save for CMAB experiments' do + # Create a CMAB experiment configuration + 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 user profile tracker + user_profile_service = Optimizely::UserProfileService.new + user_profile_tracker = Optimizely::UserProfileTracker.new('test_user', 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 user profile tracker methods + allow(user_profile_tracker).to receive(:update_user_profile).and_call_original + 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) + + 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) + + # Verify UPS lookup was NOT called for CMAB + expect(decision_service).not_to have_received(:get_saved_variation_id) + + # Verify UPS save was NOT called for CMAB + expect(user_profile_tracker).not_to have_received(:update_user_profile) + + # Verify the UPS exclusion message is in reasons + expect(variation_result.reasons).to include('User Profile Service is not used for CMAB experiments.') + end + end end end