From 8275f6d3fa68b40bc993e55f2e44e493bd043420 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Mon, 29 Jun 2026 16:56:13 +0530 Subject: [PATCH] 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 5044345d880a..c0227b9ad712 100644 --- a/api/experimentation/services.py +++ b/api/experimentation/services.py @@ -56,12 +56,16 @@ ) 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 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 @@ -69,6 +73,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 @@ -574,14 +580,58 @@ def _sync_rollout_segment(experiment: Experiment, rollout_percentage: float) -> return segment +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( f"Cannot change the rollout of a {experiment.status} experiment." ) validate_rollout_spec(experiment, spec) + 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_flag( experiment.environment, experiment.feature, @@ -596,6 +646,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: @@ -612,16 +673,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 d10ae9bc0d92..48f6e7afeadb 100644 --- a/api/tests/unit/experimentation/test_services.py +++ b/api/tests/unit/experimentation/test_services.py @@ -1356,6 +1356,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],