Skip to content

Commit caba597

Browse files
[FSSDK-11991] update: expose CMAB cache configuration options (#463)
* Enhance CMAB decision handling by returning reasons for decisions and updating type hints * Refactor CMAB cache settings and add convenience methods for cache size and TTL configuration * Refactor CMAB cache type hints for improved clarity and type safety * Fix return type hint for set_cmab_custom_cache method to improve type safety * Changed default cmab cache size to match odp
1 parent 9b4af2f commit caba597

File tree

8 files changed

+147
-35
lines changed

8 files changed

+147
-35
lines changed

optimizely/cmab/cmab_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from optimizely.exceptions import CmabFetchError, CmabInvalidResponseError
2121

2222
# Default constants for CMAB requests
23-
DEFAULT_MAX_RETRIES = 3
23+
DEFAULT_MAX_RETRIES = 1
2424
DEFAULT_INITIAL_BACKOFF = 0.1 # in seconds (100 ms)
2525
DEFAULT_MAX_BACKOFF = 10 # in seconds
2626
DEFAULT_BACKOFF_MULTIPLIER = 2.0

optimizely/cmab/cmab_service.py

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,18 @@
1515
import hashlib
1616
import threading
1717

18-
from typing import Optional, List, TypedDict
18+
from typing import Optional, List, TypedDict, Tuple
1919
from optimizely.cmab.cmab_client import DefaultCmabClient
2020
from optimizely.odp.lru_cache import LRUCache
2121
from optimizely.optimizely_user_context import OptimizelyUserContext, UserAttributes
2222
from optimizely.project_config import ProjectConfig
2323
from optimizely.decision.optimizely_decide_option import OptimizelyDecideOption
2424
from optimizely import logger as _logging
2525
from optimizely.lib import pymmh3 as mmh3
26+
2627
NUM_LOCK_STRIPES = 1000
28+
DEFAULT_CMAB_CACHE_TIMEOUT = 30 * 60 # 30 minutes
29+
DEFAULT_CMAB_CACHE_SIZE = 10000
2730

2831

2932
class CmabDecision(TypedDict):
@@ -65,26 +68,40 @@ def _get_lock_index(self, user_id: str, rule_id: str) -> int:
6568
return hash_value % NUM_LOCK_STRIPES
6669

6770
def get_decision(self, project_config: ProjectConfig, user_context: OptimizelyUserContext,
68-
rule_id: str, options: List[str]) -> CmabDecision:
71+
rule_id: str, options: List[str]) -> Tuple[CmabDecision, List[str]]:
6972

7073
lock_index = self._get_lock_index(user_context.user_id, rule_id)
7174
with self.locks[lock_index]:
7275
return self._get_decision(project_config, user_context, rule_id, options)
7376

7477
def _get_decision(self, project_config: ProjectConfig, user_context: OptimizelyUserContext,
75-
rule_id: str, options: List[str]) -> CmabDecision:
78+
rule_id: str, options: List[str]) -> Tuple[CmabDecision, List[str]]:
7679

7780
filtered_attributes = self._filter_attributes(project_config, user_context, rule_id)
81+
reasons = []
7882

7983
if OptimizelyDecideOption.IGNORE_CMAB_CACHE in options:
80-
return self._fetch_decision(rule_id, user_context.user_id, filtered_attributes)
84+
reason = f"Ignoring CMAB cache for user '{user_context.user_id}' and rule '{rule_id}'"
85+
if self.logger:
86+
self.logger.debug(reason)
87+
reasons.append(reason)
88+
cmab_decision = self._fetch_decision(rule_id, user_context.user_id, filtered_attributes)
89+
return cmab_decision, reasons
8190

8291
if OptimizelyDecideOption.RESET_CMAB_CACHE in options:
92+
reason = f"Resetting CMAB cache for user '{user_context.user_id}' and rule '{rule_id}'"
93+
if self.logger:
94+
self.logger.debug(reason)
95+
reasons.append(reason)
8396
self.cmab_cache.reset()
8497

