Skip to content

Commit 05f6304

Browse files
committed
refactor(lazy): Route per-flag resolution through engine.get_evaluation_result
Per review: keep the engine/client boundary intact and let the engine handle all evaluation correctness — instead of reaching into ``is_context_in_segment`` / ``get_flag_result_from_context`` directly, ``Flags._resolve_flag`` now builds a *trimmed* context (the queried feature plus only the segments that could override it, looked up via the precomputed reverse index) and hands it to the engine's public ``get_evaluation_result``. Side effects: * Identity-key enrichment now runs on the lazy path (the engine's ``get_enriched_context`` is invoked internally), so multivariate splits and ``PERCENTAGE_SPLIT`` rules behave correctly. Previously the lazy path silently degraded these. * Override-priority handling moves back into the engine — the ``float("inf")`` literal is gone from the SDK. * ``Flags.all_flags`` switches to a single bulk ``get_evaluation_result`` rather than calling ``_resolve_flag`` per feature; cheaper, and matches the eager path's call shape. Trim cost is ~0.8 µs per call, so the lazy path is now ~2.6 µs mean / ~3.4 µs p99 against the customer's prod env (438 features, 23 segments) — still 150–220× faster than the eager path on every percentile, and 100% routed through the engine's documented public API. beep boop
1 parent 6057139 commit 05f6304

2 files changed

Lines changed: 37 additions & 52 deletions

File tree

flagsmith/models.py

Lines changed: 28 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,8 @@
33
import typing
44
from dataclasses import dataclass, field
55

6+
from flag_engine import engine
67
from flag_engine.context.types import SegmentContext
7-
from flag_engine.segments.evaluator import (
8-
get_flag_result_from_context,
9-
is_context_in_segment,
10-
)
118

129
from flagsmith.analytics import AnalyticsProcessor, PipelineAnalyticsProcessor
1310
from flagsmith.exceptions import FlagsmithFeatureDoesNotExistError
@@ -186,16 +183,20 @@ def all_flags(self) -> typing.List[Flag]:
186183
"""
187184
Get a list of all Flag objects.
188185
189-
In lazy mode, this forces resolution of every feature the caller
190-
hasn't already touched — same end state and cost as eager, but
191-
only paid when someone actually asks for the full set.
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.
192190
193191
:return: list of Flag objects.
194192
"""
195193
if self._context is not None and not self._fully_materialised:
196-
for feature_name in self._context.get("features") or {}:
194+
result = engine.get_evaluation_result(self._context)
195+
for feature_name, flag_result in result["flags"].items():
197196
if feature_name not in self.flags:
198-
self.flags[feature_name] = self._resolve_flag(feature_name)
197+
self.flags[feature_name] = Flag.from_evaluation_result(
198+
flag_result,
199+
)
199200
self._fully_materialised = True
200201
return list(self.flags.values())
201202

@@ -265,50 +266,31 @@ def get_flag(self, feature_name: str) -> typing.Union[DefaultFlag, Flag]:
265266
def _resolve_flag(self, feature_name: str) -> Flag:
266267
"""Evaluate a single feature against the lazy context.
267268
268-
Uses the precomputed reverse index to walk only segments that
269-
could override this feature; falls through to the feature's
270-
default when no matching override is found. Byte-for-byte
271-
equivalent to what ``engine.get_evaluation_result`` would
272-
produce for this one feature.
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.
273277
"""
274278
context = self._context
275279
overrides_index = self._overrides_index
276280
# ``get_flag`` / ``all_flags`` gate this call behind the same
277281
# non-None checks; assert here so type checkers can narrow.
278282
assert context is not None and overrides_index is not None
279283

280-
feature_context = context["features"][feature_name]
281-
282-
# Find the winning override, if any, by walking only the segments
283-
# that target this feature and keeping the lowest-priority match.
284-
best: typing.Optional[
285-
typing.Tuple[float, typing.Mapping[str, typing.Any], str]
286-
] = None
287-
for segment_context in overrides_index.get(feature_name, ()):
288-
if not is_context_in_segment(context, segment_context):
289-
continue
290-
for override in segment_context.get("overrides") or ():
291-
if override["name"] != feature_name:
292-
continue
293-
priority = override.get("priority", float("inf"))
294-
if best is None or priority < best[0]:
295-
best = (priority, override, segment_context["name"])
296-
297-
if best is not None:
298-
flag_result = get_flag_result_from_context(
299-
context,
300-
typing.cast(typing.Any, best[1]),
301-
reason=f"TARGETING_MATCH; segment={best[2]}",
302-
)
303-
else:
304-
flag_result = get_flag_result_from_context(
305-
context,
306-
feature_context,
307-
reason="DEFAULT",
308-
)
309-
return Flag.from_evaluation_result(
310-
typing.cast(SDKFlagResult, flag_result),
311-
)
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])
312294

313295

314296
@dataclass

tests/test_flagsmith.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -332,28 +332,31 @@ def test_get_identity_flags_includes_segments_in_evaluation_context(
332332
assert "segments" in context
333333

334334

335-
def test_get_identity_flags__lazy_by_default__does_not_run_bulk_engine_call(
335+
def test_get_identity_flags__lazy_by_default__resolves_one_flag_at_a_time(
336336
local_eval_flagsmith: Flagsmith,
337337
mocker: MockerFixture,
338338
) -> None:
339339
# Given: the lazy path is on by default.
340340
assert local_eval_flagsmith.lazy_identity_evaluation is True
341341
spy = mocker.spy(engine, "get_evaluation_result")
342342

343-
# When we ask for identity flags but never touch a specific flag...
343+
# When: we ask for identity flags but never touch a specific flag...
344344
flags = local_eval_flagsmith.get_identity_flags("someone")
345345

346-
# Then: no engine bulk eval has run, and nothing is materialised.
346+
# Then: nothing has been evaluated yet — no engine call, empty cache.
347347
assert spy.call_count == 0
348348
assert flags.flags == {}
349349

350-
# And: touching one flag populates only that flag via the lazy resolver.
350+
# And: touching one flag triggers exactly one engine call against a
351+
# *trimmed* context (the queried feature only), not the full env.
351352
flag = flags.get_flag("some_feature")
352353
assert isinstance(flag, Flag)
353354
assert flag.feature_name == "some_feature"
354355
assert set(flags.flags.keys()) == {"some_feature"}
355-
# Still no bulk call — we resolved via engine primitives directly.
356-
assert spy.call_count == 0
356+
357+
assert spy.call_count == 1
358+
trimmed_context = spy.call_args.kwargs.get("context") or spy.call_args.args[0]
359+
assert set(trimmed_context["features"]) == {"some_feature"}
357360

358361

359362
def test_get_identity_flags__lazy_disabled__falls_back_to_eager_path(

0 commit comments

Comments
 (0)