From 8a854549745bf3450541260a5855f22263fa6650 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Tue, 30 Jun 2026 14:34:11 +0530 Subject: [PATCH 1/9] fix(features): preserve multivariate bucketing salt across feature state recreation Multivariate variant bucketing is salted on the feature state id, so any flow that recreates a feature state (publishing a new v2 version, editing multivariate weights) changed the salt and re-bucketed already-enrolled identities. Add an mv_hashing_salt field that get_multivariate_feature_state_value seeds on (falling back to the id), preserved across clone() so recreation keeps the original seed. The engine mapper feeds the salt through django_id, so edge and local evaluation stay stable without an engine document schema change. Also add a BEFORE_CREATE guard that raises when a v2 feature state is recreated outside clone() (the previous version already had it but the new row carries no salt), to catch future regressions in tests. Closes #7913 --- .../0067_add_feature_state_mv_hashing_salt.py | 23 +++ api/features/models.py | 57 +++++- .../features/test_unit_features_models.py | 185 ++++++++++++++++++ .../util/mappers/test_unit_mappers_engine.py | 25 ++- api/util/mappers/engine.py | 7 +- 5 files changed, 291 insertions(+), 6 deletions(-) create mode 100644 api/features/migrations/0067_add_feature_state_mv_hashing_salt.py diff --git a/api/features/migrations/0067_add_feature_state_mv_hashing_salt.py b/api/features/migrations/0067_add_feature_state_mv_hashing_salt.py new file mode 100644 index 000000000000..8e9a19144aeb --- /dev/null +++ b/api/features/migrations/0067_add_feature_state_mv_hashing_salt.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.15 on 2026-06-30 07:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("features", "0066_constrain_feature_type"), + ] + + operations = [ + migrations.AddField( + model_name="featurestate", + name="mv_hashing_salt", + field=models.IntegerField(blank=True, default=None, null=True), + ), + migrations.AddField( + model_name="historicalfeaturestate", + name="mv_hashing_salt", + field=models.IntegerField(blank=True, default=None, null=True), + ), + ] diff --git a/api/features/models.py b/api/features/models.py index e30a6bbb9fda..2733d358e9ab 100644 --- a/api/features/models.py +++ b/api/features/models.py @@ -522,6 +522,13 @@ class FeatureState( # to be deprecated! version = models.IntegerField(default=1, null=True) + # Seed for multivariate variant bucketing. Defaults to the feature state's own + # id (see get_multivariate_feature_state_value), but is preserved across clones + # so that recreating a feature state (e.g. publishing a new version or editing + # multivariate weights under v2 versioning) does not re-bucket already-enrolled + # identities. See https://github.com/Flagsmith/flagsmith/issues/7913. + mv_hashing_salt = models.IntegerField(null=True, blank=True, default=None) + class Meta: ordering = ["id"] @@ -669,6 +676,10 @@ def clone( clone = deepcopy(self) clone.id = None clone.uuid = uuid.uuid4() + # Preserve the multivariate bucketing seed so that recreating this feature + # state does not re-bucket already-enrolled identities. If the source never + # had an explicit salt, capture its id (the seed used until now). + clone.mv_hashing_salt = self.mv_hashing_salt or self.id if self.feature_segment: # We can only create a new feature segment if we are cloning to another environment, @@ -769,7 +780,7 @@ def get_multivariate_feature_state_value( mv_options = list(self.multivariate_feature_state_values.all()) percentage_value = get_hashed_percentage_for_object_ids( - [self.id, identity_hash_key] + [self.mv_hashing_salt or self.id, identity_hash_key] ) # Iterate over the mv options in order of id (so we get the same value each @@ -818,6 +829,50 @@ def check_for_duplicate_feature_state(self): # type: ignore[no-untyped-def] "version, segment & identity combination" ) + @hook(BEFORE_CREATE) + def check_mv_hashing_salt_preserved(self): # type: ignore[no-untyped-def] + """Fail if a feature state is recreated without keeping its bucketing salt. + + Under v2 versioning a feature state must be recreated via clone(), which + copies mv_hashing_salt so multivariate variant assignment stays stable for + enrolled identities. clone() always sets a non-null salt, so a freshly + created state with no salt that the previous version already had was made + some other way and would re-bucket those identities. Raise so the + offending path is caught in tests. Identity overrides are skipped: they + are not versioned or cloned. See + https://github.com/Flagsmith/flagsmith/issues/7913. + """ + if ( + self.mv_hashing_salt is not None + or self.identity_id is not None + or not self.environment.use_v2_feature_versioning # type: ignore[union-attr] + ): + return + + previous_version = ( + self.environment_feature_version + and self.environment_feature_version.get_previous_version() + ) + if previous_version is None: + return + + # A segment override's FeatureSegment row is itself cloned per version, so + # match the override by its segment, not by the FeatureSegment row. + if self.feature_segment_id is None: + already_existed = Q(feature_segment__isnull=True) + else: + already_existed = Q( + feature_segment__segment_id=self.feature_segment.segment_id # type: ignore[union-attr] + ) + + if previous_version.feature_states.filter(already_existed).exists(): + raise ValidationError( + "A feature state for this environment, feature and segment existed " + "in the previous version; recreate it via FeatureState.clone() so " + "the mv_hashing_salt is kept and multivariate variant assignment " + "stays stable. See https://github.com/Flagsmith/flagsmith/issues/7913." + ) + @hook(BEFORE_CREATE) def set_live_from(self): # type: ignore[no-untyped-def] """ diff --git a/api/tests/unit/features/test_unit_features_models.py b/api/tests/unit/features/test_unit_features_models.py index f755d05e7041..5002b3498aa4 100644 --- a/api/tests/unit/features/test_unit_features_models.py +++ b/api/tests/unit/features/test_unit_features_models.py @@ -643,6 +643,191 @@ def test_get_multivariate_feature_state_value__with_identity__returns_correct_va assert multivariate_value.value != multivariate_value.initial_value +@mock.patch("features.models.get_hashed_percentage_for_object_ids") +def test_get_multivariate_feature_state_value__no_mv_hashing_salt__seeds_hash_with_id( # type: ignore[no-untyped-def] + mock_get_hashed_percentage, + multivariate_feature, + environment, + identity, +): + # Given + mock_get_hashed_percentage.return_value = 0.0 + feature_state = FeatureState.objects.get( + environment=environment, + feature=multivariate_feature, + identity=None, + feature_segment=None, + ) + assert feature_state.mv_hashing_salt is None + identity_hash_key = identity.get_hash_key() + + # When + feature_state.get_multivariate_feature_state_value( + identity_hash_key=identity_hash_key + ) + + # Then the feature state id seeds the hash + mock_get_hashed_percentage.assert_called_once_with( + [feature_state.id, identity_hash_key] + ) + + +@mock.patch("features.models.get_hashed_percentage_for_object_ids") +def test_get_multivariate_feature_state_value__mv_hashing_salt_set__seeds_hash_with_salt( # type: ignore[no-untyped-def] + mock_get_hashed_percentage, + multivariate_feature, + environment, + identity, +): + # Given + mock_get_hashed_percentage.return_value = 0.0 + feature_state = FeatureState.objects.get( + environment=environment, + feature=multivariate_feature, + identity=None, + feature_segment=None, + ) + feature_state.mv_hashing_salt = 999 + identity_hash_key = identity.get_hash_key() + + # When + feature_state.get_multivariate_feature_state_value( + identity_hash_key=identity_hash_key + ) + + # Then the salt seeds the hash instead of the feature state id + mock_get_hashed_percentage.assert_called_once_with([999, identity_hash_key]) + + +def test_feature_state_clone__multivariate_feature__keeps_variant_bucketing_stable( + multivariate_feature: Feature, + environment: Environment, + environment_two: Environment, +) -> None: + # Given the environment-default feature state for a multivariate feature, and + # the variant each of a range of identities is currently bucketed into + feature_state = FeatureState.objects.get( + environment=environment, + feature=multivariate_feature, + identity=None, + feature_segment=None, + ) + identity_hash_keys = [f"identity-{i}" for i in range(50)] + original_assignment = { + key: feature_state.get_multivariate_feature_state_value(key).id + for key in identity_hash_keys + } + + # When the feature state is recreated by cloning it (e.g. publishing a new + # version or editing multivariate weights under v2 versioning) + cloned_feature_state = feature_state.clone(env=environment_two, as_draft=True) + + # Then the clone keeps the original feature state's id as its bucketing salt + assert cloned_feature_state.id != feature_state.id + assert cloned_feature_state.mv_hashing_salt == feature_state.id + + # and every identity stays in the same variant as before + cloned_assignment = { + key: cloned_feature_state.get_multivariate_feature_state_value(key).id + for key in identity_hash_keys + } + assert cloned_assignment == original_assignment + + +def test_feature_state_clone__existing_mv_hashing_salt__is_preserved( + feature: Feature, + environment: Environment, + environment_two: Environment, +) -> None: + # Given a feature state that already carries a bucketing salt + feature_state = FeatureState.objects.get( + environment=environment, + feature=feature, + identity=None, + feature_segment=None, + ) + feature_state.mv_hashing_salt = 12345 + feature_state.save() + + # When it is cloned + cloned_feature_state = feature_state.clone(env=environment_two, as_draft=True) + + # Then the existing salt is carried over rather than the source id + assert cloned_feature_state.mv_hashing_salt == 12345 + + +def test_feature_state_create__recreates_previous_version_override_directly__raises( + environment_v2_versioning: Environment, + feature: Feature, + segment: Segment, +) -> None: + # Given an initial published version and a later (unpublished) version. The + # later version is created before the segment override exists, so the clone + # receiver does not copy the override into it. + initial_version = EnvironmentFeatureVersion.objects.get( + feature=feature, environment=environment_v2_versioning + ) + later_version = EnvironmentFeatureVersion.objects.create( + feature=feature, environment=environment_v2_versioning + ) + + # and the previous (initial) version gains a segment override + FeatureState.objects.create( + feature=feature, + environment=environment_v2_versioning, + environment_feature_version=initial_version, + feature_segment=FeatureSegment.objects.create( + feature=feature, + segment=segment, + environment=environment_v2_versioning, + environment_feature_version=initial_version, + ), + ) + + # When the same segment's override is recreated directly (not via clone) in + # the later version + # Then the guard rejects it + with pytest.raises(ValidationError): + FeatureState.objects.create( + feature=feature, + environment=environment_v2_versioning, + environment_feature_version=later_version, + feature_segment=FeatureSegment.objects.create( + feature=feature, + segment=segment, + environment=environment_v2_versioning, + environment_feature_version=later_version, + ), + ) + + +def test_feature_state_create__new_segment_override_under_v2__is_allowed( + environment_v2_versioning: Environment, + feature: Feature, + segment: Segment, +) -> None: + # Given a version whose previous version has no override for this segment + later_version = EnvironmentFeatureVersion.objects.create( + feature=feature, environment=environment_v2_versioning + ) + + # When a brand-new override for the segment is created directly + feature_state = FeatureState.objects.create( + feature=feature, + environment=environment_v2_versioning, + environment_feature_version=later_version, + feature_segment=FeatureSegment.objects.create( + feature=feature, + segment=segment, + environment=environment_v2_versioning, + environment_feature_version=later_version, + ), + ) + + # Then it is allowed: a genuinely new override starts a fresh seed + assert feature_state.mv_hashing_salt is None + + @mock.patch.object(FeatureState, "get_multivariate_feature_state_value") def test_get_feature_state_value__multivariate_feature__returns_mv_value( # type: ignore[no-untyped-def] mock_get_mv_feature_state_value, environment, multivariate_feature, identity diff --git a/api/tests/unit/util/mappers/test_unit_mappers_engine.py b/api/tests/unit/util/mappers/test_unit_mappers_engine.py index 667c6e57a7c8..75f49a25d998 100644 --- a/api/tests/unit/util/mappers/test_unit_mappers_engine.py +++ b/api/tests/unit/util/mappers/test_unit_mappers_engine.py @@ -240,6 +240,20 @@ def test_map_feature_state_to_engine__standard_feature__returns_expected_model( assert result == expected_result +def test_map_feature_state_to_engine__mv_hashing_salt_set__uses_salt_as_django_id( + feature_state: FeatureState, +) -> None: + # Given a feature state carrying a bucketing salt that differs from its id + feature_state.mv_hashing_salt = feature_state.id + 1000 + + # When + result = engine.map_feature_state_to_engine(feature_state, mv_fs_values=[]) + + # Then the salt is used as the engine document's django_id so that variant + # bucketing stays stable across feature state recreation + assert result.django_id == feature_state.mv_hashing_salt + + def test_map_feature_state_to_engine__feature_segment__return_expected( segment_multivariate_feature_state: FeatureState, multivariate_feature: "Feature", @@ -710,7 +724,10 @@ def test_map_environment_to_engine__after_v2_versioning_migration__returns_lates assert len(result.feature_states) == 1 mapped_environment_feature_state = result.feature_states[0] - assert mapped_environment_feature_state.django_id == v2_environment_feature_state.id + assert ( + mapped_environment_feature_state.featurestate_uuid + == v2_environment_feature_state.uuid + ) assert mapped_environment_feature_state.enabled is True assert ( mapped_environment_feature_state.feature_state_value @@ -721,7 +738,7 @@ def test_map_environment_to_engine__after_v2_versioning_migration__returns_lates assert len(result.project.segments[0].feature_states) == 1 mapped_segment_override = result.project.segments[0].feature_states[0] - assert mapped_segment_override.django_id == v2_segment_override.id + assert mapped_segment_override.featurestate_uuid == v2_segment_override.uuid assert mapped_segment_override.enabled is True assert mapped_segment_override.feature_state_value == v2_segment_override_value @@ -777,8 +794,8 @@ def test_map_environment_to_engine__v2_versioning_segment_override_removed__retu # Then assert len(environment_model.project.segments[0].feature_states) == 1 assert ( - environment_model.project.segments[0].feature_states[0].django_id - == v3_segment_override.id + environment_model.project.segments[0].feature_states[0].featurestate_uuid + == v3_segment_override.uuid ) diff --git a/api/util/mappers/engine.py b/api/util/mappers/engine.py index ef72abdb9511..9438539f1b84 100644 --- a/api/util/mappers/engine.py +++ b/api/util/mappers/engine.py @@ -152,7 +152,12 @@ def map_feature_state_to_engine( return FeatureStateModel( enabled=feature_state.enabled, - django_id=feature_state.pk, + # Use the multivariate bucketing salt (falling back to the pk) as the + # engine document's django_id. The engine and SDKs seed multivariate + # variant allocation on django_id, so feeding it the salt keeps variant + # assignment stable when a feature state is recreated, without changing + # the engine model or environment document schema. See issue #7913. + django_id=feature_state.mv_hashing_salt or feature_state.pk, feature_state_value=feature_state.get_feature_state_value(), featurestate_uuid=feature_state.uuid, feature_segment=feature_segment_model, From aedae1da3059db9831dfae14f2cffcf66f01f569 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Mon, 13 Jul 2026 12:44:18 +0530 Subject: [PATCH 2/9] fix(features): exclude mv_hashing_salt from audit log change details --- api/features/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/features/models.py b/api/features/models.py index 2733d358e9ab..5efb23a8e4e4 100644 --- a/api/features/models.py +++ b/api/features/models.py @@ -463,7 +463,7 @@ class FeatureState( LifecycleModelMixin, # type: ignore[misc] abstract_base_auditable_model_factory( # type: ignore[misc] historical_records_excluded_fields=["uuid"], - change_details_excluded_fields=["live_from", "version"], + change_details_excluded_fields=["live_from", "version", "mv_hashing_salt"], show_change_details_for_create=True, ), ): From a32e1974fa32285e29dba6e7a1beb3d797a78e65 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Mon, 13 Jul 2026 14:00:15 +0530 Subject: [PATCH 3/9] fix(features): hide mv_hashing_salt from the API and limit recreation guard to segment overrides --- api/features/models.py | 20 +++++++++---------- api/features/serializers.py | 2 +- .../features/test_unit_features_models.py | 2 +- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/api/features/models.py b/api/features/models.py index 5efb23a8e4e4..2d39f8363596 100644 --- a/api/features/models.py +++ b/api/features/models.py @@ -831,7 +831,7 @@ def check_for_duplicate_feature_state(self): # type: ignore[no-untyped-def] @hook(BEFORE_CREATE) def check_mv_hashing_salt_preserved(self): # type: ignore[no-untyped-def] - """Fail if a feature state is recreated without keeping its bucketing salt. + """Fail if a segment override is recreated without keeping its bucketing salt. Under v2 versioning a feature state must be recreated via clone(), which copies mv_hashing_salt so multivariate variant assignment stays stable for @@ -839,12 +839,15 @@ def check_mv_hashing_salt_preserved(self): # type: ignore[no-untyped-def] created state with no salt that the previous version already had was made some other way and would re-bucket those identities. Raise so the offending path is caught in tests. Identity overrides are skipped: they - are not versioned or cloned. See - https://github.com/Flagsmith/flagsmith/issues/7913. + are not versioned or cloned. Environment defaults are skipped: the clone + receiver copies one into every new version, so a direct create duplicates + it and is rejected by check_for_duplicate_feature_state before this guard + runs. See https://github.com/Flagsmith/flagsmith/issues/7913. """ if ( self.mv_hashing_salt is not None or self.identity_id is not None + or self.feature_segment_id is None or not self.environment.use_v2_feature_versioning # type: ignore[union-attr] ): return @@ -858,14 +861,9 @@ def check_mv_hashing_salt_preserved(self): # type: ignore[no-untyped-def] # A segment override's FeatureSegment row is itself cloned per version, so # match the override by its segment, not by the FeatureSegment row. - if self.feature_segment_id is None: - already_existed = Q(feature_segment__isnull=True) - else: - already_existed = Q( - feature_segment__segment_id=self.feature_segment.segment_id # type: ignore[union-attr] - ) - - if previous_version.feature_states.filter(already_existed).exists(): + if previous_version.feature_states.filter( + feature_segment__segment_id=self.feature_segment.segment_id # type: ignore[union-attr] + ).exists(): raise ValidationError( "A feature state for this environment, feature and segment existed " "in the previous version; recreate it via FeatureState.clone() so " diff --git a/api/features/serializers.py b/api/features/serializers.py index 08dad4020b1c..52bc2fea6979 100644 --- a/api/features/serializers.py +++ b/api/features/serializers.py @@ -675,7 +675,7 @@ class FeatureStateSerializerBasic(WritableNestedModelSerializer): class Meta: model = FeatureState - fields = "__all__" + exclude = ("mv_hashing_salt",) read_only_fields = ("version", "created_at", "updated_at", "status") @extend_schema_field( diff --git a/api/tests/unit/features/test_unit_features_models.py b/api/tests/unit/features/test_unit_features_models.py index 5002b3498aa4..ae79ebe1a801 100644 --- a/api/tests/unit/features/test_unit_features_models.py +++ b/api/tests/unit/features/test_unit_features_models.py @@ -787,7 +787,7 @@ def test_feature_state_create__recreates_previous_version_override_directly__rai # When the same segment's override is recreated directly (not via clone) in # the later version # Then the guard rejects it - with pytest.raises(ValidationError): + with pytest.raises(ValidationError, match="recreate it via FeatureState"): FeatureState.objects.create( feature=feature, environment=environment_v2_versioning, From 04a287e279e1a3f0a2789e3ddc8f03a750515c37 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Mon, 13 Jul 2026 14:40:25 +0530 Subject: [PATCH 4/9] docs(features): shorten mv_hashing_salt guard docstring --- api/features/models.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/api/features/models.py b/api/features/models.py index 2d39f8363596..0fe9978a975b 100644 --- a/api/features/models.py +++ b/api/features/models.py @@ -833,16 +833,11 @@ def check_for_duplicate_feature_state(self): # type: ignore[no-untyped-def] def check_mv_hashing_salt_preserved(self): # type: ignore[no-untyped-def] """Fail if a segment override is recreated without keeping its bucketing salt. - Under v2 versioning a feature state must be recreated via clone(), which - copies mv_hashing_salt so multivariate variant assignment stays stable for - enrolled identities. clone() always sets a non-null salt, so a freshly - created state with no salt that the previous version already had was made - some other way and would re-bucket those identities. Raise so the - offending path is caught in tests. Identity overrides are skipped: they - are not versioned or cloned. Environment defaults are skipped: the clone - receiver copies one into every new version, so a direct create duplicates - it and is rejected by check_for_duplicate_feature_state before this guard - runs. See https://github.com/Flagsmith/flagsmith/issues/7913. + Under v2 versioning, recreation must go through clone(), which always + sets mv_hashing_salt; a salt-less state whose segment was overridden in + the previous version came from some other path and would re-bucket + enrolled identities (#7913). Identity overrides are not versioned; + environment defaults are covered by check_for_duplicate_feature_state. """ if ( self.mv_hashing_salt is not None From 1d015c50adce9813a21de535348dc43d9a738ed9 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Tue, 14 Jul 2026 11:36:12 +0530 Subject: [PATCH 5/9] refactor(features): inherit mv hashing salt in the model layer --- api/features/models.py | 85 ++++++++++------- .../features/test_unit_features_models.py | 84 +++++++++++------ .../core/test_unit_workflows_models.py | 92 ++++++++++++++++++- api/util/mappers/engine.py | 11 +-- 4 files changed, 202 insertions(+), 70 deletions(-) diff --git a/api/features/models.py b/api/features/models.py index 0fe9978a975b..a4ffbbc95200 100644 --- a/api/features/models.py +++ b/api/features/models.py @@ -677,9 +677,8 @@ def clone( clone.id = None clone.uuid = uuid.uuid4() # Preserve the multivariate bucketing seed so that recreating this feature - # state does not re-bucket already-enrolled identities. If the source never - # had an explicit salt, capture its id (the seed used until now). - clone.mv_hashing_salt = self.mv_hashing_salt or self.id + # state does not re-bucket already-enrolled identities. + clone.mv_hashing_salt = self.mv_hashing_seed if self.feature_segment: # We can only create a new feature segment if we are cloning to another environment, @@ -706,9 +705,11 @@ def clone( self.feature_state_value.clone(clone) if self.feature.type == MULTIVARIATE: + # Clone in id order so the new rows keep the same relative id order, + # which variant bucketing iterates over. mv_values = [ mv_value.clone(feature_state=clone, persist=False) - for mv_value in self.multivariate_feature_state_values.all() + for mv_value in self.multivariate_feature_state_values.order_by("id") ] MultivariateFeatureStateValue.objects.bulk_create(mv_values) @@ -771,6 +772,44 @@ def get_feature_state_value_defaults(self) -> dict[str, typing.Any]: return {"type": type_, key_name: parse_func(value)} + @property + def mv_hashing_seed(self) -> int: + """The seed for multivariate variant bucketing: a lineage constant — the + id of the first state created for this (environment, feature, segment) + lineage, carried forward on every recreation so enrolled identities keep + their variant (#7913). States predating the salt column fall back to + their own id, the seed used until now. + """ + return self.mv_hashing_salt or self.id # type: ignore[return-value] + + def get_superseded_live_feature_state(self) -> typing.Optional["FeatureState"]: + """Return the live feature state of this state's lineage (same + environment, feature and segment) that this state supersedes. + """ + # A segment override's FeatureSegment row is recreated with it, so match + # the lineage by its segment, not by the FeatureSegment row. + if self.feature_segment_id is None: + lineage_filter = Q(feature_segment__isnull=True) + else: + lineage_filter = Q( + feature_segment__segment_id=self.feature_segment.segment_id # type: ignore[union-attr] + ) + + superseded: FeatureState | None = ( + FeatureState.objects.get_live_feature_states( + environment=self.environment, # type: ignore[arg-type] + additional_filters=lineage_filter, + feature_id=self.feature_id, + identity__isnull=True, + ) + .exclude(id=self.id) + # Mirror __gt__: among live states, the latest live_from wins, with + # version as the tie-break. + .order_by("-live_from", "-version") + .first() + ) + return superseded + def get_multivariate_feature_state_value( self, identity_hash_key: str ) -> AbstractBaseFeatureValueModel: @@ -780,7 +819,7 @@ def get_multivariate_feature_state_value( mv_options = list(self.multivariate_feature_state_values.all()) percentage_value = get_hashed_percentage_for_object_ids( - [self.mv_hashing_salt or self.id, identity_hash_key] + [self.mv_hashing_seed, identity_hash_key] ) # Iterate over the mv options in order of id (so we get the same value each @@ -830,41 +869,21 @@ def check_for_duplicate_feature_state(self): # type: ignore[no-untyped-def] ) @hook(BEFORE_CREATE) - def check_mv_hashing_salt_preserved(self): # type: ignore[no-untyped-def] - """Fail if a segment override is recreated without keeping its bucketing salt. - - Under v2 versioning, recreation must go through clone(), which always - sets mv_hashing_salt; a salt-less state whose segment was overridden in - the previous version came from some other path and would re-bucket - enrolled identities (#7913). Identity overrides are not versioned; - environment defaults are covered by check_for_duplicate_feature_state. + def inherit_mv_hashing_salt(self): # type: ignore[no-untyped-def] + """Keep multivariate bucketing stable across recreation: a new row + superseding a live state in its lineage adopts that state's seed + (#7913). Identity overrides have no versioned lineage; the feature type + check confines the lineage query to rows where bucketing matters. """ if ( self.mv_hashing_salt is not None or self.identity_id is not None - or self.feature_segment_id is None - or not self.environment.use_v2_feature_versioning # type: ignore[union-attr] + or self.feature.type != MULTIVARIATE ): return - previous_version = ( - self.environment_feature_version - and self.environment_feature_version.get_previous_version() - ) - if previous_version is None: - return - - # A segment override's FeatureSegment row is itself cloned per version, so - # match the override by its segment, not by the FeatureSegment row. - if previous_version.feature_states.filter( - feature_segment__segment_id=self.feature_segment.segment_id # type: ignore[union-attr] - ).exists(): - raise ValidationError( - "A feature state for this environment, feature and segment existed " - "in the previous version; recreate it via FeatureState.clone() so " - "the mv_hashing_salt is kept and multivariate variant assignment " - "stays stable. See https://github.com/Flagsmith/flagsmith/issues/7913." - ) + if superseded := self.get_superseded_live_feature_state(): + self.mv_hashing_salt = superseded.mv_hashing_seed @hook(BEFORE_CREATE) def set_live_from(self): # type: ignore[no-untyped-def] diff --git a/api/tests/unit/features/test_unit_features_models.py b/api/tests/unit/features/test_unit_features_models.py index ae79ebe1a801..593cb80e2ea4 100644 --- a/api/tests/unit/features/test_unit_features_models.py +++ b/api/tests/unit/features/test_unit_features_models.py @@ -756,28 +756,26 @@ def test_feature_state_clone__existing_mv_hashing_salt__is_preserved( assert cloned_feature_state.mv_hashing_salt == 12345 -def test_feature_state_create__recreates_previous_version_override_directly__raises( +def test_feature_state_create__recreates_live_segment_override_directly__inherits_salt( environment_v2_versioning: Environment, - feature: Feature, + multivariate_feature: Feature, segment: Segment, ) -> None: - # Given an initial published version and a later (unpublished) version. The - # later version is created before the segment override exists, so the clone - # receiver does not copy the override into it. + # Given a live segment override, and a later (unpublished) version created + # before the override existed, so the clone receiver did not copy the + # override into it initial_version = EnvironmentFeatureVersion.objects.get( - feature=feature, environment=environment_v2_versioning + feature=multivariate_feature, environment=environment_v2_versioning ) later_version = EnvironmentFeatureVersion.objects.create( - feature=feature, environment=environment_v2_versioning + feature=multivariate_feature, environment=environment_v2_versioning ) - - # and the previous (initial) version gains a segment override - FeatureState.objects.create( - feature=feature, + live_override = FeatureState.objects.create( + feature=multivariate_feature, environment=environment_v2_versioning, environment_feature_version=initial_version, feature_segment=FeatureSegment.objects.create( - feature=feature, + feature=multivariate_feature, segment=segment, environment=environment_v2_versioning, environment_feature_version=initial_version, @@ -786,45 +784,71 @@ def test_feature_state_create__recreates_previous_version_override_directly__rai # When the same segment's override is recreated directly (not via clone) in # the later version - # Then the guard rejects it - with pytest.raises(ValidationError, match="recreate it via FeatureState"): - FeatureState.objects.create( - feature=feature, + feature_state = FeatureState.objects.create( + feature=multivariate_feature, + environment=environment_v2_versioning, + environment_feature_version=later_version, + feature_segment=FeatureSegment.objects.create( + feature=multivariate_feature, + segment=segment, environment=environment_v2_versioning, environment_feature_version=later_version, - feature_segment=FeatureSegment.objects.create( - feature=feature, - segment=segment, - environment=environment_v2_versioning, - environment_feature_version=later_version, - ), - ) + ), + ) + + # Then it adopts the live override's bucketing seed + assert feature_state.mv_hashing_salt == live_override.mv_hashing_seed + + +def test_feature_state_create__v1_recreates_live_state_directly__inherits_salt( + environment: Environment, + multivariate_feature: Feature, +) -> None: + # Given the live feature state of a multivariate feature in a v1 + # versioning environment + live_feature_state = FeatureState.objects.get( + environment=environment, + feature=multivariate_feature, + identity=None, + feature_segment=None, + ) + + # When a new version of the feature state is created directly, the way a + # change request draft is + feature_state = FeatureState.objects.create( + feature=multivariate_feature, + environment=environment, + version=None, + ) + + # Then it adopts the live state's bucketing seed + assert feature_state.mv_hashing_salt == live_feature_state.mv_hashing_seed -def test_feature_state_create__new_segment_override_under_v2__is_allowed( +def test_feature_state_create__new_segment_override_under_v2__no_salt_inherited( environment_v2_versioning: Environment, - feature: Feature, + multivariate_feature: Feature, segment: Segment, ) -> None: - # Given a version whose previous version has no override for this segment + # Given a version whose lineage has no live override for this segment later_version = EnvironmentFeatureVersion.objects.create( - feature=feature, environment=environment_v2_versioning + feature=multivariate_feature, environment=environment_v2_versioning ) # When a brand-new override for the segment is created directly feature_state = FeatureState.objects.create( - feature=feature, + feature=multivariate_feature, environment=environment_v2_versioning, environment_feature_version=later_version, feature_segment=FeatureSegment.objects.create( - feature=feature, + feature=multivariate_feature, segment=segment, environment=environment_v2_versioning, environment_feature_version=later_version, ), ) - # Then it is allowed: a genuinely new override starts a fresh seed + # Then a genuinely new override starts a fresh seed assert feature_state.mv_hashing_salt is None diff --git a/api/tests/unit/features/workflows/core/test_unit_workflows_models.py b/api/tests/unit/features/workflows/core/test_unit_workflows_models.py index 4068d34021d4..dce1054a8169 100644 --- a/api/tests/unit/features/workflows/core/test_unit_workflows_models.py +++ b/api/tests/unit/features/workflows/core/test_unit_workflows_models.py @@ -22,7 +22,7 @@ from audit.related_object_type import RelatedObjectType from core.helpers import get_current_site_url from environments.models import Environment -from features.models import Feature, FeatureState +from features.models import Feature, FeatureSegment, FeatureState from features.versioning.models import ( EnvironmentFeatureVersion, VersionChangeSet, @@ -1167,3 +1167,93 @@ def test_project_delete__v2_versioning_with_change_requests__does_not_trigger_au assert project.deleted_at is not None mock_went_live_task.delay.assert_not_called() mock_updated_task.delay.assert_not_called() + + +def test_change_request_commit__v1_multivariate_feature__keeps_variant_bucketing_stable( + environment: Environment, + multivariate_feature: Feature, + admin_user: FFAdminUser, +) -> None: + # Given the current live environment-default feature state of a multivariate + # feature, and the variant each of a range of identities is bucketed into + live_feature_state = FeatureState.objects.get( + environment=environment, + feature=multivariate_feature, + identity=None, + feature_segment=None, + ) + identity_hash_keys = [f"identity-{i}" for i in range(50)] + original_assignment = { + key: live_feature_state.get_multivariate_feature_state_value(key).pk + for key in identity_hash_keys + } + + # and a change request carrying a draft feature state for the same feature, + # as created by the API when a change request is raised under v1 versioning + change_request = ChangeRequest.objects.create( + title="Test CR", environment=environment, user=admin_user + ) + draft_feature_state = FeatureState.objects.create( + feature=multivariate_feature, + environment=environment, + change_request=change_request, + version=None, + ) + + # When the change request is committed + change_request.commit(committed_by=admin_user) + + # Then the draft feature state is now the live one, carrying the superseded + # feature state's id as its bucketing salt + new_live_feature_state = next( + fs + for fs in get_environment_flags_list( + environment, feature_name=multivariate_feature.name + ) + if fs.feature_segment_id is None and fs.identity_id is None + ) + assert new_live_feature_state.id == draft_feature_state.id + assert new_live_feature_state.mv_hashing_salt == live_feature_state.id + + # and every identity stays in the same variant as before the commit + new_assignment = { + key: new_live_feature_state.get_multivariate_feature_state_value(key).pk + for key in identity_hash_keys + } + assert new_assignment == original_assignment + + +def test_change_request_commit__v1_segment_override_draft__inherits_mv_hashing_salt( + environment: Environment, + multivariate_feature: Feature, + segment: Segment, + admin_user: FFAdminUser, +) -> None: + # Given a live segment override for a multivariate feature + feature_segment = FeatureSegment.objects.create( + feature=multivariate_feature, segment=segment, environment=environment + ) + live_override = FeatureState.objects.create( + feature=multivariate_feature, + environment=environment, + feature_segment=feature_segment, + ) + + # and a change request carrying a draft feature state recreating the override + change_request = ChangeRequest.objects.create( + title="Test CR", environment=environment, user=admin_user + ) + draft_feature_state = FeatureState.objects.create( + feature=multivariate_feature, + environment=environment, + feature_segment=feature_segment, + change_request=change_request, + version=None, + ) + + # When the change request is committed + change_request.commit(committed_by=admin_user) + + # Then the draft carries the superseded override's id as its bucketing salt + draft_feature_state.refresh_from_db() + assert draft_feature_state.mv_hashing_salt == live_override.id diff --git a/api/util/mappers/engine.py b/api/util/mappers/engine.py index 9438539f1b84..75da44509d3d 100644 --- a/api/util/mappers/engine.py +++ b/api/util/mappers/engine.py @@ -152,12 +152,11 @@ def map_feature_state_to_engine( return FeatureStateModel( enabled=feature_state.enabled, - # Use the multivariate bucketing salt (falling back to the pk) as the - # engine document's django_id. The engine and SDKs seed multivariate - # variant allocation on django_id, so feeding it the salt keeps variant - # assignment stable when a feature state is recreated, without changing - # the engine model or environment document schema. See issue #7913. - django_id=feature_state.mv_hashing_salt or feature_state.pk, + # The engine and SDKs seed multivariate variant allocation on django_id, + # so feeding it the bucketing seed keeps variant assignment stable when + # a feature state is recreated, without changing the engine model or + # environment document schema. See issue #7913. + django_id=feature_state.mv_hashing_seed, feature_state_value=feature_state.get_feature_state_value(), featurestate_uuid=feature_state.uuid, feature_segment=feature_segment_model, From b0ffdf568ed50febf8b14126d1ac9e3931b97dd8 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Tue, 14 Jul 2026 11:43:41 +0530 Subject: [PATCH 6/9] docs(features): shorten mv_hashing_salt field comment --- api/features/models.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/api/features/models.py b/api/features/models.py index a4ffbbc95200..dec3868bfebc 100644 --- a/api/features/models.py +++ b/api/features/models.py @@ -522,11 +522,7 @@ class FeatureState( # to be deprecated! version = models.IntegerField(default=1, null=True) - # Seed for multivariate variant bucketing. Defaults to the feature state's own - # id (see get_multivariate_feature_state_value), but is preserved across clones - # so that recreating a feature state (e.g. publishing a new version or editing - # multivariate weights under v2 versioning) does not re-bucket already-enrolled - # identities. See https://github.com/Flagsmith/flagsmith/issues/7913. + # Multivariate bucketing seed, kept stable across recreation (#7913) — see mv_hashing_seed. mv_hashing_salt = models.IntegerField(null=True, blank=True, default=None) class Meta: From e25b199d708b25c0db2c80256fa573301546f9d7 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Tue, 14 Jul 2026 11:51:01 +0530 Subject: [PATCH 7/9] test(features): port mv hashing salt behaviour tests from #7963 --- .../features/test_unit_features_models.py | 64 +++++++++++++++++ ...test_unit_versioning_versioning_service.py | 69 +++++++++++++++++++ .../versioning/test_unit_versioning_views.py | 59 ++++++++++++++++ 3 files changed, 192 insertions(+) diff --git a/api/tests/unit/features/test_unit_features_models.py b/api/tests/unit/features/test_unit_features_models.py index 593cb80e2ea4..f81be84a3f97 100644 --- a/api/tests/unit/features/test_unit_features_models.py +++ b/api/tests/unit/features/test_unit_features_models.py @@ -1514,3 +1514,67 @@ def test_feature_delete__with_gitlab_resources__dispatches_deleted_comment_task( assert mock_task.delay.call_args_list == [ mocker.call(args=(expected_name, expected_id, expected_project_id)), ] + + +def test_get_superseded_live_feature_state__segment_override_draft__returns_live_override( + environment: Environment, + multivariate_feature: Feature, + segment: Segment, +) -> None: + # Given a live segment override, and an (uncommitted) draft feature state + # recreating it + feature_segment = FeatureSegment.objects.create( + feature=multivariate_feature, segment=segment, environment=environment + ) + live_override = FeatureState.objects.create( + feature=multivariate_feature, + environment=environment, + feature_segment=feature_segment, + ) + draft = FeatureState.objects.create( + feature=multivariate_feature, + environment=environment, + feature_segment=feature_segment, + version=None, + ) + + # When + superseded = draft.get_superseded_live_feature_state() + + # Then + assert superseded == live_override + + +def test_get_superseded_live_feature_state__scheduled_change_gone_live__returns_latest_live_from_state( + environment: Environment, + multivariate_feature: Feature, +) -> None: + # Given a state committed last (highest version) that went live first, and + # a previously committed scheduled state whose live_from has since passed + FeatureState.objects.filter( + feature=multivariate_feature, environment=environment + ).update(live_from=now - timedelta(hours=3)) + committed_last = FeatureState.objects.create( + feature=multivariate_feature, + environment=environment, + version=3, + live_from=now - timedelta(hours=2), + ) + scheduled = FeatureState.objects.create( + feature=multivariate_feature, + environment=environment, + version=2, + live_from=now - timedelta(hours=1), + ) + draft = FeatureState.objects.create( + feature=multivariate_feature, + environment=environment, + version=None, + ) + + # When + superseded = draft.get_superseded_live_feature_state() + + # Then the state with the latest live_from is live, not the highest version + assert superseded == scheduled + assert superseded != committed_last diff --git a/api/tests/unit/features/versioning/test_unit_versioning_versioning_service.py b/api/tests/unit/features/versioning/test_unit_versioning_versioning_service.py index 67e9d2e5dbc3..da33bae15663 100644 --- a/api/tests/unit/features/versioning/test_unit_versioning_versioning_service.py +++ b/api/tests/unit/features/versioning/test_unit_versioning_versioning_service.py @@ -927,3 +927,72 @@ def test_update_flag_v2__retained_plus_passed_exceeds_100__raises( multivariate_values=[MultivariateValueChangeSet(option_b.id, 100.0)], ), ) + + +def test_update_flag__v2_versioning_multivariate_weight_increase__keeps_enrolled_identities_in_variant( + environment_v2_versioning: Environment, + multivariate_feature: Feature, + multivariate_options: list[MultivariateFeatureOption], + admin_user: FFAdminUser, +) -> None: + # Given a multivariate feature split 50/50 between two variants, and the + # variant each of a range of identities is bucketed into + author = AuthorData(user=admin_user) + option_a, option_b, option_c = multivariate_options + feature_state = update_flag( + environment_v2_versioning, + multivariate_feature, + FlagChangeSet( + author=author, + enabled=True, + feature_state_value="control", + type_="string", + multivariate_values=[ + MultivariateValueChangeSet(option_a.id, 50.0), + MultivariateValueChangeSet(option_b.id, 50.0), + MultivariateValueChangeSet(option_c.id, 0.0), + ], + ), + ) + identity_hash_keys = [f"identity-{i}" for i in range(100)] + original_assignment = { + key: feature_state.get_multivariate_feature_state_value(key).pk + for key in identity_hash_keys + } + + # When the first variant's allocation is increased to 60/40 + new_feature_state = update_flag( + environment_v2_versioning, + multivariate_feature, + FlagChangeSet( + author=author, + enabled=True, + feature_state_value="control", + type_="string", + multivariate_values=[ + MultivariateValueChangeSet(option_a.id, 60.0), + MultivariateValueChangeSet(option_b.id, 40.0), + MultivariateValueChangeSet(option_c.id, 0.0), + ], + ), + ) + + # Then identities already in the grown variant stay in it, and the only + # movement is from the shrunk variant into the grown one + new_assignment = { + key: new_feature_state.get_multivariate_feature_state_value(key).pk + for key in identity_hash_keys + } + movers = { + key + for key in identity_hash_keys + if new_assignment[key] != original_assignment[key] + } + assert movers + assert all(original_assignment[key] == option_b.id for key in movers) + assert all(new_assignment[key] == option_a.id for key in movers) + assert all( + new_assignment[key] == original_assignment[key] + for key in identity_hash_keys + if key not in movers + ) diff --git a/api/tests/unit/features/versioning/test_unit_versioning_views.py b/api/tests/unit/features/versioning/test_unit_versioning_views.py index b55b062003d9..3b633ad52032 100644 --- a/api/tests/unit/features/versioning/test_unit_versioning_views.py +++ b/api/tests/unit/features/versioning/test_unit_versioning_views.py @@ -1797,3 +1797,62 @@ def test_create_version__feature_state_save__dispatches_gitlab_state_change( # Then assert response.status_code == status.HTTP_201_CREATED assert mock_dispatch.call_count == 1 + + +def test_create_feature_version_feature_state__override_recreated_in_draft__inherits_mv_hashing_salt( + admin_client: APIClient, + admin_user: FFAdminUser, + environment_v2_versioning: Environment, + multivariate_feature: Feature, + segment: Segment, +) -> None: + # Given a published version containing a segment override for a + # multivariate feature + overridden_version = EnvironmentFeatureVersion.objects.create( + environment=environment_v2_versioning, feature=multivariate_feature + ) + original_override = FeatureState.objects.create( + feature=multivariate_feature, + environment=environment_v2_versioning, + environment_feature_version=overridden_version, + feature_segment=FeatureSegment.objects.create( + feature=multivariate_feature, + segment=segment, + environment=environment_v2_versioning, + environment_feature_version=overridden_version, + ), + ) + overridden_version.publish(published_by=admin_user) + + # and a draft version from which the (cloned) override has been removed + draft_version = EnvironmentFeatureVersion.objects.create( + environment=environment_v2_versioning, feature=multivariate_feature + ) + cloned_override = draft_version.feature_states.get(feature_segment__segment=segment) + cloned_override.feature_segment.delete() + + # When the override is recreated in the draft version via the API + url = reverse( + "api-v1:versioning:environment-feature-version-featurestates-list", + args=[ + environment_v2_versioning.id, + multivariate_feature.id, + draft_version.uuid, + ], + ) + data = { + "feature_segment": {"segment": segment.id}, + "enabled": True, + "feature_state_value": { + "string_value": "recreated!", + }, + } + response = admin_client.post( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then the recreated override carries the original override's id as its + # bucketing salt, keeping multivariate variant assignment stable + assert response.status_code == status.HTTP_201_CREATED + recreated_override = FeatureState.objects.get(id=response.json()["id"]) + assert recreated_override.mv_hashing_salt == original_override.id From c57ac36d007f2c4948ab3714ce73b4ef54334974 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Tue, 14 Jul 2026 11:53:30 +0530 Subject: [PATCH 8/9] fix(features): drop unused type ignore on mv_hashing_seed --- api/features/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/features/models.py b/api/features/models.py index dec3868bfebc..f8cc0daf45fb 100644 --- a/api/features/models.py +++ b/api/features/models.py @@ -776,7 +776,7 @@ def mv_hashing_seed(self) -> int: their variant (#7913). States predating the salt column fall back to their own id, the seed used until now. """ - return self.mv_hashing_salt or self.id # type: ignore[return-value] + return self.mv_hashing_salt or self.id def get_superseded_live_feature_state(self) -> typing.Optional["FeatureState"]: """Return the live feature state of this state's lineage (same From 00eb496257f8ec634db57a1db1bcb6240ae7a253 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Tue, 14 Jul 2026 12:07:27 +0530 Subject: [PATCH 9/9] docs(features): tighten lineage ordering comment --- api/features/models.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/api/features/models.py b/api/features/models.py index f8cc0daf45fb..eaaf75074503 100644 --- a/api/features/models.py +++ b/api/features/models.py @@ -799,8 +799,7 @@ def get_superseded_live_feature_state(self) -> typing.Optional["FeatureState"]: identity__isnull=True, ) .exclude(id=self.id) - # Mirror __gt__: among live states, the latest live_from wins, with - # version as the tie-break. + # Match __gt__'s precedence: latest live_from wins, version breaks ties. .order_by("-live_from", "-version") .first() )