8598
cache_key = self._get_cache_key(user_context.user_id, rule_id)
8699

87100
if OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE in options:
101+
reason = f"Invalidating CMAB cache for user '{user_context.user_id}' and rule '{rule_id}'"
102+
if self.logger:
103+
self.logger.debug(reason)
104+
reasons.append(reason)
88105
self.cmab_cache.remove(cache_key)
89106

90107
cached_value = self.cmab_cache.lookup(cache_key)
@@ -93,17 +110,39 @@ def _get_decision(self, project_config: ProjectConfig, user_context: OptimizelyU
93110

94111
if cached_value:
95112
if cached_value['attributes_hash'] == attributes_hash:
96-
return CmabDecision(variation_id=cached_value['variation_id'], cmab_uuid=cached_value['cmab_uuid'])
113+
reason = f"CMAB cache hit for user '{user_context.user_id}' and rule '{rule_id}'"
114+
if self.logger:
115+
self.logger.debug(reason)
116+
reasons.append(reason)
117+
return CmabDecision(variation_id=cached_value['variation_id'],
118+
cmab_uuid=cached_value['cmab_uuid']), reasons
97119
else:
120+
reason = (
121+
f"CMAB cache attributes mismatch for user '{user_context.user_id}' "
122+
f"and rule '{rule_id}', fetching new decision."
123+
)
124+
if self.logger:
125+
self.logger.debug(reason)
126+
reasons.append(reason)
98127
self.cmab_cache.remove(cache_key)
128+
else:
129+
reason = f"CMAB cache miss for user '{user_context.user_id}' and rule '{rule_id}'"
130+
if self.logger:
131+
self.logger.debug(reason)
132+
reasons.append(reason)
99133

100134
cmab_decision = self._fetch_decision(rule_id, user_context.user_id, filtered_attributes)
135+
reason = f"CMAB decision is {cmab_decision}"
136+
if self.logger:
137+
self.logger.debug(reason)
138+
reasons.append(reason)
139+
101140
self.cmab_cache.save(cache_key, {
102141
'attributes_hash': attributes_hash,
103142
'variation_id': cmab_decision['variation_id'],
104143
'cmab_uuid': cmab_decision['cmab_uuid'],
105144
})
106-
return cmab_decision
145+
return cmab_decision, reasons
107146

108147
def _fetch_decision(self, rule_id: str, user_id: str, attributes: UserAttributes) -> CmabDecision:
109148
cmab_uuid = str(uuid.uuid4())

optimizely/decision_service.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,9 +175,10 @@ def _get_decision_for_cmab_experiment(
175175
# User is in CMAB allocation, proceed to CMAB decision
176176
try:
177177
options_list = list(options) if options is not None else []
178-
cmab_decision = self.cmab_service.get_decision(
178+
cmab_decision, cmab_reasons = self.cmab_service.get_decision(
179179
project_config, user_context, experiment.id, options_list
180180
)
181+
decide_reasons.extend(cmab_reasons)
181182
return {
182183
"error": False,
183184
"result": cmab_decision,

optimizely/event/event_processor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ class Signal:
7272

7373
def __init__(
7474
self,
75-
event_dispatcher: Optional[type[EventDispatcher] | CustomEventDispatcher] = None,
75+
event_dispatcher: Optional[EventDispatcher | CustomEventDispatcher] = None,
7676
logger: Optional[_logging.Logger] = None,
7777
start_on_init: bool = False,
7878
event_queue: Optional[queue.Queue[UserEvent | Signal]] = None,

optimizely/optimizely.py

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -46,17 +46,13 @@
4646
from .optimizely_user_context import OptimizelyUserContext, UserAttributes
4747
from .project_config import ProjectConfig
4848
from .cmab.cmab_client import DefaultCmabClient, CmabRetryConfig
49-
from .cmab.cmab_service import DefaultCmabService, CmabCacheValue
49+
from .cmab.cmab_service import DefaultCmabService, CmabCacheValue, DEFAULT_CMAB_CACHE_SIZE, DEFAULT_CMAB_CACHE_TIMEOUT
5050

5151
if TYPE_CHECKING:
5252
# prevent circular dependency by skipping import at runtime
5353
from .user_profile import UserProfileService
5454
from .helpers.event_tag_utils import EventTags
5555

56-
# Default constants for CMAB cache
57-
DEFAULT_CMAB_CACHE_TIMEOUT = 30 * 60 * 1000 # 30 minutes in milliseconds
58-
DEFAULT_CMAB_CACHE_SIZE = 1000
59-
6056

6157
class Optimizely:
6258
""" Class encapsulating all SDK functionality. """
@@ -77,6 +73,7 @@ def __init__(
7773
default_decide_options: Optional[list[str]] = None,
7874
event_processor_options: Optional[dict[str, Any]] = None,
7975
settings: Optional[OptimizelySdkSettings] = None,
76+
cmab_service: Optional[DefaultCmabService] = None,
8077
) -> None:
8178
""" Optimizely init method for managing Custom projects.
8279
@@ -178,16 +175,20 @@ def __init__(
178175
self.event_builder = event_builder.EventBuilder()
179176

180177
# Initialize CMAB components
181-
self.cmab_client = DefaultCmabClient(
182-
retry_config=CmabRetryConfig(),
183-
logger=self.logger
184-
)
185-
self.cmab_cache: LRUCache[str, CmabCacheValue] = LRUCache(DEFAULT_CMAB_CACHE_SIZE, DEFAULT_CMAB_CACHE_TIMEOUT)
186-
self.cmab_service = DefaultCmabService(
187-
cmab_cache=self.cmab_cache,
188-
cmab_client=self.cmab_client,
189-
logger=self.logger
190-
)
178+
if cmab_service:
179+
self.cmab_service = cmab_service
180+
else:
181+
self.cmab_client = DefaultCmabClient(
182+
retry_config=CmabRetryConfig(),
183+
logger=self.logger
184+
)
185+
self.cmab_cache: LRUCache[str, CmabCacheValue] = LRUCache(DEFAULT_CMAB_CACHE_SIZE,
186+
DEFAULT_CMAB_CACHE_TIMEOUT)
187+
self.cmab_service = DefaultCmabService(
188+
cmab_cache=self.cmab_cache,
189+
cmab_client=self.cmab_client,
190+
logger=self.logger
191+
)
191192
self.decision_service = decision_service.DecisionService(self.logger, user_profile_service, self.cmab_service)
192193
self.user_profile_service = user_profile_service
193194

optimizely/optimizely_factory.py

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
from .event_dispatcher import EventDispatcher, CustomEventDispatcher
2323
from .notification_center import NotificationCenter
2424
from .optimizely import Optimizely
25+
from .odp.lru_cache import LRUCache
26+
from .cmab.cmab_client import DefaultCmabClient, CmabRetryConfig
27+
from .cmab.cmab_service import DefaultCmabService, CmabCacheValue, DEFAULT_CMAB_CACHE_TIMEOUT, DEFAULT_CMAB_CACHE_SIZE
2528

2629
if TYPE_CHECKING:
2730
# prevent circular dependenacy by skipping import at runtime
@@ -36,6 +39,9 @@ class OptimizelyFactory:
3639
max_event_flush_interval: Optional[int] = None
3740
polling_interval: Optional[float] = None
3841
blocking_timeout: Optional[int] = None
42+
cmab_cache_size: Optional[int] = None
43+
cmab_cache_ttl: Optional[int] = None
44+
cmab_custom_cache: Optional[LRUCache[str, CmabCacheValue]] = None
3945

4046
@staticmethod
4147
def set_batch_size(batch_size: int) -> int:
@@ -75,6 +81,51 @@ def set_blocking_timeout(blocking_timeout: int) -> int:
7581
OptimizelyFactory.blocking_timeout = blocking_timeout
7682
return OptimizelyFactory.blocking_timeout
7783

84+
@staticmethod
85+
def set_cmab_cache_size(cache_size: int, logger: Optional[optimizely_logger.Logger] = None) -> Optional[int]:
86+
""" Convenience method for setting the maximum number of items in CMAB cache.
87+
Args:
88+
cache_size: Maximum number of items in CMAB cache.
89+
logger: Optional logger for logging messages.
90+
"""
91+
logger = logger or optimizely_logger.NoOpLogger()
92+
93+
if not isinstance(cache_size, int) or cache_size <= 0:
94+
logger.error(
95+
f"CMAB cache size is invalid, setting to default size {DEFAULT_CMAB_CACHE_SIZE}."
96+
)
97+
return None
98+
99+
OptimizelyFactory.cmab_cache_size = cache_size
100+
return OptimizelyFactory.cmab_cache_size
101+
102+
@staticmethod
103+
def set_cmab_cache_ttl(cache_ttl: int, logger: Optional[optimizely_logger.Logger] = None) -> Optional[int]:
104+
""" Convenience method for setting CMAB cache TTL.
105+
Args:
106+
cache_ttl: Time in seconds for cache entries to live.
107+
logger: Optional logger for logging messages.
108+
"""
109+
logger = logger or optimizely_logger.NoOpLogger()
110+
111+
if not isinstance(cache_ttl, (int, float)) or cache_ttl <= 0:
112+
logger.error(
113+
f"CMAB cache TTL is invalid, setting to default TTL {DEFAULT_CMAB_CACHE_TIMEOUT}."
114+
)
115+
return None
116+
117+
OptimizelyFactory.cmab_cache_ttl = int(cache_ttl)
118+
return OptimizelyFactory.cmab_cache_ttl
119+
120+
@staticmethod
121+
def set_cmab_custom_cache(custom_cache: LRUCache[str, CmabCacheValue]) -> LRUCache[str, CmabCacheValue]:
122+
""" Convenience method for setting custom CMAB cache.
123+
Args:
124+
custom_cache: Cache implementation with lookup, save, remove, and reset methods.
125+
"""
126+
OptimizelyFactory.cmab_custom_cache = custom_cache
127+
return OptimizelyFactory.cmab_custom_cache
128+
78129
@staticmethod
79130
def default_instance(sdk_key: str, datafile: Optional[str] = None) -> Optimizely:
80131
""" Returns a new optimizely instance..
@@ -104,9 +155,17 @@ def default_instance(sdk_key: str, datafile: Optional[str] = None) -> Optimizely
104155
notification_center=notification_center,
105156
)
106157

158+
# Initialize CMAB components
159+
cmab_client = DefaultCmabClient(retry_config=CmabRetryConfig(), logger=logger)
160+
cmab_cache = OptimizelyFactory.cmab_custom_cache or LRUCache(
161+
OptimizelyFactory.cmab_cache_size or DEFAULT_CMAB_CACHE_SIZE,
162+
OptimizelyFactory.cmab_cache_ttl or DEFAULT_CMAB_CACHE_TIMEOUT
163+
)
164+
cmab_service = DefaultCmabService(cmab_cache, cmab_client, logger)
165+
107166
optimizely = Optimizely(
108167
datafile, None, logger, error_handler, None, None, sdk_key, config_manager, notification_center,
109-
event_processor
168+
event_processor, cmab_service=cmab_service
110169
)
111170
return optimizely
112171

@@ -174,7 +233,16 @@ def custom_instance(
174233
notification_center=notification_center,
175234
)
176235

236+
# Initialize CMAB components
237+
cmab_client = DefaultCmabClient(retry_config=CmabRetryConfig(), logger=logger)
238+
cmab_cache = OptimizelyFactory.cmab_custom_cache or LRUCache(
239+
OptimizelyFactory.cmab_cache_size or DEFAULT_CMAB_CACHE_SIZE,
240+
OptimizelyFactory.cmab_cache_ttl or DEFAULT_CMAB_CACHE_TIMEOUT
241+
)
242+
cmab_service = DefaultCmabService(cmab_cache, cmab_client, logger)
243+
177244
return Optimizely(
178245
datafile, event_dispatcher, logger, error_handler, skip_json_validation, user_profile_service,
179-
sdk_key, config_manager, notification_center, event_processor, settings=settings
246+
sdk_key, config_manager, notification_center, event_processor, settings=settings,
247+
cmab_service=cmab_service
180248
)

tests/test_cmab_service.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def test_returns_decision_from_cache_when_valid(self):
6060
"cmab_uuid": "uuid-123"
6161
}
6262

63-
decision = self.cmab_service.get_decision(
63+
decision, _ = self.cmab_service.get_decision(
6464
self.mock_project_config, self.mock_user_context, "exp1", []
6565
)
6666

@@ -72,7 +72,7 @@ def test_ignores_cache_when_option_given(self):
7272
self.mock_cmab_client.fetch_decision.return_value = "varB"
7373
expected_attributes = {"age": 25, "location": "USA"}
7474

75-
decision = self.cmab_service.get_decision(
75+
decision, _ = self.cmab_service.get_decision(
7676
self.mock_project_config,
7777
self.mock_user_context,
7878
"exp1",
@@ -105,7 +105,7 @@ def test_invalidates_user_cache_when_option_given(self):
105105
def test_resets_cache_when_option_given(self):
106106
self.mock_cmab_client.fetch_decision.return_value = "varD"
107107

108-
decision = self.cmab_service.get_decision(
108+
decision, _ = self.cmab_service.get_decision(
109109
self.mock_project_config,
110110
self.mock_user_context,
111111
"exp1",
@@ -128,7 +128,7 @@ def test_new_decision_when_hash_changes(self):
128128
expected_hash = self.cmab_service._hash_attributes(expected_attribute)
129129
expected_key = self.cmab_service._get_cache_key("user123", "exp1")
130130

131-
decision = self.cmab_service.get_decision(self.mock_project_config, self.mock_user_context, "exp1", [])
131+
decision, _ = self.cmab_service.get_decision(self.mock_project_config, self.mock_user_context, "exp1", [])
132132
self.mock_cmab_cache.remove.assert_called_once_with(expected_key)
133133
self.mock_cmab_cache.save.assert_called_once_with(
134134
expected_key,
@@ -171,7 +171,7 @@ def test_only_cmab_attributes_passed_to_client(self):
171171
}
172172
self.mock_cmab_client.fetch_decision.return_value = "varF"
173173

174-
decision = self.cmab_service.get_decision(
174+
decision, _ = self.cmab_service.get_decision(
175175
self.mock_project_config,
176176
self.mock_user_context,
177177
"exp1",

tests/test_decision_service.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -792,10 +792,13 @@ def test_get_variation_cmab_experiment_user_in_traffic_allocation(self):
792792
'logger') as mock_logger:
793793

794794
# Configure CMAB service to return a decision
795-
mock_cmab_service.get_decision.return_value = {
796-
'variation_id': '111151',
797-
'cmab_uuid': 'test-cmab-uuid-123'
798-
}
795+
mock_cmab_service.get_decision.return_value = (
796+
{
797+
'variation_id': '111151',
798+
'cmab_uuid': 'test-cmab-uuid-123'
799+
},
800+
[] # reasons list
801+
)
799802

800803
# Call get_variation with the CMAB experiment
801804
variation_result = self.decision_service.get_variation(

0 commit comments

Comments
 (0)