Skip to content
Merged
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
1 change: 1 addition & 0 deletions api/app/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@
"environments.identities",
"environments.identities.traits",
"features",
"features.dependencies",
"features.feature_external_resources",
"features.feature_health",
"features.import_export",
Expand Down
Empty file.
7 changes: 7 additions & 0 deletions api/features/dependencies/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from core.apps import BaseAppConfig


class FeatureDependenciesConfig(BaseAppConfig):
name = "features.dependencies"
label = "feature_dependencies"
default = True
48 changes: 48 additions & 0 deletions api/features/dependencies/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from typing import TypedDict

from rest_framework import status
from rest_framework.exceptions import APIException

from features.dependencies.types import DependencyPath, ReferencingEnvironment


class _CircularDependencyDetail(TypedDict):
"""The body served where a feature is refused for depending on itself."""

code: str
environment: ReferencingEnvironment
path: DependencyPath


class CircularDependencyError(APIException):
"""Raised where a feature would end up depending on itself."""

status_code = status.HTTP_400_BAD_REQUEST
default_code = "circular_dependency"

def __init__(
self, environment: ReferencingEnvironment, path: DependencyPath
) -> None:
super().__init__()
detail: _CircularDependencyDetail = {
"code": self.default_code,
"environment": environment,
"path": path,
}
self.detail = detail # type: ignore[assignment]


class PrerequisiteFeatureNotFoundError(APIException):
"""Raised where a segment condition names a feature that does not exist."""

status_code = status.HTTP_400_BAD_REQUEST
default_code = "prerequisite_feature_not_found"

def __init__(self, prerequisite_feature: str, condition_json_path: str) -> None:
super().__init__(
{
"code": self.default_code,
"prerequisite_feature": prerequisite_feature,
"condition_json_path": condition_json_path,
}
)
57 changes: 57 additions & 0 deletions api/features/dependencies/mappers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import jsonpath_rfc9535
from jsonpath_rfc9535.exceptions import JSONPathError
from jsonpath_rfc9535.segments import JSONPathChildSegment, JSONPathSegment
from jsonpath_rfc9535.selectors import NameSelector

from features.dependencies.types import FeatureName, JSONPathStr
from segments.types import SegmentRule


def map_rules_to_prerequisite_feature_names(
rules: list[SegmentRule],
) -> dict[JSONPathStr, FeatureName]:
"""Returns the feature names keyed by the condition $.flags JSONPath"""
return {
f"{rule_json_path}.conditions[{condition_index}]": feature_name
for rule_json_path, rule in _get_rules_by_json_path(rules).items()
for condition_index, condition in enumerate(rule["conditions"])
if (condition_property := condition["property"])
and (feature_name := _get_prerequisite_feature_name(condition_property))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
is not None
}


def _get_rules_by_json_path(
rules: list[SegmentRule],
) -> dict[JSONPathStr, SegmentRule]:
rules_by_json_path: dict[JSONPathStr, SegmentRule] = {}
for rule_index, rule in enumerate(rules):
rule_json_path = f"$[{rule_index}]"
rules_by_json_path[rule_json_path] = rule
for nested_index, nested_rule in enumerate(rule.get("rules", [])):
rules_by_json_path[f"{rule_json_path}.rules[{nested_index}]"] = nested_rule
return rules_by_json_path


def _get_prerequisite_feature_name(condition_property: str) -> FeatureName | None:
"""Return the feature name a `$.flags.<feature>` condition points at, if it does."""
# Because of historical decisions, `$['flags']` can be a trait.
if not condition_property.startswith("$.flags"):
return None
try:
query_segments = jsonpath_rfc9535.compile(condition_property).segments
except JSONPathError:
return None
if len(query_segments) < 2 or _get_selected_name(query_segments[0]) != "flags":
return None
return _get_selected_name(query_segments[1])


def _get_selected_name(query_segment: JSONPathSegment) -> str | None:
if (
not isinstance(query_segment, JSONPathChildSegment)
or len(query_segment.selectors) != 1
):
return None
selector = query_segment.selectors[0]
return selector.name if isinstance(selector, NameSelector) else None
48 changes: 48 additions & 0 deletions api/features/dependencies/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Generated by Django 5.2.17 on 2026-09-14 18:40

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

initial = True

dependencies = [
("features", "0067_add_feature_state_mv_hashing_salt"),
("segments", "0032_add_segment_rules_data"),
]

operations = [
migrations.CreateModel(
name="SegmentFlagReference",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("condition_json_path", models.TextField()),
(
"prerequisite_feature",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="segment_references",
to="features.feature",
),
),
(
"segment",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="flag_references",
to="segments.segment",
),
),
],
),
]
Empty file.
26 changes: 26 additions & 0 deletions api/features/dependencies/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from django.db import models


class SegmentFlagReference(models.Model):
"""A reference to a pre-requisite flag from the segment

When a segment rule points to a feature as a pre-requisite, an object of
this type must exist to materialise the relationship and enable easier
backreferencing, e.g. for validating circular dependencies, and querying
without inferring from JSON rules.
"""

segment = models.ForeignKey(
"segments.Segment",
on_delete=models.CASCADE,
related_name="flag_references",
)

prerequisite_feature = models.ForeignKey(
"features.Feature",
on_delete=models.CASCADE,
related_name="segment_references",
)

# JSONPath (RFC 9535) locating the rule condition
condition_json_path = models.TextField()
185 changes: 185 additions & 0 deletions api/features/dependencies/services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import typing
from collections import defaultdict
from collections.abc import Collection

