Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions api/evaluation/mappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -127,29 +130,46 @@ 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!

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:
context["segments"] = {
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 ()
],
)
Expand Down Expand Up @@ -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
Expand All @@ -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),
}

Expand Down
18 changes: 17 additions & 1 deletion api/features/dependencies/services.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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(
Expand Down
220 changes: 220 additions & 0 deletions api/tests/unit/features/dependencies/test_overrides.py
Original file line number Diff line number Diff line change
@@ -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"
Loading
Loading