Skip to content

Commit 7b1d747

Browse files
gagantrivediGagan
authored andcommitted
wip: phased rollout
1 parent 367edf4 commit 7b1d747

13 files changed

Lines changed: 880 additions & 9 deletions

File tree

api/audit/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,7 @@
7777
"Feature: %s removed from Release Pipeline: %s"
7878
)
7979
FEATURE_STATE_UPDATED_BY_RELEASE_PIPELINE_MESSAGE = "Flag state / Remote config updated for feature: %s by Release pipeline: %s (stage: %s)"
80+
PHASED_ROLLOUT_STATE_CREATED_MESSAGE = (
81+
"Phased rollout created for feature: %s by release pipeline: %s (stage: %s)"
82+
)
83+
PHASED_ROLLOUT_STATE_UPDATED_MESSAGE = "Phased rollout split changed from '%s%%' to '%s%%' for feature '%s' by release pipeline '%s' (stage: '%s')"
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Generated by Django 4.2.22 on 2025-08-06 02:31
2+
3+
import django.core.validators
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
import django_lifecycle.mixins # type: ignore[import-untyped]
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
("segments", "0029_add_is_system_segment"),
13+
("release_pipelines_core", "0001_add_release_pipelines"),
14+
]
15+
16+
operations = [
17+
migrations.AlterField(
18+
model_name="pipelinestageaction",
19+
name="action_type",
20+
field=models.CharField(
21+
choices=[
22+
("TOGGLE_FEATURE", "Enable/Disable Feature for the environment"),
23+
(
24+
"UPDATE_FEATURE_VALUE",
25+
"Update Feature Value for the environment",
26+
),
27+
(
28+
"TOGGLE_FEATURE_FOR_SEGMENT",
29+
"Enable/Disable Feature for a specific segment",
30+
),
31+
(
32+
"UPDATE_FEATURE_VALUE_FOR_SEGMENT",
33+
"Update Feature Value for a specific segment",
34+
),
35+
("PHASED_ROLLOUT", "Create Phased Rollout"),
36+
],
37+
default="TOGGLE_FEATURE",
38+
max_length=50,
39+
),
40+
),
41+
migrations.CreateModel(
42+
name="PhasedRolloutState",
43+
fields=[
44+
(
45+
"id",
46+
models.AutoField(
47+
auto_created=True,
48+
primary_key=True,
49+
serialize=False,
50+
verbose_name="ID",
51+
),
52+
),
53+
(
54+
"initial_split",
55+
models.FloatField(
56+
validators=[
57+
django.core.validators.MinValueValidator(0.0),
58+
django.core.validators.MaxValueValidator(100.0),
59+
]
60+
),
61+
),
62+
(
63+
"increase_by",
64+
models.FloatField(
65+
validators=[
66+
django.core.validators.MinValueValidator(0.0),
67+
django.core.validators.MaxValueValidator(100.0),
68+
]
69+
),
70+
),
71+
("increase_every", models.DurationField()),
72+
(
73+
"current_split",
74+
models.FloatField(
75+
validators=[
76+
django.core.validators.MinValueValidator(0.0),
77+
django.core.validators.MaxValueValidator(100.0),
78+
]
79+
),
80+
),
81+
("is_rollout_complete", models.BooleanField(default=False)),
82+
("last_updated_at", models.DateTimeField(auto_now=True)),
83+
(
84+
"rollout_segment",
85+
models.ForeignKey(
86+
blank=True,
87+
null=True,
88+
on_delete=django.db.models.deletion.SET_NULL,
89+
related_name="phased_rollout_state",
90+
to="segments.segment",
91+
),
92+
),
93+
],
94+
bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model),
95+
),
96+
]

api/features/release_pipelines/core/models.py

Lines changed: 112 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
import typing
2+
from datetime import datetime
23

3-
from django.core.validators import MaxValueValidator
4+
from django.core.validators import MaxValueValidator, MinValueValidator
45
from django.db import models
6+
from django.db.models import Q, QuerySet
57
from django.utils import timezone
8+
from django_lifecycle import ( # type: ignore[import-untyped]
9+
BEFORE_CREATE,
10+
LifecycleModelMixin,
11+
hook,
12+
)
613

714
from audit.constants import (
815
RELEASE_PIPELINE_CREATED_MESSAGE,
@@ -39,6 +46,7 @@ class StageActionType(models.TextChoices):
3946
"UPDATE_FEATURE_VALUE_FOR_SEGMENT",
4047
"Update Feature Value for a specific segment",
4148
)
49+
PHASED_ROLLOUT = ("PHASED_ROLLOUT", "Create Phased Rollout")
4250

4351

