Repository navigation
fix(api): block multivariate option changes during live experiments #8917
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,9 @@ | ||
| from django.core.exceptions import ValidationError | ||
| from django.db.models import Q | ||
|
|
||
| from environments.models import Environment | ||
| from experimentation.feature_state_metadata import has_live_experiment | ||
| from features.constants import LIVE_EXPERIMENT_MV_OPTIONS_MESSAGE | ||
| from features.import_export.types import FeatureExportData | ||
| from features.models import Feature, FeatureSegment, FeatureState | ||
| from features.multivariate.models import MultivariateFeatureOption | ||
|
|
@@ -15,6 +18,8 @@ def overwrite_feature_for_environment( | |
| Apply a destructive feature import to a single environment without | ||
| affecting other environments' feature states or the Feature definition. | ||
| """ | ||
| if has_live_experiment(existing_feature.id): | ||
| raise ValidationError(LIVE_EXPERIMENT_MV_OPTIONS_MESSAGE) | ||
|
Comment on lines
+21
to
+22
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| FeatureSegment.objects.filter( | ||
| feature=existing_feature, environment=environment | ||
| ).delete() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| from datetime import datetime | ||
| from functools import cached_property | ||
| from typing import Any, Callable | ||
| from typing import Any, Callable, cast | ||
| from uuid import UUID | ||
|
|
||
| import django.core.exceptions | ||
|
|
@@ -12,7 +12,7 @@ | |
| FeatureStateValueSerializer, | ||
| ) | ||
| from common.projects.permissions import VIEW_PROJECT | ||
| from django.db import models | ||
| from django.db import models, transaction | ||
| from drf_spectacular.types import OpenApiTypes | ||
| from drf_spectacular.utils import extend_schema_field | ||
| from drf_writable_nested import ( # type: ignore[attr-defined] | ||
|
|
@@ -31,7 +31,9 @@ | |
| from evaluation.types import EvaluatedFeatureState | ||
| from experimentation.feature_state_metadata import ( | ||
| get_feature_state_metadata_builder, | ||
| has_live_experiment, | ||
| ) | ||
| from features.constants import LIVE_EXPERIMENT_MV_OPTIONS_MESSAGE | ||
| from integrations.vcs.services import dispatch_vcs_on_feature_state_changed | ||
| from metadata.serializers import MetadataSerializer, MetadataSerializerMixin | ||
| from projects.code_references.serializers import ( | ||
|
|
@@ -307,6 +309,22 @@ def validate_project_features_limit(self, project: Project) -> None: | |
| } | ||
| ) | ||
|
|
||
| def update(self, instance: Feature, validated_data: dict[str, Any]) -> Feature: | ||
| with transaction.atomic(): | ||
| Feature.objects.select_for_update().filter(pk=instance.pk).exists() | ||
| if ( | ||
| has_live_experiment(instance.id) | ||
| and "multivariate_options" in validated_data | ||
| ): | ||
| if self._changes_multivariate_options( | ||
| validated_data["multivariate_options"], instance | ||
| ): | ||
| raise serializers.ValidationError( | ||
| {"multivariate_options": LIVE_EXPERIMENT_MV_OPTIONS_MESSAGE} | ||
|
Comment on lines
+319
to
+323
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| ) | ||
| validated_data.pop("multivariate_options") | ||
| return super().update(instance, validated_data) # type: ignore[no-any-return] | ||
|
|
||
| def validate_multivariate_options(self, multivariate_options): # type: ignore[no-untyped-def] | ||
| if multivariate_options: | ||
| user = self.context["request"].user | ||
|
|
@@ -324,8 +342,44 @@ def validate_multivariate_options(self, multivariate_options): # type: ignore[n | |
| ) | ||
| if total_percentage_allocation > 100: | ||
| raise serializers.ValidationError("Invalid percentage allocation") | ||
| instance = cast(Feature | None, self.instance) | ||
| if ( | ||
| instance | ||
| and self._changes_multivariate_options(multivariate_options, instance) | ||
| and has_live_experiment(instance.id) | ||
| ): | ||
| raise serializers.ValidationError(LIVE_EXPERIMENT_MV_OPTIONS_MESSAGE) | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| return multivariate_options | ||
|
|
||
| def _changes_multivariate_options( | ||
| self, | ||
| multivariate_options: list[dict[str, Any]] | None, | ||
| instance: Feature | None = None, | ||
| ) -> bool: | ||
| instance = instance or cast(Feature | None, self.instance) | ||
| if instance is None: | ||
| return False | ||
| # The frontend resubmits the whole feature, so only a real change to | ||
| # writable option fields counts. Compare on content, not ids. | ||
| fields = ( | ||
| "type", | ||
| "integer_value", | ||
| "string_value", | ||
| "boolean_value", | ||
| "default_percentage_allocation", | ||
| ) | ||
| current = NestedMultivariateFeatureOptionSerializer( | ||
| instance.multivariate_options.all(), many=True | ||
| ).data | ||
|
|
||
| def signature(options) -> list[tuple[Any, ...]]: # type: ignore[no-untyped-def] | ||
| return sorted( | ||
| (tuple(option.get(field) for field in fields) for option in options), | ||
| key=repr, | ||
| ) | ||
|
|
||
| return signature(current) != signature(multivariate_options or []) | ||
|
|
||
| def validate_owners(self, owners: list[FFAdminUser]) -> list[FFAdminUser]: | ||
| project: Project = self.context["project"] | ||
| invalid_users = [ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: Flagsmith/flagsmith
Length of output: 3106
Length of output: 3972
Script executed:
Repository: Flagsmith/flagsmith
Length of output: 33851
Script executed:
Repository: Flagsmith/flagsmith
Length of output: 4823
Script executed:
Repository: Flagsmith/flagsmith
Length of output: 24983
Script executed:
Repository: Flagsmith/flagsmith
Length of output: 13459
Script executed:
Repository: Flagsmith/flagsmith
Length of output: 10425
Web query:
PostgreSQL foreign key update unchanged key skip referential integrity trigger RI_FKey_fk_upd_check_required select for key shareResult:
Script executed:
Repository: Flagsmith/flagsmith
Length of output: 2589
Script executed:
Repository: Flagsmith/flagsmith
Length of output: 3544
Lock the feature when resuming an experiment.
If an option update passes its check while an experiment is paused, a concurrent resume can commit before the update. The option change can then commit while the experiment is running. Acquire the feature lock for every transition to
RUNNING.