Skip to content

Commit 415a666

Browse files
authored
feat: odp segment manager (#402)
* feat: add odp_segment_manager * feat: add segment manager * fix pr comments * fix tests * refacored tests * fix PR comments * refactor logs in tests for cache miss/ignore * cleanup
1 parent 81a5bfe commit 415a666

File tree

3 files changed

+326
-0
lines changed

3 files changed

+326
-0
lines changed

optimizely/odp/odp_segment_manager.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Copyright 2022, 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+
16+
from typing import Optional
17+
18+
from optimizely import logger as optimizely_logger
19+
from optimizely.helpers.enums import Errors
20+
from optimizely.odp.optimizely_odp_option import OptimizelyOdpOption
21+
from optimizely.odp.lru_cache import OptimizelySegmentsCache
22+
from optimizely.odp.odp_config import OdpConfig
23+
from optimizely.odp.zaius_graphql_api_manager import ZaiusGraphQLApiManager
24+
25+
26+
class OdpSegmentManager:
27+
"""Schedules connections to ODP for audience segmentation and caches the results."""
28+
29+
def __init__(self, odp_config: OdpConfig, segments_cache: OptimizelySegmentsCache,
30+
zaius_manager: ZaiusGraphQLApiManager,
31+
logger: Optional[optimizely_logger.Logger] = None) -> None:
32+
33+
self.odp_config = odp_config
34+
self.segments_cache = segments_cache
35+
self.zaius_manager = zaius_manager
36+
self.logger = logger or optimizely_logger.NoOpLogger()
37+
38+
def fetch_qualified_segments(self, user_key: str, user_value: str, options: list[str]) -> \
39+
Optional[list[str]]:
40+
"""
41+
Args:
42+
user_key: The key for identifying the id type.
43+
user_value: The id itself.
44+
options: An array of OptimizelySegmentOptions used to ignore and/or reset the cache.
45+
46+
Returns:
47+
Qualified segments for the user from the cache or the ODP server if not in the cache.
48+
"""
49+
odp_api_key = self.odp_config.get_api_key()
50+
odp_api_host = self.odp_config.get_api_host()
51+
odp_segments_to_check = self.odp_config.get_segments_to_check()
52+
53+
if not (odp_api_key and odp_api_host):
54+
self.logger.error(Errors.FETCH_SEGMENTS_FAILED.format('api_key/api_host not defined'))
55+
return None
56+
57+
if not odp_segments_to_check:
58+
self.logger.debug('No segments are used in the project. Returning empty list.')
59+
return []
60+
61+
cache_key = self.make_cache_key(user_key, user_value)
62+
63+
ignore_cache = OptimizelyOdpOption.IGNORE_CACHE in options
64+
reset_cache = OptimizelyOdpOption.RESET_CACHE in options
65+
66+
if reset_cache:
67+
self._reset()
68+
69+
if not ignore_cache and not reset_cache:
70+
segments = self.segments_cache.lookup(cache_key)
71+
if segments:
72+
self.logger.debug('ODP cache hit. Returning segments from cache.')
73+
return segments
74+
self.logger.debug('ODP cache miss.')
75+
76+
self.logger.debug('Making a call to ODP server.')
77+
78+
segments = self.zaius_manager.fetch_segments(odp_api_key, odp_api_host, user_key, user_value,
79+
odp_segments_to_check)
80+
81+
if segments and not ignore_cache:
82+
self.segments_cache.save(cache_key, segments)
83+
84+
return segments
85+
86+
def _reset(self) -> None:
87+
self.segments_cache.reset()
88+
89+
def make_cache_key(self, user_key: str, user_value: str) -> str:
90+
return f'{user_key}-$-{user_value}'
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Copyright 2022, 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 sys import version_info
15+
16+
if version_info < (3, 8):
17+
from typing_extensions import Final
18+
else:
19+
from typing import Final # type: ignore
20+
21+
22+
class OptimizelyOdpOption:
23+
"""Options for the OdpSegmentManager."""
24+
IGNORE_CACHE: Final = 'IGNORE_CACHE'
25+
RESET_CACHE: Final = 'RESET_CACHE'

tests/test_odp_segment_manager.py

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
# Copyright 2022, 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+
16+
from unittest import mock
17+
from unittest.mock import call
18+
19+
from requests import exceptions as request_exception
20+
21+
from optimizely.odp.lru_cache import LRUCache
22+
from optimizely.odp.odp_config import OdpConfig
23+
from optimizely.odp.optimizely_odp_option import OptimizelyOdpOption
24+
from optimizely.odp.odp_segment_manager import OdpSegmentManager
25+
from optimizely.odp.zaius_graphql_api_manager import ZaiusGraphQLApiManager
26+
from tests import base
27+
28+
29+
class OdpSegmentManagerTest(base.BaseTest):
30+
api_host = 'host'
31+
api_key = 'valid'
32+
user_key = 'fs_user_id'
33+
user_value = 'test-user-value'
34+
35+
def test_empty_list_with_no_segments_to_check(self):
36+
odp_config = OdpConfig(self.api_key, self.api_host, [])
37+
mock_logger = mock.MagicMock()
38+
segments_cache = LRUCache(1000, 1000)
39+
api = ZaiusGraphQLApiManager()
40+
segment_manager = OdpSegmentManager(odp_config, segments_cache, api, mock_logger)
41+
42+
with mock.patch.object(api, 'fetch_segments') as mock_fetch_segments:
43+
segments = segment_manager.fetch_qualified_segments(self.user_key, self.user_value, [])
44+
45+
self.assertEqual(segments, [])
46+
mock_logger.debug.assert_called_once_with('No segments are used in the project. Returning empty list.')
47+
mock_logger.error.assert_not_called()
48+
mock_fetch_segments.assert_not_called()
49+
50+
def test_fetch_segments_success_cache_miss(self):
51+
"""
52+
we are fetching user key/value 'fs_user_id'/'test-user-value'
53+
which is different from what we have passed to cache (fs_user_id-$-123/['d'])
54+
---> hence we trigger a cache miss
55+
"""
56+
odp_config = OdpConfig(self.api_key, self.api_host, ["a", "b", "c"])
57+
mock_logger = mock.MagicMock()
58+
segments_cache = LRUCache(1000, 1000)
59+
api = ZaiusGraphQLApiManager()
60+
61+
segment_manager = OdpSegmentManager(odp_config, segments_cache, api, mock_logger)
62+
cache_key = segment_manager.make_cache_key(self.user_key, '123')
63+
segment_manager.segments_cache.save(cache_key, ["d"])
64+
65+
with mock.patch('requests.post') as mock_request_post:
66+
mock_request_post.return_value = self.fake_server_response(status_code=200,
67+
content=self.good_response_data)
68+
69+
segments = segment_manager.fetch_qualified_segments(self.user_key, self.user_value, [])
70+
71+
self.assertEqual(segments, ["a", "b"])
72+
actual_cache_key = segment_manager.make_cache_key(self.user_key, self.user_value)
73+
self.assertEqual(segment_manager.segments_cache.lookup(actual_cache_key), ["a", "b"])
74+
75+
self.assertEqual(mock_logger.debug.call_count, 2)
76+
mock_logger.debug.assert_has_calls([call('ODP cache miss.'), call('Making a call to ODP server.')])
77+
mock_logger.error.assert_not_called()
78+
79+
def test_fetch_segments_success_cache_hit(self):
80+
odp_config = OdpConfig()
81+
odp_config.update(self.api_key, self.api_host, ['c'])
82+
mock_logger = mock.MagicMock()
83+
api = ZaiusGraphQLApiManager()
84+
segments_cache = LRUCache(1000, 1000)
85+
86+
segment_manager = OdpSegmentManager(odp_config, segments_cache, None, mock_logger)
87+
cache_key = segment_manager.make_cache_key(self.user_key, self.user_value)
88+
segment_manager.segments_cache.save(cache_key, ['c'])
89+
90+
with mock.patch.object(api, 'fetch_segments') as mock_fetch_segments:
91+
segments = segment_manager.fetch_qualified_segments(self.user_key, self.user_value, [])
92+
93+
self.assertEqual(segments, ['c'])
94+
mock_logger.debug.assert_called_once_with('ODP cache hit. Returning segments from cache.')
95+
mock_logger.error.assert_not_called()
96+
mock_fetch_segments.assert_not_called()
97+
98+
def test_fetch_segments_missing_api_host_api_key(self):
99+
with mock.patch('optimizely.logger') as mock_logger:
100+
segment_manager = OdpSegmentManager(OdpConfig(), LRUCache(1000, 1000), None, mock_logger)
101+
segments = segment_manager.fetch_qualified_segments(self.user_key, self.user_value, [])
102+
103+
self.assertEqual(segments, None)
104+
mock_logger.error.assert_called_once_with('Audience segments fetch failed (api_key/api_host not defined).')
105+
106+
def test_fetch_segments_network_error(self):
107+
"""
108+
Trigger connection error with mock side_effect. Note that Python's requests don't
109+
have a status code for connection error, that's why we need to trigger the exception
110+
instead of returning a fake server response with status code 500.
111+
The error log should come form the GraphQL API manager, not from ODP Segment Manager.
112+
The active mock logger should be placed as parameter in ZaiusGraphQLApiManager object.
113+
"""
114+
odp_config = OdpConfig(self.api_key, self.api_host, ["a", "b", "c"])
115+
mock_logger = mock.MagicMock()
116+
segments_cache = LRUCache(1000, 1000)
117+
api = ZaiusGraphQLApiManager(mock_logger)
118+
segment_manager = OdpSegmentManager(odp_config, segments_cache, api, None)
119+
120+
with mock.patch('requests.post',
121+
side_effect=request_exception.ConnectionError('Connection error')):
122+
segments = segment_manager.fetch_qualified_segments(self.user_key, self.user_value, [])
123+
124+
self.assertEqual(segments, None)
125+
mock_logger.error.assert_called_once_with('Audience segments fetch failed (network error).')
126+
127+
def test_options_ignore_cache(self):
128+
odp_config = OdpConfig(self.api_key, self.api_host, ["a", "b", "c"])
129+
mock_logger = mock.MagicMock()
130+
segments_cache = LRUCache(1000, 1000)
131+
api = ZaiusGraphQLApiManager()
132+
133+
segment_manager = OdpSegmentManager(odp_config, segments_cache, api, mock_logger)
134+
cache_key = segment_manager.make_cache_key(self.user_key, self.user_value)
135+
segment_manager.segments_cache.save(cache_key, ['d'])
136+
137+
with mock.patch('requests.post') as mock_request_post:
138+
mock_request_post.return_value = self.fake_server_response(status_code=200,
139+
content=self.good_response_data)
140+
141+
segments = segment_manager.fetch_qualified_segments(self.user_key, self.user_value,
142+
[OptimizelyOdpOption.IGNORE_CACHE])
143+
144+
self.assertEqual(segments, ["a", "b"])
145+
self.assertEqual(segment_manager.segments_cache.lookup(cache_key), ['d'])
146+
mock_logger.debug.assert_called_once_with('Making a call to ODP server.')
147+
mock_logger.error.assert_not_called()
148+
149+
def test_options_reset_cache(self):
150+
odp_config = OdpConfig(self.api_key, self.api_host, ["a", "b", "c"])
151+
mock_logger = mock.MagicMock()
152+
segments_cache = LRUCache(1000, 1000)
153+
api = ZaiusGraphQLApiManager()
154+
155+
segment_manager = OdpSegmentManager(odp_config, segments_cache, api, mock_logger)
156+
cache_key = segment_manager.make_cache_key(self.user_key, self.user_value)
157+
segment_manager.segments_cache.save(cache_key, ['d'])
158+
segment_manager.segments_cache.save('123', ['c', 'd'])
159+
160+
with mock.patch('requests.post') as mock_request_post:
161+
mock_request_post.return_value = self.fake_server_response(status_code=200,
162+
content=self.good_response_data)
163+
164+
segments = segment_manager.fetch_qualified_segments(self.user_key, self.user_value,
165+
[OptimizelyOdpOption.RESET_CACHE])
166+
167+
self.assertEqual(segments, ["a", "b"])
168+
self.assertEqual(segment_manager.segments_cache.lookup(cache_key), ['a', 'b'])
169+
self.assertTrue(len(segment_manager.segments_cache.map) == 1)
170+
mock_logger.debug.assert_called_once_with('Making a call to ODP server.')
171+
mock_logger.error.assert_not_called()
172+
173+
def test_make_correct_cache_key(self):
174+
segment_manager = OdpSegmentManager(None, None, None, None)
175+
cache_key = segment_manager.make_cache_key(self.user_key, self.user_value)
176+
self.assertEqual(cache_key, 'fs_user_id-$-test-user-value')
177+
178+
# test json response
179+
good_response_data = """
180+
{
181+
"data": {
182+
"customer": {
183+
"audiences": {
184+
"edges": [
185+
{
186+
"node": {
187+
"name": "a",
188+
"state": "qualified",
189+
"description": "qualifed sample 1"
190+
}
191+
},
192+
{
193+
"node": {
194+
"name": "b",
195+
"state": "qualified",
196+
"description": "qualifed sample 2"
197+
}
198+
},
199+
{
200+
"node": {
201+
"name": "c",
202+
"state": "not_qualified",
203+
"description": "not-qualified sample"
204+
}
205+
}
206+
]
207+
}
208+
}
209+
}
210+
}
211+
"""

0 commit comments

Comments
 (0)