4242)
4343from experimentation .models import (
4444 VALID_STATUS_TRANSITIONS ,
45+ Experiment ,
4546 ExperimentStatus ,
4647 MetricAggregation ,
4748 MetricDirection ,
5758)
5859from features .models import FeatureState
5960from features .value_types import BOOLEAN , INTEGER , STRING
60- from features .versioning .dataclasses import FlagChangeSet
61+ from features .versioning .dataclasses import FlagChangeSet , MultivariateValueChangeSet
6162from features .versioning .versioning_service import (
63+ get_environment_flags_list ,
6264 update_flag ,
6365 update_multivariate_values ,
6466)
6567from integrations .flagsmith .client import get_openfeature_client
6668from segments .models import Condition , Segment , SegmentRule
6769
68- _ROLLOUT_VALUE_TYPE = {INTEGER : "integer" , STRING : "string" , BOOLEAN : "boolean" }
70+ _ROLLOUT_VALUE_TYPE : dict [str , "FeatureValueType" ] = {
71+ INTEGER : "integer" ,
72+ STRING : "string" ,
73+ BOOLEAN : "boolean" ,
74+ }
6975
7076if typing .TYPE_CHECKING :
7177 from collections .abc import Sequence
7278 from datetime import datetime
7379
74- from experimentation .models import Experiment , Metric , WarehouseConnection
80+ from experimentation .models import Metric , WarehouseConnection
7581 from experimentation .types import ExposureGranularity
82+ from features .feature_states .models import FeatureValueType
83+ from features .models import FeatureStateValue
7684 from organisations .models import Organisation
7785 from users .models import FFAdminUser
7886
@@ -579,18 +587,15 @@ def _sync_rollout_segment(experiment: Experiment, rollout_percentage: float) ->
579587
580588
581589def _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- ),
590+ flags = get_environment_flags_list (
591+ environment = experiment .environment ,
592+ additional_filters = Q (
589593 feature_id = experiment .feature_id ,
590- )
591- . order_by ( "-id" )
592- . first ()
594+ feature_segment__segment_id = experiment . rollout_segment_id ,
595+ identity__isnull = True ,
596+ ),
593597 )
598+ return flags [0 ] if flags else None
594599
595600
596601def _update_live_feature_state (
@@ -627,15 +632,60 @@ def _update_rollout_in_place(experiment: Experiment, change_set: FlagChangeSet)
627632 update_flag (experiment .environment , experiment .feature , change_set )
628633
629634
635+ def _reset_default_allocations_to_control (
636+ experiment : Experiment , author : AuthorData
637+ ) -> None :
638+ """Zero every variant's allocation on the feature's environment-default
639+ feature state, leaving control (the unallocated remainder) at 100%.
640+
641+ Run once, when the rollout segment is first created: identities outside the
642+ rollout cohort should all receive control while the experiment runs.
643+ """
644+ (default_state ,) = get_environment_flags_list (
645+ environment = experiment .environment ,
646+ additional_filters = Q (
647+ feature_id = experiment .feature_id ,
648+ feature_segment__isnull = True ,
649+ identity__isnull = True ,
650+ ),
651+ )
652+ str_value , value_type = _serialize_feature_state_value (
653+ default_state .feature_state_value
654+ )
655+ update_flag (
656+ experiment .environment ,
657+ experiment .feature ,
658+ FlagChangeSet (
659+ author = author ,
660+ enabled = default_state .enabled ,
661+ feature_state_value = str_value ,
662+ type_ = value_type ,
663+ multivariate_values = [
664+ MultivariateValueChangeSet (
665+ multivariate_feature_option_id = option_id ,
666+ percentage_allocation = 0 ,
667+ )
668+ for option_id in experiment .feature .multivariate_options .values_list (
669+ "id" , flat = True
670+ )
671+ ],
672+ ),
673+ )
674+
675+
630676def apply_experiment_rollout (experiment : Experiment , spec : RolloutSpec ) -> None :
631- if experiment .status == ExperimentStatus .COMPLETED :
632- raise ValidationError (
633- f"Cannot change the rollout of a { experiment .status } experiment."
634- )
635677 validate_rollout_spec (experiment , spec )
636678 environment_id = experiment .environment_id
637679 with transaction .atomic ():
680+ experiment .refresh_from_db (from_queryset = Experiment .objects .select_for_update ())
681+ if experiment .status == ExperimentStatus .COMPLETED :
682+ raise ValidationError (
683+ f"Cannot change the rollout of a { experiment .status } experiment."
684+ )
685+ is_first_rollout = experiment .rollout_segment_id is None
638686 segment = _sync_rollout_segment (experiment , spec .rollout_percentage )
687+ if is_first_rollout :
688+ _reset_default_allocations_to_control (experiment , spec .author )
639689 _update_rollout_in_place (
640690 experiment ,
641691 FlagChangeSet (
@@ -655,6 +705,19 @@ def apply_experiment_rollout(experiment: Experiment, spec: RolloutSpec) -> None:
655705 )
656706
657707
708+ def _serialize_feature_state_value (
709+ value : FeatureStateValue ,
710+ ) -> tuple [str , FeatureValueType ]:
711+ """Render a stored feature state value as the (string, API type) pair that
712+ a `FlagChangeSet` expects."""
713+ if value .value is None :
714+ return "" , "string"
715+ return (
716+ str (value .value ).lower () if value .type == BOOLEAN else str (value .value ),
717+ _ROLLOUT_VALUE_TYPE .get (value .type or STRING , "string" ),
718+ )
719+
720+
658721def get_experiment_rollout (experiment : Experiment ) -> dict [str , typing .Any ] | None :
659722 segment_id = experiment .rollout_segment_id
660723 if segment_id is None :
@@ -671,16 +734,13 @@ def get_experiment_rollout(experiment: Experiment) -> dict[str, typing.Any] | No
671734 condition = Condition .objects .get (
672735 rule__segment_id = segment_id , operator = PERCENTAGE_SPLIT
673736 )
674- value = feature_state .feature_state_value
737+ str_value , value_type = _serialize_feature_state_value (
738+ feature_state .feature_state_value
739+ )
675740 return {
676741 "enabled" : feature_state .enabled ,
677742 "rollout_percentage" : float (condition .value or 0 ),
678- "feature_state_value" : {
679- "type" : _ROLLOUT_VALUE_TYPE .get (value .type or STRING , "string" ),
680- "value" : (
681- str (value .value ).lower () if value .type == BOOLEAN else str (value .value )
682- ),
683- },
743+ "feature_state_value" : {"type" : value_type , "value" : str_value },
684744 "multivariate_feature_state_values" : [
685745 {
686746 "multivariate_feature_option" : mv .multivariate_feature_option_id ,
0 commit comments