Skip to content

Commit 3fe4935

Browse files
fix: add notification center registry (#413)
* add notification center registry * add abstractmethod get_sdk_key to BaseConfigManager * make sdk_key or datafile required in PollingConfigManager
1 parent 6be3cbd commit 3fe4935

11 files changed

+401
-49
lines changed

Diff for: optimizely/config_manager.py

+32-6
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2019-2020, 2022, Optimizely
1+
# Copyright 2019-2020, 2022-2023, Optimizely
22
# Licensed under the Apache License, Version 2.0 (the "License");
33
# you may not use this file except in compliance with the License.
44
# You may obtain a copy of the License at
@@ -25,6 +25,7 @@
2525
from . import project_config
2626
from .error_handler import NoOpErrorHandler, BaseErrorHandler
2727
from .notification_center import NotificationCenter
28+
from .notification_center_registry import _NotificationCenterRegistry
2829
from .helpers import enums
2930
from .helpers import validator
3031
from .optimizely_config import OptimizelyConfig, OptimizelyConfigService
@@ -78,6 +79,13 @@ def get_config(self) -> Optional[project_config.ProjectConfig]:
7879
The config should be an instance of project_config.ProjectConfig."""
7980
pass
8081

82+
@abstractmethod
83+
def get_sdk_key(self) -> Optional[str]:
84+
""" Get sdk_key for use by optimizely.Optimizely.
85+
The sdk_key should uniquely identify the datafile for a project and environment combination.
86+
"""
87+
pass
88+
8189

8290
class StaticConfigManager(BaseConfigManager):
8391
""" Config manager that returns ProjectConfig based on provided datafile. """
@@ -106,9 +114,13 @@ def __init__(
106114
)
107115
self._config: project_config.ProjectConfig = None # type: ignore[assignment]
108116
self.optimizely_config: Optional[OptimizelyConfig] = None
117+
self._sdk_key: Optional[str] = None
109118
self.validate_schema = not skip_json_validation
110119
self._set_config(datafile)
111120

121+
def get_sdk_key(self) -> Optional[str]:
122+
return self._sdk_key
123+
112124
def _set_config(self, datafile: Optional[str | bytes]) -> None:
113125
""" Looks up and sets datafile and config based on response body.
114126
@@ -146,8 +158,16 @@ def _set_config(self, datafile: Optional[str | bytes]) -> None:
146158
return
147159

148160
self._config = config
161+
self._sdk_key = self._sdk_key or config.sdk_key
149162
self.optimizely_config = OptimizelyConfigService(config).get_config()
150163
self.notification_center.send_notifications(enums.NotificationTypes.OPTIMIZELY_CONFIG_UPDATE)
164+
165+
internal_notification_center = _NotificationCenterRegistry.get_notification_center(
166+
self._sdk_key, self.logger
167+
)
168+
if internal_notification_center:
169+
internal_notification_center.send_notifications(enums.NotificationTypes.OPTIMIZELY_CONFIG_UPDATE)
170+
151171
self.logger.debug(
152172
'Received new datafile and updated config. '
153173
f'Old revision number: {previous_revision}. New revision number: {config.get_revision()}.'
@@ -181,11 +201,12 @@ def __init__(
181201
notification_center: Optional[NotificationCenter] = None,
182202
skip_json_validation: Optional[bool] = False,
183203
):
184-
""" Initialize config manager. One of sdk_key or url has to be set to be able to use.
204+
""" Initialize config manager. One of sdk_key or datafile has to be set to be able to use.
185205
186206
Args:
187-
sdk_key: Optional string uniquely identifying the datafile.
188-
datafile: Optional JSON string representing the project.
207+
sdk_key: Optional string uniquely identifying the datafile. If not provided, datafile must
208+
contain a sdk_key.
209+
datafile: Optional JSON string representing the project. If not provided, sdk_key is required.
189210
update_interval: Optional floating point number representing time interval in seconds
190211
at which to request datafile and set ProjectConfig.
191212
blocking_timeout: Optional Time in seconds to block the get_config call until config object
@@ -209,8 +230,13 @@ def __init__(
209230
notification_center=notification_center,
210231
skip_json_validation=skip_json_validation,
211232
)
233+
self._sdk_key = sdk_key or self._sdk_key
234+
235+
if self._sdk_key is None:
236+
raise optimizely_exceptions.InvalidInputException(enums.Errors.MISSING_SDK_KEY)
237+
212238
self.datafile_url = self.get_datafile_url(
213-
sdk_key, url, url_template or self.DATAFILE_URL_TEMPLATE
239+
self._sdk_key, url, url_template or self.DATAFILE_URL_TEMPLATE
214240
)
215241
self.set_update_interval(update_interval)
216242
self.set_blocking_timeout(blocking_timeout)
@@ -415,7 +441,7 @@ def __init__(
415441
*args: Any,
416442
**kwargs: Any
417443
):
418-
""" Initialize config manager. One of sdk_key or url has to be set to be able to use.
444+
""" Initialize config manager. One of sdk_key or datafile has to be set to be able to use.
419445
420446
Args:
421447
datafile_access_token: String to be attached to the request header to fetch the authenticated datafile.

Diff for: optimizely/helpers/enums.py

+1
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ class Errors:
126126
ODP_NOT_INTEGRATED: Final = 'ODP is not integrated.'
127127
ODP_NOT_ENABLED: Final = 'ODP is not enabled.'
128128
ODP_INVALID_DATA: Final = 'ODP data is not valid.'
129+
MISSING_SDK_KEY: Final = 'SDK key not provided/cannot be found in the datafile.'
129130

130131

131132
class ForcedDecisionLogs:

Diff for: optimizely/notification_center_registry.py

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Copyright 2023, 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+
14+
from __future__ import annotations
15+
from threading import Lock
16+
from typing import Optional
17+
from .logger import Logger as OptimizelyLogger
18+
from .notification_center import NotificationCenter
19+
from .helpers.enums import Errors
20+
21+
22+
class _NotificationCenterRegistry:
23+
""" Class managing internal notification centers."""
24+
_notification_centers: dict[str, NotificationCenter] = {}
25+
_lock = Lock()
26+
27+
@classmethod
28+
def get_notification_center(cls, sdk_key: Optional[str], logger: OptimizelyLogger) -> Optional[NotificationCenter]:
29+
"""Returns an internal notification center for the given sdk_key, creating one
30+
if none exists yet.
31+
32+
Args:
33+
sdk_key: A string sdk key to uniquely identify the notification center.
34+
logger: Optional logger.
35+
36+
Returns:
37+
None or NotificationCenter
38+
"""
39+
40+
if not sdk_key:
41+
logger.error(f'{Errors.MISSING_SDK_KEY} ODP may not work properly without it.')
42+
return None
43+
44+
with cls._lock:
45+
if sdk_key in cls._notification_centers:
46+
notification_center = cls._notification_centers[sdk_key]
47+
else:
48+
notification_center = NotificationCenter(logger)
49+
cls._notification_centers[sdk_key] = notification_center
50+
51+
return notification_center
52+
53+
@classmethod
54+
def remove_notification_center(cls, sdk_key: str) -> None:
55+
"""Remove a previously added notification center and clear all its listeners.
56+
57+
Args:
58+
sdk_key: The sdk_key of the notification center to remove.
59+
"""
60+
61+
with cls._lock:
62+
notification_center = cls._notification_centers.pop(sdk_key, None)
63+
if notification_center:
64+
notification_center.clear_all_notification_listeners()

Diff for: optimizely/optimizely.py

+34-27
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2016-2022, Optimizely
1+
# Copyright 2016-2023, Optimizely
22
# Licensed under the Apache License, Version 2.0 (the "License");
33
# you may not use this file except in compliance with the License.
44
# You may obtain a copy of the License at
@@ -37,6 +37,7 @@
3737
from .helpers.sdk_settings import OptimizelySdkSettings
3838
from .helpers.enums import DecisionSources
3939
from .notification_center import NotificationCenter
40+
from .notification_center_registry import _NotificationCenterRegistry
4041
from .odp.lru_cache import LRUCache
4142
from .odp.odp_manager import OdpManager
4243
from .optimizely_config import OptimizelyConfig, OptimizelyConfigService
@@ -143,18 +144,6 @@ def __init__(
143144
self.logger.exception(str(error))
144145
return
145146

146-
self.setup_odp()
147-
148-
self.odp_manager = OdpManager(
149-
self.sdk_settings.odp_disabled,
150-
self.sdk_settings.segments_cache,
151-
self.sdk_settings.odp_segment_manager,
152-
self.sdk_settings.odp_event_manager,
153-
self.sdk_settings.fetch_segments_timeout,
154-
self.sdk_settings.odp_event_timeout,
155-
self.logger
156-
)
157-
158147
config_manager_options: dict[str, Any] = {
159148
'datafile': datafile,
160149
'logger': self.logger,
@@ -174,8 +163,8 @@ def __init__(
174163
else:
175164
self.config_manager = StaticConfigManager(**config_manager_options)
176165

177-
if not self.sdk_settings.odp_disabled:
178-
self._update_odp_config_on_datafile_update()
166+
self.odp_manager: OdpManager
167+
self.setup_odp(self.config_manager.get_sdk_key())
179168

180169
self.event_builder = event_builder.EventBuilder()
181170
self.decision_service = decision_service.DecisionService(self.logger, user_profile_service)
@@ -1303,28 +1292,46 @@ def _decide_for_keys(
13031292
decisions[key] = decision
13041293
return decisions
13051294

1306-
def setup_odp(self) -> None:
1295+
def setup_odp(self, sdk_key: Optional[str]) -> None:
13071296
"""
1308-
- Make sure cache is instantiated with provided parameters or defaults.
1297+
- Make sure odp manager is instantiated with provided parameters or defaults.
13091298
- Set up listener to update odp_config when datafile is updated.
1299+
- Manually call callback in case datafile was received before the listener was registered.
13101300
"""
1311-
if self.sdk_settings.odp_disabled:
1312-
return
13131301

1314-
self.notification_center.add_notification_listener(
1315-
enums.NotificationTypes.OPTIMIZELY_CONFIG_UPDATE,
1316-
self._update_odp_config_on_datafile_update
1302+
# no need to instantiate a cache if a custom cache or segment manager is provided.
1303+
if (
1304+
not self.sdk_settings.odp_disabled and
1305+
not self.sdk_settings.odp_segment_manager and
1306+
not self.sdk_settings.segments_cache
1307+
):
1308+
self.sdk_settings.segments_cache = LRUCache(
1309+
self.sdk_settings.segments_cache_size,
1310+
self.sdk_settings.segments_cache_timeout_in_secs
1311+
)
1312+
1313+
self.odp_manager = OdpManager(
1314+
self.sdk_settings.odp_disabled,
1315+
self.sdk_settings.segments_cache,
1316+
self.sdk_settings.odp_segment_manager,
1317+
self.sdk_settings.odp_event_manager,
1318+
self.sdk_settings.fetch_segments_timeout,
1319+
self.sdk_settings.odp_event_timeout,
1320+
self.logger
13171321
)
13181322

1319-
if self.sdk_settings.odp_segment_manager:
1323+
if self.sdk_settings.odp_disabled:
13201324
return
13211325

1322-
if not self.sdk_settings.segments_cache:
1323-
self.sdk_settings.segments_cache = LRUCache(
1324-
self.sdk_settings.segments_cache_size,
1325-
self.sdk_settings.segments_cache_timeout_in_secs
1326+
internal_notification_center = _NotificationCenterRegistry.get_notification_center(sdk_key, self.logger)
1327+
if internal_notification_center:
1328+
internal_notification_center.add_notification_listener(
1329+
enums.NotificationTypes.OPTIMIZELY_CONFIG_UPDATE,
1330+
self._update_odp_config_on_datafile_update
13261331
)
13271332

1333+
self._update_odp_config_on_datafile_update()
1334+
13281335
def _update_odp_config_on_datafile_update(self) -> None:
13291336
config = None
13301337

Diff for: tests/base.py

+15-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2016-2021, Optimizely
1+
# Copyright 2016-2023 Optimizely
22
# Licensed under the Apache License, Version 2.0 (the "License");
33
# you may not use this file except in compliance with the License.
44
# You may obtain a copy of the License at
@@ -58,6 +58,7 @@ def fake_server_response(self, status_code: Optional[int] = None,
5858
def setUp(self, config_dict='config_dict'):
5959
self.config_dict = {
6060
'revision': '42',
61+
'sdkKey': 'basic-test',
6162
'version': '2',
6263
'events': [
6364
{'key': 'test_event', 'experimentIds': ['111127'], 'id': '111095'},
@@ -150,6 +151,7 @@ def setUp(self, config_dict='config_dict'):
150151
# datafile version 4
151152
self.config_dict_with_features = {
152153
'revision': '1',
154+
'sdkKey': 'features-test',
153155
'accountId': '12001',
154156
'projectId': '111111',
155157
'version': '4',
@@ -552,6 +554,7 @@ def setUp(self, config_dict='config_dict'):
552554

553555
self.config_dict_with_multiple_experiments = {
554556
'revision': '42',
557+
'sdkKey': 'multiple-experiments',
555558
'version': '2',
556559
'events': [
557560
{'key': 'test_event', 'experimentIds': ['111127', '111130'], 'id': '111095'},
@@ -657,6 +660,7 @@ def setUp(self, config_dict='config_dict'):
657660

658661
self.config_dict_with_unsupported_version = {
659662
'version': '5',
663+
'sdkKey': 'unsupported-version',
660664
'rollouts': [],
661665
'projectId': '10431130345',
662666
'variables': [],
@@ -1073,6 +1077,7 @@ def setUp(self, config_dict='config_dict'):
10731077
{'key': 'user_signed_up', 'id': '594090', 'experimentIds': ['1323241598', '1323241599']},
10741078
],
10751079
'revision': '3',
1080+
'sdkKey': 'typed-audiences',
10761081
}
10771082

10781083
self.config_dict_with_audience_segments = {
@@ -1261,8 +1266,15 @@ def setUp(self, config_dict='config_dict'):
12611266
}
12621267
],
12631268
'accountId': '10367498574',
1264-
'events': [],
1265-
'revision': '101'
1269+
'events': [
1270+
{
1271+
"experimentIds": ["10420810910"],
1272+
"id": "10404198134",
1273+
"key": "event1"
1274+
}
1275+
],
1276+
'revision': '101',
1277+
'sdkKey': 'segments-test'
12661278
}
12671279

12681280
config = getattr(self, config_dict)

Diff for: tests/test_config.py

+1
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ def test_init__with_v4_datafile(self):
160160
# Adding some additional fields like live variables and IP anonymization
161161
config_dict = {
162162
'revision': '42',
163+
'sdkKey': 'test',
163164
'version': '4',
164165
'anonymizeIP': False,
165166
'botFiltering': True,

Diff for: tests/test_config_manager.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -220,14 +220,14 @@ def test_get_config_blocks(self):
220220

221221
@mock.patch('requests.get')
222222
class PollingConfigManagerTest(base.BaseTest):
223-
def test_init__no_sdk_key_no_url__fails(self, _):
224-
""" Test that initialization fails if there is no sdk_key or url provided. """
223+
def test_init__no_sdk_key_no_datafile__fails(self, _):
224+
""" Test that initialization fails if there is no sdk_key or datafile provided. """
225225
self.assertRaisesRegex(
226226
optimizely_exceptions.InvalidInputException,
227-
'Must provide at least one of sdk_key or url.',
227+
enums.Errors.MISSING_SDK_KEY,
228228
config_manager.PollingConfigManager,
229229
sdk_key=None,
230-
url=None,
230+
datafile=None,
231231
)
232232

233233
def test_get_datafile_url__no_sdk_key_no_url_raises(self, _):

0 commit comments

Comments
 (0)