Skip to content

Commit 1e2fd62

Browse files
committed
feat(SDK): Surface upcoming scheduled changes in the environment document
Add an opt-in `include_scheduled` query param to the SDK environment-document endpoint. When set, each feature state additionally carries a `scheduled_change` field (`live_from`/`enabled`/`feature_state_value`) describing its next not-yet-live version, if any — sourced from both a future FeatureState/EnvironmentFeatureVersion already in the DB and a committed but not-yet-materialised VersionChangeSet diff, with an earliest-wins rule when both exist. This lets SDKs running in local-evaluation mode resolve a scheduled change's maturity from an already-cached document, without waiting for a new poll after `live_from` passes — and keeps a fleet of instances in sync at the same moment, since every instance resolves maturity from its own clock rather than from when it happened to last poll. Fully backward compatible: the field is omitted entirely when not requested, so existing SDKs and cached documents are byte-for-byte unchanged. Test names follow the repo's test_{subject}__{condition}__{expected} convention with Given/When/Then comments, as flat functions per the flagsmith-lint-tests pre-commit hook. The SDK API reference page for this endpoint is regenerated from the updated OpenAPI spec.
1 parent a7b0d21 commit 1e2fd62

8 files changed

Lines changed: 1368 additions & 32 deletions

File tree

api/environments/models.py

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -534,14 +534,41 @@ def get_segments_from_cache(self) -> typing.List[Segment]:
534534
def get_environment_document(
535535
cls,
536536
api_key: str,
537+
*,
538+
include_scheduled: bool = False,
537539
) -> dict[str, typing.Any]:
540+
"""
541+
:param include_scheduled: opt-in. When True, feature states in the
542+
returned document carry an additional `scheduled_change` field
543+
describing their next not-yet-live version, if any. Defaults to
544+
False, in which case the response shape is unchanged.
545+
"""
546+
# PERSISTENT mode relies on proactive rewrite — entries never expire,
547+
# and only write_environment_documents and the delete/rename hooks
548+
# keep the base `api_key` entry fresh. None of
549+
# them maintain the `:scheduled` key variant, and no write-triggered
550+
# refresh ever could: a schedule maturing (its live_from passing)
551+
# changes the correct scheduled document without any DB write. So
552+
# opted-in requests bypass the cache entirely under PERSISTENT mode.
553+
if (
554+
include_scheduled
555+
and settings.CACHE_ENVIRONMENT_DOCUMENT_MODE
556+
== EnvironmentDocumentCacheMode.PERSISTENT
557+
):
558+
return cls._get_environment_document_from_db(
559+
api_key, include_scheduled=True
560+
)
538561
if (
539562
settings.CACHE_ENVIRONMENT_DOCUMENT_SECONDS > 0
540563
or settings.CACHE_ENVIRONMENT_DOCUMENT_MODE
541564
== EnvironmentDocumentCacheMode.PERSISTENT
542565
):
543-
return cls._get_environment_document_from_cache(api_key)
544-
return cls._get_environment_document_from_db(api_key)
566+
return cls._get_environment_document_from_cache(
567+
api_key, include_scheduled=include_scheduled
568+
)
569+
return cls._get_environment_document_from_db(
570+
api_key, include_scheduled=include_scheduled
571+
)
545572

546573
def get_create_log_message(self, history_instance) -> typing.Optional[str]: # type: ignore[no-untyped-def]
547574
return ENVIRONMENT_CREATED_MESSAGE % self.name # type: ignore[no-any-return]
@@ -559,11 +586,21 @@ def get_hide_disabled_flags(self) -> bool:
559586
def _get_environment_document_from_cache(
560587
cls,
561588
api_key: str,
589+
*,
590+
include_scheduled: bool = False,
562591
) -> dict[str, typing.Any]:
563-
environment_document = environment_document_cache.get(api_key)
592+
# The cache key must vary with `include_scheduled` — the response
593+
# shape differs between the two, so a single shared key
594+
# would let whichever request populates the cache first silently
595+
# dictate what every other request gets back, regardless of what it
596+
# actually asked for.
597+
cache_key = f"{api_key}:scheduled" if include_scheduled else api_key
598+
environment_document = environment_document_cache.get(cache_key)
564599
if not (cache_hit := environment_document is not None):
565-
environment_document = cls._get_environment_document_from_db(api_key)
566-
environment_document_cache.set(api_key, environment_document)
600+
environment_document = cls._get_environment_document_from_db(
601+
api_key, include_scheduled=include_scheduled
602+
)
603+
environment_document_cache.set(cache_key, environment_document)
567604

