Skip to content

Commit d53e0d7

Browse files
authored
feat(experimentation): default to control when rollout segment is created (#7905)
1 parent 63d57af commit d53e0d7

4 files changed

Lines changed: 362 additions & 28 deletions

File tree

api/experimentation/services.py

Lines changed: 84 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
)
4343
from experimentation.models import (
4444
VALID_STATUS_TRANSITIONS,
45+
Experiment,
4546
ExperimentStatus,
4647
MetricAggregation,
4748
MetricDirection,
@@ -57,22 +58,29 @@
5758
)
5859
from features.models import FeatureState
5960
from features.value_types import BOOLEAN, INTEGER, STRING
60-
from features.versioning.dataclasses import FlagChangeSet
61+
from features.versioning.dataclasses import FlagChangeSet, MultivariateValueChangeSet
6162
from features.versioning.versioning_service import (
63+
get_environment_flags_list,
6264
update_flag,
6365
update_multivariate_values,
6466
)
6567
from integrations.flagsmith.client import get_openfeature_client
6668
from segments.models import Condition, Segment, SegmentRule
6769

68-
_ROLLOUT_VALUE_TYPE = {INTEGER: "integer", STRING: "string", BOOLEAN: "boolean"}
70+
_ROLLOUT_VALUE_TYPE: dict[str, "FeatureValueType"] = {
71+
INTEGER: "integer",
72+
STRING: "string",
73+
BOOLEAN: "boolean",
74+
}
6975

7076
if typing.TYPE_CHECKING:
7177
from collections.abc import Sequence
7278
from datetime import datetime
7379

74-
from experimentation.models import Experiment, Metric, WarehouseConnection
80+
from experimentation.models import Metric, WarehouseConnection
7581
from experimentation.types import ExposureGranularity
82+
from features.feature_states.models import FeatureValueType
83+
from features.models import FeatureStateValue
7684
from organisations.models import Organisation
7785
from users.models import FFAdminUser
7886

@@ -579,18 +587,15 @@ def _sync_rollout_segment(experiment: Experiment, rollout_percentage: float) ->
579587

580588

