Skip to content

Commit 82ec019

Browse files
[FSSDK-11166] update: implement CMAB service (#455)
* update: Implement DefaultCmabService * update: Add tests for DefaultCmabService * update: Fix formatting in DefaultCmabService and test cases * update: Fix key mapping in ProjectConfig to use 'id' instead of empty string * update: Refactor cache decision logic and enhance test cases for DefaultCmabService * update: Refactor attribute handling in get_decision and add test for CMAB attribute filtering
1 parent 046d457 commit 82ec019

File tree

5 files changed

+311
-0
lines changed

5 files changed

+311
-0
lines changed

optimizely/cmab/cmab_service.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# Copyright 2025 Optimizely
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
import uuid
14+
import json
15+
import hashlib
16+
17+
from typing import Optional, List, TypedDict
18+
from optimizely.cmab.cmab_client import DefaultCmabClient
19+
from optimizely.odp.lru_cache import LRUCache
20+
from optimizely.optimizely_user_context import OptimizelyUserContext, UserAttributes
21+
from optimizely.project_config import ProjectConfig
22+
from optimizely.decision.optimizely_decide_option import OptimizelyDecideOption
23+
from optimizely import logger as _logging
24+
25+
26+
class CmabDecision(TypedDict):
27+
variation_id: str
28+
cmab_uuid: str
29+
30+
31+
class CmabCacheValue(TypedDict):
32+
attributes_hash: str
33+
variation_id: str
34+
cmab_uuid: str
35+
36+
37+
class DefaultCmabService:
38+
def __init__(self, cmab_cache: LRUCache[str, CmabCacheValue],
39+
cmab_client: DefaultCmabClient, logger: Optional[_logging.Logger] = None):
40+
self.cmab_cache = cmab_cache
41+
self.cmab_client = cmab_client
42+
self.logger = logger
43+
44+
def get_decision(self, project_config: ProjectConfig, user_context: OptimizelyUserContext,
45+
rule_id: str, options: List[str]) -> CmabDecision:
46+
47+
filtered_attributes = self._filter_attributes(project_config, user_context, rule_id)
48+
49+
if OptimizelyDecideOption.IGNORE_CMAB_CACHE in options:
50+
return self._fetch_decision(rule_id, user_context.user_id, filtered_attributes)
51+
52+
if OptimizelyDecideOption.RESET_CMAB_CACHE in options:
53+
self.cmab_cache.reset()
54+
55+
cache_key = self._get_cache_key(user_context.user_id, rule_id)
56+
57+
if OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE in options:
58+
self.cmab_cache.remove(cache_key)
59+
60+
cached_value = self.cmab_cache.lookup(cache_key)
61+
62+
attributes_hash = self._hash_attributes(filtered_attributes)
63+
64+
if cached_value:
65+
if cached_value['attributes_hash'] == attributes_hash:
66+
return CmabDecision(variation_id=cached_value['variation_id'], cmab_uuid=cached_value['cmab_uuid'])
67+
else:
68+
self.cmab_cache.remove(cache_key)
69+
70+
cmab_decision = self._fetch_decision(rule_id, user_context.user_id, filtered_attributes)
71+
self.cmab_cache.save(cache_key, {
72+
'attributes_hash': attributes_hash,
73+
'variation_id': cmab_decision['variation_id'],
74+
'cmab_uuid': cmab_decision['cmab_uuid'],
75+
})
76+
return cmab_decision
77+
78+
def _fetch_decision(self, rule_id: str, user_id: str, attributes: UserAttributes) -> CmabDecision:
79+
cmab_uuid = str(uuid.uuid4())
80+
variation_id = self.cmab_client.fetch_decision(rule_id, user_id, attributes, cmab_uuid)
81+
cmab_decision = CmabDecision(variation_id=variation_id, cmab_uuid=cmab_uuid)
82+
return cmab_decision
83+
84+
def _filter_attributes(self, project_config: ProjectConfig,
85+
user_context: OptimizelyUserContext, rule_id: str) -> UserAttributes:
86+
user_attributes = user_context.get_user_attributes()
87+
filtered_user_attributes = UserAttributes({})
88+
89+
experiment = project_config.experiment_id_map.get(rule_id)
90+
if not experiment or not experiment.cmab:
91+
return filtered_user_attributes
92+
93+
cmab_attribute_ids = experiment.cmab['attributeIds']
94+
for attribute_id in cmab_attribute_ids:
95+
attribute = project_config.attribute_id_map.get(attribute_id)
96+
if attribute and attribute.key in user_attributes:
97+
filtered_user_attributes[attribute.key] = user_attributes[attribute.key]
98+
99+
return filtered_user_attributes
100+
101+
def _get_cache_key(self, user_id: str, rule_id: str) -> str:
102+
return f"{len(user_id)}-{user_id}-{rule_id}"
103+
104+
def _hash_attributes(self, attributes: UserAttributes) -> str:
105+
sorted_attrs = json.dumps(attributes, sort_keys=True)
106+
return hashlib.md5(sorted_attrs.encode()).hexdigest()

optimizely/decision/optimizely_decide_option.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,6 @@ class OptimizelyDecideOption:
2525
IGNORE_USER_PROFILE_SERVICE: Final = 'IGNORE_USER_PROFILE_SERVICE'
2626
INCLUDE_REASONS: Final = 'INCLUDE_REASONS'
2727
EXCLUDE_VARIABLES: Final = 'EXCLUDE_VARIABLES'
28+
IGNORE_CMAB_CACHE: Final = "IGNORE_CMAB_CACHE"
29+
RESET_CMAB_CACHE: Final = "RESET_CMAB_CACHE"
30+
INVALIDATE_USER_CMAB_CACHE: Final = "INVALIDATE_USER_CMAB_CACHE"

optimizely/project_config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ def __init__(self, datafile: str | bytes, logger: Logger, error_handler: Any):
9797
self.attribute_id_to_key_map: dict[str, str] = {}
9898
for attribute in self.attributes:
9999
self.attribute_id_to_key_map[attribute['id']] = attribute['key']
100+
self.attribute_id_map: dict[str, entities.Attribute] = self._generate_key_map(
101+
self.attributes, 'id', entities.Attribute
102+
)
100103
self.audience_id_map: dict[str, entities.Audience] = self._generate_key_map(
101104
self.audiences, 'id', entities.Audience
102105
)

tests/test_cmab_client.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
# Copyright 2025, Optimizely
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
113
import unittest
214
import json
315
from unittest.mock import MagicMock, patch, call

tests/test_cmab_service.py

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
# Copyright 2025, Optimizely
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
import unittest
14+
from unittest.mock import MagicMock
15+
from optimizely.cmab.cmab_service import DefaultCmabService
16+
from optimizely.optimizely_user_context import OptimizelyUserContext
17+
from optimizely.decision.optimizely_decide_option import OptimizelyDecideOption
18+
from optimizely.odp.lru_cache import LRUCache
19+
from optimizely.cmab.cmab_client import DefaultCmabClient
20+
from optimizely.project_config import ProjectConfig
21+
from optimizely.entities import Attribute
22+
23+
24+
class TestDefaultCmabService(unittest.TestCase):
25+
def setUp(self):
26+
self.mock_cmab_cache = MagicMock(spec=LRUCache)
27+
self.mock_cmab_client = MagicMock(spec=DefaultCmabClient)
28+
self.mock_logger = MagicMock()
29+
30+
self.cmab_service = DefaultCmabService(
31+
cmab_cache=self.mock_cmab_cache,
32+
cmab_client=self.mock_cmab_client,
33+
logger=self.mock_logger
34+
)
35+
36+
self.mock_project_config = MagicMock(spec=ProjectConfig)
37+
self.mock_user_context = MagicMock(spec=OptimizelyUserContext)
38+
self.mock_user_context.user_id = 'user123'
39+
self.mock_user_context.get_user_attributes.return_value = {'age': 25, 'location': 'USA'}
40+
41+
# Setup mock experiment and attribute mapping
42+
self.mock_project_config.experiment_id_map = {
43+
'exp1': MagicMock(cmab={'attributeIds': ['66', '77']})
44+
}
45+
attr1 = Attribute(id="66", key="age")
46+
attr2 = Attribute(id="77", key="location")
47+
self.mock_project_config.attribute_id_map = {
48+
"66": attr1,
49+
"77": attr2
50+
}
51+
52+
def test_returns_decision_from_cache_when_valid(self):
53+
expected_key = self.cmab_service._get_cache_key("user123", "exp1")
54+
expected_attributes = {"age": 25, "location": "USA"}
55+
expected_hash = self.cmab_service._hash_attributes(expected_attributes)
56+
57+
self.mock_cmab_cache.lookup.return_value = {
58+
"attributes_hash": expected_hash,
59+
"variation_id": "varA",
60+
"cmab_uuid": "uuid-123"
61+
}
62+
63+
decision = self.cmab_service.get_decision(
64+
self.mock_project_config, self.mock_user_context, "exp1", []
65+
)
66+
67+
self.mock_cmab_cache.lookup.assert_called_once_with(expected_key)
68+
self.assertEqual(decision["variation_id"], "varA")
69+
self.assertEqual(decision["cmab_uuid"], "uuid-123")
70+
71+
def test_ignores_cache_when_option_given(self):
72+
self.mock_cmab_client.fetch_decision.return_value = "varB"
73+
expected_attributes = {"age": 25, "location": "USA"}
74+
75+
decision = self.cmab_service.get_decision(
76+
self.mock_project_config,
77+
self.mock_user_context,
78+
"exp1",
79+
[OptimizelyDecideOption.IGNORE_CMAB_CACHE]
80+
)
81+
82+
self.assertEqual(decision["variation_id"], "varB")
83+
self.assertIn('cmab_uuid', decision)
84+
self.mock_cmab_client.fetch_decision.assert_called_once_with(
85+
"exp1",
86+
self.mock_user_context.user_id,
87+
expected_attributes,
88+
decision["cmab_uuid"]
89+
)
90+
91+
def test_invalidates_user_cache_when_option_given(self):
92+
self.mock_cmab_client.fetch_decision.return_value = "varC"
93+
self.mock_cmab_cache.lookup.return_value = None
94+
self.cmab_service.get_decision(
95+
self.mock_project_config,
96+
self.mock_user_context,
97+
"exp1",
98+
[OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE]
99+
)
100+
101+
key = self.cmab_service._get_cache_key("user123", "exp1")
102+
self.mock_cmab_cache.remove.assert_called_with(key)
103+
self.mock_cmab_cache.remove.assert_called_once()
104+
105+
def test_resets_cache_when_option_given(self):
106+
self.mock_cmab_client.fetch_decision.return_value = "varD"
107+
108+
decision = self.cmab_service.get_decision(
109+
self.mock_project_config,
110+
self.mock_user_context,
111+
"exp1",
112+
[OptimizelyDecideOption.RESET_CMAB_CACHE]
113+
)
114+
115+
self.mock_cmab_cache.reset.assert_called_once()
116+
self.assertEqual(decision["variation_id"], "varD")
117+
self.assertIn('cmab_uuid', decision)
118+
119+
def test_new_decision_when_hash_changes(self):
120+
self.mock_cmab_cache.lookup.return_value = {
121+
"attributes_hash": "old_hash",
122+
"variation_id": "varA",
123+
"cmab_uuid": "uuid-123"
124+
}
125+
self.mock_cmab_client.fetch_decision.return_value = "varE"
126+
127+
expected_attribute = {"age": 25, "location": "USA"}
128+
expected_hash = self.cmab_service._hash_attributes(expected_attribute)
129+
expected_key = self.cmab_service._get_cache_key("user123", "exp1")
130+
131+
decision = self.cmab_service.get_decision(self.mock_project_config, self.mock_user_context, "exp1", [])
132+
self.mock_cmab_cache.remove.assert_called_once_with(expected_key)
133+
self.mock_cmab_cache.save.assert_called_once_with(
134+
expected_key,
135+
{
136+
"cmab_uuid": decision["cmab_uuid"],
137+
"variation_id": decision["variation_id"],
138+
"attributes_hash": expected_hash
139+
}
140+
)
141+
self.assertEqual(decision["variation_id"], "varE")
142+
self.mock_cmab_client.fetch_decision.assert_called_once_with(
143+
"exp1",
144+
self.mock_user_context.user_id,
145+
expected_attribute,
146+
decision["cmab_uuid"]
147+
)
148+
149+
def test_filter_attributes_returns_correct_subset(self):
150+
filtered = self.cmab_service._filter_attributes(self.mock_project_config, self.mock_user_context, "exp1")
151+
self.assertEqual(filtered["age"], 25)
152+
self.assertEqual(filtered["location"], "USA")
153+
154+
def test_filter_attributes_empty_when_no_cmab(self):
155+
self.mock_project_config.experiment_id_map["exp1"].cmab = None
156+
filtered = self.cmab_service._filter_attributes(self.mock_project_config, self.mock_user_context, "exp1")
157+
self.assertEqual(filtered, {})
158+
159+
def test_hash_attributes_produces_stable_output(self):
160+
attrs = {"b": 2, "a": 1}
161+
hash1 = self.cmab_service._hash_attributes(attrs)
162+
hash2 = self.cmab_service._hash_attributes({"a": 1, "b": 2})
163+
self.assertEqual(hash1, hash2)
164+
165+
def test_only_cmab_attributes_passed_to_client(self):
166+
self.mock_user_context.get_user_attributes.return_value = {
167+
'age': 25,
168+
'location': 'USA',
169+
'extra_attr': 'value', # This shouldn't be passed to CMAB
170+
'another_extra': 123 # This shouldn't be passed to CMAB
171+
}
172+
self.mock_cmab_client.fetch_decision.return_value = "varF"
173+
174+
decision = self.cmab_service.get_decision(
175+
self.mock_project_config,
176+
self.mock_user_context,
177+
"exp1",
178+
[OptimizelyDecideOption.IGNORE_CMAB_CACHE]
179+
)
180+
181+
# Verify only age and location are passed (attributes configured in setUp)
182+
self.mock_cmab_client.fetch_decision.assert_called_once_with(
183+
"exp1",
184+
self.mock_user_context.user_id,
185+
{"age": 25, "location": "USA"},
186+
decision["cmab_uuid"]
187+
)

0 commit comments

Comments
 (0)