568605
flagsmith_environment_document_cache_queries_total.labels(
569606
result=CACHE_HIT if cache_hit else CACHE_MISS,
@@ -575,6 +612,8 @@ def _get_environment_document_from_cache(
575612
def _get_environment_document_from_db(
576613
cls,
577614
api_key: str,
615+
*,
616+
include_scheduled: bool = False,
578617
) -> dict[str, typing.Any]:
579618
manager = using_database_replica(cls.objects)
580619
environment = manager.filter_for_document_builder(
@@ -611,7 +650,9 @@ def _get_environment_document_from_db(
611650
),
612651
],
613652
).get()
614-
return map_environment_to_sdk_document(environment)
653+
return map_environment_to_sdk_document(
654+
environment, include_scheduled=include_scheduled
655+
)
615656

616657
def _get_environment(self): # type: ignore[no-untyped-def]
617658
return self

api/environments/sdk/views.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from django.utils.decorators import method_decorator
55
from django.views.decorators.http import condition
6-
from drf_spectacular.utils import extend_schema
6+
from drf_spectacular.utils import OpenApiParameter, extend_schema
77
from flagsmith_schemas.api import V1EnvironmentDocumentResponse
88
from rest_framework.request import Request
99
from rest_framework.response import Response
@@ -17,7 +17,26 @@
1717
from environments.permissions.permissions import EnvironmentKeyPermissions
1818

1919

20+
def _is_include_scheduled_requested(request: Request) -> bool:
21+
# Opt-in query param for `scheduled_change` data.
22+
return request.GET.get("include_scheduled", "").lower() in ("1", "true", "yes")
23+
24+
2025
def get_last_modified(request: Request) -> datetime | None:
26+
# A schedule maturing (`live_from` passing) doesn't touch
27+
# `environment.updated_at` — nothing is saved when time simply
28+
# elapses. Requests that opt into `scheduled_change` data therefore can't
29+
# rely on this field to decide freshness, and worse, a client that has a
30+
# cached body from *before* it started opting in could otherwise get a
31+
# 304 short-circuit here and keep reusing that stale, scheduled-change-free
32+
# body forever. Returning None tells Django's `condition()` there is no
33+
# known last-modified value, so it always falls through to `get()` instead.
34+
# Trade-off: this disables the 304 short-circuit for every
35+
# `include_scheduled=True` request (each one always executes `get()` in
36+
# full), in exchange for guaranteed freshness rather than a cheap-but-stale
37+
# response.
38+
if _is_include_scheduled_requested(request):
39+
return None
2140
updated_at: Optional[datetime] = request.environment.updated_at
2241
return updated_at
2342

@@ -31,6 +50,22 @@ def get_authenticators(self): # type: ignore[no-untyped-def]
3150
return [EnvironmentKeyAuthentication(required_key_prefix="ser.")]
3251

3352
@extend_schema(
53+
parameters=[
54+
OpenApiParameter(
55+
name="include_scheduled",
56+
type=bool,
57+
required=False,
58+
description=(
59+
"Opt-in. When true, each feature state in "
60+
"the response carries an additional `scheduled_change` "
61+
"field describing its next not-yet-live version, if any. "
62+
"Defaults to false, in which case the response shape is "
63+
"unchanged. Not every scheduled-change shape is surfaced "
64+
"— see the 'Known limitations' note on "
65+
"`map_environment_to_engine` in `util/mappers/engine.py`."
66+
),
67+
),
68+
],
3469
responses={200: V1EnvironmentDocumentResponse},
3570
operation_id="sdk_v1_environment_document",
3671
)
@@ -42,6 +77,7 @@ def get(self, request: Request) -> Response:
4277
"""
4378
environment_document = Environment.get_environment_document(
4479
request.environment.api_key,
80+
include_scheduled=_is_include_scheduled_requested(request),
4581
)
4682
updated_at = self.request.environment.updated_at
4783
return Response(

0 commit comments

Comments
 (0)