Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 69 additions & 10 deletions api/experimentation/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
)
from experimentation.models import (
VALID_STATUS_TRANSITIONS,
Experiment,
ExperimentStatus,
MetricAggregation,
MetricDirection,
Expand All @@ -57,22 +58,28 @@
)
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,
)
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
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
from organisations.models import Organisation
from users.models import FFAdminUser

Expand Down Expand Up @@ -627,6 +634,44 @@ 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.
"""
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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mmh, it's a bit edgy so let's NIT it (and already existing).
The latest("id") would drift if ever there are unordered scheduled changes:

  • CR A scheduled in 30 minutes
  • CR B scheduled in 15 minutes
  • They both go live
    ==> We would get CR B instead of A

I honestly don't think it would happen in real life so if the fix is too complicated we can leave a comment and not block the PR

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 experiment.feature.multivariate_options.values_list(
"id", flat=True
)
],
),
)


def apply_experiment_rollout(experiment: Experiment, spec: RolloutSpec) -> None:
if experiment.status == ExperimentStatus.COMPLETED:
raise ValidationError(
Expand All @@ -635,7 +680,11 @@ def apply_experiment_rollout(experiment: Experiment, spec: RolloutSpec) -> None:
validate_rollout_spec(experiment, spec)
environment_id = experiment.environment_id
with transaction.atomic():
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)
_update_rollout_in_place(
experiment,
FlagChangeSet(
Expand All @@ -655,6 +704,19 @@ 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."""
if value.value is None:
return "", "string"
return (
str(value.value).lower() if value.type == BOOLEAN else str(value.value),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be return a "None" for null values no ?

_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:
Expand All @@ -671,16 +733,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,
Expand Down
130 changes: 130 additions & 0 deletions api/tests/unit/experimentation/test_experiment_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1926,6 +1927,135 @@ 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__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,
Expand Down
Loading
Loading