From e132636cbaf3a62253d0c8791c5e333fae1e954d Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Mon, 9 Feb 2026 15:16:24 -0800 Subject: [PATCH] [FSSDK-12262] Exclude CMAB from UserProfileService - Skip UPS lookup for CMAB experiments to ensure dynamic decisions - Skip UPS update for CMAB experiments to prevent sticky bucketing - Add decision reason when UPS is excluded for CMAB - Add comprehensive test to verify UPS exclusion for CMAB Co-Authored-By: Claude Sonnet 4.5 --- optimizely/decision_service.py | 14 +++++- tests/test_decision_service.py | 84 ++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index 28275ef..1354a17 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -457,7 +457,8 @@ def get_variation( } # Check to see if user has a decision available for the given experiment - if user_profile_tracker is not None and not ignore_user_profile: + # Skip UPS lookup for CMAB experiments as they require dynamic decisions + if user_profile_tracker is not None and not ignore_user_profile and not experiment.cmab: variation = self.get_stored_variation(project_config, experiment, user_profile_tracker.get_user_profile()) if variation: message = f'Returning previously activated variation ID "{variation}" of experiment ' \ @@ -472,6 +473,10 @@ def get_variation( } else: self.logger.warning('User profile has invalid format.') + elif user_profile_tracker is not None and not ignore_user_profile and experiment.cmab: + message = 'Skipped UPS lookup for CMAB experiment as it requires dynamic decisions.' + self.logger.info(message) + decide_reasons.append(message) # Check audience conditions audience_conditions = experiment.get_audience_conditions_or_ids() @@ -529,11 +534,16 @@ def get_variation( self.logger.info(message) decide_reasons.append(message) # Store this new decision and return the variation for the user - if user_profile_tracker is not None and not ignore_user_profile: + # Skip UPS update for CMAB experiments as they require dynamic decisions + if user_profile_tracker is not None and not ignore_user_profile and not experiment.cmab: try: user_profile_tracker.update_user_profile(experiment, variation) except: self.logger.exception(f'Unable to save user profile for user "{user_id}".') + elif user_profile_tracker is not None and not ignore_user_profile and experiment.cmab: + message = 'Skipped UPS update for CMAB experiment as it requires dynamic decisions.' + self.logger.info(message) + decide_reasons.append(message) return { 'cmab_uuid': cmab_uuid, 'error': False, diff --git a/tests/test_decision_service.py b/tests/test_decision_service.py index dbcb743..3dbb978 100644 --- a/tests/test_decision_service.py +++ b/tests/test_decision_service.py @@ -1074,6 +1074,90 @@ def test_get_variation_cmab_experiment_with_whitelisted_variation(self): mock_bucket.assert_not_called() mock_cmab_decision.assert_not_called() + def test_get_variation_cmab_experiment_skips_user_profile_service(self): + """Test that CMAB experiments skip UserProfileService for both lookup and save.""" + + # Create a user context + user = optimizely_user_context.OptimizelyUserContext( + optimizely_client=None, + logger=None, + user_id="test_user", + user_attributes={} + ) + + # Create a CMAB experiment + cmab_experiment = entities.Experiment( + '111150', + 'cmab_experiment', + 'Running', + '111150', + [], # No audience IDs + {}, + [ + entities.Variation('111151', 'variation_1'), + entities.Variation('111152', 'variation_2') + ], + [ + {'entityId': '111151', 'endOfRange': 5000}, + {'entityId': '111152', 'endOfRange': 10000} + ], + cmab={'trafficAllocation': 5000} + ) + + # Create a mock user profile tracker + mock_user_profile_tracker = mock.MagicMock() + mock_user_profile = user_profile.UserProfile('test_user', {'111150': {'variation_id': '111151'}}) + mock_user_profile_tracker.get_user_profile.return_value = mock_user_profile + + with mock.patch('optimizely.helpers.experiment.is_experiment_running', return_value=True), \ + mock.patch('optimizely.helpers.audience.does_user_meet_audience_conditions', return_value=[True, []]), \ + mock.patch.object(self.decision_service.bucketer, 'bucket_to_entity_id', + return_value=['$', []]) as mock_bucket, \ + mock.patch.object(self.decision_service, 'cmab_service') as mock_cmab_service, \ + mock.patch.object(self.project_config, 'get_variation_from_id', + return_value=entities.Variation('111152', 'variation_2')), \ + mock.patch.object(self.decision_service, + 'logger') as mock_logger: + + # Configure CMAB service to return a decision + mock_cmab_service.get_decision.return_value = ( + { + 'variation_id': '111152', + 'cmab_uuid': 'test-cmab-uuid-456' + }, + [] # reasons list + ) + + # Call get_variation with the CMAB experiment and user profile tracker + variation_result = self.decision_service.get_variation( + self.project_config, + cmab_experiment, + user, + mock_user_profile_tracker + ) + variation = variation_result['variation'] + cmab_uuid = variation_result['cmab_uuid'] + reasons = variation_result['reasons'] + error = variation_result['error'] + + # Verify the variation came from CMAB service, not UPS + self.assertEqual(entities.Variation('111152', 'variation_2'), variation) + self.assertEqual('test-cmab-uuid-456', cmab_uuid) + self.assertStrictFalse(error) + + # Verify UPS lookup was skipped + self.assertIn('Skipped UPS lookup for CMAB experiment as it requires dynamic decisions.', reasons) + + # Verify UPS update was skipped + self.assertIn('Skipped UPS update for CMAB experiment as it requires dynamic decisions.', reasons) + + # Verify UPS methods were not called + mock_user_profile_tracker.get_user_profile.assert_not_called() + mock_user_profile_tracker.update_user_profile.assert_not_called() + + # Verify CMAB service was called + mock_cmab_service.get_decision.assert_called_once() + class FeatureFlagDecisionTests(base.BaseTest): def setUp(self):