4452
class ReleasePipeline(
@@ -72,7 +80,7 @@ def publish(self, published_by: FFAdminUser) -> None:
7280
self.published_by = published_by
7381
self.save()
7482

75-
def unpublish(self, unpublished_by: FFAdminUser) -> None:
83+
def unpublish(self) -> None:
7684
if self.published_at is None:
7785
raise InvalidPipelineStateError("Pipeline is not published.")
7886
self.published_at = None
@@ -95,10 +103,25 @@ def get_delete_log_message(
95103
) -> typing.Optional[str]:
96104
return RELEASE_PIPELINE_DELETED_MESSAGE % self.name
97105

106+
def get_feature_versions_in_pipeline_qs(
107+
self,
108+
) -> QuerySet[EnvironmentFeatureVersion]:
109+
base_qs = EnvironmentFeatureVersion.objects.filter(
110+
pipeline_stage__pipeline=self
111+
)
112+
phased_rollout_action_filter = Q(phased_rollout_state__isnull=False) & Q(
113+
phased_rollout_state__is_rollout_complete=False
114+
)
115+
all_other_action_filters = Q(published_at__isnull=True)
116+
qs: QuerySet[EnvironmentFeatureVersion] = base_qs.filter(
117+
all_other_action_filters | phased_rollout_action_filter
118+
)
119+
return qs
120+
98121
def has_feature_in_flight(self) -> bool:
99-
has_feature_in_flight: bool = EnvironmentFeatureVersion.objects.filter(
100-
published_at__isnull=True, pipeline_stage__in=self.stages.all()
101-
).exists()
122+
has_feature_in_flight: bool = (
123+
self.get_feature_versions_in_pipeline_qs().exists()
124+
)
102125
return has_feature_in_flight
103126

104127
def _get_project(self) -> Project:
@@ -134,6 +157,37 @@ def get_next_stage(self) -> "PipelineStage | None":
134157
.first()
135158
)
136159

160+
def get_phased_rollout_action(self) -> "PipelineStageAction | None":
161+
return self.actions.filter(action_type=StageActionType.PHASED_ROLLOUT).first()
162+
163+
def get_in_stage_feature_versions_qs(self) -> QuerySet[EnvironmentFeatureVersion]:
164+
phased_rollout_action_filter = Q(
165+
phased_rollout_state__isnull=False,
166+
phased_rollout_state__is_rollout_complete=False,
167+
)
168+
all_other_action_filters = Q(
169+
published_at__isnull=True, phased_rollout_state__isnull=True
170+
)
171+
172+
return self.environment_feature_versions.filter(
173+
all_other_action_filters | phased_rollout_action_filter
174+
)
175+
176+
def get_completed_feature_versions_qs(
177+
self, completed_after: datetime = timezone.now()
178+
) -> QuerySet[EnvironmentFeatureVersion]:
179+
phased_rollout_action_filter = Q(
180+
phased_rollout_state__is_rollout_complete=True,
181+
phased_rollout_state__last_updated_at__gte=completed_after,
182+
)
183+
all_other_action_filters = Q(
184+
published_at__gte=completed_after, phased_rollout_state__isnull=True
185+
)
186+
187+
return self.environment_feature_versions.filter(
188+
all_other_action_filters | phased_rollout_action_filter
189+
)
190+
137191

138192
class PipelineStageTrigger(models.Model):
139193
trigger_type = models.CharField(
@@ -162,3 +216,56 @@ class PipelineStageAction(models.Model):
162216
related_name="actions",
163217
on_delete=models.CASCADE,
164218
)
219+
220+
221+
class PhasedRolloutState(LifecycleModelMixin, models.Model): # type: ignore[misc]
222+
initial_split = models.FloatField(
223+
validators=[
224+
MinValueValidator(0.0),
225+
MaxValueValidator(100.0),
226+
]
227+
)
228+
increase_by = models.FloatField(
229+
validators=[
230+
MinValueValidator(0.0),
231+
MaxValueValidator(100.0),
232+
]
233+
)
234+
increase_every = models.DurationField()
235+
current_split = models.FloatField(
236+
validators=[
237+
MinValueValidator(0.0),
238+
MaxValueValidator(100.0),
239+
]
240+
)
241+
rollout_segment = models.ForeignKey(
242+
"segments.Segment",
243+
related_name="phased_rollout_state",
244+
on_delete=models.SET_NULL,
245+
null=True,
246+
blank=True,
247+
)
248+
is_rollout_complete = models.BooleanField(default=False)
249+
last_updated_at = models.DateTimeField(auto_now=True)
250+
251+
@hook(BEFORE_CREATE) # type: ignore[misc]
252+
def set_initial_split(self) -> None:
253+
if self.current_split is None:
254+
self.current_split = self.initial_split
255+
256+
def increase_split(self) -> float:
257+
self.current_split = min(self.current_split + self.increase_by, 100.0)
258+
self.save()
259+
260+
# Update the segment value
261+
condition = self.rollout_segment.rules.first().conditions.first() # type: ignore[union-attr]
262+
assert condition
263+
condition.value = str(self.current_split)
264+
condition.save()
265+
return self.current_split
266+
267+
def complete_rollout(self) -> None:
268+
assert self.rollout_segment is not None
269+
self.rollout_segment.delete()
270+
self.is_rollout_complete = True
271+
self.save()
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Generated by Django 4.2.22 on 2025-08-06 02:31
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
("release_pipelines_core", "0002_add_phased_rollout"),
11+
("feature_versioning", "0006_add_pipeline_stage_to_envfeatureversion"),
12+
]
13+
14+
operations = [
15+
migrations.AddField(
16+
model_name="environmentfeatureversion",
17+
name="phased_rollout_state",
18+
field=models.ForeignKey(
19+
blank=True,
20+
null=True,
21+
on_delete=django.db.models.deletion.CASCADE,
22+
related_name="environment_feature_versions",
23+
to="release_pipelines_core.phasedrolloutstate",
24+
),
25+
),
26+
migrations.AddField(
27+
model_name="historicalenvironmentfeatureversion",
28+
name="phased_rollout_state",
29+
field=models.ForeignKey(
30+
blank=True,
31+
db_constraint=False,
32+
null=True,
33+
on_delete=django.db.models.deletion.DO_NOTHING,
34+
related_name="+",
35+
to="release_pipelines_core.phasedrolloutstate",
36+
),
37+
),
38+
]

