Skip to content

Commit d902383

Browse files
committed
feat(nimbus): add state machine for rollouts
1 parent cedc318 commit d902383

14 files changed

Lines changed: 2106 additions & 23 deletions

File tree

docs/experimenter/openapi-schema.json

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,8 @@
177177
"Draft",
178178
"Preview",
179179
"Live",
180-
"Complete"
180+
"Complete",
181+
"Disabled"
181182
]
182183
}
183184
},
@@ -273,7 +274,8 @@
273274
"Draft",
274275
"Preview",
275276
"Live",
276-
"Complete"
277+
"Complete",
278+
"Disabled"
277279
]
278280
}
279281
},
@@ -621,7 +623,8 @@
621623
"Draft",
622624
"Preview",
623625
"Live",
624-
"Complete"
626+
"Complete",
627+
"Disabled"
625628
]
626629
}
627630
},
@@ -699,7 +702,8 @@
699702
"Draft",
700703
"Preview",
701704
"Live",
702-
"Complete"
705+
"Complete",
706+
"Disabled"
703707
]
704708
}
705709
},
@@ -783,7 +787,8 @@
783787
"Draft",
784788
"Preview",
785789
"Live",
786-
"Complete"
790+
"Complete",
791+
"Disabled"
787792
]
788793
}
789794
},
@@ -888,7 +893,8 @@
888893
"Draft",
889894
"Preview",
890895
"Live",
891-
"Complete"
896+
"Complete",
897+
"Disabled"
892898
]
893899
}
894900
},
@@ -1287,7 +1293,8 @@
12871293
"Draft",
12881294
"Preview",
12891295
"Live",
1290-
"Complete"
1296+
"Complete",
1297+
"Disabled"
12911298
],
12921299
"type": "string"
12931300
},

docs/experimenter/swagger-ui.html

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,8 @@
189189
"Draft",
190190
"Preview",
191191
"Live",
192-
"Complete"
192+
"Complete",
193+
"Disabled"
193194
]
194195
}
195196
},
@@ -285,7 +286,8 @@
285286
"Draft",
286287
"Preview",
287288
"Live",
288-
"Complete"
289+
"Complete",
290+
"Disabled"
289291
]
290292
}
291293
},
@@ -633,7 +635,8 @@
633635
"Draft",
634636
"Preview",
635637
"Live",
636-
"Complete"
638+
"Complete",
639+
"Disabled"
637640
]
638641
}
639642
},
@@ -711,7 +714,8 @@
711714
"Draft",
712715
"Preview",
713716
"Live",
714-
"Complete"
717+
"Complete",
718+
"Disabled"
715719
]
716720
}
717721
},
@@ -795,7 +799,8 @@
795799
"Draft",
796800
"Preview",
797801
"Live",
798-
"Complete"
802+
"Complete",
803+
"Disabled"
799804
]
800805
}
801806
},
@@ -900,7 +905,8 @@
900905
"Draft",
901906
"Preview",
902907
"Live",
903-
"Complete"
908+
"Complete",
909+
"Disabled"
904910
]
905911
}
906912
},
@@ -1299,7 +1305,8 @@
12991305
"Draft",
13001306
"Preview",
13011307
"Live",
1302-
"Complete"
1308+
"Complete",
1309+
"Disabled"
13031310
],
13041311
"type": "string"
13051312
},

experimenter/experimenter/experiments/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,10 @@ class Status(models.TextChoices):
462462
LIVE = "Live"
463463
COMPLETE = "Complete"
464464

465+
# This status applies only to rollouts, and indicates that the rollout has been
466+
# disabled. It is not a valid status for experiments.
467+
DISABLED = "Disabled"
468+
465469
class PublishStatus(models.TextChoices):
466470
IDLE = "Idle"
467471
REVIEW = "Review"
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Generated by Django 5.2.15 on 2026-07-15 18:14
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('experiments', '0333_nimbusrolloutplantemplate_and_more'),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name='nimbuschangelog',
15+
name='new_status',
16+
field=models.CharField(choices=[('Draft', 'Draft'), ('Preview', 'Preview'), ('Live', 'Live'), ('Complete', 'Complete'), ('Disabled', 'Disabled')], max_length=255),
17+
),
18+
migrations.AlterField(
19+
model_name='nimbuschangelog',
20+
name='new_status_next',
21+
field=models.CharField(blank=True, choices=[('Draft', 'Draft'), ('Preview', 'Preview'), ('Live', 'Live'), ('Complete', 'Complete'), ('Disabled', 'Disabled')], max_length=255, null=True),
22+
),
23+
migrations.AlterField(
24+
model_name='nimbuschangelog',
25+
name='old_status',
26+
field=models.CharField(blank=True, choices=[('Draft', 'Draft'), ('Preview', 'Preview'), ('Live', 'Live'), ('Complete', 'Complete'), ('Disabled', 'Disabled')], max_length=255, null=True),
27+
),
28+
migrations.AlterField(
29+
model_name='nimbuschangelog',
30+
name='old_status_next',
31+
field=models.CharField(blank=True, choices=[('Draft', 'Draft'), ('Preview', 'Preview'), ('Live', 'Live'), ('Complete', 'Complete'), ('Disabled', 'Disabled')], max_length=255, null=True),
32+
),
33+
migrations.AlterField(
34+
model_name='nimbusexperiment',
35+
name='status',
36+
field=models.CharField(choices=[('Draft', 'Draft'), ('Preview', 'Preview'), ('Live', 'Live'), ('Complete', 'Complete'), ('Disabled', 'Disabled')], default='Draft', max_length=255, verbose_name='Status'),
37+
),
38+
migrations.AlterField(
39+
model_name='nimbusexperiment',
40+
name='status_next',
41+
field=models.CharField(blank=True, choices=[('Draft', 'Draft'), ('Preview', 'Preview'), ('Live', 'Live'), ('Complete', 'Complete'), ('Disabled', 'Disabled')], max_length=255, null=True),
42+
),
43+
]

