diff --git a/api/environments/models.py b/api/environments/models.py index 18b90242f9f6..2fab88fca8ce 100644 --- a/api/environments/models.py +++ b/api/environments/models.py @@ -534,7 +534,25 @@ def get_segments_from_cache(self) -> typing.List[Segment]: def get_environment_document( cls, api_key: str, + *, + include_scheduled: bool = False, ) -> dict[str, typing.Any]: + """ + :param include_scheduled: opt-in. When True, feature states in the + returned document carry an additional `scheduled_change` field + describing their next not-yet-live version, if any. Defaults to + False, in which case the response shape is unchanged. + """ + # No cache entry is ever refreshed by a schedule maturing (its + # `live_from` passing) — that's a pure time-based transition with no + # DB write to hang a cache-invalidation hook off. A cached document + # could therefore describe a `scheduled_change` as still upcoming + # long after it has actually gone live. Opted-in requests bypass + # caching entirely so they're always resolved fresh from the DB. + if include_scheduled: + return cls._get_environment_document_from_db( + api_key, include_scheduled=True + ) if ( settings.CACHE_ENVIRONMENT_DOCUMENT_SECONDS > 0 or settings.CACHE_ENVIRONMENT_DOCUMENT_MODE @@ -575,6 +593,8 @@ def _get_environment_document_from_cache( def _get_environment_document_from_db( cls, api_key: str, + *, + include_scheduled: bool = False, ) -> dict[str, typing.Any]: manager = using_database_replica(cls.objects) environment = manager.filter_for_document_builder( @@ -611,7 +631,9 @@ def _get_environment_document_from_db( ), ], ).get() - return map_environment_to_sdk_document(environment) + return map_environment_to_sdk_document( + environment, include_scheduled=include_scheduled + ) def _get_environment(self): # type: ignore[no-untyped-def] return self diff --git a/api/environments/sdk/views.py b/api/environments/sdk/views.py index 52d10a2c399c..e040fa7547a8 100644 --- a/api/environments/sdk/views.py +++ b/api/environments/sdk/views.py @@ -3,7 +3,7 @@ from django.utils.decorators import method_decorator from django.views.decorators.http import condition -from drf_spectacular.utils import extend_schema +from drf_spectacular.utils import OpenApiParameter, extend_schema from flagsmith_schemas.api import V1EnvironmentDocumentResponse from rest_framework.request import Request from rest_framework.response import Response @@ -17,7 +17,26 @@ from environments.permissions.permissions import EnvironmentKeyPermissions +def _is_include_scheduled_requested(request: Request) -> bool: + # Opt-in query param for `scheduled_change` data. + return request.GET.get("include_scheduled", "").lower() in ("1", "true", "yes") + + def get_last_modified(request: Request) -> datetime | None: + # A schedule maturing (`live_from` passing) doesn't touch + # `environment.updated_at` — nothing is saved when time simply + # elapses. Requests that opt into `scheduled_change` data therefore can't + # rely on this field to decide freshness, and worse, a client that has a + # cached body from *before* it started opting in could otherwise get a + # 304 short-circuit here and keep reusing that stale, scheduled-change-free + # body forever. Returning None tells Django's `condition()` there is no + # known last-modified value, so it always falls through to `get()` instead. + # Trade-off: this disables the 304 short-circuit for every + # `include_scheduled=True` request (each one always executes `get()` in + # full), in exchange for guaranteed freshness rather than a cheap-but-stale + # response. + if _is_include_scheduled_requested(request): + return None updated_at: Optional[datetime] = request.environment.updated_at return updated_at @@ -31,6 +50,23 @@ def get_authenticators(self): # type: ignore[no-untyped-def] return [EnvironmentKeyAuthentication(required_key_prefix="ser.")] @extend_schema( + parameters=[ + OpenApiParameter( + name="include_scheduled", + type=bool, + required=False, + description=( + "Opt-in. When true, each feature state in " + "the response carries an additional `scheduled_change` " + "field describing its next not-yet-live version, if any. " + "Defaults to false, in which case the response shape is " + "unchanged. Note: not every scheduled-change shape is " + "currently surfaced (e.g. v2 segment overrides via the " + "versioning flow, brand-new segment overrides, scheduled " + "deletions, and multivariate weight changes)." + ), + ), + ], responses={200: V1EnvironmentDocumentResponse}, operation_id="sdk_v1_environment_document", ) @@ -42,6 +78,7 @@ def get(self, request: Request) -> Response: """ environment_document = Environment.get_environment_document( request.environment.api_key, + include_scheduled=_is_include_scheduled_requested(request), ) updated_at = self.request.environment.updated_at return Response( diff --git a/api/tests/unit/environments/test_unit_environments_views_sdk_environment.py b/api/tests/unit/environments/test_unit_environments_views_sdk_environment.py index 4b471ab4b4a7..71730cee6c2f 100644 --- a/api/tests/unit/environments/test_unit_environments_views_sdk_environment.py +++ b/api/tests/unit/environments/test_unit_environments_views_sdk_environment.py @@ -1,3 +1,4 @@ +import datetime import time from typing import TYPE_CHECKING from unittest.mock import ANY @@ -317,3 +318,86 @@ def test_get_environment_document__if_modified_since_header__returns_304_or_200( # Then - actual environment is returned with a 200 assert response4.status_code == status.HTTP_200_OK assert len(response4.content) > 0 + + +def test_get_environment_document__include_scheduled_query_param__feature_state_carries_scheduled_change( + organisation_one: "Organisation", + organisation_one_project_one: "Project", +) -> None: + # Given + project = organisation_one_project_one + environment = Environment.objects.create(name="Test Environment", project=project) + api_key = EnvironmentAPIKey.objects.create(environment=environment).key + feature = Feature.objects.create(name="test_feature", project=project) + + live_from = timezone.now() + datetime.timedelta(hours=1) + FeatureState.objects.create( + environment=environment, + feature=feature, + version=2, + live_from=live_from, + enabled=True, + ) + + client = APIClient() + client.credentials(HTTP_X_ENVIRONMENT_KEY=api_key) + url = reverse("api-v1:environment-document") + + # When - opted in via the query param + response = client.get(url, {"include_scheduled": "true"}) + + # Then - the feature state carries the scheduled change + assert response.status_code == status.HTTP_200_OK + [feature_state] = [ + fs + for fs in response.data["feature_states"] + if fs["feature"]["id"] == feature.id + ] + assert feature_state["scheduled_change"] == { + "live_from": live_from, + "enabled": True, + "feature_state_value": None, + } + + # When - not opted in + default_response = client.get(url) + + # Then - the field is omitted entirely, not present-and-null + [default_feature_state] = [ + fs + for fs in default_response.data["feature_states"] + if fs["feature"]["id"] == feature.id + ] + assert "scheduled_change" not in default_feature_state + + +def test_get_environment_document__include_scheduled_query_param__bypasses_conditional_get( + organisation_one: "Organisation", + organisation_one_project_one: "Project", +) -> None: + # Given + project = organisation_one_project_one + environment = Environment.objects.create(name="Test Environment", project=project) + api_key = EnvironmentAPIKey.objects.create(environment=environment).key + + client = APIClient() + client.credentials(HTTP_X_ENVIRONMENT_KEY=api_key) + url = reverse("api-v1:environment-document") + + # a cached If-Modified-Since value that matches the environment's + # current state, so a plain request would 304 + if_modified_since = http_date(environment.updated_at.timestamp()) + baseline_response = client.get(url, HTTP_IF_MODIFIED_SINCE=if_modified_since) + assert baseline_response.status_code == status.HTTP_304_NOT_MODIFIED + + # When - an opted-in request with the same If-Modified-Since header + scheduled_response = client.get( + url, + {"include_scheduled": "true"}, + HTTP_IF_MODIFIED_SINCE=if_modified_since, + ) + + # Then - it's never short-circuited to 304, and carries no Last-Modified + # header for a client to cache against + assert scheduled_response.status_code == status.HTTP_200_OK + assert "Last-Modified" not in scheduled_response.headers diff --git a/api/tests/unit/util/mappers/test_scheduled_changes.py b/api/tests/unit/util/mappers/test_scheduled_changes.py new file mode 100644 index 000000000000..0503944a2e3d --- /dev/null +++ b/api/tests/unit/util/mappers/test_scheduled_changes.py @@ -0,0 +1,871 @@ +""" +Regression tests for the opt-in `include_scheduled` extension to the SDK +environment-document mapper (`util/mappers/engine.py`), covering both +mechanisms by which a change can be scheduled: a future `FeatureState`/ +`EnvironmentFeatureVersion` that already exists in the DB, and a committed +but not-yet-materialised `VersionChangeSet` diff. +""" + +import datetime +import json +from typing import Any + +import pytest +from django.utils import timezone + +from environments.models import Environment +from features.models import Feature, FeatureState +from features.versioning.models import EnvironmentFeatureVersion, VersionChangeSet +from features.versioning.tasks import enable_v2_versioning +from features.workflows.core.models import ChangeRequest +from segments.models import Segment +from users.models import FFAdminUser +from util.mappers.sdk import map_environment_to_sdk_document + + +@pytest.fixture() +def environment_v2(environment: Environment) -> Environment: + enable_v2_versioning(environment.id) + environment.refresh_from_db() + return environment + + +def _make_live_feature_state( + environment: Environment, + feature: Feature, + *, + enabled: bool, +) -> FeatureState: + """ + By the time this runs, Feature-creation has already auto-cloned a + default FeatureState into every environment of its project, and — since + `environment_v2` enables v2 versioning first — that clone is already + wrapped in its own initial, published EnvironmentFeatureVersion. Edit it + in place: creating a *second* EnvironmentFeatureVersion here would + collide, since `add_existing_feature_states` (a post_save receiver on + EnvironmentFeatureVersion) auto-clones the existing live FeatureState + into any newly created version, and a second explicit create() call + would then duplicate it. + """ + feature_state: FeatureState = FeatureState.objects.get( + environment=environment, + feature=feature, + feature_segment__isnull=True, + identity__isnull=True, + ) + feature_state.enabled = enabled + feature_state.save() + return feature_state + + +def _make_future_feature_state( + environment: Environment, + feature: Feature, + *, + enabled: bool, + live_from: datetime.datetime, + publish: bool = True, +) -> FeatureState: + """ + Creating a new EnvironmentFeatureVersion auto-clones the current live + FeatureState into it (same receiver as `_make_live_feature_state`'s + docstring explains) — edit that auto-clone rather than calling + `FeatureState.clone()` again, which would add a second, competing + FeatureState to the same version. + + With `publish=False` the version stays an unpublished draft, with + `live_from` set directly on it — the model field is plain-writable and + the versioning API accepts it at draft-create time, so this is a state + reachable through the public API, not a test contrivance. + """ + version = EnvironmentFeatureVersion.objects.create( + environment=environment, feature=feature + ) + feature_state: FeatureState = FeatureState.objects.get( + environment=environment, + feature=feature, + environment_feature_version=version, + feature_segment__isnull=True, + identity__isnull=True, + ) + feature_state.enabled = enabled + feature_state.save() + if publish: + version.publish(live_from=live_from) + else: + version.live_from = live_from + version.save() + return feature_state + + +def _get_feature_state(document: dict[str, Any], feature_id: int) -> dict[str, Any]: + for feature_state in document["feature_states"]: + if feature_state["feature"]["id"] == feature_id: + return feature_state # type: ignore[no-any-return] + raise AssertionError(f"no feature state found for feature {feature_id}") + + +def _get_scheduled_change( + document: dict[str, Any], feature_id: int +) -> dict[str, Any] | None: + # `scheduled_change` is omitted from the dump entirely when unpopulated + # (FeatureStateModel's model_serializer pops the None-valued key to keep + # the shape upstream-identical), so `.get` here tolerates absence; the + # default-path tests additionally assert outright that the key is absent. + return _get_feature_state(document, feature_id).get("scheduled_change") + + +def _get_segment_scheduled_change( + document: dict[str, Any], feature_id: int, segment_id: int +) -> dict[str, Any] | None: + for segment in document["project"]["segments"]: + if segment["id"] != segment_id: + continue + for feature_state in segment["feature_states"]: + if feature_state["feature"]["id"] == feature_id: + return feature_state.get("scheduled_change") # type: ignore[no-any-return] + raise AssertionError(f"no segment feature state found for feature {feature_id}") + + +def _bool_value(value: bool) -> dict[str, Any]: + return { + "boolean_value": value, + "integer_value": None, + "string_value": None, + "type": "bool", + } + + +def _committed_change_request( + environment: Environment, admin_user: FFAdminUser +) -> ChangeRequest: + change_request: ChangeRequest = ChangeRequest.objects.create( + environment=environment, title="scheduled change", user=admin_user + ) + change_request.committed_at = timezone.now() + change_request.committed_by = admin_user + change_request.save() + return change_request + + +def _pending_change_set( + environment: Environment, + feature: Feature, + admin_user: FFAdminUser, + *, + live_from: datetime.datetime, + enabled: bool, +) -> VersionChangeSet: + change_request = _committed_change_request(environment, admin_user) + change_set: VersionChangeSet = VersionChangeSet.objects.create( + environment=environment, + feature=feature, + change_request=change_request, + live_from=live_from, + feature_states_to_update=json.dumps( + [ + { + "environment": environment.id, + "identity": None, + "feature_segment": None, + "feature": feature.id, + "enabled": enabled, + "feature_state_value": _bool_value(enabled), + "multivariate_feature_state_values": [], + } + ] + ), + ) + return change_set + + +# -- FeatureState-based scheduled change -------------------------------------- +# Covers a future FeatureState that already exists in the DB (created via +# `EnvironmentFeatureVersion.publish(live_from=)`) — the original, +# minimal-scope implementation. + + +def test_scheduled_change__not_opted_in__is_never_populated( + environment_v2: Environment, + feature: Feature, + admin_user: FFAdminUser, +) -> None: + # Given a published, future-scheduled version exists + _make_live_feature_state(environment_v2, feature, enabled=False) + _make_future_feature_state( + environment_v2, + feature, + enabled=True, + live_from=timezone.now() + datetime.timedelta(hours=1), + ) + + # When mapped without opting in + document = map_environment_to_sdk_document(environment_v2) + + # Then the response shape is byte-for-byte unchanged from upstream: + # the `scheduled_change` key must not be present at all (not even + # as an explicit null — upstream has no such field) + assert "scheduled_change" not in _get_feature_state(document, feature.id) + + +def test_scheduled_change__future_feature_state_exists__is_surfaced( + environment_v2: Environment, + feature: Feature, + admin_user: FFAdminUser, +) -> None: + # Given a published, future-scheduled version exists + _make_live_feature_state(environment_v2, feature, enabled=False) + live_from = timezone.now() + datetime.timedelta(hours=1) + future_fs = _make_future_feature_state( + environment_v2, feature, enabled=True, live_from=live_from + ) + + # When mapped with include_scheduled opted in + document = map_environment_to_sdk_document(environment_v2, include_scheduled=True) + + # Then the future feature state is surfaced as the scheduled change + scheduled_change = _get_scheduled_change(document, feature.id) + assert scheduled_change is not None + assert scheduled_change["enabled"] == future_fs.enabled + assert scheduled_change["live_from"] == live_from + + +def test_scheduled_change__live_from_in_past__is_not_surfaced( + environment_v2: Environment, + feature: Feature, + admin_user: FFAdminUser, +) -> None: + """ + A change whose live_from has already elapsed should no longer read as + "scheduled" even if a background task never actually promoted it to + the current live version (e.g. task-processor was down). + """ + # Given a future-scheduled version whose live_from has already elapsed + _make_live_feature_state(environment_v2, feature, enabled=False) + _make_future_feature_state( + environment_v2, + feature, + enabled=True, + live_from=timezone.now() - datetime.timedelta(minutes=1), + ) + + # When mapped with include_scheduled opted in + document = map_environment_to_sdk_document(environment_v2, include_scheduled=True) + + # Then it is no longer surfaced as a scheduled change + assert _get_scheduled_change(document, feature.id) is None + + +def test_scheduled_change__unpublished_draft_version__is_not_surfaced( + environment_v2: Environment, + feature: Feature, + admin_user: FFAdminUser, +) -> None: + """ + An unpublished draft EnvironmentFeatureVersion can carry a future + `live_from` (it's a writable field at draft-create time) and its + auto-cloned feature states sit in `environment.feature_states.all()`, + but until the version is published nothing is locked in — the draft + must not be advertised to SDKs as a scheduled change. + """ + # Given an unpublished draft version with a future live_from + _make_live_feature_state(environment_v2, feature, enabled=False) + _make_future_feature_state( + environment_v2, + feature, + enabled=True, + live_from=timezone.now() + datetime.timedelta(hours=1), + publish=False, + ) + + # When mapped with include_scheduled opted in + document = map_environment_to_sdk_document(environment_v2, include_scheduled=True) + + # Then the draft is not advertised as a scheduled change + assert _get_scheduled_change(document, feature.id) is None + + +def test_scheduled_change__soft_deleted_published_version__is_not_surfaced( + environment_v2: Environment, + feature: Feature, +) -> None: + """ + `select_related` on `environment_feature_version` in the document + builder joins straight onto the FK column, bypassing + `EnvironmentFeatureVersion`'s soft-delete manager, so a soft-deleted + version can still come back as "published". Setting `deleted_at` via a + queryset `.update()` isolates exactly that condition: the model's own + `.delete()` cascades a `SET_NULL` onto the referencing FeatureState's + `environment_feature_version_id` (its FK's `on_delete`), which would + already fall back to the (also-excluding) V1 branch regardless of this + fix — that's not what's under test here. + """ + # Given a published future version that then gets soft-deleted without + # nulling the FeatureState's reference to it + _make_live_feature_state(environment_v2, feature, enabled=False) + future_feature_state = _make_future_feature_state( + environment_v2, + feature, + enabled=True, + live_from=timezone.now() + datetime.timedelta(hours=1), + ) + version = future_feature_state.environment_feature_version + assert version is not None + EnvironmentFeatureVersion.objects.filter(uuid=version.uuid).update( + deleted_at=timezone.now() + ) + + # When mapped with include_scheduled opted in + document = map_environment_to_sdk_document(environment_v2, include_scheduled=True) + + # Then the cancelled version is not advertised as a scheduled change + assert _get_scheduled_change(document, feature.id) is None + + +def test_scheduled_change__v1_committed_future_feature_state__is_surfaced( + environment: Environment, + feature: Feature, +) -> None: + """ + V1 (legacy versioning) happy path: committing a scheduled change + request assigns the new FeatureState a real version number while its + `live_from` stays in the future — construct that at-rest shape + directly. The FeatureState-based mechanism must work for V1, not + just the v2 cases covered above. + """ + # Given a committed V1 feature state with a future live_from + live_from = timezone.now() + datetime.timedelta(hours=1) + FeatureState.objects.create( + environment=environment, + feature=feature, + version=2, + live_from=live_from, + enabled=True, + ) + + # When mapped with include_scheduled opted in + document = map_environment_to_sdk_document(environment, include_scheduled=True) + + # Then it is surfaced as the scheduled change + scheduled_change = _get_scheduled_change(document, feature.id) + assert scheduled_change is not None + assert scheduled_change["enabled"] is True + assert scheduled_change["live_from"] == live_from + + +def test_scheduled_change__v1_uncommitted_change_request__is_not_surfaced( + environment: Environment, + feature: Feature, + admin_user: FFAdminUser, +) -> None: + """ + V1 sibling of the unpublished-draft leak (CR-SF-v1 H-1): feature + states belonging to a not-yet-committed change request have + `version=None` but already carry their target `live_from` — upstream + relies on this at commit time, when + `features/workflows/core/models.py` compares `committed_at < + feature_state.live_from` to decide whether the change is scheduled. + Until the CR is committed nothing is locked in, so the draft must + not be advertised to SDKs as a scheduled change. + """ + # Given a V1 feature state tied to a not-yet-committed change request + change_request = ChangeRequest.objects.create( + environment=environment, title="draft v1 change", user=admin_user + ) + FeatureState.objects.create( + environment=environment, + feature=feature, + version=None, + live_from=timezone.now() + datetime.timedelta(hours=1), + enabled=True, + change_request=change_request, + ) + + # When mapped with include_scheduled opted in + document = map_environment_to_sdk_document(environment, include_scheduled=True) + + # Then it is not advertised as a scheduled change, and the live default + # is served untouched + assert _get_scheduled_change(document, feature.id) is None + assert _get_feature_state(document, feature.id)["enabled"] is False + + +# -- VersionChangeSet-based scheduled change ---------------------------------- +# Covers a change-request-scheduled change that has NOT been materialised +# into a real FeatureState yet — it only exists as a `VersionChangeSet` +# JSON diff, per the actual mechanism used by the versioning REST API +# (`features/versioning/serializers.py::VersionChangeSetSerializer.create`) +# when a change request with a future live_from is committed. + + +def test_scheduled_change__pending_committed_change_set__is_surfaced( + environment_v2: Environment, + feature: Feature, + admin_user: FFAdminUser, +) -> None: + # Given a committed change request with a pending, not-yet-materialised + # VersionChangeSet + _make_live_feature_state(environment_v2, feature, enabled=False) + change_request = _committed_change_request(environment_v2, admin_user) + live_from = timezone.now() + datetime.timedelta(hours=1) + VersionChangeSet.objects.create( + environment=environment_v2, + feature=feature, + change_request=change_request, + live_from=live_from, + feature_states_to_update=json.dumps( + [ + { + "environment": environment_v2.id, + "identity": None, + "feature_segment": None, + "feature": feature.id, + "enabled": True, + "feature_state_value": _bool_value(True), + "multivariate_feature_state_values": [], + } + ] + ), + ) + + # When mapped with include_scheduled opted in + document = map_environment_to_sdk_document(environment_v2, include_scheduled=True) + + # Then the pending change is surfaced as the scheduled change + scheduled_change = _get_scheduled_change(document, feature.id) + assert scheduled_change is not None + assert scheduled_change["enabled"] is True + assert scheduled_change["live_from"] == live_from + + +def test_scheduled_change__pending_change_set_not_opted_in__is_never_surfaced( + environment_v2: Environment, + feature: Feature, + admin_user: FFAdminUser, +) -> None: + # Given a committed change request with a pending VersionChangeSet + _make_live_feature_state(environment_v2, feature, enabled=False) + change_request = _committed_change_request(environment_v2, admin_user) + VersionChangeSet.objects.create( + environment=environment_v2, + feature=feature, + change_request=change_request, + live_from=timezone.now() + datetime.timedelta(hours=1), + feature_states_to_update=json.dumps( + [ + { + "environment": environment_v2.id, + "identity": None, + "feature_segment": None, + "feature": feature.id, + "enabled": True, + "feature_state_value": _bool_value(True), + "multivariate_feature_state_values": [], + } + ] + ), + ) + + # When mapped without opting in + document = map_environment_to_sdk_document(environment_v2) + + # Then the key must be absent entirely, not present-and-null + assert "scheduled_change" not in _get_feature_state(document, feature.id) + + +def test_scheduled_change__soft_deleted_change_request__is_not_surfaced( + environment_v2: Environment, + feature: Feature, + admin_user: FFAdminUser, +) -> None: + """ + `change_request__...` in the pending-changeset query is a JOIN + condition, not a query through ChangeRequest's own soft-delete manager + — a soft-deleted (e.g. reverted) change request would still match + unless excluded explicitly. Setting `deleted_at` via a queryset + `.update()` isolates exactly that condition: calling the model's own + `.delete()` would cascade the soft-delete onto the VersionChangeSet + below too, which would then be excluded by its own manager regardless + of this fix — that's not what's under test here. + """ + # Given a committed but since soft-deleted change request with a + # pending, not-yet-materialised VersionChangeSet + _make_live_feature_state(environment_v2, feature, enabled=False) + change_request = _committed_change_request(environment_v2, admin_user) + VersionChangeSet.objects.create( + environment=environment_v2, + feature=feature, + change_request=change_request, + live_from=timezone.now() + datetime.timedelta(hours=1), + feature_states_to_update=json.dumps( + [ + { + "environment": environment_v2.id, + "identity": None, + "feature_segment": None, + "feature": feature.id, + "enabled": True, + "feature_state_value": _bool_value(True), + "multivariate_feature_state_values": [], + } + ] + ), + ) + ChangeRequest.objects.filter(id=change_request.id).update(deleted_at=timezone.now()) + + # When mapped with include_scheduled opted in + document = map_environment_to_sdk_document(environment_v2, include_scheduled=True) + + # Then it is not surfaced as a scheduled change + assert _get_scheduled_change(document, feature.id) is None + + +def test_scheduled_change__uncommitted_change_request__is_not_surfaced( + environment_v2: Environment, + feature: Feature, + admin_user: FFAdminUser, +) -> None: + # Given a change request still in draft/review (never committed) — not a + # locked-in change yet, and so must not be advertised to SDKs + _make_live_feature_state(environment_v2, feature, enabled=False) + change_request = ChangeRequest.objects.create( + environment=environment_v2, title="draft change", user=admin_user + ) + VersionChangeSet.objects.create( + environment=environment_v2, + feature=feature, + change_request=change_request, + live_from=timezone.now() + datetime.timedelta(hours=1), + feature_states_to_update=json.dumps( + [ + { + "environment": environment_v2.id, + "identity": None, + "feature_segment": None, + "feature": feature.id, + "enabled": True, + "feature_state_value": _bool_value(True), + "multivariate_feature_state_values": [], + } + ] + ), + ) + + # When mapped with include_scheduled opted in + document = map_environment_to_sdk_document(environment_v2, include_scheduled=True) + + # Then it is not surfaced as a scheduled change + assert _get_scheduled_change(document, feature.id) is None + + +def test_scheduled_change__pending_segment_override_update__is_surfaced( + environment_v2: Environment, + feature: Feature, + segment: Segment, + admin_user: FFAdminUser, +) -> None: + """ + `feature_states_to_update` staged against an *existing* live segment + override. `feature_states_to_create` (a brand new override with no + live counterpart yet) is a known, separate gap: the mapper only ever + attaches `scheduled_change` to an already-existing FeatureStateModel + entry, and there is none to attach to until the override actually + exists. Not covered here. + """ + from features.models import FeatureSegment + + # Given an existing live segment override and a pending changeset update + # to it + live_version = EnvironmentFeatureVersion.objects.create( + environment=environment_v2, feature=feature + ) + FeatureState.objects.create( + feature=feature, + environment=environment_v2, + environment_feature_version=live_version, + feature_segment=FeatureSegment.objects.create( + feature=feature, + segment=segment, + environment=environment_v2, + environment_feature_version=live_version, + ), + enabled=False, + ) + live_version.publish(admin_user) + + change_request = _committed_change_request(environment_v2, admin_user) + live_from = timezone.now() + datetime.timedelta(hours=1) + VersionChangeSet.objects.create( + environment=environment_v2, + feature=feature, + change_request=change_request, + live_from=live_from, + feature_states_to_update=json.dumps( + [ + { + "environment": environment_v2.id, + "identity": None, + "feature_segment": {"segment": segment.id}, + "feature": feature.id, + "enabled": True, + "feature_state_value": _bool_value(True), + "multivariate_feature_state_values": [], + } + ] + ), + ) + + # When mapped with include_scheduled opted in + document = map_environment_to_sdk_document(environment_v2, include_scheduled=True) + + # Then the pending update is surfaced on the segment override + scheduled_change = _get_segment_scheduled_change(document, feature.id, segment.id) + assert scheduled_change is not None + assert scheduled_change["enabled"] is True + assert scheduled_change["live_from"] == live_from + + +def test_scheduled_change__entry_missing_enabled__is_skipped( + environment_v2: Environment, + feature: Feature, + admin_user: FFAdminUser, +) -> None: + """ + The changeset JSON is `json.dumps()` of raw client input, only + validated at publish time — and `enabled` is genuinely optional in + the upstream create serializer (it has a model default), so an entry + without it is a reachable at-rest shape. It must be skipped, not 500 + the document build for every opted-in client; other, well-formed + changesets in the same environment must still surface. + """ + # Given a changeset entry missing the "enabled" key, alongside a + # well-formed changeset for another feature + _make_live_feature_state(environment_v2, feature, enabled=False) + other_feature = Feature.objects.create( + name="well_formed_feature", project=environment_v2.project + ) + change_request = _committed_change_request(environment_v2, admin_user) + VersionChangeSet.objects.create( + environment=environment_v2, + feature=feature, + change_request=change_request, + live_from=timezone.now() + datetime.timedelta(hours=1), + feature_states_to_update=json.dumps( + [ + { + "environment": environment_v2.id, + "identity": None, + "feature_segment": None, + "feature": feature.id, + # no "enabled" key + "feature_state_value": _bool_value(True), + "multivariate_feature_state_values": [], + } + ] + ), + ) + live_from = timezone.now() + datetime.timedelta(hours=2) + VersionChangeSet.objects.create( + environment=environment_v2, + feature=other_feature, + change_request=change_request, + live_from=live_from, + feature_states_to_update=json.dumps( + [ + { + "environment": environment_v2.id, + "identity": None, + "feature_segment": None, + "feature": other_feature.id, + "enabled": True, + "feature_state_value": _bool_value(True), + "multivariate_feature_state_values": [], + } + ] + ), + ) + + # When mapped — must not raise + document = map_environment_to_sdk_document(environment_v2, include_scheduled=True) + + # Then the malformed entry is skipped... + assert _get_scheduled_change(document, feature.id) is None + # ...while the rest of the document — the live feature state and the + # well-formed changeset for the other feature — is unaffected + assert _get_feature_state(document, feature.id)["enabled"] is False + other_scheduled = _get_scheduled_change(document, other_feature.id) + assert other_scheduled is not None + assert other_scheduled["enabled"] is True + assert other_scheduled["live_from"] == live_from + + +def test_scheduled_change__non_dict_feature_segment_and_entry__are_skipped( + environment_v2: Environment, + feature: Feature, + admin_user: FFAdminUser, +) -> None: + # Given a changeset with a non-dict `feature_segment` and a non-dict + # entry + _make_live_feature_state(environment_v2, feature, enabled=False) + change_request = _committed_change_request(environment_v2, admin_user) + VersionChangeSet.objects.create( + environment=environment_v2, + feature=feature, + change_request=change_request, + live_from=timezone.now() + datetime.timedelta(hours=1), + feature_states_to_update=json.dumps( + [ + { + "environment": environment_v2.id, + "identity": None, + # not an object — the mapper subscripts ["segment"] + "feature_segment": 42, + "feature": feature.id, + "enabled": True, + "feature_state_value": _bool_value(True), + "multivariate_feature_state_values": [], + }, + # not even an object at the entry level + "nonsense", + ] + ), + ) + + # When mapped — must not raise + document = map_environment_to_sdk_document(environment_v2, include_scheduled=True) + + # Then both malformed entries are skipped and the document is intact + assert _get_scheduled_change(document, feature.id) is None + assert _get_feature_state(document, feature.id)["enabled"] is False + + +def test_scheduled_change__non_list_changeset_json__is_skipped( + environment_v2: Environment, + feature: Feature, + admin_user: FFAdminUser, +) -> None: + """ + Even the top-level JSON shape is untrusted: a blob that isn't a list + at all (on either field) must only disable *this* changeset's + contribution — the rest of the environment document, including a + well-formed changeset for another feature, must still build. + """ + # Given a changeset whose top-level JSON isn't a list at all, alongside + # a well-formed changeset for another feature + _make_live_feature_state(environment_v2, feature, enabled=False) + other_feature = Feature.objects.create( + name="well_formed_feature", project=environment_v2.project + ) + change_request = _committed_change_request(environment_v2, admin_user) + VersionChangeSet.objects.create( + environment=environment_v2, + feature=feature, + change_request=change_request, + live_from=timezone.now() + datetime.timedelta(hours=1), + feature_states_to_update=json.dumps({"not": "a list"}), + feature_states_to_create=json.dumps("garbage"), + ) + live_from = timezone.now() + datetime.timedelta(hours=2) + VersionChangeSet.objects.create( + environment=environment_v2, + feature=other_feature, + change_request=change_request, + live_from=live_from, + feature_states_to_update=json.dumps( + [ + { + "environment": environment_v2.id, + "identity": None, + "feature_segment": None, + "feature": other_feature.id, + "enabled": True, + "feature_state_value": _bool_value(True), + "multivariate_feature_state_values": [], + } + ] + ), + ) + + # When mapped — must not raise + document = map_environment_to_sdk_document(environment_v2, include_scheduled=True) + + # Then the garbage changeset surfaces nothing for its feature... + assert _get_scheduled_change(document, feature.id) is None + # ...and the well-formed changeset for the other feature still does + other_scheduled = _get_scheduled_change(document, other_feature.id) + assert other_scheduled is not None + assert other_scheduled["enabled"] is True + assert other_scheduled["live_from"] == live_from + + +# -- Earliest-wins across both mechanisms ------------------------------------- +# When BOTH mechanisms hold a pending change for the same feature — a future +# FeatureState that already exists in the DB (mechanism 1) and a +# committed-but-unpublished VersionChangeSet diff (mechanism 2) — the +# surfaced `scheduled_change` must be whichever goes live first, mirroring the +# earliest-wins rule each mechanism already applies internally +# (`map_feature_state_to_engine` compares the two candidates' `live_from` and +# keeps the earlier one). The two changes disagree on `enabled` so the +# assertions can tell which mechanism won. + + +def test_scheduled_change__earlier_feature_state_vs_later_change_set__feature_state_wins( + environment_v2: Environment, + feature: Feature, + admin_user: FFAdminUser, +) -> None: + # Given the FeatureState-based change goes live an hour before the + # changeset-based one + _make_live_feature_state(environment_v2, feature, enabled=False) + feature_state_live_from = timezone.now() + datetime.timedelta(hours=1) + _make_future_feature_state( + environment_v2, feature, enabled=True, live_from=feature_state_live_from + ) + _pending_change_set( + environment_v2, + feature, + admin_user, + live_from=timezone.now() + datetime.timedelta(hours=2), + enabled=False, + ) + + # When + document = map_environment_to_sdk_document(environment_v2, include_scheduled=True) + + # Then — mechanism 1 (the future FeatureState) wins + scheduled_change = _get_scheduled_change(document, feature.id) + assert scheduled_change is not None + assert scheduled_change["enabled"] is True + assert scheduled_change["live_from"] == feature_state_live_from + + +def test_scheduled_change__earlier_change_set_vs_later_feature_state__change_set_wins( + environment_v2: Environment, + feature: Feature, + admin_user: FFAdminUser, +) -> None: + # Given — the same setup with the timestamps swapped + _make_live_feature_state(environment_v2, feature, enabled=False) + change_set_live_from = timezone.now() + datetime.timedelta(hours=1) + _make_future_feature_state( + environment_v2, + feature, + enabled=True, + live_from=timezone.now() + datetime.timedelta(hours=2), + ) + _pending_change_set( + environment_v2, + feature, + admin_user, + live_from=change_set_live_from, + enabled=False, + ) + + # When + document = map_environment_to_sdk_document(environment_v2, include_scheduled=True) + + # Then — mechanism 2 (the pending changeset) wins + scheduled_change = _get_scheduled_change(document, feature.id) + assert scheduled_change is not None + assert scheduled_change["enabled"] is False + assert scheduled_change["live_from"] == change_set_live_from diff --git a/api/util/engine_models/features/models.py b/api/util/engine_models/features/models.py index 7dfce7af87a0..31b70877dcfa 100644 --- a/api/util/engine_models/features/models.py +++ b/api/util/engine_models/features/models.py @@ -1,8 +1,9 @@ +import datetime import typing import uuid from annotated_types import Ge, Le, SupportsLt -from pydantic import UUID4, BaseModel, Field, model_validator +from pydantic import UUID4, BaseModel, Field, model_serializer, model_validator from pydantic_collections import BaseCollectionModel # type: ignore[import-untyped] from typing_extensions import Annotated @@ -66,6 +67,19 @@ def append( super().append(multivariate_feature_state_value) +class ScheduledChangeModel(BaseModel): + """ + A future FeatureState for the same feature/segment/identity that hasn't + gone live yet (`live_from` is in the future). Only ever populated when the + SDK explicitly opts in — see `include_scheduled` on the + environment-document endpoint. + """ + + live_from: datetime.datetime + enabled: bool + feature_state_value: typing.Any = None + + class FeatureStateModel(BaseModel, validate_assignment=True): feature: FeatureModel enabled: bool @@ -76,6 +90,25 @@ class FeatureStateModel(BaseModel, validate_assignment=True): multivariate_feature_state_values: MultivariateFeatureStateValueList = Field( default_factory=MultivariateFeatureStateValueList ) + # Next not-yet-live version of this feature state, if any and if + # requested. Never populated unless explicitly opted in. + scheduled_change: typing.Optional[ScheduledChangeModel] = None + + @model_serializer(mode="wrap") + def _omit_scheduled_change_when_absent( + self, + handler: typing.Callable[["FeatureStateModel"], typing.Dict[str, typing.Any]], + ) -> typing.Dict[str, typing.Any]: + # When `scheduled_change` is unpopulated (the default, i.e. nobody + # opted in), drop the key entirely so that every serialized shape — + # SDK environment documents, DynamoDB documents, any other + # `model_dump()` caller — stays byte-for-byte identical to callers + # that never opted in: emitting `"scheduled_change": null` would be a + # shape change for callers that never asked for this feature. + data = handler(self) + if self.scheduled_change is None: + data.pop("scheduled_change", None) + return data def set_value(self, value: typing.Any) -> None: self.feature_state_value = value diff --git a/api/util/mappers/engine.py b/api/util/mappers/engine.py index ef72abdb9511..084af7d62aef 100644 --- a/api/util/mappers/engine.py +++ b/api/util/mappers/engine.py @@ -1,14 +1,18 @@ +import datetime +import logging from collections.abc import Iterable from itertools import chain -from typing import TYPE_CHECKING, Dict, List, Optional +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, TypeVar, cast from uuid import UUID +from common.core.utils import using_database_replica +from django.utils import timezone from flag_engine.context import types as engine_types from flag_engine.segments.types import ConditionOperator, RuleType from pydantic import TypeAdapter from environments.constants import IDENTITY_INTEGRATIONS_RELATION_NAMES -from features.versioning.models import EnvironmentFeatureVersion +from features.versioning.models import EnvironmentFeatureVersion, VersionChangeSet from segments.types import SegmentEngineMetadata from util.engine_models.environments.integrations.models import IntegrationModel from util.engine_models.environments.models import ( @@ -22,6 +26,7 @@ FeatureStateModel, MultivariateFeatureOptionModel, MultivariateFeatureStateValueModel, + ScheduledChangeModel, ) from util.engine_models.identities.models import IdentityModel from util.engine_models.identities.traits.models import TraitModel @@ -51,6 +56,9 @@ from segments.models import Condition, Segment, SegmentRule +logger = logging.getLogger(__name__) + + __all__ = ( "map_condition_to_segment_condition", "map_environment_api_key_to_engine", @@ -135,10 +143,282 @@ def map_webhook_config_to_engine( ) +def _get_effective_live_from( + feature_state: "FeatureState", +) -> Optional[datetime.datetime]: + """ + Resolve `live_from` consistently across both versioning systems. + + V1 (legacy): the timestamp lives directly on the FeatureState. + V2 (`use_v2_feature_versioning`): it lives on the linked + EnvironmentFeatureVersion instead — FeatureState.live_from isn't + guaranteed to be populated there. + + Mirrors the branching already used by `FeatureState.is_live`, which + doesn't expose this comparison as a reusable, version-agnostic helper. + """ + if feature_state.environment_feature_version_id is not None: + environment_feature_version = feature_state.environment_feature_version + return ( + environment_feature_version.live_from + if environment_feature_version + else None + ) + return feature_state.live_from + + +def _is_scheduled_change_locked_in(feature_state: "FeatureState") -> bool: + """ + Whether a not-yet-live feature state is actually committed to going + live, mirroring the publication half of `FeatureState.is_live`'s V1/V2 + branching (`_get_effective_live_from` mirrors its timestamp half). + + A future `live_from` alone doesn't mean "scheduled": + + V2: `live_from` is writable on an *unpublished* EnvironmentFeatureVersion + (the versioning API accepts it at draft-create time), so its auto-cloned + feature states carry a future timestamp before anyone decides to publish. + Require `published_at` to be set. + + V1: feature states belonging to an uncommitted change request have + `version=None` but already carry their target `live_from`. Require a + real version number. + + Either way, a draft isn't locked in yet and must not be advertised to + SDKs as a scheduled change (matching the committed-only filter the + VersionChangeSet mechanism applies). + """ + if feature_state.environment_feature_version_id is not None: + environment_feature_version = feature_state.environment_feature_version + # `select_related` joins straight onto the FK column, bypassing + # `EnvironmentFeatureVersion`'s soft-delete manager — a cancelled + # (soft-deleted) version can still come back here, so `deleted_at` + # must be checked explicitly rather than relying on the manager. + return ( + environment_feature_version is not None + and environment_feature_version.published_at is not None + and environment_feature_version.deleted_at is None + ) + return feature_state.version is not None + + +def _parse_changeset_feature_state_value( + value_dict: object, +) -> object: + if not isinstance(value_dict, dict): + return None + for key in ("string_value", "integer_value", "boolean_value"): + value = value_dict.get(key) + if value is not None: + return value + return None + + +def _get_valid_changeset_entries( + change_set: VersionChangeSet, + field_name: str, + *, + feature_segment_required: bool, +) -> List[Dict[str, object]]: + """ + `feature_states_to_create`/`feature_states_to_update` store + `json.dumps()` of *raw client JSON* at CR-create time — that JSON is only + validated at publish time (`features/versioning/tasks.py`). This + parser runs on the SDK hot read path (the environment-document endpoint), + so a malformed blob must degrade to "skip and log", never propagate: one + garbage changeset must not 500 the document build — nor hide scheduled + changes for the environment's other, well-formed changesets. + """ + try: + entries = getattr(change_set, f"get_parsed_{field_name}")() + except (ValueError, TypeError) as exc: + logger.warning( + "Skipping unparseable %s JSON on VersionChangeSet id=%s " + "(feature id=%s, change request id=%s): %s", + field_name, + change_set.id, + change_set.feature_id, + change_set.change_request_id, + exc, + ) + return [] + if not isinstance(entries, list): + logger.warning( + "Skipping %s on VersionChangeSet id=%s (feature id=%s, change " + "request id=%s): expected a JSON list, got %s", + field_name, + change_set.id, + change_set.feature_id, + change_set.change_request_id, + type(entries).__name__, + ) + return [] + return [ + entry + for entry in entries + if _is_valid_changeset_entry( + entry, + change_set, + field_name, + feature_segment_required=feature_segment_required, + ) + ] + + +def _is_valid_changeset_entry( + entry: object, + change_set: VersionChangeSet, + field_name: str, + *, + feature_segment_required: bool, +) -> bool: + """ + Shape-check a single changeset feature-state entry so the caller can use + plain subscripts safely. `enabled` is genuinely optional client-side (the + upstream serializer leaves it to a model default), so its absence here is + an expected malformed-at-rest shape, not a can't-happen guard. + """ + + def _skip(reason: str) -> bool: + logger.warning( + "Skipping malformed %s entry on VersionChangeSet id=%s " + "(feature id=%s, change request id=%s): %s", + field_name, + change_set.id, + change_set.feature_id, + change_set.change_request_id, + reason, + ) + return False + + if not isinstance(entry, dict): + return _skip(f"expected an object, got {type(entry).__name__}") + if not isinstance(entry.get("enabled"), bool): + return _skip("missing or non-boolean 'enabled'") + feature_state_value = entry.get("feature_state_value") + if feature_state_value is not None and not isinstance(feature_state_value, dict): + return _skip("'feature_state_value' is not an object") + feature_segment = entry.get("feature_segment") + if feature_segment is None: + if feature_segment_required: + return _skip("missing 'feature_segment'") + return True + if not isinstance(feature_segment, dict): + return _skip("'feature_segment' is not an object") + segment_id = feature_segment.get("segment") + if not isinstance(segment_id, int) or isinstance(segment_id, bool): + return _skip("'feature_segment.segment' is not an integer") + return True + + +_ScheduledChangeKeyT = TypeVar("_ScheduledChangeKeyT") + + +def _keep_earliest_scheduled_change( + by_key: Dict[_ScheduledChangeKeyT, ScheduledChangeModel], + key: _ScheduledChangeKeyT, + candidate: ScheduledChangeModel, +) -> None: + existing = by_key.get(key) + if existing is None or candidate.live_from < existing.live_from: + by_key[key] = candidate + + +def _get_pending_version_change_set_scheduled_changes( + environment: "Environment", +) -> Tuple[ + Dict[int, ScheduledChangeModel], Dict[Tuple[int, int], ScheduledChangeModel] +]: + """ + Change-request-driven scheduled changes are staged + as a `VersionChangeSet` (a JSON diff of the change request) rather than + a real FeatureState row. A background task only materialises the actual + FeatureState/EnvironmentFeatureVersion at `live_from`, so between commit + and go-live there is nothing for `_get_prioritised_feature_states` to + find. This reads those pending diffs directly. + + Only changesets tied to an already-*committed* change request are + surfaced — a change still in draft/review isn't locked in yet and + shouldn't be advertised to SDKs as a scheduled change. + + Returns a 2-tuple: (environment-default changes by feature id, + segment-override changes by (feature id, segment id)). + """ + environment_default_by_feature_id: Dict[int, ScheduledChangeModel] = {} + segment_override_by_feature_and_segment_id: Dict[ + Tuple[int, int], ScheduledChangeModel + ] = {} + + # `change_request__...` is a JOIN condition, not a query through + # ChangeRequest's own soft-delete manager — a soft-deleted change + # request would still match here unless excluded explicitly. + pending_change_sets = using_database_replica(VersionChangeSet.objects).filter( + environment=environment, + published_at__isnull=True, + live_from__isnull=False, + live_from__gt=timezone.now(), + change_request__committed_at__isnull=False, + change_request__deleted_at__isnull=True, + ) + + for change_set in pending_change_sets: + # The changeset JSON is raw, publish-time-validated client input — + # `_get_valid_changeset_entries` shape-checks every entry (skipping + # and logging bad ones), so the subscripts below can't raise. + for fs_update in _get_valid_changeset_entries( + change_set, "feature_states_to_update", feature_segment_required=False + ): + candidate = ScheduledChangeModel( + live_from=change_set.live_from, + enabled=bool(fs_update["enabled"]), + feature_state_value=_parse_changeset_feature_state_value( + fs_update.get("feature_state_value") + ), + ) + feature_segment = fs_update.get("feature_segment") + if feature_segment is None: + _keep_earliest_scheduled_change( + environment_default_by_feature_id, + change_set.feature_id, + candidate, + ) + else: + segment_id = cast(Dict[str, object], feature_segment)["segment"] + _keep_earliest_scheduled_change( + segment_override_by_feature_and_segment_id, + (change_set.feature_id, cast(int, segment_id)), + candidate, + ) + + # feature states to create are always segment overrides — an + # environment default always already exists, so it's updated, + # never created. Hence `feature_segment_required`. + for fs_create in _get_valid_changeset_entries( + change_set, "feature_states_to_create", feature_segment_required=True + ): + feature_segment = cast(Dict[str, object], fs_create["feature_segment"]) + candidate = ScheduledChangeModel( + live_from=change_set.live_from, + enabled=bool(fs_create["enabled"]), + feature_state_value=_parse_changeset_feature_state_value( + fs_create.get("feature_state_value") + ), + ) + _keep_earliest_scheduled_change( + segment_override_by_feature_and_segment_id, + (change_set.feature_id, cast(int, feature_segment["segment"])), + candidate, + ) + + return environment_default_by_feature_id, segment_override_by_feature_and_segment_id + + def map_feature_state_to_engine( feature_state: "FeatureState", *, mv_fs_values: Optional[Iterable["MultivariateFeatureStateValue"]] = None, + scheduled_feature_state: Optional["FeatureState"] = None, + scheduled_change_override: Optional[ScheduledChangeModel] = None, ) -> FeatureStateModel: feature = feature_state.feature feature_segment: Optional["FeatureSegment"] = feature_state.feature_segment @@ -150,6 +430,31 @@ def map_feature_state_to_engine( else: feature_segment_model = None + # Surface the next not-yet-live version of this feature state, if the + # caller opted in and one exists. Without opting in, non-live feature + # states are never mapped at all. + scheduled_change_model: Optional[ScheduledChangeModel] = None + if scheduled_feature_state is not None: + scheduled_live_from = _get_effective_live_from(scheduled_feature_state) + if scheduled_live_from is not None: + scheduled_change_model = ScheduledChangeModel( + live_from=scheduled_live_from, + enabled=scheduled_feature_state.enabled, + feature_state_value=scheduled_feature_state.get_feature_state_value(), + ) + + # A change-request-scheduled change has no + # FeatureState row yet — it's staged as a VersionChangeSet JSON diff + # until a background task materialises it at `live_from`. The caller + # resolves that from `_get_pending_version_change_set_scheduled_changes` + # and passes the winner here, since there's no ORM object to compare + # against `scheduled_feature_state` this deep in the call stack. + if scheduled_change_override is not None and ( + scheduled_change_model is None + or scheduled_change_override.live_from < scheduled_change_model.live_from + ): + scheduled_change_model = scheduled_change_override + return FeatureStateModel( enabled=feature_state.enabled, django_id=feature_state.pk, @@ -160,6 +465,7 @@ def map_feature_state_to_engine( multivariate_feature_state_values=[ # type: ignore[arg-type] map_mv_fs_value_to_engine(mv_fs_value) for mv_fs_value in mv_fs_values or [] ], + scheduled_change=scheduled_change_model, ) @@ -194,6 +500,7 @@ def map_environment_to_engine( environment: "Environment", *, with_integrations: bool = True, + include_scheduled: bool = False, ) -> EnvironmentModel: """ Maps Core API's `environments.models.Environment` model instance to the @@ -202,6 +509,40 @@ def map_environment_to_engine( feature versions. :param Environment environment: the environment to map + :param include_scheduled: opt-in. When True, each returned + FeatureStateModel additionally carries a `scheduled_change` field + describing the next not-yet-live version of that feature state, if + any. Defaults to False, in which case the response shape is + unchanged. + + Known limitations — shapes of genuinely scheduled change that are + *not* surfaced (a consumer cannot distinguish "nothing scheduled" + from "scheduled, but in an unsupported shape"): + + * v2 segment overrides via the versioning flow: a future *published* + EnvironmentFeatureVersion's segment overrides hang off that + version's own FeatureSegment clones, which the + `latest_environment_feature_version_uuids` filter in + `_get_segment_feature_states` excludes before the scheduled scan + ever sees them. The FeatureState-based mechanism therefore covers + v2 environment defaults and V1 feature states (any scope), but + never v2 segment overrides. + * Brand-new segment overrides staged in a changeset + (`feature_states_to_create`): there is no live FeatureStateModel + to attach the `scheduled_change` to until the override exists — + see the test docstring on + `test_include_scheduled__surfaces_pending_update_to_existing_segment_override` + in `tests/unit/util/mappers/test_scheduled_changes.py`. + * Scheduled deletions: `segment_ids_to_delete_overrides` in a + committed changeset is never read, so a scheduled *removal* of a + segment override surfaces nothing. + * Multivariate values: `ScheduledChangeModel` carries only `enabled` + and the control `feature_state_value` — a scheduled change to MV + weights alone advertises no meaningful diff. + * Identity overrides: effectively V1-only. v2 identity feature + states are always live, so the `include_scheduled` threading + through `map_identity_to_engine` rarely has anything to surface + under v2. :rtype EnvironmentModel """ project: "Project" = environment.project @@ -213,7 +554,10 @@ def map_environment_to_engine( ps for ps in project.segments.all() if ps.id == ps.version_of_id ] - project_segment_feature_states_by_segment_id = _get_segment_feature_states( + ( + project_segment_feature_states_by_segment_id, + project_segment_scheduled_by_segment_id, + ) = _get_segment_feature_states( project_segments, environment.pk, latest_environment_feature_version_uuids=( @@ -226,6 +570,7 @@ def map_environment_to_engine( if environment.use_v2_feature_versioning else [] ), + include_scheduled=include_scheduled, ) # Drop feature-specific segments that have no FeatureSegment in this # environment — without one, they have no evaluation path here, and @@ -240,13 +585,16 @@ def map_environment_to_engine( int, Iterable["SegmentRule"], ] = {segment.pk: segment.rules.all() for segment in project_segments} - environment_feature_states: List["FeatureState"] = _get_prioritised_feature_states( - [ - feature_state - for feature_state in environment.feature_states.all() - if feature_state.feature_segment_id is None - and feature_state.identity_id is None - ] + environment_feature_states, environment_scheduled_by_feature_id = ( + _get_prioritised_feature_states( + [ + feature_state + for feature_state in environment.feature_states.all() + if feature_state.feature_segment_id is None + and feature_state.identity_id is None + ], + include_scheduled=include_scheduled, + ) ) all_environment_feature_states = ( *environment_feature_states, @@ -257,6 +605,17 @@ def map_environment_to_engine( for feature_state in all_environment_feature_states } + # Change-request-scheduled changes have no + # FeatureState row yet — see `_get_pending_version_change_set_scheduled_changes`. + ( + changeset_environment_default_by_feature_id, + changeset_segment_override_by_feature_and_segment_id, + ) = ( + _get_pending_version_change_set_scheduled_changes(environment) + if include_scheduled + else ({}, {}) + ) + # Read integrations. integration_configs: dict[ str, "EnvironmentIntegrationModel | WebhookConfiguration | None" @@ -291,6 +650,12 @@ def map_environment_to_engine( mv_fs_values=multivariate_feature_state_values_by_feature_state_id.pop( feature_state.pk, ), + scheduled_feature_state=project_segment_scheduled_by_segment_id.get( + segment.pk, {} + ).get(feature_state.feature_id), + scheduled_change_override=changeset_segment_override_by_feature_and_segment_id.get( + (feature_state.feature_id, segment.pk) + ), ) for feature_state in project_segment_feature_states_by_segment_id.pop( segment.pk @@ -318,6 +683,12 @@ def map_environment_to_engine( mv_fs_values=multivariate_feature_state_values_by_feature_state_id.pop( feature_state.pk, ), + scheduled_feature_state=environment_scheduled_by_feature_id.get( + feature_state.feature_id + ), + scheduled_change_override=changeset_environment_default_by_feature_id.get( + feature_state.feature_id + ), ) for feature_state in environment_feature_states ] @@ -390,13 +761,17 @@ def map_identity_to_engine( *, with_overrides: bool = True, with_traits: bool = True, + include_scheduled: bool = False, ) -> IdentityModel: environment_api_key = identity.environment.api_key # Read relationships - grab all the data needed from the ORM here. if with_overrides: - identity_feature_states: List["FeatureState"] = _get_prioritised_feature_states( - identity.identity_features.all(), + identity_feature_states, identity_scheduled_by_feature_id = ( + _get_prioritised_feature_states( + identity.identity_features.all(), + include_scheduled=include_scheduled, + ) ) multivariate_feature_state_values_by_feature_state_id = { feature_state.pk: feature_state.multivariate_feature_state_values.all() @@ -404,6 +779,7 @@ def map_identity_to_engine( } else: identity_feature_states = [] + identity_scheduled_by_feature_id = {} multivariate_feature_state_values_by_feature_state_id = {} identity_traits: Iterable["Trait"] = ( @@ -417,6 +793,9 @@ def map_identity_to_engine( mv_fs_values=multivariate_feature_state_values_by_feature_state_id.pop( feature_state.pk, ), + scheduled_feature_state=identity_scheduled_by_feature_id.get( + feature_state.feature_id + ), ) for feature_state in identity_feature_states ] @@ -509,13 +888,37 @@ def map_condition_to_segment_condition( def _get_prioritised_feature_states( feature_states: Iterable["FeatureState"], -) -> List["FeatureState"]: + *, + include_scheduled: bool = False, +) -> Tuple[List["FeatureState"], Dict[int, "FeatureState"]]: + """ + Returns a 2-tuple of (prioritised live feature states, next-scheduled + feature state by feature id). + + The second element is only ever populated when + `include_scheduled=True`. When it's not, non-live + feature states (including future-scheduled ones) are silently discarded + with no way to recover them from this function's result. + """ prioritised_feature_state_by_feature_id = {} # type: ignore[var-annotated] + scheduled_feature_state_by_feature_id: Dict[int, "FeatureState"] = {} for feature_state in feature_states: # TODO: this call to is_live was causing an N+1 issue. # For now, we have solved it with an extra select_related, but # there is probably a neater solution here. if not feature_state.is_live: + if include_scheduled and _is_scheduled_change_locked_in(feature_state): + live_from = _get_effective_live_from(feature_state) + if live_from is not None and live_from > timezone.now(): + existing_scheduled = scheduled_feature_state_by_feature_id.get( + feature_state.feature_id + ) + if existing_scheduled is None or live_from < ( + _get_effective_live_from(existing_scheduled) or live_from + ): + scheduled_feature_state_by_feature_id[ + feature_state.feature_id + ] = feature_state continue if existing_feature_state := prioritised_feature_state_by_feature_id.get( feature_state.feature_id @@ -525,18 +928,25 @@ def _get_prioritised_feature_states( prioritised_feature_state_by_feature_id[feature_state.feature_id] = ( feature_state ) - return list(prioritised_feature_state_by_feature_id.values()) + return ( + list(prioritised_feature_state_by_feature_id.values()), + scheduled_feature_state_by_feature_id, + ) def _get_segment_feature_states( segments: Iterable["Segment"], environment_id: int, latest_environment_feature_version_uuids: Iterable[UUID], -) -> Dict[int, List["FeatureState"]]: + *, + include_scheduled: bool = False, +) -> Tuple[Dict[int, List["FeatureState"]], Dict[int, Dict[int, "FeatureState"]]]: feature_states_by_segment_id = {} # type: ignore[var-annotated] + scheduled_by_segment_id: Dict[int, Dict[int, "FeatureState"]] = {} for segment in segments: segment_feature_states = feature_states_by_segment_id.setdefault(segment.pk, []) + segment_scheduled = scheduled_by_segment_id.setdefault(segment.pk, {}) for feature_segment in segment.feature_segments.all(): if feature_segment.environment_id != environment_id: @@ -549,8 +959,11 @@ def _get_segment_feature_states( ): continue - segment_feature_states += _get_prioritised_feature_states( - feature_segment.feature_states.all() + live_states, scheduled_states = _get_prioritised_feature_states( + feature_segment.feature_states.all(), + include_scheduled=include_scheduled, ) + segment_feature_states += live_states + segment_scheduled.update(scheduled_states) - return feature_states_by_segment_id + return feature_states_by_segment_id, scheduled_by_segment_id diff --git a/api/util/mappers/sdk.py b/api/util/mappers/sdk.py index 24f32b356dfc..bd6504752994 100644 --- a/api/util/mappers/sdk.py +++ b/api/util/mappers/sdk.py @@ -19,13 +19,27 @@ ] -def map_environment_to_sdk_document(environment: "Environment") -> SDKDocument: +def map_environment_to_sdk_document( + environment: "Environment", + *, + include_scheduled: bool = False, +) -> SDKDocument: """ Map an `environments.models.Environment` instance to an SDK document used by SDKs with local evaluation mode. It's virtually the same data that gets indexed in DynamoDB, except it presents identity overrides and omits integrations configurations. + + :param include_scheduled: opt-in. When True, every feature + state in the resulting document additionally carries a + `scheduled_change` field describing its next not-yet-live version, if + any. Defaults to False, in which case the SDK-facing shape is + unchanged. + + Not every scheduled-change shape is surfaced — see the consolidated + "Known limitations" note on `map_environment_to_engine` in + `util/mappers/engine.py`. """ # Read relationships. identities_with_overrides = {} @@ -37,13 +51,21 @@ def map_environment_to_sdk_document(environment: "Environment") -> SDKDocument: identities_with_overrides[identity_id] = feature_state.identity # Get the engine data. - engine_environment = map_environment_to_engine(environment, with_integrations=False) + engine_environment = map_environment_to_engine( + environment, + with_integrations=False, + include_scheduled=include_scheduled, + ) # No reading from ORM past this point! # Prepare relationships. engine_environment.identity_overrides = [ - map_identity_to_engine(identity, with_traits=False) + map_identity_to_engine( + identity, + with_traits=False, + include_scheduled=include_scheduled, + ) for identity in identities_with_overrides.values() ] diff --git a/docs/docs/sdk-api/sdk-v-1-environment-document.api.mdx b/docs/docs/sdk-api/sdk-v-1-environment-document.api.mdx index fe28e47e675d..435bbdb5ec0d 100644 --- a/docs/docs/sdk-api/sdk-v-1-environment-document.api.mdx +++ b/docs/docs/sdk-api/sdk-v-1-environment-document.api.mdx @@ -5,7 +5,7 @@ description: "Retrieve the environment document." sidebar_label: "sdk_v1_environment_document" hide_title: true hide_table_of_contents: true -api: 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 +api: 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 sidebar_class_name: "get api-method" info_path: sdk-api/flagsmith-api custom_edit_url: null @@ -39,8 +39,16 @@ import Heading from "@theme/Heading"; Retrieve the environment document. Used by SDKs in local evaluation mode, and Edge Proxy. + + + @@ -55,7 +63,7 @@ Used by SDKs in local evaluation mode, and Edge Proxy. diff --git a/sdk/openapi.yaml b/sdk/openapi.yaml index 2422d7dc3133..6332f2d7a52f 100644 --- a/sdk/openapi.yaml +++ b/sdk/openapi.yaml @@ -18,6 +18,12 @@ paths: description: |- Retrieve the environment document. Used by SDKs in local evaluation mode, and Edge Proxy. + parameters: + - name: include_scheduled + in: query + description: 'Opt-in. When true, each feature state in the response carries an additional `scheduled_change` field describing its next not-yet-live version, if any. Defaults to false, in which case the response shape is unchanged. Note: not every scheduled-change shape is currently surfaced (e.g. v2 segment overrides via the versioning flow, brand-new segment overrides, scheduled deletions, and multivariate weight changes).' + schema: + type: boolean responses: '200': description: ''