api/features/versioning/models.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,14 +84,21 @@ class EnvironmentFeatureVersion( # type: ignore[django-manager-missing]
8484
null=True,
8585
blank=True,
8686
)
87+
8788
pipeline_stage = models.ForeignKey(
8889
"release_pipelines_core.PipelineStage",
8990
related_name="environment_feature_versions",
9091
on_delete=models.CASCADE,
9192
null=True,
9293
blank=True,
9394
)
94-
95+
phased_rollout_state = models.ForeignKey(
96+
"release_pipelines_core.PhasedRolloutState",
97+
related_name="environment_feature_versions",
98+
on_delete=models.CASCADE,
99+
null=True,
100+
blank=True,
101+
)
95102
objects = EnvironmentFeatureVersionManager() # type: ignore[misc]
96103

97104
class Meta:
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Generated by Django 4.2.22 on 2025-08-06 03:18
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("segments", "0028_condition_property_required"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="historicalsegment",
15+
name="is_system_segment",
16+
field=models.BooleanField(default=False),
17+
),
18+
migrations.AddField(
19+
model_name="segment",
20+
name="is_system_segment",
21+
field=models.BooleanField(default=False),
22+
),
23+
]

api/segments/models.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ class Segment(
7979

8080
created_at = models.DateTimeField(null=True, auto_now_add=True)
8181
updated_at = models.DateTimeField(null=True, auto_now=True)
82+
is_system_segment = models.BooleanField(default=False)
8283

8384
objects = SegmentManager() # type: ignore[misc]
8485

@@ -92,6 +93,8 @@ def __str__(self): # type: ignore[no-untyped-def]
9293
return "Segment - %s" % self.name
9394

9495
def get_skip_create_audit_log(self) -> bool:
96+
if self.is_system_segment:
97+
return True
9598
try:
9699
if self.version_of_id and self.version_of_id != self.id:
97100
return True
@@ -201,6 +204,8 @@ def get_skip_create_audit_log(self) -> bool:
201204
segment = self.get_segment() # type: ignore[no-untyped-call]
202205
if segment.deleted_at:
203206
return True
207+
if segment.is_system_segment:
208+
return True
204209
return segment.version_of_id != segment.id # type: ignore[no-any-return]
205210
except (Segment.DoesNotExist, SegmentRule.DoesNotExist):
206211
# handle hard delete
@@ -346,6 +351,8 @@ def get_skip_create_audit_log(self) -> bool:
346351
segment = self.rule.get_segment() # type: ignore[no-untyped-call]
347352
if segment.deleted_at:
348353
return True
354+
if segment.is_system_segment:
355+
return True
349356

350357
return segment.version_of_id != segment.id # type: ignore[no-any-return]
351358
except (Segment.DoesNotExist, SegmentRule.DoesNotExist):

api/segments/views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def get_queryset(self): # type: ignore[no-untyped-def]
5151
)
5252
project = get_object_or_404(permitted_projects, pk=self.kwargs["project_pk"])
5353

54-
queryset = Segment.live_objects.filter(project=project)
54+
queryset = Segment.live_objects.filter(project=project, is_system_segment=False)
5555

5656
if self.action == "list":
5757
# TODO: at the moment, the UI only shows the name and description of the segment in the list view.

0 commit comments

Comments
 (0)