import structlog

from environments.models import Environment
from features.dependencies.exceptions import (
CircularDependencyError,
PrerequisiteFeatureNotFoundError,
)
from features.dependencies.mappers import map_rules_to_prerequisite_feature_names
from features.dependencies.models import SegmentFlagReference
from features.dependencies.types import DependencyEdge, DependencyPath, FeatureName
from features.models import Feature, FeatureSegment
from segments.services import get_all_live_or_scheduled_overrides

if typing.TYPE_CHECKING:
from segments.models import Segment

logger = structlog.get_logger("features")


def index_segment_flag_references(segment: "Segment") -> None:
"""Materialise the segment's `$.flags` conditions as SegmentFlagReference rows."""
feature_names_by_json_path = map_rules_to_prerequisite_feature_names(
segment.rules_data or []
)
feature_ids_by_name = dict(
Feature.objects.filter(
project_id=segment.project_id,
name__in=set(feature_names_by_json_path.values()),
).values_list("name", "id")
)
for condition_json_path, feature_name in feature_names_by_json_path.items():
if feature_name not in feature_ids_by_name:
raise PrerequisiteFeatureNotFoundError(
prerequisite_feature=feature_name,
condition_json_path=condition_json_path,
)
references = SegmentFlagReference.objects.filter(segment=segment)
previous_feature_names = set(
references.values_list("prerequisite_feature__name", flat=True)
)
references.delete()
SegmentFlagReference.objects.bulk_create(
SegmentFlagReference(
segment=segment,
prerequisite_feature_id=feature_ids_by_name[feature_name],
condition_json_path=condition_json_path,
)
for condition_json_path, feature_name in feature_names_by_json_path.items()
)
for override in FeatureSegment.objects.filter(segment=segment).select_related(
"environment__project", "feature"
):
report_flag_dependencies(
environment=override.environment,
feature=override.feature,
created=feature_ids_by_name.keys() - previous_feature_names,
deleted=previous_feature_names - feature_ids_by_name.keys(),
)


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(
"prerequisite_feature"
)
overrides = FeatureSegment.objects.filter(segment=segment).select_related(
"environment", "feature"
)
for override in overrides:
for reference in references:
logger.info(
"dependencies.deleted",
organisation__id=segment.project.organisation_id,
project__id=segment.project_id,
environment__key=override.environment.api_key,
feature__name=override.feature.name,
prerequisite_feature__name=reference.prerequisite_feature.name,
)
references.delete()


def report_flag_dependencies(
*,
environment: Environment,
feature: Feature,
created: Collection[FeatureName],
deleted: Collection[FeatureName],
) -> None:
"""Report the prerequisites a feature gains and loses in an environment."""
log = logger.bind(
organisation__id=environment.project.organisation_id,
project__id=environment.project_id,
environment__key=environment.api_key,
feature__name=feature.name,
)
for feature_name in deleted:
log.info("dependencies.deleted", prerequisite_feature__name=feature_name)
for feature_name in created:
log.info("dependencies.created", prerequisite_feature__name=feature_name)


def validate_segment_flag_dependencies(segment: "Segment") -> None:
"""Raise if any feature the segment overrides ends up depending on itself."""
existing_references = SegmentFlagReference.objects.filter(segment=segment)
if not existing_references.exists():
return
edges_by_environment_id: dict[int, dict[FeatureName, list[DependencyEdge]]] = {}
for override in (
get_all_live_or_scheduled_overrides()
.filter(segment=segment)
.select_related("environment", "feature")
Comment thread
khvn26 marked this conversation as resolved.
):
if override.environment_id not in edges_by_environment_id:
edges_by_environment_id[override.environment_id] = _get_dependency_edges(
override.environment
)
edges = edges_by_environment_id[override.environment_id]
pending: list[DependencyPath] = [
[edge] for edge in edges[override.feature.name]
]
visited: set[str] = set()
while pending:
path = pending.pop()
if (prerequisite_feature_name := path[-1]["needs"]) in visited:
continue
if prerequisite_feature_name == override.feature.name:
logger.info(
"dependencies.create_failed",
organisation__id=segment.project.organisation_id,
project__id=segment.project_id,
environment__key=override.environment.api_key,
feature__name=override.feature.name,
prerequisite_feature__name=path[0]["needs"],
)
raise CircularDependencyError(
environment={
"key": override.environment.api_key,
"name": override.environment.name,
},
path=path,
)
visited.add(prerequisite_feature_name)
pending += [[*path, edge] for edge in edges[prerequisite_feature_name]]


def _get_dependency_edges(
environment: Environment,
) -> dict[FeatureName, list[DependencyEdge]]:
edges: dict[FeatureName, list[DependencyEdge]] = defaultdict(list)
for (
feature_name,
prerequisite_feature_name,
segment_id,
segment_name,
condition_json_path,
) in (
get_all_live_or_scheduled_overrides()
.filter(
environment=environment,
segment__flag_references__isnull=False,
)
.values_list(
"feature__name",
"segment__flag_references__prerequisite_feature__name",
"segment_id",
"segment__name",
"segment__flag_references__condition_json_path",
)
):
edges[feature_name].append(
{
"feature": feature_name,
"needs": prerequisite_feature_name,
"segment": {
"id": segment_id,
"name": segment_name,
"condition_json_path": condition_json_path,
},
}
)
return edges
Loading
Loading