Skip to content

Commit 747cbe6

Browse files
committed
[AI-FSSDK] [FSSDK-12262] Exclude CMAB from UserProfileService
1 parent 88b0644 commit 747cbe6

2 files changed

Lines changed: 190 additions & 2 deletions

File tree

optimizely/decision_service.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -457,7 +457,11 @@ 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
461+
# across the experiment lifetime without considering TTL or user attributes,
462+
# which contradicts CMAB's dynamic nature.
463+
is_cmab_experiment = bool(experiment.cmab)
464+
if user_profile_tracker is not None and not ignore_user_profile and not is_cmab_experiment:
461465
variation = self.get_stored_variation(project_config, experiment, user_profile_tracker.get_user_profile())
462466
if variation:
463467
message = f'Returning previously activated variation ID "{variation}" of experiment ' \
@@ -473,6 +477,11 @@ def get_variation(
473477
else:
474478
self.logger.warning('User profile has invalid format.')
475479

480+
if is_cmab_experiment and user_profile_tracker is not None and not ignore_user_profile:
481+
message = f'Skipping user profile service for CMAB experiment "{experiment.key}".'
482+
self.logger.info(message)
483+
decide_reasons.append(message)
484+
476485
# Check audience conditions
477486
audience_conditions = experiment.get_audience_conditions_or_ids()
478487
user_meets_audience_conditions, reasons_received = audience_helper.does_user_meet_audience_conditions(
@@ -529,7 +538,8 @@ def get_variation(
529538
self.logger.info(message)
530539
decide_reasons.append(message)
531540
# Store this new decision and return the variation for the user
532-
if user_profile_tracker is not None and not ignore_user_profile:
541+
# CMAB experiments are excluded from UPS to preserve dynamic decision-making
542+
if user_profile_tracker is not None and not ignore_user_profile and not is_cmab_experiment:
533543
try:
534544
user_profile_tracker.update_user_profile(experiment, variation)
535545
except:

tests/test_decision_service.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1074,6 +1074,184 @@ 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_skips_ups_lookup(self):
1078+
"""Test that get_variation skips UPS lookup for CMAB experiments."""
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 CMAB experiment
1089+
cmab_experiment = entities.Experiment(
1090+
'111150',
1091+
'cmab_experiment',
1092+
'Running',
1093+
'111150',
1094+
[],
1095+
{},
1096+
[entities.Variation('111151', 'variation_1')],
1097+
[{'entityId': '111151', 'endOfRange': 10000}],
1098+
cmab={'trafficAllocation': 5000}
1099+
)
1100+
1101+
# Set up a user profile tracker with a stored variation
1102+
user_profile_tracker = user_profile.UserProfileTracker(
1103+
'test_user', self.decision_service.user_profile_service, self.decision_service.logger
1104+
)
1105+
user_profile_tracker.user_profile = user_profile.UserProfile(
1106+
'test_user',
1107+
{'111150': {'variation_id': '111151'}}
1108+
)
1109+
1110+
with mock.patch('optimizely.helpers.audience.does_user_meet_audience_conditions',
1111+
return_value=[True, []]), \
1112+
mock.patch.object(self.decision_service.bucketer, 'bucket_to_entity_id',
1113+
return_value=['$', []]), \
1114+
mock.patch.object(self.decision_service, 'cmab_service') as mock_cmab_service, \
1115+
mock.patch.object(self.project_config, 'get_variation_from_id',
1116+
return_value=entities.Variation('111151', 'variation_1')), \
1117+
mock.patch.object(self.decision_service, 'get_stored_variation') as mock_get_stored:
1118+
1119+
mock_cmab_service.get_decision.return_value = (
1120+
{'variation_id': '111151', 'cmab_uuid': 'test-cmab-uuid'},
1121+
[]
1122+
)
1123+
1124+
variation_result = self.decision_service.get_variation(
1125+
self.project_config,
1126+
cmab_experiment,
1127+
user,
1128+
user_profile_tracker
1129+
)
1130+
1131+
# Verify UPS lookup was NOT called for CMAB experiment
1132+
mock_get_stored.assert_not_called()
1133+
1134+
# Verify the decision reason for UPS exclusion is present
1135+
self.assertIn(
1136+
'Skipping user profile service for CMAB experiment "cmab_experiment".',
1137+
variation_result['reasons']
1138+
)
1139+
1140+
# Verify CMAB service was still called
1141+
mock_cmab_service.get_decision.assert_called_once()
1142+
1143+
def test_get_variation_cmab_experiment_skips_ups_save(self):
1144+
"""Test that get_variation skips saving to UPS for CMAB experiments."""
1145+
1146+
# Create a user context
1147+
user = optimizely_user_context.OptimizelyUserContext(
1148+
optimizely_client=None,
1149+
logger=None,
1150+
user_id="test_user",
1151+
user_attributes={}
1152+
)
1153+
1154+
# Create a CMAB experiment
1155+
cmab_experiment = entities.Experiment(
1156+
'111150',
1157+
'cmab_experiment',
1158+
'Running',
1159+
'111150',
1160+
[],
1161+
{},
1162+
[entities.Variation('111151', 'variation_1')],
1163+
[{'entityId': '111151', 'endOfRange': 10000}],
1164+
cmab={'trafficAllocation': 5000}
1165+
)
1166+
1167+
# Set up a user profile tracker
1168+
user_profile_tracker = user_profile.UserProfileTracker(
1169+
'test_user', self.decision_service.user_profile_service, self.decision_service.logger
1170+
)
1171+
1172+
with mock.patch('optimizely.helpers.audience.does_user_meet_audience_conditions',
1173+
return_value=[True, []]), \
1174+
mock.patch.object(self.decision_service.bucketer, 'bucket_to_entity_id',
1175+
return_value=['$', []]), \
1176+
mock.patch.object(self.decision_service, 'cmab_service') as mock_cmab_service, \
1177+
mock.patch.object(self.project_config, 'get_variation_from_id',
1178+
return_value=entities.Variation('111151', 'variation_1')), \
1179+
mock.patch.object(user_profile_tracker, 'update_user_profile') as mock_update_profile:
1180+
1181+
mock_cmab_service.get_decision.return_value = (
1182+
{'variation_id': '111151', 'cmab_uuid': 'test-cmab-uuid'},
1183+
[]
1184+
)
1185+
1186+
variation_result = self.decision_service.get_variation(
1187+
self.project_config,
1188+
cmab_experiment,
1189+
user,
1190+
user_profile_tracker
1191+
)
1192+
1193+
# Verify UPS save was NOT called for CMAB experiment
1194+
mock_update_profile.assert_not_called()
1195+
1196+
# Verify we still got the correct variation
1197+
self.assertEqual(entities.Variation('111151', 'variation_1'), variation_result['variation'])
1198+
self.assertEqual('test-cmab-uuid', variation_result['cmab_uuid'])
1199+
self.assertStrictFalse(variation_result['error'])
1200+
1201+
def test_get_variation_non_cmab_experiment_still_uses_ups(self):
1202+
"""Test that non-CMAB experiments still use UPS for lookup and save."""
1203+
1204+
# Create a user context
1205+
user = optimizely_user_context.OptimizelyUserContext(
1206+
optimizely_client=None,
1207+
logger=None,
1208+
user_id="test_user",
1209+
user_attributes={}
1210+
)
1211+
1212+
# Create a non-CMAB experiment (no cmab attribute)
1213+
experiment = entities.Experiment(
1214+
'111150',
1215+
'regular_experiment',
1216+
'Running',
1217+
'111150',
1218+
[],
1219+
{},
1220+
[entities.Variation('111151', 'variation_1')],
1221+
[{'entityId': '111151', 'endOfRange': 10000}]
1222+
)
1223+
1224+
stored_variation = entities.Variation('111151', 'variation_1')
1225+
1226+
# Set up a user profile tracker with a stored variation
1227+
user_profile_tracker = user_profile.UserProfileTracker(
1228+
'test_user', self.decision_service.user_profile_service, self.decision_service.logger
1229+
)
1230+
user_profile_tracker.user_profile = user_profile.UserProfile(
1231+
'test_user',
1232+
{'111150': {'variation_id': '111151'}}
1233+
)
1234+
1235+
with mock.patch.object(self.decision_service, 'get_stored_variation',
1236+
return_value=stored_variation) as mock_get_stored:
1237+
1238+
variation_result = self.decision_service.get_variation(
1239+
self.project_config,
1240+
experiment,
1241+
user,
1242+
user_profile_tracker
1243+
)
1244+
1245+
# Verify UPS lookup WAS called for non-CMAB experiment
1246+
mock_get_stored.assert_called_once()
1247+
1248+
# Verify the UPS exclusion message is NOT in reasons
1249+
for reason in variation_result['reasons']:
1250+
self.assertNotIn('Skipping user profile service', reason)
1251+
1252+
# Verify we got the stored variation
1253+
self.assertEqual(stored_variation, variation_result['variation'])
1254+
10771255

10781256
class FeatureFlagDecisionTests(base.BaseTest):
10791257
def setUp(self):

0 commit comments

Comments
 (0)