From 665b243d4569ad5aae9753e290e8f97002b40785 Mon Sep 17 00:00:00 2001 From: Ishita Singh Date: Sun, 4 Oct 2026 23:36:21 +0530 Subject: [PATCH 1/5] feat: add helper to find dependency segments --- api/features/dependencies/services.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/api/features/dependencies/services.py b/api/features/dependencies/services.py index 80eaaa86a12f..ce059ac91a80 100644 --- a/api/features/dependencies/services.py +++ b/api/features/dependencies/services.py @@ -1,6 +1,6 @@ import json from collections import defaultdict -from collections.abc import Collection +from collections.abc import Collection, Iterable import structlog from django.db import transaction @@ -89,6 +89,22 @@ def index_segment_flag_references(segment: "Segment") -> None: ) +def get_dependency_segment_ids(segments: Iterable[Segment]) -> set[int]: + """The ids of the system segments that carry a feature's dependencies.""" + candidate_ids = [ + segment.pk + for segment in segments + if segment.is_system_segment and segment.feature_id is not None + ] + if not candidate_ids: + return set() + return set( + SegmentFlagReference.objects.filter(segment__in=candidate_ids).values_list( + "segment_id", flat=True + ) + ) + + def delete_segment_flag_references(segment: "Segment") -> None: """Drop the segment's index rows, reporting every dependency lost.""" references = SegmentFlagReference.objects.filter(segment=segment).select_related( @@ -476,4 +492,4 @@ def _create_dependency_audit_log( master_api_key=None if user else author.key, log=FEATURE_DEPENDENCY_CREATED_MESSAGE % (prerequisite_feature.name, feature.name), - ) + ) From 0ce314546914cd4317900586245314614c6cfbc2 Mon Sep 17 00:00:00 2001 From: Ishita Singh Date: Sun, 4 Oct 2026 23:36:44 +0530 Subject: [PATCH 2/5] fix: serve current default for dependency overrides in evaluation mapper --- api/evaluation/mappers.py | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/api/evaluation/mappers.py b/api/evaluation/mappers.py index 1dd7424d4ecb..b66b8150959d 100644 --- a/api/evaluation/mappers.py +++ b/api/evaluation/mappers.py @@ -100,6 +100,9 @@ def map_environment_to_evaluation_context( identity keeps both its traits and its overrides in DynamoDB. :param segments: segments to evaluate. """ + # Deferred: `environments.models` imports this module's package. + from features.dependencies.services import get_dependency_segment_ids + context: EvaluationContext = { "environment": { "key": environment.api_key, @@ -127,9 +130,14 @@ def map_environment_to_evaluation_context( # The engine only splits between variants for an identity. with_variants=identity is not None or identity_context is not None, ) + dependency_segment_ids: set[int] = set() if segments is not None: segments = list(segments) prefetch_related_objects(segments, *_SEGMENT_RULES_LOOKUPS) + dependency_segment_ids = get_dependency_segment_ids(segments) + default_feature_states = { + feature_state.feature_id: feature_state for feature_state in feature_states + } # No reading from ORM past this point! @@ -137,11 +145,20 @@ def to_feature_context( feature_state: "FeatureState", *, priority: float | None = None, + inherits_default: bool = False, ) -> FeatureContext: + value_from = ( + default_feature_states.get(feature_state.feature_id) + if inherits_default + else None + ) return map_feature_state_to_feature_context( feature_state, - mv_fs_values=mv_fs_values_by_feature_state_id.get(feature_state.pk), + mv_fs_values=mv_fs_values_by_feature_state_id.get( + (value_from or feature_state).pk + ), priority=priority, + value_from=value_from, ) if segments is not None: @@ -149,7 +166,10 @@ def to_feature_context( str(segment.pk): map_segment_to_segment_context( segment, overrides=[ - to_feature_context(feature_state) + to_feature_context( + feature_state, + inherits_default=segment.pk in dependency_segment_ids, + ) for feature_state in segment_overrides.get(segment.pk) or () ], ) @@ -301,8 +321,13 @@ def map_feature_state_to_feature_context( *, mv_fs_values: "Iterable[MultivariateFeatureStateValue] | None" = None, priority: float | None = None, + value_from: "FeatureState | None" = None, ) -> FeatureContext: - """Map a Django ORM FeatureState to a flag-engine FeatureContext TypedDict.""" + """Map a Django ORM FeatureState to a flag-engine FeatureContext TypedDict. + + :param value_from: the feature state to read the value from, when it is not + `feature_state` itself. `mv_fs_values` then belong to it as well. + """ feature = feature_state.feature feature_context: FeatureContext = { # The engine seeds multivariate variant allocation on the feature @@ -314,7 +339,7 @@ def map_feature_state_to_feature_context( "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(), + "value": (value_from or feature_state).get_feature_state_value(), "metadata": FeatureEngineMetadata(feature_state=feature_state), } From ce702209973836a3a0dbc3dd3538132f36b58a0d Mon Sep 17 00:00:00 2001 From: Ishita Singh Date: Sun, 4 Oct 2026 23:36:59 +0530 Subject: [PATCH 3/5] fix: serve current default for dependency overrides in engine mapper --- api/util/mappers/engine.py | 40 ++++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/api/util/mappers/engine.py b/api/util/mappers/engine.py index 77ffc7a6bb02..20f9635012e0 100644 --- a/api/util/mappers/engine.py +++ b/api/util/mappers/engine.py @@ -131,6 +131,7 @@ def map_feature_state_to_engine( *, mv_fs_values: Optional[Iterable["MultivariateFeatureStateValue"]] = None, metadata: Optional[dict[str, object]] = None, + value_from: Optional["FeatureState"] = None, ) -> FeatureStateModel: feature = feature_state.feature feature_segment: Optional["FeatureSegment"] = feature_state.feature_segment @@ -150,7 +151,7 @@ def map_feature_state_to_engine( # a feature state is recreated, without changing the engine model or # environment document schema. See issue #7913. django_id=feature_state.mv_hashing_seed, - feature_state_value=feature_state.get_feature_state_value(), + feature_state_value=(value_from or feature_state).get_feature_state_value(), featurestate_uuid=feature_state.uuid, feature_segment=feature_segment_model, feature=map_feature_to_engine(feature), @@ -204,6 +205,7 @@ def map_environment_to_engine( from experimentation.feature_state_metadata import ( # avoid circular import get_feature_state_metadata_builder, ) + from features.dependencies.services import get_dependency_segment_ids project: "Project" = environment.project organisation: "Organisation" = project.organisation @@ -239,6 +241,7 @@ def map_environment_to_engine( if ps.feature_id is None or project_segment_feature_states_by_segment_id.get(ps.pk) ] + dependency_segment_ids = get_dependency_segment_ids(project_segments) project_segment_rules_by_segment_id: Dict[ int, Iterable["SegmentRule"], @@ -259,6 +262,33 @@ def map_environment_to_engine( feature_state.pk: feature_state.multivariate_feature_state_values.all() for feature_state in all_environment_feature_states } + default_feature_states = { + feature_state.feature_id: feature_state + for feature_state in environment_feature_states + } + + def map_segment_feature_state( + segment: "Segment", feature_state: "FeatureState" + ) -> FeatureStateModel: + mv_fs_values = multivariate_feature_state_values_by_feature_state_id.pop( + feature_state.pk + ) + # A dependency override serves the feature's current default value. + value_from = ( + default_feature_states.get(feature_state.feature_id) + if segment.pk in dependency_segment_ids + else None + ) + if value_from is not None: + mv_fs_values = multivariate_feature_state_values_by_feature_state_id[ + value_from.pk + ] + return map_feature_state_to_engine( + feature_state, + mv_fs_values=mv_fs_values, + metadata=get_feature_state_metadata(feature_state), + value_from=value_from, + ) # Read integrations. integration_configs: dict[ @@ -289,13 +319,7 @@ def map_environment_to_engine( for segment_rule in project_segment_rules_by_segment_id.pop(segment.pk) ], feature_states=[ - map_feature_state_to_engine( - feature_state, - mv_fs_values=multivariate_feature_state_values_by_feature_state_id.pop( - feature_state.pk, - ), - metadata=get_feature_state_metadata(feature_state), - ) + map_segment_feature_state(segment, feature_state) for feature_state in project_segment_feature_states_by_segment_id.pop( segment.pk ) From f3005c41687c8530b98b708a29f132c6743f3138 Mon Sep 17 00:00:00 2001 From: Ishita Singh Date: Sun, 4 Oct 2026 23:37:14 +0530 Subject: [PATCH 4/5] test: cover dependency override values in both mappers --- .../features/dependencies/test_overrides.py | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 api/tests/unit/features/dependencies/test_overrides.py diff --git a/api/tests/unit/features/dependencies/test_overrides.py b/api/tests/unit/features/dependencies/test_overrides.py new file mode 100644 index 000000000000..0462c4385064 --- /dev/null +++ b/api/tests/unit/features/dependencies/test_overrides.py @@ -0,0 +1,220 @@ +import pytest + +from environments.identities.models import Identity +from environments.models import Environment +from evaluation.mappers import map_environment_to_evaluation_context +from features.dependencies.services import create_flag_dependency +from features.models import Feature, FeatureSegment, FeatureState +from features.multivariate.models import MultivariateFeatureStateValue +from projects.models import Project +from segments.models import Segment +from users.models import FFAdminUser +from util.mappers.engine import map_environment_to_engine + + +@pytest.fixture() +def prerequisite(project: Project) -> Feature: + return Feature.objects.create(name="chicken", project=project) # type: ignore[no-any-return] + + +@pytest.fixture() +def dependent(project: Project) -> Feature: + return Feature.objects.create( # type: ignore[no-any-return] + name="egg", project=project, initial_value="wild egg" + ) + + +@pytest.fixture() +def dependency_segment( + environment: Environment, + dependent: Feature, + prerequisite: Feature, + admin_user: FFAdminUser, +) -> Segment: + create_flag_dependency( + environment=environment, + feature=dependent, + prerequisite_feature=prerequisite, + author=admin_user, + ) + return Segment.objects.get(feature=dependent, is_system_segment=True) # type: ignore[no-any-return] + + +def get_default_feature_state( + environment: Environment, feature: Feature +) -> FeatureState: + return FeatureState.objects.get( # type: ignore[no-any-return] + environment=environment, + feature=feature, + feature_segment__isnull=True, + identity__isnull=True, + ) + + +def test_map_environment_to_evaluation_context__dependency_override__serves_current_default_value( + environment: Environment, + dependent: Feature, + dependency_segment: Segment, +) -> None: + # Given + default = get_default_feature_state(environment, dependent) + default.feature_state_value.string_value = "farm egg" + default.feature_state_value.save() + + # When + context = map_environment_to_evaluation_context( + environment=environment, segments=[dependency_segment] + ) + + # Then + override = context["segments"][str(dependency_segment.pk)]["overrides"][0] + assert override["value"] == "farm egg" + assert override["enabled"] is False + assert override["priority"] == 0 + + +def test_map_environment_to_evaluation_context__dependency_override__serves_current_default_variants( + environment: Environment, + multivariate_feature: Feature, + prerequisite: Feature, + admin_user: FFAdminUser, + identity: Identity, +) -> None: + # Given + create_flag_dependency( + environment=environment, + feature=multivariate_feature, + prerequisite_feature=prerequisite, + author=admin_user, + ) + segment = Segment.objects.get(feature=multivariate_feature, is_system_segment=True) + default = get_default_feature_state(environment, multivariate_feature) + for mv_value, percentage in zip( + default.multivariate_feature_state_values.order_by("id"), (100, 0, 0) + ): + MultivariateFeatureStateValue.objects.filter(pk=mv_value.pk).update( + percentage_allocation=percentage + ) + + # When + context = map_environment_to_evaluation_context( + environment=environment, identity=identity, segments=[segment] + ) + + # Then + override = context["segments"][str(segment.pk)]["overrides"][0] + assert [variant["weight"] for variant in override["variants"]] == [100, 0, 0] + + +def test_map_environment_to_evaluation_context__system_segment_without_dependency__keeps_own_value( + environment: Environment, + project: Project, + dependent: Feature, +) -> None: + # Given + segment = Segment.objects.create( + name="rollout", project=project, feature=dependent, is_system_segment=True + ) + override = FeatureState.objects.create( + feature=dependent, + environment=environment, + feature_segment=FeatureSegment.objects.create( + feature=dependent, segment=segment, environment=environment + ), + ) + override.feature_state_value.string_value = "rollout egg" + override.feature_state_value.save() + + # When + context = map_environment_to_evaluation_context( + environment=environment, segments=[segment] + ) + + # Then + override = context["segments"][str(segment.pk)]["overrides"][0] + assert override["value"] == "rollout egg" + + +def test_map_environment_to_engine__dependency_override__serves_current_default_value( + environment: Environment, + dependent: Feature, + dependency_segment: Segment, +) -> None: + # Given + default = get_default_feature_state(environment, dependent) + default.feature_state_value.string_value = "farm egg" + default.feature_state_value.save() + + # When + result = map_environment_to_engine(environment) + + # Then + segment_model = next( + s for s in result.project.segments if s.id == dependency_segment.pk + ) + (override,) = segment_model.feature_states + assert override.feature_state_value == "farm egg" + assert override.enabled is False + assert override.feature_segment is not None + assert override.feature_segment.priority == 0 + + +def test_map_environment_to_engine__dependency_override__serves_current_default_variants( + environment: Environment, + multivariate_feature: Feature, + prerequisite: Feature, + admin_user: FFAdminUser, +) -> None: + # Given + create_flag_dependency( + environment=environment, + feature=multivariate_feature, + prerequisite_feature=prerequisite, + author=admin_user, + ) + segment = Segment.objects.get(feature=multivariate_feature, is_system_segment=True) + default = get_default_feature_state(environment, multivariate_feature) + for mv_value, percentage in zip( + default.multivariate_feature_state_values.order_by("id"), (100, 0, 0) + ): + MultivariateFeatureStateValue.objects.filter(pk=mv_value.pk).update( + percentage_allocation=percentage + ) + + # When + result = map_environment_to_engine(environment) + + # Then + segment_model = next(s for s in result.project.segments if s.id == segment.pk) + (override,) = segment_model.feature_states + assert [ + mv_value.percentage_allocation + for mv_value in override.multivariate_feature_state_values + ] == [100, 0, 0] + + +def test_map_environment_to_engine__system_segment_without_dependency__keeps_own_value( + environment: Environment, + project: Project, + dependent: Feature, +) -> None: + # Given + segment = Segment.objects.create( + name="rollout", project=project, feature=dependent, is_system_segment=True + ) + override = FeatureState.objects.create( + feature=dependent, + environment=environment, + feature_segment=FeatureSegment.objects.create( + feature=dependent, segment=segment, environment=environment + ), + ) + override.feature_state_value.string_value = "rollout egg" + override.feature_state_value.save() + + # When + result = map_environment_to_engine(environment) + + # Then + segment_model = next(s for s in result.project.segments if s.id == segment.pk) + assert segment_model.feature_states[0].feature_state_value == "rollout egg" From 226d6c0777c3977a32d2fbb8a09817c6fe1d64f0 Mon Sep 17 00:00:00 2001 From: Ishita Singh Date: Sun, 4 Oct 2026 23:37:36 +0530 Subject: [PATCH 5/5] style: fix indentation --- api/features/dependencies/services.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/features/dependencies/services.py b/api/features/dependencies/services.py index ce059ac91a80..b38faf744498 100644 --- a/api/features/dependencies/services.py +++ b/api/features/dependencies/services.py @@ -492,4 +492,4 @@ def _create_dependency_audit_log( master_api_key=None if user else author.key, log=FEATURE_DEPENDENCY_CREATED_MESSAGE % (prerequisite_feature.name, feature.name), - ) + )