Skip to content

Commit f7750bb

Browse files
authored
feat: Lazy identity-flag evaluation in local-eval mode (#200)
1 parent ae45cbd commit f7750bb

6 files changed

Lines changed: 442 additions & 31 deletions

File tree

flagsmith/flagsmith.py

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,13 @@
2121
map_segment_results_to_identity_segments,
2222
resolve_trait_values,
2323
)
24-
from flagsmith.models import DefaultFlag, Flags, Segment
24+
from flagsmith.models import (
25+
DefaultFlag,
26+
Flags,
27+
Segment,
28+
SegmentOverridesIndex,
29+
build_segment_overrides_index,
30+
)
2531
from flagsmith.offline_handlers import OfflineHandler
2632
from flagsmith.polling_manager import EnvironmentDataPollingManager
2733
from flagsmith.streaming_manager import EventStreamManager
@@ -117,7 +123,8 @@ def __init__(
117123
self._pipeline_analytics_processor: typing.Optional[
118124
PipelineAnalyticsProcessor
119125
] = None
120-
self._evaluation_context: typing.Optional[SDKEvaluationContext] = None
126+
self.__evaluation_context: typing.Optional[SDKEvaluationContext] = None
127+
self._segment_overrides_index: SegmentOverridesIndex = {}
121128
self._environment_updated_at: typing.Optional[datetime] = None
122129

123130
# argument validation
@@ -356,6 +363,26 @@ def update_environment(self) -> None:
356363
except (KeyError, TypeError, ValueError):
357364
logger.exception("Error parsing environment document")
358365

366+
@property
367+
def _evaluation_context(self) -> typing.Optional[SDKEvaluationContext]:
368+
return self.__evaluation_context
369+
370+
@_evaluation_context.setter
371+
def _evaluation_context(
372+
self, context: typing.Optional[SDKEvaluationContext]
373+
) -> None:
374+
"""Swap in a new evaluation context and rebuild the overrides index.
375+
376+
The index maps feature_name -> segments that override it. Built once
377+
per refresh and reused across every subsequent per-identity lazy
378+
resolution; rebuilding here keeps it in sync with the current doc
379+
without any hot-path cost.
380+
"""
381+
self.__evaluation_context = context
382+
self._segment_overrides_index = (
383+
build_segment_overrides_index(context) if context is not None else {}
384+
)
385+
359386
def _get_headers(
360387
self,
361388
environment_key: str,
@@ -407,12 +434,12 @@ def _get_identity_flags_from_document(
407434
identifier=identifier,
408435
traits=traits,
409436
)
410-
evaluation_result = engine.get_evaluation_result(
437+
# Lazy: defer per-feature evaluation until the caller actually reads
438+
# a flag. Hot for callers that only read one or a few flags out of a
439+
# large environment.
440+
return Flags.from_evaluation_context(
411441
context=context,
412-
)
413-
414-
return Flags.from_evaluation_result(
415-
evaluation_result=evaluation_result,
442+
overrides_index=self._segment_overrides_index,
416443
analytics_processor=self._analytics_processor,
417444
default_flag_handler=self.default_flag_handler,
418445
pipeline_analytics_processor=self._pipeline_analytics_processor,

flagsmith/models.py

Lines changed: 126 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,37 @@
33
import typing
44
from dataclasses import dataclass, field
55

6+
from flag_engine import engine
7+
from flag_engine.context.types import SegmentContext
8+
69
from flagsmith.analytics import AnalyticsProcessor, PipelineAnalyticsProcessor
710
from flagsmith.exceptions import FlagsmithFeatureDoesNotExistError
8-
from flagsmith.types import SDKEvaluationResult, SDKFlagResult
11+
from flagsmith.types import (
12+
FeatureMetadata,
13+
SDKEvaluationContext,
14+
SDKEvaluationResult,
15+
SDKFlagResult,
16+
SegmentMetadata,
17+
)
18+
19+
SegmentOverridesIndex = typing.Dict[
20+
str, typing.List[SegmentContext[SegmentMetadata, FeatureMetadata]]
21+
]
22+
23+
24+
def build_segment_overrides_index(
25+
context: SDKEvaluationContext,
26+
) -> SegmentOverridesIndex:
27+
"""Map feature_name -> segments that carry an override for that feature.
28+
29+
Computed once per environment-document refresh so the lazy eval path
30+
can walk only the segments actually relevant to a given flag.
31+
"""
32+
index: SegmentOverridesIndex = {}
33+
for segment_context in (context.get("segments") or {}).values():
34+
for override in segment_context.get("overrides") or ():
35+
index.setdefault(override["name"], []).append(segment_context)
36+
return index
937

1038

1139
@dataclass
@@ -60,6 +88,14 @@ class Flags:
6088
_pipeline_analytics_processor: typing.Optional[PipelineAnalyticsProcessor] = None
6189
_identity_identifier: typing.Optional[str] = None
6290
_traits: typing.Optional[typing.Dict[str, typing.Any]] = None
91+
# Lazy-evaluation state. When `_context` is set, `flags` is a
92+
# per-feature memo rather than a fully-materialised snapshot; unseen
93+
# features are resolved on demand via the engine primitives and
94+
# cached back into `flags`. Left as `None` by the eager code
95+
# paths (`from_evaluation_result` / `from_api_flags`).
96+
_context: typing.Optional[SDKEvaluationContext] = None
97+
_overrides_index: typing.Optional[SegmentOverridesIndex] = None
98+
_fully_materialised: bool = False
6399

64100
@classmethod
65101
def from_evaluation_result(
@@ -86,6 +122,37 @@ def from_evaluation_result(
86122
_traits=traits,
87123
)
88124

125+
@classmethod
126+
def from_evaluation_context(
127+
cls,
128+
context: SDKEvaluationContext,
129+
overrides_index: SegmentOverridesIndex,
130+
analytics_processor: typing.Optional[AnalyticsProcessor],
131+
default_flag_handler: typing.Optional[typing.Callable[[str], DefaultFlag]],
132+
pipeline_analytics_processor: typing.Optional[
133+
PipelineAnalyticsProcessor
134+
] = None,
135+
identity_identifier: typing.Optional[str] = None,
136+
traits: typing.Optional[typing.Dict[str, typing.Any]] = None,
137+
) -> Flags:
138+
"""Build a lazy `Flags` backed by an evaluation context.
139+
140+
No engine work is done here — flags are resolved on first access
141+
via :meth:`_resolve_flag`. Reusing the same `overrides_index`
142+
across calls amortises its construction cost (it's rebuilt only
143+
when the environment doc refreshes, not per identity).
144+
"""
145+
return cls(
146+
flags={},
147+
default_flag_handler=default_flag_handler,
148+
_analytics_processor=analytics_processor,
149+
_pipeline_analytics_processor=pipeline_analytics_processor,
150+
_identity_identifier=identity_identifier,
151+
_traits=traits,
152+
_context=context,
153+
_overrides_index=overrides_index,
154+
)
155+
89156
@classmethod
90157
def from_api_flags(
91158
cls,
@@ -116,8 +183,21 @@ def all_flags(self) -> typing.List[Flag]:
116183
"""
117184
Get a list of all Flag objects.
118185
186+
In lazy mode, the caller has signalled they want every flag, so
187+
we run the bulk evaluator once on the full context and copy the
188+
results into the per-flag cache. Cheaper than asking the engine
189+
for each feature one at a time.
190+
119191
:return: list of Flag objects.
120192
"""
193+
if self._context is not None and not self._fully_materialised:
194+
result = engine.get_evaluation_result(self._context)
195+
for feature_name, flag_result in result["flags"].items():
196+
if feature_name not in self.flags:
197+
self.flags[feature_name] = Flag.from_evaluation_result(
198+
flag_result,
199+
)
200+
self._fully_materialised = True
121201
return list(self.flags.values())
122202

123203
def is_feature_enabled(self, feature_name: str) -> bool:
@@ -151,11 +231,23 @@ def get_flag(self, feature_name: str) -> typing.Union[DefaultFlag, Flag]:
151231
try:
152232
flag = self.flags[feature_name]
153233
except KeyError:
154-
if self.default_flag_handler:
234+
# Lazy path: if this `Flags` wraps an evaluation context and
235+
# the feature exists in it, resolve and memoise now. Otherwise
236+
# fall through to the default_flag_handler / not-found error,
237+
# preserving the eager-mode behaviour byte-for-byte.
238+
if (
239+
self._context is not None
240+
and self._overrides_index is not None
241+
and feature_name in (self._context.get("features") or {})
242+
):
243+
flag = self._resolve_flag(feature_name)
244+
self.flags[feature_name] = flag
245+
elif self.default_flag_handler:
155246
return self.default_flag_handler(feature_name)
156-
raise FlagsmithFeatureDoesNotExistError(
157-
"Feature does not exist: %s" % feature_name
158-
)
247+
else:
248+
raise FlagsmithFeatureDoesNotExistError(
249+
"Feature does not exist: %s" % feature_name
250+
)
159251

160252
if self._analytics_processor and hasattr(flag, "feature_name"):
161253
self._analytics_processor.track_feature(flag.feature_name)
@@ -171,6 +263,35 @@ def get_flag(self, feature_name: str) -> typing.Union[DefaultFlag, Flag]:
171263

172264
return flag
173265

266+
def _resolve_flag(self, feature_name: str) -> Flag:
267+
"""Evaluate a single feature against the lazy context.
268+
269+
Goes through the engine's public `get_evaluation_result` so
270+
identity-key enrichment, multivariate hashing, percentage-split
271+
rules and override-priority handling all stay where they
272+
belong (in the engine). The performance win comes from passing
273+
a *trimmed* context — just the queried feature plus the segments
274+
that could override it, looked up in O(1) via the precomputed
275+
reverse index — so the engine's full pipeline runs against an
276+
input small enough to evaluate in ~1 µs.
277+
"""
278+
context = self._context
279+
overrides_index = self._overrides_index
280+
# `get_flag` / `all_flags` gate this call behind the same
281+
# non-None checks; assert here so type checkers can narrow.
282+
assert context is not None and overrides_index is not None
283+
284+
trimmed: SDKEvaluationContext = {
285+
**context,
286+
"features": {feature_name: context["features"][feature_name]},
287+
"segments": {
288+
segment_context["key"]: segment_context
289+
for segment_context in overrides_index.get(feature_name, ())
290+
},
291+
}
292+
result = engine.get_evaluation_result(trimmed)
293+
return Flag.from_evaluation_result(result["flags"][feature_name])
294+
174295

175296
@dataclass
176297
class Segment:

poetry.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ documentation = "https://docs.flagsmith.com"
1010
packages = [{ include = "flagsmith" }]
1111

1212
[tool.poetry.dependencies]
13-
flagsmith-flag-engine = "^10.0.3"
13+
flagsmith-flag-engine = "^10.0.4"
1414
iso8601 = { version = "^2.1.0", python = "<3.11" }
1515
python = ">=3.9,<4"
1616
requests = "^2.32.3"

tests/test_flagsmith.py

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import pytest
66
import requests
77
import responses
8+
from flag_engine import engine
89
from pytest_mock import MockerFixture
910
from responses import matchers
1011

@@ -15,7 +16,7 @@
1516
FlagsmithAPIError,
1617
FlagsmithFeatureDoesNotExistError,
1718
)
18-
from flagsmith.models import DefaultFlag, Flags
19+
from flagsmith.models import DefaultFlag, Flag, Flags
1920
from flagsmith.offline_handlers import OfflineHandler
2021
from flagsmith.types import SDKEvaluationContext
2122

@@ -193,7 +194,12 @@ def test_get_identity_flags_uses_local_environment_when_available(
193194
# Given
194195
flagsmith._evaluation_context = evaluation_context
195196
flagsmith.enable_local_evaluation = True
196-
mock_engine = mocker.patch("flagsmith.flagsmith.engine")
197+
# `Flags` materialises identity flags via `engine.get_evaluation_result`
198+
# imported from `flagsmith.models`, so patch it where it's actually used.
199+
mock_get_evaluation_result = mocker.patch(
200+
"flagsmith.models.engine.get_evaluation_result",
201+
autospec=True,
202+
)
197203

198204
expected_evaluation_result = {
199205
"flags": {
@@ -210,15 +216,15 @@ def test_get_identity_flags_uses_local_environment_when_available(
210216
identifier = "identifier"
211217
traits = {"some_trait": "some_value"}
212218

213-
mock_engine.get_evaluation_result.return_value = expected_evaluation_result
219+
mock_get_evaluation_result.return_value = expected_evaluation_result
214220

215221
# When
216222
identity_flags = flagsmith.get_identity_flags(identifier, traits).all_flags()
217223

218224
# Then
219-
mock_engine.get_evaluation_result.assert_called_once()
220-
call_args = mock_engine.get_evaluation_result.call_args
221-
context = call_args[1]["context"]
225+
mock_get_evaluation_result.assert_called_once()
226+
call_args = mock_get_evaluation_result.call_args
227+
context = call_args[0][0] if call_args.args else call_args[1]["context"]
222228
assert context["identity"]["identifier"] == identifier
223229
assert context["identity"]["traits"]["some_trait"] == "some_value"
224230
assert "some_trait" in context["identity"]["traits"]
@@ -233,7 +239,7 @@ def test_get_identity_flags_includes_segments_in_evaluation_context(
233239
) -> None:
234240
# Given
235241
mock_get_evaluation_result = mocker.patch(
236-
"flagsmith.flagsmith.engine.get_evaluation_result",
242+
"flagsmith.models.engine.get_evaluation_result",
237243
autospec=True,
238244
)
239245

@@ -254,16 +260,43 @@ def test_get_identity_flags_includes_segments_in_evaluation_context(
254260

255261
mock_get_evaluation_result.return_value = expected_evaluation_result
256262

257-
# When
258-
local_eval_flagsmith.get_identity_flags(identifier, traits)
263+
# When: `all_flags` triggers the bulk evaluation path on the lazy
264+
# `Flags` object, which is where the full identity context — segments
265+
# included — is passed to the engine.
266+
local_eval_flagsmith.get_identity_flags(identifier, traits).all_flags()
259267

260-
# Then
261-
# Verify segments are present in the context passed to the engine for identity flags
268+
# Then: segments are present in the context passed to the engine for
269+
# identity flags (in contrast to the env-flags path, which strips them).
262270
call_args = mock_get_evaluation_result.call_args
263-
context = call_args[1]["context"]
271+
context = call_args[0][0] if call_args.args else call_args[1]["context"]
264272
assert "segments" in context
265273

266274

275+
def test_get_identity_flags__resolves_one_flag_at_a_time(
276+
local_eval_flagsmith: Flagsmith,
277+
mocker: MockerFixture,
278+
) -> None:
279+
spy = mocker.spy(engine, "get_evaluation_result")
280+
281+
# When: we ask for identity flags but never touch a specific flag...
282+
flags = local_eval_flagsmith.get_identity_flags("someone")
283+
284+
# Then: nothing has been evaluated yet — no engine call, empty cache.
285+
assert spy.call_count == 0
286+
assert flags.flags == {}
287+
288+
# And: touching one flag triggers exactly one engine call against a
289+
# *trimmed* context (the queried feature only), not the full env.
290+
flag = flags.get_flag("some_feature")
291+
assert isinstance(flag, Flag)
292+
assert flag.feature_name == "some_feature"
293+
assert set(flags.flags.keys()) == {"some_feature"}
294+
295+
assert spy.call_count == 1
296+
trimmed_context = spy.call_args.kwargs.get("context") or spy.call_args.args[0]
297+
assert set(trimmed_context["features"]) == {"some_feature"}
298+
299+
267300
@responses.activate()
268301
def test_get_identity_flags__transient_identity__calls_expected(
269302
flagsmith: Flagsmith,

0 commit comments

Comments
 (0)