Skip to content

Commit 23017e0

Browse files
perf(feature_flags): validate schema once per fetched document (#8431)
* perf(feature_flags): validate schema once per fetched document FeatureFlags.get_configuration built a SchemaValidator and walked the whole document on every call. evaluate and get_enabled_features both call it, so a handler evaluating five flags validated the full document five times per invocation, even when the store served it from cache. Skip validation when the store returns the same dict object that was last validated. The Parameters cache hands back the same object until expiry, so identity is a reliable signal for a cache hit, and a fresh document is always validated. A strong reference to the last validated document is kept so its id() cannot be recycled. AppConfigStore with an envelope produced a new object per call from the JMESPath query, which would defeat the check. Memoise the extraction on the raw document's identity so envelope users benefit as well. Closes #8426 * docs(feature_flags): document configuration immutability --------- Co-authored-by: Leandro <lcdama@amazon.pt>
1 parent de4af6b commit 23017e0

4 files changed

Lines changed: 97 additions & 1 deletion

File tree

‎aws_lambda_powertools/utilities/feature_flags/appconfig.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ def __init__(
8686
boto3_client=boto3_client,
8787
boto3_session=boto3_session,
8888
)
89+
# Memoised envelope extraction: (raw document, extracted config). The Parameters cache hands back the
90+
# same raw dict until expiry, so we can reuse the extracted result rather than re-running the JMESPath
91+
# query and producing a new object on every call.
92+
self._last_extracted: tuple[dict[str, Any], dict[str, Any]] | None = None
8993

9094
# Override the user agent to use "feature_flags" instead of "parameters"
9195
self._register_feature_flags_user_agent()
@@ -140,11 +144,17 @@ def get_configuration(self) -> dict[str, Any]:
140144
config = self.get_raw_configuration
141145

142146
if self.envelope:
147+
if self._last_extracted is not None and self._last_extracted[0] is config:
148+
self.logger.debug("Envelope enabled; reusing previously extracted config for cached document")
149+
return self._last_extracted[1]
150+
143151
self.logger.debug("Envelope enabled; extracting data from config", extra={"envelope": self.envelope})
144-
config = jmespath_utils.query(
152+
extracted = jmespath_utils.query(
145153
data=config,
146154
envelope=self.envelope,
147155
jmespath_options=self.jmespath_options,
148156
)
157+
self._last_extracted = (config, extracted)
158+
return extracted
149159

150160
return config

‎aws_lambda_powertools/utilities/feature_flags/feature_flags.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ def __init__(self, store: StoreProvider, logger: logging.Logger | Logger | None
8181
self.store = store
8282
self.logger = logger or logging.getLogger(__name__)
8383
self._exception_handlers: dict[Exception, Callable] = {}
84+
# Last document that passed schema validation. We keep a strong reference so its id() can't be
85+
# recycled by a different object, which lets us safely skip re-validation on store cache hits.
86+
self._last_validated_config: dict | None = None
8487

8588
def _match_by_action(self, action: str, condition_value: Any, context_value: Any) -> bool:
8689
try:
@@ -210,8 +213,17 @@ def get_configuration(self) -> dict:
210213
# parse result conf as JSON, keep in cache for max age defined in store
211214
self.logger.debug(f"Fetching schema from registered store, store={self.store}")
212215
config: dict = self.store.get_configuration()
216+
217+
# Stores that serve from cache (e.g. AppConfigStore via Parameters) return the same dict object until
218+
# expiry, so identity is a reliable signal that we've already validated this exact document.
219+
# A store that applies an envelope returns a fresh object each time and will still be validated.
220+
if config is self._last_validated_config:
221+
self.logger.debug("Schema already validated, skipping validation")
222+
return config
223+
213224
validator = schema.SchemaValidator(schema=config, logger=self.logger)
214225
validator.validate()
226+
self._last_validated_config = config
215227

216228
return config
217229

‎docs/utilities/feature_flags.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,9 @@ You can override `max_age` parameter when instantiating the store.
369369

370370
You can access the configuration fetched from the store via `get_raw_configuration` property within the store instance.
371371

372+
???+ warning
373+
Treat the returned configuration as read-only. If you need to modify it, create a deep copy first.
374+
372375
=== "getting_stored_features.py"
373376

374377
```python hl_lines="9"
@@ -555,6 +558,10 @@ You can create your own custom FeatureFlags store provider by inheriting the `St
555558
* **`get_raw_configuration()`** – get the raw configuration from the store provider and return the parsed JSON dictionary
556559
* **`get_configuration()`** – get the configuration from the store provider, parsing it as a JSON dictionary. If an envelope is set, extract the envelope data
557560

561+
Feature Flags can reuse a previously validated configuration when a store returns the same dictionary. Custom store
562+
providers must return a new dictionary when the configuration changes instead of modifying a previously returned
563+
dictionary in place.
564+
558565
Here are an example of implementing a custom store provider using Amazon S3, a popular object storage.
559566

560567
???+ note

‎tests/functional/feature_flags/_boto3/test_feature_flags.py‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1702,3 +1702,70 @@ def catch_exception(exc):
17021702
context={"tenant_id": "not a list value"},
17031703
default=False,
17041704
)
1705+
1706+
1707+
# Test schema validation is performed once per fetched document (#8426)
1708+
def test_schema_validated_once_for_cached_document(mocker, config):
1709+
# GIVEN a store that serves the same document object on every call (e.g. a Parameters cache hit)
1710+
mocked_app_config_schema = {"my_feature": {"default": True}}
1711+
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
1712+
validate = mocker.spy(schema.SchemaValidator, "validate")
1713+
1714+
# WHEN evaluating several flags in the same invocation
1715+
for _ in range(5):
1716+
assert feature_flags.evaluate(name="my_feature", context={}, default=False) is True
1717+
feature_flags.get_enabled_features(context={})
1718+
1719+
# THEN the schema is validated only once
1720+
assert validate.call_count == 1
1721+
1722+
1723+
def test_schema_revalidated_when_store_returns_new_document(mocker, config):
1724+
# GIVEN a store that returns a fresh document object on each call (e.g. after cache expiry)
1725+
first = {"my_feature": {"default": True}}
1726+
second = {"my_feature": {"default": False}}
1727+
store = init_fetcher_side_effect(mocker, config, side_effect=[first, second, second])
1728+
feature_flags = FeatureFlags(store=store)
1729+
validate = mocker.spy(schema.SchemaValidator, "validate")
1730+
1731+
# WHEN evaluating across a document change, then again on the same document
1732+
assert feature_flags.evaluate(name="my_feature", context={}, default=False) is True
1733+
assert feature_flags.evaluate(name="my_feature", context={}, default=False) is False
1734+
assert feature_flags.evaluate(name="my_feature", context={}, default=False) is False
1735+
1736+
# THEN each distinct document is validated exactly once
1737+
assert validate.call_count == 2
1738+
1739+
1740+
def test_schema_invalid_document_is_never_cached_as_validated(mocker, config):
1741+
# GIVEN a store that first returns an invalid document, then a valid one
1742+
invalid = {"my_feature": {"default": "not a bool"}}
1743+
valid = {"my_feature": {"default": True}}
1744+
store = init_fetcher_side_effect(mocker, config, side_effect=[invalid, invalid, valid])
1745+
feature_flags = FeatureFlags(store=store)
1746+
1747+
# WHEN the invalid document is served twice
1748+
# THEN validation fails both times rather than being skipped after the first failure
1749+
with pytest.raises(schema.SchemaValidationError):
1750+
feature_flags.evaluate(name="my_feature", context={}, default=False)
1751+
with pytest.raises(schema.SchemaValidationError):
1752+
feature_flags.evaluate(name="my_feature", context={}, default=False)
1753+
1754+
# AND a subsequent valid document is validated and evaluated normally
1755+
assert feature_flags.evaluate(name="my_feature", context={}, default=False) is True
1756+
1757+
1758+
def test_envelope_extraction_reused_for_cached_document(mocker, config):
1759+
# GIVEN a store with an envelope, served from cache
1760+
mocked_app_config_schema = {"app": {"features": {"my_feature": {"default": True}}}}
1761+
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config, envelope="app.features")
1762+
validate = mocker.spy(schema.SchemaValidator, "validate")
1763+
1764+
# WHEN evaluating several times
1765+
first = feature_flags.get_configuration()
1766+
for _ in range(3):
1767+
assert feature_flags.evaluate(name="my_feature", context={}, default=False) is True
1768+
1769+
# THEN the extracted document is the same object each time and validated only once
1770+
assert feature_flags.get_configuration() is first
1771+
assert validate.call_count == 1

0 commit comments

Comments
 (0)