From cf19595292a602d6d38f810859f124a05ef99834 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Mon, 29 Jun 2026 16:56:13 +0530 Subject: [PATCH 01/11] feat(experimentation): default to control when rollout segment is created When a feature is first added to an experiment's rollout, zero every variant's allocation on the feature's environment-default feature state so control (the unallocated remainder) receives 100% of out-of-rollout traffic. The rollout segment override keeps the experiment's own split. This only runs when the rollout segment is first created; tuning the rollout of a running experiment leaves the default allocations alone. --- api/experimentation/services.py | 76 +++++++++++++-- .../experimentation/test_experiment_views.py | 65 +++++++++++++ .../unit/experimentation/test_services.py | 93 +++++++++++++++++++ 3 files changed, 225 insertions(+), 9 deletions(-) diff --git a/api/experimentation/services.py b/api/experimentation/services.py index abddebe6dc86..8d5df8b7574e 100644 --- a/api/experimentation/services.py +++ b/api/experimentation/services.py @@ -57,7 +57,7 @@ ) from features.models import FeatureState from features.value_types import BOOLEAN, INTEGER, STRING -from features.versioning.dataclasses import FlagChangeSet +from features.versioning.dataclasses import FlagChangeSet, MultivariateValueChangeSet from features.versioning.versioning_service import ( update_flag, update_multivariate_values, @@ -65,7 +65,11 @@ from integrations.flagsmith.client import get_openfeature_client from segments.models import Condition, Segment, SegmentRule -_ROLLOUT_VALUE_TYPE = {INTEGER: "integer", STRING: "string", BOOLEAN: "boolean"} +_ROLLOUT_VALUE_TYPE: dict[str, "FeatureValueType"] = { + INTEGER: "integer", + STRING: "string", + BOOLEAN: "boolean", +} if typing.TYPE_CHECKING: from collections.abc import Sequence @@ -73,6 +77,8 @@ from experimentation.models import Experiment, Metric, WarehouseConnection from experimentation.types import ExposureGranularity + from features.feature_states.models import FeatureValueType + from features.models import FeatureStateValue from organisations.models import Organisation from users.models import FFAdminUser @@ -627,6 +633,47 @@ def _update_rollout_in_place(experiment: Experiment, change_set: FlagChangeSet) update_flag(experiment.environment, experiment.feature, change_set) +def _reset_default_allocations_to_control( + experiment: Experiment, author: AuthorData +) -> None: + """Zero every variant's allocation on the feature's environment-default + feature state, leaving control (the unallocated remainder) at 100%. + + Run once, when the rollout segment is first created: identities outside the + rollout cohort should all receive control while the experiment runs. + """ + option_ids = list( + experiment.feature.multivariate_options.values_list("id", flat=True) + ) + if not option_ids: + return + default_state = FeatureState.objects.get_live_feature_states( + environment=experiment.environment, + additional_filters=Q(feature_segment__isnull=True, identity__isnull=True), + feature_id=experiment.feature_id, + ).latest("id") + str_value, value_type = _serialize_feature_state_value( + default_state.feature_state_value + ) + update_flag( + experiment.environment, + experiment.feature, + FlagChangeSet( + author=author, + enabled=default_state.enabled, + feature_state_value=str_value, + type_=value_type, + multivariate_values=[ + MultivariateValueChangeSet( + multivariate_feature_option_id=option_id, + percentage_allocation=0, + ) + for option_id in option_ids + ], + ), + ) + + def apply_experiment_rollout(experiment: Experiment, spec: RolloutSpec) -> None: if experiment.status == ExperimentStatus.COMPLETED: raise ValidationError( @@ -634,8 +681,11 @@ def apply_experiment_rollout(experiment: Experiment, spec: RolloutSpec) -> None: ) validate_rollout_spec(experiment, spec) environment_id = experiment.environment_id + is_first_rollout = experiment.rollout_segment_id is None with transaction.atomic(): segment = _sync_rollout_segment(experiment, spec.rollout_percentage) + if is_first_rollout: + _reset_default_allocations_to_control(experiment, spec.author) _update_rollout_in_place( experiment, FlagChangeSet( @@ -655,6 +705,17 @@ def apply_experiment_rollout(experiment: Experiment, spec: RolloutSpec) -> None: ) +def _serialize_feature_state_value( + value: FeatureStateValue, +) -> tuple[str, FeatureValueType]: + """Render a stored feature state value as the (string, API type) pair that + a `FlagChangeSet` expects.""" + return ( + str(value.value).lower() if value.type == BOOLEAN else str(value.value), + _ROLLOUT_VALUE_TYPE.get(value.type or STRING, "string"), + ) + + def get_experiment_rollout(experiment: Experiment) -> dict[str, typing.Any] | None: segment_id = experiment.rollout_segment_id if segment_id is None: @@ -671,16 +732,13 @@ def get_experiment_rollout(experiment: Experiment) -> dict[str, typing.Any] | No condition = Condition.objects.get( rule__segment_id=segment_id, operator=PERCENTAGE_SPLIT ) - value = feature_state.feature_state_value + str_value, value_type = _serialize_feature_state_value( + feature_state.feature_state_value + ) return { "enabled": feature_state.enabled, "rollout_percentage": float(condition.value or 0), - "feature_state_value": { - "type": _ROLLOUT_VALUE_TYPE.get(value.type or STRING, "string"), - "value": ( - str(value.value).lower() if value.type == BOOLEAN else str(value.value) - ), - }, + "feature_state_value": {"type": value_type, "value": str_value}, "multivariate_feature_state_values": [ { "multivariate_feature_option": mv.multivariate_feature_option_id, diff --git a/api/tests/unit/experimentation/test_experiment_views.py b/api/tests/unit/experimentation/test_experiment_views.py index 2b7ec0fe1b5d..ad82b10304d3 100644 --- a/api/tests/unit/experimentation/test_experiment_views.py +++ b/api/tests/unit/experimentation/test_experiment_views.py @@ -1926,6 +1926,71 @@ def test_post__with_experiment_rollout__creates_rollout( assert experiment.rollout_segment.is_system_segment is True +def test_post__with_experiment_rollout__zeroes_default_allocations( + admin_client_new: APIClient, + environment: Environment, + multivariate_feature: Feature, + multivariate_options: list[MultivariateFeatureOption], + enable_features: EnableFeaturesFixture, +) -> None: + # Given + enable_features(EXPERIMENT_FLAG) + option_a, option_b, option_c = multivariate_options + + # When + response = admin_client_new.post( + _list_url(environment), + data={ + "feature": multivariate_feature.id, + "name": "Rollout experiment", + "hypothesis": "It will work", + "experiment_rollout": { + "enabled": True, + "rollout_percentage": 30, + "feature_state_value": {"type": "string", "value": "control"}, + "multivariate_feature_state_values": [ + { + "multivariate_feature_option": option_a.id, + "percentage_allocation": 60, + }, + { + "multivariate_feature_option": option_b.id, + "percentage_allocation": 40, + }, + ], + }, + }, + format="json", + ) + + # Then + assert response.status_code == status.HTTP_201_CREATED + experiment = Experiment.objects.get(id=response.json()["id"]) + env_default_state = FeatureState.objects.get( + feature=multivariate_feature, + environment=environment, + identity__isnull=True, + feature_segment__isnull=True, + ) + default_allocations = { + mv.multivariate_feature_option_id: mv.percentage_allocation + for mv in env_default_state.multivariate_feature_state_values.all() + } + assert default_allocations == {option_a.id: 0, option_b.id: 0, option_c.id: 0} + + # The rollout segment override keeps the experiment's own split. + override = FeatureState.objects.get( + feature=multivariate_feature, + environment=environment, + feature_segment__segment=experiment.rollout_segment, + ) + override_allocations = { + mv.multivariate_feature_option_id: mv.percentage_allocation + for mv in override.multivariate_feature_state_values.all() + } + assert override_allocations == {option_a.id: 60.0, option_b.id: 40.0} + + def test_post__rollout_allocations_exceed_100__returns_400( admin_client_new: APIClient, environment: Environment, diff --git a/api/tests/unit/experimentation/test_services.py b/api/tests/unit/experimentation/test_services.py index 2f881ecbfc73..cfd6f735d393 100644 --- a/api/tests/unit/experimentation/test_services.py +++ b/api/tests/unit/experimentation/test_services.py @@ -1359,6 +1359,99 @@ def test_apply_experiment_rollout__no_segment__creates_segment_and_override( assert allocations == {option_a.id: 60.0, option_b.id: 40.0} +def test_apply_experiment_rollout__first_rollout__zeroes_default_allocations( + experiment: Experiment, + multivariate_options: list[MultivariateFeatureOption], + admin_user: FFAdminUser, +) -> None: + # Given + option_a, option_b, option_c = multivariate_options + + # When + services.apply_experiment_rollout( + experiment, + RolloutSpec( + enabled=True, + rollout_percentage=42.0, + feature_state_value="control", + value_type="string", + multivariate_values=[ + MultivariateValueChangeSet(option_a.id, 60.0), + MultivariateValueChangeSet(option_b.id, 40.0), + ], + author=AuthorData(user=admin_user), + ), + ) + + # Then + experiment.refresh_from_db() + default_state = FeatureState.objects.get( + environment=experiment.environment, + feature=experiment.feature, + feature_segment__isnull=True, + identity__isnull=True, + ) + default_allocations = { + mv.multivariate_feature_option_id: mv.percentage_allocation + for mv in default_state.multivariate_feature_state_values.all() + } + assert default_allocations == {option_a.id: 0, option_b.id: 0, option_c.id: 0} + + # The rollout segment override keeps the experiment's own split. + override = FeatureState.objects.get( + environment=experiment.environment, + feature=experiment.feature, + feature_segment__segment=experiment.rollout_segment, + ) + override_allocations = { + mv.multivariate_feature_option_id: mv.percentage_allocation + for mv in override.multivariate_feature_state_values.all() + } + assert override_allocations == {option_a.id: 60.0, option_b.id: 40.0} + + +def test_apply_experiment_rollout__existing_segment__leaves_default_allocations( + experiment_with_rollout: Experiment, + multivariate_options: list[MultivariateFeatureOption], + admin_user: FFAdminUser, +) -> None: + # Given + experiment = experiment_with_rollout + option_a, option_b, _ = multivariate_options + default_state = FeatureState.objects.get( + environment=experiment.environment, + feature=experiment.feature, + feature_segment__isnull=True, + identity__isnull=True, + ) + # A later manual edit to the default allocations must survive a rollout update. + default_state.multivariate_feature_state_values.filter( + multivariate_feature_option=option_a + ).update(percentage_allocation=25.0) + + # When + services.apply_experiment_rollout( + experiment, + RolloutSpec( + enabled=True, + rollout_percentage=80.0, + feature_state_value="control", + value_type="string", + multivariate_values=[ + MultivariateValueChangeSet(option_a.id, 50.0), + MultivariateValueChangeSet(option_b.id, 50.0), + ], + author=AuthorData(user=admin_user), + ), + ) + + # Then + allocation = default_state.multivariate_feature_state_values.get( + multivariate_feature_option=option_a + ).percentage_allocation + assert allocation == 25.0 + + def test_apply_experiment_rollout__existing_segment__updates_percentage_and_enabled( experiment_with_rollout: Experiment, multivariate_options: list[MultivariateFeatureOption], From 792379301cddd5ae3c0394269ea44b40b236e5a7 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Wed, 15 Jul 2026 12:53:03 +0530 Subject: [PATCH 02/11] test(experimentation): cover default allocation zeroing under v2 versioning --- .../experimentation/test_experiment_views.py | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/api/tests/unit/experimentation/test_experiment_views.py b/api/tests/unit/experimentation/test_experiment_views.py index ad82b10304d3..a27762c29823 100644 --- a/api/tests/unit/experimentation/test_experiment_views.py +++ b/api/tests/unit/experimentation/test_experiment_views.py @@ -6,6 +6,7 @@ import pytest from django.db import IntegrityError +from django.db.models import Q from django.urls import reverse from django.utils import timezone from freezegun import freeze_time @@ -1991,6 +1992,70 @@ def test_post__with_experiment_rollout__zeroes_default_allocations( assert override_allocations == {option_a.id: 60.0, option_b.id: 40.0} +def test_post__with_experiment_rollout_v2_versioning__zeroes_default_allocations( + admin_client_new: APIClient, + environment_v2_versioning: Environment, + multivariate_feature: Feature, + multivariate_options: list[MultivariateFeatureOption], + enable_features: EnableFeaturesFixture, +) -> None: + # Given + enable_features(EXPERIMENT_FLAG) + option_a, option_b, option_c = multivariate_options + + # When + response = admin_client_new.post( + _list_url(environment_v2_versioning), + data={ + "feature": multivariate_feature.id, + "name": "Rollout experiment", + "hypothesis": "It will work", + "experiment_rollout": { + "enabled": True, + "rollout_percentage": 30, + "feature_state_value": {"type": "string", "value": "control"}, + "multivariate_feature_state_values": [ + { + "multivariate_feature_option": option_a.id, + "percentage_allocation": 60, + }, + { + "multivariate_feature_option": option_b.id, + "percentage_allocation": 40, + }, + ], + }, + }, + format="json", + ) + + # Then the live environment default has its allocations zeroed + assert response.status_code == status.HTTP_201_CREATED + experiment = Experiment.objects.get(id=response.json()["id"]) + env_default_state = FeatureState.objects.get_live_feature_states( + environment=environment_v2_versioning, + additional_filters=Q(feature_segment__isnull=True, identity__isnull=True), + feature_id=multivariate_feature.id, + ).latest("id") + default_allocations = { + mv.multivariate_feature_option_id: mv.percentage_allocation + for mv in env_default_state.multivariate_feature_state_values.all() + } + assert default_allocations == {option_a.id: 0, option_b.id: 0, option_c.id: 0} + + # and the live rollout segment override keeps the experiment's own split + override = FeatureState.objects.get_live_feature_states( + environment=environment_v2_versioning, + additional_filters=Q(feature_segment__segment=experiment.rollout_segment), + feature_id=multivariate_feature.id, + ).latest("id") + override_allocations = { + mv.multivariate_feature_option_id: mv.percentage_allocation + for mv in override.multivariate_feature_state_values.all() + } + assert override_allocations == {option_a.id: 60.0, option_b.id: 40.0} + + def test_post__rollout_allocations_exceed_100__returns_400( admin_client_new: APIClient, environment: Environment, From d5d7fe5b150ede87ebcad32b90030802f34c4a92 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Wed, 15 Jul 2026 12:57:40 +0530 Subject: [PATCH 03/11] docs: regenerate events catalogue --- .../observability/_events-catalogue.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 21ceb55de3a7..f31c38d9a049 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -532,7 +532,7 @@ Attributes: ### `warehouse.connection.connected` Logged at `info` from: - - `api/experimentation/services.py:742` + - `api/experimentation/services.py:800` Attributes: - `environment.id` @@ -541,7 +541,7 @@ Attributes: ### `warehouse.connection.test_event_sent` Logged at `info` from: - - `api/experimentation/services.py:722` + - `api/experimentation/services.py:780` Attributes: - `environment.id` @@ -550,7 +550,7 @@ Attributes: ### `warehouse.srm.overallocated` Logged at `error` from: - - `api/experimentation/services.py:396` + - `api/experimentation/services.py:402` Attributes: - `environment.id` @@ -560,7 +560,7 @@ Attributes: ### `warehouse.srm.unkeyed_variant` Logged at `error` from: - - `api/experimentation/services.py:382` + - `api/experimentation/services.py:388` Attributes: - `environment.id` From 68d71f9e8a0aabac4249a4406a9df4bf12709b2f Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Wed, 15 Jul 2026 14:23:26 +0530 Subject: [PATCH 04/11] fix(experimentation): drop unreachable no-options guard in allocation reset --- api/experimentation/services.py | 9 +++------ .../observability/_events-catalogue.md | 4 ++-- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/api/experimentation/services.py b/api/experimentation/services.py index 8d5df8b7574e..254154cb09c7 100644 --- a/api/experimentation/services.py +++ b/api/experimentation/services.py @@ -642,11 +642,6 @@ def _reset_default_allocations_to_control( Run once, when the rollout segment is first created: identities outside the rollout cohort should all receive control while the experiment runs. """ - option_ids = list( - experiment.feature.multivariate_options.values_list("id", flat=True) - ) - if not option_ids: - return default_state = FeatureState.objects.get_live_feature_states( environment=experiment.environment, additional_filters=Q(feature_segment__isnull=True, identity__isnull=True), @@ -668,7 +663,9 @@ def _reset_default_allocations_to_control( multivariate_feature_option_id=option_id, percentage_allocation=0, ) - for option_id in option_ids + for option_id in experiment.feature.multivariate_options.values_list( + "id", flat=True + ) ], ), ) diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index f31c38d9a049..c731786d75d0 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -532,7 +532,7 @@ Attributes: ### `warehouse.connection.connected` Logged at `info` from: - - `api/experimentation/services.py:800` + - `api/experimentation/services.py:797` Attributes: - `environment.id` @@ -541,7 +541,7 @@ Attributes: ### `warehouse.connection.test_event_sent` Logged at `info` from: - - `api/experimentation/services.py:780` + - `api/experimentation/services.py:777` Attributes: - `environment.id` From 501a0427c10acb72512f2ac215cd9f07ed1e1117 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Wed, 15 Jul 2026 15:00:23 +0530 Subject: [PATCH 05/11] test(experimentation): assert rollout update applies alongside surviving default edit --- api/tests/unit/experimentation/test_services.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/api/tests/unit/experimentation/test_services.py b/api/tests/unit/experimentation/test_services.py index cfd6f735d393..0fc82327005c 100644 --- a/api/tests/unit/experimentation/test_services.py +++ b/api/tests/unit/experimentation/test_services.py @@ -1445,12 +1445,26 @@ def test_apply_experiment_rollout__existing_segment__leaves_default_allocations( ), ) - # Then + # Then the manual edit to the default allocations survives... allocation = default_state.multivariate_feature_state_values.get( multivariate_feature_option=option_a ).percentage_allocation assert allocation == 25.0 + # ...while the rollout update itself is applied + condition = Condition.objects.get(rule__segment=experiment.rollout_segment) + assert condition.value == "80.0" + override = FeatureState.objects.get( + environment=experiment.environment, + feature=experiment.feature, + feature_segment__segment=experiment.rollout_segment, + ) + override_allocations = { + mv.multivariate_feature_option_id: mv.percentage_allocation + for mv in override.multivariate_feature_state_values.all() + } + assert override_allocations == {option_a.id: 50.0, option_b.id: 50.0} + def test_apply_experiment_rollout__existing_segment__updates_percentage_and_enabled( experiment_with_rollout: Experiment, From a5d3aa69b89ba129c01c78341b6c0bf29bcb588f Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Wed, 15 Jul 2026 15:30:21 +0530 Subject: [PATCH 06/11] fix(experimentation): lock experiment row to prevent duplicate rollout segments --- api/experimentation/services.py | 6 ++++-- .../observability/_events-catalogue.md | 8 ++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/api/experimentation/services.py b/api/experimentation/services.py index 254154cb09c7..f5fef5a626f2 100644 --- a/api/experimentation/services.py +++ b/api/experimentation/services.py @@ -42,6 +42,7 @@ ) from experimentation.models import ( VALID_STATUS_TRANSITIONS, + Experiment, ExperimentStatus, MetricAggregation, MetricDirection, @@ -75,7 +76,7 @@ from collections.abc import Sequence from datetime import datetime - from experimentation.models import Experiment, Metric, WarehouseConnection + from experimentation.models import Metric, WarehouseConnection from experimentation.types import ExposureGranularity from features.feature_states.models import FeatureValueType from features.models import FeatureStateValue @@ -678,8 +679,9 @@ def apply_experiment_rollout(experiment: Experiment, spec: RolloutSpec) -> None: ) validate_rollout_spec(experiment, spec) environment_id = experiment.environment_id - is_first_rollout = experiment.rollout_segment_id is None with transaction.atomic(): + locked_experiment = Experiment.objects.select_for_update().get(pk=experiment.pk) + is_first_rollout = locked_experiment.rollout_segment_id is None segment = _sync_rollout_segment(experiment, spec.rollout_percentage) if is_first_rollout: _reset_default_allocations_to_control(experiment, spec.author) diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index c731786d75d0..2360c654a8a0 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -532,7 +532,7 @@ Attributes: ### `warehouse.connection.connected` Logged at `info` from: - - `api/experimentation/services.py:797` + - `api/experimentation/services.py:799` Attributes: - `environment.id` @@ -541,7 +541,7 @@ Attributes: ### `warehouse.connection.test_event_sent` Logged at `info` from: - - `api/experimentation/services.py:777` + - `api/experimentation/services.py:779` Attributes: - `environment.id` @@ -550,7 +550,7 @@ Attributes: ### `warehouse.srm.overallocated` Logged at `error` from: - - `api/experimentation/services.py:402` + - `api/experimentation/services.py:403` Attributes: - `environment.id` @@ -560,7 +560,7 @@ Attributes: ### `warehouse.srm.unkeyed_variant` Logged at `error` from: - - `api/experimentation/services.py:388` + - `api/experimentation/services.py:389` Attributes: - `environment.id` From f14a64e3ed48de755561587ddbb86c5a0339e516 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Wed, 15 Jul 2026 15:41:03 +0530 Subject: [PATCH 07/11] fix(experimentation): read rollout state from the locked experiment row --- api/experimentation/services.py | 4 ++-- .../observability/_events-catalogue.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/api/experimentation/services.py b/api/experimentation/services.py index f5fef5a626f2..04b96543be58 100644 --- a/api/experimentation/services.py +++ b/api/experimentation/services.py @@ -680,8 +680,8 @@ def apply_experiment_rollout(experiment: Experiment, spec: RolloutSpec) -> None: validate_rollout_spec(experiment, spec) environment_id = experiment.environment_id with transaction.atomic(): - locked_experiment = Experiment.objects.select_for_update().get(pk=experiment.pk) - is_first_rollout = locked_experiment.rollout_segment_id is None + experiment.refresh_from_db(from_queryset=Experiment.objects.select_for_update()) + is_first_rollout = experiment.rollout_segment_id is None segment = _sync_rollout_segment(experiment, spec.rollout_percentage) if is_first_rollout: _reset_default_allocations_to_control(experiment, spec.author) diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 2360c654a8a0..5ad9c8d4889e 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -532,7 +532,7 @@ Attributes: ### `warehouse.connection.connected` Logged at `info` from: - - `api/experimentation/services.py:799` + - `api/experimentation/services.py:801` Attributes: - `environment.id` @@ -541,7 +541,7 @@ Attributes: ### `warehouse.connection.test_event_sent` Logged at `info` from: - - `api/experimentation/services.py:779` + - `api/experimentation/services.py:781` Attributes: - `environment.id` From 166b3a10fa89de92603a0808b3ac8a47eddbb70b Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Wed, 15 Jul 2026 15:42:33 +0530 Subject: [PATCH 08/11] docs: regenerate events catalogue --- .../observability/_events-catalogue.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 5ad9c8d4889e..2360c654a8a0 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -532,7 +532,7 @@ Attributes: ### `warehouse.connection.connected` Logged at `info` from: - - `api/experimentation/services.py:801` + - `api/experimentation/services.py:799` Attributes: - `environment.id` @@ -541,7 +541,7 @@ Attributes: ### `warehouse.connection.test_event_sent` Logged at `info` from: - - `api/experimentation/services.py:781` + - `api/experimentation/services.py:779` Attributes: - `environment.id` From 6417f9d52989e9042b7bfcc8a5382b9ed1cc1b1f Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Thu, 16 Jul 2026 12:00:08 +0530 Subject: [PATCH 09/11] fix(experimentation): serialise null default value as empty string --- api/experimentation/services.py | 2 + .../unit/experimentation/test_services.py | 37 +++++++++++++++++++ .../observability/_events-catalogue.md | 4 +- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/api/experimentation/services.py b/api/experimentation/services.py index 04b96543be58..b6a1551f02a0 100644 --- a/api/experimentation/services.py +++ b/api/experimentation/services.py @@ -709,6 +709,8 @@ def _serialize_feature_state_value( ) -> tuple[str, FeatureValueType]: """Render a stored feature state value as the (string, API type) pair that a `FlagChangeSet` expects.""" + if value.value is None: + return "", "string" return ( str(value.value).lower() if value.type == BOOLEAN else str(value.value), _ROLLOUT_VALUE_TYPE.get(value.type or STRING, "string"), diff --git a/api/tests/unit/experimentation/test_services.py b/api/tests/unit/experimentation/test_services.py index 0fc82327005c..804de33159d3 100644 --- a/api/tests/unit/experimentation/test_services.py +++ b/api/tests/unit/experimentation/test_services.py @@ -1359,6 +1359,43 @@ def test_apply_experiment_rollout__no_segment__creates_segment_and_override( assert allocations == {option_a.id: 60.0, option_b.id: 40.0} +def test_apply_experiment_rollout__null_default_value__serialised_as_empty_string( + experiment: Experiment, + multivariate_options: list[MultivariateFeatureOption], + admin_user: FFAdminUser, +) -> None: + # Given the environment default has no value stored + option_a, option_b, _ = multivariate_options + default_state = FeatureState.objects.get( + environment=experiment.environment, + feature=experiment.feature, + feature_segment__isnull=True, + identity__isnull=True, + ) + default_state.feature_state_value.string_value = None + default_state.feature_state_value.save() + + # When + services.apply_experiment_rollout( + experiment, + RolloutSpec( + enabled=True, + rollout_percentage=42.0, + feature_state_value="control", + value_type="string", + multivariate_values=[ + MultivariateValueChangeSet(option_a.id, 60.0), + MultivariateValueChangeSet(option_b.id, 40.0), + ], + author=AuthorData(user=admin_user), + ), + ) + + # Then the default value is not rewritten as the string "None" + default_state.refresh_from_db() + assert default_state.get_feature_state_value() == "" + + def test_apply_experiment_rollout__first_rollout__zeroes_default_allocations( experiment: Experiment, multivariate_options: list[MultivariateFeatureOption], diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 2360c654a8a0..5ad9c8d4889e 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -532,7 +532,7 @@ Attributes: ### `warehouse.connection.connected` Logged at `info` from: - - `api/experimentation/services.py:799` + - `api/experimentation/services.py:801` Attributes: - `environment.id` @@ -541,7 +541,7 @@ Attributes: ### `warehouse.connection.test_event_sent` Logged at `info` from: - - `api/experimentation/services.py:779` + - `api/experimentation/services.py:781` Attributes: - `environment.id` From bf5b04493efe1df8d8ef6aac4efda6389813b53a Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Thu, 16 Jul 2026 12:08:26 +0530 Subject: [PATCH 10/11] fix(experimentation): resolve live feature states canonically instead of by id --- api/experimentation/services.py | 29 ++++++++++--------- .../observability/_events-catalogue.md | 8 ++--- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/api/experimentation/services.py b/api/experimentation/services.py index b6a1551f02a0..af07aaf89186 100644 --- a/api/experimentation/services.py +++ b/api/experimentation/services.py @@ -60,6 +60,7 @@ from features.value_types import BOOLEAN, INTEGER, STRING from features.versioning.dataclasses import FlagChangeSet, MultivariateValueChangeSet from features.versioning.versioning_service import ( + get_environment_flags_list, update_flag, update_multivariate_values, ) @@ -586,18 +587,15 @@ def _sync_rollout_segment(experiment: Experiment, rollout_percentage: float) -> def _get_live_rollout_override(experiment: Experiment) -> FeatureState | None: - return ( - FeatureState.objects.get_live_feature_states( - environment=experiment.environment, - additional_filters=Q( - feature_segment__segment_id=experiment.rollout_segment_id, - identity__isnull=True, - ), + flags = get_environment_flags_list( + environment=experiment.environment, + additional_filters=Q( feature_id=experiment.feature_id, - ) - .order_by("-id") - .first() + feature_segment__segment_id=experiment.rollout_segment_id, + identity__isnull=True, + ), ) + return flags[0] if flags else None def _update_live_feature_state( @@ -643,11 +641,14 @@ def _reset_default_allocations_to_control( Run once, when the rollout segment is first created: identities outside the rollout cohort should all receive control while the experiment runs. """ - default_state = FeatureState.objects.get_live_feature_states( + (default_state,) = get_environment_flags_list( environment=experiment.environment, - additional_filters=Q(feature_segment__isnull=True, identity__isnull=True), - feature_id=experiment.feature_id, - ).latest("id") + additional_filters=Q( + feature_id=experiment.feature_id, + feature_segment__isnull=True, + identity__isnull=True, + ), + ) str_value, value_type = _serialize_feature_state_value( default_state.feature_state_value ) diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 5ad9c8d4889e..398503624dd4 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -532,7 +532,7 @@ Attributes: ### `warehouse.connection.connected` Logged at `info` from: - - `api/experimentation/services.py:801` + - `api/experimentation/services.py:802` Attributes: - `environment.id` @@ -541,7 +541,7 @@ Attributes: ### `warehouse.connection.test_event_sent` Logged at `info` from: - - `api/experimentation/services.py:781` + - `api/experimentation/services.py:782` Attributes: - `environment.id` @@ -550,7 +550,7 @@ Attributes: ### `warehouse.srm.overallocated` Logged at `error` from: - - `api/experimentation/services.py:403` + - `api/experimentation/services.py:404` Attributes: - `environment.id` @@ -560,7 +560,7 @@ Attributes: ### `warehouse.srm.unkeyed_variant` Logged at `error` from: - - `api/experimentation/services.py:389` + - `api/experimentation/services.py:390` Attributes: - `environment.id` From 93069f0fcab82aa3b3357b3a52e5b2c4481d19e5 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Thu, 16 Jul 2026 12:11:39 +0530 Subject: [PATCH 11/11] fix(experimentation): check experiment status under the rollout row lock --- api/experimentation/services.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api/experimentation/services.py b/api/experimentation/services.py index af07aaf89186..d1cd4935c44c 100644 --- a/api/experimentation/services.py +++ b/api/experimentation/services.py @@ -674,14 +674,14 @@ def _reset_default_allocations_to_control( def apply_experiment_rollout(experiment: Experiment, spec: RolloutSpec) -> None: - if experiment.status == ExperimentStatus.COMPLETED: - raise ValidationError( - f"Cannot change the rollout of a {experiment.status} experiment." - ) validate_rollout_spec(experiment, spec) environment_id = experiment.environment_id with transaction.atomic(): experiment.refresh_from_db(from_queryset=Experiment.objects.select_for_update()) + if experiment.status == ExperimentStatus.COMPLETED: + raise ValidationError( + f"Cannot change the rollout of a {experiment.status} experiment." + ) is_first_rollout = experiment.rollout_segment_id is None segment = _sync_rollout_segment(experiment, spec.rollout_percentage) if is_first_rollout: