Skip to content

Commit 772b9cb

Browse files
authored
fix(experimentation): keep variant assignment stable across rollout updates (#7912)
1 parent 48ff59c commit 772b9cb

4 files changed

Lines changed: 138 additions & 18 deletions

File tree

api/experimentation/services.py

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from audit.models import AuditLog
1818
from audit.related_object_type import RelatedObjectType
1919
from core.dataclasses import AuthorData
20+
from environments.tasks import rebuild_environment_document
2021
from experimentation.constants import (
2122
CONTROL_VARIANT_KEY,
2223
EXPERIMENT_FLAG,
@@ -57,7 +58,10 @@
5758
from features.models import FeatureState
5859
from features.value_types import BOOLEAN, INTEGER, STRING
5960
from features.versioning.dataclasses import FlagChangeSet
60-
from features.versioning.versioning_service import update_flag
61+
from features.versioning.versioning_service import (
62+
update_flag,
63+
update_multivariate_values,
64+
)
6165
from integrations.flagsmith.client import get_openfeature_client
6266
from segments.models import Condition, Segment, SegmentRule
6367

@@ -574,6 +578,58 @@ def _sync_rollout_segment(experiment: Experiment, rollout_percentage: float) ->
574578
return segment
575579

576580

581+
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+
),
589+
feature_id=experiment.feature_id,
590+
)
591+
.order_by("-id")
592+
.first()
593+
)
594+
595+
596+
def _update_live_feature_state(
597+
feature_state: FeatureState, change_set: FlagChangeSet
598+
) -> None:
599+
feature_state.enabled = change_set.enabled
600+
feature_state.save()
601+
feature_state.feature_state_value.set_value(
602+
change_set.feature_state_value, change_set.type_
603+
)
604+
feature_state.feature_state_value.save()
605+
update_multivariate_values(feature_state, change_set.multivariate_values)
606+
607+
608+
def _update_rollout_in_place(experiment: Experiment, change_set: FlagChangeSet) -> None:
609+
"""Write the rollout-segment override, keeping variant assignment stable.
610+
611+
Under v2 versioning, ``update_flag`` clones the override into a fresh feature
612+
state on every call. Since the multivariate split is salted on the feature
613+
state id, that would re-randomise control/variant for already-enrolled
614+
identities on each rollout update. Once the override exists, mutate it in
615+
place instead and rebuild the environment document by hand (no version is
616+
published). Creating the override, and v1 versioning, still go through
617+
``update_flag``, which already reuses the feature state.
618+
619+
This is a temporary solution until we find a permanent fix for the
620+
underlying salting issue: https://github.com/Flagsmith/flagsmith/issues/7913
621+
"""
622+
if experiment.environment.use_v2_feature_versioning and (
623+
override := _get_live_rollout_override(experiment)
624+
):
625+
_update_live_feature_state(override, change_set)
626+
rebuild_environment_document.delay(
627+
kwargs={"environment_id": experiment.environment_id}
628+
)
629+
return
630+
update_flag(experiment.environment, experiment.feature, change_set)
631+
632+
577633
def apply_experiment_rollout(experiment: Experiment, spec: RolloutSpec) -> None:
578634
if experiment.status == ExperimentStatus.COMPLETED:
579635
raise ValidationError(
@@ -582,9 +638,8 @@ def apply_experiment_rollout(experiment: Experiment, spec: RolloutSpec) -> None:
582638
validate_rollout_spec(experiment, spec)
583639
with transaction.atomic():
584640
segment = _sync_rollout_segment(experiment, spec.rollout_percentage)
585-
update_flag(
586-
experiment.environment,
587-
experiment.feature,
641+
_update_rollout_in_place(
642+
experiment,
588643
FlagChangeSet(
589644
author=spec.author,
590645
enabled=spec.enabled,
@@ -638,9 +693,8 @@ def enable_experiment_rollout(experiment: Experiment, author: AuthorData) -> Non
638693
return
639694

640695
value = rollout["feature_state_value"]
641-
update_flag(
642-
experiment.environment,
643-
experiment.feature,
696+
_update_rollout_in_place(
697+
experiment,
644698
FlagChangeSet(
645699
author=author,
646700
enabled=True,

api/features/versioning/versioning_service.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ def _update_flag_for_versioning_v2(
188188
change_set.feature_state_value,
189189
change_set.type_,
190190
)
191-
_update_multivariate_values(target_feature_state, change_set.multivariate_values)
191+
update_multivariate_values(target_feature_state, change_set.multivariate_values)
192192

193193
if change_set.segment_id is not None and change_set.segment_priority is not None:
194194
_update_segment_priority(target_feature_state, change_set.segment_priority)
@@ -241,7 +241,7 @@ def _update_flag_for_versioning_v1(
241241
change_set.feature_state_value,
242242
change_set.type_,
243243
)
244-
_update_multivariate_values(target_feature_state, change_set.multivariate_values)
244+
update_multivariate_values(target_feature_state, change_set.multivariate_values)
245245

246246
if change_set.segment_id is not None and change_set.segment_priority is not None:
247247
_update_segment_priority(target_feature_state, change_set.segment_priority)
@@ -256,7 +256,7 @@ def _update_feature_state_value(
256256
fsv.save()
257257

258258

259-
def _update_multivariate_values(
259+
def update_multivariate_values(
260260
feature_state: FeatureState,
261261
values: list[MultivariateValueChangeSet] | None,
262262
) -> None:
@@ -374,7 +374,7 @@ def _update_flag_v2_for_versioning_v2(
374374
override.feature_state_value,
375375
override.type_,
376376
)
377-
_update_multivariate_values(segment_state, override.multivariate_values)
377+
update_multivariate_values(segment_state, override.multivariate_values)
378378

379379
if override.priority is not None:
380380
_update_segment_priority(segment_state, override.priority)
@@ -393,7 +393,7 @@ def _update_flag_v2_for_versioning_v2(
393393
override.feature_state_value,
394394
override.type_,
395395
)
396-
_update_multivariate_values(segment_state, override.multivariate_values)
396+
update_multivariate_values(segment_state, override.multivariate_values)
397397

398398
new_version.publish(
399399
published_by=change_set.author.user,
@@ -445,7 +445,7 @@ def _update_flag_v2_for_versioning_v1(
445445
override.feature_state_value,
446446
override.type_,
447447
)
448-
_update_multivariate_values(segment_state, override.multivariate_values)
448+
update_multivariate_values(segment_state, override.multivariate_values)
449449
else:
450450
assert len(segment_states) == 1
451451
segment_state = list(segment_states.values())[0]
@@ -457,7 +457,7 @@ def _update_flag_v2_for_versioning_v1(
457457
override.feature_state_value,
458458
override.type_,
459459
)
460-
_update_multivariate_values(segment_state, override.multivariate_values)
460+
update_multivariate_values(segment_state, override.multivariate_values)
461461

462462
if override.priority is not None:
463463
_update_segment_priority(segment_state, override.priority)

api/tests/unit/experimentation/test_services.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from datetime import datetime, timezone
33

44
import pytest
5+
from django.db.models import Q
56
from flag_engine.segments.constants import PERCENTAGE_SPLIT
67
from pytest_django.fixtures import SettingsWrapper
78
from pytest_mock import MockerFixture
@@ -1668,3 +1669,68 @@ def test_enable_experiment_rollout__no_rollout__no_op(
16681669

16691670
# Then nothing is written
16701671
update_flag.assert_not_called()
1672+
1673+
1674+
def test_apply_experiment_rollout__reapplied_under_v2__keeps_variant_assignment(
1675+
environment_v2_versioning: Environment,
1676+
multivariate_feature: Feature,
1677+
multivariate_options: list[MultivariateFeatureOption],
1678+
admin_user: FFAdminUser,
1679+
) -> None:
1680+
# Given a running experiment whose rollout splits two variants 50/50
1681+
option_a, option_b, _ = multivariate_options
1682+
experiment = Experiment.objects.create(
1683+
environment=environment_v2_versioning,
1684+
feature=multivariate_feature,
1685+
name="exp",
1686+
hypothesis="h",
1687+
status=ExperimentStatus.RUNNING,
1688+
)
1689+
spec = RolloutSpec(
1690+
enabled=True,
1691+
rollout_percentage=50.0,
1692+
feature_state_value="control",
1693+
value_type="string",
1694+
multivariate_values=[
1695+
MultivariateValueChangeSet(option_a.id, 50.0),
1696+
MultivariateValueChangeSet(option_b.id, 50.0),
1697+
],
1698+
author=AuthorData(user=admin_user),
1699+
)
1700+
identity_hash_keys = [f"identity-{i}" for i in range(50)]
1701+
1702+
def variant_assignment() -> dict[str, int]:
1703+
override = (
1704+
FeatureState.objects.get_live_feature_states(
1705+
environment=experiment.environment,
1706+
additional_filters=Q(
1707+
feature_segment__segment=experiment.rollout_segment,
1708+
identity__isnull=True,
1709+
),
1710+
feature_id=experiment.feature_id,
1711+
)
1712+
.prefetch_related(
1713+
"multivariate_feature_state_values__multivariate_feature_option"
1714+
)
1715+
.latest("id")
1716+
)
1717+
assignment: dict[str, int] = {}
1718+
for key in identity_hash_keys:
1719+
option = override.get_multivariate_feature_state_value(key)
1720+
# The 50/50 split allocates 100%, so every identity lands on an option.
1721+
assert isinstance(option, MultivariateFeatureOption)
1722+
assignment[key] = option.id
1723+
return assignment
1724+
1725+
# When the rollout is applied, then re-applied unchanged (e.g. tuned while
1726+
# the experiment is running)
1727+
services.apply_experiment_rollout(experiment, spec)
1728+
experiment.refresh_from_db()
1729+
before = variant_assignment()
1730+
1731+
services.apply_experiment_rollout(experiment, spec)
1732+
after = variant_assignment()
1733+
1734+
# Then every already-enrolled identity keeps the variant it was first
1735+
# assigned; tuning the rollout must not re-randomise the split.
1736+
assert before == after

docs/docs/deployment-self-hosting/observability/_events-catalogue.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -494,7 +494,7 @@ Attributes:
494494
### `warehouse.connection.connected`
495495

496496
Logged at `info` from:
497-
- `api/experimentation/services.py:684`
497+
- `api/experimentation/services.py:738`
498498

499499
Attributes:
500500
- `environment.id`
@@ -503,7 +503,7 @@ Attributes:
503503
### `warehouse.connection.test_event_sent`
504504

505505
Logged at `info` from:
506-
- `api/experimentation/services.py:664`
506+
- `api/experimentation/services.py:718`
507507

508508
Attributes:
509509
- `environment.id`
@@ -512,7 +512,7 @@ Attributes:
512512
### `warehouse.srm.overallocated`
513513

514514
Logged at `error` from:
515-
- `api/experimentation/services.py:392`
515+
- `api/experimentation/services.py:396`
516516

517517
Attributes:
518518
- `environment.id`
@@ -522,7 +522,7 @@ Attributes:
522522
### `warehouse.srm.unkeyed_variant`
523523

524524
Logged at `error` from:
525-
- `api/experimentation/services.py:378`
525+
- `api/experimentation/services.py:382`
526526

527527
Attributes:
528528
- `environment.id`

0 commit comments

Comments
 (0)