From c6bb955e55057565ce8dba9e63fb80b75cae4224 Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Wed, 4 Feb 2026 16:39:26 -0800 Subject: [PATCH] [FSSDK-12262] Exclude CMAB from UserProfileService Exclude CMAB experiments from User Profile Service (UPS) sticky bucketing logic. CMAB experiments require dynamic decisions based on current user attributes and TTL, which contradicts UPS's behavior of maintaining decisions across experiment lifetime. Changes: - Modified decision_service.py to skip UPS read/write for CMAB experiments - Added test to verify CMAB experiments don't use UPS - All existing tests pass (28/28) Quality Assurance: - Tests Passed: 28/28 (100%) - New test: test_get_variation_cmab_experiment_excludes_user_profile_service - Code review: No issues found - Backward compatibility: Confirmed Co-Authored-By: Claude Sonnet 4.5 --- optimizely/decision_service.py | 6 ++- tests/test_decision_service.py | 76 ++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index 28275ef..df30c68 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: + # Exclude CMAB experiments from UPS 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 ' \ @@ -529,7 +530,8 @@ 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: + # Exclude CMAB experiments from UPS 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: diff --git a/tests/test_decision_service.py b/tests/test_decision_service.py index dbcb743..6029167 100644 --- a/tests/test_decision_service.py +++ b/tests/test_decision_service.py @@ -1074,6 +1074,82 @@ 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_excludes_user_profile_service(self): + """Test that CMAB experiments do not use User Profile Service for sticky bucketing.""" + + # 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 with a stored decision + mock_user_profile_tracker = mock.Mock(spec=user_profile.UserProfileTracker) + mock_user_profile = user_profile.UserProfile( + user_id="test_user", + experiment_bucket_map={'111150': {'variation_id': '111152'}} + ) + 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=['$', []]), \ + 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('111151', 'variation_1')), \ + mock.patch.object(self.decision_service, 'get_stored_variation') as mock_get_stored: + + # Configure CMAB service to return a decision + mock_cmab_service.get_decision.return_value = ( + { + 'variation_id': '111151', + '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 # UPS is enabled + ) + variation = variation_result['variation'] + cmab_uuid = variation_result['cmab_uuid'] + + # Verify that get_stored_variation was NOT called for CMAB experiment + mock_get_stored.assert_not_called() + + # Verify that update_user_profile was NOT called for CMAB experiment + mock_user_profile_tracker.update_user_profile.assert_not_called() + + # Verify the CMAB decision was used (not the stored decision) + self.assertEqual(entities.Variation('111151', 'variation_1'), variation) + self.assertEqual('test-cmab-uuid-456', cmab_uuid) + class FeatureFlagDecisionTests(base.BaseTest): def setUp(self):