Skip to content

Commit a9106e0

Browse files
jaeoptclaude
andcommitted
[FSSDK-12262] Exclude CMAB from UserProfileService
- CMAB experiments now skip UserProfileService for both lookup and save operations - UPS maintains decisions across experiment lifetime without considering TTL or user attributes, which contradicts CMAB's dynamic nature - Added decision reason message when skipping UPS for CMAB experiments - Added comprehensive unit test to verify CMAB excludes UPS Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 88b0644 commit a9106e0

2 files changed

Lines changed: 88 additions & 2 deletions

File tree

optimizely/decision_service.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -457,7 +457,9 @@ def get_variation(
457457
}
458458

459459
# Check to see if user has a decision available for the given experiment
460-
if user_profile_tracker is not None and not ignore_user_profile:
460+
# CMAB experiments are excluded from UPS because UPS maintains decisions across the experiment
461+
# lifetime without considering TTL or user attributes, which contradicts CMAB's dynamic nature.
462+
if user_profile_tracker is not None and not ignore_user_profile and not experiment.cmab:
461463
variation = self.get_stored_variation(project_config, experiment, user_profile_tracker.get_user_profile())
462464
if variation:
463465
message = f'Returning previously activated variation ID "{variation}" of experiment ' \
@@ -472,6 +474,10 @@ def get_variation(
472474
}
473475
else:
474476
self.logger.warning('User profile has invalid format.')
477+
elif experiment.cmab:
478+
message = f'Skipping user profile service for CMAB experiment "{experiment.key}".'
479+
self.logger.debug(message)
480+
decide_reasons.append(message)
475481

476482
# Check audience conditions
477483
audience_conditions = experiment.get_audience_conditions_or_ids()
@@ -529,7 +535,9 @@ def get_variation(
529535
self.logger.info(message)
530536
decide_reasons.append(message)
531537
# Store this new decision and return the variation for the user
532-
if user_profile_tracker is not None and not ignore_user_profile:
538+
# CMAB experiments are excluded from UPS because UPS maintains decisions across the experiment
539+
# lifetime without considering TTL or user attributes, which contradicts CMAB's dynamic nature.
540+
if user_profile_tracker is not None and not ignore_user_profile and not experiment.cmab:
533541
try:
534542
user_profile_tracker.update_user_profile(experiment, variation)
535543
except:

tests/test_decision_service.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1074,6 +1074,84 @@ def test_get_variation_cmab_experiment_with_whitelisted_variation(self):
10741074
mock_bucket.assert_not_called()
10751075
mock_cmab_decision.assert_not_called()
10761076

1077+
def test_get_variation_cmab_experiment_excludes_user_profile_service(self):
1078+
"""Test that CMAB experiments do not use user profile service for sticky bucketing."""
1079+
1080+
# Create a user context
1081+
user = optimizely_user_context.OptimizelyUserContext(
1082+
optimizely_client=None,
1083+
logger=None,
1084+
user_id="test_user",
1085+
user_attributes={}
1086+
)
1087+
1088+
# Create a user profile service and tracker
1089+
user_profile_service = user_profile.UserProfileService()
1090+
user_profile_tracker = user_profile.UserProfileTracker(user.user_id, user_profile_service)
1091+
1092+
# Create a CMAB experiment
1093+
cmab_experiment = entities.Experiment(
1094+
'111150',
1095+
'cmab_experiment',
1096+
'Running',
1097+
'111150',
1098+
[], # No audience IDs
1099+
{},
1100+
[
1101+
entities.Variation('111151', 'variation_1'),
1102+
entities.Variation('111152', 'variation_2')
1103+
],
1104+
[
1105+
{'entityId': '111151', 'endOfRange': 5000},
1106+
{'entityId': '111152', 'endOfRange': 10000}
1107+
],
1108+
cmab={'trafficAllocation': 5000}
1109+
)
1110+
1111+
with mock.patch('optimizely.helpers.experiment.is_experiment_running', return_value=True), \
1112+
mock.patch('optimizely.helpers.audience.does_user_meet_audience_conditions', return_value=[True, []]), \
1113+
mock.patch.object(self.decision_service.bucketer, 'bucket_to_entity_id',
1114+
return_value=['$', []]) as mock_bucket, \
1115+
mock.patch.object(self.decision_service, 'cmab_service') as mock_cmab_service, \
1116+
mock.patch.object(self.project_config, 'get_variation_from_id',
1117+
return_value=entities.Variation('111151', 'variation_1')), \
1118+
mock.patch.object(self.decision_service, 'get_stored_variation') as mock_get_stored_variation, \
1119+
mock.patch.object(user_profile_tracker, 'update_user_profile') as mock_update_profile, \
1120+
mock.patch.object(self.decision_service, 'logger') as mock_logger:
1121+
1122+
# Configure CMAB service to return a decision
1123+
mock_cmab_service.get_decision.return_value = (
1124+
{
1125+
'variation_id': '111151',
1126+
'cmab_uuid': 'test-cmab-uuid'
1127+
},
1128+
[]
1129+
)
1130+
1131+
# Call get_variation with the CMAB experiment and user profile tracker
1132+
variation_result = self.decision_service.get_variation(
1133+
self.project_config,
1134+
cmab_experiment,
1135+
user,
1136+
user_profile_tracker
1137+
)
1138+
variation = variation_result['variation']
1139+
cmab_uuid = variation_result['cmab_uuid']
1140+
reasons = variation_result['reasons']
1141+
1142+
# Verify we get a variation from CMAB decision
1143+
self.assertEqual('variation_1', variation.key)
1144+
self.assertEqual('test-cmab-uuid', cmab_uuid)
1145+
1146+
# Verify that get_stored_variation was NOT called (UPS lookup skipped)
1147+
mock_get_stored_variation.assert_not_called()
1148+
1149+
# Verify that update_user_profile was NOT called (UPS save skipped)
1150+
mock_update_profile.assert_not_called()
1151+
1152+
# Verify decision reasons include the UPS exclusion message
1153+
self.assertIn('Skipping user profile service for CMAB experiment "cmab_experiment".', reasons)
1154+
10771155

10781156
class FeatureFlagDecisionTests(base.BaseTest):
10791157
def setUp(self):

0 commit comments

Comments
 (0)