Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
8a85454
fix(features): preserve multivariate bucketing salt across feature st…
gagantrivedi Jun 30, 2026
aedae1d
fix(features): exclude mv_hashing_salt from audit log change details
gagantrivedi Jul 13, 2026
a32e197
fix(features): hide mv_hashing_salt from the API and limit recreation…
gagantrivedi Jul 13, 2026
04a287e
docs(features): shorten mv_hashing_salt guard docstring
gagantrivedi Jul 13, 2026
6538900
refactor(features): add mv hashing salt succession helpers
Zaimwa9 Jul 2, 2026
46fdbb9
fix(features): clone multivariate state values in id order
Zaimwa9 Jul 2, 2026
46604de
fix(workflows): carry mv hashing salt across change request commits
Zaimwa9 Jul 2, 2026
6848952
fix(features): warn when a live multivariate lineage is recreated wit…
Zaimwa9 Jul 2, 2026
753e2f4
fix(versioning): inherit mv hashing salt when recreating a segment ov…
Zaimwa9 Jul 2, 2026
a7761fb
docs: add mv variants rebucketed event to events catalogue
Zaimwa9 Jul 8, 2026
e52cfc6
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 8, 2026
d15da77
test(features): add missing given comment
Zaimwa9 Jul 8, 2026
7657919
docs: regenerate events catalogue after models.py line shift
Zaimwa9 Jul 8, 2026
f11212f
test(features): cover rebucketing protection for weight edits and cha…
Zaimwa9 Jul 8, 2026
33c2da1
test(versioning): cover mv hashing salt inheritance guard clauses
Zaimwa9 Jul 8, 2026
003520e
fix(features): resolve the superseded live state by live_from, not ve…
Zaimwa9 Jul 13, 2026
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
11 changes: 10 additions & 1 deletion api/core/workflows_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ def _publish_feature_states(self) -> None:
if not fs.live_from or fs.live_from < now:
fs.live_from = now

# The draft is a brand new row about to replace the live feature
# state, so carry over the multivariate bucketing seed to keep
# variant assignment stable for enrolled identities.
if fs.mv_hashing_salt is None and fs.identity_id is None:
if superseded := fs.get_superseded_live_feature_state():
fs.mv_hashing_salt = superseded.get_mv_hashing_salt_for_successor()

fs.version = fs.get_next_version_number(
environment_id=fs.environment_id, # type: ignore[arg-type]
feature_id=fs.feature_id,
Expand All @@ -61,7 +68,9 @@ def _publish_feature_states(self) -> None:
)

if feature_states:
type(fs).objects.bulk_update(feature_states, ["live_from", "version"])
type(fs).objects.bulk_update(
feature_states, ["live_from", "version", "mv_hashing_salt"]
)

def _publish_environment_feature_versions(
self, published_by: "FFAdminUser"
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
),
]
116 changes: 113 additions & 3 deletions api/features/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import uuid
from copy import deepcopy

import structlog
from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import (
Expand Down Expand Up @@ -83,6 +84,7 @@
from . import audit_helpers

logger = logging.getLogger(__name__)
event_logger = structlog.get_logger("features")

if typing.TYPE_CHECKING:
from environments.identities.models import Identity
Expand Down Expand Up @@ -463,7 +465,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,
),
):
Expand Down Expand Up @@ -522,6 +524,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"]

Expand Down Expand Up @@ -669,6 +678,9 @@ 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.
clone.mv_hashing_salt = self.get_mv_hashing_salt_for_successor()

if self.feature_segment:
# We can only create a new feature segment if we are cloning to another environment,
Expand All @@ -695,9 +707,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)

Expand Down Expand Up @@ -760,6 +774,44 @@ def get_feature_state_value_defaults(self) -> dict[str, typing.Any]:

return {"type": type_, key_name: parse_func(value)}

def get_mv_hashing_salt_for_successor(self) -> int:
"""Return the multivariate bucketing seed a feature state superseding
this one must carry to keep variant assignment stable. If this state
never had an explicit salt, its id is the seed used until now.
See https://github.com/Flagsmith/flagsmith/issues/7913.
"""
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
environment, feature and segment) that this state supersedes when it
goes live. Only meaningful for v1 versioning; identity overrides have
no versioned lineage.
"""
# A segment override's FeatureSegment row can be 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:
Expand All @@ -769,7 +821,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
Expand Down Expand Up @@ -818,6 +870,64 @@ 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]
"""Complain if a feature state is recreated without keeping its bucketing salt.

A feature state must be recreated via clone(), which copies
mv_hashing_salt so multivariate variant assignment stays stable for
enrolled identities, or set the salt explicitly (see
get_mv_hashing_salt_for_successor call sites). A freshly created state
with no salt whose lineage already existed was made some other way and
re-buckets those identities: raise under v2 versioning so the offending
path is caught in tests; warn under v1. Identity overrides are skipped:
they are not versioned or cloned. Under v2, environment defaults are
covered by check_for_duplicate_feature_state. See
https://github.com/Flagsmith/flagsmith/issues/7913.
"""
if self.mv_hashing_salt is not None or self.identity_id is not None:
return

if not self.environment.use_v2_feature_versioning: # type: ignore[union-attr]
# Under v1 versioning, change request drafts inherit the salt when
# committed (see ChangeRequestCommitService); any other path
# recreating a live multivariate lineage re-buckets its identities,
# so leave a trace for operators instead of failing the write.
if (
self.change_request_id is None
and self.feature.type == MULTIVARIATE
and (superseded := self.get_superseded_live_feature_state()) is not None
):
event_logger.warning(
"feature_state.mv_variants_rebucketed",
environment__id=self.environment_id,
feature__id=self.feature_id,
superseded_feature_state__id=superseded.id,
)
return

if self.feature_segment_id is None:
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."
)

@hook(BEFORE_CREATE)
def set_live_from(self): # type: ignore[no-untyped-def]
"""
Expand Down
2 changes: 1 addition & 1 deletion api/features/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
33 changes: 33 additions & 0 deletions api/features/versioning/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,39 @@ class Meta(CustomCreateSegmentOverrideFeatureStateSerializer.Meta):
+ ("feature",) # type: ignore[assignment]
)

def create(self, validated_data: dict) -> "FeatureState": # type: ignore[type-arg]
# A state created directly in a version may supersede an override the
# previous version already had (e.g. one deleted from this draft and
# recreated), so carry over the multivariate bucketing seed to keep
# variant assignment stable for enrolled identities.
if validated_data.get("mv_hashing_salt") is None and (
inherited_salt := self._get_inherited_mv_hashing_salt(validated_data)
):
validated_data["mv_hashing_salt"] = inherited_salt
return super().create(validated_data)

def _get_inherited_mv_hashing_salt(
self,
validated_data: dict, # type: ignore[type-arg]
) -> int | None:
version: EnvironmentFeatureVersion | None = validated_data.get(
"environment_feature_version"
)
feature_segment_data = validated_data.get("feature_segment")
if version is None or feature_segment_data is None:
return None

previous_version = version.get_previous_version()
if previous_version is None:
return None

superseded: "FeatureState | None" = previous_version.feature_states.filter(
feature_segment__segment=feature_segment_data["segment"]
).first()
if superseded is None:
return None
return superseded.get_mv_hashing_salt_for_successor()

def save(self, **kwargs): # type: ignore[no-untyped-def]
response = super().save(**kwargs) # type: ignore[no-untyped-call]

Expand Down
Loading
Loading