Skip to content

Commit cf7bbae

Browse files
committed
Merge branch 'perf/local-eval-regression' into feat/flags-context-values
# Conflicts: # flagsmith/local_evaluation.py
2 parents 0608980 + 2276774 commit cf7bbae

5 files changed

Lines changed: 112 additions & 184 deletions

File tree

‎flagsmith/flagsmith.py‎

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
from flagsmith.local_evaluation import LocalEvaluation
1919
from flagsmith.mappers import (
2020
map_context_and_identity_data_to_context,
21-
map_environment_document_to_context,
2221
map_environment_document_to_environment_updated_at,
2322
map_segment_results_to_identity_segments,
2423
resolve_trait_values,
@@ -150,10 +149,8 @@ def __init__(
150149
)
151150

152151
if self.offline_handler:
153-
self._local_evaluation = LocalEvaluation(
154-
map_environment_document_to_context(
155-
self.offline_handler.get_environment()
156-
)
152+
self._local_evaluation = LocalEvaluation.from_environment_document(
153+
self.offline_handler.get_environment()
157154
)
158155

159156
if not self.offline_mode:
@@ -467,8 +464,8 @@ def update_environment(self) -> None:
467464
logger.exception("Error retrieving environment document from API")
468465
else:
469466
try:
470-
self._local_evaluation = LocalEvaluation(
471-
map_environment_document_to_context(environment_data)
467+
self._local_evaluation = LocalEvaluation.from_environment_document(
468+
environment_data
472469
)
473470
self._environment_updated_at = (
474471
map_environment_document_to_environment_updated_at(

‎flagsmith/local_evaluation.py‎

Lines changed: 61 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@
77
from flag_engine import engine
88
from flag_engine.context.types import SegmentContext, SegmentRule
99

10+
from flagsmith.api.types import EnvironmentModel
1011
from flagsmith.mappers import (
11-
get_identity_overrides_identifiers,
1212
map_context_and_identity_data_to_context,
13-
map_identifiers_to_identity_overrides_rule,
13+
map_environment_document_to_context,
14+
map_environment_document_to_identity_overrides_segments,
1415
)
1516
from flagsmith.models import (
1617
Flag,
@@ -61,40 +62,49 @@ class LocalEvaluation:
6162
Built once each time the environment document is refreshed, so the
6263
work here is amortised across every evaluation until the next refresh.
6364
64-
Identity overrides are mapped to segments matching the identifiers
65-
they apply to. Left in the context, every one of them would be
66-
matched on every identity evaluation. Instead, they're indexed by
67-
identifier, and each identity is evaluated with only its own.
65+
Identity overrides are kept out of the context, by identifier. Each
66+
identity is evaluated with its own identity overrides segment only.
6867
"""
6968

70-
def __init__(self, context: SDKEvaluationContext) -> None:
71-
segments: SegmentContexts = {}
69+
def __init__(
70+
self,
71+
context: SDKEvaluationContext,
72+
identity_overrides_segments: typing.Optional[
73+
typing.Mapping[str, SegmentContext[SegmentMetadata, FeatureMetadata]]
74+
] = None,
75+
) -> None:
76+
self.context = context
7277
self._identity_free_segments: SegmentContexts = {}
73-
self._identity_overrides_segments: SegmentContexts = {}
7478
reads_flags = reads_identity = False
75-
self._identity_overrides_segment_keys: typing.Dict[str, typing.List[str]] = {}
7679
for segment_key, segment_context in (context.get("segments") or {}).items():
77-
identifiers = get_identity_overrides_identifiers(segment_context)
78-
if identifiers is None:
79-
segments[segment_key] = segment_context
80-
reads = _get_context_reads(segment_context["rules"])
81-
reads_flags |= reads.flags
82-
reads_identity |= reads.identity
83-
if not reads.identity:
84-
self._identity_free_segments[segment_key] = segment_context
85-
continue
86-
self._identity_overrides_segments[segment_key] = segment_context
87-
for identifier in identifiers:
88-
self._identity_overrides_segment_keys.setdefault(identifier, []).append(
89-
segment_key
90-
)
91-
80+
reads = _get_context_reads(segment_context["rules"])
81+
reads_flags |= reads.flags
82+
reads_identity |= reads.identity
83+
if not reads.identity:
84+
self._identity_free_segments[segment_key] = segment_context
9285
self.reads = _ContextReads(flags=reads_flags, identity=reads_identity)
93-
94-
# The context without identity overrides segments.
95-
self.context: SDKEvaluationContext = {**context, "segments": segments}
9686
self.overrides_index: SegmentOverridesIndex = build_segment_overrides_index(
97-
self.context
87+
context
88+
)
89+
self._identity_overrides = {
90+
identifier: (segment_context, self._index_overrides(segment_context))
91+
for identifier, segment_context in (
92+
identity_overrides_segments or {}
93+
).items()
94+
}
95+
96+
@classmethod
97+
def from_environment_document(
98+
cls,
99+
environment_document: EnvironmentModel,
100+
) -> LocalEvaluation:
101+
return cls(
102+
context=map_environment_document_to_context(environment_document),
103+
identity_overrides_segments=(
104+
map_environment_document_to_identity_overrides_segments(
105+
environment_document
106+
)
107+
),
98108
)
99109

100110
@cached_property
@@ -113,35 +123,33 @@ def get_identity_context(
113123
identifier: str,
114124
traits: typing.Optional[TraitMapping] = None,
115125
) -> typing.Tuple[SDKEvaluationContext, SegmentOverridesIndex]:
116-
"""Evaluation context and overrides index for an identity.
117-
118-
The context carries the identity's own identity overrides segments,
119-
narrowed to its identifier, so that their size doesn't depend on how
120-
many identities share the same overrides.
121-
"""
126+
"""Evaluation context and overrides index for an identity, including
127+
its own identity overrides segment, if any."""
122128
context = map_context_and_identity_data_to_context(
123129
context=self.context,
124130
identifier=identifier,
125131
traits=traits,
126132
)
127-
if not (segment_keys := self._identity_overrides_segment_keys.get(identifier)):
133+
if (identity_overrides := self._identity_overrides.get(identifier)) is None:
128134
return context, self.overrides_index
129135

130-
identity_segments: SegmentContexts = {}
131-
identity_overrides_index: SegmentOverridesIndex = {}
132-
for segment_key in segment_keys:
133-
segment_context: SegmentContext[SegmentMetadata, FeatureMetadata] = {
134-
**self._identity_overrides_segments[segment_key],
135-
"rules": [map_identifiers_to_identity_overrides_rule([identifier])],
136-
}
137-
identity_segments[segment_key] = segment_context
138-
for override in segment_context.get("overrides") or ():
139-
feature_name = override["name"]
140-
if feature_name not in identity_overrides_index:
141-
identity_overrides_index[feature_name] = dict(
142-
self.overrides_index.get(feature_name) or {}
143-
)
144-
identity_overrides_index[feature_name][segment_key] = segment_context
145-
146-
context["segments"] = {**(context.get("segments") or {}), **identity_segments}
136+
segment_context, identity_overrides_index = identity_overrides
137+
context["segments"] = {
138+
**(context.get("segments") or {}),
139+
segment_context["key"]: segment_context,
140+
}
147141
return context, ChainMap(identity_overrides_index, self.overrides_index)
142+
143+
def _index_overrides(
144+
self,
145+
segment_context: SegmentContext[SegmentMetadata, FeatureMetadata],
146+
) -> SegmentOverridesIndex:
147+
"""The overrides index, for the features `segment_context` overrides."""
148+
return {
149+
feature_name: {
150+
**(self.overrides_index.get(feature_name) or {}),
151+
segment_context["key"]: segment_context,
152+
}
153+
for override in segment_context.get("overrides") or ()
154+
if (feature_name := override["name"])
155+
}

‎flagsmith/mappers.py‎

Lines changed: 44 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import json
22
import typing
33
import uuid
4-
from collections import defaultdict
54
from datetime import datetime, timezone
65

76
import sseclient
@@ -18,7 +17,6 @@
1817
from flagsmith.api.types import (
1918
EnvironmentModel,
2019
FeatureStateModel,
21-
IdentityModel,
2220
SegmentRuleModel,
2321
)
2422
from flagsmith.models import Segment
@@ -31,13 +29,8 @@
3129
)
3230
from flagsmith.utils.datetime import fromisoformat
3331

34-
OverrideKey = typing.Tuple[
35-
int,
36-
str,
37-
bool,
38-
typing.Any,
39-
]
40-
OverridesKey = typing.Tuple[OverrideKey, ...]
32+
# Each identity is evaluated with its own identity overrides segment only.
33+
IDENTITY_OVERRIDES_SEGMENT_KEY = "identity_overrides"
4134

4235

4336
def map_segment_results_to_identity_segments(
@@ -116,85 +109,57 @@ def map_environment_document_to_context(
116109
)
117110
},
118111
"segments": {
119-
**{
120-
(segment_key := str(segment_id := segment["id"])): {
121-
"key": segment_key,
122-
"name": segment["name"],
123-
"rules": _map_environment_document_rules_to_context_rules(
124-
segment["rules"]
125-
),
126-
"overrides": list(
127-
_map_environment_document_feature_states_to_feature_contexts(
128-
segment.get("feature_states") or []
129-
)
130-
),
131-
"metadata": SegmentMetadata(
132-
id=segment_id,
133-
source="api",
134-
),
135-
}
136-
for segment in environment_document["project"]["segments"]
137-
},
138-
**_map_identity_overrides_to_segments(
139-
environment_document.get("identity_overrides") or []
140-
),
112+
(segment_key := str(segment_id := segment["id"])): {
113+
"key": segment_key,
114+
"name": segment["name"],
115+
"rules": _map_environment_document_rules_to_context_rules(
116+
segment["rules"]
117+
),
118+
"overrides": list(
119+
_map_environment_document_feature_states_to_feature_contexts(
120+
segment.get("feature_states") or []
121+
)
122+
),
123+
"metadata": SegmentMetadata(
124+
id=segment_id,
125+
source="api",
126+
),
127+
}
128+
for segment in environment_document["project"]["segments"]
141129
},
142130
}
143131

144132

145-
def _map_identity_overrides_to_segments(
146-
identity_overrides: list[IdentityModel],
147-
) -> dict[str, SegmentContext[SegmentMetadata, FeatureMetadata]]:
148-
features_to_identifiers: typing.Dict[
149-
OverridesKey,
150-
typing.List[str],
151-
] = defaultdict(list)
152-
for identity_override in identity_overrides:
153-
identity_features = identity_override["identity_features"]
154-
if not identity_features:
155-
continue
156-
overrides_key = tuple(
157-
(
158-
feature_state["feature"]["id"],
159-
feature_state["feature"]["name"],
160-
feature_state["enabled"],
161-
feature_state["feature_state_value"],
162-
)
163-
for feature_state in sorted(
164-
identity_features,
165-
key=lambda feature_state: feature_state["feature"]["name"],
166-
)
167-
)
168-
features_to_identifiers[overrides_key].append(identity_override["identifier"])
169-
segment_contexts: typing.Dict[
170-
str,
171-
SegmentContext[
172-
SegmentMetadata,
173-
FeatureMetadata,
174-
],
175-
] = {}
176-
for overrides_key, identifiers in features_to_identifiers.items():
177-
# Create a segment context for each unique set of overrides
178-
# Generate a unique key to avoid collisions
179-
segment_key = str(hash(overrides_key))
180-
segment_contexts[segment_key] = SegmentContext(
181-
key=segment_key,
182-
name="identity_overrides",
183-
rules=[map_identifiers_to_identity_overrides_rule(identifiers)],
184-
overrides=[
133+
def map_environment_document_to_identity_overrides_segments(
134+
environment_document: EnvironmentModel,
135+
) -> typing.Dict[str, SegmentContext[SegmentMetadata, FeatureMetadata]]:
136+
"""Map identity overrides to segment contexts by identifier, each matching
137+
its identity alone and carrying its overrides."""
138+
return {
139+
identity_override["identifier"]: {
140+
"key": IDENTITY_OVERRIDES_SEGMENT_KEY,
141+
"name": "identity_overrides",
142+
"rules": [
143+
map_identifiers_to_identity_overrides_rule(
144+
[identity_override["identifier"]]
145+
)
146+
],
147+
"overrides": [
185148
{
186149
"key": "", # Identity overrides never carry multivariate options
187-
"name": feature_name,
188-
"enabled": feature_enabled,
189-
"value": feature_value,
150+
"name": feature_state["feature"]["name"],
151+
"enabled": feature_state["enabled"],
152+
"value": feature_state["feature_state_value"],
190153
"priority": float("-inf"), # Highest possible priority
191-
"metadata": {"id": feature_id},
154+
"metadata": {"id": feature_state["feature"]["id"]},
192155
}
193-
for feature_id, feature_name, feature_enabled, feature_value in overrides_key
156+
for feature_state in identity_features
194157
],
195-
metadata=SegmentMetadata(source="identity_overrides"),
196-
)
197-
return segment_contexts
158+
"metadata": SegmentMetadata(source="identity_overrides"),
159+
}
160+
for identity_override in environment_document.get("identity_overrides") or []
161+
if (identity_features := identity_override["identity_features"])
162+
}
198163

199164

200165
def map_identifiers_to_identity_overrides_rule(
@@ -208,18 +173,6 @@ def map_identifiers_to_identity_overrides_rule(
208173
return {"type": "ALL", "conditions": [condition]}
209174

210175

211-
def get_identity_overrides_identifiers(
212-
segment_context: SegmentContext[SegmentMetadata, FeatureMetadata],
213-
) -> typing.Optional[typing.List[str]]:
214-
"""Identifiers an identity-overrides segment applies to, or `None` for
215-
any other segment."""
216-
if (segment_context.get("metadata") or {}).get("source") != "identity_overrides":
217-
return None
218-
[rule] = segment_context["rules"]
219-
[condition] = rule["conditions"]
220-
return typing.cast(InOperatorSegmentCondition, condition)["value"]
221-
222-
223176
def _map_environment_document_rules_to_context_rules(
224177
rules: list[SegmentRuleModel],
225178
) -> list[SegmentRule]:

0 commit comments

Comments
 (0)