Skip to content

Commit f778989

Browse files
authored
[FSSDK-9780] Return Latest Experiment When Duplicate Keys in Config (#430)
* firt run to add guard againsts duplicate key * cleanup * fix logger * cleanup comments * linting * fix logger
1 parent d2ed4be commit f778989

File tree

4 files changed

+95
-10
lines changed

4 files changed

+95
-10
lines changed

optimizely/config_manager.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ def _set_config(self, datafile: Optional[str | bytes]) -> None:
159159

160160
self._config = config
161161
self._sdk_key = self._sdk_key or config.sdk_key
162-
self.optimizely_config = OptimizelyConfigService(config).get_config()
162+
self.optimizely_config = OptimizelyConfigService(config, self.logger).get_config()
163163
self.notification_center.send_notifications(enums.NotificationTypes.OPTIMIZELY_CONFIG_UPDATE)
164164

165165
internal_notification_center = _NotificationCenterRegistry.get_notification_center(

optimizely/optimizely.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1039,7 +1039,7 @@ def get_optimizely_config(self) -> Optional[OptimizelyConfig]:
10391039
if hasattr(self.config_manager, 'optimizely_config'):
10401040
return self.config_manager.optimizely_config
10411041

1042-
return OptimizelyConfigService(project_config).get_config()
1042+
return OptimizelyConfigService(project_config, self.logger).get_config()
10431043

10441044
def create_user_context(
10451045
self, user_id: str, attributes: Optional[UserAttributes] = None

optimizely/optimizely_config.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
from .helpers.types import VariationDict, ExperimentDict, RolloutDict, AttributeDict, EventDict
2020
from .project_config import ProjectConfig
2121

22+
from .logger import Logger
23+
2224

2325
class OptimizelyConfig:
2426
def __init__(
@@ -126,11 +128,12 @@ def __init__(self, id: Optional[str], name: Optional[str], conditions: Optional[
126128
class OptimizelyConfigService:
127129
""" Class encapsulating methods to be used in creating instance of OptimizelyConfig. """
128130

129-
def __init__(self, project_config: ProjectConfig):
131+
def __init__(self, project_config: ProjectConfig, logger: Logger):
130132
"""
131133
Args:
132134
project_config ProjectConfig
133135
"""
136+
self.logger = logger
134137
self.is_valid = True
135138

136139
if not isinstance(project_config, ProjectConfig):
@@ -411,7 +414,12 @@ def _get_experiments_maps(self) -> tuple[dict[str, OptimizelyExperiment], dict[s
411414
audiences_map[audience_id] = audience_name if audience_name is not None else ''
412415

413416
all_experiments = self._get_all_experiments()
417+
414418
for exp in all_experiments:
419+
# check if experiment key already exists
420+
if exp["key"] in experiments_key_map:
421+
self.logger.warning(f"Duplicate experiment keys found in datafile: {exp['key']}")
422+
415423
optly_exp = OptimizelyExperiment(
416424
exp['id'], exp['key'], self._get_variations_map(exp)
417425
)

tests/test_optimizely_config.py

+84-7
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,18 @@
44
# You may obtain a copy of the License at
55
#
66
# http://www.apache.org/licenses/LICENSE-2.0
7-
87
# Unless required by applicable law or agreed to in writing, software
98
# distributed under the License is distributed on an "AS IS" BASIS,
109
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1110
# See the License for the specific language governing permissions and
1211
# limitations under the License.
1312

1413
import json
14+
from unittest.mock import patch
1515

1616
from optimizely import optimizely, project_config
1717
from optimizely import optimizely_config
18+
from optimizely import logger
1819
from . import base
1920

2021

@@ -23,7 +24,8 @@ def setUp(self):
2324
base.BaseTest.setUp(self)
2425
opt_instance = optimizely.Optimizely(json.dumps(self.config_dict_with_features))
2526
self.project_config = opt_instance.config_manager.get_config()
26-
self.opt_config_service = optimizely_config.OptimizelyConfigService(self.project_config)
27+
self.opt_config_service = optimizely_config.OptimizelyConfigService(self.project_config,
28+
logger=logger.SimpleLogger())
2729

2830
self.expected_config = {
2931
'sdk_key': 'features-test',
@@ -1452,7 +1454,7 @@ def test__get_config(self):
14521454
def test__get_config__invalid_project_config(self):
14531455
""" Test that get_config returns None when invalid project config supplied. """
14541456

1455-
opt_service = optimizely_config.OptimizelyConfigService({"key": "invalid"})
1457+
opt_service = optimizely_config.OptimizelyConfigService({"key": "invalid"}, None)
14561458
self.assertIsNone(opt_service.get_config())
14571459

14581460
def test__get_experiments_maps(self):
@@ -1473,6 +1475,81 @@ def test__get_experiments_maps(self):
14731475

14741476
self.assertEqual(expected_id_map, self.to_dict(actual_id_map))
14751477

1478+
def test__duplicate_experiment_keys(self):
1479+
""" Test that multiple features don't have the same experiment key. """
1480+
1481+
# update the test datafile with an additional feature flag with the same experiment rule key
1482+
new_experiment = {
1483+
'key': 'test_experiment', # added duplicate "test_experiment"
1484+
'status': 'Running',
1485+
'layerId': '8',
1486+
"audienceConditions": [
1487+
"or",
1488+
"11160"
1489+
],
1490+
'audienceIds': ['11160'],
1491+
'id': '111137',
1492+
'forcedVariations': {},
1493+
'trafficAllocation': [
1494+
{'entityId': '222242', 'endOfRange': 8000},
1495+
{'entityId': '', 'endOfRange': 10000}
1496+
],
1497+
'variations': [
1498+
{
1499+
'id': '222242',
1500+
'key': 'control',
1501+
'variables': [],
1502+
}
1503+
],
1504+
}
1505+
1506+
new_feature = {
1507+
'id': '91117',
1508+
'key': 'new_feature',
1509+
'experimentIds': ['111137'],
1510+
'rolloutId': '',
1511+
'variables': [
1512+
{'id': '127', 'key': 'is_working', 'defaultValue': 'true', 'type': 'boolean'},
1513+
{'id': '128', 'key': 'environment', 'defaultValue': 'devel', 'type': 'string'},
1514+
{'id': '129', 'key': 'cost', 'defaultValue': '10.99', 'type': 'double'},
1515+
{'id': '130', 'key': 'count', 'defaultValue': '999', 'type': 'integer'},
1516+
{'id': '131', 'key': 'variable_without_usage', 'defaultValue': '45', 'type': 'integer'},
1517+
{'id': '132', 'key': 'object', 'defaultValue': '{"test": 12}', 'type': 'string',
1518+
'subType': 'json'},
1519+
{'id': '133', 'key': 'true_object', 'defaultValue': '{"true_test": 23.54}', 'type': 'json'},
1520+
],
1521+
}
1522+
1523+
# add new experiment rule with the same key and a new feature with the same rule key
1524+
self.config_dict_with_features['experiments'].append(new_experiment)
1525+
self.config_dict_with_features['featureFlags'].append(new_feature)
1526+
1527+
config_with_duplicate_key = self.config_dict_with_features
1528+
opt_instance = optimizely.Optimizely(json.dumps(config_with_duplicate_key))
1529+
self.project_config = opt_instance.config_manager.get_config()
1530+
1531+
with patch('optimizely.logger.SimpleLogger.warning') as mock_logger:
1532+
self.opt_config_service = optimizely_config.OptimizelyConfigService(self.project_config,
1533+
logger=logger.SimpleLogger())
1534+
1535+
actual_key_map, actual_id_map = self.opt_config_service._get_experiments_maps()
1536+
1537+
self.assertIsInstance(actual_key_map, dict)
1538+
for exp in actual_key_map.values():
1539+
self.assertIsInstance(exp, optimizely_config.OptimizelyExperiment)
1540+
1541+
# Assert that the warning method of the mock logger was called with the expected message
1542+
expected_warning_message = f"Duplicate experiment keys found in datafile: {new_experiment['key']}"
1543+
mock_logger.assert_called_with(expected_warning_message)
1544+
1545+
# assert we get ID of the duplicated experiment
1546+
assert actual_key_map.get('test_experiment').id == "111137"
1547+
1548+
# assert we get one duplicated experiment
1549+
keys_list = list(actual_key_map.keys())
1550+
assert "test_experiment" in keys_list, "Key 'test_experiment' not found in actual key map"
1551+
assert keys_list.count("test_experiment") == 1, "Key 'test_experiment' found more than once in actual key map"
1552+
14761553
def test__get_features_map(self):
14771554
""" Test that get_features_map returns expected features map. """
14781555

@@ -1674,7 +1751,7 @@ def test_get_audiences(self):
16741751
error_handler=None
16751752
)
16761753

1677-
config_service = optimizely_config.OptimizelyConfigService(proj_conf)
1754+
config_service = optimizely_config.OptimizelyConfigService(proj_conf, logger=logger.SimpleLogger())
16781755

16791756
for audience in config_service.audiences:
16801757
self.assertIsInstance(audience, optimizely_config.OptimizelyAudience)
@@ -1742,7 +1819,7 @@ def test_stringify_audience_conditions_all_cases(self):
17421819
'("us" OR ("female" AND "adult")) AND ("fr" AND ("male" OR "adult"))'
17431820
]
17441821

1745-
config_service = optimizely_config.OptimizelyConfigService(config)
1822+
config_service = optimizely_config.OptimizelyConfigService(config, None)
17461823

17471824
for i in range(len(audiences_input)):
17481825
result = config_service.stringify_conditions(audiences_input[i], audiences_map)
@@ -1760,7 +1837,7 @@ def test_optimizely_audience_conversion(self):
17601837
error_handler=None
17611838
)
17621839

1763-
config_service = optimizely_config.OptimizelyConfigService(proj_conf)
1840+
config_service = optimizely_config.OptimizelyConfigService(proj_conf, None)
17641841

17651842
for audience in config_service.audiences:
17661843
self.assertIsInstance(audience, optimizely_config.OptimizelyAudience)
@@ -1776,7 +1853,7 @@ def test_get_variations_from_experiments_map(self):
17761853
error_handler=None
17771854
)
17781855

1779-
config_service = optimizely_config.OptimizelyConfigService(proj_conf)
1856+
config_service = optimizely_config.OptimizelyConfigService(proj_conf, None)
17801857

17811858
experiments_key_map, experiments_id_map = config_service._get_experiments_maps()
17821859

0 commit comments

Comments
 (0)