Skip to content

Commit caeaffe

Browse files
Python: Exclude CMAB experiments from user profile updates and add related tests
1 parent f253379 commit caeaffe

File tree

2 files changed

+185
-4
lines changed

2 files changed

+185
-4
lines changed

optimizely/decision_service.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -529,11 +529,18 @@ def get_variation(
529529
self.logger.info(message)
530530
decide_reasons.append(message)
531531
# Store this new decision and return the variation for the user
532+
# CMAB experiments are excluded from user profile storage to allow dynamic decision-making
532533
if user_profile_tracker is not None and not ignore_user_profile:
533-
try:
534-
user_profile_tracker.update_user_profile(experiment, variation)
535-
except:
536-
self.logger.exception(f'Unable to save user profile for user "{user_id}".')
534+
if not experiment.cmab:
535+
try:
536+
user_profile_tracker.update_user_profile(experiment, variation)
537+
except:
538+
self.logger.exception(f'Unable to save user profile for user "{user_id}".')
539+
else:
540+
self.logger.debug(
541+
f'Skipping user profile update for CMAB experiment "{experiment.key}". '
542+
f'CMAB decisions are dynamic and not stored for sticky bucketing.'
543+
)
537544
return {
538545
'cmab_uuid': cmab_uuid,
539546
'error': False,

tests/test_decision_service.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1074,6 +1074,180 @@ 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_does_not_save_user_profile(self):
1078+
"""Test that CMAB experiments do not save bucketing decisions to user profile."""
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=['$', []]), \
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(user_profile_tracker, 'update_user_profile') as mock_update_profile, \
1119+
mock.patch.object(self.decision_service, 'logger') as mock_logger:
1120+
1121+
# Configure CMAB service to return a decision
1122+
mock_cmab_service.get_decision.return_value = (
1123+
{
1124+
'variation_id': '111151',
1125+
'cmab_uuid': 'test-cmab-uuid-123'
1126+
},
1127+
[] # reasons list
1128+
)
1129+
1130+
# Call get_variation with the CMAB experiment and user profile tracker
1131+
variation_result = self.decision_service.get_variation(
1132+
self.project_config,
1133+
cmab_experiment,
1134+
user,
1135+
user_profile_tracker
1136+
)
1137+
variation = variation_result['variation']
1138+
cmab_uuid = variation_result['cmab_uuid']
1139+
1140+
# Verify the variation and cmab_uuid are returned
1141+
self.assertEqual(entities.Variation('111151', 'variation_1'), variation)
1142+
self.assertEqual('test-cmab-uuid-123', cmab_uuid)
1143+
1144+
# Verify user profile was NOT updated for CMAB experiment
1145+
mock_update_profile.assert_not_called()
1146+
1147+
# Verify debug log was called to explain CMAB exclusion
1148+
mock_logger.debug.assert_called_with(
1149+
'Skipping user profile update for CMAB experiment "cmab_experiment". '
1150+
'CMAB decisions are dynamic and not stored for sticky bucketing.'
1151+
)
1152+
1153+
def test_get_variation_standard_experiment_saves_user_profile(self):
1154+
"""Test that standard (non-CMAB) experiments DO save bucketing decisions to user profile."""
1155+
1156+
user = optimizely_user_context.OptimizelyUserContext(
1157+
optimizely_client=None,
1158+
logger=None,
1159+
user_id="test_user",
1160+
user_attributes={}
1161+
)
1162+
1163+
# Create a user profile service and tracker
1164+
user_profile_service = user_profile.UserProfileService()
1165+
user_profile_tracker = user_profile.UserProfileTracker(user.user_id, user_profile_service)
1166+
1167+
# Get a standard (non-CMAB) experiment
1168+
experiment = self.project_config.get_experiment_from_key("test_experiment")
1169+
1170+
with mock.patch('optimizely.decision_service.DecisionService.get_whitelisted_variation',
1171+
return_value=[None, []]), \
1172+
mock.patch('optimizely.decision_service.DecisionService.get_stored_variation',
1173+
return_value=None), \
1174+
mock.patch('optimizely.helpers.audience.does_user_meet_audience_conditions',
1175+
return_value=[True, []]), \
1176+
mock.patch('optimizely.bucketer.Bucketer.bucket',
1177+
return_value=[entities.Variation("111129", "variation"), []]), \
1178+
mock.patch.object(user_profile_tracker, 'update_user_profile') as mock_update_profile:
1179+
1180+
# Call get_variation with standard experiment and user profile tracker
1181+
variation_result = self.decision_service.get_variation(
1182+
self.project_config,
1183+
experiment,
1184+
user,
1185+
user_profile_tracker
1186+
)
1187+
variation = variation_result['variation']
1188+
1189+
# Verify variation was returned
1190+
self.assertEqual(entities.Variation("111129", "variation"), variation)
1191+
1192+
# Verify user profile WAS updated for standard experiment
1193+
mock_update_profile.assert_called_once_with(experiment, variation)
1194+
1195+
def test_get_variation_cmab_experiment_with_ignore_ups_option(self):
1196+
"""Test that CMAB experiments with IGNORE_USER_PROFILE_SERVICE option don't attempt profile update."""
1197+
1198+
user = optimizely_user_context.OptimizelyUserContext(
1199+
optimizely_client=None,
1200+
logger=None,
1201+
user_id="test_user",
1202+
user_attributes={}
1203+
)
1204+
1205+
# Create a user profile tracker
1206+
user_profile_service = user_profile.UserProfileService()
1207+
user_profile_tracker = user_profile.UserProfileTracker(user.user_id, user_profile_service)
1208+
1209+
# Create a CMAB experiment
1210+
cmab_experiment = entities.Experiment(
1211+
'111150',
1212+
'cmab_experiment',
1213+
'Running',
1214+
'111150',
1215+
[],
1216+
{},
1217+
[entities.Variation('111151', 'variation_1')],
1218+
[{'entityId': '111151', 'endOfRange': 10000}],
1219+
cmab={'trafficAllocation': 5000}
1220+
)
1221+
1222+
with mock.patch('optimizely.helpers.experiment.is_experiment_running', return_value=True), \
1223+
mock.patch('optimizely.helpers.audience.does_user_meet_audience_conditions', return_value=[True, []]), \
1224+
mock.patch.object(self.decision_service.bucketer, 'bucket_to_entity_id',
1225+
return_value=['$', []]), \
1226+
mock.patch.object(self.decision_service, 'cmab_service') as mock_cmab_service, \
1227+
mock.patch.object(self.project_config, 'get_variation_from_id',
1228+
return_value=entities.Variation('111151', 'variation_1')), \
1229+
mock.patch.object(user_profile_tracker, 'update_user_profile') as mock_update_profile, \
1230+
mock.patch.object(self.decision_service, 'logger'):
1231+
1232+
mock_cmab_service.get_decision.return_value = (
1233+
{'variation_id': '111151', 'cmab_uuid': 'test-uuid'},
1234+
[]
1235+
)
1236+
1237+
# Call with IGNORE_USER_PROFILE_SERVICE option
1238+
variation_result = self.decision_service.get_variation(
1239+
self.project_config,
1240+
cmab_experiment,
1241+
user,
1242+
user_profile_tracker,
1243+
[],
1244+
options=['IGNORE_USER_PROFILE_SERVICE']
1245+
)
1246+
1247+
# Verify variation returned but profile not updated
1248+
self.assertIsNotNone(variation_result['variation'])
1249+
mock_update_profile.assert_not_called()
1250+
10771251

10781252
class FeatureFlagDecisionTests(base.BaseTest):
10791253
def setUp(self):

0 commit comments

Comments
 (0)