581589
def _get_live_rollout_override(experiment: Experiment) -> FeatureState | None:
582-
return (
583-
FeatureState.objects.get_live_feature_states(
584-
environment=experiment.environment,
585-
additional_filters=Q(
586-
feature_segment__segment_id=experiment.rollout_segment_id,
587-
identity__isnull=True,
588-
),
590+
flags = get_environment_flags_list(
591+
environment=experiment.environment,
592+
additional_filters=Q(
589593
feature_id=experiment.feature_id,
590-
)
591-
.order_by("-id")
592-
.first()
594+
feature_segment__segment_id=experiment.rollout_segment_id,
595+
identity__isnull=True,
596+
),
593597
)
598+
return flags[0] if flags else None
594599

595600

596601
def _update_live_feature_state(
@@ -627,15 +632,60 @@ def _update_rollout_in_place(experiment: Experiment, change_set: FlagChangeSet)
627632
update_flag(experiment.environment, experiment.feature, change_set)
628633

629634

635+
def _reset_default_allocations_to_control(
636+
experiment: Experiment, author: AuthorData
637+
) -> None:
638+
"""Zero every variant's allocation on the feature's environment-default
639+
feature state, leaving control (the unallocated remainder) at 100%.
640+
641+
Run once, when the rollout segment is first created: identities outside the
642+
rollout cohort should all receive control while the experiment runs.
643+
"""
644+
(default_state,) = get_environment_flags_list(
645+
environment=experiment.environment,
646+
additional_filters=Q(
647+
feature_id=experiment.feature_id,
648+
feature_segment__isnull=True,
649+
identity__isnull=True,
650+
),
651+
)
652+
str_value, value_type = _serialize_feature_state_value(
653+
default_state.feature_state_value
654+
)
655+
update_flag(
656+
experiment.environment,
657+
experiment.feature,
658+
FlagChangeSet(
659+
author=author,
660+
enabled=default_state.enabled,
661+
feature_state_value=str_value,
662+
type_=value_type,
663+
multivariate_values=[
664+
MultivariateValueChangeSet(
665+
multivariate_feature_option_id=option_id,
666+
percentage_allocation=0,
667+
)
668+
for option_id in experiment.feature.multivariate_options.values_list(
669+
"id", flat=True
670+
)
671+
],
672+
),
673+
)
674+
675+
630676
def apply_experiment_rollout(experiment: Experiment, spec: RolloutSpec) -> None:
631-
if experiment.status == ExperimentStatus.COMPLETED:
632-
raise ValidationError(
633-
f"Cannot change the rollout of a {experiment.status} experiment."
634-
)
635677
validate_rollout_spec(experiment, spec)
636678
environment_id = experiment.environment_id
637679
with transaction.atomic():
680+
experiment.refresh_from_db(from_queryset=Experiment.objects.select_for_update())
681+
if experiment.status == ExperimentStatus.COMPLETED:
682+
raise ValidationError(
683+
f"Cannot change the rollout of a {experiment.status} experiment."
684+
)
685+
is_first_rollout = experiment.rollout_segment_id is None
638686
segment = _sync_rollout_segment(experiment, spec.rollout_percentage)
687+
if is_first_rollout:
688+
_reset_default_allocations_to_control(experiment, spec.author)
639689
_update_rollout_in_place(
640690
experiment,
641691
FlagChangeSet(
@@ -655,6 +705,19 @@ def apply_experiment_rollout(experiment: Experiment, spec: RolloutSpec) -> None:
655705
)
656706

657707

708+
def _serialize_feature_state_value(
709+
value: FeatureStateValue,
710+
) -> tuple[str, FeatureValueType]:
711+
"""Render a stored feature state value as the (string, API type) pair that
712+
a `FlagChangeSet` expects."""
713+
if value.value is None:
714+
return "", "string"
715+
return (
716+
str(value.value).lower() if value.type == BOOLEAN else str(value.value),
717+
_ROLLOUT_VALUE_TYPE.get(value.type or STRING, "string"),
718+
)
719+
720+
658721
def get_experiment_rollout(experiment: Experiment) -> dict[str, typing.Any] | None:
659722
segment_id = experiment.rollout_segment_id
660723
if segment_id is None:
@@ -671,16 +734,13 @@ def get_experiment_rollout(experiment: Experiment) -> dict[str, typing.Any] | No
671734
condition = Condition.objects.get(
672735
rule__segment_id=segment_id, operator=PERCENTAGE_SPLIT
673736
)
674-
value = feature_state.feature_state_value
737+
str_value, value_type = _serialize_feature_state_value(
738+
feature_state.feature_state_value
739+
)
675740
return {
676741
"enabled": feature_state.enabled,
677742
"rollout_percentage": float(condition.value or 0),
678-
"feature_state_value": {
679-
"type": _ROLLOUT_VALUE_TYPE.get(value.type or STRING, "string"),
680-
"value": (
681-
str(value.value).lower() if value.type == BOOLEAN else str(value.value)
682-
),
683-
},
743+
"feature_state_value": {"type": value_type, "value": str_value},
684744
"multivariate_feature_state_values": [
685745
{
686746
"multivariate_feature_option": mv.multivariate_feature_option_id,

api/tests/unit/experimentation/test_experiment_views.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import pytest
88
from django.db import IntegrityError
9+
from django.db.models import Q
910
from django.urls import reverse
1011
from django.utils import timezone
1112
from freezegun import freeze_time
@@ -1926,6 +1927,135 @@ def test_post__with_experiment_rollout__creates_rollout(
19261927
assert experiment.rollout_segment.is_system_segment is True
19271928

19281929

1930+
def test_post__with_experiment_rollout__zeroes_default_allocations(
1931+
admin_client_new: APIClient,
1932+
environment: Environment,
1933+
multivariate_feature: Feature,
1934+
multivariate_options: list[MultivariateFeatureOption],
1935+
enable_features: EnableFeaturesFixture,
1936+
) -> None:
1937+
# Given
1938+
enable_features(EXPERIMENT_FLAG)
1939+
option_a, option_b, option_c = multivariate_options
1940+
1941+
# When
1942+
response = admin_client_new.post(
1943+
_list_url(environment),
1944+
data={
1945+
"feature": multivariate_feature.id,
1946+
"name": "Rollout experiment",
1947+
"hypothesis": "It will work",
1948+
"experiment_rollout": {
1949+
"enabled": True,
1950+
"rollout_percentage": 30,
1951+
"feature_state_value": {"type": "string", "value": "control"},
1952+
"multivariate_feature_state_values": [
1953+
{
1954+
"multivariate_feature_option": option_a.id,
1955+
"percentage_allocation": 60,
1956+
},
1957+
{
1958+
"multivariate_feature_option": option_b.id,
1959+
"percentage_allocation": 40,
1960+
},
1961+
],
1962+
},
1963+
},
1964+
format="json",
1965+
)
1966+
1967+
# Then
1968+
assert response.status_code == status.HTTP_201_CREATED
1969+
experiment = Experiment.objects.get(id=response.json()["id"])
1970+
env_default_state = FeatureState.objects.get(
1971+
feature=multivariate_feature,
1972+
environment=environment,
1973+
identity__isnull=True,
1974+
feature_segment__isnull=True,
1975+
)
1976+
default_allocations = {
1977+
mv.multivariate_feature_option_id: mv.percentage_allocation
1978+
for mv in env_default_state.multivariate_feature_state_values.all()
1979+
}
1980+
assert default_allocations == {option_a.id: 0, option_b.id: 0, option_c.id: 0}
1981+
1982+
# The rollout segment override keeps the experiment's own split.
1983+
override = FeatureState.objects.get(
1984+
feature=multivariate_feature,
1985+
environment=environment,
1986+
feature_segment__segment=experiment.rollout_segment,
1987+
)
1988+
override_allocations = {
1989+
mv.multivariate_feature_option_id: mv.percentage_allocation
1990+
for mv in override.multivariate_feature_state_values.all()
1991+
}
1992+
assert override_allocations == {option_a.id: 60.0, option_b.id: 40.0}
1993+
1994+
1995+
def test_post__with_experiment_rollout_v2_versioning__zeroes_default_allocations(
1996+
admin_client_new: APIClient,
1997+
environment_v2_versioning: Environment,
1998+
multivariate_feature: Feature,
1999+
multivariate_options: list[MultivariateFeatureOption],
2000+
enable_features: EnableFeaturesFixture,
2001+
) -> None:
2002+
# Given
2003+
enable_features(EXPERIMENT_FLAG)
2004+
option_a, option_b, option_c = multivariate_options
2005+
2006+
# When
2007+
response = admin_client_new.post(
2008+
_list_url(environment_v2_versioning),
2009+
data={
2010+
"feature": multivariate_feature.id,
2011+
"name": "Rollout experiment",
2012+
"hypothesis": "It will work",
2013+
"experiment_rollout": {
2014+
"enabled": True,
2015+
"rollout_percentage": 30,
2016+
"feature_state_value": {"type": "string", "value": "control"},
2017+
"multivariate_feature_state_values": [
2018+
{
2019+
"multivariate_feature_option": option_a.id,
2020+
"percentage_allocation": 60,
2021+
},
2022+
{
2023+
"multivariate_feature_option": option_b.id,
2024+
"percentage_allocation": 40,
2025+
},
2026+
],
2027+
},
2028+
},
2029+
format="json",
2030+
)
2031+
2032+
# Then the live environment default has its allocations zeroed
2033+
assert response.status_code == status.HTTP_201_CREATED
2034+
experiment = Experiment.objects.get(id=response.json()["id"])
2035+
env_default_state = FeatureState.objects.get_live_feature_states(
2036+
environment=environment_v2_versioning,
2037+
additional_filters=Q(feature_segment__isnull=True, identity__isnull=True),
2038+
feature_id=multivariate_feature.id,
2039+
).latest("id")
2040+
default_allocations = {
2041+
mv.multivariate_feature_option_id: mv.percentage_allocation
2042+
for mv in env_default_state.multivariate_feature_state_values.all()
2043+
}
2044+
assert default_allocations == {option_a.id: 0, option_b.id: 0, option_c.id: 0}
2045+
2046+
# and the live rollout segment override keeps the experiment's own split
2047+
override = FeatureState.objects.get_live_feature_states(
2048+
environment=environment_v2_versioning,
2049+
additional_filters=Q(feature_segment__segment=experiment.rollout_segment),
2050+
feature_id=multivariate_feature.id,
2051+
).latest("id")
2052+
override_allocations = {
2053+
mv.multivariate_feature_option_id: mv.percentage_allocation
2054+
for mv in override.multivariate_feature_state_values.all()
2055+
}
2056+
assert override_allocations == {option_a.id: 60.0, option_b.id: 40.0}
2057+
2058+
19292059
def test_post__rollout_allocations_exceed_100__returns_400(
19302060
admin_client_new: APIClient,
19312061
environment: Environment,

0 commit comments

Comments
 (0)