experimenter/experimenter/experiments/models.py

Lines changed: 80 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -555,13 +555,31 @@ class Filters:
555555
publish_status=NimbusConstants.PublishStatus.WAITING,
556556
)
557557
IS_UPDATE_QUEUED = Q(
558-
status=NimbusConstants.Status.LIVE,
559-
status_next=NimbusConstants.Status.LIVE,
558+
Q(
559+
status=NimbusConstants.Status.LIVE,
560+
status_next__in=(
561+
NimbusConstants.Status.LIVE,
562+
NimbusConstants.Status.DISABLED,
563+
),
564+
)
565+
| Q(
566+
status=NimbusConstants.Status.DISABLED,
567+
status_next=NimbusConstants.Status.LIVE,
568+
),
560569
publish_status=NimbusConstants.PublishStatus.APPROVED,
561570
)
562571
IS_UPDATING = Q(
563-
status=NimbusConstants.Status.LIVE,
564-
status_next=NimbusConstants.Status.LIVE,
572+
Q(
573+
status=NimbusConstants.Status.LIVE,
574+
status_next__in=(
575+
NimbusConstants.Status.LIVE,
576+
NimbusConstants.Status.DISABLED,
577+
),
578+
)
579+
| Q(
580+
status=NimbusConstants.Status.DISABLED,
581+
status_next=NimbusConstants.Status.LIVE,
582+
),
565583
publish_status=NimbusConstants.PublishStatus.WAITING,
566584
)
567585
IS_END_QUEUED = Q(
@@ -1285,7 +1303,10 @@ def advance_rollout_phase(self):
12851303
today = timezone.now().date()
12861304
phase_ids = [phase.id for phase in phases]
12871305

1288-
if self.rollout_phase_id is None:
1306+
if self.rollout_phase_next_id is not None:
1307+
current_phase = self.rollout_phase
1308+
next_phase = self.rollout_phase_next
1309+
elif self.rollout_phase_id is None:
12891310
current_phase = None
12901311
next_phase = phases[0]
12911312
else:
@@ -1297,7 +1318,9 @@ def advance_rollout_phase(self):
12971318
if next_phase is not None and not next_phase.population_percent:
12981319
return
12991320

1300-
if current_phase is not None:
1321+
if current_phase is not None and (
1322+
current_phase.end_date is None or current_phase.end_date > today
1323+
):
13011324
current_phase.end_date = today
13021325
if current_phase.actual_start_date:
13031326
current_phase.start_date = current_phase.actual_start_date
@@ -1312,8 +1335,59 @@ def advance_rollout_phase(self):
13121335
next_phase.save()
13131336
self.rollout_phase = next_phase
13141337
self.rollout_phase_next = None
1338+
self.population_percent = next_phase.population_percent
13151339
self.save()
13161340

1341+
def stage_rollout_phase_advance(self, copy_current_if_missing=False):
1342+
phases = list(self.rollout_phases.all())
1343+
if not phases:
1344+
return None
1345+
1346+
current_phase = self.rollout_phase
1347+
if self.rollout_phase_next_id is not None:
1348+
next_phase = self.rollout_phase_next
1349+
elif self.rollout_phase_id is None:
1350+
next_phase = phases[0]
1351+
else:
1352+
phase_ids = [phase.id for phase in phases]
1353+
current_index = phase_ids.index(self.rollout_phase_id)
1354+
next_index = current_index + 1
1355+
next_phase = phases[next_index] if next_index < len(phases) else None
1356+
1357+
if next_phase is None and copy_current_if_missing and current_phase is not None:
1358+
next_phase = self.rollout_phases.create(
1359+
population_percent=current_phase.population_percent
1360+
)
1361+
1362+
if next_phase is None or not next_phase.population_percent:
1363+
return None
1364+
1365+
self.rollout_phase_next = next_phase
1366+
self.population_percent = next_phase.population_percent
1367+
self.save(update_fields=["rollout_phase_next", "population_percent"])
1368+
return next_phase
1369+
1370+
def end_current_rollout_phase(self):
1371+
current_phase = self.rollout_phase
1372+
if current_phase is None:
1373+
return
1374+
1375+
today = timezone.now().date()
1376+
if current_phase.end_date is not None and current_phase.end_date <= today:
1377+
return
1378+
1379+
current_phase.end_date = today
1380+
if current_phase.actual_start_date:
1381+
current_phase.start_date = current_phase.actual_start_date
1382+
current_phase.save()
1383+
1384+
def disable_rollout(self):
1385+
if self.is_rollout:
1386+
self.population_percent = 0
1387+
self.save()
1388+
else:
1389+
raise ValueError("Cannot disable rollout for a non-rollout experiment.")
1390+
13171391
@property
13181392
def is_missing_takeaway_info(self):
13191393
return (

0 commit comments

Comments
 (0)