From 2445b0d008b30ff51247a83e4884cc1d90c07d35 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 18 Sep 2026 14:43:39 +0100 Subject: [PATCH 01/30] feat(api): evaluate identity flags through flag-engine Core API resolved override precedence and multivariate bucketing itself, in parallel with the engine it already used for segment matching. This routes identity flag evaluation through `get_evaluation_result` instead, leaving Core API responsible only for building an `EvaluationContext`. `Identity.get_all_feature_states` now asks the engine which override won rather than comparing feature states with `>`. It still returns Django rows: `FlagResult.metadata.feature_state_id` maps the engine's verdict back to the row it came from, so callers are unaffected for now. Identity overrides reach the engine the way SDKs express them, as a synthetic segment whose overrides sit at `priority = -inf`. That encodes "no segment override outranks an identity override", which until now was `FeatureState.__gt__` returning True unconditionally. Every segment with an override in the environment is now evaluated, rather than pre-filtering to segments the identity is known to match. A single pass is what lets a segment condition depend on a flag that is itself only resolved during evaluation (#1674). Contributes to #6654. --- api/environments/identities/evaluation.py | 140 ++++++++ api/environments/identities/models.py | 72 +---- api/features/types.py | 17 + api/segments/types.py | 11 +- .../test_unit_identities_evaluation.py | 305 ++++++++++++++++++ .../util/mappers/test_unit_mappers_engine.py | 2 +- api/util/mappers/engine.py | 219 ++++++++++++- 7 files changed, 698 insertions(+), 68 deletions(-) create mode 100644 api/environments/identities/evaluation.py create mode 100644 api/features/types.py create mode 100644 api/tests/unit/environments/identities/test_unit_identities_evaluation.py diff --git a/api/environments/identities/evaluation.py b/api/environments/identities/evaluation.py new file mode 100644 index 000000000000..040f9d4bfa12 --- /dev/null +++ b/api/environments/identities/evaluation.py @@ -0,0 +1,140 @@ +"""Building an `EvaluationContext` for an identity, and evaluating it. + +Core API's contribution to flag evaluation is this module: resolve the rows +that are current for an environment, hand them to flag-engine, and let it +decide which override wins and which multivariate variant an identity lands in. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, NamedTuple + +from django.db.models import Prefetch, Q +from flag_engine.engine import get_evaluation_result + +from features.models import FeatureState +from features.multivariate.models import MultivariateFeatureStateValue +from features.versioning.versioning_service import get_environment_flags_list +from util.mappers.engine import ( + EvaluationContext, + EvaluationResult, + map_environment_to_evaluation_context, +) + +if TYPE_CHECKING: # pragma: no cover + from environments.identities.models import Identity + from environments.identities.traits.models import Trait + from environments.models import Environment + from segments.models import Segment + + +__all__ = ( + "IdentityEvaluation", + "build_identity_evaluation_context", + "evaluate_identity", +) + + +class IdentityEvaluationContext(NamedTuple): + context: EvaluationContext + #: The rows the context was built from, by id. `FlagResult` and + #: `SegmentContext.overrides` carry `metadata.feature_state_id`, so callers + #: that still need a Django row can reach one without re-deriving which + #: override won. + feature_states_by_id: dict[int, FeatureState] + + +class IdentityEvaluation(NamedTuple): + result: EvaluationResult + feature_states_by_id: dict[int, FeatureState] + + +def evaluate_identity( + identity: "Identity", + *, + traits: "list[Trait] | None" = None, + feature_name: str | None = None, + additional_filters: Q | None = None, +) -> IdentityEvaluation: + """Evaluate every flag in `identity`'s environment for that identity.""" + context, feature_states_by_id = build_identity_evaluation_context( + identity, + traits=traits, + feature_name=feature_name, + additional_filters=additional_filters, + ) + return IdentityEvaluation(get_evaluation_result(context), feature_states_by_id) + + +def build_identity_evaluation_context( + identity: "Identity", + *, + traits: "list[Trait] | None" = None, + feature_name: str | None = None, + additional_filters: Q | None = None, +) -> IdentityEvaluationContext: + """Build the context for evaluating `identity`'s flags. + + Every segment with an override in the environment is included, not just + those the identity is known to match: which segments match is the engine's + verdict to reach, and a segment condition may depend on a flag that is only + resolved during evaluation. + """ + environment: "Environment" = identity.environment + segments: list["Segment"] = environment.get_segments_from_cache() + + # Identity overrides belong to this identity alone; a transient identity + # has none, having never been persisted. + identity_query = Q(identity=identity) if identity.id else Q(identity__isnull=True) + full_query = Q(identity__isnull=True) | identity_query + if additional_filters: + full_query &= additional_filters + + feature_states = get_environment_flags_list( + environment=environment, + feature_name=feature_name, + additional_filters=full_query, + additional_select_related_args=["feature_segment__segment"], + additional_prefetch_related_args=[ + Prefetch( + "multivariate_feature_state_values", + queryset=MultivariateFeatureStateValue.objects.select_related( + "multivariate_feature_option" + ), + ) + ], + ) + + environment_defaults: list[FeatureState] = [] + identity_overrides: list[FeatureState] = [] + segment_overrides: dict[int, list[FeatureState]] = {} + mv_fs_values_by_feature_state_id = {} + + for feature_state in feature_states: + mv_fs_values_by_feature_state_id[feature_state.pk] = ( + feature_state.multivariate_feature_state_values.all() + ) + if feature_state.identity_id is not None: + identity_overrides.append(feature_state) + elif (feature_segment := feature_state.feature_segment) is not None: + segment_overrides.setdefault(feature_segment.segment_id, []).append( + feature_state + ) + else: + environment_defaults.append(feature_state) + + return IdentityEvaluationContext( + context=map_environment_to_evaluation_context( + environment=environment, + identity=identity, + traits=traits, + segments=segments, + features=environment_defaults, + segment_overrides=segment_overrides, + identity_overrides=identity_overrides, + mv_fs_values_by_feature_state_id=mv_fs_values_by_feature_state_id, + ), + feature_states_by_id={ + feature_state.pk: feature_state for feature_state in feature_states + }, + ) diff --git a/api/environments/identities/models.py b/api/environments/identities/models.py index 003a0bbca5f8..23c9f5533ba5 100644 --- a/api/environments/identities/models.py +++ b/api/environments/identities/models.py @@ -1,16 +1,15 @@ from itertools import chain from django.db import models -from django.db.models import Prefetch, Q +from django.db.models import Q from flag_engine.engine import get_evaluation_result +from environments.identities.evaluation import evaluate_identity from environments.identities.managers import IdentityManager from environments.identities.traits.models import Trait from environments.models import Environment from environments.sdk.types import SDKTraitData from features.models import FeatureState -from features.multivariate.models import MultivariateFeatureStateValue -from features.versioning.versioning_service import get_environment_flags_list from segments.models import Segment from util.mappers.engine import map_environment_to_evaluation_context @@ -70,62 +69,20 @@ def get_all_feature_states( :return: (list) flags for an identity with the correct values based on identity / segment priorities """ - segments = self.get_segments(traits=traits, overrides_only=True) - - # define sub queries - belongs_to_environment_query = Q(environment=self.environment) - if self.id: - overridden_for_identity_query = Q(identity=self) - else: - # skip identity overrides for transient identities - overridden_for_identity_query = Q() - - overridden_for_segment_query = Q( - feature_segment__segment__in=segments, - feature_segment__environment=self.environment, - ) - environment_default_query = Q(identity=None, feature_segment=None) - - # define the full query - full_query = belongs_to_environment_query & ( - overridden_for_identity_query - | overridden_for_segment_query - | environment_default_query - ) - - if additional_filters: - full_query &= additional_filters - - all_flags = get_environment_flags_list( - environment=self.environment, + result, feature_states_by_id = evaluate_identity( + self, + traits=traits, feature_name=feature_name, - additional_filters=full_query, - additional_prefetch_related_args=[ - Prefetch( - "multivariate_feature_state_values", - queryset=MultivariateFeatureStateValue.objects.select_related( - "multivariate_feature_option" - ), - ) - ], + additional_filters=additional_filters, ) - # iterate over all the flags and build a dictionary keyed on feature with the highest priority flag - # for the given identity as the value. - identity_flags = {} - for flag in all_flags: - if flag.feature_id not in identity_flags: - identity_flags[flag.feature_id] = flag - else: - current_flag = identity_flags[flag.feature_id] - if flag > current_flag: - identity_flags[flag.feature_id] = flag + hide_disabled_flags = self.environment.get_hide_disabled_flags() is True - if self.environment.get_hide_disabled_flags() is True: - # filter out any flags that are disabled - return [value for value in identity_flags.values() if value.enabled] - - return list(identity_flags.values()) + return [ + feature_states_by_id[flag["metadata"]["feature_state_id"]] + for flag in result["flags"].values() + if not (hide_disabled_flags and not flag["enabled"]) + ] def get_overridden_feature_states(self) -> dict[int, FeatureState]: """ @@ -166,8 +123,11 @@ def get_segments( ) result = get_evaluation_result(context) return [ - segments_by_pk[segment_result["metadata"]["pk"]] + segments_by_pk[pk] for segment_result in result["segments"] + # Synthetic identity-override segments carry no pk, and are not + # segments as far as any caller is concerned. + if (pk := segment_result["metadata"].get("pk")) is not None ] def get_all_user_traits(self): # type: ignore[no-untyped-def] diff --git a/api/features/types.py b/api/features/types.py new file mode 100644 index 000000000000..72d852160773 --- /dev/null +++ b/api/features/types.py @@ -0,0 +1,17 @@ +from typing_extensions import NotRequired, TypedDict + + +class FeatureEngineMetadata(TypedDict): + """Core API data carried on a `FeatureContext` and returned on a `FlagResult`. + + The engine treats this as opaque. It exists so that callers can map an + evaluated flag back to the Django rows it was built from, without + re-deriving which override won. + """ + + feature_id: int + feature_state_id: int + #: Set when the context was built from a segment override. + segment_id: NotRequired[int] + #: Set when the context was built from an identity override. + identity_id: NotRequired[int] diff --git a/api/segments/types.py b/api/segments/types.py index 57bb09634974..ed6ad817c403 100644 --- a/api/segments/types.py +++ b/api/segments/types.py @@ -1,9 +1,18 @@ +from typing import Literal + from flag_engine.segments.types import ConditionOperator, RuleType from typing_extensions import NotRequired, TypedDict +#: Where a `SegmentContext` came from. Identity overrides have no segment of +#: their own; they are expressed to the engine as a synthetic segment, so that +#: the engine resolves them by priority like any other override. +SegmentSource = Literal["segment", "identity_overrides"] + class SegmentEngineMetadata(TypedDict): - pk: int + source: SegmentSource + #: Absent on synthetic identity-override segments. + pk: NotRequired[int] class SegmentCondition(TypedDict): diff --git a/api/tests/unit/environments/identities/test_unit_identities_evaluation.py b/api/tests/unit/environments/identities/test_unit_identities_evaluation.py new file mode 100644 index 000000000000..5dc47b7b7327 --- /dev/null +++ b/api/tests/unit/environments/identities/test_unit_identities_evaluation.py @@ -0,0 +1,305 @@ +import pytest +from flag_engine.segments.constants import EQUAL +from flag_engine.utils.hashing import get_hashed_percentage_for_object_ids + +from environments.identities.evaluation import ( + build_identity_evaluation_context, + evaluate_identity, +) +from environments.identities.models import Identity +from environments.identities.traits.models import Trait +from environments.models import Environment +from features.models import Feature, FeatureSegment, FeatureState +from features.multivariate.models import MultivariateFeatureStateValue +from projects.models import Project +from segments.models import Condition, Segment, SegmentRule +from util.mappers.engine import ( + IDENTITY_OVERRIDES_SEGMENT_KEY, + IDENTITY_OVERRIDES_SEGMENT_NAME, +) + + +def test_build_identity_evaluation_context__environment_default__populates_features( + identity: Identity, + feature: Feature, +) -> None: + # Given + feature_state = FeatureState.objects.get( + feature=feature, environment=identity.environment + ) + + # When + context, feature_states_by_id = build_identity_evaluation_context(identity) + + # Then + assert context["features"] == { + feature.name: { + "key": str(feature_state.pk), + "name": feature.name, + "enabled": feature_state.enabled, + "value": feature_state.get_feature_state_value(), + "metadata": { + "feature_id": feature.pk, + "feature_state_id": feature_state.pk, + }, + } + } + assert feature_states_by_id == {feature_state.pk: feature_state} + + +def test_build_identity_evaluation_context__transient_identity__omits_stored_traits( + environment: Environment, +) -> None: + # Given + # A transient identity is never saved, so it has no traits to read. + transient_identity = Identity(identifier="transient", environment=environment) + + # When + context, _ = build_identity_evaluation_context(transient_identity) + + # Then + assert context["identity"] == { + "identifier": "transient", + "key": transient_identity.get_hash_key( + environment.use_identity_composite_key_for_hashing + ), + "traits": {}, + } + + +def test_build_identity_evaluation_context__segment_override__carries_segment_id( + identity: Identity, + feature: Feature, + identity_matching_segment: Segment, +) -> None: + # Given + feature_segment = FeatureSegment.objects.create( + feature=feature, + segment=identity_matching_segment, + environment=identity.environment, + priority=3, + ) + override = FeatureState.objects.create( + feature=feature, + environment=identity.environment, + feature_segment=feature_segment, + enabled=True, + ) + + # When + context, _ = build_identity_evaluation_context(identity) + + # Then + segment_context = context["segments"][str(identity_matching_segment.pk)] + assert segment_context["metadata"] == { + "source": "segment", + "pk": identity_matching_segment.pk, + } + (override_context,) = segment_context["overrides"] + assert override_context["priority"] == 3 + assert override_context["metadata"] == { + "feature_id": feature.pk, + "feature_state_id": override.pk, + "segment_id": identity_matching_segment.pk, + } + + +def test_build_identity_evaluation_context__identity_override__returns_synthetic_segment( + identity: Identity, + feature: Feature, +) -> None: + # Given + override = FeatureState.objects.create( + identity=identity, + feature=feature, + environment=identity.environment, + enabled=True, + ) + + # When + context, _ = build_identity_evaluation_context(identity) + + # Then + segment_context = context["segments"][IDENTITY_OVERRIDES_SEGMENT_KEY] + assert segment_context["name"] == IDENTITY_OVERRIDES_SEGMENT_NAME + assert segment_context["metadata"] == {"source": "identity_overrides"} + (override_context,) = segment_context["overrides"] + # No segment override may outrank an identity override. + assert override_context["priority"] == float("-inf") + assert override_context["metadata"]["feature_state_id"] == override.pk + assert override_context["metadata"]["identity_id"] == identity.pk + + +def test_evaluate_identity__identity_and_segment_override__identity_override_wins( + identity: Identity, + feature: Feature, + identity_matching_segment: Segment, +) -> None: + # Given + feature_segment = FeatureSegment.objects.create( + feature=feature, + segment=identity_matching_segment, + environment=identity.environment, + # A priority high enough to beat every other segment override, but not + # an identity override. + priority=0, + ) + segment_override = FeatureState.objects.create( + feature=feature, + environment=identity.environment, + feature_segment=feature_segment, + enabled=True, + ) + segment_override.feature_state_value.string_value = "segment" + segment_override.feature_state_value.save() + + identity_override = FeatureState.objects.create( + identity=identity, + feature=feature, + environment=identity.environment, + enabled=True, + ) + identity_override.feature_state_value.string_value = "identity" + identity_override.feature_state_value.save() + + # When + result, _ = evaluate_identity(identity) + + # Then + flag = result["flags"][feature.name] + assert flag["value"] == "identity" + assert flag["metadata"]["feature_state_id"] == identity_override.pk + + +def test_evaluate_identity__segment_overrides__lowest_priority_wins( + identity: Identity, + feature: Feature, + identity_matching_segment: Segment, + project: Project, + trait: Trait, +) -> None: + # Given + # A second segment the identity also matches, overriding the same feature. + other_segment = Segment.objects.create(name="Everyone", project=project) + Condition.objects.create( + rule=SegmentRule.objects.create( + segment=other_segment, type=SegmentRule.ALL_RULE + ), + property=trait.trait_key, + operator=EQUAL, + value=trait.trait_value, + ) + + for segment, priority, value in ( + (identity_matching_segment, 1, "winner"), + (other_segment, 2, "loser"), + ): + feature_segment = FeatureSegment.objects.create( + feature=feature, + segment=segment, + environment=identity.environment, + priority=priority, + ) + override = FeatureState.objects.create( + feature=feature, + environment=identity.environment, + feature_segment=feature_segment, + ) + override.feature_state_value.string_value = value + override.feature_state_value.save() + + # When + result, _ = evaluate_identity(identity) + + # Then + assert result["flags"][feature.name]["value"] == "winner" + + +@pytest.mark.parametrize("mv_hashing_salt", [None, 12345]) +def test_evaluate_identity__multivariate_feature__matches_legacy_bucketing( + identity: Identity, + multivariate_feature: Feature, + mv_hashing_salt: int | None, +) -> None: + """The engine must bucket an identity exactly as Core API used to. + + Core API seeds allocation on `mv_hashing_seed`, a lineage constant that + survives a feature state being recreated (#7913). Seeding on anything else + — the feature state id, say — would silently move enrolled identities to a + different variant. + """ + # Given + feature_state = FeatureState.objects.get( + feature=multivariate_feature, environment=identity.environment + ) + feature_state.mv_hashing_salt = mv_hashing_salt + feature_state.save() + + hash_key = identity.get_hash_key( + identity.environment.use_identity_composite_key_for_hashing + ) + expected_value = feature_state.get_feature_state_value_by_hash_key(hash_key) + + # When + result, _ = evaluate_identity(identity) + + # Then + assert result["flags"][multivariate_feature.name]["value"] == expected_value + # And the seed the engine used is the lineage constant, not the row id. + assert get_hashed_percentage_for_object_ids( + [str(feature_state.mv_hashing_seed), hash_key] + ) == get_hashed_percentage_for_object_ids([feature_state.mv_hashing_seed, hash_key]) + + +def test_evaluate_identity__multivariate_feature__returns_variant_key( + identity: Identity, + multivariate_feature: Feature, +) -> None: + # Given + feature_state = FeatureState.objects.get( + feature=multivariate_feature, environment=identity.environment + ) + for index, mv_value in enumerate( + MultivariateFeatureStateValue.objects.filter( + feature_state=feature_state + ).order_by("id") + ): + mv_value.multivariate_feature_option.key = f"variant-{index}" + mv_value.multivariate_feature_option.save() + + # When + result, _ = evaluate_identity(identity) + + # Then + flag = result["flags"][multivariate_feature.name] + # Either a named variant or the control bucket — never a silent `None`, + # which is what an unkeyed variant context would produce. + assert flag["variant"] in {"control", "variant-0", "variant-1", "variant-2"} + + +def test_evaluate_identity__multivariate_feature__weights_variants_in_id_order( + identity: Identity, + multivariate_feature: Feature, +) -> None: + # Given + feature_state = FeatureState.objects.get( + feature=multivariate_feature, environment=identity.environment + ) + + # When + context, _ = build_identity_evaluation_context(identity) + + # Then + # Core API allocates percentages in id order; the engine allocates in + # `priority` order, so the two only agree if priority follows id. + mv_values = MultivariateFeatureStateValue.objects.filter( + feature_state=feature_state + ).order_by("id") + assert context["features"][multivariate_feature.name]["variants"] == [ + { + "value": mv_value.multivariate_feature_option.value, + "weight": mv_value.percentage_allocation, + "priority": index, + } + for index, mv_value in enumerate(mv_values) + ] diff --git a/api/tests/unit/util/mappers/test_unit_mappers_engine.py b/api/tests/unit/util/mappers/test_unit_mappers_engine.py index ef5bf4e62f79..57d81fd2a7a2 100644 --- a/api/tests/unit/util/mappers/test_unit_mappers_engine.py +++ b/api/tests/unit/util/mappers/test_unit_mappers_engine.py @@ -950,7 +950,7 @@ def test_map_segment_to_segment_context__segment_with_rule__returns_expected( "rules": [], }, ], - "metadata": {"pk": identity_matching_segment.pk}, + "metadata": {"source": "segment", "pk": identity_matching_segment.pk}, } diff --git a/api/util/mappers/engine.py b/api/util/mappers/engine.py index 55afadd9f186..7cacf6445c80 100644 --- a/api/util/mappers/engine.py +++ b/api/util/mappers/engine.py @@ -1,13 +1,18 @@ -from collections.abc import Iterable +from collections.abc import Iterable, Mapping from itertools import chain -from typing import TYPE_CHECKING, Dict, List, Optional +from math import inf +from operator import attrgetter +from typing import TYPE_CHECKING, Dict, List, Optional, TypeAlias from uuid import UUID from flag_engine.context import types as engine_types +from flag_engine.result import types as engine_result_types +from flag_engine.segments.constants import IS_SET from flag_engine.segments.types import ConditionOperator, RuleType from pydantic import TypeAdapter from environments.constants import IDENTITY_INTEGRATIONS_RELATION_NAMES +from features.types import FeatureEngineMetadata from features.versioning.models import EnvironmentFeatureVersion from segments.types import SegmentEngineMetadata from util.engine_models.environments.integrations.models import IntegrationModel @@ -51,10 +56,38 @@ from segments.models import Condition, Segment, SegmentRule +#: `EvaluationContext` and friends, parameterised with Core API's metadata +#: types. Prefer these aliases over the bare generics so that reading metadata +#: off a result stays type-checked. +EvaluationContext: TypeAlias = engine_types.EvaluationContext[ + SegmentEngineMetadata, FeatureEngineMetadata +] +SegmentContext: TypeAlias = engine_types.SegmentContext[ + SegmentEngineMetadata, FeatureEngineMetadata +] +EvaluationResult: TypeAlias = engine_result_types.EvaluationResult[ + SegmentEngineMetadata, FeatureEngineMetadata +] +FlagResult: TypeAlias = engine_result_types.FlagResult[FeatureEngineMetadata] + +MultivariateValuesByFeatureStateId: TypeAlias = Mapping[ + int, "Iterable[MultivariateFeatureStateValue]" +] + +#: Context key and name of the synthetic segment carrying identity overrides. +#: Not a segment id — prefixed so it cannot collide with one. +IDENTITY_OVERRIDES_SEGMENT_KEY = "$identity_overrides" +IDENTITY_OVERRIDES_SEGMENT_NAME = "identity_overrides" + __all__ = ( + "EvaluationContext", + "EvaluationResult", + "FlagResult", + "SegmentContext", "map_condition_to_segment_condition", "map_environment_api_key_to_engine", "map_environment_to_engine", + "map_feature_state_to_feature_context", "map_feature_to_engine", "map_identity_to_engine", "map_environment_to_evaluation_context", @@ -462,9 +495,26 @@ def map_environment_to_evaluation_context( identity: "Identity | None" = None, traits: "Iterable[Trait] | None" = None, segments: "Iterable[Segment] | None" = None, -) -> "engine_types.EvaluationContext[SegmentEngineMetadata, object]": - """Map Django ORM Environment (and optionally Identity) to a flag-engine EvaluationContext.""" - context: engine_types.EvaluationContext[SegmentEngineMetadata, object] = { + features: "Iterable[FeatureState] | None" = None, + segment_overrides: "Mapping[int, Iterable[FeatureState]] | None" = None, + identity_overrides: "Iterable[FeatureState] | None" = None, + mv_fs_values_by_feature_state_id: "MultivariateValuesByFeatureStateId | None" = None, +) -> EvaluationContext: + """Map Django ORM models to a flag-engine `EvaluationContext`. + + All arguments are expected to be resolved already: this function does not + read from the ORM beyond traversing prefetched relations. See + `environments.identities.evaluation` for the query side. + + :param features: environment default feature states, keyed into + `$.features` by feature name. Required for the engine to produce flags + at all — without it `get_evaluation_result` returns segments only. + :param segment_overrides: feature states overriding `features`, by segment id. + :param identity_overrides: feature states overriding `features` for + `identity`. Expressed as a synthetic segment (see + `_map_identity_overrides_to_segment_context`). + """ + context: EvaluationContext = { "environment": { "key": environment.api_key, "name": environment.name or "", @@ -472,7 +522,13 @@ def map_environment_to_evaluation_context( } if identity is not None: trait_items: "Iterable[Trait]" = ( - traits if traits is not None else identity.identity_traits.all() + traits + if traits is not None + # A transient identity was never persisted, so it has no stored + # traits to read, and asking for them would raise. + else identity.identity_traits.all() + if identity.pk + else () ) identity_traits = {trait.trait_key: trait.trait_value for trait in trait_items} if identity.system_traits: @@ -486,23 +542,166 @@ def map_environment_to_evaluation_context( ), "traits": identity_traits, } + + mv_fs_values_by_feature_state_id = mv_fs_values_by_feature_state_id or {} + + def to_feature_context( + feature_state: "FeatureState", + *, + segment_id: int | None = None, + priority: float | None = None, + ) -> engine_types.FeatureContext[FeatureEngineMetadata]: + return map_feature_state_to_feature_context( + feature_state, + mv_fs_values=mv_fs_values_by_feature_state_id.get(feature_state.pk), + segment_id=segment_id, + priority=priority, + ) + if segments is not None: + segment_overrides = segment_overrides or {} context["segments"] = { - str(segment.pk): map_segment_to_segment_context(segment) + str(segment.pk): map_segment_to_segment_context( + segment, + overrides=[ + to_feature_context(feature_state, segment_id=segment.pk) + for feature_state in segment_overrides.get(segment.pk) or () + ], + ) for segment in segments } + + if identity_overrides: + # An identity override outranks every segment override, which the + # engine expresses as a priority no segment can beat. + context.setdefault("segments", {})[IDENTITY_OVERRIDES_SEGMENT_KEY] = ( + _map_identity_overrides_to_segment_context( + [ + to_feature_context(feature_state, priority=-inf) + for feature_state in identity_overrides + ] + ) + ) + + if features is not None: + context["features"] = { + (feature_context := to_feature_context(feature_state))[ + "name" + ]: feature_context + for feature_state in features + } + return context +def map_feature_state_to_feature_context( + feature_state: "FeatureState", + *, + mv_fs_values: "Iterable[MultivariateFeatureStateValue] | None" = None, + segment_id: int | None = None, + priority: float | None = None, +) -> engine_types.FeatureContext[FeatureEngineMetadata]: + """Map a Django ORM FeatureState to a flag-engine FeatureContext TypedDict.""" + feature = feature_state.feature + metadata = FeatureEngineMetadata( + feature_id=feature.pk, + feature_state_id=feature_state.pk, + ) + if segment_id is not None: + metadata["segment_id"] = segment_id + if feature_state.identity_id is not None: + metadata["identity_id"] = feature_state.identity_id + + feature_context: engine_types.FeatureContext[FeatureEngineMetadata] = { + # The engine seeds multivariate variant allocation on the feature + # context key, so it has to be the bucketing seed rather than the + # feature state id, or recreating a feature state would move every + # enrolled identity to a different variant. See issue #7913. + "key": str(feature_state.mv_hashing_seed), + "name": feature.name, + "enabled": feature_state.enabled, + # Deliberately unparameterised by identity: picking a multivariate + # value is the engine's job now. + "value": feature_state.get_feature_state_value(), + "metadata": metadata, + } + + if variants := _map_mv_fs_values_to_feature_values(mv_fs_values or ()): + feature_context["variants"] = variants + + if priority is not None: + feature_context["priority"] = priority + elif (feature_segment := feature_state.feature_segment) is not None: + feature_context["priority"] = feature_segment.priority + + return feature_context + + +def _map_mv_fs_values_to_feature_values( + mv_fs_values: "Iterable[MultivariateFeatureStateValue]", +) -> list[engine_types.FeatureValue]: + # Ordered by id, and weighted by position in that order, because that is + # the order Core API has always allocated percentages in. The engine + # orders by `priority`, so the two only agree if we hand it the id order. + feature_values: list[engine_types.FeatureValue] = [] + for index, mv_fs_value in enumerate(sorted(mv_fs_values, key=attrgetter("id"))): + mv_option = mv_fs_value.multivariate_feature_option + feature_value: engine_types.FeatureValue = { + "value": mv_option.value, + "weight": mv_fs_value.percentage_allocation, + "priority": index, + } + if mv_option.key is not None: + # An unkeyed option resolves to a null variant, as it does today. + feature_value["key"] = mv_option.key + feature_values.append(feature_value) + return feature_values + + def map_segment_to_segment_context( segment: "Segment", -) -> "engine_types.SegmentContext[SegmentEngineMetadata, object]": + *, + overrides: "list[engine_types.FeatureContext[FeatureEngineMetadata]] | None" = None, +) -> SegmentContext: """Map a Django ORM Segment to a flag-engine SegmentContext TypedDict.""" - return { + segment_context: SegmentContext = { "key": str(segment.pk), "name": segment.name, "rules": [map_rule_to_segment_rule(rule) for rule in segment.rules.all()], - "metadata": SegmentEngineMetadata(pk=segment.pk), + "metadata": SegmentEngineMetadata(source="segment", pk=segment.pk), + } + if overrides: + segment_context["overrides"] = overrides + return segment_context + + +def _map_identity_overrides_to_segment_context( + overrides: "list[engine_types.FeatureContext[FeatureEngineMetadata]]", +) -> SegmentContext: + """Express identity overrides as a segment matching only that identity. + + The engine has no identity-override concept, so SDKs model them as a + segment keyed on the identifier. Core API does the same, for one identity + at a time — the identity being evaluated is the only one whose overrides + are ever in the context. + """ + return { + "key": IDENTITY_OVERRIDES_SEGMENT_KEY, + "name": IDENTITY_OVERRIDES_SEGMENT_NAME, + "rules": [ + { + "type": "ALL", + "conditions": [ + { + "property": "$.identity.key", + "operator": IS_SET, + "value": "", + } + ], + } + ], + "overrides": overrides, + "metadata": SegmentEngineMetadata(source="identity_overrides"), } From 8928675f04f313aadd1129d2f8de526e14660076 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Mon, 21 Sep 2026 12:12:31 +0100 Subject: [PATCH 02/30] refactor(api): simplify identity override filter Drop the redundant OR term on the transient path, and the module docstring, comments and pragmas that restated what the code says. --- api/environments/identities/evaluation.py | 28 +++++++---------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/api/environments/identities/evaluation.py b/api/environments/identities/evaluation.py index 040f9d4bfa12..095e21aa3182 100644 --- a/api/environments/identities/evaluation.py +++ b/api/environments/identities/evaluation.py @@ -1,12 +1,3 @@ -"""Building an `EvaluationContext` for an identity, and evaluating it. - -Core API's contribution to flag evaluation is this module: resolve the rows -that are current for an environment, hand them to flag-engine, and let it -decide which override wins and which multivariate variant an identity lands in. -""" - -from __future__ import annotations - from typing import TYPE_CHECKING, NamedTuple from django.db.models import Prefetch, Q @@ -21,7 +12,7 @@ map_environment_to_evaluation_context, ) -if TYPE_CHECKING: # pragma: no cover +if TYPE_CHECKING: from environments.identities.models import Identity from environments.identities.traits.models import Trait from environments.models import Environment @@ -37,10 +28,6 @@ class IdentityEvaluationContext(NamedTuple): context: EvaluationContext - #: The rows the context was built from, by id. `FlagResult` and - #: `SegmentContext.overrides` carry `metadata.feature_state_id`, so callers - #: that still need a Django row can reach one without re-deriving which - #: override won. feature_states_by_id: dict[int, FeatureState] @@ -83,17 +70,18 @@ def build_identity_evaluation_context( environment: "Environment" = identity.environment segments: list["Segment"] = environment.get_segments_from_cache() - # Identity overrides belong to this identity alone; a transient identity - # has none, having never been persisted. - identity_query = Q(identity=identity) if identity.id else Q(identity__isnull=True) - full_query = Q(identity__isnull=True) | identity_query + override_filters = Q(identity__isnull=True) + if identity.pk: + # The identity is persisted (non-transient), + # Look for its identity overrides in addition to segment overrides. + override_filters = Q(identity=identity) | override_filters if additional_filters: - full_query &= additional_filters + override_filters &= additional_filters feature_states = get_environment_flags_list( environment=environment, feature_name=feature_name, - additional_filters=full_query, + additional_filters=override_filters, additional_select_related_args=["feature_segment__segment"], additional_prefetch_related_args=[ Prefetch( From 964fa5aaaf724919846667c887ae2fe4dc42b490 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Mon, 21 Sep 2026 12:33:27 +0100 Subject: [PATCH 03/30] refactor(api): split identity evaluation into mappers, services and types `evaluation.py` mixed a mapper, a service and two result types. Split it along the lines the rest of the package already uses, and rename `build_identity_evaluation_context` to `map_identity_to_evaluation_context` to match the mapper naming convention. Tests follow the split. No behaviour change. --- .../identities/{evaluation.py => mappers.py} | 55 +------ api/environments/identities/models.py | 2 +- api/environments/identities/services.py | 24 +++ api/environments/identities/types.py | 22 +++ .../test_unit_identities_mappers.py | 149 ++++++++++++++++++ ...on.py => test_unit_identities_services.py} | 149 +----------------- api/util/mappers/engine.py | 2 +- 7 files changed, 205 insertions(+), 198 deletions(-) rename api/environments/identities/{evaluation.py => mappers.py} (65%) create mode 100644 api/environments/identities/types.py create mode 100644 api/tests/unit/environments/identities/test_unit_identities_mappers.py rename api/tests/unit/environments/identities/{test_unit_identities_evaluation.py => test_unit_identities_services.py} (53%) diff --git a/api/environments/identities/evaluation.py b/api/environments/identities/mappers.py similarity index 65% rename from api/environments/identities/evaluation.py rename to api/environments/identities/mappers.py index 095e21aa3182..8b3d6eb1c3aa 100644 --- a/api/environments/identities/evaluation.py +++ b/api/environments/identities/mappers.py @@ -1,16 +1,12 @@ -from typing import TYPE_CHECKING, NamedTuple +from typing import TYPE_CHECKING from django.db.models import Prefetch, Q -from flag_engine.engine import get_evaluation_result +from environments.identities.types import IdentityEvaluationContext from features.models import FeatureState from features.multivariate.models import MultivariateFeatureStateValue from features.versioning.versioning_service import get_environment_flags_list -from util.mappers.engine import ( - EvaluationContext, - EvaluationResult, - map_environment_to_evaluation_context, -) +from util.mappers.engine import map_environment_to_evaluation_context if TYPE_CHECKING: from environments.identities.models import Identity @@ -19,60 +15,23 @@ from segments.models import Segment -__all__ = ( - "IdentityEvaluation", - "build_identity_evaluation_context", - "evaluate_identity", -) +__all__ = ("map_identity_to_evaluation_context",) -class IdentityEvaluationContext(NamedTuple): - context: EvaluationContext - feature_states_by_id: dict[int, FeatureState] - - -class IdentityEvaluation(NamedTuple): - result: EvaluationResult - feature_states_by_id: dict[int, FeatureState] - - -def evaluate_identity( - identity: "Identity", - *, - traits: "list[Trait] | None" = None, - feature_name: str | None = None, - additional_filters: Q | None = None, -) -> IdentityEvaluation: - """Evaluate every flag in `identity`'s environment for that identity.""" - context, feature_states_by_id = build_identity_evaluation_context( - identity, - traits=traits, - feature_name=feature_name, - additional_filters=additional_filters, - ) - return IdentityEvaluation(get_evaluation_result(context), feature_states_by_id) - - -def build_identity_evaluation_context( +def map_identity_to_evaluation_context( identity: "Identity", *, traits: "list[Trait] | None" = None, feature_name: str | None = None, additional_filters: Q | None = None, ) -> IdentityEvaluationContext: - """Build the context for evaluating `identity`'s flags. - - Every segment with an override in the environment is included, not just - those the identity is known to match: which segments match is the engine's - verdict to reach, and a segment condition may depend on a flag that is only - resolved during evaluation. - """ + """Build the context for evaluating `identity`'s flags.""" environment: "Environment" = identity.environment segments: list["Segment"] = environment.get_segments_from_cache() override_filters = Q(identity__isnull=True) if identity.pk: - # The identity is persisted (non-transient), + # The identity is persisted (non-transient). # Look for its identity overrides in addition to segment overrides. override_filters = Q(identity=identity) | override_filters if additional_filters: diff --git a/api/environments/identities/models.py b/api/environments/identities/models.py index 23c9f5533ba5..65409c88c6f3 100644 --- a/api/environments/identities/models.py +++ b/api/environments/identities/models.py @@ -4,8 +4,8 @@ from django.db.models import Q from flag_engine.engine import get_evaluation_result -from environments.identities.evaluation import evaluate_identity from environments.identities.managers import IdentityManager +from environments.identities.services import evaluate_identity from environments.identities.traits.models import Trait from environments.models import Environment from environments.sdk.types import SDKTraitData diff --git a/api/environments/identities/services.py b/api/environments/identities/services.py index c54563a01757..7506cc295fa1 100644 --- a/api/environments/identities/services.py +++ b/api/environments/identities/services.py @@ -1,10 +1,34 @@ from typing import TYPE_CHECKING +from django.db.models import Q +from flag_engine.engine import get_evaluation_result + +from environments.identities.mappers import map_identity_to_evaluation_context +from environments.identities.types import IdentityEvaluation + if TYPE_CHECKING: from environments.identities.models import Identity + from environments.identities.traits.models import Trait from environments.models import Environment +def evaluate_identity( + identity: "Identity", + *, + traits: "list[Trait] | None" = None, + feature_name: str | None = None, + additional_filters: Q | None = None, +) -> IdentityEvaluation: + """Evaluate every flag in `identity`'s environment for that identity.""" + context, feature_states_by_id = map_identity_to_evaluation_context( + identity, + traits=traits, + feature_name=feature_name, + additional_filters=additional_filters, + ) + return IdentityEvaluation(get_evaluation_result(context), feature_states_by_id) + + def replace_identity_environment( identity: "Identity", environment: "Environment", diff --git a/api/environments/identities/types.py b/api/environments/identities/types.py new file mode 100644 index 000000000000..f3b11fb9004e --- /dev/null +++ b/api/environments/identities/types.py @@ -0,0 +1,22 @@ +from typing import TYPE_CHECKING, NamedTuple + +from util.mappers.engine import EvaluationContext, EvaluationResult + +if TYPE_CHECKING: + from features.models import FeatureState + + +__all__ = ( + "IdentityEvaluation", + "IdentityEvaluationContext", +) + + +class IdentityEvaluationContext(NamedTuple): + context: EvaluationContext + feature_states_by_id: "dict[int, FeatureState]" + + +class IdentityEvaluation(NamedTuple): + result: EvaluationResult + feature_states_by_id: "dict[int, FeatureState]" diff --git a/api/tests/unit/environments/identities/test_unit_identities_mappers.py b/api/tests/unit/environments/identities/test_unit_identities_mappers.py new file mode 100644 index 000000000000..7739e6a0082a --- /dev/null +++ b/api/tests/unit/environments/identities/test_unit_identities_mappers.py @@ -0,0 +1,149 @@ +from environments.identities.mappers import map_identity_to_evaluation_context +from environments.identities.models import Identity +from environments.models import Environment +from features.models import Feature, FeatureSegment, FeatureState +from features.multivariate.models import MultivariateFeatureStateValue +from segments.models import Segment +from util.mappers.engine import ( + IDENTITY_OVERRIDES_SEGMENT_KEY, + IDENTITY_OVERRIDES_SEGMENT_NAME, +) + + +def test_map_identity_to_evaluation_context__environment_default__populates_features( + identity: Identity, + feature: Feature, +) -> None: + # Given + feature_state = FeatureState.objects.get( + feature=feature, environment=identity.environment + ) + + # When + context, feature_states_by_id = map_identity_to_evaluation_context(identity) + + # Then + assert context["features"] == { + feature.name: { + "key": str(feature_state.pk), + "name": feature.name, + "enabled": feature_state.enabled, + "value": feature_state.get_feature_state_value(), + "metadata": { + "feature_id": feature.pk, + "feature_state_id": feature_state.pk, + }, + } + } + assert feature_states_by_id == {feature_state.pk: feature_state} + + +def test_map_identity_to_evaluation_context__transient_identity__omits_stored_traits( + environment: Environment, +) -> None: + # Given + # A transient identity is never saved, so it has no traits to read. + transient_identity = Identity(identifier="transient", environment=environment) + + # When + context, _ = map_identity_to_evaluation_context(transient_identity) + + # Then + assert context["identity"] == { + "identifier": "transient", + "key": transient_identity.get_hash_key( + environment.use_identity_composite_key_for_hashing + ), + "traits": {}, + } + + +def test_map_identity_to_evaluation_context__segment_override__carries_segment_id( + identity: Identity, + feature: Feature, + identity_matching_segment: Segment, +) -> None: + # Given + feature_segment = FeatureSegment.objects.create( + feature=feature, + segment=identity_matching_segment, + environment=identity.environment, + priority=3, + ) + override = FeatureState.objects.create( + feature=feature, + environment=identity.environment, + feature_segment=feature_segment, + enabled=True, + ) + + # When + context, _ = map_identity_to_evaluation_context(identity) + + # Then + segment_context = context["segments"][str(identity_matching_segment.pk)] + assert segment_context["metadata"] == { + "source": "segment", + "pk": identity_matching_segment.pk, + } + (override_context,) = segment_context["overrides"] + assert override_context["priority"] == 3 + assert override_context["metadata"] == { + "feature_id": feature.pk, + "feature_state_id": override.pk, + "segment_id": identity_matching_segment.pk, + } + + +def test_map_identity_to_evaluation_context__identity_override__returns_synthetic_segment( + identity: Identity, + feature: Feature, +) -> None: + # Given + override = FeatureState.objects.create( + identity=identity, + feature=feature, + environment=identity.environment, + enabled=True, + ) + + # When + context, _ = map_identity_to_evaluation_context(identity) + + # Then + segment_context = context["segments"][IDENTITY_OVERRIDES_SEGMENT_KEY] + assert segment_context["name"] == IDENTITY_OVERRIDES_SEGMENT_NAME + assert segment_context["metadata"] == {"source": "identity_overrides"} + (override_context,) = segment_context["overrides"] + # No segment override may outrank an identity override. + assert override_context["priority"] == float("-inf") + assert override_context["metadata"]["feature_state_id"] == override.pk + assert override_context["metadata"]["identity_id"] == identity.pk + + +def test_map_identity_to_evaluation_context__multivariate_feature__weights_variants_in_id_order( + identity: Identity, + multivariate_feature: Feature, +) -> None: + # Given + feature_state = FeatureState.objects.get( + feature=multivariate_feature, environment=identity.environment + ) + + # When + context, _ = map_identity_to_evaluation_context(identity) + + # Then + # Core API allocates percentages in id order; the engine allocates in + # `priority` order, so the two only agree if priority follows id. + mv_values = MultivariateFeatureStateValue.objects.filter( + feature_state=feature_state + ).order_by("id") + assert context["features"][multivariate_feature.name]["variants"] == [ + { + "value": mv_value.multivariate_feature_option.value, + "weight": mv_value.percentage_allocation, + "priority": index, + } + for index, mv_value in enumerate(mv_values) + ] diff --git a/api/tests/unit/environments/identities/test_unit_identities_evaluation.py b/api/tests/unit/environments/identities/test_unit_identities_services.py similarity index 53% rename from api/tests/unit/environments/identities/test_unit_identities_evaluation.py rename to api/tests/unit/environments/identities/test_unit_identities_services.py index 5dc47b7b7327..82f949625af7 100644 --- a/api/tests/unit/environments/identities/test_unit_identities_evaluation.py +++ b/api/tests/unit/environments/identities/test_unit_identities_services.py @@ -2,132 +2,13 @@ from flag_engine.segments.constants import EQUAL from flag_engine.utils.hashing import get_hashed_percentage_for_object_ids -from environments.identities.evaluation import ( - build_identity_evaluation_context, - evaluate_identity, -) from environments.identities.models import Identity +from environments.identities.services import evaluate_identity from environments.identities.traits.models import Trait -from environments.models import Environment from features.models import Feature, FeatureSegment, FeatureState from features.multivariate.models import MultivariateFeatureStateValue from projects.models import Project from segments.models import Condition, Segment, SegmentRule -from util.mappers.engine import ( - IDENTITY_OVERRIDES_SEGMENT_KEY, - IDENTITY_OVERRIDES_SEGMENT_NAME, -) - - -def test_build_identity_evaluation_context__environment_default__populates_features( - identity: Identity, - feature: Feature, -) -> None: - # Given - feature_state = FeatureState.objects.get( - feature=feature, environment=identity.environment - ) - - # When - context, feature_states_by_id = build_identity_evaluation_context(identity) - - # Then - assert context["features"] == { - feature.name: { - "key": str(feature_state.pk), - "name": feature.name, - "enabled": feature_state.enabled, - "value": feature_state.get_feature_state_value(), - "metadata": { - "feature_id": feature.pk, - "feature_state_id": feature_state.pk, - }, - } - } - assert feature_states_by_id == {feature_state.pk: feature_state} - - -def test_build_identity_evaluation_context__transient_identity__omits_stored_traits( - environment: Environment, -) -> None: - # Given - # A transient identity is never saved, so it has no traits to read. - transient_identity = Identity(identifier="transient", environment=environment) - - # When - context, _ = build_identity_evaluation_context(transient_identity) - - # Then - assert context["identity"] == { - "identifier": "transient", - "key": transient_identity.get_hash_key( - environment.use_identity_composite_key_for_hashing - ), - "traits": {}, - } - - -def test_build_identity_evaluation_context__segment_override__carries_segment_id( - identity: Identity, - feature: Feature, - identity_matching_segment: Segment, -) -> None: - # Given - feature_segment = FeatureSegment.objects.create( - feature=feature, - segment=identity_matching_segment, - environment=identity.environment, - priority=3, - ) - override = FeatureState.objects.create( - feature=feature, - environment=identity.environment, - feature_segment=feature_segment, - enabled=True, - ) - - # When - context, _ = build_identity_evaluation_context(identity) - - # Then - segment_context = context["segments"][str(identity_matching_segment.pk)] - assert segment_context["metadata"] == { - "source": "segment", - "pk": identity_matching_segment.pk, - } - (override_context,) = segment_context["overrides"] - assert override_context["priority"] == 3 - assert override_context["metadata"] == { - "feature_id": feature.pk, - "feature_state_id": override.pk, - "segment_id": identity_matching_segment.pk, - } - - -def test_build_identity_evaluation_context__identity_override__returns_synthetic_segment( - identity: Identity, - feature: Feature, -) -> None: - # Given - override = FeatureState.objects.create( - identity=identity, - feature=feature, - environment=identity.environment, - enabled=True, - ) - - # When - context, _ = build_identity_evaluation_context(identity) - - # Then - segment_context = context["segments"][IDENTITY_OVERRIDES_SEGMENT_KEY] - assert segment_context["name"] == IDENTITY_OVERRIDES_SEGMENT_NAME - assert segment_context["metadata"] == {"source": "identity_overrides"} - (override_context,) = segment_context["overrides"] - # No segment override may outrank an identity override. - assert override_context["priority"] == float("-inf") - assert override_context["metadata"]["feature_state_id"] == override.pk - assert override_context["metadata"]["identity_id"] == identity.pk def test_evaluate_identity__identity_and_segment_override__identity_override_wins( @@ -275,31 +156,3 @@ def test_evaluate_identity__multivariate_feature__returns_variant_key( # Either a named variant or the control bucket — never a silent `None`, # which is what an unkeyed variant context would produce. assert flag["variant"] in {"control", "variant-0", "variant-1", "variant-2"} - - -def test_evaluate_identity__multivariate_feature__weights_variants_in_id_order( - identity: Identity, - multivariate_feature: Feature, -) -> None: - # Given - feature_state = FeatureState.objects.get( - feature=multivariate_feature, environment=identity.environment - ) - - # When - context, _ = build_identity_evaluation_context(identity) - - # Then - # Core API allocates percentages in id order; the engine allocates in - # `priority` order, so the two only agree if priority follows id. - mv_values = MultivariateFeatureStateValue.objects.filter( - feature_state=feature_state - ).order_by("id") - assert context["features"][multivariate_feature.name]["variants"] == [ - { - "value": mv_value.multivariate_feature_option.value, - "weight": mv_value.percentage_allocation, - "priority": index, - } - for index, mv_value in enumerate(mv_values) - ] diff --git a/api/util/mappers/engine.py b/api/util/mappers/engine.py index 7cacf6445c80..ff860e4e8667 100644 --- a/api/util/mappers/engine.py +++ b/api/util/mappers/engine.py @@ -504,7 +504,7 @@ def map_environment_to_evaluation_context( All arguments are expected to be resolved already: this function does not read from the ORM beyond traversing prefetched relations. See - `environments.identities.evaluation` for the query side. + `environments.identities.mappers` for the query side. :param features: environment default feature states, keyed into `$.features` by feature name. Required for the engine to produce flags From 6ac930f9b8bcf3cbb314071427f42e8a1155b957 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 22 Sep 2026 11:14:23 +0100 Subject: [PATCH 04/30] refactor(api): resolve feature states in map_environment_to_evaluation_context The caller had to query feature states and hand them back in four mutually-consistent arguments. Resolve them in the mapper instead, as `map_environment_to_engine` alongside it already does, leaving one argument per genuine caller choice. `environments.identities.mappers` had nothing left to do and is gone; `evaluate_identity` calls the engine mapper directly. Feature states are now always resolved, so `Identity.get_segments` costs one more query. That is the price of correctness once segment conditions can read `$.flags.*`: a segments-only context cannot answer them. --- api/environments/identities/mappers.py | 87 ----------- api/environments/identities/models.py | 2 +- api/environments/identities/services.py | 9 +- api/integrations/webhook/serializers.py | 2 +- .../identities/test_unit_identities_models.py | 2 +- .../identities/test_unit_identities_views.py | 8 +- .../util/mappers/test_unit_mappers_engine.py | 25 +-- .../test_unit_mappers_evaluation_context.py} | 41 +++-- api/util/mappers/engine.py | 145 ++++++++++++++---- 9 files changed, 172 insertions(+), 149 deletions(-) delete mode 100644 api/environments/identities/mappers.py rename api/tests/unit/{environments/identities/test_unit_identities_mappers.py => util/mappers/test_unit_mappers_evaluation_context.py} (73%) diff --git a/api/environments/identities/mappers.py b/api/environments/identities/mappers.py deleted file mode 100644 index 8b3d6eb1c3aa..000000000000 --- a/api/environments/identities/mappers.py +++ /dev/null @@ -1,87 +0,0 @@ -from typing import TYPE_CHECKING - -from django.db.models import Prefetch, Q - -from environments.identities.types import IdentityEvaluationContext -from features.models import FeatureState -from features.multivariate.models import MultivariateFeatureStateValue -from features.versioning.versioning_service import get_environment_flags_list -from util.mappers.engine import map_environment_to_evaluation_context - -if TYPE_CHECKING: - from environments.identities.models import Identity - from environments.identities.traits.models import Trait - from environments.models import Environment - from segments.models import Segment - - -__all__ = ("map_identity_to_evaluation_context",) - - -def map_identity_to_evaluation_context( - identity: "Identity", - *, - traits: "list[Trait] | None" = None, - feature_name: str | None = None, - additional_filters: Q | None = None, -) -> IdentityEvaluationContext: - """Build the context for evaluating `identity`'s flags.""" - environment: "Environment" = identity.environment - segments: list["Segment"] = environment.get_segments_from_cache() - - override_filters = Q(identity__isnull=True) - if identity.pk: - # The identity is persisted (non-transient). - # Look for its identity overrides in addition to segment overrides. - override_filters = Q(identity=identity) | override_filters - if additional_filters: - override_filters &= additional_filters - - feature_states = get_environment_flags_list( - environment=environment, - feature_name=feature_name, - additional_filters=override_filters, - additional_select_related_args=["feature_segment__segment"], - additional_prefetch_related_args=[ - Prefetch( - "multivariate_feature_state_values", - queryset=MultivariateFeatureStateValue.objects.select_related( - "multivariate_feature_option" - ), - ) - ], - ) - - environment_defaults: list[FeatureState] = [] - identity_overrides: list[FeatureState] = [] - segment_overrides: dict[int, list[FeatureState]] = {} - mv_fs_values_by_feature_state_id = {} - - for feature_state in feature_states: - mv_fs_values_by_feature_state_id[feature_state.pk] = ( - feature_state.multivariate_feature_state_values.all() - ) - if feature_state.identity_id is not None: - identity_overrides.append(feature_state) - elif (feature_segment := feature_state.feature_segment) is not None: - segment_overrides.setdefault(feature_segment.segment_id, []).append( - feature_state - ) - else: - environment_defaults.append(feature_state) - - return IdentityEvaluationContext( - context=map_environment_to_evaluation_context( - environment=environment, - identity=identity, - traits=traits, - segments=segments, - features=environment_defaults, - segment_overrides=segment_overrides, - identity_overrides=identity_overrides, - mv_fs_values_by_feature_state_id=mv_fs_values_by_feature_state_id, - ), - feature_states_by_id={ - feature_state.pk: feature_state for feature_state in feature_states - }, - ) diff --git a/api/environments/identities/models.py b/api/environments/identities/models.py index 65409c88c6f3..d9145da3f547 100644 --- a/api/environments/identities/models.py +++ b/api/environments/identities/models.py @@ -115,7 +115,7 @@ def get_segments( all_segments = self.environment.project.get_segments_from_cache() segments_by_pk = {segment.pk: segment for segment in all_segments} - context = map_environment_to_evaluation_context( + context, _ = map_environment_to_evaluation_context( identity=self, environment=self.environment, traits=db_traits, diff --git a/api/environments/identities/services.py b/api/environments/identities/services.py index 7506cc295fa1..eb578b11f436 100644 --- a/api/environments/identities/services.py +++ b/api/environments/identities/services.py @@ -3,8 +3,8 @@ from django.db.models import Q from flag_engine.engine import get_evaluation_result -from environments.identities.mappers import map_identity_to_evaluation_context from environments.identities.types import IdentityEvaluation +from util.mappers.engine import map_environment_to_evaluation_context if TYPE_CHECKING: from environments.identities.models import Identity @@ -20,9 +20,12 @@ def evaluate_identity( additional_filters: Q | None = None, ) -> IdentityEvaluation: """Evaluate every flag in `identity`'s environment for that identity.""" - context, feature_states_by_id = map_identity_to_evaluation_context( - identity, + environment: "Environment" = identity.environment + context, feature_states_by_id = map_environment_to_evaluation_context( + environment=environment, + identity=identity, traits=traits, + segments=environment.get_segments_from_cache(), feature_name=feature_name, additional_filters=additional_filters, ) diff --git a/api/integrations/webhook/serializers.py b/api/integrations/webhook/serializers.py index b844ff6b7308..ad644e1fc901 100644 --- a/api/integrations/webhook/serializers.py +++ b/api/integrations/webhook/serializers.py @@ -29,7 +29,7 @@ class Meta: def get_member(self, obj: Segment) -> bool: identity = self.context["identity"] - context = map_environment_to_evaluation_context( + context, _ = map_environment_to_evaluation_context( identity=identity, environment=identity.environment, segments=[obj], diff --git a/api/tests/unit/environments/identities/test_unit_identities_models.py b/api/tests/unit/environments/identities/test_unit_identities_models.py index 05184155b1ca..51fc00a30c69 100644 --- a/api/tests/unit/environments/identities/test_unit_identities_models.py +++ b/api/tests/unit/environments/identities/test_unit_identities_models.py @@ -825,7 +825,7 @@ def test_get_segments__matching_traits__returns_segment_with_expected_queries( # When # we get the matching segments for an identity - with django_assert_num_queries(7): + with django_assert_num_queries(8): segments = identity.get_segments() # Then diff --git a/api/tests/unit/environments/identities/test_unit_identities_views.py b/api/tests/unit/environments/identities/test_unit_identities_views.py index 4d49ff843cec..679279c750e8 100644 --- a/api/tests/unit/environments/identities/test_unit_identities_views.py +++ b/api/tests/unit/environments/identities/test_unit_identities_views.py @@ -1373,10 +1373,10 @@ def test_identity_view_set__get_permissions__returns_expected_permissions(): # @pytest.mark.parametrize( ["use_replica", "is_new_identity", "num_queries"], [ - pytest.param(False, True, 12, id="default_database,new_identity"), - pytest.param(False, False, 7, id="default_database,existing_identity"), - pytest.param(True, True, 12, id="replica_database,new_identity"), - pytest.param(True, False, 9, id="replica_database,existing_identity"), + pytest.param(False, True, 14, id="default_database,new_identity"), + pytest.param(False, False, 9, id="default_database,existing_identity"), + pytest.param(True, True, 14, id="replica_database,new_identity"), + pytest.param(True, False, 11, id="replica_database,existing_identity"), ], ) def test_SDKIdentitiesDeprecated__given_identifier__retrieves_identity( diff --git a/api/tests/unit/util/mappers/test_unit_mappers_engine.py b/api/tests/unit/util/mappers/test_unit_mappers_engine.py index 57d81fd2a7a2..f26bc23656de 100644 --- a/api/tests/unit/util/mappers/test_unit_mappers_engine.py +++ b/api/tests/unit/util/mappers/test_unit_mappers_engine.py @@ -805,14 +805,15 @@ def test_map_environment_to_evaluation_context__no_identity__returns_environment environment: Environment, ) -> None: # Given / When - result = engine.map_environment_to_evaluation_context(environment=environment) + context, _ = engine.map_environment_to_evaluation_context(environment=environment) # Then - assert result == { + assert context == { "environment": { "key": environment.api_key, "name": environment.name, }, + "features": {}, } @@ -821,13 +822,13 @@ def test_map_environment_to_evaluation_context__with_identity__returns_identity_ identity: Identity, ) -> None: # Given / When - result = engine.map_environment_to_evaluation_context( + context, _ = engine.map_environment_to_evaluation_context( environment=environment, identity=identity, ) # Then - assert result == { + assert context == { "environment": { "key": environment.api_key, "name": environment.name, @@ -839,6 +840,7 @@ def test_map_environment_to_evaluation_context__with_identity__returns_identity_ ), "traits": {}, }, + "features": {}, } @@ -848,14 +850,14 @@ def test_map_environment_to_evaluation_context__with_explicit_traits__returns_gi trait: Trait, ) -> None: # Given / When - result = engine.map_environment_to_evaluation_context( + context, _ = engine.map_environment_to_evaluation_context( environment=environment, identity=identity, traits=[trait], ) # Then - assert result == { + assert context == { "environment": { "key": environment.api_key, "name": environment.name, @@ -867,6 +869,7 @@ def test_map_environment_to_evaluation_context__with_explicit_traits__returns_gi ), "traits": {trait.trait_key: trait.trait_value}, }, + "features": {}, } @@ -876,13 +879,13 @@ def test_map_environment_to_evaluation_context__no_explicit_traits__returns_iden trait: Trait, ) -> None: # Given / When - result = engine.map_environment_to_evaluation_context( + context, _ = engine.map_environment_to_evaluation_context( environment=environment, identity=identity, ) # Then - assert result == { + assert context == { "environment": { "key": environment.api_key, "name": environment.name, @@ -894,6 +897,7 @@ def test_map_environment_to_evaluation_context__no_explicit_traits__returns_iden ), "traits": {trait.trait_key: trait.trait_value}, }, + "features": {}, } @@ -902,14 +906,14 @@ def test_map_environment_to_evaluation_context__with_segments__returns_segment_c identity_matching_segment: Segment, ) -> None: # Given / When - result = engine.map_environment_to_evaluation_context( + context, _ = engine.map_environment_to_evaluation_context( environment=environment, segments=[identity_matching_segment], ) # Then segment_key = str(identity_matching_segment.pk) - assert result == { + assert context == { "environment": { "key": environment.api_key, "name": environment.name, @@ -919,6 +923,7 @@ def test_map_environment_to_evaluation_context__with_segments__returns_segment_c identity_matching_segment ), }, + "features": {}, } diff --git a/api/tests/unit/environments/identities/test_unit_identities_mappers.py b/api/tests/unit/util/mappers/test_unit_mappers_evaluation_context.py similarity index 73% rename from api/tests/unit/environments/identities/test_unit_identities_mappers.py rename to api/tests/unit/util/mappers/test_unit_mappers_evaluation_context.py index 7739e6a0082a..ca5c302c6318 100644 --- a/api/tests/unit/environments/identities/test_unit_identities_mappers.py +++ b/api/tests/unit/util/mappers/test_unit_mappers_evaluation_context.py @@ -1,4 +1,3 @@ -from environments.identities.mappers import map_identity_to_evaluation_context from environments.identities.models import Identity from environments.models import Environment from features.models import Feature, FeatureSegment, FeatureState @@ -7,10 +6,11 @@ from util.mappers.engine import ( IDENTITY_OVERRIDES_SEGMENT_KEY, IDENTITY_OVERRIDES_SEGMENT_NAME, + map_environment_to_evaluation_context, ) -def test_map_identity_to_evaluation_context__environment_default__populates_features( +def test_map_environment_to_evaluation_context__environment_default__populates_features( identity: Identity, feature: Feature, ) -> None: @@ -20,7 +20,11 @@ def test_map_identity_to_evaluation_context__environment_default__populates_feat ) # When - context, feature_states_by_id = map_identity_to_evaluation_context(identity) + context, feature_states_by_id = map_environment_to_evaluation_context( + environment=identity.environment, + identity=identity, + segments=identity.environment.get_segments_from_cache(), + ) # Then assert context["features"] == { @@ -38,7 +42,7 @@ def test_map_identity_to_evaluation_context__environment_default__populates_feat assert feature_states_by_id == {feature_state.pk: feature_state} -def test_map_identity_to_evaluation_context__transient_identity__omits_stored_traits( +def test_map_environment_to_evaluation_context__transient_identity__omits_stored_traits( environment: Environment, ) -> None: # Given @@ -46,7 +50,10 @@ def test_map_identity_to_evaluation_context__transient_identity__omits_stored_tr transient_identity = Identity(identifier="transient", environment=environment) # When - context, _ = map_identity_to_evaluation_context(transient_identity) + context, _ = map_environment_to_evaluation_context( + environment=environment, + identity=transient_identity, + ) # Then assert context["identity"] == { @@ -58,7 +65,7 @@ def test_map_identity_to_evaluation_context__transient_identity__omits_stored_tr } -def test_map_identity_to_evaluation_context__segment_override__carries_segment_id( +def test_map_environment_to_evaluation_context__segment_override__carries_segment_id( identity: Identity, feature: Feature, identity_matching_segment: Segment, @@ -78,7 +85,11 @@ def test_map_identity_to_evaluation_context__segment_override__carries_segment_i ) # When - context, _ = map_identity_to_evaluation_context(identity) + context, _ = map_environment_to_evaluation_context( + environment=identity.environment, + identity=identity, + segments=identity.environment.get_segments_from_cache(), + ) # Then segment_context = context["segments"][str(identity_matching_segment.pk)] @@ -95,7 +106,7 @@ def test_map_identity_to_evaluation_context__segment_override__carries_segment_i } -def test_map_identity_to_evaluation_context__identity_override__returns_synthetic_segment( +def test_map_environment_to_evaluation_context__identity_override__returns_synthetic_segment( identity: Identity, feature: Feature, ) -> None: @@ -108,7 +119,11 @@ def test_map_identity_to_evaluation_context__identity_override__returns_syntheti ) # When - context, _ = map_identity_to_evaluation_context(identity) + context, _ = map_environment_to_evaluation_context( + environment=identity.environment, + identity=identity, + segments=identity.environment.get_segments_from_cache(), + ) # Then segment_context = context["segments"][IDENTITY_OVERRIDES_SEGMENT_KEY] @@ -121,7 +136,7 @@ def test_map_identity_to_evaluation_context__identity_override__returns_syntheti assert override_context["metadata"]["identity_id"] == identity.pk -def test_map_identity_to_evaluation_context__multivariate_feature__weights_variants_in_id_order( +def test_map_environment_to_evaluation_context__multivariate_feature__weights_variants_in_id_order( identity: Identity, multivariate_feature: Feature, ) -> None: @@ -131,7 +146,11 @@ def test_map_identity_to_evaluation_context__multivariate_feature__weights_varia ) # When - context, _ = map_identity_to_evaluation_context(identity) + context, _ = map_environment_to_evaluation_context( + environment=identity.environment, + identity=identity, + segments=identity.environment.get_segments_from_cache(), + ) # Then # Core API allocates percentages in id order; the engine allocates in diff --git a/api/util/mappers/engine.py b/api/util/mappers/engine.py index ff860e4e8667..c2089487d844 100644 --- a/api/util/mappers/engine.py +++ b/api/util/mappers/engine.py @@ -1,10 +1,11 @@ -from collections.abc import Iterable, Mapping +from collections.abc import Iterable from itertools import chain from math import inf from operator import attrgetter -from typing import TYPE_CHECKING, Dict, List, Optional, TypeAlias +from typing import TYPE_CHECKING, Dict, List, NamedTuple, Optional, TypeAlias from uuid import UUID +from django.db.models import Prefetch, Q from flag_engine.context import types as engine_types from flag_engine.result import types as engine_result_types from flag_engine.segments.constants import IS_SET @@ -70,9 +71,13 @@ ] FlagResult: TypeAlias = engine_result_types.FlagResult[FeatureEngineMetadata] -MultivariateValuesByFeatureStateId: TypeAlias = Mapping[ - int, "Iterable[MultivariateFeatureStateValue]" -] + +class MappedEvaluationContext(NamedTuple): + context: EvaluationContext + #: The feature states the context was built from, by id. Transitional — see + #: `map_environment_to_evaluation_context`. + feature_states_by_id: "dict[int, FeatureState]" + #: Context key and name of the synthetic segment carrying identity overrides. #: Not a segment id — prefixed so it cannot collide with one. @@ -82,6 +87,7 @@ __all__ = ( "EvaluationContext", "EvaluationResult", + "MappedEvaluationContext", "FlagResult", "SegmentContext", "map_condition_to_segment_condition", @@ -495,24 +501,23 @@ def map_environment_to_evaluation_context( identity: "Identity | None" = None, traits: "Iterable[Trait] | None" = None, segments: "Iterable[Segment] | None" = None, - features: "Iterable[FeatureState] | None" = None, - segment_overrides: "Mapping[int, Iterable[FeatureState]] | None" = None, - identity_overrides: "Iterable[FeatureState] | None" = None, - mv_fs_values_by_feature_state_id: "MultivariateValuesByFeatureStateId | None" = None, -) -> EvaluationContext: + feature_name: str | None = None, + additional_filters: "Q | None" = None, +) -> MappedEvaluationContext: """Map Django ORM models to a flag-engine `EvaluationContext`. - All arguments are expected to be resolved already: this function does not - read from the ORM beyond traversing prefetched relations. See - `environments.identities.mappers` for the query side. - - :param features: environment default feature states, keyed into - `$.features` by feature name. Required for the engine to produce flags - at all — without it `get_evaluation_result` returns segments only. - :param segment_overrides: feature states overriding `features`, by segment id. - :param identity_overrides: feature states overriding `features` for - `identity`. Expressed as a synthetic segment (see - `_map_identity_overrides_to_segment_context`). + Resolves the feature states that are current for `environment` — defaults, + segment overrides, and `identity`'s own overrides — and lays them out as + `$.features` plus the overrides carried on each segment. The engine decides + which of them wins. + + Returns those feature states alongside the context, keyed by id, so that + callers still working in Django rows can map a `FlagResult` back to one via + `metadata.feature_state_id`. That is scaffolding for the migration off + `FeatureState.get_feature_state_value(identity=...)`; once serialisers read + values off the result, only the context is needed. + + :param segments: segments to evaluate. """ context: EvaluationContext = { "environment": { @@ -543,7 +548,20 @@ def map_environment_to_evaluation_context( "traits": identity_traits, } - mv_fs_values_by_feature_state_id = mv_fs_values_by_feature_state_id or {} + ( + feature_states, + features, + identity_overrides, + segment_overrides, + mv_fs_values_by_feature_state_id, + ) = _resolve_feature_states( + environment=environment, + identity=identity, + feature_name=feature_name, + additional_filters=additional_filters, + ) + + # No reading from ORM past this point! def to_feature_context( feature_state: "FeatureState", @@ -559,7 +577,6 @@ def to_feature_context( ) if segments is not None: - segment_overrides = segment_overrides or {} context["segments"] = { str(segment.pk): map_segment_to_segment_context( segment, @@ -583,15 +600,81 @@ def to_feature_context( ) ) - if features is not None: - context["features"] = { - (feature_context := to_feature_context(feature_state))[ - "name" - ]: feature_context - for feature_state in features - } + context["features"] = { + (feature_context := to_feature_context(feature_state))["name"]: feature_context + for feature_state in features + } + + return MappedEvaluationContext( + context=context, + feature_states_by_id={ + feature_state.pk: feature_state for feature_state in feature_states + }, + ) - return context + +class _ResolvedFeatureStates(NamedTuple): + all: list["FeatureState"] + #: Environment defaults, i.e. neither segment- nor identity-scoped. + features: list["FeatureState"] + identity_overrides: list["FeatureState"] + segment_overrides: dict[int, list["FeatureState"]] + mv_fs_values_by_feature_state_id: dict[ + int, "Iterable[MultivariateFeatureStateValue]" + ] + + +def _resolve_feature_states( + *, + environment: "Environment", + identity: "Identity | None", + feature_name: str | None, + additional_filters: "Q | None", +) -> _ResolvedFeatureStates: + """Read the feature states current for `environment`, split by what they override.""" + # Deferred: `environments.models` imports this module's package. + from features.multivariate.models import MultivariateFeatureStateValue + from features.versioning.versioning_service import get_environment_flags_list + + override_filters = Q(identity__isnull=True) + if identity is not None and identity.pk: + # The identity is persisted (non-transient). + # Look for its identity overrides in addition to segment overrides. + override_filters = Q(identity=identity) | override_filters + if additional_filters: + override_filters &= additional_filters + + feature_states = get_environment_flags_list( + environment=environment, + feature_name=feature_name, + additional_filters=override_filters, + additional_select_related_args=["feature_segment__segment"], + additional_prefetch_related_args=[ + Prefetch( + "multivariate_feature_state_values", + queryset=MultivariateFeatureStateValue.objects.select_related( + "multivariate_feature_option" + ), + ) + ], + ) + + resolved = _ResolvedFeatureStates(feature_states, [], [], {}, {}) + + for feature_state in feature_states: + resolved.mv_fs_values_by_feature_state_id[feature_state.pk] = ( + feature_state.multivariate_feature_state_values.all() + ) + if feature_state.identity_id is not None: + resolved.identity_overrides.append(feature_state) + elif (feature_segment := feature_state.feature_segment) is not None: + resolved.segment_overrides.setdefault( + feature_segment.segment_id, [] + ).append(feature_state) + else: + resolved.features.append(feature_state) + + return resolved def map_feature_state_to_feature_context( From 0d3144652bd79fac3b471885f87a431ced27eb2f Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 22 Sep 2026 12:36:35 +0100 Subject: [PATCH 05/30] feat(api): resolve flag values from the engine result Serialisers and integrations re-resolved a feature state's value per identity, hashing multivariate allocation themselves, after the engine had already decided it. Carry the engine's `FlagResult` on each feature state `Identity.get_all_feature_states` returns, so the value and variant travel with the row they came from. Removes `FeatureState.get_multivariate_feature_state_value` and `get_feature_state_value_by_hash_key`, and the `identity` argument to `get_feature_state_value`, which now means only "this state's stored value". `evaluated_value` is the value as evaluated, falling back to the stored one where nothing evaluated it. Edge identities keep their overrides in DynamoDB, so the rows the edge serialiser reads never pass through an evaluation. Those resolve through `features.evaluation.evaluate_feature_state` instead, which asks the engine about a single feature state. --- api/environments/identities/models.py | 18 +++- api/environments/identities/serializers.py | 29 +++-- api/features/evaluation.py | 35 ++++++ api/features/models.py | 74 ++++--------- api/features/serializers.py | 23 ++-- api/integrations/amplitude/amplitude.py | 2 +- api/integrations/heap/heap.py | 2 +- api/integrations/mixpanel/mixpanel.py | 2 +- api/integrations/rudderstack/rudderstack.py | 2 +- api/integrations/segment/segment.py | 2 +- api/tests/evaluation_helpers.py | 30 ++++++ .../identities/test_integration_identities.py | 4 +- .../test_unit_identities_services.py | 21 +++- .../unit/experimentation/test_services.py | 20 ++-- .../features/test_unit_features_models.py | 101 ++++-------------- ...test_unit_versioning_versioning_service.py | 14 ++- .../core/test_unit_workflows_models.py | 10 +- .../test_unit_import_export_export.py | 18 ++-- .../webhook/test_unit_webhook_serializers.py | 10 +- .../test_unit_mappers_evaluation_context.py | 34 ++++++ api/util/mappers/engine.py | 3 +- 21 files changed, 250 insertions(+), 204 deletions(-) create mode 100644 api/features/evaluation.py create mode 100644 api/tests/evaluation_helpers.py diff --git a/api/environments/identities/models.py b/api/environments/identities/models.py index d9145da3f547..d2987afe23c3 100644 --- a/api/environments/identities/models.py +++ b/api/environments/identities/models.py @@ -66,6 +66,10 @@ def get_all_feature_states( 2. Segment - flag overridden for a segment this identity belongs to 3. Environment - default value for the environment + Each returned feature state carries the engine's verdict on + `flag_result`, so that callers read the evaluated value and variant off + the row rather than resolving them again. + :return: (list) flags for an identity with the correct values based on identity / segment priorities """ @@ -78,11 +82,15 @@ def get_all_feature_states( hide_disabled_flags = self.environment.get_hide_disabled_flags() is True - return [ - feature_states_by_id[flag["metadata"]["feature_state_id"]] - for flag in result["flags"].values() - if not (hide_disabled_flags and not flag["enabled"]) - ] + feature_states = [] + for flag in result["flags"].values(): + if hide_disabled_flags and not flag["enabled"]: + continue + feature_state = feature_states_by_id[flag["metadata"]["feature_state_id"]] + feature_state.flag_result = flag + feature_states.append(feature_state) + + return feature_states def get_overridden_feature_states(self) -> dict[int, FeatureState]: """ diff --git a/api/environments/identities/serializers.py b/api/environments/identities/serializers.py index f3e47f42824f..c8da4624a894 100644 --- a/api/environments/identities/serializers.py +++ b/api/environments/identities/serializers.py @@ -7,6 +7,7 @@ from environments.identities.models import Identity from environments.models import Environment from environments.serializers import EnvironmentSerializerFull +from features.evaluation import evaluate_feature_state from features.models import FeatureState from features.serializers import FeatureStateSerializerFull from util.engine_models.features.models import FeatureStateModel @@ -93,22 +94,28 @@ class IdentityAllFeatureStatesSerializer(serializers.Serializer): # type: ignor IdentityAllFeatureStatesMVFeatureStateValueSerializer(many=True) ) - def get_feature_state_value( - self, instance: typing.Union[FeatureState, FeatureStateModel] - ) -> typing.Union[str, int, bool]: - identity = self.context["identity"] - environment_api_key = self.context["environment_api_key"] - - environment = Environment.get_from_cache(environment_api_key) + @property + def _identity_hash_key(self) -> str: + environment = Environment.get_from_cache(self.context["environment_api_key"]) assert environment - hash_key = identity.get_hash_key( + return self.context["identity"].get_hash_key( # type: ignore[no-any-return] environment.use_identity_composite_key_for_hashing ) + def get_feature_state_value( + self, instance: typing.Union[FeatureState, FeatureStateModel] + ) -> typing.Union[str, int, bool]: if isinstance(instance, FeatureState): - return instance.get_feature_state_value_by_hash_key(hash_key) # type: ignore[no-any-return] - - return instance.get_value(hash_key) # type: ignore[no-any-return] + if (flag_result := instance.flag_result) is not None: + return flag_result["value"] # type: ignore[no-any-return] + # An edge identity's overrides live in DynamoDB, so these rows were + # read straight from the ORM and never evaluated. Only multivariate + # allocation is left to resolve. + return evaluate_feature_state( # type: ignore[no-any-return] + instance, self._identity_hash_key + )["value"] + + return instance.get_value(self._identity_hash_key) # type: ignore[no-any-return] def get_overridden_by(self, instance) -> typing.Optional[str]: # type: ignore[no-untyped-def] if getattr(instance, "feature_segment_id", None) is not None: diff --git a/api/features/evaluation.py b/api/features/evaluation.py new file mode 100644 index 000000000000..848134c2f7db --- /dev/null +++ b/api/features/evaluation.py @@ -0,0 +1,35 @@ +from typing import TYPE_CHECKING + +from flag_engine.segments.evaluator import get_flag_result_from_context + +from util.mappers.engine import FlagResult, map_feature_state_to_feature_context + +if TYPE_CHECKING: + from features.models import FeatureState + + +__all__ = ("evaluate_feature_state",) + + +def evaluate_feature_state( + feature_state: "FeatureState", + identity_key: str, +) -> FlagResult: + """Evaluate a single feature state for an identity, ignoring overrides. + + For callers holding one feature state already known to be the right one, + where the only thing left to resolve is multivariate allocation. Prefer + `Identity.get_all_feature_states`, which evaluates the whole environment + and so can apply segment and identity overrides too. + """ + return get_flag_result_from_context( + context={ + "environment": {"key": "", "name": ""}, + "identity": {"identifier": "", "key": identity_key}, + }, + feature_context=map_feature_state_to_feature_context( + feature_state, + mv_fs_values=feature_state.multivariate_feature_state_values.all(), + ), + reason="DEFAULT", + ) diff --git a/api/features/models.py b/api/features/models.py index eaaf75074503..e0f1d2751d3c 100644 --- a/api/features/models.py +++ b/api/features/models.py @@ -25,7 +25,6 @@ LifecycleModelMixin, hook, ) -from flag_engine.utils.hashing import get_hashed_percentage_for_object_ids from ordered_model.models import OrderedModelBase # type: ignore[import-untyped] from simple_history.models import HistoricalRecords # type: ignore[import-untyped] @@ -87,6 +86,7 @@ if typing.TYPE_CHECKING: from environments.identities.models import Identity from environments.models import Environment + from util.mappers.engine import FlagResult class Feature( # type: ignore[django-manager-missing] @@ -525,6 +525,13 @@ class FeatureState( # Multivariate bucketing seed, kept stable across recreation (#7913) — see mv_hashing_seed. mv_hashing_salt = models.IntegerField(null=True, blank=True, default=None) + #: How flag-engine evaluated this feature state, set by + #: `Identity.get_all_feature_states`. Carried on the instance so that the + #: value and variant travel with the row they were resolved from, rather + #: than being recomputed per serialiser. `None` where the state was read + #: outside an evaluation, in which case only its stored value is meaningful. + flag_result: "FlagResult | None" = None + class Meta: ordering = ["id"] @@ -726,30 +733,23 @@ def generate_feature_state_value_data(self, value): # type: ignore[no-untyped-d self.get_feature_state_key_name(fsv_type): value, } - def get_feature_state_value_by_hash_key( - self, - identity_hash_key: typing.Union[str, int] = None, # type: ignore[assignment] - ) -> typing.Any: - feature_state_value = ( - self.get_multivariate_feature_state_value(identity_hash_key) # type: ignore[arg-type] - if self.feature.type == MULTIVARIATE and identity_hash_key - else getattr(self, "feature_state_value", None) - ) + def get_feature_state_value(self) -> typing.Any: + """This state's stored value, before any evaluation. - # return the value of the feature state value only if the feature state - # has a related feature state value. Note that we use getattr rather than - # hasattr as we want to return None if no feature state value exists. + Multivariate allocation is flag-engine's job: read `flag_result` for + the value an identity actually sees. + """ + # Use getattr rather than hasattr, to return None where the feature + # state has no related feature state value. + feature_state_value = getattr(self, "feature_state_value", None) return feature_state_value and feature_state_value.value - def get_feature_state_value(self, identity: "Identity" = None) -> typing.Any: # type: ignore[assignment] - identity_hash_key = ( - identity.get_hash_key( - identity.environment.use_identity_composite_key_for_hashing - ) - if identity - else None - ) - return self.get_feature_state_value_by_hash_key(identity_hash_key) # type: ignore[arg-type] + @property + def evaluated_value(self) -> typing.Any: + """The value as evaluated for an identity, where one was evaluated.""" + if (flag_result := self.flag_result) is not None: + return flag_result["value"] + return self.get_feature_state_value() def get_feature_state_value_defaults(self) -> dict[str, typing.Any]: if ( @@ -805,36 +805,6 @@ def get_superseded_live_feature_state(self) -> typing.Optional["FeatureState"]: ) return superseded - def get_multivariate_feature_state_value( - self, identity_hash_key: str - ) -> AbstractBaseFeatureValueModel: - # the multivariate_feature_state_values should be prefetched at this point - # so we just convert them to a list and use python operations from here to - # avoid further queries to the DB - mv_options = list(self.multivariate_feature_state_values.all()) - - percentage_value = get_hashed_percentage_for_object_ids( - [self.mv_hashing_seed, identity_hash_key] - ) - - # Iterate over the mv options in order of id (so we get the same value each - # time) to determine the correct value to return to the identity based on - # the percentage allocations of the multivariate options. This gives us a - # way to ensure that the same value is returned every time we use the same - # percentage value. - start_percentage = 0 - for mv_option in sorted(mv_options, key=lambda o: o.id): - limit = getattr(mv_option, "percentage_allocation", 0) + start_percentage - if start_percentage <= percentage_value < limit: - return mv_option.multivariate_feature_option - - start_percentage = limit - - # if none of the percentage allocations match the percentage value we got for - # the identity, then we just return the default feature state value (or None - # if there isn't one - although this should never happen) - return getattr(self, "feature_state_value", None) # type: ignore[return-value] - @hook(BEFORE_CREATE) @hook(BEFORE_SAVE, when="deleted", is_not=True) def check_for_duplicate_feature_state(self): # type: ignore[no-untyped-def] diff --git a/api/features/serializers.py b/api/features/serializers.py index ebd4372e571e..f2ab64654397 100644 --- a/api/features/serializers.py +++ b/api/features/serializers.py @@ -50,7 +50,7 @@ DeleteBeforeUpdateWritableNestedModelSerializer, ) -from .constants import CONTROL_VARIANT_KEY, INTERSECTION, UNION +from .constants import INTERSECTION, UNION from .feature_lifecycle.types import LifecycleStage from .feature_segments.limits import ( SEGMENT_OVERRIDE_LIMIT_EXCEEDED_MESSAGE, @@ -61,7 +61,6 @@ ) from .feature_types import FEATURE_TYPE_CHOICES, MULTIVARIATE from .models import Feature, FeatureState -from .multivariate.models import MultivariateFeatureOption from .multivariate.serializers import NestedMultivariateFeatureOptionSerializer @@ -81,7 +80,7 @@ class Meta: @extend_schema_field({"type": ["string", "integer", "boolean"], "nullable": True}) def get_feature_state_value(self, obj): # type: ignore[no-untyped-def] - return obj.get_feature_state_value(identity=self.context.get("identity")) + return obj.evaluated_value class FeatureQuerySerializer(serializers.Serializer): # type: ignore[type-arg] @@ -589,7 +588,7 @@ class Meta: } ) def get_feature_state_value(self, obj): # type: ignore[no-untyped-def] - return obj.get_feature_state_value(identity=self.context.get("identity")) + return obj.evaluated_value class FeatureOwnerInputSerializer(UserIdsSerializer): @@ -656,17 +655,11 @@ class Meta(SDKFeatureStateSerializer.Meta): @extend_schema_field({"type": "string", "nullable": True}) def get_variant(self, obj: FeatureState) -> str | None: - if obj.feature.type != MULTIVARIATE: + if obj.feature.type != MULTIVARIATE or obj.flag_result is None: return None - identity = self.context["identity"] - value_object = obj.get_multivariate_feature_state_value( - identity.get_hash_key( - identity.environment.use_identity_composite_key_for_hashing - ) - ) - if isinstance(value_object, MultivariateFeatureOption): - return value_object.key - return CONTROL_VARIANT_KEY + # The engine reports the control bucket as `CONTROL_VARIANT_KEY`, and + # an unkeyed variant as None, which is what this returned before. + return obj.flag_result["variant"] @cached_property def _build_metadata(self) -> Callable[[FeatureState], dict[str, Any] | None]: @@ -710,7 +703,7 @@ class Meta: } ) def get_feature_state_value(self, obj): # type: ignore[no-untyped-def] - return obj.get_feature_state_value(identity=self.context.get("identity")) + return obj.evaluated_value def save(self, **kwargs): # type: ignore[no-untyped-def] try: diff --git a/api/integrations/amplitude/amplitude.py b/api/integrations/amplitude/amplitude.py index 7bf758e8c45e..e2f164726f63 100644 --- a/api/integrations/amplitude/amplitude.py +++ b/api/integrations/amplitude/amplitude.py @@ -38,7 +38,7 @@ def generate_user_data( feature_properties = {} for feature_state in feature_states: - value = feature_state.get_feature_state_value(identity=identity) + value = feature_state.evaluated_value feature_properties[feature_state.feature.name] = ( value if (feature_state.enabled and value is not None) diff --git a/api/integrations/heap/heap.py b/api/integrations/heap/heap.py index 5b15d4ca1c17..1e72e10157fb 100644 --- a/api/integrations/heap/heap.py +++ b/api/integrations/heap/heap.py @@ -33,7 +33,7 @@ def generate_user_data( feature_properties = {} for feature_state in feature_states: - value = feature_state.get_feature_state_value(identity=identity) + value = feature_state.evaluated_value feature_properties[feature_state.feature.name] = ( value if (feature_state.enabled and value is not None) diff --git a/api/integrations/mixpanel/mixpanel.py b/api/integrations/mixpanel/mixpanel.py index cc2820aaa53c..eea1853348c8 100644 --- a/api/integrations/mixpanel/mixpanel.py +++ b/api/integrations/mixpanel/mixpanel.py @@ -45,7 +45,7 @@ def generate_user_data( feature_properties = {} for feature_state in feature_states: - value = feature_state.get_feature_state_value(identity=identity) + value = feature_state.evaluated_value feature_properties[feature_state.feature.name] = ( value if (feature_state.enabled and value) else feature_state.enabled ) diff --git a/api/integrations/rudderstack/rudderstack.py b/api/integrations/rudderstack/rudderstack.py index 1c6a248ca8c7..e5b93c978161 100644 --- a/api/integrations/rudderstack/rudderstack.py +++ b/api/integrations/rudderstack/rudderstack.py @@ -30,7 +30,7 @@ def generate_user_data( feature_properties = {} for feature_state in feature_states: - value = feature_state.get_feature_state_value(identity=identity) + value = feature_state.evaluated_value feature_properties[feature_state.feature.name] = ( value if (feature_state.enabled and value) else feature_state.enabled ) diff --git a/api/integrations/segment/segment.py b/api/integrations/segment/segment.py index a2ba93ce1863..e1d03f2da5f7 100644 --- a/api/integrations/segment/segment.py +++ b/api/integrations/segment/segment.py @@ -31,7 +31,7 @@ def generate_user_data( feature_properties = {} for feature_state in feature_states: - value = feature_state.get_feature_state_value(identity=identity) + value = feature_state.evaluated_value feature_properties[feature_state.feature.name] = ( value if (feature_state.enabled and value is not None) diff --git a/api/tests/evaluation_helpers.py b/api/tests/evaluation_helpers.py new file mode 100644 index 000000000000..3fb1d272132e --- /dev/null +++ b/api/tests/evaluation_helpers.py @@ -0,0 +1,30 @@ +"""Helpers for asserting how flag-engine evaluates a feature state. + +Multivariate allocation moved into the engine, so tests that used to probe it +via `FeatureState.get_multivariate_feature_state_value(key)` ask the engine the +same question here, for a bare identity key rather than a persisted identity. +""" + +from typing import TYPE_CHECKING, Any, NamedTuple + +from features.evaluation import evaluate_feature_state as _evaluate_feature_state + +if TYPE_CHECKING: + from features.models import FeatureState + + +__all__ = ("EvaluatedFlag", "evaluate_feature_state") + + +class EvaluatedFlag(NamedTuple): + value: Any + variant: str | None + + +def evaluate_feature_state( + feature_state: "FeatureState", + identity_key: str | int, +) -> EvaluatedFlag: + """Evaluate `feature_state` as the engine would, for `identity_key`.""" + flag_result = _evaluate_feature_state(feature_state, str(identity_key)) + return EvaluatedFlag(flag_result["value"], flag_result["variant"]) diff --git a/api/tests/integration/environments/identities/test_integration_identities.py b/api/tests/integration/environments/identities/test_integration_identities.py index eaa98c8942ab..058ae3ddc2a6 100644 --- a/api/tests/integration/environments/identities/test_integration_identities.py +++ b/api/tests/integration/environments/identities/test_integration_identities.py @@ -36,7 +36,7 @@ (total_variance_percentage + 1, control_value), ), ) -@mock.patch("features.models.get_hashed_percentage_for_object_ids") +@mock.patch("flag_engine.segments.evaluator.get_hashed_percentage_for_object_ids") def test_get_feature_states_for_identity__mv_percentage_allocation__returns_correct_value( # type: ignore[no-untyped-def] mock_get_hashed_percentage_value, hashed_percentage, @@ -159,7 +159,7 @@ def test_get_feature_states_for_identity__mv_percentage_allocation__returns_corr (total_variance_percentage + 1, "control"), ), ) -@mock.patch("features.models.get_hashed_percentage_for_object_ids") +@mock.patch("flag_engine.segments.evaluator.get_hashed_percentage_for_object_ids") def test_get_feature_states_for_identity__mv_allocation__returns_variant( # type: ignore[no-untyped-def] mock_get_hashed_percentage_value, hashed_percentage, diff --git a/api/tests/unit/environments/identities/test_unit_identities_services.py b/api/tests/unit/environments/identities/test_unit_identities_services.py index 82f949625af7..e26dc659965e 100644 --- a/api/tests/unit/environments/identities/test_unit_identities_services.py +++ b/api/tests/unit/environments/identities/test_unit_identities_services.py @@ -119,17 +119,28 @@ def test_evaluate_identity__multivariate_feature__matches_legacy_bucketing( hash_key = identity.get_hash_key( identity.environment.use_identity_composite_key_for_hashing ) - expected_value = feature_state.get_feature_state_value_by_hash_key(hash_key) + + # The allocation Core API performed before the engine took it over, kept + # here as an oracle independent of the code under test. + percentage_value = get_hashed_percentage_for_object_ids( + [feature_state.mv_hashing_seed, hash_key] + ) + expected_value = feature_state.get_feature_state_value() + start_percentage = 0.0 + for mv_value in sorted( + feature_state.multivariate_feature_state_values.all(), key=lambda o: o.id + ): + limit = mv_value.percentage_allocation + start_percentage + if start_percentage <= percentage_value < limit: + expected_value = mv_value.multivariate_feature_option.value + break + start_percentage = limit # When result, _ = evaluate_identity(identity) # Then assert result["flags"][multivariate_feature.name]["value"] == expected_value - # And the seed the engine used is the lineage constant, not the row id. - assert get_hashed_percentage_for_object_ids( - [str(feature_state.mv_hashing_seed), hash_key] - ) == get_hashed_percentage_for_object_ids([feature_state.mv_hashing_seed, hash_key]) def test_evaluate_identity__multivariate_feature__returns_variant_key( diff --git a/api/tests/unit/experimentation/test_services.py b/api/tests/unit/experimentation/test_services.py index 1932355a1381..4742d9619652 100644 --- a/api/tests/unit/experimentation/test_services.py +++ b/api/tests/unit/experimentation/test_services.py @@ -66,6 +66,7 @@ from organisations.models import Organisation from projects.models import Project from segments.models import Condition, Segment, SegmentRule +from tests.evaluation_helpers import evaluate_feature_state from tests.unit.experimentation.conftest import RolloutSpecFactory from users.models import FFAdminUser from util.mappers import map_environment_to_environment_document @@ -2572,6 +2573,11 @@ def test_apply_experiment_rollout__reapplied_under_v2__keeps_variant_assignment( ) -> None: # Given a running experiment whose rollout splits two variants 50/50 option_a, option_b, _ = multivariate_options + # The fixture derives an option's value from its percentage, so two of them + # share a value. Key them so a variant identifies which option won. + for index, option in enumerate(multivariate_options): + option.key = f"variant-{index}" + option.save() experiment = Experiment.objects.create( environment=environment_v2_versioning, feature=multivariate_feature, @@ -2592,7 +2598,7 @@ def test_apply_experiment_rollout__reapplied_under_v2__keeps_variant_assignment( ) identity_hash_keys = [f"identity-{i}" for i in range(50)] - def variant_assignment() -> dict[str, int]: + def variant_assignment() -> dict[str, str]: override = ( FeatureState.objects.get_live_feature_states( environment=experiment.environment, @@ -2607,12 +2613,12 @@ def variant_assignment() -> dict[str, int]: ) .latest("id") ) - assignment: dict[str, int] = {} + assignment: dict[str, str] = {} for key in identity_hash_keys: - option = override.get_multivariate_feature_state_value(key) - # The 50/50 split allocates 100%, so every identity lands on an option. - assert isinstance(option, MultivariateFeatureOption) - assignment[key] = option.id + variant = evaluate_feature_state(override, key).variant + # The 50/50 split allocates 100%, so every identity lands on a variant. + assert variant is not None + assignment[key] = variant return assignment # When the rollout is applied, then re-applied unchanged (e.g. tuned while @@ -3511,7 +3517,7 @@ def _identity_flag_value( if feature_state.feature_id == feature.id ] return ( - feature_state.get_feature_state_value(identity=identity), + feature_state.evaluated_value, ( feature_state.feature_segment.segment_id if feature_state.feature_segment diff --git a/api/tests/unit/features/test_unit_features_models.py b/api/tests/unit/features/test_unit_features_models.py index 40325673c236..e44338cb8e94 100644 --- a/api/tests/unit/features/test_unit_features_models.py +++ b/api/tests/unit/features/test_unit_features_models.py @@ -28,6 +28,7 @@ from projects.models import Project from projects.tags.models import Tag from segments.models import Segment +from tests.evaluation_helpers import evaluate_feature_state from users.models import FFAdminUser now = timezone.now() @@ -612,8 +613,8 @@ def test_feature_state_type__feature_segment_state__returns_feature_segment( @pytest.mark.parametrize("hashed_percentage", (0.0, 30.0, 50.0, 80.0, 99.9999)) -@mock.patch("features.models.get_hashed_percentage_for_object_ids") -def test_get_multivariate_feature_state_value__with_identity__returns_correct_value( # type: ignore[no-untyped-def] +@mock.patch("flag_engine.segments.evaluator.get_hashed_percentage_for_object_ids") +def test_evaluated_feature_state__multivariate_feature__returns_variant_value( # type: ignore[no-untyped-def] mock_get_hashed_percentage, hashed_percentage, multivariate_feature, @@ -630,21 +631,17 @@ def test_get_multivariate_feature_state_value__with_identity__returns_correct_va ) # When - multivariate_value = feature_state.get_multivariate_feature_state_value( - identity_hash_key=identity.get_hash_key() - ) + evaluated = evaluate_feature_state(feature_state, identity.get_hash_key()) # Then - # we get a multivariate value - assert multivariate_value - - # and that value is not the control (since the fixture includes values that span + # we get a multivariate value, not the control (the fixture's options span # the entire 100%) - assert multivariate_value.value != multivariate_value.initial_value + assert evaluated.value + assert evaluated.value != feature_state.get_feature_state_value() -@mock.patch("features.models.get_hashed_percentage_for_object_ids") -def test_get_multivariate_feature_state_value__no_mv_hashing_salt__seeds_hash_with_id( # type: ignore[no-untyped-def] +@mock.patch("flag_engine.segments.evaluator.get_hashed_percentage_for_object_ids") +def test_evaluated_feature_state__no_mv_hashing_salt__seeds_hash_with_id( # type: ignore[no-untyped-def] mock_get_hashed_percentage, multivariate_feature, environment, @@ -662,18 +659,16 @@ def test_get_multivariate_feature_state_value__no_mv_hashing_salt__seeds_hash_wi identity_hash_key = identity.get_hash_key() # When - feature_state.get_multivariate_feature_state_value( - identity_hash_key=identity_hash_key - ) + evaluate_feature_state(feature_state, identity_hash_key) # Then the feature state id seeds the hash mock_get_hashed_percentage.assert_called_once_with( - [feature_state.id, identity_hash_key] + [str(feature_state.id), identity_hash_key] ) -@mock.patch("features.models.get_hashed_percentage_for_object_ids") -def test_get_multivariate_feature_state_value__mv_hashing_salt_set__seeds_hash_with_salt( # type: ignore[no-untyped-def] +@mock.patch("flag_engine.segments.evaluator.get_hashed_percentage_for_object_ids") +def test_evaluated_feature_state__mv_hashing_salt_set__seeds_hash_with_salt( # type: ignore[no-untyped-def] mock_get_hashed_percentage, multivariate_feature, environment, @@ -691,12 +686,10 @@ def test_get_multivariate_feature_state_value__mv_hashing_salt_set__seeds_hash_w identity_hash_key = identity.get_hash_key() # When - feature_state.get_multivariate_feature_state_value( - identity_hash_key=identity_hash_key - ) + evaluate_feature_state(feature_state, identity_hash_key) # Then the salt seeds the hash instead of the feature state id - mock_get_hashed_percentage.assert_called_once_with([999, identity_hash_key]) + mock_get_hashed_percentage.assert_called_once_with(["999", identity_hash_key]) def test_feature_state_clone__multivariate_feature__keeps_variant_bucketing_stable( @@ -706,6 +699,11 @@ def test_feature_state_clone__multivariate_feature__keeps_variant_bucketing_stab ) -> None: # Given the environment-default feature state for a multivariate feature, and # the variant each of a range of identities is currently bucketed into + # The fixture derives an option's value from its percentage, so two of them + # share a value. Key them so a variant identifies which option won. + for index, option in enumerate(multivariate_feature.multivariate_options.all()): + option.key = f"variant-{index}" + option.save() feature_state = FeatureState.objects.get( environment=environment, feature=multivariate_feature, @@ -714,7 +712,7 @@ def test_feature_state_clone__multivariate_feature__keeps_variant_bucketing_stab ) identity_hash_keys = [f"identity-{i}" for i in range(50)] original_assignment = { - key: feature_state.get_multivariate_feature_state_value(key).id + key: evaluate_feature_state(feature_state, key).variant for key in identity_hash_keys } @@ -728,7 +726,7 @@ def test_feature_state_clone__multivariate_feature__keeps_variant_bucketing_stab # and every identity stays in the same variant as before cloned_assignment = { - key: cloned_feature_state.get_multivariate_feature_state_value(key).id + key: evaluate_feature_state(cloned_feature_state, key).variant for key in identity_hash_keys } assert cloned_assignment == original_assignment @@ -852,61 +850,6 @@ def test_feature_state_create__new_segment_override_under_v2__no_salt_inherited( assert feature_state.mv_hashing_salt is None -@mock.patch.object(FeatureState, "get_multivariate_feature_state_value") -def test_get_feature_state_value__multivariate_feature__returns_mv_value( # type: ignore[no-untyped-def] - mock_get_mv_feature_state_value, environment, multivariate_feature, identity -): - # Given - value = "value" - mock_mv_feature_state_value = mock.MagicMock(value=value) - mock_get_mv_feature_state_value.return_value = mock_mv_feature_state_value - - environment.use_identity_composite_key_for_hashing = False - environment.save() - - feature_state = FeatureState.objects.get( - environment=environment, - feature=multivariate_feature, - identity=None, - feature_segment=None, - ) - - # When - feature_state_value = feature_state.get_feature_state_value(identity=identity) - - # Then - # the correct value is returned - assert feature_state_value == value - # and the correct call is made to get the multivariate feature state value - mock_get_mv_feature_state_value.assert_called_once_with(str(identity.id)) - - -@mock.patch.object(FeatureState, "get_multivariate_feature_state_value") -def test_get_feature_state_value__multivariate_v2_evaluation__uses_composite_key( # type: ignore[no-untyped-def] - mock_get_mv_feature_state_value, environment, multivariate_feature, identity -): - # Given - value = "value" - mock_mv_feature_state_value = mock.MagicMock(value=value) - mock_get_mv_feature_state_value.return_value = mock_mv_feature_state_value - - feature_state = FeatureState.objects.get( - environment=environment, - feature=multivariate_feature, - identity=None, - feature_segment=None, - ) - - # When - feature_state_value = feature_state.get_feature_state_value(identity=identity) - - # Then - # the correct value is returned - assert feature_state_value == value - # and the correct call is made to get the multivariate feature state value - mock_get_mv_feature_state_value.assert_called_once_with(identity.composite_key) - - @pytest.mark.parametrize( "feature_state_version_generator", ( diff --git a/api/tests/unit/features/versioning/test_unit_versioning_versioning_service.py b/api/tests/unit/features/versioning/test_unit_versioning_versioning_service.py index f190e7d86741..279aaa92acfa 100644 --- a/api/tests/unit/features/versioning/test_unit_versioning_versioning_service.py +++ b/api/tests/unit/features/versioning/test_unit_versioning_versioning_service.py @@ -32,6 +32,7 @@ ) from projects.models import Project from segments.models import Segment +from tests.evaluation_helpers import evaluate_feature_state from users.models import FFAdminUser @@ -1047,6 +1048,11 @@ def test_update_flag__v2_versioning_multivariate_weight_increase__keeps_enrolled # variant each of a range of identities is bucketed into author = AuthorData(user=admin_user) option_a, option_b, option_c = multivariate_options + # The fixture derives an option's value from its percentage, so two of them + # share a value. Key them so a variant identifies which option won. + for index, option in enumerate(multivariate_options): + option.key = f"variant-{index}" + option.save() feature_state = update_flag( environment_v2_versioning, multivariate_feature, @@ -1064,7 +1070,7 @@ def test_update_flag__v2_versioning_multivariate_weight_increase__keeps_enrolled ) identity_hash_keys = [f"identity-{i}" for i in range(100)] original_assignment = { - key: feature_state.get_multivariate_feature_state_value(key).pk + key: evaluate_feature_state(feature_state, key).variant for key in identity_hash_keys } @@ -1088,7 +1094,7 @@ def test_update_flag__v2_versioning_multivariate_weight_increase__keeps_enrolled # Then identities already in the grown variant stay in it, and the only # movement is from the shrunk variant into the grown one new_assignment = { - key: new_feature_state.get_multivariate_feature_state_value(key).pk + key: evaluate_feature_state(new_feature_state, key).variant for key in identity_hash_keys } movers = { @@ -1097,8 +1103,8 @@ def test_update_flag__v2_versioning_multivariate_weight_increase__keeps_enrolled if new_assignment[key] != original_assignment[key] } assert movers - assert all(original_assignment[key] == option_b.id for key in movers) - assert all(new_assignment[key] == option_a.id for key in movers) + assert all(original_assignment[key] == option_b.key for key in movers) + assert all(new_assignment[key] == option_a.key for key in movers) assert all( new_assignment[key] == original_assignment[key] for key in identity_hash_keys diff --git a/api/tests/unit/features/workflows/core/test_unit_workflows_models.py b/api/tests/unit/features/workflows/core/test_unit_workflows_models.py index d4fb731421e4..5e2121ae35d7 100644 --- a/api/tests/unit/features/workflows/core/test_unit_workflows_models.py +++ b/api/tests/unit/features/workflows/core/test_unit_workflows_models.py @@ -48,6 +48,7 @@ # TODO: Delete alias as per https://github.com/Flagsmith/flagsmith/issues/7818 from segments.types import SegmentRule as SegmentRuleType +from tests.evaluation_helpers import evaluate_feature_state from users.models import FFAdminUser now = timezone.now() @@ -1245,6 +1246,11 @@ def test_change_request_commit__v1_multivariate_feature__keeps_variant_bucketing ) -> None: # Given the current live environment-default feature state of a multivariate # feature, and the variant each of a range of identities is bucketed into + # The fixture derives an option's value from its percentage, so two of them + # share a value. Key them so a variant identifies which option won. + for index, option in enumerate(multivariate_feature.multivariate_options.all()): + option.key = f"variant-{index}" + option.save() live_feature_state = FeatureState.objects.get( environment=environment, feature=multivariate_feature, @@ -1253,7 +1259,7 @@ def test_change_request_commit__v1_multivariate_feature__keeps_variant_bucketing ) identity_hash_keys = [f"identity-{i}" for i in range(50)] original_assignment = { - key: live_feature_state.get_multivariate_feature_state_value(key).pk + key: evaluate_feature_state(live_feature_state, key).variant for key in identity_hash_keys } @@ -1286,7 +1292,7 @@ def test_change_request_commit__v1_multivariate_feature__keeps_variant_bucketing # and every identity stays in the same variant as before the commit new_assignment = { - key: new_live_feature_state.get_multivariate_feature_state_value(key).pk + key: evaluate_feature_state(new_live_feature_state, key).variant for key in identity_hash_keys } assert new_assignment == original_assignment diff --git a/api/tests/unit/import_export/test_unit_import_export_export.py b/api/tests/unit/import_export/test_unit_import_export_export.py index 157833be68f0..96bc0989809c 100644 --- a/api/tests/unit/import_export/test_unit_import_export_export.py +++ b/api/tests/unit/import_export/test_unit_import_export_export.py @@ -544,32 +544,26 @@ def test_export_edge_identities__identities_with_overrides_and_traits__exports_a actual_mv_override = all_feature_states[0] assert str(actual_mv_override.uuid) == mv_override_fs_uuid - assert ( - actual_mv_override.get_feature_state_value(identity=identity) - == mv_option.string_value - ) + assert actual_mv_override.evaluated_value == mv_option.string_value actual_int_override = all_feature_states[1] assert str(actual_int_override.uuid) == int_override_fs_uuid - assert actual_int_override.get_feature_state_value(identity=identity) == 123 + assert actual_int_override.evaluated_value == 123 actual_float_override = all_feature_states[2] assert str(actual_float_override.uuid) == float_override_fs_uuid - assert actual_float_override.get_feature_state_value(identity=identity) == "123.123" + assert actual_float_override.evaluated_value == "123.123" actual_bool_override = all_feature_states[3] assert str(actual_bool_override.uuid) == bool_override_fs_uuid - assert actual_bool_override.get_feature_state_value(identity=identity) is False + assert actual_bool_override.evaluated_value is False actual_string_fs = all_feature_states[4] - assert actual_string_fs.get_feature_state_value(identity=identity) == "foo" + assert actual_string_fs.evaluated_value == "foo" assert actual_string_fs.identity is None override_without_mv_option = all_feature_states[5] - assert ( - override_without_mv_option.get_feature_state_value(identity=identity) - == "control" - ) + assert override_without_mv_option.evaluated_value == "control" assert override_without_mv_option.identity == identity override_with_missing_attributes = all_feature_states[6] diff --git a/api/tests/unit/integrations/webhook/test_unit_webhook_serializers.py b/api/tests/unit/integrations/webhook/test_unit_webhook_serializers.py index 99fbe3a226a2..354caf747a99 100644 --- a/api/tests/unit/integrations/webhook/test_unit_webhook_serializers.py +++ b/api/tests/unit/integrations/webhook/test_unit_webhook_serializers.py @@ -1,3 +1,4 @@ +from features.evaluation import evaluate_feature_state as evaluate_flag from features.models import FeatureState from integrations.webhook.serializers import ( IntegrationFeatureStateSerializer, @@ -10,10 +11,13 @@ def test_integration_feature_state_serializer__multivariate_feature__returns_cor ): # Given mv_option = multivariate_feature.multivariate_options.first() - mocker.patch.object( - FeatureState, "get_multivariate_feature_state_value", return_value=mv_option - ) feature_state = FeatureState.objects.filter(feature=multivariate_feature).first() + # Bucket the identity into the first option, whatever its weight. + mocker.patch( + "flag_engine.segments.evaluator.get_hashed_percentage_for_object_ids", + return_value=0.0, + ) + feature_state.flag_result = evaluate_flag(feature_state, identity.get_hash_key()) # When serializer = IntegrationFeatureStateSerializer( diff --git a/api/tests/unit/util/mappers/test_unit_mappers_evaluation_context.py b/api/tests/unit/util/mappers/test_unit_mappers_evaluation_context.py index ca5c302c6318..1b21c6aa9baa 100644 --- a/api/tests/unit/util/mappers/test_unit_mappers_evaluation_context.py +++ b/api/tests/unit/util/mappers/test_unit_mappers_evaluation_context.py @@ -1,3 +1,5 @@ +import pytest + from environments.identities.models import Identity from environments.models import Environment from features.models import Feature, FeatureSegment, FeatureState @@ -166,3 +168,35 @@ def test_map_environment_to_evaluation_context__multivariate_feature__weights_va } for index, mv_value in enumerate(mv_values) ] + + +@pytest.mark.parametrize( + "use_identity_composite_key_for_hashing", + [True, False], +) +def test_map_environment_to_evaluation_context__hashing_setting__sets_matching_identity_key( + identity: Identity, + use_identity_composite_key_for_hashing: bool, +) -> None: + """The identity key seeds multivariate allocation, so which one is used matters.""" + # Given + environment = identity.environment + environment.use_identity_composite_key_for_hashing = ( + use_identity_composite_key_for_hashing + ) + environment.save() + + # When + context, _ = map_environment_to_evaluation_context( + environment=environment, + identity=identity, + ) + + # Then + identity_context = context["identity"] + assert identity_context + assert identity_context["key"] == ( + identity.composite_key + if use_identity_composite_key_for_hashing + else str(identity.pk) + ) diff --git a/api/util/mappers/engine.py b/api/util/mappers/engine.py index c2089487d844..3d895c348a4a 100644 --- a/api/util/mappers/engine.py +++ b/api/util/mappers/engine.py @@ -508,8 +508,7 @@ def map_environment_to_evaluation_context( Resolves the feature states that are current for `environment` — defaults, segment overrides, and `identity`'s own overrides — and lays them out as - `$.features` plus the overrides carried on each segment. The engine decides - which of them wins. + `$.features` plus the overrides carried on each segment. Returns those feature states alongside the context, keyed by id, so that callers still working in Django rows can map a `FlagResult` back to one via From 9407db701207d946e1f9e68d4cfecf3f1013054d Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 22 Sep 2026 16:39:13 +0100 Subject: [PATCH 06/30] refactor(api): evaluate edge identity flags through the engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The edge serialiser resolved a feature state on its own, through a helper calling `get_flag_result_from_context` — which flag-engine does not export. Evaluate in `EdgeIdentity.get_all_feature_states` instead, via `get_evaluation_result`, so its rows carry `flag_result` like the Postgres ones and the serialiser has nothing left to resolve. Removes `features.evaluation`, and with it the only use of a private engine API. Drops the mocks of the engine's internal hashing where a test can say what it means instead: the multivariate seed is an assertion about the mapped feature context, and a serialiser test can set `flag_result` itself. The two left are end-to-end tests of allocation boundaries, where the hash is the input being varied. --- api/edge_api/identities/models.py | 80 +++++++++++++------ api/environments/identities/serializers.py | 30 +++---- api/features/evaluation.py | 35 -------- api/tests/evaluation_helpers.py | 17 +++- .../test_edge_api_identities_serializers.py | 26 ++++-- .../features/test_unit_features_models.py | 80 ------------------- .../webhook/test_unit_webhook_serializers.py | 17 ++-- .../test_unit_mappers_evaluation_context.py | 37 +++++++++ 8 files changed, 147 insertions(+), 175 deletions(-) delete mode 100644 api/features/evaluation.py diff --git a/api/edge_api/identities/models.py b/api/edge_api/identities/models.py index bf36e0f3eca4..f102cc5a0fdc 100644 --- a/api/edge_api/identities/models.py +++ b/api/edge_api/identities/models.py @@ -6,6 +6,7 @@ from django.conf import settings from django.db.models import Prefetch, Q from django.utils import timezone +from flag_engine.engine import get_evaluation_result from api_keys.user import APIKeyUser from edge_api.identities.tasks import ( @@ -24,6 +25,10 @@ from util.engine_models.features.models import FeatureStateModel from util.engine_models.identities.models import IdentityFeaturesList, IdentityModel from util.mappers import map_engine_identity_to_identity_document +from util.mappers.engine import ( + EvaluationContext, + map_feature_state_to_feature_context, +) class EdgeIdentity: @@ -103,32 +108,59 @@ def get_all_feature_states( | Q(feature_segment__isnull=True) ) - feature_states: dict[str, FeatureState | FeatureStateModel] = ( - get_environment_flags_dict( # type: ignore[assignment] - environment=django_environment, - additional_filters=additional_filters, - additional_select_related_args=[ - "feature", - "feature_segment", - "feature_segment__segment", - "feature_state_value", - ], - additional_prefetch_related_args=[ - Prefetch( - "multivariate_feature_state_values", - queryset=MultivariateFeatureStateValue.objects.select_related( - "multivariate_feature_option" - ), - ) - ], - # since we only want to retrieve the highest priority feature state, - # we key off the feature name instead of the default - # (feature_id, segment_id, identity_id). This will give us only e.g. - # the highest priority matching segment override for a given feature. - key_function=lambda fs: fs.feature.name, # type: ignore[arg-type,return-value] - ) + django_feature_states: dict[str, FeatureState] = get_environment_flags_dict( # type: ignore[assignment] + environment=django_environment, + additional_filters=additional_filters, + additional_select_related_args=[ + "feature", + "feature_segment", + "feature_segment__segment", + "feature_state_value", + ], + additional_prefetch_related_args=[ + Prefetch( + "multivariate_feature_state_values", + queryset=MultivariateFeatureStateValue.objects.select_related( + "multivariate_feature_option" + ), + ) + ], + # since we only want to retrieve the highest priority feature state, + # we key off the feature name instead of the default + # (feature_id, segment_id, identity_id). This will give us only e.g. + # the highest priority matching segment override for a given feature. + key_function=lambda fs: fs.feature.name, # type: ignore[arg-type,return-value] ) + # The winning row per feature is settled above, but the value an + # identity is served for a multivariate feature is the engine's to + # decide. Ask it, and carry the answer on each row. + context: EvaluationContext = { + "environment": { + "key": django_environment.api_key, + "name": django_environment.name or "", + }, + "identity": { + "identifier": self.identifier, + "key": self.get_hash_key( + django_environment.use_identity_composite_key_for_hashing + ), + }, + "features": { + feature_name: map_feature_state_to_feature_context( + feature_state, + mv_fs_values=feature_state.multivariate_feature_state_values.all(), + ) + for feature_name, feature_state in django_feature_states.items() + }, + } + for flag in get_evaluation_result(context)["flags"].values(): + django_feature_states[flag["name"]].flag_result = flag + + feature_states: dict[str, FeatureState | FeatureStateModel] = { + **django_feature_states + } + # Since the identity overrides are the highest priority, we can now iterate # over the dictionary and replace any feature states with those that have # an identity override, stored against the identity in dynamo. diff --git a/api/environments/identities/serializers.py b/api/environments/identities/serializers.py index c8da4624a894..3fd0d15f966a 100644 --- a/api/environments/identities/serializers.py +++ b/api/environments/identities/serializers.py @@ -7,7 +7,6 @@ from environments.identities.models import Identity from environments.models import Environment from environments.serializers import EnvironmentSerializerFull -from features.evaluation import evaluate_feature_state from features.models import FeatureState from features.serializers import FeatureStateSerializerFull from util.engine_models.features.models import FeatureStateModel @@ -94,28 +93,21 @@ class IdentityAllFeatureStatesSerializer(serializers.Serializer): # type: ignor IdentityAllFeatureStatesMVFeatureStateValueSerializer(many=True) ) - @property - def _identity_hash_key(self) -> str: - environment = Environment.get_from_cache(self.context["environment_api_key"]) - assert environment - return self.context["identity"].get_hash_key( # type: ignore[no-any-return] - environment.use_identity_composite_key_for_hashing - ) - def get_feature_state_value( self, instance: typing.Union[FeatureState, FeatureStateModel] ) -> typing.Union[str, int, bool]: if isinstance(instance, FeatureState): - if (flag_result := instance.flag_result) is not None: - return flag_result["value"] # type: ignore[no-any-return] - # An edge identity's overrides live in DynamoDB, so these rows were - # read straight from the ORM and never evaluated. Only multivariate - # allocation is left to resolve. - return evaluate_feature_state( # type: ignore[no-any-return] - instance, self._identity_hash_key - )["value"] - - return instance.get_value(self._identity_hash_key) # type: ignore[no-any-return] + return instance.evaluated_value # type: ignore[no-any-return] + + # An edge identity's own overrides are stored in DynamoDB rather than + # the ORM, and are still resolved outside the engine. + environment = Environment.get_from_cache(self.context["environment_api_key"]) + assert environment + return instance.get_value( # type: ignore[no-any-return] + self.context["identity"].get_hash_key( + environment.use_identity_composite_key_for_hashing + ) + ) def get_overridden_by(self, instance) -> typing.Optional[str]: # type: ignore[no-untyped-def] if getattr(instance, "feature_segment_id", None) is not None: diff --git a/api/features/evaluation.py b/api/features/evaluation.py deleted file mode 100644 index 848134c2f7db..000000000000 --- a/api/features/evaluation.py +++ /dev/null @@ -1,35 +0,0 @@ -from typing import TYPE_CHECKING - -from flag_engine.segments.evaluator import get_flag_result_from_context - -from util.mappers.engine import FlagResult, map_feature_state_to_feature_context - -if TYPE_CHECKING: - from features.models import FeatureState - - -__all__ = ("evaluate_feature_state",) - - -def evaluate_feature_state( - feature_state: "FeatureState", - identity_key: str, -) -> FlagResult: - """Evaluate a single feature state for an identity, ignoring overrides. - - For callers holding one feature state already known to be the right one, - where the only thing left to resolve is multivariate allocation. Prefer - `Identity.get_all_feature_states`, which evaluates the whole environment - and so can apply segment and identity overrides too. - """ - return get_flag_result_from_context( - context={ - "environment": {"key": "", "name": ""}, - "identity": {"identifier": "", "key": identity_key}, - }, - feature_context=map_feature_state_to_feature_context( - feature_state, - mv_fs_values=feature_state.multivariate_feature_state_values.all(), - ), - reason="DEFAULT", - ) diff --git a/api/tests/evaluation_helpers.py b/api/tests/evaluation_helpers.py index 3fb1d272132e..edd13d4b09d4 100644 --- a/api/tests/evaluation_helpers.py +++ b/api/tests/evaluation_helpers.py @@ -7,7 +7,9 @@ from typing import TYPE_CHECKING, Any, NamedTuple -from features.evaluation import evaluate_feature_state as _evaluate_feature_state +from flag_engine.engine import get_evaluation_result + +from util.mappers.engine import EvaluationContext, map_feature_state_to_feature_context if TYPE_CHECKING: from features.models import FeatureState @@ -26,5 +28,16 @@ def evaluate_feature_state( identity_key: str | int, ) -> EvaluatedFlag: """Evaluate `feature_state` as the engine would, for `identity_key`.""" - flag_result = _evaluate_feature_state(feature_state, str(identity_key)) + feature_name = feature_state.feature.name + context: EvaluationContext = { + "environment": {"key": "", "name": ""}, + "identity": {"identifier": "", "key": str(identity_key)}, + "features": { + feature_name: map_feature_state_to_feature_context( + feature_state, + mv_fs_values=feature_state.multivariate_feature_state_values.all(), + ) + }, + } + flag_result = get_evaluation_result(context)["flags"][feature_name] return EvaluatedFlag(flag_result["value"], flag_result["variant"]) diff --git a/api/tests/unit/edge_api/identities/test_edge_api_identities_serializers.py b/api/tests/unit/edge_api/identities/test_edge_api_identities_serializers.py index 49374ceef516..6ab97c6fa84a 100644 --- a/api/tests/unit/edge_api/identities/test_edge_api_identities_serializers.py +++ b/api/tests/unit/edge_api/identities/test_edge_api_identities_serializers.py @@ -12,7 +12,7 @@ IdentityAllFeatureStatesSerializer, ) from features.feature_types import STANDARD -from features.models import Feature, FeatureState +from features.models import Feature from users.models import FFAdminUser from util.engine_models.features.models import FeatureModel, FeatureStateModel from util.mappers import map_identity_to_identity_document @@ -184,19 +184,31 @@ def test_edge_identity_feature_state_serializer__update_override__calls_webhook( def test_all_feature_states_serializer__edge_identity_with_mv_feature__uses_mv_values( # type: ignore[no-untyped-def] - identity, multivariate_feature, environment + identity, multivariate_feature, environment, mocker ): # Given identity_document = map_identity_to_identity_document(identity) del identity_document["django_id"] # delete django id to simulate an edge identity - identity_model = EdgeIdentity.from_identity_document(identity_document) + edge_identity = EdgeIdentity.from_identity_document(identity_document) - feature_state = FeatureState.objects.get( - feature=multivariate_feature, environment=environment - ) + mocker.patch( + "edge_api.identities.models.EdgeIdentity.dynamo_wrapper" + ).get_segment_ids.return_value = [] + + # The feature state has to come from an evaluation, which is what resolves + # the identity's multivariate value. + feature_states, _ = edge_identity.get_all_feature_states() + (feature_state,) = [ + feature_state + for feature_state in feature_states + if feature_state.feature.name == multivariate_feature.name + ] serializer = IdentityAllFeatureStatesSerializer( - context={"identity": identity_model, "environment_api_key": environment.api_key} + context={ + "identity": edge_identity, + "environment_api_key": environment.api_key, + } ) # When diff --git a/api/tests/unit/features/test_unit_features_models.py b/api/tests/unit/features/test_unit_features_models.py index e44338cb8e94..11789b86a797 100644 --- a/api/tests/unit/features/test_unit_features_models.py +++ b/api/tests/unit/features/test_unit_features_models.py @@ -612,86 +612,6 @@ def test_feature_state_type__feature_segment_state__returns_feature_segment( assert result == FEATURE_SEGMENT -@pytest.mark.parametrize("hashed_percentage", (0.0, 30.0, 50.0, 80.0, 99.9999)) -@mock.patch("flag_engine.segments.evaluator.get_hashed_percentage_for_object_ids") -def test_evaluated_feature_state__multivariate_feature__returns_variant_value( # type: ignore[no-untyped-def] - mock_get_hashed_percentage, - hashed_percentage, - multivariate_feature, - environment, - identity, -): - # Given - mock_get_hashed_percentage.return_value = hashed_percentage - feature_state = FeatureState.objects.get( - environment=environment, - feature=multivariate_feature, - identity=None, - feature_segment=None, - ) - - # When - evaluated = evaluate_feature_state(feature_state, identity.get_hash_key()) - - # Then - # we get a multivariate value, not the control (the fixture's options span - # the entire 100%) - assert evaluated.value - assert evaluated.value != feature_state.get_feature_state_value() - - -@mock.patch("flag_engine.segments.evaluator.get_hashed_percentage_for_object_ids") -def test_evaluated_feature_state__no_mv_hashing_salt__seeds_hash_with_id( # type: ignore[no-untyped-def] - mock_get_hashed_percentage, - multivariate_feature, - environment, - identity, -): - # Given - mock_get_hashed_percentage.return_value = 0.0 - feature_state = FeatureState.objects.get( - environment=environment, - feature=multivariate_feature, - identity=None, - feature_segment=None, - ) - assert feature_state.mv_hashing_salt is None - identity_hash_key = identity.get_hash_key() - - # When - evaluate_feature_state(feature_state, identity_hash_key) - - # Then the feature state id seeds the hash - mock_get_hashed_percentage.assert_called_once_with( - [str(feature_state.id), identity_hash_key] - ) - - -@mock.patch("flag_engine.segments.evaluator.get_hashed_percentage_for_object_ids") -def test_evaluated_feature_state__mv_hashing_salt_set__seeds_hash_with_salt( # type: ignore[no-untyped-def] - mock_get_hashed_percentage, - multivariate_feature, - environment, - identity, -): - # Given - mock_get_hashed_percentage.return_value = 0.0 - feature_state = FeatureState.objects.get( - environment=environment, - feature=multivariate_feature, - identity=None, - feature_segment=None, - ) - feature_state.mv_hashing_salt = 999 - identity_hash_key = identity.get_hash_key() - - # When - evaluate_feature_state(feature_state, identity_hash_key) - - # Then the salt seeds the hash instead of the feature state id - mock_get_hashed_percentage.assert_called_once_with(["999", identity_hash_key]) - - def test_feature_state_clone__multivariate_feature__keeps_variant_bucketing_stable( multivariate_feature: Feature, environment: Environment, diff --git a/api/tests/unit/integrations/webhook/test_unit_webhook_serializers.py b/api/tests/unit/integrations/webhook/test_unit_webhook_serializers.py index 354caf747a99..401bc5997b5b 100644 --- a/api/tests/unit/integrations/webhook/test_unit_webhook_serializers.py +++ b/api/tests/unit/integrations/webhook/test_unit_webhook_serializers.py @@ -1,4 +1,3 @@ -from features.evaluation import evaluate_feature_state as evaluate_flag from features.models import FeatureState from integrations.webhook.serializers import ( IntegrationFeatureStateSerializer, @@ -7,17 +6,19 @@ def test_integration_feature_state_serializer__multivariate_feature__returns_correct_weight( # type: ignore[no-untyped-def] - identity, multivariate_feature, mocker + identity, multivariate_feature ): # Given mv_option = multivariate_feature.multivariate_options.first() feature_state = FeatureState.objects.filter(feature=multivariate_feature).first() - # Bucket the identity into the first option, whatever its weight. - mocker.patch( - "flag_engine.segments.evaluator.get_hashed_percentage_for_object_ids", - return_value=0.0, - ) - feature_state.flag_result = evaluate_flag(feature_state, identity.get_hash_key()) + # The identity was evaluated into the first option. + feature_state.flag_result = { + "name": multivariate_feature.name, + "enabled": feature_state.enabled, + "value": mv_option.value, + "reason": "DEFAULT", + "variant": mv_option.key, + } # When serializer = IntegrationFeatureStateSerializer( diff --git a/api/tests/unit/util/mappers/test_unit_mappers_evaluation_context.py b/api/tests/unit/util/mappers/test_unit_mappers_evaluation_context.py index 1b21c6aa9baa..1f79e639ae5d 100644 --- a/api/tests/unit/util/mappers/test_unit_mappers_evaluation_context.py +++ b/api/tests/unit/util/mappers/test_unit_mappers_evaluation_context.py @@ -9,6 +9,7 @@ IDENTITY_OVERRIDES_SEGMENT_KEY, IDENTITY_OVERRIDES_SEGMENT_NAME, map_environment_to_evaluation_context, + map_feature_state_to_feature_context, ) @@ -200,3 +201,39 @@ def test_map_environment_to_evaluation_context__hashing_setting__sets_matching_i if use_identity_composite_key_for_hashing else str(identity.pk) ) + + +@pytest.mark.parametrize( + ["mv_hashing_salt", "expected_key"], + [ + pytest.param(None, "id", id="no_salt"), + pytest.param(999, "999", id="salt_set"), + ], +) +def test_map_feature_state_to_feature_context__multivariate_feature__keys_on_hashing_seed( + environment: Environment, + multivariate_feature: Feature, + mv_hashing_salt: int | None, + expected_key: str, +) -> None: + """The context key seeds multivariate allocation. + + It has to be the lineage constant (#7913), so that recreating a feature + state does not move enrolled identities to another variant. + """ + # Given + feature_state = FeatureState.objects.get( + environment=environment, + feature=multivariate_feature, + identity=None, + feature_segment=None, + ) + feature_state.mv_hashing_salt = mv_hashing_salt + + # When + feature_context = map_feature_state_to_feature_context(feature_state) + + # Then + assert feature_context["key"] == ( + str(feature_state.id) if expected_key == "id" else expected_key + ) From f703a4c09bec226ca4cf66c2e0846052f15c09e1 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 22 Sep 2026 16:56:13 +0100 Subject: [PATCH 07/30] test(api): drop engine mocks from identity integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These are black box API tests, so they had no business patching the engine's hashing to pin which variant an identity fell into. Allocate the whole range to one variant instead, or to neither, which makes the outcome deterministic through the API alone. Both cases the old tests covered — the served value and the reported variant key — fold into one parametrised test, including the fall-through to the control value when nothing is allocated. --- .../identities/test_integration_identities.py | 208 ++++++------------ 1 file changed, 69 insertions(+), 139 deletions(-) diff --git a/api/tests/integration/environments/identities/test_integration_identities.py b/api/tests/integration/environments/identities/test_integration_identities.py index 058ae3ddc2a6..644141a8d61a 100644 --- a/api/tests/integration/environments/identities/test_integration_identities.py +++ b/api/tests/integration/environments/identities/test_integration_identities.py @@ -26,143 +26,49 @@ ) -# mock the returned percentage for the identity to simulate them falling into each of -# the percentage allocation brackets for the feature variants -@pytest.mark.parametrize( - "hashed_percentage, expected_mv_value", - ( - (variant_1_percentage_allocation - 1, variant_1_value), - (total_variance_percentage - 1, variant_2_value), - (total_variance_percentage + 1, control_value), - ), -) -@mock.patch("flag_engine.segments.evaluator.get_hashed_percentage_for_object_ids") -def test_get_feature_states_for_identity__mv_percentage_allocation__returns_correct_value( # type: ignore[no-untyped-def] - mock_get_hashed_percentage_value, - hashed_percentage, - expected_mv_value, - sdk_client, - admin_client, - project, - environment_api_key, - environment, - identity, - identity_identifier, -): - # Given / When - # Then - # Firstly, let's create some features to use - # one standard feature - standard_feature_initial_value = "control" - standard_feature_id = create_feature_with_api( - client=admin_client, - project_id=project, - feature_name="standard_feature", - initial_value=standard_feature_initial_value, - ) - - # and one multivariate feature - multivariate_feature_id = create_feature_with_api( - client=admin_client, - project_id=project, - feature_name="multivariate_feature", - initial_value=control_value, - feature_type=MULTIVARIATE, - ) - # With two mv options - create_mv_option_with_api( - admin_client, - project, - multivariate_feature_id, - variant_1_percentage_allocation, - variant_1_value, - ) - variant_2_mvfo_id = create_mv_option_with_api( - admin_client, - project, - multivariate_feature_id, - variant_2_percentage_allocation, - variant_2_value, - ) - - # Now, when we mock the hashed percentage that the user gets - # to avoid the randomness factor - mock_get_hashed_percentage_value.return_value = hashed_percentage - - # and request the flags for the identity - base_identity_flags_url = reverse("api-v1:sdk-identities") - identity_flags_url = f"{base_identity_flags_url}?identifier={identity_identifier}" - identity_response_1 = sdk_client.get(identity_flags_url) - - # Then, we get a result for both of the features we created - assert identity_response_1.status_code == status.HTTP_200_OK - identity_response_json = identity_response_1.json() - assert len(identity_response_json["flags"]) == 2 - - # and the correct values are returned for the features - values_dict = { - flag["feature"]["id"]: flag["feature_state_value"] - for flag in identity_response_json["flags"] - } - assert values_dict[standard_feature_id] == standard_feature_initial_value - assert values_dict[multivariate_feature_id] == expected_mv_value - - # Now, let's change the percentage allocations on the mv options - # first, we need to get the feature state id for the feature in the given - # environment - feature_state_id = next( - filter( - lambda flag: flag["feature"]["id"] == multivariate_feature_id, - identity_response_json["flags"], - ) - )["id"] - - # now let's get the existing data for the feature state so we can alter it and - # then PUT it back +def _set_mv_allocations( + admin_client: APIClient, + environment_api_key: str, + feature_state_id: int, + allocation_by_mv_option_id: dict[int, float], +) -> None: + """Reallocate a multivariate feature state's variants, through the API.""" feature_state_detail_url = reverse( "api-v1:environments:environment-featurestates-detail", args=[environment_api_key, feature_state_id], ) - retrieve_feature_state_response = admin_client.get(feature_state_detail_url) - feature_state_data = retrieve_feature_state_response.json() - - # now let's amend the data so that all identities should receive variant 2 + feature_state_data = admin_client.get(feature_state_detail_url).json() for mv_value in feature_state_data["multivariate_feature_state_values"]: - if mv_value["multivariate_feature_option"] == variant_2_mvfo_id: - mv_value["percentage_allocation"] = 100 - else: - mv_value["percentage_allocation"] = 0 - - # and PUT the data back - update_feature_state_response = admin_client.put( + mv_value["percentage_allocation"] = allocation_by_mv_option_id[ + mv_value["multivariate_feature_option"] + ] + update_response = admin_client.put( feature_state_detail_url, data=json.dumps(feature_state_data), content_type="application/json", ) - assert update_feature_state_response.status_code == status.HTTP_200_OK - - # Then when we get the flags for an identity, the multivariate feature returns the - # value of the 2nd variate - identity_response_2 = sdk_client.get(identity_flags_url) - values_dict = { - flag["feature"]["id"]: flag["feature_state_value"] - for flag in identity_response_2.json()["flags"] - } - assert values_dict[multivariate_feature_id] == variant_2_value + assert update_response.status_code == status.HTTP_200_OK +# Which variant an identity hashes into is not something an API test can pin +# down, so each case allocates a single variant the whole range instead. @pytest.mark.parametrize( - "hashed_percentage, expected_variant", + [ + "variant_1_allocation", + "variant_2_allocation", + "expected_value", + "expected_variant", + ], ( - (variant_1_percentage_allocation - 1, "variant-1"), - (total_variance_percentage - 1, "variant-2"), - (total_variance_percentage + 1, "control"), + pytest.param(100, 0, variant_1_value, "variant-1", id="all_variant_1"), + pytest.param(0, 100, variant_2_value, "variant-2", id="all_variant_2"), + pytest.param(0, 0, control_value, "control", id="unallocated_falls_through"), ), ) -@mock.patch("flag_engine.segments.evaluator.get_hashed_percentage_for_object_ids") -def test_get_feature_states_for_identity__mv_allocation__returns_variant( # type: ignore[no-untyped-def] - mock_get_hashed_percentage_value, - hashed_percentage, +def test_get_feature_states_for_identity__mv_allocation__returns_value_and_variant( # type: ignore[no-untyped-def] + variant_1_allocation, + variant_2_allocation, + expected_value, expected_variant, sdk_client, admin_client, @@ -174,15 +80,15 @@ def test_get_feature_states_for_identity__mv_allocation__returns_variant( # typ ): # Given # a standard (non-multivariate) feature + standard_feature_initial_value = "control" standard_feature_id = create_feature_with_api( client=admin_client, project_id=project, feature_name="standard_feature", - initial_value="control", + initial_value=standard_feature_initial_value, ) - # and a multivariate feature with two keyed variants spanning part of the range, - # so the remainder falls through to the control + # and a multivariate feature with two keyed variants multivariate_feature_id = create_feature_with_api( client=admin_client, project_id=project, @@ -190,7 +96,7 @@ def test_get_feature_states_for_identity__mv_allocation__returns_variant( # typ initial_value=control_value, feature_type=MULTIVARIATE, ) - create_mv_option_with_api( + variant_1_mvfo_id = create_mv_option_with_api( admin_client, project, multivariate_feature_id, @@ -198,7 +104,7 @@ def test_get_feature_states_for_identity__mv_allocation__returns_variant( # typ variant_1_value, key="variant-1", ) - create_mv_option_with_api( + variant_2_mvfo_id = create_mv_option_with_api( admin_client, project, multivariate_feature_id, @@ -207,22 +113,46 @@ def test_get_feature_states_for_identity__mv_allocation__returns_variant( # typ key="variant-2", ) + base_identity_flags_url = reverse("api-v1:sdk-identities") + identity_flags_url = f"{base_identity_flags_url}?identifier={identity_identifier}" + flags = sdk_client.get(identity_flags_url).json()["flags"] + multivariate_feature_state_id = next( + flag["id"] for flag in flags if flag["feature"]["id"] == multivariate_feature_id + ) + # When - # the identity hashes into a known allocation band - mock_get_hashed_percentage_value.return_value = hashed_percentage - base_url = reverse("api-v1:sdk-identities") - url = f"{base_url}?identifier={identity_identifier}" - response = sdk_client.get(url) + # the whole range is allocated to one variant, or to neither + _set_mv_allocations( + admin_client, + environment_api_key, + multivariate_feature_state_id, + { + variant_1_mvfo_id: variant_1_allocation, + variant_2_mvfo_id: variant_2_allocation, + }, + ) + response = sdk_client.get(identity_flags_url) # Then assert response.status_code == status.HTTP_200_OK - variant_by_feature = { - flag["feature"]["id"]: flag["variant"] for flag in response.json()["flags"] - } - # the multivariate flag reports the variant key (or "control" on fall-through) - assert variant_by_feature[multivariate_feature_id] == expected_variant - # and the standard flag has no variant - assert variant_by_feature[standard_feature_id] is None + flags = response.json()["flags"] + assert len(flags) == 2 + flags_by_feature_id = {flag["feature"]["id"]: flag for flag in flags} + + # the multivariate flag serves the allocated variant, reporting its key, + # falling through to the control value when nothing is allocated + assert ( + flags_by_feature_id[multivariate_feature_id]["feature_state_value"] + == expected_value + ) + assert flags_by_feature_id[multivariate_feature_id]["variant"] == expected_variant + + # and the standard flag is unaffected, and has no variant + assert ( + flags_by_feature_id[standard_feature_id]["feature_state_value"] + == standard_feature_initial_value + ) + assert flags_by_feature_id[standard_feature_id]["variant"] is None def test_get_flags__multivariate_feature__response_excludes_variant( # type: ignore[no-untyped-def] From 64902f0ec6d915e9f982f30e4e00df49d01cb9d0 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 22 Sep 2026 17:21:38 +0100 Subject: [PATCH 08/30] test(api): cover multivariate allocation bands without mocking Pinning `mv_hashing_salt` fixes the bucketing seed, so an identity key lands in a known band without the engine's hashing having to be patched. Restores the boundary coverage the integration tests used to carry, as a unit test where it belongs. Moves the expected value into the parameters alongside the variant, and names the fixture's options apart, since two of them share a value. Lifts the allocation helper into a `set_mv_allocations` fixture. --- api/tests/integration/conftest.py | 33 ++++++++++- .../identities/test_integration_identities.py | 28 +-------- api/tests/types.py | 9 +++ .../test_unit_identities_services.py | 59 +++++++++++++++++++ 4 files changed, 102 insertions(+), 27 deletions(-) diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py index c981b819e814..3b418641219f 100644 --- a/api/tests/integration/conftest.py +++ b/api/tests/integration/conftest.py @@ -20,7 +20,10 @@ from features.future.types import SegmentOverrideRequest, UpdateFlagRequest from organisations.models import Organisation from tests.integration.helpers import create_mv_option_with_api -from tests.types import CreateSegmentOverrideFixture +from tests.types import ( + CreateSegmentOverrideFixture, + SetMultivariateAllocationsFixture, +) from users.models import FFAdminUser @@ -29,6 +32,34 @@ def mv_option_value(): # type: ignore[no-untyped-def] return "test_mv_value" +@pytest.fixture() +def set_mv_allocations(admin_client: APIClient) -> SetMultivariateAllocationsFixture: + """Reallocate a multivariate feature state's variants, through the API.""" + + def _set_mv_allocations( + environment_api_key: str, + feature_state_id: int, + allocation_by_mv_option_id: dict[int, float], + ) -> None: + url = reverse( + "api-v1:environments:environment-featurestates-detail", + args=[environment_api_key, feature_state_id], + ) + feature_state_data = admin_client.get(url).json() + for mv_value in feature_state_data["multivariate_feature_state_values"]: + mv_value["percentage_allocation"] = allocation_by_mv_option_id[ + mv_value["multivariate_feature_option"] + ] + response = admin_client.put( + url, + data=json.dumps(feature_state_data), + content_type="application/json", + ) + assert response.status_code == status.HTTP_200_OK + + return _set_mv_allocations + + @pytest.fixture() def django_client(): # type: ignore[no-untyped-def] return DjangoClient() diff --git a/api/tests/integration/environments/identities/test_integration_identities.py b/api/tests/integration/environments/identities/test_integration_identities.py index 644141a8d61a..5ce938e1bfaf 100644 --- a/api/tests/integration/environments/identities/test_integration_identities.py +++ b/api/tests/integration/environments/identities/test_integration_identities.py @@ -26,30 +26,6 @@ ) -def _set_mv_allocations( - admin_client: APIClient, - environment_api_key: str, - feature_state_id: int, - allocation_by_mv_option_id: dict[int, float], -) -> None: - """Reallocate a multivariate feature state's variants, through the API.""" - feature_state_detail_url = reverse( - "api-v1:environments:environment-featurestates-detail", - args=[environment_api_key, feature_state_id], - ) - feature_state_data = admin_client.get(feature_state_detail_url).json() - for mv_value in feature_state_data["multivariate_feature_state_values"]: - mv_value["percentage_allocation"] = allocation_by_mv_option_id[ - mv_value["multivariate_feature_option"] - ] - update_response = admin_client.put( - feature_state_detail_url, - data=json.dumps(feature_state_data), - content_type="application/json", - ) - assert update_response.status_code == status.HTTP_200_OK - - # Which variant an identity hashes into is not something an API test can pin # down, so each case allocates a single variant the whole range instead. @pytest.mark.parametrize( @@ -77,6 +53,7 @@ def test_get_feature_states_for_identity__mv_allocation__returns_value_and_varia environment, identity, identity_identifier, + set_mv_allocations, ): # Given # a standard (non-multivariate) feature @@ -122,8 +99,7 @@ def test_get_feature_states_for_identity__mv_allocation__returns_value_and_varia # When # the whole range is allocated to one variant, or to neither - _set_mv_allocations( - admin_client, + set_mv_allocations( environment_api_key, multivariate_feature_state_id, { diff --git a/api/tests/types.py b/api/tests/types.py index ffbfd955bda5..4c91e7b89594 100644 --- a/api/tests/types.py +++ b/api/tests/types.py @@ -46,6 +46,15 @@ class MigratorFactory(Protocol): def __call__(self, name: Optional[str] = None) -> Migrator: ... +class SetMultivariateAllocationsFixture(Protocol): + def __call__( + self, + environment_api_key: str, + feature_state_id: int, + allocation_by_mv_option_id: dict[int, float], + ) -> None: ... + + class CreateSegmentOverrideFixture(Protocol): def __call__( self, diff --git a/api/tests/unit/environments/identities/test_unit_identities_services.py b/api/tests/unit/environments/identities/test_unit_identities_services.py index e26dc659965e..4da683f091b2 100644 --- a/api/tests/unit/environments/identities/test_unit_identities_services.py +++ b/api/tests/unit/environments/identities/test_unit_identities_services.py @@ -5,10 +5,16 @@ from environments.identities.models import Identity from environments.identities.services import evaluate_identity from environments.identities.traits.models import Trait +from environments.models import Environment +from features.constants import CONTROL_VARIANT_KEY from features.models import Feature, FeatureSegment, FeatureState from features.multivariate.models import MultivariateFeatureStateValue from projects.models import Project from segments.models import Condition, Segment, SegmentRule +from tests.evaluation_helpers import evaluate_feature_state + +#: `multivariate_feature`'s initial value, served when nothing is allocated. +CONTROL_VALUE = "control" def test_evaluate_identity__identity_and_segment_override__identity_override_wins( @@ -167,3 +173,56 @@ def test_evaluate_identity__multivariate_feature__returns_variant_key( # Either a named variant or the control bucket — never a silent `None`, # which is what an unkeyed variant context would produce. assert flag["variant"] in {"control", "variant-0", "variant-1", "variant-2"} + + +@pytest.mark.parametrize( + ["identity_key", "expected_variant", "expected_value"], + ( + pytest.param("identity-4", "variant-1", "variant-1-value", id="first_band"), + pytest.param("identity-3", "variant-2", "variant-2-value", id="second_band"), + pytest.param( + "identity-0", + CONTROL_VARIANT_KEY, + CONTROL_VALUE, + id="unallocated_falls_through", + ), + ), +) +def test_evaluate_feature_state__multivariate_feature__allocates_variants_in_order( + environment: Environment, + multivariate_feature: Feature, + identity_key: str, + expected_variant: str, + expected_value: str, +) -> None: + # Given + feature_state = FeatureState.objects.get( + environment=environment, + feature=multivariate_feature, + identity=None, + feature_segment=None, + ) + feature_state.mv_hashing_salt = 1 + feature_state.save() + + # Two variants taking 20% and 30%, leaving half the range to the control. + # The fixture gives two of its options the same value, so name them apart. + for index, (mv_value, allocation) in enumerate( + zip( + feature_state.multivariate_feature_state_values.order_by("id"), + (20, 30, 0), + ) + ): + mv_value.percentage_allocation = allocation + mv_value.save() + option = mv_value.multivariate_feature_option + option.key = f"variant-{index + 1}" + option.string_value = f"variant-{index + 1}-value" + option.save() + + # When + evaluated = evaluate_feature_state(feature_state, identity_key) + + # Then + assert evaluated.variant == expected_variant + assert evaluated.value == expected_value From 994422757cd963510b0e798fa1df7cd416c94a68 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 22 Sep 2026 18:43:52 +0100 Subject: [PATCH 09/30] refactor(api): move engine type aliases into an evaluation package The parameterised `EvaluationContext` and friends sat in a mappers module, though little of what uses them is a mapper: `FeatureState` annotates a `FlagResult`, and edge identities build a context. Move them to `evaluation.types`, which gives evaluation a home of its own. The module composes two leaves and imports no Django, so `features.models` can annotate `flag_result` directly instead of guarding the import behind `TYPE_CHECKING`. --- api/edge_api/identities/models.py | 6 ++--- api/environments/identities/types.py | 2 +- api/evaluation/__init__.py | 0 api/evaluation/types.py | 27 +++++++++++++++++++++++ api/features/models.py | 4 ++-- api/tests/evaluation_helpers.py | 3 ++- api/util/mappers/engine.py | 33 ++++++---------------------- 7 files changed, 41 insertions(+), 34 deletions(-) create mode 100644 api/evaluation/__init__.py create mode 100644 api/evaluation/types.py diff --git a/api/edge_api/identities/models.py b/api/edge_api/identities/models.py index f102cc5a0fdc..7dac109a9dc9 100644 --- a/api/edge_api/identities/models.py +++ b/api/edge_api/identities/models.py @@ -18,6 +18,7 @@ from edge_api.identities.utils import generate_change_dict from environments.dynamodb import DynamoIdentityWrapper from environments.models import Environment +from evaluation.types import EvaluationContext from features.models import FeatureState from features.multivariate.models import MultivariateFeatureStateValue from features.versioning.versioning_service import get_environment_flags_dict @@ -25,10 +26,7 @@ from util.engine_models.features.models import FeatureStateModel from util.engine_models.identities.models import IdentityFeaturesList, IdentityModel from util.mappers import map_engine_identity_to_identity_document -from util.mappers.engine import ( - EvaluationContext, - map_feature_state_to_feature_context, -) +from util.mappers.engine import map_feature_state_to_feature_context class EdgeIdentity: diff --git a/api/environments/identities/types.py b/api/environments/identities/types.py index f3b11fb9004e..60e68d8e25f5 100644 --- a/api/environments/identities/types.py +++ b/api/environments/identities/types.py @@ -1,6 +1,6 @@ from typing import TYPE_CHECKING, NamedTuple -from util.mappers.engine import EvaluationContext, EvaluationResult +from evaluation.types import EvaluationContext, EvaluationResult if TYPE_CHECKING: from features.models import FeatureState diff --git a/api/evaluation/__init__.py b/api/evaluation/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/evaluation/types.py b/api/evaluation/types.py new file mode 100644 index 000000000000..d05fb3cfa443 --- /dev/null +++ b/api/evaluation/types.py @@ -0,0 +1,27 @@ +from typing import TypeAlias + +from flag_engine.context import types as context_types +from flag_engine.result import types as result_types + +from features.types import FeatureEngineMetadata +from segments.types import SegmentEngineMetadata + +__all__ = ( + "EvaluationContext", + "EvaluationResult", + "FeatureContext", + "FlagResult", + "SegmentContext", +) + +EvaluationContext: TypeAlias = context_types.EvaluationContext[ + SegmentEngineMetadata, FeatureEngineMetadata +] +SegmentContext: TypeAlias = context_types.SegmentContext[ + SegmentEngineMetadata, FeatureEngineMetadata +] +FeatureContext: TypeAlias = context_types.FeatureContext[FeatureEngineMetadata] +EvaluationResult: TypeAlias = result_types.EvaluationResult[ + SegmentEngineMetadata, FeatureEngineMetadata +] +FlagResult: TypeAlias = result_types.FlagResult[FeatureEngineMetadata] diff --git a/api/features/models.py b/api/features/models.py index e0f1d2751d3c..aa0b79619c57 100644 --- a/api/features/models.py +++ b/api/features/models.py @@ -49,6 +49,7 @@ SoftDeleteExportableModel, abstract_base_auditable_model_factory, ) +from evaluation.types import FlagResult from features.constants import ENVIRONMENT, FEATURE_SEGMENT, IDENTITY from features.custom_lifecycle import CustomLifecycleModelMixin from features.feature_states.models import AbstractBaseFeatureValueModel @@ -86,7 +87,6 @@ if typing.TYPE_CHECKING: from environments.identities.models import Identity from environments.models import Environment - from util.mappers.engine import FlagResult class Feature( # type: ignore[django-manager-missing] @@ -530,7 +530,7 @@ class FeatureState( #: value and variant travel with the row they were resolved from, rather #: than being recomputed per serialiser. `None` where the state was read #: outside an evaluation, in which case only its stored value is meaningful. - flag_result: "FlagResult | None" = None + flag_result: FlagResult | None = None class Meta: ordering = ["id"] diff --git a/api/tests/evaluation_helpers.py b/api/tests/evaluation_helpers.py index edd13d4b09d4..b60e944eb775 100644 --- a/api/tests/evaluation_helpers.py +++ b/api/tests/evaluation_helpers.py @@ -9,7 +9,8 @@ from flag_engine.engine import get_evaluation_result -from util.mappers.engine import EvaluationContext, map_feature_state_to_feature_context +from evaluation.types import EvaluationContext +from util.mappers.engine import map_feature_state_to_feature_context if TYPE_CHECKING: from features.models import FeatureState diff --git a/api/util/mappers/engine.py b/api/util/mappers/engine.py index 3d895c348a4a..2bda41a02bc9 100644 --- a/api/util/mappers/engine.py +++ b/api/util/mappers/engine.py @@ -2,17 +2,17 @@ from itertools import chain from math import inf from operator import attrgetter -from typing import TYPE_CHECKING, Dict, List, NamedTuple, Optional, TypeAlias +from typing import TYPE_CHECKING, Dict, List, NamedTuple, Optional from uuid import UUID from django.db.models import Prefetch, Q from flag_engine.context import types as engine_types -from flag_engine.result import types as engine_result_types from flag_engine.segments.constants import IS_SET from flag_engine.segments.types import ConditionOperator, RuleType from pydantic import TypeAdapter from environments.constants import IDENTITY_INTEGRATIONS_RELATION_NAMES +from evaluation.types import EvaluationContext, FeatureContext, SegmentContext from features.types import FeatureEngineMetadata from features.versioning.models import EnvironmentFeatureVersion from segments.types import SegmentEngineMetadata @@ -57,21 +57,6 @@ from segments.models import Condition, Segment, SegmentRule -#: `EvaluationContext` and friends, parameterised with Core API's metadata -#: types. Prefer these aliases over the bare generics so that reading metadata -#: off a result stays type-checked. -EvaluationContext: TypeAlias = engine_types.EvaluationContext[ - SegmentEngineMetadata, FeatureEngineMetadata -] -SegmentContext: TypeAlias = engine_types.SegmentContext[ - SegmentEngineMetadata, FeatureEngineMetadata -] -EvaluationResult: TypeAlias = engine_result_types.EvaluationResult[ - SegmentEngineMetadata, FeatureEngineMetadata -] -FlagResult: TypeAlias = engine_result_types.FlagResult[FeatureEngineMetadata] - - class MappedEvaluationContext(NamedTuple): context: EvaluationContext #: The feature states the context was built from, by id. Transitional — see @@ -85,11 +70,7 @@ class MappedEvaluationContext(NamedTuple): IDENTITY_OVERRIDES_SEGMENT_NAME = "identity_overrides" __all__ = ( - "EvaluationContext", - "EvaluationResult", "MappedEvaluationContext", - "FlagResult", - "SegmentContext", "map_condition_to_segment_condition", "map_environment_api_key_to_engine", "map_environment_to_engine", @@ -567,7 +548,7 @@ def to_feature_context( *, segment_id: int | None = None, priority: float | None = None, - ) -> engine_types.FeatureContext[FeatureEngineMetadata]: + ) -> FeatureContext: return map_feature_state_to_feature_context( feature_state, mv_fs_values=mv_fs_values_by_feature_state_id.get(feature_state.pk), @@ -682,7 +663,7 @@ def map_feature_state_to_feature_context( mv_fs_values: "Iterable[MultivariateFeatureStateValue] | None" = None, segment_id: int | None = None, priority: float | None = None, -) -> engine_types.FeatureContext[FeatureEngineMetadata]: +) -> FeatureContext: """Map a Django ORM FeatureState to a flag-engine FeatureContext TypedDict.""" feature = feature_state.feature metadata = FeatureEngineMetadata( @@ -694,7 +675,7 @@ def map_feature_state_to_feature_context( if feature_state.identity_id is not None: metadata["identity_id"] = feature_state.identity_id - feature_context: engine_types.FeatureContext[FeatureEngineMetadata] = { + feature_context: FeatureContext = { # The engine seeds multivariate variant allocation on the feature # context key, so it has to be the bucketing seed rather than the # feature state id, or recreating a feature state would move every @@ -743,7 +724,7 @@ def _map_mv_fs_values_to_feature_values( def map_segment_to_segment_context( segment: "Segment", *, - overrides: "list[engine_types.FeatureContext[FeatureEngineMetadata]] | None" = None, + overrides: "list[FeatureContext] | None" = None, ) -> SegmentContext: """Map a Django ORM Segment to a flag-engine SegmentContext TypedDict.""" segment_context: SegmentContext = { @@ -758,7 +739,7 @@ def map_segment_to_segment_context( def _map_identity_overrides_to_segment_context( - overrides: "list[engine_types.FeatureContext[FeatureEngineMetadata]]", + overrides: "list[FeatureContext]", ) -> SegmentContext: """Express identity overrides as a segment matching only that identity. From 0e40bf502deb9fe829f18b9c01150cc7bcf49e86 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 22 Sep 2026 19:09:26 +0100 Subject: [PATCH 10/30] refactor(api): gather evaluation into its own package Evaluation was spread across three homes: context mapping sat in `util.mappers.engine` next to the DynamoDB and SDK document mappers it has nothing to do with, and the service and its result type lived under `environments.identities`, though neither is about identities as such. Gather them into `evaluation`, leaving `util.mappers.engine` to the document mapping it was originally for. Tests follow. `IdentityEvaluationContext` goes with them, unused since the mapper started returning `MappedEvaluationContext`. --- api/edge_api/identities/models.py | 2 +- api/environments/identities/models.py | 4 +- api/environments/identities/services.py | 27 -- api/environments/identities/types.py | 22 -- api/evaluation/mappers.py | 373 ++++++++++++++++++ api/evaluation/services.py | 35 ++ api/evaluation/types.py | 15 +- api/integrations/webhook/serializers.py | 2 +- api/tests/evaluation_helpers.py | 2 +- api/tests/unit/evaluation/__init__.py | 0 .../test_unit_evaluation_mappers.py} | 219 +++++++++- .../test_unit_evaluation_services.py} | 2 +- .../util/mappers/test_unit_mappers_engine.py | 211 +--------- api/util/mappers/engine.py | 352 +---------------- 14 files changed, 646 insertions(+), 620 deletions(-) delete mode 100644 api/environments/identities/types.py create mode 100644 api/evaluation/mappers.py create mode 100644 api/evaluation/services.py create mode 100644 api/tests/unit/evaluation/__init__.py rename api/tests/unit/{util/mappers/test_unit_mappers_evaluation_context.py => evaluation/test_unit_evaluation_mappers.py} (56%) rename api/tests/unit/{environments/identities/test_unit_identities_services.py => evaluation/test_unit_evaluation_services.py} (99%) diff --git a/api/edge_api/identities/models.py b/api/edge_api/identities/models.py index 7dac109a9dc9..ced2400c0ca4 100644 --- a/api/edge_api/identities/models.py +++ b/api/edge_api/identities/models.py @@ -18,6 +18,7 @@ from edge_api.identities.utils import generate_change_dict from environments.dynamodb import DynamoIdentityWrapper from environments.models import Environment +from evaluation.mappers import map_feature_state_to_feature_context from evaluation.types import EvaluationContext from features.models import FeatureState from features.multivariate.models import MultivariateFeatureStateValue @@ -26,7 +27,6 @@ from util.engine_models.features.models import FeatureStateModel from util.engine_models.identities.models import IdentityFeaturesList, IdentityModel from util.mappers import map_engine_identity_to_identity_document -from util.mappers.engine import map_feature_state_to_feature_context class EdgeIdentity: diff --git a/api/environments/identities/models.py b/api/environments/identities/models.py index d2987afe23c3..6648290354f5 100644 --- a/api/environments/identities/models.py +++ b/api/environments/identities/models.py @@ -5,13 +5,13 @@ from flag_engine.engine import get_evaluation_result from environments.identities.managers import IdentityManager -from environments.identities.services import evaluate_identity from environments.identities.traits.models import Trait from environments.models import Environment from environments.sdk.types import SDKTraitData +from evaluation.mappers import map_environment_to_evaluation_context +from evaluation.services import evaluate_identity from features.models import FeatureState from segments.models import Segment -from util.mappers.engine import map_environment_to_evaluation_context class Identity(models.Model): diff --git a/api/environments/identities/services.py b/api/environments/identities/services.py index eb578b11f436..c54563a01757 100644 --- a/api/environments/identities/services.py +++ b/api/environments/identities/services.py @@ -1,37 +1,10 @@ from typing import TYPE_CHECKING -from django.db.models import Q -from flag_engine.engine import get_evaluation_result - -from environments.identities.types import IdentityEvaluation -from util.mappers.engine import map_environment_to_evaluation_context - if TYPE_CHECKING: from environments.identities.models import Identity - from environments.identities.traits.models import Trait from environments.models import Environment -def evaluate_identity( - identity: "Identity", - *, - traits: "list[Trait] | None" = None, - feature_name: str | None = None, - additional_filters: Q | None = None, -) -> IdentityEvaluation: - """Evaluate every flag in `identity`'s environment for that identity.""" - environment: "Environment" = identity.environment - context, feature_states_by_id = map_environment_to_evaluation_context( - environment=environment, - identity=identity, - traits=traits, - segments=environment.get_segments_from_cache(), - feature_name=feature_name, - additional_filters=additional_filters, - ) - return IdentityEvaluation(get_evaluation_result(context), feature_states_by_id) - - def replace_identity_environment( identity: "Identity", environment: "Environment", diff --git a/api/environments/identities/types.py b/api/environments/identities/types.py deleted file mode 100644 index 60e68d8e25f5..000000000000 --- a/api/environments/identities/types.py +++ /dev/null @@ -1,22 +0,0 @@ -from typing import TYPE_CHECKING, NamedTuple - -from evaluation.types import EvaluationContext, EvaluationResult - -if TYPE_CHECKING: - from features.models import FeatureState - - -__all__ = ( - "IdentityEvaluation", - "IdentityEvaluationContext", -) - - -class IdentityEvaluationContext(NamedTuple): - context: EvaluationContext - feature_states_by_id: "dict[int, FeatureState]" - - -class IdentityEvaluation(NamedTuple): - result: EvaluationResult - feature_states_by_id: "dict[int, FeatureState]" diff --git a/api/evaluation/mappers.py b/api/evaluation/mappers.py new file mode 100644 index 000000000000..6cc1bb2e8f64 --- /dev/null +++ b/api/evaluation/mappers.py @@ -0,0 +1,373 @@ +"""Mapping Django ORM models to a flag-engine `EvaluationContext`. + +Core API's whole contribution to flag evaluation: resolve the rows that are +current for an environment, lay them out as the engine expects, and let it +decide which override wins and which variant an identity lands in. +""" + +from collections.abc import Iterable +from math import inf +from operator import attrgetter +from typing import TYPE_CHECKING, NamedTuple + +from django.db.models import Prefetch, Q +from flag_engine.context import types as engine_types +from flag_engine.segments.constants import IS_SET +from flag_engine.segments.types import ConditionOperator, RuleType +from pydantic import TypeAdapter + +from evaluation.types import EvaluationContext, FeatureContext, SegmentContext +from features.types import FeatureEngineMetadata +from segments.types import SegmentEngineMetadata + +if TYPE_CHECKING: + from environments.identities.models import Identity + from environments.identities.traits.models import Trait + from environments.models import Environment + from features.models import FeatureState + from features.multivariate.models import MultivariateFeatureStateValue + from segments.models import Condition, Segment, SegmentRule + + +__all__ = ( + "IDENTITY_OVERRIDES_SEGMENT_KEY", + "IDENTITY_OVERRIDES_SEGMENT_NAME", + "MappedEvaluationContext", + "map_condition_to_segment_condition", + "map_environment_to_evaluation_context", + "map_feature_state_to_feature_context", + "map_rule_to_segment_rule", + "map_segment_to_segment_context", +) + + +class MappedEvaluationContext(NamedTuple): + context: EvaluationContext + #: The feature states the context was built from, by id. Transitional — see + #: `map_environment_to_evaluation_context`. + feature_states_by_id: "dict[int, FeatureState]" + + +#: Context key and name of the synthetic segment carrying identity overrides. +#: Not a segment id — prefixed so it cannot collide with one. +IDENTITY_OVERRIDES_SEGMENT_KEY = "$identity_overrides" +IDENTITY_OVERRIDES_SEGMENT_NAME = "identity_overrides" + + +_rule_type_adapter: TypeAdapter[RuleType] = TypeAdapter(RuleType) +_condition_operator_adapter: TypeAdapter[ConditionOperator] = TypeAdapter( + ConditionOperator +) + + +def map_environment_to_evaluation_context( + *, + environment: "Environment", + identity: "Identity | None" = None, + traits: "Iterable[Trait] | None" = None, + segments: "Iterable[Segment] | None" = None, + feature_name: str | None = None, + additional_filters: "Q | None" = None, +) -> MappedEvaluationContext: + """Map Django ORM models to a flag-engine `EvaluationContext`. + + Resolves the feature states that are current for `environment` — defaults, + segment overrides, and `identity`'s own overrides — and lays them out as + `$.features` plus the overrides carried on each segment. + + Returns those feature states alongside the context, keyed by id, so that + callers still working in Django rows can map a `FlagResult` back to one via + `metadata.feature_state_id`. That is scaffolding for the migration off + `FeatureState.get_feature_state_value(identity=...)`; once serialisers read + values off the result, only the context is needed. + + :param segments: segments to evaluate. + """ + context: EvaluationContext = { + "environment": { + "key": environment.api_key, + "name": environment.name or "", + }, + } + if identity is not None: + trait_items: "Iterable[Trait]" = ( + traits + if traits is not None + # A transient identity was never persisted, so it has no stored + # traits to read, and asking for them would raise. + else identity.identity_traits.all() + if identity.pk + else () + ) + identity_traits = {trait.trait_key: trait.trait_value for trait in trait_items} + if identity.system_traits: + # System-owned traits are not user data: on a key clash, the system + # value wins. + identity_traits.update(identity.system_traits) + context["identity"] = { + "identifier": identity.identifier, + "key": identity.get_hash_key( + environment.use_identity_composite_key_for_hashing + ), + "traits": identity_traits, + } + + ( + feature_states, + features, + identity_overrides, + segment_overrides, + mv_fs_values_by_feature_state_id, + ) = _resolve_feature_states( + environment=environment, + identity=identity, + feature_name=feature_name, + additional_filters=additional_filters, + ) + + # No reading from ORM past this point! + + def to_feature_context( + feature_state: "FeatureState", + *, + segment_id: int | None = None, + priority: float | None = None, + ) -> FeatureContext: + return map_feature_state_to_feature_context( + feature_state, + mv_fs_values=mv_fs_values_by_feature_state_id.get(feature_state.pk), + segment_id=segment_id, + priority=priority, + ) + + if segments is not None: + context["segments"] = { + str(segment.pk): map_segment_to_segment_context( + segment, + overrides=[ + to_feature_context(feature_state, segment_id=segment.pk) + for feature_state in segment_overrides.get(segment.pk) or () + ], + ) + for segment in segments + } + + if identity_overrides: + # An identity override outranks every segment override, which the + # engine expresses as a priority no segment can beat. + context.setdefault("segments", {})[IDENTITY_OVERRIDES_SEGMENT_KEY] = ( + _map_identity_overrides_to_segment_context( + [ + to_feature_context(feature_state, priority=-inf) + for feature_state in identity_overrides + ] + ) + ) + + context["features"] = { + (feature_context := to_feature_context(feature_state))["name"]: feature_context + for feature_state in features + } + + return MappedEvaluationContext( + context=context, + feature_states_by_id={ + feature_state.pk: feature_state for feature_state in feature_states + }, + ) + + +class _ResolvedFeatureStates(NamedTuple): + all: list["FeatureState"] + #: Environment defaults, i.e. neither segment- nor identity-scoped. + features: list["FeatureState"] + identity_overrides: list["FeatureState"] + segment_overrides: dict[int, list["FeatureState"]] + mv_fs_values_by_feature_state_id: dict[ + int, "Iterable[MultivariateFeatureStateValue]" + ] + + +def _resolve_feature_states( + *, + environment: "Environment", + identity: "Identity | None", + feature_name: str | None, + additional_filters: "Q | None", +) -> _ResolvedFeatureStates: + """Read the feature states current for `environment`, split by what they override.""" + # Deferred: `environments.models` imports this module's package. + from features.multivariate.models import MultivariateFeatureStateValue + from features.versioning.versioning_service import get_environment_flags_list + + override_filters = Q(identity__isnull=True) + if identity is not None and identity.pk: + # The identity is persisted (non-transient). + # Look for its identity overrides in addition to segment overrides. + override_filters = Q(identity=identity) | override_filters + if additional_filters: + override_filters &= additional_filters + + feature_states = get_environment_flags_list( + environment=environment, + feature_name=feature_name, + additional_filters=override_filters, + additional_select_related_args=["feature_segment__segment"], + additional_prefetch_related_args=[ + Prefetch( + "multivariate_feature_state_values", + queryset=MultivariateFeatureStateValue.objects.select_related( + "multivariate_feature_option" + ), + ) + ], + ) + + resolved = _ResolvedFeatureStates(feature_states, [], [], {}, {}) + + for feature_state in feature_states: + resolved.mv_fs_values_by_feature_state_id[feature_state.pk] = ( + feature_state.multivariate_feature_state_values.all() + ) + if feature_state.identity_id is not None: + resolved.identity_overrides.append(feature_state) + elif (feature_segment := feature_state.feature_segment) is not None: + resolved.segment_overrides.setdefault( + feature_segment.segment_id, [] + ).append(feature_state) + else: + resolved.features.append(feature_state) + + return resolved + + +def map_feature_state_to_feature_context( + feature_state: "FeatureState", + *, + mv_fs_values: "Iterable[MultivariateFeatureStateValue] | None" = None, + segment_id: int | None = None, + priority: float | None = None, +) -> FeatureContext: + """Map a Django ORM FeatureState to a flag-engine FeatureContext TypedDict.""" + feature = feature_state.feature + metadata = FeatureEngineMetadata( + feature_id=feature.pk, + feature_state_id=feature_state.pk, + ) + if segment_id is not None: + metadata["segment_id"] = segment_id + if feature_state.identity_id is not None: + metadata["identity_id"] = feature_state.identity_id + + feature_context: FeatureContext = { + # The engine seeds multivariate variant allocation on the feature + # context key, so it has to be the bucketing seed rather than the + # feature state id, or recreating a feature state would move every + # enrolled identity to a different variant. See issue #7913. + "key": str(feature_state.mv_hashing_seed), + "name": feature.name, + "enabled": feature_state.enabled, + # Deliberately unparameterised by identity: picking a multivariate + # value is the engine's job now. + "value": feature_state.get_feature_state_value(), + "metadata": metadata, + } + + if variants := _map_mv_fs_values_to_feature_values(mv_fs_values or ()): + feature_context["variants"] = variants + + if priority is not None: + feature_context["priority"] = priority + elif (feature_segment := feature_state.feature_segment) is not None: + feature_context["priority"] = feature_segment.priority + + return feature_context + + +def _map_mv_fs_values_to_feature_values( + mv_fs_values: "Iterable[MultivariateFeatureStateValue]", +) -> list[engine_types.FeatureValue]: + # Ordered by id, and weighted by position in that order, because that is + # the order Core API has always allocated percentages in. The engine + # orders by `priority`, so the two only agree if we hand it the id order. + feature_values: list[engine_types.FeatureValue] = [] + for index, mv_fs_value in enumerate(sorted(mv_fs_values, key=attrgetter("id"))): + mv_option = mv_fs_value.multivariate_feature_option + feature_value: engine_types.FeatureValue = { + "value": mv_option.value, + "weight": mv_fs_value.percentage_allocation, + "priority": index, + } + if mv_option.key is not None: + # An unkeyed option resolves to a null variant, as it does today. + feature_value["key"] = mv_option.key + feature_values.append(feature_value) + return feature_values + + +def map_segment_to_segment_context( + segment: "Segment", + *, + overrides: "list[FeatureContext] | None" = None, +) -> SegmentContext: + """Map a Django ORM Segment to a flag-engine SegmentContext TypedDict.""" + segment_context: SegmentContext = { + "key": str(segment.pk), + "name": segment.name, + "rules": [map_rule_to_segment_rule(rule) for rule in segment.rules.all()], + "metadata": SegmentEngineMetadata(source="segment", pk=segment.pk), + } + if overrides: + segment_context["overrides"] = overrides + return segment_context + + +def _map_identity_overrides_to_segment_context( + overrides: "list[FeatureContext]", +) -> SegmentContext: + """Express identity overrides as a segment matching only that identity. + + The engine has no identity-override concept, so SDKs model them as a + segment keyed on the identifier. Core API does the same, for one identity + at a time — the identity being evaluated is the only one whose overrides + are ever in the context. + """ + return { + "key": IDENTITY_OVERRIDES_SEGMENT_KEY, + "name": IDENTITY_OVERRIDES_SEGMENT_NAME, + "rules": [ + { + "type": "ALL", + "conditions": [ + { + "property": "$.identity.key", + "operator": IS_SET, + "value": "", + } + ], + } + ], + "overrides": overrides, + "metadata": SegmentEngineMetadata(source="identity_overrides"), + } + + +def map_rule_to_segment_rule(rule: "SegmentRule") -> engine_types.SegmentRule: + return { + "type": _rule_type_adapter.validate_python(rule.type), + "conditions": [ + map_condition_to_segment_condition(condition) + for condition in rule.conditions.all() + ], + "rules": [map_rule_to_segment_rule(sub_rule) for sub_rule in rule.rules.all()], + } + + +def map_condition_to_segment_condition( + condition: "Condition", +) -> engine_types.StrValueSegmentCondition: + return { + "property": condition.property or "", + "operator": _condition_operator_adapter.validate_python(condition.operator), + "value": condition.value or "", + } diff --git a/api/evaluation/services.py b/api/evaluation/services.py new file mode 100644 index 000000000000..00153b63cdc3 --- /dev/null +++ b/api/evaluation/services.py @@ -0,0 +1,35 @@ +from typing import TYPE_CHECKING + +from django.db.models import Q +from flag_engine.engine import get_evaluation_result + +from evaluation.mappers import map_environment_to_evaluation_context +from evaluation.types import IdentityEvaluation + +if TYPE_CHECKING: + from environments.identities.models import Identity + from environments.identities.traits.models import Trait + from environments.models import Environment + + +__all__ = ("evaluate_identity",) + + +def evaluate_identity( + identity: "Identity", + *, + traits: "list[Trait] | None" = None, + feature_name: str | None = None, + additional_filters: Q | None = None, +) -> IdentityEvaluation: + """Evaluate every flag in `identity`'s environment for that identity.""" + environment: "Environment" = identity.environment + context, feature_states_by_id = map_environment_to_evaluation_context( + environment=environment, + identity=identity, + traits=traits, + segments=environment.get_segments_from_cache(), + feature_name=feature_name, + additional_filters=additional_filters, + ) + return IdentityEvaluation(get_evaluation_result(context), feature_states_by_id) diff --git a/api/evaluation/types.py b/api/evaluation/types.py index d05fb3cfa443..8f65226ef6e8 100644 --- a/api/evaluation/types.py +++ b/api/evaluation/types.py @@ -1,4 +1,4 @@ -from typing import TypeAlias +from typing import TYPE_CHECKING, NamedTuple, TypeAlias from flag_engine.context import types as context_types from flag_engine.result import types as result_types @@ -6,11 +6,16 @@ from features.types import FeatureEngineMetadata from segments.types import SegmentEngineMetadata +if TYPE_CHECKING: + from features.models import FeatureState + + __all__ = ( "EvaluationContext", "EvaluationResult", "FeatureContext", "FlagResult", + "IdentityEvaluation", "SegmentContext", ) @@ -25,3 +30,11 @@ SegmentEngineMetadata, FeatureEngineMetadata ] FlagResult: TypeAlias = result_types.FlagResult[FeatureEngineMetadata] + + +class IdentityEvaluation(NamedTuple): + result: EvaluationResult + #: The feature states evaluated, by id. `FlagResult.metadata` carries a + #: `feature_state_id`, so a caller still working in Django rows can reach + #: the one a flag came from. + feature_states_by_id: "dict[int, FeatureState]" diff --git a/api/integrations/webhook/serializers.py b/api/integrations/webhook/serializers.py index ad644e1fc901..ddea12cbf988 100644 --- a/api/integrations/webhook/serializers.py +++ b/api/integrations/webhook/serializers.py @@ -4,12 +4,12 @@ from flag_engine.engine import get_evaluation_result from rest_framework import serializers +from evaluation.mappers import map_environment_to_evaluation_context from features.serializers import FeatureStateSerializerFull from integrations.common.serializers import ( BaseEnvironmentIntegrationModelSerializer, ) from segments.models import Segment -from util.mappers.engine import map_environment_to_evaluation_context from .models import WebhookConfiguration diff --git a/api/tests/evaluation_helpers.py b/api/tests/evaluation_helpers.py index b60e944eb775..169b27aed262 100644 --- a/api/tests/evaluation_helpers.py +++ b/api/tests/evaluation_helpers.py @@ -9,8 +9,8 @@ from flag_engine.engine import get_evaluation_result +from evaluation.mappers import map_feature_state_to_feature_context from evaluation.types import EvaluationContext -from util.mappers.engine import map_feature_state_to_feature_context if TYPE_CHECKING: from features.models import FeatureState diff --git a/api/tests/unit/evaluation/__init__.py b/api/tests/unit/evaluation/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/tests/unit/util/mappers/test_unit_mappers_evaluation_context.py b/api/tests/unit/evaluation/test_unit_evaluation_mappers.py similarity index 56% rename from api/tests/unit/util/mappers/test_unit_mappers_evaluation_context.py rename to api/tests/unit/evaluation/test_unit_evaluation_mappers.py index 1f79e639ae5d..b669a0e320b3 100644 --- a/api/tests/unit/util/mappers/test_unit_mappers_evaluation_context.py +++ b/api/tests/unit/evaluation/test_unit_evaluation_mappers.py @@ -1,16 +1,20 @@ import pytest from environments.identities.models import Identity +from environments.identities.traits.models import Trait from environments.models import Environment -from features.models import Feature, FeatureSegment, FeatureState -from features.multivariate.models import MultivariateFeatureStateValue -from segments.models import Segment -from util.mappers.engine import ( +from evaluation.mappers import ( IDENTITY_OVERRIDES_SEGMENT_KEY, IDENTITY_OVERRIDES_SEGMENT_NAME, + map_condition_to_segment_condition, map_environment_to_evaluation_context, map_feature_state_to_feature_context, + map_rule_to_segment_rule, + map_segment_to_segment_context, ) +from features.models import Feature, FeatureSegment, FeatureState +from features.multivariate.models import MultivariateFeatureStateValue +from segments.models import Condition, Segment, SegmentRule def test_map_environment_to_evaluation_context__environment_default__populates_features( @@ -237,3 +241,210 @@ def test_map_feature_state_to_feature_context__multivariate_feature__keys_on_has assert feature_context["key"] == ( str(feature_state.id) if expected_key == "id" else expected_key ) + + +def test_map_environment_to_evaluation_context__no_identity__returns_environment_only( + environment: Environment, +) -> None: + # Given / When + context, _ = map_environment_to_evaluation_context(environment=environment) + + # Then + assert context == { + "environment": { + "key": environment.api_key, + "name": environment.name, + }, + "features": {}, + } + + +def test_map_environment_to_evaluation_context__with_identity__returns_identity_context( + environment: Environment, + identity: Identity, +) -> None: + # Given / When + context, _ = map_environment_to_evaluation_context( + environment=environment, + identity=identity, + ) + + # Then + assert context == { + "environment": { + "key": environment.api_key, + "name": environment.name, + }, + "identity": { + "identifier": identity.identifier, + "key": identity.get_hash_key( + environment.use_identity_composite_key_for_hashing + ), + "traits": {}, + }, + "features": {}, + } + + +def test_map_environment_to_evaluation_context__with_explicit_traits__returns_given_traits( + environment: Environment, + identity: Identity, + trait: Trait, +) -> None: + # Given / When + context, _ = map_environment_to_evaluation_context( + environment=environment, + identity=identity, + traits=[trait], + ) + + # Then + assert context == { + "environment": { + "key": environment.api_key, + "name": environment.name, + }, + "identity": { + "identifier": identity.identifier, + "key": identity.get_hash_key( + environment.use_identity_composite_key_for_hashing + ), + "traits": {trait.trait_key: trait.trait_value}, + }, + "features": {}, + } + + +def test_map_environment_to_evaluation_context__no_explicit_traits__returns_identity_traits( + environment: Environment, + identity: Identity, + trait: Trait, +) -> None: + # Given / When + context, _ = map_environment_to_evaluation_context( + environment=environment, + identity=identity, + ) + + # Then + assert context == { + "environment": { + "key": environment.api_key, + "name": environment.name, + }, + "identity": { + "identifier": identity.identifier, + "key": identity.get_hash_key( + environment.use_identity_composite_key_for_hashing + ), + "traits": {trait.trait_key: trait.trait_value}, + }, + "features": {}, + } + + +def test_map_environment_to_evaluation_context__with_segments__returns_segment_contexts( + environment: Environment, + identity_matching_segment: Segment, +) -> None: + # Given / When + context, _ = map_environment_to_evaluation_context( + environment=environment, + segments=[identity_matching_segment], + ) + + # Then + segment_key = str(identity_matching_segment.pk) + assert context == { + "environment": { + "key": environment.api_key, + "name": environment.name, + }, + "segments": { + segment_key: map_segment_to_segment_context(identity_matching_segment), + }, + "features": {}, + } + + +def test_map_segment_to_segment_context__segment_with_rule__returns_expected( + identity_matching_segment: Segment, +) -> None: + # Given + condition = Condition.objects.get( + rule__segment=identity_matching_segment, + ) + + # When + result = map_segment_to_segment_context(identity_matching_segment) + + # Then + assert result == { + "key": str(identity_matching_segment.pk), + "name": identity_matching_segment.name, + "rules": [ + { + "type": "ALL", + "conditions": [ + { + "property": condition.property, + "operator": condition.operator, + "value": condition.value, + }, + ], + "rules": [], + }, + ], + "metadata": {"source": "segment", "pk": identity_matching_segment.pk}, + } + + +def test_map_rule_to_segment_rule__with_nested_rule__returns_expected( + segment_rule: SegmentRule, + identity_matching_segment: Segment, +) -> None: + # Given + matching_rule = SegmentRule.objects.get(segment=identity_matching_segment) + matching_rule.rules.add(segment_rule) + condition = Condition.objects.get(rule=matching_rule) + + # When + result = map_rule_to_segment_rule(matching_rule) + + # Then + assert result == { + "type": "ALL", + "conditions": [ + { + "property": condition.property, + "operator": condition.operator, + "value": condition.value, + }, + ], + "rules": [ + { + "type": "ALL", + "conditions": [], + "rules": [], + }, + ], + } + + +def test_map_condition_to_segment_condition__valid_condition__returns_expected( + identity_matching_segment: Segment, +) -> None: + # Given + condition = Condition.objects.get( + rule__segment=identity_matching_segment, + ) + + # When + result = map_condition_to_segment_condition(condition) + + # Then + assert result == { + "property": condition.property, + "operator": condition.operator, + "value": condition.value, + } diff --git a/api/tests/unit/environments/identities/test_unit_identities_services.py b/api/tests/unit/evaluation/test_unit_evaluation_services.py similarity index 99% rename from api/tests/unit/environments/identities/test_unit_identities_services.py rename to api/tests/unit/evaluation/test_unit_evaluation_services.py index 4da683f091b2..ac5b7688b81b 100644 --- a/api/tests/unit/environments/identities/test_unit_identities_services.py +++ b/api/tests/unit/evaluation/test_unit_evaluation_services.py @@ -3,9 +3,9 @@ from flag_engine.utils.hashing import get_hashed_percentage_for_object_ids from environments.identities.models import Identity -from environments.identities.services import evaluate_identity from environments.identities.traits.models import Trait from environments.models import Environment +from evaluation.services import evaluate_identity from features.constants import CONTROL_VARIANT_KEY from features.models import Feature, FeatureSegment, FeatureState from features.multivariate.models import MultivariateFeatureStateValue diff --git a/api/tests/unit/util/mappers/test_unit_mappers_engine.py b/api/tests/unit/util/mappers/test_unit_mappers_engine.py index f26bc23656de..5f1f0a96d0ed 100644 --- a/api/tests/unit/util/mappers/test_unit_mappers_engine.py +++ b/api/tests/unit/util/mappers/test_unit_mappers_engine.py @@ -18,7 +18,7 @@ from integrations.mixpanel.models import MixpanelConfiguration from integrations.segment.models import SegmentConfiguration from integrations.webhook.models import WebhookConfiguration -from segments.models import Condition, Segment, SegmentRule +from segments.models import Segment, SegmentRule from users.models import FFAdminUser from util.engine_models.environments.integrations.models import IntegrationModel from util.engine_models.environments.models import ( @@ -801,215 +801,6 @@ def test_map_environment_to_engine__v2_versioning_segment_override_removed__retu ) -def test_map_environment_to_evaluation_context__no_identity__returns_environment_only( - environment: Environment, -) -> None: - # Given / When - context, _ = engine.map_environment_to_evaluation_context(environment=environment) - - # Then - assert context == { - "environment": { - "key": environment.api_key, - "name": environment.name, - }, - "features": {}, - } - - -def test_map_environment_to_evaluation_context__with_identity__returns_identity_context( - environment: Environment, - identity: Identity, -) -> None: - # Given / When - context, _ = engine.map_environment_to_evaluation_context( - environment=environment, - identity=identity, - ) - - # Then - assert context == { - "environment": { - "key": environment.api_key, - "name": environment.name, - }, - "identity": { - "identifier": identity.identifier, - "key": identity.get_hash_key( - environment.use_identity_composite_key_for_hashing - ), - "traits": {}, - }, - "features": {}, - } - - -def test_map_environment_to_evaluation_context__with_explicit_traits__returns_given_traits( - environment: Environment, - identity: Identity, - trait: Trait, -) -> None: - # Given / When - context, _ = engine.map_environment_to_evaluation_context( - environment=environment, - identity=identity, - traits=[trait], - ) - - # Then - assert context == { - "environment": { - "key": environment.api_key, - "name": environment.name, - }, - "identity": { - "identifier": identity.identifier, - "key": identity.get_hash_key( - environment.use_identity_composite_key_for_hashing - ), - "traits": {trait.trait_key: trait.trait_value}, - }, - "features": {}, - } - - -def test_map_environment_to_evaluation_context__no_explicit_traits__returns_identity_traits( - environment: Environment, - identity: Identity, - trait: Trait, -) -> None: - # Given / When - context, _ = engine.map_environment_to_evaluation_context( - environment=environment, - identity=identity, - ) - - # Then - assert context == { - "environment": { - "key": environment.api_key, - "name": environment.name, - }, - "identity": { - "identifier": identity.identifier, - "key": identity.get_hash_key( - environment.use_identity_composite_key_for_hashing - ), - "traits": {trait.trait_key: trait.trait_value}, - }, - "features": {}, - } - - -def test_map_environment_to_evaluation_context__with_segments__returns_segment_contexts( - environment: Environment, - identity_matching_segment: Segment, -) -> None: - # Given / When - context, _ = engine.map_environment_to_evaluation_context( - environment=environment, - segments=[identity_matching_segment], - ) - - # Then - segment_key = str(identity_matching_segment.pk) - assert context == { - "environment": { - "key": environment.api_key, - "name": environment.name, - }, - "segments": { - segment_key: engine.map_segment_to_segment_context( - identity_matching_segment - ), - }, - "features": {}, - } - - -def test_map_segment_to_segment_context__segment_with_rule__returns_expected( - identity_matching_segment: Segment, -) -> None: - # Given - condition = Condition.objects.get( - rule__segment=identity_matching_segment, - ) - - # When - result = engine.map_segment_to_segment_context(identity_matching_segment) - - # Then - assert result == { - "key": str(identity_matching_segment.pk), - "name": identity_matching_segment.name, - "rules": [ - { - "type": "ALL", - "conditions": [ - { - "property": condition.property, - "operator": condition.operator, - "value": condition.value, - }, - ], - "rules": [], - }, - ], - "metadata": {"source": "segment", "pk": identity_matching_segment.pk}, - } - - -def test_map_rule_to_segment_rule__with_nested_rule__returns_expected( - segment_rule: SegmentRule, - identity_matching_segment: Segment, -) -> None: - # Given - matching_rule = SegmentRule.objects.get(segment=identity_matching_segment) - matching_rule.rules.add(segment_rule) - condition = Condition.objects.get(rule=matching_rule) - - # When - result = engine.map_rule_to_segment_rule(matching_rule) - - # Then - assert result == { - "type": "ALL", - "conditions": [ - { - "property": condition.property, - "operator": condition.operator, - "value": condition.value, - }, - ], - "rules": [ - { - "type": "ALL", - "conditions": [], - "rules": [], - }, - ], - } - - -def test_map_condition_to_segment_condition__valid_condition__returns_expected( - identity_matching_segment: Segment, -) -> None: - # Given - condition = Condition.objects.get( - rule__segment=identity_matching_segment, - ) - - # When - result = engine.map_condition_to_segment_condition(condition) - - # Then - assert result == { - "property": condition.property, - "operator": condition.operator, - "value": condition.value, - } - - def test_map_environment_to_engine__running_experiment__stamps_every_state_of_feature( environment: Environment, feature: "Feature", diff --git a/api/util/mappers/engine.py b/api/util/mappers/engine.py index 2bda41a02bc9..77ffc7a6bb02 100644 --- a/api/util/mappers/engine.py +++ b/api/util/mappers/engine.py @@ -1,21 +1,10 @@ from collections.abc import Iterable from itertools import chain -from math import inf -from operator import attrgetter -from typing import TYPE_CHECKING, Dict, List, NamedTuple, Optional +from typing import TYPE_CHECKING, Dict, List, Optional from uuid import UUID -from django.db.models import Prefetch, Q -from flag_engine.context import types as engine_types -from flag_engine.segments.constants import IS_SET -from flag_engine.segments.types import ConditionOperator, RuleType -from pydantic import TypeAdapter - from environments.constants import IDENTITY_INTEGRATIONS_RELATION_NAMES -from evaluation.types import EvaluationContext, FeatureContext, SegmentContext -from features.types import FeatureEngineMetadata from features.versioning.models import EnvironmentFeatureVersion -from segments.types import SegmentEngineMetadata from util.engine_models.environments.integrations.models import IntegrationModel from util.engine_models.environments.models import ( EnvironmentAPIKeyModel, @@ -54,34 +43,16 @@ from integrations.webhook.models import WebhookConfiguration from organisations.models import Organisation from projects.models import Project - from segments.models import Condition, Segment, SegmentRule - - -class MappedEvaluationContext(NamedTuple): - context: EvaluationContext - #: The feature states the context was built from, by id. Transitional — see - #: `map_environment_to_evaluation_context`. - feature_states_by_id: "dict[int, FeatureState]" + from segments.models import Segment, SegmentRule -#: Context key and name of the synthetic segment carrying identity overrides. -#: Not a segment id — prefixed so it cannot collide with one. -IDENTITY_OVERRIDES_SEGMENT_KEY = "$identity_overrides" -IDENTITY_OVERRIDES_SEGMENT_NAME = "identity_overrides" - __all__ = ( - "MappedEvaluationContext", - "map_condition_to_segment_condition", "map_environment_api_key_to_engine", "map_environment_to_engine", - "map_feature_state_to_feature_context", "map_feature_to_engine", "map_identity_to_engine", - "map_environment_to_evaluation_context", "map_mv_option_to_engine", - "map_rule_to_segment_rule", "map_segment_to_engine", - "map_segment_to_segment_context", "map_traits_to_engine", ) @@ -470,325 +441,6 @@ def map_identity_to_engine( ) -_rule_type_adapter: TypeAdapter[RuleType] = TypeAdapter(RuleType) -_condition_operator_adapter: TypeAdapter[ConditionOperator] = TypeAdapter( - ConditionOperator -) - - -def map_environment_to_evaluation_context( - *, - environment: "Environment", - identity: "Identity | None" = None, - traits: "Iterable[Trait] | None" = None, - segments: "Iterable[Segment] | None" = None, - feature_name: str | None = None, - additional_filters: "Q | None" = None, -) -> MappedEvaluationContext: - """Map Django ORM models to a flag-engine `EvaluationContext`. - - Resolves the feature states that are current for `environment` — defaults, - segment overrides, and `identity`'s own overrides — and lays them out as - `$.features` plus the overrides carried on each segment. - - Returns those feature states alongside the context, keyed by id, so that - callers still working in Django rows can map a `FlagResult` back to one via - `metadata.feature_state_id`. That is scaffolding for the migration off - `FeatureState.get_feature_state_value(identity=...)`; once serialisers read - values off the result, only the context is needed. - - :param segments: segments to evaluate. - """ - context: EvaluationContext = { - "environment": { - "key": environment.api_key, - "name": environment.name or "", - }, - } - if identity is not None: - trait_items: "Iterable[Trait]" = ( - traits - if traits is not None - # A transient identity was never persisted, so it has no stored - # traits to read, and asking for them would raise. - else identity.identity_traits.all() - if identity.pk - else () - ) - identity_traits = {trait.trait_key: trait.trait_value for trait in trait_items} - if identity.system_traits: - # System-owned traits are not user data: on a key clash, the system - # value wins. - identity_traits.update(identity.system_traits) - context["identity"] = { - "identifier": identity.identifier, - "key": identity.get_hash_key( - environment.use_identity_composite_key_for_hashing - ), - "traits": identity_traits, - } - - ( - feature_states, - features, - identity_overrides, - segment_overrides, - mv_fs_values_by_feature_state_id, - ) = _resolve_feature_states( - environment=environment, - identity=identity, - feature_name=feature_name, - additional_filters=additional_filters, - ) - - # No reading from ORM past this point! - - def to_feature_context( - feature_state: "FeatureState", - *, - segment_id: int | None = None, - priority: float | None = None, - ) -> FeatureContext: - return map_feature_state_to_feature_context( - feature_state, - mv_fs_values=mv_fs_values_by_feature_state_id.get(feature_state.pk), - segment_id=segment_id, - priority=priority, - ) - - if segments is not None: - context["segments"] = { - str(segment.pk): map_segment_to_segment_context( - segment, - overrides=[ - to_feature_context(feature_state, segment_id=segment.pk) - for feature_state in segment_overrides.get(segment.pk) or () - ], - ) - for segment in segments - } - - if identity_overrides: - # An identity override outranks every segment override, which the - # engine expresses as a priority no segment can beat. - context.setdefault("segments", {})[IDENTITY_OVERRIDES_SEGMENT_KEY] = ( - _map_identity_overrides_to_segment_context( - [ - to_feature_context(feature_state, priority=-inf) - for feature_state in identity_overrides - ] - ) - ) - - context["features"] = { - (feature_context := to_feature_context(feature_state))["name"]: feature_context - for feature_state in features - } - - return MappedEvaluationContext( - context=context, - feature_states_by_id={ - feature_state.pk: feature_state for feature_state in feature_states - }, - ) - - -class _ResolvedFeatureStates(NamedTuple): - all: list["FeatureState"] - #: Environment defaults, i.e. neither segment- nor identity-scoped. - features: list["FeatureState"] - identity_overrides: list["FeatureState"] - segment_overrides: dict[int, list["FeatureState"]] - mv_fs_values_by_feature_state_id: dict[ - int, "Iterable[MultivariateFeatureStateValue]" - ] - - -def _resolve_feature_states( - *, - environment: "Environment", - identity: "Identity | None", - feature_name: str | None, - additional_filters: "Q | None", -) -> _ResolvedFeatureStates: - """Read the feature states current for `environment`, split by what they override.""" - # Deferred: `environments.models` imports this module's package. - from features.multivariate.models import MultivariateFeatureStateValue - from features.versioning.versioning_service import get_environment_flags_list - - override_filters = Q(identity__isnull=True) - if identity is not None and identity.pk: - # The identity is persisted (non-transient). - # Look for its identity overrides in addition to segment overrides. - override_filters = Q(identity=identity) | override_filters - if additional_filters: - override_filters &= additional_filters - - feature_states = get_environment_flags_list( - environment=environment, - feature_name=feature_name, - additional_filters=override_filters, - additional_select_related_args=["feature_segment__segment"], - additional_prefetch_related_args=[ - Prefetch( - "multivariate_feature_state_values", - queryset=MultivariateFeatureStateValue.objects.select_related( - "multivariate_feature_option" - ), - ) - ], - ) - - resolved = _ResolvedFeatureStates(feature_states, [], [], {}, {}) - - for feature_state in feature_states: - resolved.mv_fs_values_by_feature_state_id[feature_state.pk] = ( - feature_state.multivariate_feature_state_values.all() - ) - if feature_state.identity_id is not None: - resolved.identity_overrides.append(feature_state) - elif (feature_segment := feature_state.feature_segment) is not None: - resolved.segment_overrides.setdefault( - feature_segment.segment_id, [] - ).append(feature_state) - else: - resolved.features.append(feature_state) - - return resolved - - -def map_feature_state_to_feature_context( - feature_state: "FeatureState", - *, - mv_fs_values: "Iterable[MultivariateFeatureStateValue] | None" = None, - segment_id: int | None = None, - priority: float | None = None, -) -> FeatureContext: - """Map a Django ORM FeatureState to a flag-engine FeatureContext TypedDict.""" - feature = feature_state.feature - metadata = FeatureEngineMetadata( - feature_id=feature.pk, - feature_state_id=feature_state.pk, - ) - if segment_id is not None: - metadata["segment_id"] = segment_id - if feature_state.identity_id is not None: - metadata["identity_id"] = feature_state.identity_id - - feature_context: FeatureContext = { - # The engine seeds multivariate variant allocation on the feature - # context key, so it has to be the bucketing seed rather than the - # feature state id, or recreating a feature state would move every - # enrolled identity to a different variant. See issue #7913. - "key": str(feature_state.mv_hashing_seed), - "name": feature.name, - "enabled": feature_state.enabled, - # Deliberately unparameterised by identity: picking a multivariate - # value is the engine's job now. - "value": feature_state.get_feature_state_value(), - "metadata": metadata, - } - - if variants := _map_mv_fs_values_to_feature_values(mv_fs_values or ()): - feature_context["variants"] = variants - - if priority is not None: - feature_context["priority"] = priority - elif (feature_segment := feature_state.feature_segment) is not None: - feature_context["priority"] = feature_segment.priority - - return feature_context - - -def _map_mv_fs_values_to_feature_values( - mv_fs_values: "Iterable[MultivariateFeatureStateValue]", -) -> list[engine_types.FeatureValue]: - # Ordered by id, and weighted by position in that order, because that is - # the order Core API has always allocated percentages in. The engine - # orders by `priority`, so the two only agree if we hand it the id order. - feature_values: list[engine_types.FeatureValue] = [] - for index, mv_fs_value in enumerate(sorted(mv_fs_values, key=attrgetter("id"))): - mv_option = mv_fs_value.multivariate_feature_option - feature_value: engine_types.FeatureValue = { - "value": mv_option.value, - "weight": mv_fs_value.percentage_allocation, - "priority": index, - } - if mv_option.key is not None: - # An unkeyed option resolves to a null variant, as it does today. - feature_value["key"] = mv_option.key - feature_values.append(feature_value) - return feature_values - - -def map_segment_to_segment_context( - segment: "Segment", - *, - overrides: "list[FeatureContext] | None" = None, -) -> SegmentContext: - """Map a Django ORM Segment to a flag-engine SegmentContext TypedDict.""" - segment_context: SegmentContext = { - "key": str(segment.pk), - "name": segment.name, - "rules": [map_rule_to_segment_rule(rule) for rule in segment.rules.all()], - "metadata": SegmentEngineMetadata(source="segment", pk=segment.pk), - } - if overrides: - segment_context["overrides"] = overrides - return segment_context - - -def _map_identity_overrides_to_segment_context( - overrides: "list[FeatureContext]", -) -> SegmentContext: - """Express identity overrides as a segment matching only that identity. - - The engine has no identity-override concept, so SDKs model them as a - segment keyed on the identifier. Core API does the same, for one identity - at a time — the identity being evaluated is the only one whose overrides - are ever in the context. - """ - return { - "key": IDENTITY_OVERRIDES_SEGMENT_KEY, - "name": IDENTITY_OVERRIDES_SEGMENT_NAME, - "rules": [ - { - "type": "ALL", - "conditions": [ - { - "property": "$.identity.key", - "operator": IS_SET, - "value": "", - } - ], - } - ], - "overrides": overrides, - "metadata": SegmentEngineMetadata(source="identity_overrides"), - } - - -def map_rule_to_segment_rule(rule: "SegmentRule") -> engine_types.SegmentRule: - return { - "type": _rule_type_adapter.validate_python(rule.type), - "conditions": [ - map_condition_to_segment_condition(condition) - for condition in rule.conditions.all() - ], - "rules": [map_rule_to_segment_rule(sub_rule) for sub_rule in rule.rules.all()], - } - - -def map_condition_to_segment_condition( - condition: "Condition", -) -> engine_types.StrValueSegmentCondition: - return { - "property": condition.property or "", - "operator": _condition_operator_adapter.validate_python(condition.operator), - "value": condition.value or "", - } - - def _get_prioritised_feature_states( feature_states: Iterable["FeatureState"], ) -> List["FeatureState"]: From 8676fc2f5b3824f76d20b5af80490c77e2e0b27e Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 22 Sep 2026 19:26:48 +0100 Subject: [PATCH 11/30] refactor(api): hand back evaluated feature states, not a lookup table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `IdentityEvaluation` carried the same `feature_states_by_id` map as `MappedEvaluationContext`, and its one caller used it only to look each row up and attach the flag it came from. Attach in the service instead and hand back the rows themselves, so `get_all_feature_states` is left doing nothing but the disabled-flag filter — on `feature_state.enabled`, as it did before any of this. `MappedEvaluationContext` stays: the mapper owns the query, so it is the only thing that can hand the rows back in the first place. --- api/environments/identities/models.py | 17 +++++++---------- api/evaluation/services.py | 12 +++++++++++- api/evaluation/types.py | 7 +++---- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/api/environments/identities/models.py b/api/environments/identities/models.py index 6648290354f5..0decad4b541e 100644 --- a/api/environments/identities/models.py +++ b/api/environments/identities/models.py @@ -73,22 +73,19 @@ def get_all_feature_states( :return: (list) flags for an identity with the correct values based on identity / segment priorities """ - result, feature_states_by_id = evaluate_identity( + _, feature_states = evaluate_identity( self, traits=traits, feature_name=feature_name, additional_filters=additional_filters, ) - hide_disabled_flags = self.environment.get_hide_disabled_flags() is True - - feature_states = [] - for flag in result["flags"].values(): - if hide_disabled_flags and not flag["enabled"]: - continue - feature_state = feature_states_by_id[flag["metadata"]["feature_state_id"]] - feature_state.flag_result = flag - feature_states.append(feature_state) + if self.environment.get_hide_disabled_flags() is True: + return [ + feature_state + for feature_state in feature_states + if feature_state.enabled + ] return feature_states diff --git a/api/evaluation/services.py b/api/evaluation/services.py index 00153b63cdc3..3cda7b7ec414 100644 --- a/api/evaluation/services.py +++ b/api/evaluation/services.py @@ -32,4 +32,14 @@ def evaluate_identity( feature_name=feature_name, additional_filters=additional_filters, ) - return IdentityEvaluation(get_evaluation_result(context), feature_states_by_id) + result = get_evaluation_result(context) + + # Hand back the rows the engine ruled on, carrying its verdict, so that + # callers neither re-resolve a value nor work out which row won. + feature_states = [] + for flag in result["flags"].values(): + feature_state = feature_states_by_id[flag["metadata"]["feature_state_id"]] + feature_state.flag_result = flag + feature_states.append(feature_state) + + return IdentityEvaluation(result, feature_states) diff --git a/api/evaluation/types.py b/api/evaluation/types.py index 8f65226ef6e8..cd2f71e5073a 100644 --- a/api/evaluation/types.py +++ b/api/evaluation/types.py @@ -34,7 +34,6 @@ class IdentityEvaluation(NamedTuple): result: EvaluationResult - #: The feature states evaluated, by id. `FlagResult.metadata` carries a - #: `feature_state_id`, so a caller still working in Django rows can reach - #: the one a flag came from. - feature_states_by_id: "dict[int, FeatureState]" + #: The evaluated feature states, each carrying its `flag_result`, for + #: callers still working in Django rows. + feature_states: "list[FeatureState]" From a854d811c1280f6ad57c7f7756fb9c7218e11680 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 22 Sep 2026 19:56:17 +0100 Subject: [PATCH 12/30] refactor(api): carry the feature state itself as engine metadata The engine hands a feature context's metadata back untouched on the corresponding flag, and places no constraint on its type. So the row can ride along directly, rather than an id the caller has to look up in a map the mapper had to return alongside the context. `FeatureEngineMetadata` loses `feature_id`, `segment_id` and `identity_id` with it: nothing read them, all three being derivable from the row. `MappedEvaluationContext` goes entirely, and the mapper returns just an `EvaluationContext`. --- api/environments/identities/models.py | 2 +- api/evaluation/mappers.py | 45 ++++--------------- api/evaluation/services.py | 4 +- api/features/types.py | 24 +++++----- api/integrations/webhook/serializers.py | 2 +- .../test_unit_evaluation_mappers.py | 37 ++++++--------- .../test_unit_evaluation_services.py | 2 +- 7 files changed, 40 insertions(+), 76 deletions(-) diff --git a/api/environments/identities/models.py b/api/environments/identities/models.py index 0decad4b541e..47755a39dece 100644 --- a/api/environments/identities/models.py +++ b/api/environments/identities/models.py @@ -120,7 +120,7 @@ def get_segments( all_segments = self.environment.project.get_segments_from_cache() segments_by_pk = {segment.pk: segment for segment in all_segments} - context, _ = map_environment_to_evaluation_context( + context = map_environment_to_evaluation_context( identity=self, environment=self.environment, traits=db_traits, diff --git a/api/evaluation/mappers.py b/api/evaluation/mappers.py index 6cc1bb2e8f64..9f3d798e49bc 100644 --- a/api/evaluation/mappers.py +++ b/api/evaluation/mappers.py @@ -32,7 +32,6 @@ __all__ = ( "IDENTITY_OVERRIDES_SEGMENT_KEY", "IDENTITY_OVERRIDES_SEGMENT_NAME", - "MappedEvaluationContext", "map_condition_to_segment_condition", "map_environment_to_evaluation_context", "map_feature_state_to_feature_context", @@ -41,13 +40,6 @@ ) -class MappedEvaluationContext(NamedTuple): - context: EvaluationContext - #: The feature states the context was built from, by id. Transitional — see - #: `map_environment_to_evaluation_context`. - feature_states_by_id: "dict[int, FeatureState]" - - #: Context key and name of the synthetic segment carrying identity overrides. #: Not a segment id — prefixed so it cannot collide with one. IDENTITY_OVERRIDES_SEGMENT_KEY = "$identity_overrides" @@ -68,18 +60,16 @@ def map_environment_to_evaluation_context( segments: "Iterable[Segment] | None" = None, feature_name: str | None = None, additional_filters: "Q | None" = None, -) -> MappedEvaluationContext: +) -> EvaluationContext: """Map Django ORM models to a flag-engine `EvaluationContext`. Resolves the feature states that are current for `environment` — defaults, segment overrides, and `identity`'s own overrides — and lays them out as `$.features` plus the overrides carried on each segment. - Returns those feature states alongside the context, keyed by id, so that - callers still working in Django rows can map a `FlagResult` back to one via - `metadata.feature_state_id`. That is scaffolding for the migration off - `FeatureState.get_feature_state_value(identity=...)`; once serialisers read - values off the result, only the context is needed. + Each feature context carries the row it was built from as metadata, which + the engine hands back on the corresponding `FlagResult`, so a caller still + working in Django rows never has to work out which override won. :param segments: segments to evaluate. """ @@ -113,7 +103,6 @@ def map_environment_to_evaluation_context( } ( - feature_states, features, identity_overrides, segment_overrides, @@ -130,13 +119,11 @@ def map_environment_to_evaluation_context( def to_feature_context( feature_state: "FeatureState", *, - segment_id: int | None = None, priority: float | None = None, ) -> FeatureContext: return map_feature_state_to_feature_context( feature_state, mv_fs_values=mv_fs_values_by_feature_state_id.get(feature_state.pk), - segment_id=segment_id, priority=priority, ) @@ -145,7 +132,7 @@ def to_feature_context( str(segment.pk): map_segment_to_segment_context( segment, overrides=[ - to_feature_context(feature_state, segment_id=segment.pk) + to_feature_context(feature_state) for feature_state in segment_overrides.get(segment.pk) or () ], ) @@ -169,16 +156,10 @@ def to_feature_context( for feature_state in features } - return MappedEvaluationContext( - context=context, - feature_states_by_id={ - feature_state.pk: feature_state for feature_state in feature_states - }, - ) + return context class _ResolvedFeatureStates(NamedTuple): - all: list["FeatureState"] #: Environment defaults, i.e. neither segment- nor identity-scoped. features: list["FeatureState"] identity_overrides: list["FeatureState"] @@ -223,7 +204,7 @@ def _resolve_feature_states( ], ) - resolved = _ResolvedFeatureStates(feature_states, [], [], {}, {}) + resolved = _ResolvedFeatureStates([], [], {}, {}) for feature_state in feature_states: resolved.mv_fs_values_by_feature_state_id[feature_state.pk] = ( @@ -245,20 +226,10 @@ def map_feature_state_to_feature_context( feature_state: "FeatureState", *, mv_fs_values: "Iterable[MultivariateFeatureStateValue] | None" = None, - segment_id: int | None = None, priority: float | None = None, ) -> FeatureContext: """Map a Django ORM FeatureState to a flag-engine FeatureContext TypedDict.""" feature = feature_state.feature - metadata = FeatureEngineMetadata( - feature_id=feature.pk, - feature_state_id=feature_state.pk, - ) - if segment_id is not None: - metadata["segment_id"] = segment_id - if feature_state.identity_id is not None: - metadata["identity_id"] = feature_state.identity_id - feature_context: FeatureContext = { # The engine seeds multivariate variant allocation on the feature # context key, so it has to be the bucketing seed rather than the @@ -270,7 +241,7 @@ def map_feature_state_to_feature_context( # Deliberately unparameterised by identity: picking a multivariate # value is the engine's job now. "value": feature_state.get_feature_state_value(), - "metadata": metadata, + "metadata": FeatureEngineMetadata(feature_state=feature_state), } if variants := _map_mv_fs_values_to_feature_values(mv_fs_values or ()): diff --git a/api/evaluation/services.py b/api/evaluation/services.py index 3cda7b7ec414..f5adc89cf7f4 100644 --- a/api/evaluation/services.py +++ b/api/evaluation/services.py @@ -24,7 +24,7 @@ def evaluate_identity( ) -> IdentityEvaluation: """Evaluate every flag in `identity`'s environment for that identity.""" environment: "Environment" = identity.environment - context, feature_states_by_id = map_environment_to_evaluation_context( + context = map_environment_to_evaluation_context( environment=environment, identity=identity, traits=traits, @@ -38,7 +38,7 @@ def evaluate_identity( # callers neither re-resolve a value nor work out which row won. feature_states = [] for flag in result["flags"].values(): - feature_state = feature_states_by_id[flag["metadata"]["feature_state_id"]] + feature_state = flag["metadata"]["feature_state"] feature_state.flag_result = flag feature_states.append(feature_state) diff --git a/api/features/types.py b/api/features/types.py index 72d852160773..edd9723bff15 100644 --- a/api/features/types.py +++ b/api/features/types.py @@ -1,17 +1,19 @@ -from typing_extensions import NotRequired, TypedDict +from typing import TYPE_CHECKING + +from typing_extensions import TypedDict + +if TYPE_CHECKING: + from features.models import FeatureState class FeatureEngineMetadata(TypedDict): - """Core API data carried on a `FeatureContext` and returned on a `FlagResult`. + """Core API data carried on a `FeatureContext` and back on a `FlagResult`. + + The engine treats this as opaque, so the row an evaluated flag came from + can simply ride along, saving callers from working out which override won. - The engine treats this as opaque. It exists so that callers can map an - evaluated flag back to the Django rows it was built from, without - re-deriving which override won. + The annotation is deliberately a forward reference: nothing here may import + Django at runtime, or `features.models` could not annotate against it. """ - feature_id: int - feature_state_id: int - #: Set when the context was built from a segment override. - segment_id: NotRequired[int] - #: Set when the context was built from an identity override. - identity_id: NotRequired[int] + feature_state: "FeatureState" diff --git a/api/integrations/webhook/serializers.py b/api/integrations/webhook/serializers.py index ddea12cbf988..7238b31a9614 100644 --- a/api/integrations/webhook/serializers.py +++ b/api/integrations/webhook/serializers.py @@ -29,7 +29,7 @@ class Meta: def get_member(self, obj: Segment) -> bool: identity = self.context["identity"] - context, _ = map_environment_to_evaluation_context( + context = map_environment_to_evaluation_context( identity=identity, environment=identity.environment, segments=[obj], diff --git a/api/tests/unit/evaluation/test_unit_evaluation_mappers.py b/api/tests/unit/evaluation/test_unit_evaluation_mappers.py index b669a0e320b3..08b0f6ac16a5 100644 --- a/api/tests/unit/evaluation/test_unit_evaluation_mappers.py +++ b/api/tests/unit/evaluation/test_unit_evaluation_mappers.py @@ -27,7 +27,7 @@ def test_map_environment_to_evaluation_context__environment_default__populates_f ) # When - context, feature_states_by_id = map_environment_to_evaluation_context( + context = map_environment_to_evaluation_context( environment=identity.environment, identity=identity, segments=identity.environment.get_segments_from_cache(), @@ -40,13 +40,9 @@ def test_map_environment_to_evaluation_context__environment_default__populates_f "name": feature.name, "enabled": feature_state.enabled, "value": feature_state.get_feature_state_value(), - "metadata": { - "feature_id": feature.pk, - "feature_state_id": feature_state.pk, - }, + "metadata": {"feature_state": feature_state}, } } - assert feature_states_by_id == {feature_state.pk: feature_state} def test_map_environment_to_evaluation_context__transient_identity__omits_stored_traits( @@ -57,7 +53,7 @@ def test_map_environment_to_evaluation_context__transient_identity__omits_stored transient_identity = Identity(identifier="transient", environment=environment) # When - context, _ = map_environment_to_evaluation_context( + context = map_environment_to_evaluation_context( environment=environment, identity=transient_identity, ) @@ -92,7 +88,7 @@ def test_map_environment_to_evaluation_context__segment_override__carries_segmen ) # When - context, _ = map_environment_to_evaluation_context( + context = map_environment_to_evaluation_context( environment=identity.environment, identity=identity, segments=identity.environment.get_segments_from_cache(), @@ -106,11 +102,7 @@ def test_map_environment_to_evaluation_context__segment_override__carries_segmen } (override_context,) = segment_context["overrides"] assert override_context["priority"] == 3 - assert override_context["metadata"] == { - "feature_id": feature.pk, - "feature_state_id": override.pk, - "segment_id": identity_matching_segment.pk, - } + assert override_context["metadata"] == {"feature_state": override} def test_map_environment_to_evaluation_context__identity_override__returns_synthetic_segment( @@ -126,7 +118,7 @@ def test_map_environment_to_evaluation_context__identity_override__returns_synth ) # When - context, _ = map_environment_to_evaluation_context( + context = map_environment_to_evaluation_context( environment=identity.environment, identity=identity, segments=identity.environment.get_segments_from_cache(), @@ -139,8 +131,7 @@ def test_map_environment_to_evaluation_context__identity_override__returns_synth (override_context,) = segment_context["overrides"] # No segment override may outrank an identity override. assert override_context["priority"] == float("-inf") - assert override_context["metadata"]["feature_state_id"] == override.pk - assert override_context["metadata"]["identity_id"] == identity.pk + assert override_context["metadata"] == {"feature_state": override} def test_map_environment_to_evaluation_context__multivariate_feature__weights_variants_in_id_order( @@ -153,7 +144,7 @@ def test_map_environment_to_evaluation_context__multivariate_feature__weights_va ) # When - context, _ = map_environment_to_evaluation_context( + context = map_environment_to_evaluation_context( environment=identity.environment, identity=identity, segments=identity.environment.get_segments_from_cache(), @@ -192,7 +183,7 @@ def test_map_environment_to_evaluation_context__hashing_setting__sets_matching_i environment.save() # When - context, _ = map_environment_to_evaluation_context( + context = map_environment_to_evaluation_context( environment=environment, identity=identity, ) @@ -247,7 +238,7 @@ def test_map_environment_to_evaluation_context__no_identity__returns_environment environment: Environment, ) -> None: # Given / When - context, _ = map_environment_to_evaluation_context(environment=environment) + context = map_environment_to_evaluation_context(environment=environment) # Then assert context == { @@ -264,7 +255,7 @@ def test_map_environment_to_evaluation_context__with_identity__returns_identity_ identity: Identity, ) -> None: # Given / When - context, _ = map_environment_to_evaluation_context( + context = map_environment_to_evaluation_context( environment=environment, identity=identity, ) @@ -292,7 +283,7 @@ def test_map_environment_to_evaluation_context__with_explicit_traits__returns_gi trait: Trait, ) -> None: # Given / When - context, _ = map_environment_to_evaluation_context( + context = map_environment_to_evaluation_context( environment=environment, identity=identity, traits=[trait], @@ -321,7 +312,7 @@ def test_map_environment_to_evaluation_context__no_explicit_traits__returns_iden trait: Trait, ) -> None: # Given / When - context, _ = map_environment_to_evaluation_context( + context = map_environment_to_evaluation_context( environment=environment, identity=identity, ) @@ -348,7 +339,7 @@ def test_map_environment_to_evaluation_context__with_segments__returns_segment_c identity_matching_segment: Segment, ) -> None: # Given / When - context, _ = map_environment_to_evaluation_context( + context = map_environment_to_evaluation_context( environment=environment, segments=[identity_matching_segment], ) diff --git a/api/tests/unit/evaluation/test_unit_evaluation_services.py b/api/tests/unit/evaluation/test_unit_evaluation_services.py index ac5b7688b81b..4aa4a84f56ed 100644 --- a/api/tests/unit/evaluation/test_unit_evaluation_services.py +++ b/api/tests/unit/evaluation/test_unit_evaluation_services.py @@ -55,7 +55,7 @@ def test_evaluate_identity__identity_and_segment_override__identity_override_win # Then flag = result["flags"][feature.name] assert flag["value"] == "identity" - assert flag["metadata"]["feature_state_id"] == identity_override.pk + assert flag["metadata"]["feature_state"] == identity_override def test_evaluate_identity__segment_overrides__lowest_priority_wins( From f1bbd513644ff1fdd6cc46863b7a035a49167126 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 22 Sep 2026 20:19:52 +0100 Subject: [PATCH 13/30] refactor(api): finish renaming _ResolvedFeatureStates.feature_states Carry the rename through to the one attribute access and the unpacked local, which still went by the old name. --- api/evaluation/mappers.py | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/api/evaluation/mappers.py b/api/evaluation/mappers.py index 9f3d798e49bc..d6d55a16156f 100644 --- a/api/evaluation/mappers.py +++ b/api/evaluation/mappers.py @@ -40,10 +40,7 @@ ) -#: Context key and name of the synthetic segment carrying identity overrides. -#: Not a segment id — prefixed so it cannot collide with one. -IDENTITY_OVERRIDES_SEGMENT_KEY = "$identity_overrides" -IDENTITY_OVERRIDES_SEGMENT_NAME = "identity_overrides" +IDENTITY_OVERRIDES_SEGMENT_KEY = IDENTITY_OVERRIDES_SEGMENT_NAME = "identity_overrides" _rule_type_adapter: TypeAdapter[RuleType] = TypeAdapter(RuleType) @@ -103,7 +100,7 @@ def map_environment_to_evaluation_context( } ( - features, + feature_states, identity_overrides, segment_overrides, mv_fs_values_by_feature_state_id, @@ -153,15 +150,14 @@ def to_feature_context( context["features"] = { (feature_context := to_feature_context(feature_state))["name"]: feature_context - for feature_state in features + for feature_state in feature_states } return context class _ResolvedFeatureStates(NamedTuple): - #: Environment defaults, i.e. neither segment- nor identity-scoped. - features: list["FeatureState"] + feature_states: list["FeatureState"] identity_overrides: list["FeatureState"] segment_overrides: dict[int, list["FeatureState"]] mv_fs_values_by_feature_state_id: dict[ @@ -217,7 +213,7 @@ def _resolve_feature_states( feature_segment.segment_id, [] ).append(feature_state) else: - resolved.features.append(feature_state) + resolved.feature_states.append(feature_state) return resolved @@ -296,13 +292,7 @@ def map_segment_to_segment_context( def _map_identity_overrides_to_segment_context( overrides: "list[FeatureContext]", ) -> SegmentContext: - """Express identity overrides as a segment matching only that identity. - - The engine has no identity-override concept, so SDKs model them as a - segment keyed on the identifier. Core API does the same, for one identity - at a time — the identity being evaluated is the only one whose overrides - are ever in the context. - """ + """Express identity overrides as a segment matching the current identity.""" return { "key": IDENTITY_OVERRIDES_SEGMENT_KEY, "name": IDENTITY_OVERRIDES_SEGMENT_NAME, From 9bef7713ca097253cdec888ca57003f21a332f6d Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 22 Sep 2026 20:27:04 +0100 Subject: [PATCH 14/30] fix(api): evaluate every flag before picking the one asked for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /flags/?identifier=&feature=` narrowed the query to a single feature, so the evaluation context held only that one. A segment condition depending on another flag could not be resolved from it. Filter after evaluating instead, as the equivalent identities endpoint already does, and drop `feature_name` from the evaluation path — that endpoint was its only caller. --- api/environments/identities/models.py | 2 -- api/evaluation/mappers.py | 4 ---- api/evaluation/services.py | 2 -- api/features/views.py | 18 ++++++++++++++---- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/api/environments/identities/models.py b/api/environments/identities/models.py index 47755a39dece..c9163a625b96 100644 --- a/api/environments/identities/models.py +++ b/api/environments/identities/models.py @@ -53,7 +53,6 @@ def get_hash_key(self, use_identity_composite_key_for_hashing: bool = False) -> def get_all_feature_states( self, - feature_name: str | None = None, traits: list[Trait] | None = None, additional_filters: Q | None = None, ) -> list[FeatureState]: @@ -76,7 +75,6 @@ def get_all_feature_states( _, feature_states = evaluate_identity( self, traits=traits, - feature_name=feature_name, additional_filters=additional_filters, ) diff --git a/api/evaluation/mappers.py b/api/evaluation/mappers.py index d6d55a16156f..680e50973286 100644 --- a/api/evaluation/mappers.py +++ b/api/evaluation/mappers.py @@ -55,7 +55,6 @@ def map_environment_to_evaluation_context( identity: "Identity | None" = None, traits: "Iterable[Trait] | None" = None, segments: "Iterable[Segment] | None" = None, - feature_name: str | None = None, additional_filters: "Q | None" = None, ) -> EvaluationContext: """Map Django ORM models to a flag-engine `EvaluationContext`. @@ -107,7 +106,6 @@ def map_environment_to_evaluation_context( ) = _resolve_feature_states( environment=environment, identity=identity, - feature_name=feature_name, additional_filters=additional_filters, ) @@ -169,7 +167,6 @@ def _resolve_feature_states( *, environment: "Environment", identity: "Identity | None", - feature_name: str | None, additional_filters: "Q | None", ) -> _ResolvedFeatureStates: """Read the feature states current for `environment`, split by what they override.""" @@ -187,7 +184,6 @@ def _resolve_feature_states( feature_states = get_environment_flags_list( environment=environment, - feature_name=feature_name, additional_filters=override_filters, additional_select_related_args=["feature_segment__segment"], additional_prefetch_related_args=[ diff --git a/api/evaluation/services.py b/api/evaluation/services.py index f5adc89cf7f4..e3d51d36f984 100644 --- a/api/evaluation/services.py +++ b/api/evaluation/services.py @@ -19,7 +19,6 @@ def evaluate_identity( identity: "Identity", *, traits: "list[Trait] | None" = None, - feature_name: str | None = None, additional_filters: Q | None = None, ) -> IdentityEvaluation: """Evaluate every flag in `identity`'s environment for that identity.""" @@ -29,7 +28,6 @@ def evaluate_identity( identity=identity, traits=traits, segments=environment.get_segments_from_cache(), - feature_name=feature_name, additional_filters=additional_filters, ) result = get_evaluation_result(context) diff --git a/api/features/views.py b/api/features/views.py index db62e6f445f1..2f15006b15ad 100644 --- a/api/features/views.py +++ b/api/features/views.py @@ -1130,19 +1130,29 @@ def _get_flags_response_with_identifier( ) replace_identity_environment(identity, request.environment) + feature_states = identity.get_all_feature_states() + if feature_name := request.GET.get("feature"): - feature_states = identity.get_all_feature_states(feature_name=feature_name) - if not feature_states: + # Filtered after evaluation rather than before: a segment may + # depend on a flag other than the one asked for. + feature_state = next( + ( + feature_state + for feature_state in feature_states + if feature_state.feature.name == feature_name + ), + None, + ) + if feature_state is None: return Response( {"detail": "Given feature not found"}, status=status.HTTP_404_NOT_FOUND, ) return Response( - self.get_serializer(feature_states[0]).data, + self.get_serializer(feature_state).data, status=status.HTTP_200_OK, ) - feature_states = identity.get_all_feature_states() flags = self.get_serializer(feature_states, many=True) return Response(flags.data, status=status.HTTP_200_OK) From a8a1554ee946eceb667a0a87daf2721e75fbd83d Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 22 Sep 2026 20:54:10 +0100 Subject: [PATCH 15/30] test(api): pin multivariate bucketing to static expectations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parity test computed what it expected with the engine's own hashing, so a change there would move both sides together and the test would still pass — proving nothing about identities keeping their variant. Split the feature into ten equal variants, so the variant an identity gets names the decile its hash fell in, and freeze the expected mapping as plain data. The values come from Core API's allocation as it stood before the engine took it over, reimplemented from first principles to derive them. --- .../test_unit_evaluation_services.py | 96 +++++++++++-------- 1 file changed, 58 insertions(+), 38 deletions(-) diff --git a/api/tests/unit/evaluation/test_unit_evaluation_services.py b/api/tests/unit/evaluation/test_unit_evaluation_services.py index 4aa4a84f56ed..91b6230e897a 100644 --- a/api/tests/unit/evaluation/test_unit_evaluation_services.py +++ b/api/tests/unit/evaluation/test_unit_evaluation_services.py @@ -1,14 +1,18 @@ import pytest from flag_engine.segments.constants import EQUAL -from flag_engine.utils.hashing import get_hashed_percentage_for_object_ids from environments.identities.models import Identity from environments.identities.traits.models import Trait from environments.models import Environment from evaluation.services import evaluate_identity from features.constants import CONTROL_VARIANT_KEY +from features.feature_types import MULTIVARIATE from features.models import Feature, FeatureSegment, FeatureState -from features.multivariate.models import MultivariateFeatureStateValue +from features.multivariate.models import ( + MultivariateFeatureOption, + MultivariateFeatureStateValue, +) +from features.value_types import STRING from projects.models import Project from segments.models import Condition, Segment, SegmentRule from tests.evaluation_helpers import evaluate_feature_state @@ -102,51 +106,67 @@ def test_evaluate_identity__segment_overrides__lowest_priority_wins( assert result["flags"][feature.name]["value"] == "winner" -@pytest.mark.parametrize("mv_hashing_salt", [None, 12345]) -def test_evaluate_identity__multivariate_feature__matches_legacy_bucketing( - identity: Identity, - multivariate_feature: Feature, - mv_hashing_salt: int | None, +#: How the identity keys below bucket for `MV_HASHING_SALT` under ten equal +#: variants, so each expectation names the decile its hash falls in. +#: +#: Derived from Core API's allocation as it stood before flag-engine took it +#: over — an md5 of "{seed},{identity key}", modulo 9999, over 9998 — and +#: frozen here as plain data. Deliberately not computed with the engine's own +#: hashing, which would move in step with any change and assert nothing. +#: +#: A failure here means enrolled identities would land on a different variant +#: than they do in production. See #7913. +MV_HASHING_SALT = 1 +VARIANT_BY_IDENTITY_KEY = { + "identity-0": "variant-9", + "identity-1": "variant-9", + "identity-2": "variant-9", + "identity-3": "variant-2", + "identity-4": "variant-1", + "identity-5": "variant-3", + "identity-6": "variant-4", + "identity-7": "variant-1", + "identity-8": "variant-2", + "identity-9": "variant-1", +} + + +def test_evaluate_feature_state__multivariate_feature__buckets_as_before_the_engine( + environment: Environment, + project: Project, ) -> None: - """The engine must bucket an identity exactly as Core API used to. - - Core API seeds allocation on `mv_hashing_seed`, a lineage constant that - survives a feature state being recreated (#7913). Seeding on anything else - — the feature state id, say — would silently move enrolled identities to a - different variant. - """ # Given - feature_state = FeatureState.objects.get( - feature=multivariate_feature, environment=identity.environment - ) - feature_state.mv_hashing_salt = mv_hashing_salt - feature_state.save() - - hash_key = identity.get_hash_key( - identity.environment.use_identity_composite_key_for_hashing + # a multivariate feature split into ten equal variants, so that the variant + # an identity gets names the decile its hash fell in + feature = Feature.objects.create( + name="decile_feature", + project=project, + type=MULTIVARIATE, + initial_value="control", ) + for index in range(10): + MultivariateFeatureOption.objects.create( + feature=feature, + default_percentage_allocation=10, + type=STRING, + string_value=f"variant-{index}-value", + key=f"variant-{index}", + ) - # The allocation Core API performed before the engine took it over, kept - # here as an oracle independent of the code under test. - percentage_value = get_hashed_percentage_for_object_ids( - [feature_state.mv_hashing_seed, hash_key] + feature_state = FeatureState.objects.get( + environment=environment, feature=feature, identity=None, feature_segment=None ) - expected_value = feature_state.get_feature_state_value() - start_percentage = 0.0 - for mv_value in sorted( - feature_state.multivariate_feature_state_values.all(), key=lambda o: o.id - ): - limit = mv_value.percentage_allocation + start_percentage - if start_percentage <= percentage_value < limit: - expected_value = mv_value.multivariate_feature_option.value - break - start_percentage = limit + feature_state.mv_hashing_salt = MV_HASHING_SALT + feature_state.save() # When - result, _ = evaluate_identity(identity) + variant_by_identity_key = { + identity_key: evaluate_feature_state(feature_state, identity_key).variant + for identity_key in VARIANT_BY_IDENTITY_KEY + } # Then - assert result["flags"][multivariate_feature.name]["value"] == expected_value + assert variant_by_identity_key == VARIANT_BY_IDENTITY_KEY def test_evaluate_identity__multivariate_feature__returns_variant_key( From 8edb0c6e45a3d606d69f6d572ea2bd52fbe6cd56 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 22 Sep 2026 21:04:41 +0100 Subject: [PATCH 16/30] test(api): assert bucketing through evaluate_identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tests/evaluation_helpers` reimplemented a slice of evaluation so that tests could pass a bare identity key, which meant the bucketing tests asserted of the helper rather than of anything a request goes through. Pin the environment's API key instead, so an identity's hash key — which is derived from it — is predictable, and go through `evaluate_identity`. A `variant_assignment` fixture replaces the helper for the before-and- after tests. The clone test keeps to the model: it clones across environments, where an identity of one has no hash key in the other. What it can assert on its own is that the salt carries, which is what makes bucketing stable; that the seed is honoured is asserted where evaluation is. --- api/tests/conftest.py | 25 ++++ api/tests/evaluation_helpers.py | 44 ------- api/tests/types.py | 9 ++ .../test_unit_evaluation_services.py | 113 ++++++++++-------- .../unit/experimentation/test_services.py | 39 ++---- .../features/test_unit_features_models.py | 38 +++--- ...test_unit_versioning_versioning_service.py | 28 ++--- .../core/test_unit_workflows_models.py | 22 ++-- 8 files changed, 151 insertions(+), 167 deletions(-) delete mode 100644 api/tests/evaluation_helpers.py diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 1fec4a7f847b..615116b19d35 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -77,6 +77,7 @@ UserEnvironmentPermission, UserPermissionGroupEnvironmentPermission, ) +from evaluation.services import evaluate_identity from experimentation.models import Experiment, ExperimentStatus from features.feature_external_resources.models import FeatureExternalResource from features.feature_types import MULTIVARIATE @@ -123,6 +124,7 @@ AdminClientAuthType, EnableFeaturesFixture, MigratorFactory, + VariantAssignmentFixture, WithEnvironmentPermissionsCallable, WithOrganisationPermissionsCallable, WithProjectPermissionsCallable, @@ -550,6 +552,29 @@ def environment_v2_versioning(environment: Environment) -> Environment: return environment +@pytest.fixture() +def variant_assignment() -> VariantAssignmentFixture: + """Which variant each identity is currently bucketed into. + + Goes through the same entry point the SDK endpoints do, so that a test + asserting an identity keeps its variant is asserting it of production + behaviour rather than of a probe written alongside it. + """ + + def _variant_assignment( + identities: list[Identity], + feature_name: str, + ) -> dict[str, str | None]: + return { + identity.identifier: evaluate_identity(identity).result["flags"][ + feature_name + ]["variant"] + for identity in identities + } + + return _variant_assignment + + @pytest.fixture() def identity(environment: Environment) -> Identity: return Identity.objects.create(identifier="test_identity", environment=environment) diff --git a/api/tests/evaluation_helpers.py b/api/tests/evaluation_helpers.py deleted file mode 100644 index 169b27aed262..000000000000 --- a/api/tests/evaluation_helpers.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Helpers for asserting how flag-engine evaluates a feature state. - -Multivariate allocation moved into the engine, so tests that used to probe it -via `FeatureState.get_multivariate_feature_state_value(key)` ask the engine the -same question here, for a bare identity key rather than a persisted identity. -""" - -from typing import TYPE_CHECKING, Any, NamedTuple - -from flag_engine.engine import get_evaluation_result - -from evaluation.mappers import map_feature_state_to_feature_context -from evaluation.types import EvaluationContext - -if TYPE_CHECKING: - from features.models import FeatureState - - -__all__ = ("EvaluatedFlag", "evaluate_feature_state") - - -class EvaluatedFlag(NamedTuple): - value: Any - variant: str | None - - -def evaluate_feature_state( - feature_state: "FeatureState", - identity_key: str | int, -) -> EvaluatedFlag: - """Evaluate `feature_state` as the engine would, for `identity_key`.""" - feature_name = feature_state.feature.name - context: EvaluationContext = { - "environment": {"key": "", "name": ""}, - "identity": {"identifier": "", "key": str(identity_key)}, - "features": { - feature_name: map_feature_state_to_feature_context( - feature_state, - mv_fs_values=feature_state.multivariate_feature_state_values.all(), - ) - }, - } - flag_result = get_evaluation_result(context)["flags"][feature_name] - return EvaluatedFlag(flag_result["value"], flag_result["variant"]) diff --git a/api/tests/types.py b/api/tests/types.py index 4c91e7b89594..7cf0104e17a7 100644 --- a/api/tests/types.py +++ b/api/tests/types.py @@ -2,6 +2,7 @@ from django_test_migrations.migrator import Migrator +from environments.identities.models import Identity from environments.permissions.models import UserEnvironmentPermission from organisations.permissions.models import UserOrganisationPermission from projects.models import UserProjectPermission @@ -46,6 +47,14 @@ class MigratorFactory(Protocol): def __call__(self, name: Optional[str] = None) -> Migrator: ... +class VariantAssignmentFixture(Protocol): + def __call__( + self, + identities: "list[Identity]", + feature_name: str, + ) -> dict[str, str | None]: ... + + class SetMultivariateAllocationsFixture(Protocol): def __call__( self, diff --git a/api/tests/unit/evaluation/test_unit_evaluation_services.py b/api/tests/unit/evaluation/test_unit_evaluation_services.py index 91b6230e897a..f18043013ec8 100644 --- a/api/tests/unit/evaluation/test_unit_evaluation_services.py +++ b/api/tests/unit/evaluation/test_unit_evaluation_services.py @@ -15,11 +15,32 @@ from features.value_types import STRING from projects.models import Project from segments.models import Condition, Segment, SegmentRule -from tests.evaluation_helpers import evaluate_feature_state #: `multivariate_feature`'s initial value, served when nothing is allocated. CONTROL_VALUE = "control" +#: Variant allocation is seeded on the feature state's hashing salt and the +#: identity's hash key, which is derived from the environment's API key. Pin +#: both, so that the expectations below can be plain data. +#: +#: They were derived from Core API's allocation as it stood before flag-engine +#: took it over — an md5 of "{seed},{identity key}", modulo 9999, over 9998 — +#: rather than computed with the engine's own hashing, which would move in step +#: with any change and so assert nothing. +#: +#: A failure means enrolled identities would land on a different variant than +#: they do in production. See #7913. +MV_HASHING_SALT = 1 +HASHING_ENVIRONMENT_API_KEY = "test-environment-key" + + +@pytest.fixture() +def hashing_environment(environment: Environment) -> Environment: + environment.api_key = HASHING_ENVIRONMENT_API_KEY + environment.use_identity_composite_key_for_hashing = True + environment.save() + return environment + def test_evaluate_identity__identity_and_segment_override__identity_override_wins( identity: Identity, @@ -106,38 +127,25 @@ def test_evaluate_identity__segment_overrides__lowest_priority_wins( assert result["flags"][feature.name]["value"] == "winner" -#: How the identity keys below bucket for `MV_HASHING_SALT` under ten equal -#: variants, so each expectation names the decile its hash falls in. -#: -#: Derived from Core API's allocation as it stood before flag-engine took it -#: over — an md5 of "{seed},{identity key}", modulo 9999, over 9998 — and -#: frozen here as plain data. Deliberately not computed with the engine's own -#: hashing, which would move in step with any change and assert nothing. -#: -#: A failure here means enrolled identities would land on a different variant -#: than they do in production. See #7913. -MV_HASHING_SALT = 1 -VARIANT_BY_IDENTITY_KEY = { - "identity-0": "variant-9", - "identity-1": "variant-9", - "identity-2": "variant-9", - "identity-3": "variant-2", - "identity-4": "variant-1", - "identity-5": "variant-3", - "identity-6": "variant-4", - "identity-7": "variant-1", - "identity-8": "variant-2", - "identity-9": "variant-1", -} - - -def test_evaluate_feature_state__multivariate_feature__buckets_as_before_the_engine( - environment: Environment, +def test_evaluate_identity__multivariate_feature__buckets_as_before_the_engine( + hashing_environment: Environment, project: Project, ) -> None: # Given - # a multivariate feature split into ten equal variants, so that the variant - # an identity gets names the decile its hash fell in + # ten equal variants, so the variant an identity gets names the decile its + # hash fell in + expected_variant_by_identifier = { + "identity-0": "variant-3", + "identity-1": "variant-0", + "identity-2": "variant-2", + "identity-3": "variant-5", + "identity-4": "variant-8", + "identity-5": "variant-5", + "identity-6": "variant-1", + "identity-7": "variant-2", + "identity-8": "variant-2", + "identity-9": "variant-1", + } feature = Feature.objects.create( name="decile_feature", project=project, @@ -154,19 +162,26 @@ def test_evaluate_feature_state__multivariate_feature__buckets_as_before_the_eng ) feature_state = FeatureState.objects.get( - environment=environment, feature=feature, identity=None, feature_segment=None + environment=hashing_environment, + feature=feature, + identity=None, + feature_segment=None, ) feature_state.mv_hashing_salt = MV_HASHING_SALT feature_state.save() # When - variant_by_identity_key = { - identity_key: evaluate_feature_state(feature_state, identity_key).variant - for identity_key in VARIANT_BY_IDENTITY_KEY + variant_by_identifier = { + identifier: evaluate_identity( + Identity.objects.create( + identifier=identifier, environment=hashing_environment + ) + ).result["flags"][feature.name]["variant"] + for identifier in expected_variant_by_identifier } # Then - assert variant_by_identity_key == VARIANT_BY_IDENTITY_KEY + assert variant_by_identifier == expected_variant_by_identifier def test_evaluate_identity__multivariate_feature__returns_variant_key( @@ -196,33 +211,33 @@ def test_evaluate_identity__multivariate_feature__returns_variant_key( @pytest.mark.parametrize( - ["identity_key", "expected_variant", "expected_value"], + ["identifier", "expected_variant", "expected_value"], ( - pytest.param("identity-4", "variant-1", "variant-1-value", id="first_band"), - pytest.param("identity-3", "variant-2", "variant-2-value", id="second_band"), + pytest.param("identity-1", "variant-1", "variant-1-value", id="first_band"), + pytest.param("identity-2", "variant-2", "variant-2-value", id="second_band"), pytest.param( - "identity-0", + "identity-4", CONTROL_VARIANT_KEY, CONTROL_VALUE, id="unallocated_falls_through", ), ), ) -def test_evaluate_feature_state__multivariate_feature__allocates_variants_in_order( - environment: Environment, +def test_evaluate_identity__multivariate_feature__allocates_variants_in_order( + hashing_environment: Environment, multivariate_feature: Feature, - identity_key: str, + identifier: str, expected_variant: str, expected_value: str, ) -> None: # Given feature_state = FeatureState.objects.get( - environment=environment, + environment=hashing_environment, feature=multivariate_feature, identity=None, feature_segment=None, ) - feature_state.mv_hashing_salt = 1 + feature_state.mv_hashing_salt = MV_HASHING_SALT feature_state.save() # Two variants taking 20% and 30%, leaving half the range to the control. @@ -240,9 +255,13 @@ def test_evaluate_feature_state__multivariate_feature__allocates_variants_in_ord option.string_value = f"variant-{index + 1}-value" option.save() + identity = Identity.objects.create( + identifier=identifier, environment=hashing_environment + ) + # When - evaluated = evaluate_feature_state(feature_state, identity_key) + flag = evaluate_identity(identity).result["flags"][multivariate_feature.name] # Then - assert evaluated.variant == expected_variant - assert evaluated.value == expected_value + assert flag["variant"] == expected_variant + assert flag["value"] == expected_value diff --git a/api/tests/unit/experimentation/test_services.py b/api/tests/unit/experimentation/test_services.py index 4742d9619652..3758e0b0d4a0 100644 --- a/api/tests/unit/experimentation/test_services.py +++ b/api/tests/unit/experimentation/test_services.py @@ -5,7 +5,6 @@ import pytest from django.db import IntegrityError, connection -from django.db.models import Q from django.test.utils import CaptureQueriesContext from flag_engine.segments.constants import EQUAL, PERCENTAGE_SPLIT from prometheus_client import REGISTRY @@ -66,7 +65,7 @@ from organisations.models import Organisation from projects.models import Project from segments.models import Condition, Segment, SegmentRule -from tests.evaluation_helpers import evaluate_feature_state +from tests.types import VariantAssignmentFixture from tests.unit.experimentation.conftest import RolloutSpecFactory from users.models import FFAdminUser from util.mappers import map_environment_to_environment_document @@ -2570,6 +2569,7 @@ def test_apply_experiment_rollout__reapplied_under_v2__keeps_variant_assignment( multivariate_feature: Feature, multivariate_options: list[MultivariateFeatureOption], admin_user: FFAdminUser, + variant_assignment: VariantAssignmentFixture, ) -> None: # Given a running experiment whose rollout splits two variants 50/50 option_a, option_b, _ = multivariate_options @@ -2596,43 +2596,28 @@ def test_apply_experiment_rollout__reapplied_under_v2__keeps_variant_assignment( ], author=AuthorData(user=admin_user), ) - identity_hash_keys = [f"identity-{i}" for i in range(50)] - - def variant_assignment() -> dict[str, str]: - override = ( - FeatureState.objects.get_live_feature_states( - environment=experiment.environment, - additional_filters=Q( - feature_segment__segment=experiment.rollout_segment, - identity__isnull=True, - ), - feature_id=experiment.feature_id, - ) - .prefetch_related( - "multivariate_feature_state_values__multivariate_feature_option" - ) - .latest("id") + # and identities, some of which the rollout's percentage split enrols + identities = [ + Identity.objects.create( + identifier=f"identity-{i}", environment=environment_v2_versioning ) - assignment: dict[str, str] = {} - for key in identity_hash_keys: - variant = evaluate_feature_state(override, key).variant - # The 50/50 split allocates 100%, so every identity lands on a variant. - assert variant is not None - assignment[key] = variant - return assignment + for i in range(50) + ] # When the rollout is applied, then re-applied unchanged (e.g. tuned while # the experiment is running) services.apply_experiment_rollout(experiment, spec) experiment.refresh_from_db() - before = variant_assignment() + before = variant_assignment(identities, multivariate_feature.name) services.apply_experiment_rollout(experiment, spec) - after = variant_assignment() + after = variant_assignment(identities, multivariate_feature.name) # Then every already-enrolled identity keeps the variant it was first # assigned; tuning the rollout must not re-randomise the split. assert before == after + # and the split is not trivially one-sided, so the above means something + assert len(set(before.values())) > 1 def _verification_count(result: str) -> float: diff --git a/api/tests/unit/features/test_unit_features_models.py b/api/tests/unit/features/test_unit_features_models.py index 11789b86a797..bd10f38da8d7 100644 --- a/api/tests/unit/features/test_unit_features_models.py +++ b/api/tests/unit/features/test_unit_features_models.py @@ -28,7 +28,6 @@ from projects.models import Project from projects.tags.models import Tag from segments.models import Segment -from tests.evaluation_helpers import evaluate_feature_state from users.models import FFAdminUser now = timezone.now() @@ -612,44 +611,35 @@ def test_feature_state_type__feature_segment_state__returns_feature_segment( assert result == FEATURE_SEGMENT -def test_feature_state_clone__multivariate_feature__keeps_variant_bucketing_stable( +def test_feature_state_clone__multivariate_feature__carries_source_id_as_salt( multivariate_feature: Feature, environment: Environment, environment_two: Environment, ) -> None: - # Given the environment-default feature state for a multivariate feature, and - # the variant each of a range of identities is currently bucketed into - # The fixture derives an option's value from its percentage, so two of them - # share a value. Key them so a variant identifies which option won. - for index, option in enumerate(multivariate_feature.multivariate_options.all()): - option.key = f"variant-{index}" - option.save() + """Cloning must not re-randomise which variant an identity is served. + + Allocation is seeded on `mv_hashing_seed`, so a clone carrying the source + id as its salt keeps every enrolled identity where it was. That the seed + is honoured through a real evaluation is covered in + `tests/unit/evaluation`, and across a feature state actually being + recreated in the change request and versioning tests. + """ + # Given feature_state = FeatureState.objects.get( environment=environment, feature=multivariate_feature, identity=None, feature_segment=None, ) - identity_hash_keys = [f"identity-{i}" for i in range(50)] - original_assignment = { - key: evaluate_feature_state(feature_state, key).variant - for key in identity_hash_keys - } + assert feature_state.mv_hashing_salt is None - # When the feature state is recreated by cloning it (e.g. publishing a new - # version or editing multivariate weights under v2 versioning) + # When cloned_feature_state = feature_state.clone(env=environment_two, as_draft=True) - # Then the clone keeps the original feature state's id as its bucketing salt + # Then assert cloned_feature_state.id != feature_state.id assert cloned_feature_state.mv_hashing_salt == feature_state.id - - # and every identity stays in the same variant as before - cloned_assignment = { - key: evaluate_feature_state(cloned_feature_state, key).variant - for key in identity_hash_keys - } - assert cloned_assignment == original_assignment + assert cloned_feature_state.mv_hashing_seed == feature_state.mv_hashing_seed def test_feature_state_clone__existing_mv_hashing_salt__is_preserved( diff --git a/api/tests/unit/features/versioning/test_unit_versioning_versioning_service.py b/api/tests/unit/features/versioning/test_unit_versioning_versioning_service.py index 279aaa92acfa..fcf7ba5f68ea 100644 --- a/api/tests/unit/features/versioning/test_unit_versioning_versioning_service.py +++ b/api/tests/unit/features/versioning/test_unit_versioning_versioning_service.py @@ -32,7 +32,7 @@ ) from projects.models import Project from segments.models import Segment -from tests.evaluation_helpers import evaluate_feature_state +from tests.types import VariantAssignmentFixture from users.models import FFAdminUser @@ -1043,6 +1043,7 @@ def test_update_flag__v2_versioning_multivariate_weight_increase__keeps_enrolled multivariate_feature: Feature, multivariate_options: list[MultivariateFeatureOption], admin_user: FFAdminUser, + variant_assignment: VariantAssignmentFixture, ) -> None: # Given a multivariate feature split 50/50 between two variants, and the # variant each of a range of identities is bucketed into @@ -1053,7 +1054,7 @@ def test_update_flag__v2_versioning_multivariate_weight_increase__keeps_enrolled for index, option in enumerate(multivariate_options): option.key = f"variant-{index}" option.save() - feature_state = update_flag( + update_flag( environment_v2_versioning, multivariate_feature, FlagChangeSet( @@ -1068,14 +1069,16 @@ def test_update_flag__v2_versioning_multivariate_weight_increase__keeps_enrolled ], ), ) - identity_hash_keys = [f"identity-{i}" for i in range(100)] - original_assignment = { - key: evaluate_feature_state(feature_state, key).variant - for key in identity_hash_keys - } + identities = [ + Identity.objects.create( + identifier=f"identity-{i}", environment=environment_v2_versioning + ) + for i in range(100) + ] + original_assignment = variant_assignment(identities, multivariate_feature.name) # When the first variant's allocation is increased to 60/40 - new_feature_state = update_flag( + update_flag( environment_v2_versioning, multivariate_feature, FlagChangeSet( @@ -1093,13 +1096,10 @@ def test_update_flag__v2_versioning_multivariate_weight_increase__keeps_enrolled # Then identities already in the grown variant stay in it, and the only # movement is from the shrunk variant into the grown one - new_assignment = { - key: evaluate_feature_state(new_feature_state, key).variant - for key in identity_hash_keys - } + new_assignment = variant_assignment(identities, multivariate_feature.name) movers = { key - for key in identity_hash_keys + for key in original_assignment if new_assignment[key] != original_assignment[key] } assert movers @@ -1107,6 +1107,6 @@ def test_update_flag__v2_versioning_multivariate_weight_increase__keeps_enrolled assert all(new_assignment[key] == option_a.key for key in movers) assert all( new_assignment[key] == original_assignment[key] - for key in identity_hash_keys + for key in original_assignment if key not in movers ) diff --git a/api/tests/unit/features/workflows/core/test_unit_workflows_models.py b/api/tests/unit/features/workflows/core/test_unit_workflows_models.py index 5e2121ae35d7..d79edeb84312 100644 --- a/api/tests/unit/features/workflows/core/test_unit_workflows_models.py +++ b/api/tests/unit/features/workflows/core/test_unit_workflows_models.py @@ -23,6 +23,7 @@ from audit.related_object_type import RelatedObjectType from cohorts.models import Cohort from core.helpers import get_current_site_url +from environments.identities.models import Identity from environments.models import Environment from features.models import Feature, FeatureSegment, FeatureState from features.versioning.models import ( @@ -48,7 +49,7 @@ # TODO: Delete alias as per https://github.com/Flagsmith/flagsmith/issues/7818 from segments.types import SegmentRule as SegmentRuleType -from tests.evaluation_helpers import evaluate_feature_state +from tests.types import VariantAssignmentFixture from users.models import FFAdminUser now = timezone.now() @@ -1243,6 +1244,7 @@ def test_change_request_commit__v1_multivariate_feature__keeps_variant_bucketing environment: Environment, multivariate_feature: Feature, admin_user: FFAdminUser, + variant_assignment: VariantAssignmentFixture, ) -> None: # Given the current live environment-default feature state of a multivariate # feature, and the variant each of a range of identities is bucketed into @@ -1257,11 +1259,11 @@ def test_change_request_commit__v1_multivariate_feature__keeps_variant_bucketing identity=None, feature_segment=None, ) - identity_hash_keys = [f"identity-{i}" for i in range(50)] - original_assignment = { - key: evaluate_feature_state(live_feature_state, key).variant - for key in identity_hash_keys - } + identities = [ + Identity.objects.create(identifier=f"identity-{i}", environment=environment) + for i in range(50) + ] + original_assignment = variant_assignment(identities, multivariate_feature.name) # and a change request carrying a draft feature state for the same feature, # as created by the API when a change request is raised under v1 versioning @@ -1291,11 +1293,9 @@ def test_change_request_commit__v1_multivariate_feature__keeps_variant_bucketing assert new_live_feature_state.mv_hashing_salt == live_feature_state.id # and every identity stays in the same variant as before the commit - new_assignment = { - key: evaluate_feature_state(new_live_feature_state, key).variant - for key in identity_hash_keys - } - assert new_assignment == original_assignment + assert variant_assignment(identities, multivariate_feature.name) == ( + original_assignment + ) def test_change_request_commit__v1_segment_override_draft__inherits_mv_hashing_salt( From a3476c1e416ac07f3a3bbd8db7c05b79f881ae22 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 22 Sep 2026 21:09:56 +0100 Subject: [PATCH 17/30] test(api): make the pinned bucketing inputs fixtures Each constant says what it is in a docstring now, rather than in a block comment pointing at whatever happened to follow it, and the provenance of the frozen expectations sits on the test that holds them. --- .../test_unit_evaluation_services.py | 61 ++++++++++++------- 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/api/tests/unit/evaluation/test_unit_evaluation_services.py b/api/tests/unit/evaluation/test_unit_evaluation_services.py index f18043013ec8..81d60ddaf379 100644 --- a/api/tests/unit/evaluation/test_unit_evaluation_services.py +++ b/api/tests/unit/evaluation/test_unit_evaluation_services.py @@ -1,5 +1,6 @@ import pytest from flag_engine.segments.constants import EQUAL +from pytest_lazy_fixtures import lf as lazy_fixture from environments.identities.models import Identity from environments.identities.traits.models import Trait @@ -16,27 +17,32 @@ from projects.models import Project from segments.models import Condition, Segment, SegmentRule -#: `multivariate_feature`'s initial value, served when nothing is allocated. -CONTROL_VALUE = "control" -#: Variant allocation is seeded on the feature state's hashing salt and the -#: identity's hash key, which is derived from the environment's API key. Pin -#: both, so that the expectations below can be plain data. -#: -#: They were derived from Core API's allocation as it stood before flag-engine -#: took it over — an md5 of "{seed},{identity key}", modulo 9999, over 9998 — -#: rather than computed with the engine's own hashing, which would move in step -#: with any change and so assert nothing. -#: -#: A failure means enrolled identities would land on a different variant than -#: they do in production. See #7913. -MV_HASHING_SALT = 1 -HASHING_ENVIRONMENT_API_KEY = "test-environment-key" +@pytest.fixture() +def control_value() -> str: + """`multivariate_feature`'s initial value, served when nothing is allocated.""" + return "control" @pytest.fixture() -def hashing_environment(environment: Environment) -> Environment: - environment.api_key = HASHING_ENVIRONMENT_API_KEY +def mv_hashing_salt() -> int: + """A pinned bucketing seed, half of what decides an identity's variant.""" + return 1 + + +@pytest.fixture() +def hashing_environment_api_key() -> str: + """A pinned API key, which an identity's hash key is derived from.""" + return "test-environment-key" + + +@pytest.fixture() +def hashing_environment( + environment: Environment, + hashing_environment_api_key: str, +) -> Environment: + """An environment whose identities bucket predictably.""" + environment.api_key = hashing_environment_api_key environment.use_identity_composite_key_for_hashing = True environment.save() return environment @@ -129,8 +135,19 @@ def test_evaluate_identity__segment_overrides__lowest_priority_wins( def test_evaluate_identity__multivariate_feature__buckets_as_before_the_engine( hashing_environment: Environment, + mv_hashing_salt: int, project: Project, ) -> None: + """An identity must land on the variant it always has. + + The expectations were derived from Core API's allocation as it stood + before flag-engine took it over — an md5 of "{seed},{identity key}", + modulo 9999, over 9998 — rather than computed with the engine's own + hashing, which would move in step with any change and so assert nothing. + + A failure means enrolled identities would be served a different variant + than they are in production. See #7913. + """ # Given # ten equal variants, so the variant an identity gets names the decile its # hash fell in @@ -167,7 +184,7 @@ def test_evaluate_identity__multivariate_feature__buckets_as_before_the_engine( identity=None, feature_segment=None, ) - feature_state.mv_hashing_salt = MV_HASHING_SALT + feature_state.mv_hashing_salt = mv_hashing_salt feature_state.save() # When @@ -205,8 +222,7 @@ def test_evaluate_identity__multivariate_feature__returns_variant_key( # Then flag = result["flags"][multivariate_feature.name] - # Either a named variant or the control bucket — never a silent `None`, - # which is what an unkeyed variant context would produce. + # Either a named variant or the control bucket assert flag["variant"] in {"control", "variant-0", "variant-1", "variant-2"} @@ -218,13 +234,14 @@ def test_evaluate_identity__multivariate_feature__returns_variant_key( pytest.param( "identity-4", CONTROL_VARIANT_KEY, - CONTROL_VALUE, + lazy_fixture("control_value"), id="unallocated_falls_through", ), ), ) def test_evaluate_identity__multivariate_feature__allocates_variants_in_order( hashing_environment: Environment, + mv_hashing_salt: int, multivariate_feature: Feature, identifier: str, expected_variant: str, @@ -237,7 +254,7 @@ def test_evaluate_identity__multivariate_feature__allocates_variants_in_order( identity=None, feature_segment=None, ) - feature_state.mv_hashing_salt = MV_HASHING_SALT + feature_state.mv_hashing_salt = mv_hashing_salt feature_state.save() # Two variants taking 20% and 30%, leaving half the range to the control. From e8dafec052d56eae47a4da73ce514a251741ff5b Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 22 Sep 2026 21:14:05 +0100 Subject: [PATCH 18/30] test(api): trim bucketing test comments --- .../evaluation/test_unit_evaluation_services.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/api/tests/unit/evaluation/test_unit_evaluation_services.py b/api/tests/unit/evaluation/test_unit_evaluation_services.py index 81d60ddaf379..28bfcde896e1 100644 --- a/api/tests/unit/evaluation/test_unit_evaluation_services.py +++ b/api/tests/unit/evaluation/test_unit_evaluation_services.py @@ -20,19 +20,16 @@ @pytest.fixture() def control_value() -> str: - """`multivariate_feature`'s initial value, served when nothing is allocated.""" return "control" @pytest.fixture() def mv_hashing_salt() -> int: - """A pinned bucketing seed, half of what decides an identity's variant.""" return 1 @pytest.fixture() def hashing_environment_api_key() -> str: - """A pinned API key, which an identity's hash key is derived from.""" return "test-environment-key" @@ -138,16 +135,7 @@ def test_evaluate_identity__multivariate_feature__buckets_as_before_the_engine( mv_hashing_salt: int, project: Project, ) -> None: - """An identity must land on the variant it always has. - - The expectations were derived from Core API's allocation as it stood - before flag-engine took it over — an md5 of "{seed},{identity key}", - modulo 9999, over 9998 — rather than computed with the engine's own - hashing, which would move in step with any change and so assert nothing. - - A failure means enrolled identities would be served a different variant - than they are in production. See #7913. - """ + """The expectations are derived from Core API's allocation as it stood before flag-engine took over.""" # Given # ten equal variants, so the variant an identity gets names the decile its # hash fell in From e703ee44228e250452a960f168fd84465cd074dc Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 22 Sep 2026 21:20:51 +0100 Subject: [PATCH 19/30] test(api): drop the clone test docstring --- api/tests/unit/features/test_unit_features_models.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/api/tests/unit/features/test_unit_features_models.py b/api/tests/unit/features/test_unit_features_models.py index bd10f38da8d7..ea1a826b8167 100644 --- a/api/tests/unit/features/test_unit_features_models.py +++ b/api/tests/unit/features/test_unit_features_models.py @@ -616,14 +616,6 @@ def test_feature_state_clone__multivariate_feature__carries_source_id_as_salt( environment: Environment, environment_two: Environment, ) -> None: - """Cloning must not re-randomise which variant an identity is served. - - Allocation is seeded on `mv_hashing_seed`, so a clone carrying the source - id as its salt keeps every enrolled identity where it was. That the seed - is honoured through a real evaluation is covered in - `tests/unit/evaluation`, and across a feature state actually being - recreated in the change request and versioning tests. - """ # Given feature_state = FeatureState.objects.get( environment=environment, From 8ae3f1f8b6e18b93d961e74435280741cf102c96 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 22 Sep 2026 21:28:54 +0100 Subject: [PATCH 20/30] refactor(api): move get_all_feature_states to the evaluation package Once evaluation moved out, the method was a shim: delegate, then drop disabled flags. Its docstring still described resolving overrides by priority, which the engine has been doing for some time. `evaluation.services.get_identity_feature_states` takes its place, so identity evaluation has one entry point rather than a model method standing in front of it. `EdgeIdentity.get_all_feature_states` keeps its name, and no longer shares one with a method that returned something else. --- api/environments/identities/models.py | 38 ------------------- api/environments/identities/views.py | 9 +++-- api/environments/sdk/serializers.py | 4 +- api/evaluation/services.py | 28 +++++++++++++- api/features/views.py | 5 ++- .../identities/test_unit_identities_models.py | 25 ++++++------ .../unit/experimentation/test_services.py | 3 +- .../test_unit_import_export_export.py | 3 +- .../unit/integrations/heap/test_unit_heap.py | 3 +- .../mixpanel/test_unit_mixpanel.py | 3 +- .../integrations/test_unit_integration.py | 13 ++++--- 11 files changed, 67 insertions(+), 67 deletions(-) diff --git a/api/environments/identities/models.py b/api/environments/identities/models.py index c9163a625b96..74bcbc893a3d 100644 --- a/api/environments/identities/models.py +++ b/api/environments/identities/models.py @@ -1,7 +1,6 @@ from itertools import chain from django.db import models -from django.db.models import Q from flag_engine.engine import get_evaluation_result from environments.identities.managers import IdentityManager @@ -9,7 +8,6 @@ from environments.models import Environment from environments.sdk.types import SDKTraitData from evaluation.mappers import map_environment_to_evaluation_context -from evaluation.services import evaluate_identity from features.models import FeatureState from segments.models import Segment @@ -51,42 +49,6 @@ def get_hash_key(self, use_identity_composite_key_for_hashing: bool = False) -> else str(self.id) ) - def get_all_feature_states( - self, - traits: list[Trait] | None = None, - additional_filters: Q | None = None, - ) -> list[FeatureState]: - """ - Get all feature states for an identity. This method returns a single flag for - each feature in the identity's environment's project. The flag returned is the - correct flag based on the priorities as follows (highest -> lowest): - - 1. Identity - flag override for this specific identity - 2. Segment - flag overridden for a segment this identity belongs to - 3. Environment - default value for the environment - - Each returned feature state carries the engine's verdict on - `flag_result`, so that callers read the evaluated value and variant off - the row rather than resolving them again. - - :return: (list) flags for an identity with the correct values based on - identity / segment priorities - """ - _, feature_states = evaluate_identity( - self, - traits=traits, - additional_filters=additional_filters, - ) - - if self.environment.get_hide_disabled_flags() is True: - return [ - feature_state - for feature_state in feature_states - if feature_state.enabled - ] - - return feature_states - def get_overridden_feature_states(self) -> dict[int, FeatureState]: """ Get all overridden feature states for an identity. diff --git a/api/environments/identities/views.py b/api/environments/identities/views.py index 4e07c99b04d7..5af5c73f3d1b 100644 --- a/api/environments/identities/views.py +++ b/api/environments/identities/views.py @@ -34,6 +34,7 @@ IdentifyWithTraitsSerializer, IdentitySerializerWithTraitsAndSegments, ) +from evaluation.services import get_identity_feature_states from features.serializers import SDKIdentityFeatureStateSerializer from integrations.integration import identify_integrations from util.views import SDKAPIView @@ -134,7 +135,7 @@ def get(self, request, identifier, *args, **kwargs): # type: ignore[no-untyped- "IdentityTraitFlagsSegments", ("flags", "traits", "segments") ) identity_flags_traits_segments = IdentityFlagsWithTraitsAndSegments( - flags=identity.get_all_feature_states(), + flags=get_identity_feature_states(identity), traits=traits_data, segments=identity.get_segments(), ) @@ -289,7 +290,8 @@ def _get_single_feature_state_response( ) -> Response: context = self.get_serializer_context() # type: ignore[no-untyped-call] - for feature_state in identity.get_all_feature_states( + for feature_state in get_identity_feature_states( + identity, additional_filters=self._get_additional_filters(), ): if feature_state.feature.name == feature_name: @@ -317,7 +319,8 @@ def _get_all_feature_states_for_user_response( # type: ignore[no-untyped-def] :param identity: Identity model to return feature states for :return: Response containing lists of both serialized flags and traits """ - all_feature_states = identity.get_all_feature_states( + all_feature_states = get_identity_feature_states( + identity, additional_filters=self._get_additional_filters(), ) serializer_class = self.get_serializer_class() diff --git a/api/environments/sdk/serializers.py b/api/environments/sdk/serializers.py index 78750f2350df..b021fb43e98f 100644 --- a/api/environments/sdk/serializers.py +++ b/api/environments/sdk/serializers.py @@ -17,6 +17,7 @@ get_transient_identity_and_traits, ) from environments.sdk.types import SDKTraitData +from evaluation.services import get_identity_feature_states from features.serializers import ( FeatureStateSerializerFull, SDKIdentityFeatureStateSerializer, @@ -177,7 +178,8 @@ def save(self, **kwargs): # type: ignore[no-untyped-def] sdk_trait_data=sdk_trait_data, ) - all_feature_states = identity.get_all_feature_states( + all_feature_states = get_identity_feature_states( + identity, traits=traits, additional_filters=self.context.get("feature_states_additional_filters"), ) diff --git a/api/evaluation/services.py b/api/evaluation/services.py index e3d51d36f984..3af96f043213 100644 --- a/api/evaluation/services.py +++ b/api/evaluation/services.py @@ -10,9 +10,10 @@ from environments.identities.models import Identity from environments.identities.traits.models import Trait from environments.models import Environment + from features.models import FeatureState -__all__ = ("evaluate_identity",) +__all__ = ("evaluate_identity", "get_identity_feature_states") def evaluate_identity( @@ -41,3 +42,28 @@ def evaluate_identity( feature_states.append(feature_state) return IdentityEvaluation(result, feature_states) + + +def get_identity_feature_states( + identity: "Identity", + *, + traits: "list[Trait] | None" = None, + additional_filters: Q | None = None, +) -> "list[FeatureState]": + """The feature states to serve `identity`, one per feature. + + Each carries the engine's verdict on `flag_result`, so a caller reads the + evaluated value and variant off the row rather than resolving them again. + """ + _, feature_states = evaluate_identity( + identity, + traits=traits, + additional_filters=additional_filters, + ) + + if identity.environment.get_hide_disabled_flags() is True: + return [ + feature_state for feature_state in feature_states if feature_state.enabled + ] + + return feature_states diff --git a/api/features/views.py b/api/features/views.py index 2f15006b15ad..00d2876aebc1 100644 --- a/api/features/views.py +++ b/api/features/views.py @@ -63,6 +63,7 @@ EnvironmentKeyPermissions, NestedEnvironmentPermissions, ) +from evaluation.services import get_identity_feature_states from features.dependencies.services import validate_segment_flag_dependencies from features.feature_lifecycle.services import ( annotate_feature_queryset_with_lifecycle_stage, @@ -882,7 +883,7 @@ def get_queryset(self): # type: ignore[no-untyped-def] @action(methods=["GET"], detail=False) def all(self, request, *args, **kwargs): # type: ignore[no-untyped-def] identity = get_object_or_404(Identity, pk=self.kwargs["identity_pk"]) - feature_states = identity.get_all_feature_states() + feature_states = get_identity_feature_states(identity) serializer = IdentityAllFeatureStatesSerializer( instance=feature_states, @@ -1130,7 +1131,7 @@ def _get_flags_response_with_identifier( ) replace_identity_environment(identity, request.environment) - feature_states = identity.get_all_feature_states() + feature_states = get_identity_feature_states(identity) if feature_name := request.GET.get("feature"): # Filtered after evaluation rather than before: a segment may diff --git a/api/tests/unit/environments/identities/test_unit_identities_models.py b/api/tests/unit/environments/identities/test_unit_identities_models.py index 51fc00a30c69..b936bf3ebbf2 100644 --- a/api/tests/unit/environments/identities/test_unit_identities_models.py +++ b/api/tests/unit/environments/identities/test_unit_identities_models.py @@ -13,6 +13,7 @@ from environments.identities.models import Identity from environments.identities.traits.models import Trait from environments.models import Environment +from evaluation.services import get_identity_feature_states from features.models import ( Feature, FeatureSegment, @@ -106,7 +107,7 @@ def test_get_all_feature_states__multiple_identities_and_environments__returns_c ) # When - flags = identity_1.get_all_feature_states() + flags = get_identity_feature_states(identity_1) # Then # For identity_1 all items in a different environment should not appear. Identity @@ -249,7 +250,7 @@ def test_get_all_feature_states__matching_segment__returns_overridden_values( ) # When - feature_states = identity.get_all_feature_states() + feature_states = get_identity_feature_states(identity) # Then feature_flag_state = next( @@ -311,7 +312,7 @@ def test_get_all_feature_states__identity_not_matching_segment__returns_default_ ) # When - feature_states = identity.get_all_feature_states() + feature_states = get_identity_feature_states(identity) # Then feature_flag_state = next( @@ -367,7 +368,7 @@ def test_get_all_feature_states__matching_segment_with_integer_value__returns_ov ) # When - feature_states = identity.get_all_feature_states() + feature_states = get_identity_feature_states(identity) # Then feature_state = next(filter(lambda fs: fs.feature == remote_config, feature_states)) @@ -417,7 +418,7 @@ def test_get_all_feature_states__matching_segment_with_boolean_value__returns_ov ) # When - feature_states = identity.get_all_feature_states() + feature_states = get_identity_feature_states(identity) # Then feature_state = next(filter(lambda fs: fs.feature == remote_config, feature_states)) @@ -498,7 +499,7 @@ def test_get_all_feature_states__multiple_matching_segments__returns_highest_pri ) # When - we get all feature states for an identity - feature_states = identity.get_all_feature_states() + feature_states = get_identity_feature_states(identity) # Then - only the flag associated with the highest priority feature segment is returned assert len(feature_states) == 1 @@ -556,7 +557,7 @@ def test_get_all_feature_states__segment_override_updated__returns_new_value( overridden_value_2 = "overridden value 2" segment_feature_state.feature_state_value.string_value = overridden_value_2 segment_feature_state.feature_state_value.save() - feature_states = identity.get_all_feature_states() + feature_states = get_identity_feature_states(identity) # Then - the feature state value is correctly set to the newly updated feature segment value assert len(feature_states) == 1 @@ -617,7 +618,7 @@ def test_get_all_feature_states__traits_passed_manually__returns_segment_overrid ) # When - we get all feature states for an identity - feature_states = identity.get_all_feature_states(traits=[trait]) + feature_states = get_identity_feature_states(identity, traits=[trait]) # Then - the flag is returned with the correct state assert len(feature_states) == 1 @@ -896,7 +897,7 @@ def test_get_all_feature_states__null_version_exists__excludes_null_version( identity = Identity.objects.create(environment=environment, identifier="identity") # When - identity_feature_states = identity.get_all_feature_states() + identity_feature_states = get_identity_feature_states(identity) # Then assert len(identity_feature_states) == 1 @@ -992,7 +993,7 @@ def test_get_all_feature_states__hide_disabled_flags__returns_expected_flags( # ) # When # we get flags for the identity - identity_flags = identity.get_all_feature_states() + identity_flags = get_identity_feature_states(identity) # Then assert bool(identity_flags) == disabled_flag_returned @@ -1038,7 +1039,7 @@ def test_get_all_feature_states__multiple_versions__returns_latest_committed_ver not_live_feature_state.feature_state_value.save() # When - identity_feature_states = identity.get_all_feature_states() + identity_feature_states = get_identity_feature_states(identity) # Then identity_feature_state = next( @@ -1076,7 +1077,7 @@ def test_identity_get_all_feature_states__returns_identity_override__when_v2_fea ) # When - all_feature_states = identity.get_all_feature_states() + all_feature_states = get_identity_feature_states(identity) # Then assert len(all_feature_states) == 1 diff --git a/api/tests/unit/experimentation/test_services.py b/api/tests/unit/experimentation/test_services.py index 3758e0b0d4a0..446e4dad3c38 100644 --- a/api/tests/unit/experimentation/test_services.py +++ b/api/tests/unit/experimentation/test_services.py @@ -22,6 +22,7 @@ from environments.identities.models import Identity from environments.identities.traits.models import Trait from environments.models import Environment +from evaluation.services import get_identity_feature_states from experimentation import services from experimentation.constants import MAX_AUDIENCE_SEGMENTS from experimentation.dataclasses import ( @@ -3498,7 +3499,7 @@ def _identity_flag_value( feature segment it came from (``None`` for the environment default).""" (feature_state,) = [ feature_state - for feature_state in identity.get_all_feature_states() + for feature_state in get_identity_feature_states(identity) if feature_state.feature_id == feature.id ] return ( diff --git a/api/tests/unit/import_export/test_unit_import_export_export.py b/api/tests/unit/import_export/test_unit_import_export_export.py index 96bc0989809c..5e595b3c1931 100644 --- a/api/tests/unit/import_export/test_unit_import_export_export.py +++ b/api/tests/unit/import_export/test_unit_import_export_export.py @@ -17,6 +17,7 @@ from core.constants import STRING from environments.identities.models import Identity from environments.models import Environment, EnvironmentAPIKey, Webhook +from evaluation.services import get_identity_feature_states from features.feature_types import MULTIVARIATE from features.models import Feature, FeatureSegment, FeatureState from features.multivariate.models import MultivariateFeatureOption @@ -539,7 +540,7 @@ def test_export_edge_identities__identities_with_overrides_and_traits__exports_a assert bool_trait.trait_key == "bool_trait" assert bool_trait.trait_value is True - all_feature_states = identity.get_all_feature_states() + all_feature_states = get_identity_feature_states(identity) assert len(all_feature_states) == 7 actual_mv_override = all_feature_states[0] diff --git a/api/tests/unit/integrations/heap/test_unit_heap.py b/api/tests/unit/integrations/heap/test_unit_heap.py index ae450f47ca5f..f3b341bca40b 100644 --- a/api/tests/unit/integrations/heap/test_unit_heap.py +++ b/api/tests/unit/integrations/heap/test_unit_heap.py @@ -4,6 +4,7 @@ from environments.identities.models import Identity from environments.models import Environment +from evaluation.services import get_identity_feature_states from features.models import FeatureState from integrations.heap.heap import HeapWrapper from integrations.heap.models import HeapConfiguration @@ -91,7 +92,7 @@ def test_identify_integrations__heap_configured__posts_to_expected_url( mocked_post = mocker.patch("integrations.heap.heap.requests.post") # When - identify_integrations(identity, identity.get_all_feature_states()) # type: ignore[no-untyped-call] + identify_integrations(identity, get_identity_feature_states(identity)) # type: ignore[no-untyped-call] # Then assert mocked_post.call_args.args[0] == expected_url diff --git a/api/tests/unit/integrations/mixpanel/test_unit_mixpanel.py b/api/tests/unit/integrations/mixpanel/test_unit_mixpanel.py index aca925dc0c85..72cf3d2874d0 100644 --- a/api/tests/unit/integrations/mixpanel/test_unit_mixpanel.py +++ b/api/tests/unit/integrations/mixpanel/test_unit_mixpanel.py @@ -3,6 +3,7 @@ import pytest +from evaluation.services import get_identity_feature_states from integrations.mixpanel.constants import DEFAULT_MIXPANEL_API_URL from integrations.mixpanel.mixpanel import MixpanelWrapper from integrations.mixpanel.models import MixpanelConfiguration @@ -194,7 +195,7 @@ def test_identify_integrations__mixpanel_configured__posts_to_expected_url( mocked_post = mocker.patch("integrations.mixpanel.mixpanel.requests.post") # When - identify_integrations(identity, identity.get_all_feature_states()) # type: ignore[no-untyped-call] + identify_integrations(identity, get_identity_feature_states(identity)) # type: ignore[no-untyped-call] # Then assert mocked_post.call_args.args[0] == expected_url diff --git a/api/tests/unit/integrations/test_unit_integration.py b/api/tests/unit/integrations/test_unit_integration.py index 3029d30e6f8f..50c93028a478 100644 --- a/api/tests/unit/integrations/test_unit_integration.py +++ b/api/tests/unit/integrations/test_unit_integration.py @@ -1,3 +1,4 @@ +from evaluation.services import get_identity_feature_states from integrations.amplitude.models import AmplitudeConfiguration from integrations.common.models import EnvironmentIntegrationModel from integrations.common.wrapper import AbstractBaseIdentityIntegrationWrapper @@ -15,7 +16,7 @@ def test_identify_integrations__amplitude_configured__calls_amplitude( # type: AmplitudeConfiguration.objects.create(api_key="abc-123", environment=environment) # When - identify_integrations(identity, identity.get_all_feature_states()) # type: ignore[no-untyped-call] + identify_integrations(identity, get_identity_feature_states(identity)) # type: ignore[no-untyped-call] # Then mock_amplitude_wrapper.assert_called() @@ -30,7 +31,7 @@ def test_identify_integrations__segment_configured__calls_segment( # type: igno ) SegmentConfiguration.objects.create(api_key="abc-123", environment=environment) # When - identify_integrations(identity, identity.get_all_feature_states()) # type: ignore[no-untyped-call] + identify_integrations(identity, get_identity_feature_states(identity)) # type: ignore[no-untyped-call] # Then mock_segment_wrapper.assert_called() @@ -66,7 +67,7 @@ def test_identify_integrations__multiple_integrations__calls_all( # type: ignor ) # When - identify_integrations(identity, identity.get_all_feature_states()) # type: ignore[no-untyped-call] + identify_integrations(identity, get_identity_feature_states(identity)) # type: ignore[no-untyped-call] # Then # Integration a was successfully called @@ -79,7 +80,7 @@ def test_identify_integrations__multiple_integrations__calls_all( # type: ignor integration_a_mocked_generate_user_data.assert_called_with( identity=identity, - feature_states=identity.get_all_feature_states(), + feature_states=get_identity_feature_states(identity), trait_models=None, ) integration_wrapper_a.return_value.identify_user_async.assert_called_with( @@ -95,7 +96,7 @@ def test_identify_integrations__multiple_integrations__calls_all( # type: ignor integration_b_mocked_generate_user_data.assert_called_with( identity=identity, - feature_states=identity.get_all_feature_states(), + feature_states=get_identity_feature_states(identity), trait_models=None, ) integration_wrapper_b.return_value.identify_user_async.assert_called_with( @@ -115,7 +116,7 @@ def test_identify_integrations__deleted_integration__does_not_call( # type: ign sc.delete() # When - identify_integrations(identity, identity.get_all_feature_states()) # type: ignore[no-untyped-call] + identify_integrations(identity, get_identity_feature_states(identity)) # type: ignore[no-untyped-call] # Then mock_segment_wrapper.assert_not_called() From 2029d7e055a15a227748cd4082ad218cc1e15411 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 23 Sep 2026 01:32:50 +0100 Subject: [PATCH 21/30] feat(api): evaluate edge identity segments through the engine `EdgeIdentity.get_all_feature_states` asked DynamoDB which segments an identity matched, then settled precedence itself by keying feature states on name. Both are the engine's to decide, and it could not decide them without the segments in the context. `evaluation.services.get_edge_identity_feature_states` builds one, with the identity taken from the DynamoDB document rather than an ORM row. Its own overrides stay in DynamoDB, so they are laid over the result afterwards, and nothing Pydantic enters the context. The `identity_feature_names` set goes with it: the serialiser was told which features an edge identity overrode because those states carry no `identity_id`, but not being an ORM row says the same thing. `DynamoIdentityWrapper.get_segment_ids` survives for the segments endpoint, which is the last caller of the vendored segment evaluation. --- api/edge_api/identities/models.py | 98 ------------------- api/edge_api/identities/views.py | 9 +- api/environments/identities/serializers.py | 8 +- api/evaluation/mappers.py | 72 +++++++++----- api/evaluation/services.py | 49 +++++++++- api/evaluation/types.py | 2 + ...est_edge_identity_featurestates_viewset.py | 14 +-- .../test_edge_api_identities_serializers.py | 3 +- .../identities/test_edge_identity_models.py | 75 ++++++++------ 9 files changed, 157 insertions(+), 173 deletions(-) diff --git a/api/edge_api/identities/models.py b/api/edge_api/identities/models.py index ced2400c0ca4..77864d48d1f7 100644 --- a/api/edge_api/identities/models.py +++ b/api/edge_api/identities/models.py @@ -4,9 +4,7 @@ from datetime import timedelta from django.conf import settings -from django.db.models import Prefetch, Q from django.utils import timezone -from flag_engine.engine import get_evaluation_result from api_keys.user import APIKeyUser from edge_api.identities.tasks import ( @@ -18,11 +16,6 @@ from edge_api.identities.utils import generate_change_dict from environments.dynamodb import DynamoIdentityWrapper from environments.models import Environment -from evaluation.mappers import map_feature_state_to_feature_context -from evaluation.types import EvaluationContext -from features.models import FeatureState -from features.multivariate.models import MultivariateFeatureStateValue -from features.versioning.versioning_service import get_environment_flags_dict from users.models import FFAdminUser from util.engine_models.features.models import FeatureStateModel from util.engine_models.identities.models import IdentityFeaturesList, IdentityModel @@ -80,97 +73,6 @@ def dashboard_alias(self, dashboard_alias: str) -> None: def add_feature_override(self, feature_state: FeatureStateModel) -> None: self.engine_identity_model.identity_features.append(feature_state) - def get_all_feature_states( - self, - ) -> typing.Tuple[ - typing.List[typing.Union[FeatureState, FeatureStateModel]], typing.Set[str] - ]: - """ - Get all feature states for a flag engine identity model. The list returned by - this function contains two distinct types: features.models.FeatureState & - flag_engine.features.models.FeatureStateModel. - - :return: tuple of (list of feature states, set of feature names that were overridden - for the identity specifically) - """ - segment_ids = self.dynamo_wrapper.get_segment_ids( - identity_model=self.engine_identity_model - ) - django_environment = self.environment - - # since identity overrides are included in the document retrieved from dynamo, - # we only want to retrieve the environment default and (relevant) segment overrides - # from the ORM. - additional_filters = Q(identity__isnull=True) & ( - Q(feature_segment__segment__id__in=segment_ids) - | Q(feature_segment__isnull=True) - ) - - django_feature_states: dict[str, FeatureState] = get_environment_flags_dict( # type: ignore[assignment] - environment=django_environment, - additional_filters=additional_filters, - additional_select_related_args=[ - "feature", - "feature_segment", - "feature_segment__segment", - "feature_state_value", - ], - additional_prefetch_related_args=[ - Prefetch( - "multivariate_feature_state_values", - queryset=MultivariateFeatureStateValue.objects.select_related( - "multivariate_feature_option" - ), - ) - ], - # since we only want to retrieve the highest priority feature state, - # we key off the feature name instead of the default - # (feature_id, segment_id, identity_id). This will give us only e.g. - # the highest priority matching segment override for a given feature. - key_function=lambda fs: fs.feature.name, # type: ignore[arg-type,return-value] - ) - - # The winning row per feature is settled above, but the value an - # identity is served for a multivariate feature is the engine's to - # decide. Ask it, and carry the answer on each row. - context: EvaluationContext = { - "environment": { - "key": django_environment.api_key, - "name": django_environment.name or "", - }, - "identity": { - "identifier": self.identifier, - "key": self.get_hash_key( - django_environment.use_identity_composite_key_for_hashing - ), - }, - "features": { - feature_name: map_feature_state_to_feature_context( - feature_state, - mv_fs_values=feature_state.multivariate_feature_state_values.all(), - ) - for feature_name, feature_state in django_feature_states.items() - }, - } - for flag in get_evaluation_result(context)["flags"].values(): - django_feature_states[flag["name"]].flag_result = flag - - feature_states: dict[str, FeatureState | FeatureStateModel] = { - **django_feature_states - } - - # Since the identity overrides are the highest priority, we can now iterate - # over the dictionary and replace any feature states with those that have - # an identity override, stored against the identity in dynamo. - identity_feature_states = self.feature_overrides - identity_feature_names = set() - for identity_feature_state in identity_feature_states: - feature_name = identity_feature_state.feature.name - feature_states[feature_name] = identity_feature_state - identity_feature_names.add(feature_name) - - return list(feature_states.values()), identity_feature_names - def get_feature_state_by_feature_name_or_id( self, feature: typing.Union[str, int] ) -> typing.Optional[FeatureStateModel]: diff --git a/api/edge_api/identities/views.py b/api/edge_api/identities/views.py index f3812d037b59..34265cdec002 100644 --- a/api/edge_api/identities/views.py +++ b/api/edge_api/identities/views.py @@ -51,6 +51,7 @@ ) from environments.models import Environment from environments.permissions.permissions import NestedEnvironmentPermissions +from evaluation.services import get_edge_identity_feature_states from features.models import FeatureState from features.permissions import IdentityFeatureStatePermissions from projects.exceptions import DynamoNotEnabledError @@ -280,19 +281,13 @@ def perform_destroy(self, instance): # type: ignore[no-untyped-def] @extend_schema(responses={200: IdentityAllFeatureStatesSerializer(many=True)}) @action(detail=False, methods=["GET"]) def all(self, request, *args, **kwargs): # type: ignore[no-untyped-def] - ( - feature_states, - identity_feature_names, - ) = self.identity.get_all_feature_states() - serializer = IdentityAllFeatureStatesSerializer( - instance=feature_states, + instance=get_edge_identity_feature_states(self.identity), many=True, context={ "request": request, "identity": self.identity, "environment_api_key": self.identity.environment_api_key, - "identity_feature_names": identity_feature_names, }, ) diff --git a/api/environments/identities/serializers.py b/api/environments/identities/serializers.py index 3fd0d15f966a..2d66caf7b1d8 100644 --- a/api/environments/identities/serializers.py +++ b/api/environments/identities/serializers.py @@ -112,9 +112,11 @@ def get_feature_state_value( def get_overridden_by(self, instance) -> typing.Optional[str]: # type: ignore[no-untyped-def] if getattr(instance, "feature_segment_id", None) is not None: return "SEGMENT" - elif getattr( - instance, "identity_id", None - ) or instance.feature.name in self.context.get("identity_feature_names", []): + elif getattr(instance, "identity_id", None) or isinstance( + instance, FeatureStateModel + ): + # An edge identity's overrides are the only states reaching this + # serialiser that are not ORM rows. return "IDENTITY" return None diff --git a/api/evaluation/mappers.py b/api/evaluation/mappers.py index 680e50973286..319045b5a6c6 100644 --- a/api/evaluation/mappers.py +++ b/api/evaluation/mappers.py @@ -16,7 +16,12 @@ from flag_engine.segments.types import ConditionOperator, RuleType from pydantic import TypeAdapter -from evaluation.types import EvaluationContext, FeatureContext, SegmentContext +from evaluation.types import ( + EvaluationContext, + FeatureContext, + IdentityContext, + SegmentContext, +) from features.types import FeatureEngineMetadata from segments.types import SegmentEngineMetadata @@ -35,6 +40,7 @@ "map_condition_to_segment_condition", "map_environment_to_evaluation_context", "map_feature_state_to_feature_context", + "map_identity_to_identity_context", "map_rule_to_segment_rule", "map_segment_to_segment_context", ) @@ -53,6 +59,7 @@ def map_environment_to_evaluation_context( *, environment: "Environment", identity: "Identity | None" = None, + identity_context: "IdentityContext | None" = None, traits: "Iterable[Trait] | None" = None, segments: "Iterable[Segment] | None" = None, additional_filters: "Q | None" = None, @@ -67,6 +74,11 @@ def map_environment_to_evaluation_context( the engine hands back on the corresponding `FlagResult`, so a caller still working in Django rows never has to work out which override won. + :param identity: the identity being evaluated, whose overrides are read + from the ORM along with the environment's own feature states. + :param identity_context: who is being evaluated, for an identity that is + not an ORM row and so has no overrides to read from it. An edge + identity keeps both its traits and its overrides in DynamoDB. :param segments: segments to evaluate. """ context: EvaluationContext = { @@ -75,28 +87,14 @@ def map_environment_to_evaluation_context( "name": environment.name or "", }, } - if identity is not None: - trait_items: "Iterable[Trait]" = ( - traits - if traits is not None - # A transient identity was never persisted, so it has no stored - # traits to read, and asking for them would raise. - else identity.identity_traits.all() - if identity.pk - else () + if identity_context is not None: + context["identity"] = identity_context + elif identity is not None: + context["identity"] = map_identity_to_identity_context( + identity, + environment=environment, + traits=traits, ) - identity_traits = {trait.trait_key: trait.trait_value for trait in trait_items} - if identity.system_traits: - # System-owned traits are not user data: on a key clash, the system - # value wins. - identity_traits.update(identity.system_traits) - context["identity"] = { - "identifier": identity.identifier, - "key": identity.get_hash_key( - environment.use_identity_composite_key_for_hashing - ), - "traits": identity_traits, - } ( feature_states, @@ -214,6 +212,36 @@ def _resolve_feature_states( return resolved +def map_identity_to_identity_context( + identity: "Identity", + *, + environment: "Environment", + traits: "Iterable[Trait] | None" = None, +) -> "IdentityContext": + """Map a Django ORM Identity to a flag-engine IdentityContext TypedDict.""" + trait_items: "Iterable[Trait]" = ( + traits + if traits is not None + # A transient identity was never persisted, so it has no stored traits + # to read, and asking for them would raise. + else identity.identity_traits.all() + if identity.pk + else () + ) + identity_traits = {trait.trait_key: trait.trait_value for trait in trait_items} + if identity.system_traits: + # System-owned traits are not user data: on a key clash, the system + # value wins. + identity_traits.update(identity.system_traits) + return { + "identifier": identity.identifier, + "key": identity.get_hash_key( + environment.use_identity_composite_key_for_hashing + ), + "traits": identity_traits, + } + + def map_feature_state_to_feature_context( feature_state: "FeatureState", *, diff --git a/api/evaluation/services.py b/api/evaluation/services.py index 3af96f043213..c3eaf191bab2 100644 --- a/api/evaluation/services.py +++ b/api/evaluation/services.py @@ -7,13 +7,19 @@ from evaluation.types import IdentityEvaluation if TYPE_CHECKING: + from edge_api.identities.models import EdgeIdentity from environments.identities.models import Identity from environments.identities.traits.models import Trait from environments.models import Environment from features.models import FeatureState + from util.engine_models.features.models import FeatureStateModel -__all__ = ("evaluate_identity", "get_identity_feature_states") +__all__ = ( + "evaluate_identity", + "get_edge_identity_feature_states", + "get_identity_feature_states", +) def evaluate_identity( @@ -67,3 +73,44 @@ def get_identity_feature_states( ] return feature_states + + +def get_edge_identity_feature_states( + edge_identity: "EdgeIdentity", +) -> "list[FeatureState | FeatureStateModel]": + """The feature states to serve an edge identity, one per feature. + + An edge identity's own overrides live in DynamoDB rather than the ORM, so + they are laid over the evaluated environment afterwards, and are the only + states in the returned list not carrying a `flag_result`. + """ + environment: "Environment" = edge_identity.environment + + context = map_environment_to_evaluation_context( + environment=environment, + identity_context={ + "identifier": edge_identity.identifier, + "key": edge_identity.get_hash_key( + environment.use_identity_composite_key_for_hashing + ), + "traits": { + trait.trait_key: trait.trait_value + for trait in edge_identity.engine_identity_model.identity_traits + } + | (edge_identity.engine_identity_model.system_traits or {}), + }, + segments=environment.get_segments_from_cache(), + ) + result = get_evaluation_result(context) + + feature_states: dict[str, "FeatureState | FeatureStateModel"] = {} + for flag in result["flags"].values(): + feature_state = flag["metadata"]["feature_state"] + feature_state.flag_result = flag + feature_states[flag["name"]] = feature_state + + # An identity override outranks anything the engine ruled on. + for identity_feature_state in edge_identity.feature_overrides: + feature_states[identity_feature_state.feature.name] = identity_feature_state + + return list(feature_states.values()) diff --git a/api/evaluation/types.py b/api/evaluation/types.py index cd2f71e5073a..49b4d587f07c 100644 --- a/api/evaluation/types.py +++ b/api/evaluation/types.py @@ -15,6 +15,7 @@ "EvaluationResult", "FeatureContext", "FlagResult", + "IdentityContext", "IdentityEvaluation", "SegmentContext", ) @@ -26,6 +27,7 @@ SegmentEngineMetadata, FeatureEngineMetadata ] FeatureContext: TypeAlias = context_types.FeatureContext[FeatureEngineMetadata] +IdentityContext: TypeAlias = context_types.IdentityContext EvaluationResult: TypeAlias = result_types.EvaluationResult[ SegmentEngineMetadata, FeatureEngineMetadata ] diff --git a/api/tests/integration/edge_api/identities/test_edge_identity_featurestates_viewset.py b/api/tests/integration/edge_api/identities/test_edge_identity_featurestates_viewset.py index 32e24c58352f..08c4d8044d5a 100644 --- a/api/tests/integration/edge_api/identities/test_edge_identity_featurestates_viewset.py +++ b/api/tests/integration/edge_api/identities/test_edge_identity_featurestates_viewset.py @@ -955,6 +955,8 @@ def test_put_identity_override__identity_not_found__creates_identity_with_overri assert feature_state_data["feature_state_value"] == expected_feature_state_value +@pytest.mark.parametrize("segment_condition_property", ["trait_key_1"]) +@pytest.mark.parametrize("segment_condition_value", ["trait_value_1"]) @pytest.mark.parametrize( "segment_override_type, segment_override_value", ( @@ -979,21 +981,9 @@ def test_get_all_feature_states__with_overrides__returns_correct_override_source segment_override_value, ): # Given - # Mock the get_segment_ids method so that it returns no segments for the first - # request (to get the environment default), then so that it returns one segment - # for the segment and identity override requests. - segment_ids_responses = [[], [segment], [segment]] - - def get_segment_ids_side_effect(*args, **kwargs): # type: ignore[no-untyped-def] - nonlocal segment_ids_responses - return segment_ids_responses.pop(0) - edge_identity_dynamo_wrapper_mock.get_item_from_uuid_or_404.return_value = ( identity_document_without_fs ) - edge_identity_dynamo_wrapper_mock.get_segment_ids.side_effect = ( - get_segment_ids_side_effect - ) get_all_identity_feature_states_url = reverse( "api-v1:environments:edge-identity-featurestates-all", diff --git a/api/tests/unit/edge_api/identities/test_edge_api_identities_serializers.py b/api/tests/unit/edge_api/identities/test_edge_api_identities_serializers.py index 6ab97c6fa84a..3ea6780c89c6 100644 --- a/api/tests/unit/edge_api/identities/test_edge_api_identities_serializers.py +++ b/api/tests/unit/edge_api/identities/test_edge_api_identities_serializers.py @@ -11,6 +11,7 @@ from environments.identities.serializers import ( IdentityAllFeatureStatesSerializer, ) +from evaluation.services import get_edge_identity_feature_states from features.feature_types import STANDARD from features.models import Feature from users.models import FFAdminUser @@ -197,7 +198,7 @@ def test_all_feature_states_serializer__edge_identity_with_mv_feature__uses_mv_v # The feature state has to come from an evaluation, which is what resolves # the identity's multivariate value. - feature_states, _ = edge_identity.get_all_feature_states() + feature_states = get_edge_identity_feature_states(edge_identity) (feature_state,) = [ feature_state for feature_state in feature_states diff --git a/api/tests/unit/edge_api/identities/test_edge_identity_models.py b/api/tests/unit/edge_api/identities/test_edge_identity_models.py index 46f1dfa0bdec..8cab5f364215 100644 --- a/api/tests/unit/edge_api/identities/test_edge_identity_models.py +++ b/api/tests/unit/edge_api/identities/test_edge_identity_models.py @@ -4,6 +4,7 @@ import pytest import shortuuid from django.utils import timezone +from flag_engine.segments.constants import EQUAL from freezegun import freeze_time from pytest_django import DjangoAssertNumQueries from pytest_django.fixtures import SettingsWrapper @@ -14,29 +15,52 @@ from api_keys.user import APIKeyUser from edge_api.identities.models import EdgeIdentity from environments.models import Environment +from evaluation.services import get_edge_identity_feature_states from features.models import Feature, FeatureSegment, FeatureState from features.versioning.tasks import enable_v2_versioning from features.workflows.core.models import ChangeRequest from projects.models import Project -from segments.models import Segment +from segments.models import Condition, Segment, SegmentRule from tests.types import EnableFeaturesFixture from users.models import FFAdminUser from util.engine_models.features.models import FeatureModel, FeatureStateModel +from util.engine_models.identities.models import IdentityModel +from util.engine_models.identities.traits.models import TraitModel + +MATCHING_TRAIT_KEY = "segment-membership" +MATCHING_TRAIT_VALUE = "yes" + + +def _create_matching_segment(project: Project, name: str) -> Segment: + """A segment every identity carrying the matching trait belongs to.""" + segment: Segment = Segment.objects.create(name=name, project=project) + Condition.objects.create( + rule=SegmentRule.objects.create(segment=segment, type=SegmentRule.ALL_RULE), + property=MATCHING_TRAIT_KEY, + operator=EQUAL, + value=MATCHING_TRAIT_VALUE, + ) + return segment + + +def _matching_identity_model(environment_api_key: str) -> IdentityModel: + return IdentityModel( + identifier="identity", + environment_api_key=environment_api_key, + identity_traits=[ + TraitModel(trait_key=MATCHING_TRAIT_KEY, trait_value=MATCHING_TRAIT_VALUE) + ], + ) def test_get_all_feature_states__multiple_segment_overrides__uses_segment_priorities( # type: ignore[no-untyped-def] - environment, project, segment, feature, mocker + environment, project, feature, mocker ): # Given - another_segment = Segment.objects.create(name="another_segment", project=project) - - edge_identity_dynamo_wrapper_mock = mocker.patch( - "edge_api.identities.models.EdgeIdentity.dynamo_wrapper", + # two segments the identity matches + segment, another_segment = ( + _create_matching_segment(project, name) for name in ("segment", "another") ) - edge_identity_dynamo_wrapper_mock.get_segment_ids.return_value = [ - segment.id, - another_segment.id, - ] feature_segment_p1 = FeatureSegment.objects.create( segment=segment, feature=feature, environment=environment, priority=1 @@ -52,22 +76,15 @@ def test_get_all_feature_states__multiple_segment_overrides__uses_segment_priori feature=feature, environment=environment, feature_segment=feature_segment_p2 ) - identity_model = mocker.MagicMock( - environment_api_key=environment.api_key, identity_features=[] - ) - edge_identity = EdgeIdentity(identity_model) + edge_identity = EdgeIdentity(_matching_identity_model(environment.api_key)) # When - feature_states, _ = edge_identity.get_all_feature_states() + feature_states = get_edge_identity_feature_states(edge_identity) # Then assert len(feature_states) == 1 assert feature_states[0] == segment_override_p1 - edge_identity_dynamo_wrapper_mock.get_segment_ids.assert_called_once_with( - identity_model=identity_model - ) - def test_get_all_feature_states__not_live_change_request__ignores_not_live_states( # type: ignore[no-untyped-def] environment, project, segment, feature, feature_state, admin_user, mocker @@ -96,7 +113,7 @@ def test_get_all_feature_states__not_live_change_request__ignores_not_live_state # When with freeze_time(timezone.now() + timedelta(hours=2)): - feature_states, _ = edge_identity.get_all_feature_states() + feature_states = get_edge_identity_feature_states(edge_identity) # Then assert feature_states == [feature_state] @@ -519,8 +536,6 @@ def test_get_all_feature_states__post_v2_versioning_migration__returns_latest_ov feature_state: FeatureState, segment: Segment, segment_featurestate: FeatureState, - edge_identity_model: EdgeIdentity, - mocker: MockerFixture, django_assert_num_queries: DjangoAssertNumQueries, ) -> None: """ @@ -539,16 +554,18 @@ def test_get_all_feature_states__post_v2_versioning_migration__returns_latest_ov enable_v2_versioning(environment.id) - edge_identity_dynamo_wrapper_mock = mocker.patch( - "edge_api.identities.models.EdgeIdentity.dynamo_wrapper", + # and an identity belonging to the overridden segment + Condition.objects.create( + rule=SegmentRule.objects.create(segment=segment, type=SegmentRule.ALL_RULE), + property=MATCHING_TRAIT_KEY, + operator=EQUAL, + value=MATCHING_TRAIT_VALUE, ) - edge_identity_dynamo_wrapper_mock.get_segment_ids.return_value = [segment.id] + edge_identity = EdgeIdentity(_matching_identity_model(environment.api_key)) # When - with django_assert_num_queries(4): - feature_states, identity_override_feature_names = ( - edge_identity_model.get_all_feature_states() - ) + with django_assert_num_queries(8): + feature_states = get_edge_identity_feature_states(edge_identity) # Then assert len(feature_states) == 1 From d656741127fc196dd8fe69e71e9eb980a91a7017 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 23 Sep 2026 03:08:26 +0200 Subject: [PATCH 22/30] refactor(api): match edge identity segments with get_evaluation_result `get_segment_ids` reached for `is_context_in_segment`, which flag-engine does not export, and built its context with a vendored mapper. Ask `get_evaluation_result` instead, through a context the engine is happy to be handed. That leaves nothing calling the vendored segment evaluation, so it and the context mapper feeding it are gone. What remains of that module maps a segment for `flagsmith-sql-flag-engine`, which translates rather than evaluates. --- .../dynamodb/wrappers/identity_wrapper.py | 20 +----- api/evaluation/mappers.py | 24 +++++++ api/evaluation/services.py | 42 +++++++---- .../test_unit_evaluation_mappers.py | 33 +++++++++ .../context/test_unit_context_mappers.py | 32 --------- api/util/engine_models/context/mappers.py | 69 ------------------- 6 files changed, 90 insertions(+), 130 deletions(-) delete mode 100644 api/tests/unit/util/engine_models/context/test_unit_context_mappers.py diff --git a/api/environments/dynamodb/wrappers/identity_wrapper.py b/api/environments/dynamodb/wrappers/identity_wrapper.py index b649192b6d39..4376ad2ab665 100644 --- a/api/environments/dynamodb/wrappers/identity_wrapper.py +++ b/api/environments/dynamodb/wrappers/identity_wrapper.py @@ -22,10 +22,6 @@ from environments.identities.traits.constants import ( TRAIT_STRING_VALUE_MAX_LENGTH, ) -from util.engine_models.context.mappers import ( - is_context_in_segment, - map_environment_identity_to_context, -) from util.engine_models.identities.models import IdentityModel from util.mappers import ( map_engine_identity_to_identity_document, @@ -326,8 +322,8 @@ def get_segment_ids( identity_pk: str = None, # type: ignore[assignment] identity_model: IdentityModel = None, # type: ignore[assignment] ) -> list: # type: ignore[type-arg] - from environments.models import Environment - from util.mappers.engine import map_segment_to_engine + from edge_api.identities.models import EdgeIdentity + from evaluation.services import get_edge_identity_segments if not (identity_pk or identity_model): raise ValueError("Must provide one of identity_pk or identity_model.") @@ -336,19 +332,9 @@ def get_segment_ids( identity = identity_model or IdentityModel.model_validate( self.get_item_from_uuid(identity_pk) ) - environment = Environment.objects.select_related("project").get( - api_key=identity.environment_api_key, - ) - segments = environment.project.get_segments_from_cache() - context = map_environment_identity_to_context( - environment=environment, - identity=identity, - override_traits=None, - ) return [ segment.id - for segment in segments - if is_context_in_segment(context, map_segment_to_engine(segment)) + for segment in get_edge_identity_segments(EdgeIdentity(identity)) ] return [] diff --git a/api/evaluation/mappers.py b/api/evaluation/mappers.py index 319045b5a6c6..79fe00d68564 100644 --- a/api/evaluation/mappers.py +++ b/api/evaluation/mappers.py @@ -26,6 +26,7 @@ from segments.types import SegmentEngineMetadata if TYPE_CHECKING: + from edge_api.identities.models import EdgeIdentity from environments.identities.models import Identity from environments.identities.traits.models import Trait from environments.models import Environment @@ -39,6 +40,7 @@ "IDENTITY_OVERRIDES_SEGMENT_NAME", "map_condition_to_segment_condition", "map_environment_to_evaluation_context", + "map_edge_identity_to_identity_context", "map_feature_state_to_feature_context", "map_identity_to_identity_context", "map_rule_to_segment_rule", @@ -242,6 +244,28 @@ def map_identity_to_identity_context( } +def map_edge_identity_to_identity_context( + edge_identity: "EdgeIdentity", + *, + environment: "Environment", +) -> "IdentityContext": + """Map an edge identity, read back from DynamoDB, to an IdentityContext.""" + identity_model = edge_identity.engine_identity_model + return { + "identifier": edge_identity.identifier, + "key": edge_identity.get_hash_key( + environment.use_identity_composite_key_for_hashing + ), + "traits": { + trait.trait_key: trait.trait_value + for trait in identity_model.identity_traits + } + # System-owned traits are not user data: on a key clash, the system + # value wins. + | (identity_model.system_traits or {}), + } + + def map_feature_state_to_feature_context( feature_state: "FeatureState", *, diff --git a/api/evaluation/services.py b/api/evaluation/services.py index c3eaf191bab2..d19d140b4b81 100644 --- a/api/evaluation/services.py +++ b/api/evaluation/services.py @@ -3,7 +3,10 @@ from django.db.models import Q from flag_engine.engine import get_evaluation_result -from evaluation.mappers import map_environment_to_evaluation_context +from evaluation.mappers import ( + map_edge_identity_to_identity_context, + map_environment_to_evaluation_context, +) from evaluation.types import IdentityEvaluation if TYPE_CHECKING: @@ -12,12 +15,14 @@ from environments.identities.traits.models import Trait from environments.models import Environment from features.models import FeatureState + from segments.models import Segment from util.engine_models.features.models import FeatureStateModel __all__ = ( "evaluate_identity", "get_edge_identity_feature_states", + "get_edge_identity_segments", "get_identity_feature_states", ) @@ -88,17 +93,9 @@ def get_edge_identity_feature_states( context = map_environment_to_evaluation_context( environment=environment, - identity_context={ - "identifier": edge_identity.identifier, - "key": edge_identity.get_hash_key( - environment.use_identity_composite_key_for_hashing - ), - "traits": { - trait.trait_key: trait.trait_value - for trait in edge_identity.engine_identity_model.identity_traits - } - | (edge_identity.engine_identity_model.system_traits or {}), - }, + identity_context=map_edge_identity_to_identity_context( + edge_identity, environment=environment + ), segments=environment.get_segments_from_cache(), ) result = get_evaluation_result(context) @@ -114,3 +111,24 @@ def get_edge_identity_feature_states( feature_states[identity_feature_state.feature.name] = identity_feature_state return list(feature_states.values()) + + +def get_edge_identity_segments(edge_identity: "EdgeIdentity") -> "list[Segment]": + """The segments an edge identity belongs to.""" + environment: "Environment" = edge_identity.environment + segments: "list[Segment]" = environment.project.get_segments_from_cache() + segments_by_pk = {segment.pk: segment for segment in segments} + + context = map_environment_to_evaluation_context( + environment=environment, + identity_context=map_edge_identity_to_identity_context( + edge_identity, environment=environment + ), + segments=segments, + ) + + return [ + segments_by_pk[pk] + for segment_result in get_evaluation_result(context)["segments"] + if (pk := segment_result["metadata"].get("pk")) is not None + ] diff --git a/api/tests/unit/evaluation/test_unit_evaluation_mappers.py b/api/tests/unit/evaluation/test_unit_evaluation_mappers.py index 08b0f6ac16a5..2ea7fb7b6f5b 100644 --- a/api/tests/unit/evaluation/test_unit_evaluation_mappers.py +++ b/api/tests/unit/evaluation/test_unit_evaluation_mappers.py @@ -1,5 +1,6 @@ import pytest +from edge_api.identities.models import EdgeIdentity from environments.identities.models import Identity from environments.identities.traits.models import Trait from environments.models import Environment @@ -7,6 +8,7 @@ IDENTITY_OVERRIDES_SEGMENT_KEY, IDENTITY_OVERRIDES_SEGMENT_NAME, map_condition_to_segment_condition, + map_edge_identity_to_identity_context, map_environment_to_evaluation_context, map_feature_state_to_feature_context, map_rule_to_segment_rule, @@ -15,6 +17,8 @@ from features.models import Feature, FeatureSegment, FeatureState from features.multivariate.models import MultivariateFeatureStateValue from segments.models import Condition, Segment, SegmentRule +from util.engine_models.identities.models import IdentityModel +from util.engine_models.identities.traits.models import TraitModel def test_map_environment_to_evaluation_context__environment_default__populates_features( @@ -439,3 +443,32 @@ def test_map_condition_to_segment_condition__valid_condition__returns_expected( "operator": condition.operator, "value": condition.value, } + + +def test_map_edge_identity_to_identity_context__system_traits__merged_with_system_winning( + environment: Environment, +) -> None: + # Given + edge_identity = EdgeIdentity( + IdentityModel( + identifier="identity", + environment_api_key=environment.api_key, + identity_traits=[ + TraitModel(trait_key="owned-by-user", trait_value="user value"), + TraitModel(trait_key="clashing", trait_value="user value"), + ], + system_traits={"clashing": "system value"}, + ) + ) + + # When + identity_context = map_edge_identity_to_identity_context( + edge_identity, environment=environment + ) + + # Then + assert identity_context["traits"] == { + "owned-by-user": "user value", + # System-owned traits are not user data, so they win a key clash. + "clashing": "system value", + } diff --git a/api/tests/unit/util/engine_models/context/test_unit_context_mappers.py b/api/tests/unit/util/engine_models/context/test_unit_context_mappers.py deleted file mode 100644 index db2b2d57bbd4..000000000000 --- a/api/tests/unit/util/engine_models/context/test_unit_context_mappers.py +++ /dev/null @@ -1,32 +0,0 @@ -from environments.models import Environment -from util.engine_models.context.mappers import map_environment_identity_to_context -from util.engine_models.identities.models import IdentityModel -from util.engine_models.identities.traits.models import TraitModel - - -def test_map_environment_identity_to_context__system_traits__merged_with_system_winning( - environment: Environment, -) -> None: - # Given - identity = IdentityModel( - identifier="user-1", - environment_api_key=environment.api_key, - identity_traits=[ - TraitModel(trait_key="plan", trait_value="free"), - TraitModel(trait_key="flagsmith_cohort_a", trait_value="user-written"), - ], - system_traits={"flagsmith_cohort_a": True}, - ) - - # When - context = map_environment_identity_to_context( - environment=environment, identity=identity, override_traits=None - ) - - # Then - identity_context = context["identity"] - assert identity_context is not None - assert identity_context["traits"] == { - "plan": "free", - "flagsmith_cohort_a": True, - } diff --git a/api/util/engine_models/context/mappers.py b/api/util/engine_models/context/mappers.py index 0f6301981bfd..86a243d5774e 100644 --- a/api/util/engine_models/context/mappers.py +++ b/api/util/engine_models/context/mappers.py @@ -8,7 +8,6 @@ import typing from flag_engine.context.types import ( - EvaluationContext, FeatureContext, SegmentContext, SegmentRule, @@ -18,53 +17,8 @@ FeatureStateModel, MultivariateFeatureStateValueModel, ) -from util.engine_models.identities.models import IdentityModel -from util.engine_models.identities.traits.models import TraitModel from util.engine_models.segments.models import SegmentModel, SegmentRuleModel -if typing.TYPE_CHECKING: - from environments.models import Environment - - -def map_environment_identity_to_context( - environment: "Environment", - identity: IdentityModel, - override_traits: typing.Optional[typing.List[TraitModel]], -) -> EvaluationContext: - """ - Map an environment and IdentityModel to an EvaluationContext. - - Vendored from flagsmith-flag-engine's fix/missing-export branch and adapted - to return v10's EvaluationContext TypedDict. - - :param environment: An Environment object. - :param identity: The identity model object (Pydantic IdentityModel). - :param override_traits: A list of TraitModel objects, to be used in place of - `identity.identity_traits` if provided. - :return: An EvaluationContext containing the environment and identity. - """ - traits = { - trait.trait_key: trait.trait_value - for trait in ( - override_traits if override_traits is not None else identity.identity_traits - ) - } - if identity.system_traits: - # System-owned traits are not user data: on a key clash, the system - # value wins. - traits.update(identity.system_traits) - return { - "environment": { - "key": environment.api_key, - "name": environment.name or "", - }, - "identity": { - "identifier": identity.identifier, - "key": str(identity.django_id or identity.composite_key), - "traits": traits, - }, - } - def _map_feature_states_to_feature_contexts( feature_states: typing.List[FeatureStateModel], @@ -160,26 +114,3 @@ def map_segment_to_segment_context(segment: SegmentModel) -> SegmentContext: _map_feature_states_to_feature_contexts(segment_feature_states).values() ) return segment_ctx - - -# TODO: Migrate to get_evaluation_result - see #6669 -def is_context_in_segment( - context: EvaluationContext, - segment: SegmentModel, -) -> bool: - """ - Check if an evaluation context matches a segment. - - This is a compatibility wrapper that bridges the Pydantic SegmentModel - with the v10 flag-engine's TypedDict-based evaluation API. - - :param context: The EvaluationContext (TypedDict). - :param segment: The SegmentModel (Pydantic model). - :return: True if the context matches the segment rules. - """ - from flag_engine.segments.evaluator import ( - is_context_in_segment as v10_is_context_in_segment, - ) - - segment_context = map_segment_to_segment_context(segment) - return v10_is_context_in_segment(context, segment_context) From 526e82083eb54c639c6c3358e61548b7d6c3c9f1 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 23 Sep 2026 03:27:51 +0200 Subject: [PATCH 23/30] feat(api): evaluate edge identity overrides through the engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An edge identity's own overrides were laid over the result once the engine had ruled, so the engine neither knew about them nor said anything about them: no reason named the identity-override segment, and precedence over segment overrides rested on the order of a dict update. Give them to the engine instead, as the synthetic segment every identity override already reaches it through, and read the winners back off the result. `FeatureEngineMetadata` gains `edge_feature_state` to carry one back. A stored model cannot be assigned a `flag_result`, so those states are still resolved by the serialiser until the reason exposed for a multivariate override names its segment — Flagsmith/flagsmith-engine#340. --- api/evaluation/mappers.py | 62 ++++++++++++++++- api/evaluation/services.py | 38 +++++++---- api/features/types.py | 19 ++++-- .../test_unit_evaluation_services.py | 67 ++++++++++++++++++- 4 files changed, 165 insertions(+), 21 deletions(-) diff --git a/api/evaluation/mappers.py b/api/evaluation/mappers.py index 79fe00d68564..9e639328665e 100644 --- a/api/evaluation/mappers.py +++ b/api/evaluation/mappers.py @@ -33,6 +33,10 @@ from features.models import FeatureState from features.multivariate.models import MultivariateFeatureStateValue from segments.models import Condition, Segment, SegmentRule + from util.engine_models.features.models import ( + FeatureStateModel, + MultivariateFeatureStateValueModel, + ) __all__ = ( @@ -41,7 +45,9 @@ "map_condition_to_segment_condition", "map_environment_to_evaluation_context", "map_edge_identity_to_identity_context", + "map_engine_feature_state_to_feature_context", "map_feature_state_to_feature_context", + "map_identity_overrides_to_segment_context", "map_identity_to_identity_context", "map_rule_to_segment_rule", "map_segment_to_segment_context", @@ -138,7 +144,7 @@ def to_feature_context( # An identity override outranks every segment override, which the # engine expresses as a priority no segment can beat. context.setdefault("segments", {})[IDENTITY_OVERRIDES_SEGMENT_KEY] = ( - _map_identity_overrides_to_segment_context( + map_identity_overrides_to_segment_context( [ to_feature_context(feature_state, priority=-inf) for feature_state in identity_overrides @@ -337,7 +343,59 @@ def map_segment_to_segment_context( return segment_context -def _map_identity_overrides_to_segment_context( +def map_engine_feature_state_to_feature_context( + feature_state: "FeatureStateModel", + *, + priority: float | None = None, +) -> FeatureContext: + """Map a DynamoDB-sourced FeatureStateModel to a FeatureContext TypedDict. + + An edge identity's overrides are stored rather than evaluated, so they + carry no bucketing salt: their own id seeds allocation, as it always has. + """ + feature_context: FeatureContext = { + "key": str(feature_state.django_id or feature_state.featurestate_uuid), + "name": feature_state.feature.name, + "enabled": feature_state.enabled, + "value": feature_state.feature_state_value, + "metadata": FeatureEngineMetadata(edge_feature_state=feature_state), + } + + if variants := _map_engine_mv_fs_values_to_feature_values( + feature_state.multivariate_feature_state_values + ): + feature_context["variants"] = variants + + if priority is not None: + feature_context["priority"] = priority + + return feature_context + + +def _map_engine_mv_fs_values_to_feature_values( + mv_fs_values: "Iterable[MultivariateFeatureStateValueModel]", +) -> list[engine_types.FeatureValue]: + # Ordered by id as the stored models always have been, falling back to the + # uuid for values that never reached the ORM. + feature_values: list[engine_types.FeatureValue] = [] + for index, mv_fs_value in enumerate( + sorted( + mv_fs_values, key=lambda mv_value: mv_value.id or mv_value.mv_fs_value_uuid + ) + ): + mv_option = mv_fs_value.multivariate_feature_option + feature_value: engine_types.FeatureValue = { + "value": mv_option.value, + "weight": mv_fs_value.percentage_allocation, + "priority": index, + } + if mv_option.key is not None: + feature_value["key"] = mv_option.key + feature_values.append(feature_value) + return feature_values + + +def map_identity_overrides_to_segment_context( overrides: "list[FeatureContext]", ) -> SegmentContext: """Express identity overrides as a segment matching the current identity.""" diff --git a/api/evaluation/services.py b/api/evaluation/services.py index d19d140b4b81..23440bdb66ef 100644 --- a/api/evaluation/services.py +++ b/api/evaluation/services.py @@ -1,11 +1,15 @@ +from math import inf from typing import TYPE_CHECKING from django.db.models import Q from flag_engine.engine import get_evaluation_result from evaluation.mappers import ( + IDENTITY_OVERRIDES_SEGMENT_KEY, map_edge_identity_to_identity_context, + map_engine_feature_state_to_feature_context, map_environment_to_evaluation_context, + map_identity_overrides_to_segment_context, ) from evaluation.types import IdentityEvaluation @@ -98,19 +102,29 @@ def get_edge_identity_feature_states( ), segments=environment.get_segments_from_cache(), ) - result = get_evaluation_result(context) + # The identity's own overrides are read back from DynamoDB rather than the + # ORM, so the mapper never saw them. They reach the engine the way every + # identity override does, as a segment no other override can outrank. + if overrides := [ + map_engine_feature_state_to_feature_context(feature_state, priority=-inf) + for feature_state in edge_identity.feature_overrides + ]: + context.setdefault("segments", {})[IDENTITY_OVERRIDES_SEGMENT_KEY] = ( + map_identity_overrides_to_segment_context(overrides) + ) + + feature_states: list["FeatureState | FeatureStateModel"] = [] + for flag in get_evaluation_result(context)["flags"].values(): + if (feature_state := flag["metadata"].get("feature_state")) is not None: + feature_state.flag_result = flag + feature_states.append(feature_state) + else: + # A stored model cannot be assigned the engine's verdict, so an + # edge identity's own overrides are still resolved by the + # serialiser. See Flagsmith/flagsmith-engine#340. + feature_states.append(flag["metadata"]["edge_feature_state"]) - feature_states: dict[str, "FeatureState | FeatureStateModel"] = {} - for flag in result["flags"].values(): - feature_state = flag["metadata"]["feature_state"] - feature_state.flag_result = flag - feature_states[flag["name"]] = feature_state - - # An identity override outranks anything the engine ruled on. - for identity_feature_state in edge_identity.feature_overrides: - feature_states[identity_feature_state.feature.name] = identity_feature_state - - return list(feature_states.values()) + return feature_states def get_edge_identity_segments(edge_identity: "EdgeIdentity") -> "list[Segment]": diff --git a/api/features/types.py b/api/features/types.py index edd9723bff15..d82a1b506eb3 100644 --- a/api/features/types.py +++ b/api/features/types.py @@ -1,19 +1,26 @@ from typing import TYPE_CHECKING -from typing_extensions import TypedDict +from typing_extensions import NotRequired, TypedDict if TYPE_CHECKING: from features.models import FeatureState + from util.engine_models.features.models import FeatureStateModel class FeatureEngineMetadata(TypedDict): """Core API data carried on a `FeatureContext` and back on a `FlagResult`. - The engine treats this as opaque, so the row an evaluated flag came from - can simply ride along, saving callers from working out which override won. + The engine treats this as opaque, so the feature state an evaluated flag + came from can simply ride along, saving callers from working out which + override won. Exactly one of the two is set, naming where it was read + from. - The annotation is deliberately a forward reference: nothing here may import - Django at runtime, or `features.models` could not annotate against it. + The annotations are deliberately forward references: nothing here may + import Django at runtime, or `features.models` could not annotate against + it. """ - feature_state: "FeatureState" + feature_state: NotRequired["FeatureState"] + #: An edge identity's own overrides are stored in DynamoDB rather than the + #: ORM, so they reach evaluation as the model they were read back as. + edge_feature_state: NotRequired["FeatureStateModel"] diff --git a/api/tests/unit/evaluation/test_unit_evaluation_services.py b/api/tests/unit/evaluation/test_unit_evaluation_services.py index 28bfcde896e1..8076980eba74 100644 --- a/api/tests/unit/evaluation/test_unit_evaluation_services.py +++ b/api/tests/unit/evaluation/test_unit_evaluation_services.py @@ -2,10 +2,14 @@ from flag_engine.segments.constants import EQUAL from pytest_lazy_fixtures import lf as lazy_fixture +from edge_api.identities.models import EdgeIdentity from environments.identities.models import Identity from environments.identities.traits.models import Trait from environments.models import Environment -from evaluation.services import evaluate_identity +from evaluation.services import ( + evaluate_identity, + get_edge_identity_feature_states, +) from features.constants import CONTROL_VARIANT_KEY from features.feature_types import MULTIVARIATE from features.models import Feature, FeatureSegment, FeatureState @@ -16,6 +20,9 @@ from features.value_types import STRING from projects.models import Project from segments.models import Condition, Segment, SegmentRule +from util.engine_models.features.models import FeatureModel, FeatureStateModel +from util.engine_models.identities.models import IdentityFeaturesList, IdentityModel +from util.engine_models.identities.traits.models import TraitModel @pytest.fixture() @@ -270,3 +277,61 @@ def test_evaluate_identity__multivariate_feature__allocates_variants_in_order( # Then assert flag["variant"] == expected_variant assert flag["value"] == expected_value + + +def test_get_edge_identity_feature_states__segment_and_identity_override__identity_override_wins( + environment: Environment, + feature: Feature, + identity_matching_segment: Segment, + trait: Trait, +) -> None: + # Given + # a segment override the identity matches + feature_segment = FeatureSegment.objects.create( + feature=feature, + segment=identity_matching_segment, + environment=environment, + priority=0, + ) + segment_override = FeatureState.objects.create( + feature=feature, + environment=environment, + feature_segment=feature_segment, + enabled=True, + ) + segment_override.feature_state_value.string_value = "segment" + segment_override.feature_state_value.save() + + # and an identity override, stored against the identity in DynamoDB + edge_identity = EdgeIdentity( + IdentityModel( + identifier="identity", + environment_api_key=environment.api_key, + identity_traits=[ + TraitModel(trait_key=trait.trait_key, trait_value=trait.trait_value) + ], + identity_features=IdentityFeaturesList( + [ + FeatureStateModel( + django_id=1, + feature=FeatureModel( + id=feature.id, name=feature.name, type=feature.type + ), + enabled=True, + feature_state_value="identity", + ) + ] + ), + ) + ) + + # When + feature_states = get_edge_identity_feature_states(edge_identity) + + # Then + (feature_state,) = [ + feature_state + for feature_state in feature_states + if feature_state.feature.name == feature.name + ] + assert feature_state.feature_state_value == "identity" From 680e91f482bf65b1bd85d63d80a7f2a8146be9c3 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 23 Sep 2026 03:54:32 +0200 Subject: [PATCH 24/30] refactor(api): move edge identity segment matching out of the wrapper `get_segment_ids` had become a shim around `get_edge_identity_segments`, and matching segments was never DynamoDB's business. The segments view reads the document and asks the evaluation service itself. Its tests move with it, and stop reaching for DynamoDB on the way: what they set up is an identity document and some segments, which is all `get_edge_identity_segments` wants. The ones left behind covered the method's own signature. --- .../dynamodb/wrappers/identity_wrapper.py | 22 -- api/segments/views.py | 21 +- .../test_unit_dynamodb_identity_wrapper.py | 270 +----------------- .../test_unit_evaluation_services.py | 148 +++++++++- .../unit/segments/test_unit_segments_views.py | 8 +- 5 files changed, 169 insertions(+), 300 deletions(-) diff --git a/api/environments/dynamodb/wrappers/identity_wrapper.py b/api/environments/dynamodb/wrappers/identity_wrapper.py index 4376ad2ab665..69cd36571555 100644 --- a/api/environments/dynamodb/wrappers/identity_wrapper.py +++ b/api/environments/dynamodb/wrappers/identity_wrapper.py @@ -316,25 +316,3 @@ def search_items( query_kwargs.update(ExclusiveStartKey=start_key) return self.query_items(**query_kwargs) - - def get_segment_ids( - self, - identity_pk: str = None, # type: ignore[assignment] - identity_model: IdentityModel = None, # type: ignore[assignment] - ) -> list: # type: ignore[type-arg] - from edge_api.identities.models import EdgeIdentity - from evaluation.services import get_edge_identity_segments - - if not (identity_pk or identity_model): - raise ValueError("Must provide one of identity_pk or identity_model.") - - with suppress(ObjectDoesNotExist): - identity = identity_model or IdentityModel.model_validate( - self.get_item_from_uuid(identity_pk) - ) - return [ - segment.id - for segment in get_edge_identity_segments(EdgeIdentity(identity)) - ] - - return [] diff --git a/api/segments/views.py b/api/segments/views.py index 79a005b68351..c04626517dd0 100644 --- a/api/segments/views.py +++ b/api/segments/views.py @@ -1,8 +1,10 @@ +from contextlib import suppress from typing import TYPE_CHECKING, Any import structlog from common.environments.permissions import VIEW_IDENTITIES from common.projects.permissions import VIEW_PROJECT +from django.core.exceptions import ObjectDoesNotExist from django.db import models from django.db.models import Prefetch from django.utils.decorators import method_decorator @@ -21,6 +23,7 @@ from edge_api.identities.models import EdgeIdentity from environments.identities.models import Identity from environments.models import Environment +from evaluation.services import get_edge_identity_segments from features.models import FeatureState from features.serializers import ( AssociatedFeaturesQuerySerializer, @@ -54,6 +57,17 @@ logger = structlog.get_logger("segments") +def _get_edge_identity_segments(identity_uuid: str) -> list[Segment]: + """The segments an edge identity belongs to, or none if it is unknown.""" + with suppress(ObjectDoesNotExist): + return get_edge_identity_segments( + EdgeIdentity.from_identity_document( + EdgeIdentity.dynamo_wrapper.get_item_from_uuid(identity_uuid) + ) + ) + return [] + + @method_decorator( name="list", decorator=extend_schema( @@ -134,11 +148,10 @@ def get_queryset(self): # type: ignore[no-untyped-def] identity_pk = query_serializer.validated_data.get("identity") if identity_pk: if identity_pk.isdigit(): - identity = Identity.objects.get(pk=identity_pk) - segment_ids = [segment.id for segment in identity.get_segments()] + segments = Identity.objects.get(pk=identity_pk).get_segments() else: - segment_ids = EdgeIdentity.dynamo_wrapper.get_segment_ids(identity_pk) - queryset = queryset.filter(id__in=segment_ids) + segments = _get_edge_identity_segments(identity_pk) + queryset = queryset.filter(id__in=[segment.id for segment in segments]) search_term = query_serializer.validated_data.get("q") if search_term: diff --git a/api/tests/unit/environments/dynamodb/wrappers/test_unit_dynamodb_identity_wrapper.py b/api/tests/unit/environments/dynamodb/wrappers/test_unit_dynamodb_identity_wrapper.py index 32bd84555fc9..eb195d313e84 100644 --- a/api/tests/unit/environments/dynamodb/wrappers/test_unit_dynamodb_identity_wrapper.py +++ b/api/tests/unit/environments/dynamodb/wrappers/test_unit_dynamodb_identity_wrapper.py @@ -3,16 +3,12 @@ import pytest from boto3.dynamodb.conditions import Key -from boto3.dynamodb.types import Binary from botocore.exceptions import ClientError from django.core.exceptions import ObjectDoesNotExist -from flag_engine.segments.constants import IN, IS_SET from mypy_boto3_dynamodb.service_resource import Table -from pytest_django.fixtures import SettingsWrapper from pytest_mock import MockerFixture from rest_framework.exceptions import NotFound -from core.constants import INTEGER from edge_api.identities.search import ( IDENTIFIER_ATTRIBUTE, EdgeIdentitySearchData, @@ -27,22 +23,12 @@ from environments.identities.traits.constants import ( TRAIT_STRING_VALUE_MAX_LENGTH, ) -from environments.identities.traits.models import Trait -from features.models import Feature, FeatureSegment, FeatureState -from features.multivariate.models import ( - MultivariateFeatureOption, - MultivariateFeatureStateValue, -) -from segments.models import Condition, Segment, SegmentRule -from util.engine_models.identities.models import IdentityModel from util.mappers import ( - map_environment_to_compressed_environment_document, map_identity_to_identity_document, ) if typing.TYPE_CHECKING: - from environments.models import Environment - from projects.models import Project + pass def test_get_item_from_uuid__valid_uuid__calls_query_correctly(mocker): # type: ignore[no-untyped-def] @@ -316,128 +302,6 @@ def test_is_enabled__table_name_set__returns_true(settings, mocker): # type: ig mocked_boto3.resource.return_value.Table.assert_called_with(table_name) -def test_get_segment_ids__matching_segment_exists__returns_correct_ids( # type: ignore[no-untyped-def] - project, environment, identity, identity_matching_segment, mocker -): - # Given - two segments (one that matches the identity and one that does not) - Segment.objects.create(name="Non matching segment", project=project) - - identity_document = map_identity_to_identity_document(identity) - identity_uuid = identity_document["identity_uuid"] - - dynamo_identity_wrapper = DynamoIdentityWrapper() - mocked_get_item_from_uuid = mocker.patch.object( - dynamo_identity_wrapper, "get_item_from_uuid", return_value=identity_document - ) - - # When - segment_ids = dynamo_identity_wrapper.get_segment_ids(identity_uuid) # type: ignore[arg-type] - - # Then - assert segment_ids == [identity_matching_segment.id] - mocked_get_item_from_uuid.assert_called_with(identity_uuid) - - -def test_get_segment_ids__segment_with_feature_overrides__returns_correct_ids( - project: "Project", - environment: "Environment", - feature: "Feature", - identity: "Identity", - identity_matching_segment: "Segment", - mocker: "MockerFixture", -) -> None: - # Given - a segment with two feature overrides: - # one simple override and one with multivariate values - simple_feature_segment = FeatureSegment.objects.create( - feature=feature, - segment=identity_matching_segment, - environment=environment, - ) - FeatureState.objects.create( - feature=feature, - environment=environment, - feature_segment=simple_feature_segment, - enabled=True, - ) - - mv_feature = Feature.objects.create( - name="mv_feature", - project=project, - type="MULTIVARIATE", - ) - mv_option = MultivariateFeatureOption.objects.create( - feature=mv_feature, - default_percentage_allocation=30, - type="unicode", - string_value="variant_a", - ) - mv_feature_segment = FeatureSegment.objects.create( - feature=mv_feature, - segment=identity_matching_segment, - environment=environment, - ) - mv_feature_state = FeatureState.objects.create( - feature=mv_feature, - environment=environment, - feature_segment=mv_feature_segment, - enabled=True, - ) - MultivariateFeatureStateValue.objects.create( - feature_state=mv_feature_state, - multivariate_feature_option=mv_option, - percentage_allocation=30, - ) - - identity_document = map_identity_to_identity_document(identity) - identity_uuid = identity_document["identity_uuid"] - - dynamo_identity_wrapper = DynamoIdentityWrapper() - mocker.patch.object( - dynamo_identity_wrapper, "get_item_from_uuid", return_value=identity_document - ) - - # When - segment_ids = dynamo_identity_wrapper.get_segment_ids(identity_uuid) # type: ignore[arg-type] - - # Then - assert segment_ids == [identity_matching_segment.id] - - -def test_get_segment_ids__system_trait_backed_segment__returns_correct_ids( - project: "Project", - environment: "Environment", - identity: "Identity", - mocker: "MockerFixture", -) -> None: - # Given - two IS_SET segments: one keyed to a system trait the identity - # carries, one keyed to a system trait it does not - member_segment = Segment.objects.create(name="Cohort members", project=project) - rule = SegmentRule.objects.create(segment=member_segment, type=SegmentRule.ALL_RULE) - Condition.objects.create(rule=rule, operator=IS_SET, property="flagsmith_cohort_a") - other_segment = Segment.objects.create(name="Other cohort", project=project) - other_rule = SegmentRule.objects.create( - segment=other_segment, type=SegmentRule.ALL_RULE - ) - Condition.objects.create( - rule=other_rule, operator=IS_SET, property="flagsmith_cohort_b" - ) - - identity_document = map_identity_to_identity_document(identity) - identity_document["system_traits"] = {"flagsmith_cohort_a": True} - identity_uuid = identity_document["identity_uuid"] - - dynamo_identity_wrapper = DynamoIdentityWrapper() - mocker.patch.object( - dynamo_identity_wrapper, "get_item_from_uuid", return_value=identity_document - ) - - # When - segment_ids = dynamo_identity_wrapper.get_segment_ids(identity_uuid) # type: ignore[arg-type] - - # Then - assert segment_ids == [member_segment.id] - - def test_set_system_trait__oversized_string_value__raises() -> None: # Given wrapper = DynamoIdentityWrapper() @@ -452,138 +316,6 @@ def test_set_system_trait__oversized_string_value__raises() -> None: ) -def test_get_segment_ids__in_operator_with_integer_traits__returns_matching_segment( - project: "Project", environment: "Environment", mocker: "MockerFixture" -) -> None: - """ - Specific test to cover https://github.com/Flagsmith/flagsmith/issues/2602 - """ - # Given - trait_key = "trait_key" - - segment = Segment.objects.create(name="Test Segment", project=project) - parent_rule = SegmentRule.objects.create(segment=segment, type=SegmentRule.ALL_RULE) - child_rule = SegmentRule.objects.create(rule=parent_rule, type=SegmentRule.ANY_RULE) - Condition.objects.create( - property=trait_key, operator=IN, value="1,2,3,4", rule=child_rule - ) - - identity = Identity.objects.create(environment=environment, identifier="identifier") - Trait.objects.create( - trait_key=trait_key, integer_value=1, value_type=INTEGER, identity=identity - ) - - identity_document = map_identity_to_identity_document(identity) - identity_uuid = identity_document["identity_uuid"] - - dynamo_identity_wrapper = DynamoIdentityWrapper() - mocker.patch.object( - dynamo_identity_wrapper, "get_item_from_uuid", return_value=identity_document - ) - - # When - segment_ids = dynamo_identity_wrapper.get_segment_ids(identity_uuid) # type: ignore[arg-type] - - # Then - assert segment_ids == [segment.id] - - -def test_get_segment_ids__identity_does_not_exist__returns_empty_list( # type: ignore[no-untyped-def] - project, environment, identity, mocker -): - # Given - identity_document = map_identity_to_identity_document(identity) - dynamo_identity_wrapper = DynamoIdentityWrapper() - mocker.patch.object( - dynamo_identity_wrapper, "get_item_from_uuid", side_effect=ObjectDoesNotExist - ) - identity_uuid = identity_document["identity_uuid"] - - # When - segment_ids = dynamo_identity_wrapper.get_segment_ids(identity_uuid) # type: ignore[arg-type] - - # Then - assert segment_ids == [] - - -def test_get_segment_ids__no_arguments__raises_value_error(): # type: ignore[no-untyped-def] - # Given - dynamo_identity_wrapper = DynamoIdentityWrapper() - - # When / Then - with pytest.raises(ValueError): - dynamo_identity_wrapper.get_segment_ids() - - -def test_get_segment_ids__none_argument__raises_value_error(): # type: ignore[no-untyped-def] - # Given - dynamo_identity_wrapper = DynamoIdentityWrapper() - - # When / Then - with pytest.raises(ValueError): - dynamo_identity_wrapper.get_segment_ids(None) # type: ignore[arg-type] - - -def test_get_segment_ids__with_identity_model__returns_empty_list( # type: ignore[no-untyped-def] - identity, environment, mocker -): - # Given - identity_document = map_identity_to_identity_document(identity) - identity_model = IdentityModel.parse_obj(identity_document) - - dynamo_identity_wrapper = DynamoIdentityWrapper() - mocker.patch.object( - dynamo_identity_wrapper, "get_item_from_uuid", return_value=identity_document - ) - - # When - segment_ids = dynamo_identity_wrapper.get_segment_ids(identity_model=identity_model) - - # Then - assert segment_ids == [] - - -def test_get_segment_ids__compressed_environment_in_dynamo__returns_correct_segment_ids( - identity: "Identity", - identity_matching_segment: "Segment", - dynamodb_identity_wrapper: DynamoIdentityWrapper, - flagsmith_identities_table: Table, - flagsmith_environment_table: Table, - settings: "SettingsWrapper", -) -> None: - """Regression test for https://github.com/Flagsmith/flagsmith/issues/6912 - - Previously, get_segment_ids read the environment document from DynamoDB - and failed with a ValidationError when the document contained compressed - (gzipped Binary) `project` and `feature_states` fields. - """ - # Given - identity written to DynamoDB - identity_document = map_identity_to_identity_document(identity) - flagsmith_identities_table.put_item(Item=identity_document) - identity_uuid = str(identity_document["identity_uuid"]) - - # And - a compressed environment document in DynamoDB - settings.ENVIRONMENTS_TABLE_NAME_DYNAMO = flagsmith_environment_table.name - compressed_result = map_environment_to_compressed_environment_document( - identity.environment, - ) - flagsmith_environment_table.put_item(Item=compressed_result.document) - - # Verify the document actually has compressed Binary fields - stored = flagsmith_environment_table.get_item( - Key={"api_key": identity.environment.api_key}, - )["Item"] - assert stored.get("compressed") is True - assert isinstance(stored["project"], Binary) - assert isinstance(stored["feature_states"], Binary) - - # When - segment_ids = dynamodb_identity_wrapper.get_segment_ids(identity_uuid) - - # Then - assert segment_ids == [identity_matching_segment.id] - - def test_identity_wrapper__iter_all_items_paginated__returns_expected( identity: "Identity", mocker: "MockerFixture", diff --git a/api/tests/unit/evaluation/test_unit_evaluation_services.py b/api/tests/unit/evaluation/test_unit_evaluation_services.py index 8076980eba74..9a3029e65b82 100644 --- a/api/tests/unit/evaluation/test_unit_evaluation_services.py +++ b/api/tests/unit/evaluation/test_unit_evaluation_services.py @@ -1,5 +1,5 @@ import pytest -from flag_engine.segments.constants import EQUAL +from flag_engine.segments.constants import EQUAL, IN, IS_SET from pytest_lazy_fixtures import lf as lazy_fixture from edge_api.identities.models import EdgeIdentity @@ -9,6 +9,7 @@ from evaluation.services import ( evaluate_identity, get_edge_identity_feature_states, + get_edge_identity_segments, ) from features.constants import CONTROL_VARIANT_KEY from features.feature_types import MULTIVARIATE @@ -17,12 +18,13 @@ MultivariateFeatureOption, MultivariateFeatureStateValue, ) -from features.value_types import STRING +from features.value_types import INTEGER, STRING from projects.models import Project from segments.models import Condition, Segment, SegmentRule from util.engine_models.features.models import FeatureModel, FeatureStateModel from util.engine_models.identities.models import IdentityFeaturesList, IdentityModel from util.engine_models.identities.traits.models import TraitModel +from util.mappers import map_identity_to_identity_document @pytest.fixture() @@ -335,3 +337,145 @@ def test_get_edge_identity_feature_states__segment_and_identity_override__identi if feature_state.feature.name == feature.name ] assert feature_state.feature_state_value == "identity" + + +def test_get_edge_identity_segments__matching_segment_exists__returns_matching_only( + project: Project, + environment: Environment, + identity: Identity, + identity_matching_segment: Segment, +) -> None: + # Given - two segments (one that matches the identity and one that does not) + Segment.objects.create(name="Non matching segment", project=project) + + edge_identity = EdgeIdentity.from_identity_document( + map_identity_to_identity_document(identity) + ) + + # When + segments = get_edge_identity_segments(edge_identity) + + # Then + assert segments == [identity_matching_segment] + + +def test_get_edge_identity_segments__segment_with_feature_overrides__returns_matching_only( + project: Project, + environment: Environment, + feature: Feature, + identity: Identity, + identity_matching_segment: Segment, +) -> None: + # Given - a segment with two feature overrides: + # one simple override and one with multivariate values + simple_feature_segment = FeatureSegment.objects.create( + feature=feature, + segment=identity_matching_segment, + environment=environment, + ) + FeatureState.objects.create( + feature=feature, + environment=environment, + feature_segment=simple_feature_segment, + enabled=True, + ) + + mv_feature = Feature.objects.create( + name="mv_feature", + project=project, + type="MULTIVARIATE", + ) + mv_option = MultivariateFeatureOption.objects.create( + feature=mv_feature, + default_percentage_allocation=30, + type="unicode", + string_value="variant_a", + ) + mv_feature_segment = FeatureSegment.objects.create( + feature=mv_feature, + segment=identity_matching_segment, + environment=environment, + ) + mv_feature_state = FeatureState.objects.create( + feature=mv_feature, + environment=environment, + feature_segment=mv_feature_segment, + enabled=True, + ) + MultivariateFeatureStateValue.objects.create( + feature_state=mv_feature_state, + multivariate_feature_option=mv_option, + percentage_allocation=30, + ) + + edge_identity = EdgeIdentity.from_identity_document( + map_identity_to_identity_document(identity) + ) + + # When + segments = get_edge_identity_segments(edge_identity) + + # Then + assert segments == [identity_matching_segment] + + +def test_get_edge_identity_segments__system_trait_backed_segment__returns_matching_only( + project: Project, + environment: Environment, + identity: Identity, +) -> None: + # Given - two IS_SET segments: one keyed to a system trait the identity + # carries, one keyed to a system trait it does not + member_segment = Segment.objects.create(name="Cohort members", project=project) + rule = SegmentRule.objects.create(segment=member_segment, type=SegmentRule.ALL_RULE) + Condition.objects.create(rule=rule, operator=IS_SET, property="flagsmith_cohort_a") + other_segment = Segment.objects.create(name="Other cohort", project=project) + other_rule = SegmentRule.objects.create( + segment=other_segment, type=SegmentRule.ALL_RULE + ) + Condition.objects.create( + rule=other_rule, operator=IS_SET, property="flagsmith_cohort_b" + ) + + identity_document = map_identity_to_identity_document(identity) + identity_document["system_traits"] = {"flagsmith_cohort_a": True} + edge_identity = EdgeIdentity.from_identity_document(identity_document) + + # When + segments = get_edge_identity_segments(edge_identity) + + # Then + assert segments == [member_segment] + + +def test_get_edge_identity_segments__in_operator_with_integer_traits__returns_matching_only( + project: Project, + environment: Environment, +) -> None: + """ + Specific test to cover https://github.com/Flagsmith/flagsmith/issues/2602 + """ + # Given + trait_key = "trait_key" + + segment = Segment.objects.create(name="Test Segment", project=project) + parent_rule = SegmentRule.objects.create(segment=segment, type=SegmentRule.ALL_RULE) + child_rule = SegmentRule.objects.create(rule=parent_rule, type=SegmentRule.ANY_RULE) + Condition.objects.create( + property=trait_key, operator=IN, value="1,2,3,4", rule=child_rule + ) + + identity = Identity.objects.create(environment=environment, identifier="identifier") + Trait.objects.create( + trait_key=trait_key, integer_value=1, value_type=INTEGER, identity=identity + ) + + edge_identity = EdgeIdentity.from_identity_document( + map_identity_to_identity_document(identity) + ) + + # When + segments = get_edge_identity_segments(edge_identity) + + # Then + assert segments == [segment] diff --git a/api/tests/unit/segments/test_unit_segments_views.py b/api/tests/unit/segments/test_unit_segments_views.py index 8dc792563315..0f6ef90a002a 100644 --- a/api/tests/unit/segments/test_unit_segments_views.py +++ b/api/tests/unit/segments/test_unit_segments_views.py @@ -518,8 +518,8 @@ def test_list_segments__filter_by_edge_identity__returns_only_matching_segments( identity_uuid = identity_document["identity_uuid"] assert isinstance(identity_uuid, str) - edge_identity_dynamo_wrapper_mock.get_segment_ids.return_value = ( - expected_segment_ids + edge_identity_dynamo_wrapper_mock.get_item_from_uuid.return_value = ( + identity_document ) base_url = reverse("api-v1:projects:project-segments-list", args=[project.id]) @@ -531,7 +531,9 @@ def test_list_segments__filter_by_edge_identity__returns_only_matching_segments( # Then assert response.json().get("count") == len(expected_segment_ids) assert response.json()["results"][0]["id"] == expected_segment_ids[0] - edge_identity_dynamo_wrapper_mock.get_segment_ids.assert_called_with(identity_uuid) + edge_identity_dynamo_wrapper_mock.get_item_from_uuid.assert_called_with( + identity_uuid + ) @pytest.mark.parametrize( From de84f69f0e8131b53cadb0ecd61abd817d65981f Mon Sep 17 00:00:00 2001 From: "flagsmith-engineering[bot]" Date: Wed, 23 Sep 2026 01:55:32 +0000 Subject: [PATCH 25/30] chore: Update documentation artefacts --- .../observability/_events-catalogue.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 79394941051d..665d8364c1e2 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -676,7 +676,7 @@ Attributes: ### `segments.delete_rejected` Logged at `warning` from: - - `api/segments/views.py:279` + - `api/segments/views.py:292` Attributes: - `organisation.id` @@ -696,7 +696,7 @@ Attributes: ### `segments.update_rejected` Logged at `warning` from: - - `api/segments/views.py:255` + - `api/segments/views.py:268` Attributes: - `organisation.id` From 2d36db0e7919110285176dab945e0896209954bd Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 23 Sep 2026 03:56:08 +0200 Subject: [PATCH 26/30] refactor(api): inline _get_edge_identity_segments --- api/segments/views.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/api/segments/views.py b/api/segments/views.py index c04626517dd0..bd1f38678695 100644 --- a/api/segments/views.py +++ b/api/segments/views.py @@ -57,17 +57,6 @@ logger = structlog.get_logger("segments") -def _get_edge_identity_segments(identity_uuid: str) -> list[Segment]: - """The segments an edge identity belongs to, or none if it is unknown.""" - with suppress(ObjectDoesNotExist): - return get_edge_identity_segments( - EdgeIdentity.from_identity_document( - EdgeIdentity.dynamo_wrapper.get_item_from_uuid(identity_uuid) - ) - ) - return [] - - @method_decorator( name="list", decorator=extend_schema( @@ -147,10 +136,18 @@ def get_queryset(self): # type: ignore[no-untyped-def] identity_pk = query_serializer.validated_data.get("identity") if identity_pk: + segments: list[Segment] = [] if identity_pk.isdigit(): segments = Identity.objects.get(pk=identity_pk).get_segments() else: - segments = _get_edge_identity_segments(identity_pk) + # An edge identity the environment has never seen belongs to + # no segments, rather than being an error. + with suppress(ObjectDoesNotExist): + segments = get_edge_identity_segments( + EdgeIdentity.from_identity_document( + EdgeIdentity.dynamo_wrapper.get_item_from_uuid(identity_pk) + ) + ) queryset = queryset.filter(id__in=[segment.id for segment in segments]) search_term = query_serializer.validated_data.get("q") From d0227d03f999f4b07b4833f8dc14951405f01126 Mon Sep 17 00:00:00 2001 From: "flagsmith-engineering[bot]" Date: Wed, 23 Sep 2026 01:57:31 +0000 Subject: [PATCH 27/30] chore: Update documentation artefacts --- .../observability/_events-catalogue.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 665d8364c1e2..c192ee09bb5f 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -676,7 +676,7 @@ Attributes: ### `segments.delete_rejected` Logged at `warning` from: - - `api/segments/views.py:292` + - `api/segments/views.py:289` Attributes: - `organisation.id` @@ -696,7 +696,7 @@ Attributes: ### `segments.update_rejected` Logged at `warning` from: - - `api/segments/views.py:268` + - `api/segments/views.py:265` Attributes: - `organisation.id` From 7d9b6de0271100dd2e4a296a242ca8beb6b233fe Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 23 Sep 2026 04:00:23 +0200 Subject: [PATCH 28/30] fix(api): only suppress the identity lookup that can legitimately miss Suppressing `ObjectDoesNotExist` around the whole branch also swallowed the `Environment.DoesNotExist` that `get_edge_identity_segments` raises when reading the environment, reporting an identity as belonging to no segments when something was actually wrong. Inherited from the method this replaced. Catch around the DynamoDB read alone, and cover the missing identity where it is now handled. --- api/segments/views.py | 17 +++++++------ .../unit/segments/test_unit_segments_views.py | 25 +++++++++++++++++++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/api/segments/views.py b/api/segments/views.py index bd1f38678695..a8a9dcfd64b4 100644 --- a/api/segments/views.py +++ b/api/segments/views.py @@ -1,4 +1,3 @@ -from contextlib import suppress from typing import TYPE_CHECKING, Any import structlog @@ -140,13 +139,17 @@ def get_queryset(self): # type: ignore[no-untyped-def] if identity_pk.isdigit(): segments = Identity.objects.get(pk=identity_pk).get_segments() else: - # An edge identity the environment has never seen belongs to - # no segments, rather than being an error. - with suppress(ObjectDoesNotExist): + try: + identity_document = EdgeIdentity.dynamo_wrapper.get_item_from_uuid( + identity_pk + ) + except ObjectDoesNotExist: + # An identity the environment has never seen belongs to no + # segments, rather than being an error. + pass + else: segments = get_edge_identity_segments( - EdgeIdentity.from_identity_document( - EdgeIdentity.dynamo_wrapper.get_item_from_uuid(identity_pk) - ) + EdgeIdentity.from_identity_document(identity_document) ) queryset = queryset.filter(id__in=[segment.id for segment in segments]) diff --git a/api/tests/unit/segments/test_unit_segments_views.py b/api/tests/unit/segments/test_unit_segments_views.py index 0f6ef90a002a..4df1d283b3b9 100644 --- a/api/tests/unit/segments/test_unit_segments_views.py +++ b/api/tests/unit/segments/test_unit_segments_views.py @@ -3,6 +3,7 @@ from collections.abc import Callable from copy import deepcopy from datetime import timedelta +from unittest.mock import MagicMock import freezegun import pytest @@ -13,6 +14,7 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist from django.urls import reverse from django.utils import timezone from flag_engine.segments.constants import EQUAL @@ -536,6 +538,29 @@ def test_list_segments__filter_by_edge_identity__returns_only_matching_segments( ) +def test_list_segments__filter_by_unknown_edge_identity__returns_no_segments( + project: Project, + environment: Environment, + identity_matching_segment: Segment, + edge_identity_dynamo_wrapper_mock: MagicMock, + admin_client: APIClient, +) -> None: + # Given + edge_identity_dynamo_wrapper_mock.get_item_from_uuid.side_effect = ( + ObjectDoesNotExist + ) + base_url = reverse("api-v1:projects:project-segments-list", args=[project.id]) + + # When + response = admin_client.get( + f"{base_url}?identity=8ce1e2f8-0a0f-4f5c-9f4c-2b4f6f4f4f4f" + ) + + # Then + assert response.status_code == status.HTTP_200_OK + assert response.json()["count"] == 0 + + @pytest.mark.parametrize( "client", [lazy_fixture("admin_master_api_key_client"), lazy_fixture("admin_client")], From 4d060a5825f9fc821b4b4acc91cd9dfb284b24ae Mon Sep 17 00:00:00 2001 From: "flagsmith-engineering[bot]" Date: Wed, 23 Sep 2026 02:01:34 +0000 Subject: [PATCH 29/30] chore: Update documentation artefacts --- .../observability/_events-catalogue.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index c192ee09bb5f..665d8364c1e2 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -676,7 +676,7 @@ Attributes: ### `segments.delete_rejected` Logged at `warning` from: - - `api/segments/views.py:289` + - `api/segments/views.py:292` Attributes: - `organisation.id` @@ -696,7 +696,7 @@ Attributes: ### `segments.update_rejected` Logged at `warning` from: - - `api/segments/views.py:265` + - `api/segments/views.py:268` Attributes: - `organisation.id` From 334c714dee49e304607f15efccf6dfde34d61b33 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 23 Sep 2026 04:19:12 +0200 Subject: [PATCH 30/30] feat(api): evaluate environment flags through the engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /flags` read environment defaults straight from the ORM, filtering segment overrides out of the query. So a segment that does not depend on an identity — one reading `$.environment`, or another flag — was ignored, though an SDK evaluating the same environment document locally honours it. Evaluate the environment instead, with segments and without an identity, and let the engine decide. Disabled flags are now hidden after evaluating rather than filtered from the query, so that a disabled override is no longer passed over in favour of an enabled default. --- api/evaluation/mappers.py | 4 ++ api/evaluation/services.py | 33 ++++++++++++ api/features/views.py | 54 ++++++++++--------- .../unit/features/test_unit_features_views.py | 44 ++++++++++++++- 4 files changed, 110 insertions(+), 25 deletions(-) diff --git a/api/evaluation/mappers.py b/api/evaluation/mappers.py index 9e639328665e..9f2feabbfc3f 100644 --- a/api/evaluation/mappers.py +++ b/api/evaluation/mappers.py @@ -71,6 +71,7 @@ def map_environment_to_evaluation_context( traits: "Iterable[Trait] | None" = None, segments: "Iterable[Segment] | None" = None, additional_filters: "Q | None" = None, + from_replica: bool = False, ) -> EvaluationContext: """Map Django ORM models to a flag-engine `EvaluationContext`. @@ -113,6 +114,7 @@ def map_environment_to_evaluation_context( environment=environment, identity=identity, additional_filters=additional_filters, + from_replica=from_replica, ) # No reading from ORM past this point! @@ -174,6 +176,7 @@ def _resolve_feature_states( environment: "Environment", identity: "Identity | None", additional_filters: "Q | None", + from_replica: bool, ) -> _ResolvedFeatureStates: """Read the feature states current for `environment`, split by what they override.""" # Deferred: `environments.models` imports this module's package. @@ -191,6 +194,7 @@ def _resolve_feature_states( feature_states = get_environment_flags_list( environment=environment, additional_filters=override_filters, + from_replica=from_replica, additional_select_related_args=["feature_segment__segment"], additional_prefetch_related_args=[ Prefetch( diff --git a/api/evaluation/services.py b/api/evaluation/services.py index 23440bdb66ef..5fbf164e0993 100644 --- a/api/evaluation/services.py +++ b/api/evaluation/services.py @@ -25,6 +25,7 @@ __all__ = ( "evaluate_identity", + "get_environment_feature_states", "get_edge_identity_feature_states", "get_edge_identity_segments", "get_identity_feature_states", @@ -146,3 +147,35 @@ def get_edge_identity_segments(edge_identity: "EdgeIdentity") -> "list[Segment]" for segment_result in get_evaluation_result(context)["segments"] if (pk := segment_result["metadata"].get("pk")) is not None ] + + +def get_environment_feature_states( + environment: "Environment", + *, + additional_filters: Q | None = None, + from_replica: bool = False, +) -> "list[FeatureState]": + """The feature states to serve for an environment, one per feature. + + Evaluated without an identity, so a segment whose rules read a trait or + split on the identity key cannot match. One reading `$.environment`, or + another flag, still can — as it does for an SDK evaluating locally. + """ + context = map_environment_to_evaluation_context( + environment=environment, + segments=environment.get_segments_from_cache(), + additional_filters=additional_filters, + from_replica=from_replica, + ) + result = get_evaluation_result(context) + + hide_disabled_flags = environment.get_hide_disabled_flags() is True + feature_states = [] + for flag in result["flags"].values(): + if hide_disabled_flags and not flag["enabled"]: + continue + feature_state = flag["metadata"]["feature_state"] + feature_state.flag_result = flag + feature_states.append(feature_state) + + return feature_states diff --git a/api/features/views.py b/api/features/views.py index 00d2876aebc1..2999f4ff66d8 100644 --- a/api/features/views.py +++ b/api/features/views.py @@ -63,7 +63,10 @@ EnvironmentKeyPermissions, NestedEnvironmentPermissions, ) -from evaluation.services import get_identity_feature_states +from evaluation.services import ( + get_environment_feature_states, + get_identity_feature_states, +) from features.dependencies.services import validate_segment_flag_dependencies from features.feature_lifecycle.services import ( annotate_feature_queryset_with_lifecycle_stage, @@ -1048,27 +1051,34 @@ def get(self, request, identifier=None, *args, **kwargs): # type: ignore[no-unt if identifier: return self._get_flags_response_with_identifier(request, identifier) - if "feature" in request.GET: - feature_states = get_environment_flags_list( - environment=request.environment, - feature_name=request.GET["feature"], - additional_filters=self._additional_filters, - from_replica=True, + if feature_name := request.GET.get("feature"): + # Filtered after evaluating, not before, because of dependent flags + feature_state = next( + ( + feature_state + for feature_state in get_environment_feature_states( + request.environment, + additional_filters=self._additional_filters, + from_replica=True, + ) + if feature_state.feature.name == feature_name + ), + None, ) - if not feature_states: + if feature_state is None: return Response( {"detail": "Given feature not found"}, status=status.HTTP_404_NOT_FOUND, ) - return Response(self.get_serializer(feature_states[0]).data) + return Response(self.get_serializer(feature_state).data) if settings.CACHE_FLAGS_SECONDS > 0: - data = self._get_flags_from_cache(request.environment, from_replica=True) + data = self._get_flags_from_cache(request.environment) else: data = self.get_serializer( - get_environment_flags_list( - environment=request.environment, + get_environment_feature_states( + request.environment, additional_filters=self._additional_filters, from_replica=True, ), @@ -1082,21 +1092,17 @@ def get(self, request, identifier=None, *args, **kwargs): # type: ignore[no-unt ) @property - def _additional_filters(self) -> Q: - filters = Q(feature_segment=None, identity=None) - - if self.request.environment.get_hide_disabled_flags() is True: - return filters & Q(enabled=True) - + def _additional_filters(self) -> Q | None: + # Disabled flags are hidden after evaluating, so that a disabled + # override is not passed over in favour of an enabled default. if self.request.originated_from is RequestOrigin.CLIENT: - return filters & Q(feature__is_server_key_only=False) + return Q(feature__is_server_key_only=False) - return filters + return None def _get_flags_from_cache( self, environment: Environment, - from_replica: bool = False, ) -> list[typing.Any]: data: list[typing.Any] # Include request origin in cache key to isolate client vs server requests @@ -1104,10 +1110,10 @@ def _get_flags_from_cache( data = flags_cache.get(cache_key) if not data: data = self.get_serializer( - get_environment_flags_list( - environment=environment, + get_environment_feature_states( + environment, additional_filters=self._additional_filters, - from_replica=from_replica, + from_replica=True, ), many=True, ).data diff --git a/api/tests/unit/features/test_unit_features_views.py b/api/tests/unit/features/test_unit_features_views.py index f47d3951d262..484d5dec3ba8 100644 --- a/api/tests/unit/features/test_unit_features_views.py +++ b/api/tests/unit/features/test_unit_features_views.py @@ -20,6 +20,7 @@ from django.forms import model_to_dict from django.urls import reverse from django.utils import timezone +from flag_engine.segments.constants import EQUAL from freezegun import freeze_time from pytest_django import DjangoAssertNumQueries from pytest_django.fixtures import SettingsWrapper @@ -60,7 +61,7 @@ from projects.code_references.models import ScannedCodeReferences, VCSRepository from projects.models import Project, UserProjectPermission from projects.tags.models import Tag -from segments.models import Segment +from segments.models import Condition, Segment, SegmentRule from tests.types import ( WithEnvironmentPermissionsCallable, WithProjectPermissionsCallable, @@ -704,6 +705,47 @@ def test_get_flags__environment_with_overrides__returns_environment_default( ) +def test_get_flags__segment_matching_without_identity__returns_segment_override( + api_client: APIClient, + environment: Environment, + project: Project, +) -> None: + """A segment that does not depend on an identity applies without one. + + Which is how an SDK evaluating the environment document locally has + always read it. + """ + # Given + feature = Feature.objects.create( + name="Test feature", project=project, initial_value="environment" + ) + segment = Segment.objects.create(name="This environment", project=project) + Condition.objects.create( + rule=SegmentRule.objects.create(segment=segment, type=SegmentRule.ALL_RULE), + property="$.environment.name", + operator=EQUAL, + value=environment.name, + ) + feature_segment = FeatureSegment.objects.create( + segment=segment, feature=feature, environment=environment + ) + segment_override = FeatureState.objects.create( + feature=feature, feature_segment=feature_segment, environment=environment + ) + segment_override.feature_state_value.string_value = "segment" + segment_override.feature_state_value.save() + + api_client.credentials(HTTP_X_ENVIRONMENT_KEY=environment.api_key) + + # When + response = api_client.get(reverse("api-v1:flags")) + + # Then + assert response.status_code == status.HTTP_200_OK + (flag,) = [flag for flag in response.json() if flag["feature"]["id"] == feature.id] + assert flag["feature_state_value"] == "segment" + + @pytest.mark.parametrize("cache_flags_seconds", [0, 30]) def test_sdk_feature_states_get__no_identifier__returns_feature_list( api_client: APIClient,