Skip to content

Commit f551fe5

Browse files
feat(nimbus): stricter status transitions (#14266)
Becuase * With more experiments comes more collisions with multiple users pressing buttons quickly * We do not have strict requirements for each status transition form * This means things can be approved multiple times by multiple users concurrently * This can leave experiments in broken states if users collide * We should add strict conditions to every state transition form so a state transition can only occur once This commit * Adds required entry states for every status transition form based on the states defined in the diagrams * Updates the UI to show more detailed information if a status collision occurs * Updates tests fixes #14227
1 parent bc4325f commit f551fe5

File tree

5 files changed

+1039
-218
lines changed

5 files changed

+1039
-218
lines changed

experimenter/experimenter/nimbus_ui/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ class NimbusUIConstants:
2222
"Name maps to a pre-existing slug, please choose another name."
2323
)
2424
ERROR_TAG_DUPLICATE_NAME = "Tag with this Name already exists."
25+
ERROR_INVALID_STATE_TRANSITION = (
26+
"Cannot perform this action: experiment must be in state {required_state}, "
27+
"but is currently in state {current_state}."
28+
)
2529

2630
RISK_MESSAGE_URL = "https://mozilla-hub.atlassian.net/wiki/spaces/FIREFOX/pages/208308555/Message+Consult+Creation"
2731
REVIEW_URL = "https://experimenter.info/access"

experimenter/experimenter/nimbus_ui/forms.py

Lines changed: 145 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1366,10 +1366,51 @@ class UpdateStatusForm(NimbusChangeLogFormMixin, forms.ModelForm):
13661366
publish_status = None
13671367
is_paused = None
13681368

1369+
required_status = None
1370+
required_status_next = None
1371+
required_publish_status = None
1372+
required_is_paused = None
1373+
13691374
class Meta:
13701375
model = NimbusExperiment
13711376
fields = []
13721377

1378+
def clean(self):
1379+
cleaned_data = super().clean()
1380+
1381+
required_state = (
1382+
self.required_status,
1383+
self.required_status_next,
1384+
self.required_publish_status,
1385+
self.required_is_paused,
1386+
)
1387+
current_state = (
1388+
self.instance.status,
1389+
self.instance.status_next,
1390+
self.instance.publish_status,
1391+
self.instance.is_paused,
1392+
)
1393+
1394+
state_mismatch = (
1395+
self.required_status != self.instance.status
1396+
or self.required_status_next != self.instance.status_next
1397+
or self.required_publish_status != self.instance.publish_status
1398+
or (
1399+
self.required_is_paused is not None
1400+
and self.required_is_paused != self.instance.is_paused
1401+
)
1402+
)
1403+
1404+
if state_mismatch:
1405+
raise forms.ValidationError(
1406+
NimbusUIConstants.ERROR_INVALID_STATE_TRANSITION.format(
1407+
required_state=required_state,
1408+
current_state=current_state,
1409+
)
1410+
)
1411+
1412+
return cleaned_data
1413+
13731414
@transaction.atomic
13741415
def save(self, commit=True):
13751416
self.instance.status = self.status
@@ -1401,9 +1442,15 @@ def save(self, commit=True):
14011442

14021443

14031444
class DraftToPreviewForm(UpdateStatusForm):
1445+
required_status = NimbusExperiment.Status.DRAFT
1446+
required_status_next = None
1447+
required_publish_status = NimbusExperiment.PublishStatus.IDLE
1448+
required_is_paused = False
1449+
14041450
status = NimbusExperiment.Status.PREVIEW
1405-
status_next = NimbusExperiment.Status.PREVIEW
1451+
status_next = None
14061452
publish_status = NimbusExperiment.PublishStatus.IDLE
1453+
is_paused = False
14071454

14081455
def get_changelog_message(self):
14091456
return f"{self.request.user} launched experiment to Preview"
@@ -1418,29 +1465,49 @@ def save(self, commit=True):
14181465

14191466

14201467
class DraftToReviewForm(SlackNotificationMixin, UpdateStatusForm):
1468+
required_status = NimbusExperiment.Status.DRAFT
1469+
required_status_next = None
1470+
required_publish_status = NimbusExperiment.PublishStatus.IDLE
1471+
required_is_paused = False
1472+
14211473
status = NimbusExperiment.Status.DRAFT
14221474
status_next = NimbusExperiment.Status.LIVE
14231475
publish_status = NimbusExperiment.PublishStatus.REVIEW
1476+
is_paused = False
1477+
14241478
slack_action = NimbusConstants.SLACK_ACTION_LAUNCH_REQUEST
14251479

14261480
def get_changelog_message(self):
14271481
return f"{self.request.user} requested launch without Preview"
14281482

14291483

14301484
class PreviewToReviewForm(SlackNotificationMixin, UpdateStatusForm):
1485+
required_status = NimbusExperiment.Status.PREVIEW
1486+
required_status_next = None
1487+
required_publish_status = NimbusExperiment.PublishStatus.IDLE
1488+
required_is_paused = False
1489+
14311490
status = NimbusExperiment.Status.DRAFT
14321491
status_next = NimbusExperiment.Status.LIVE
14331492
publish_status = NimbusExperiment.PublishStatus.REVIEW
1493+
is_paused = False
1494+
14341495
slack_action = NimbusConstants.SLACK_ACTION_LAUNCH_REQUEST
14351496

14361497
def get_changelog_message(self):
14371498
return f"{self.request.user} requested launch from Preview"
14381499

14391500

14401501
class PreviewToDraftForm(UpdateStatusForm):
1502+
required_status = NimbusExperiment.Status.PREVIEW
1503+
required_status_next = None
1504+
required_publish_status = NimbusExperiment.PublishStatus.IDLE
1505+
required_is_paused = False
1506+
14411507
status = NimbusExperiment.Status.DRAFT
1442-
status_next = NimbusExperiment.Status.DRAFT
1508+
status_next = None
14431509
publish_status = NimbusExperiment.PublishStatus.IDLE
1510+
is_paused = False
14441511

14451512
def get_changelog_message(self):
14461513
return f"{self.request.user} moved the experiment back to Draft"
@@ -1453,9 +1520,16 @@ def save(self, commit=True):
14531520

14541521

14551522
class ReviewToDraftForm(UpdateStatusForm):
1523+
required_status = NimbusExperiment.Status.DRAFT
1524+
required_status_next = NimbusExperiment.Status.LIVE
1525+
required_publish_status = NimbusExperiment.PublishStatus.REVIEW
1526+
required_is_paused = False
1527+
14561528
status = NimbusExperiment.Status.DRAFT
1457-
status_next = NimbusExperiment.Status.DRAFT
1529+
status_next = None
14581530
publish_status = NimbusExperiment.PublishStatus.IDLE
1531+
is_paused = False
1532+
14591533
changelog_message = forms.CharField(
14601534
required=False, label="Changelog Message", max_length=1000
14611535
)
@@ -1474,9 +1548,15 @@ def get_changelog_message(self):
14741548

14751549

14761550
class ReviewToApproveForm(UpdateStatusForm):
1551+
required_status = NimbusExperiment.Status.DRAFT
1552+
required_status_next = NimbusExperiment.Status.LIVE
1553+
required_publish_status = NimbusExperiment.PublishStatus.REVIEW
1554+
required_is_paused = False
1555+
14771556
status = NimbusExperiment.Status.DRAFT
14781557
status_next = NimbusExperiment.Status.LIVE
14791558
publish_status = NimbusExperiment.PublishStatus.APPROVED
1559+
is_paused = False
14801560

14811561
def get_changelog_message(self):
14821562
return f"{self.request.user} approved the review."
@@ -1493,46 +1573,43 @@ def save(self, commit=True):
14931573

14941574

14951575
class LiveToEndEnrollmentForm(SlackNotificationMixin, UpdateStatusForm):
1576+
required_status = NimbusExperiment.Status.LIVE
1577+
required_status_next = None
1578+
required_publish_status = NimbusExperiment.PublishStatus.IDLE
1579+
required_is_paused = False
1580+
14961581
status = NimbusExperiment.Status.LIVE
14971582
status_next = NimbusExperiment.Status.LIVE
14981583
publish_status = NimbusExperiment.PublishStatus.REVIEW
14991584
is_paused = True
1585+
15001586
slack_action = NimbusConstants.SLACK_ACTION_END_ENROLLMENT_REQUEST
15011587

15021588
def clean(self):
1589+
cleaned_data = super().clean()
1590+
15031591
if self.instance and self.instance.is_rollout_dirty:
15041592
raise forms.ValidationError(NimbusExperiment.ERROR_CANNOT_PAUSE_UNPUBLISHED)
15051593

1506-
if not self.instance.should_show_end_enrollment:
1507-
if (
1508-
self.instance.is_draft
1509-
or self.instance.is_preview
1510-
or self.instance.is_complete
1511-
):
1512-
raise forms.ValidationError(NimbusExperiment.ERROR_CANNOT_PAUSE_NOT_LIVE)
1513-
1514-
if not self.instance.is_enrolling:
1515-
raise forms.ValidationError(NimbusExperiment.ERROR_CANNOT_PAUSE_PAUSED)
1594+
if self.instance.is_rollout and not self.instance.is_firefox_labs_opt_in:
1595+
raise forms.ValidationError(NimbusExperiment.ERROR_CANNOT_PAUSE_ROLLOUT)
15161596

1517-
if self.instance.is_rollout and not self.instance.is_firefox_labs_opt_in:
1518-
raise forms.ValidationError(NimbusExperiment.ERROR_CANNOT_PAUSE_ROLLOUT)
1519-
1520-
# The conditions for Experiment.should_show_enrollment have changed
1521-
# but this function has become out of sync.
1522-
raise forms.ValidationError(
1523-
NimbusExperiment.ERROR_CANNOT_PAUSE_INVALID
1524-
) # pragma: no cover
1525-
1526-
return super().clean()
1597+
return cleaned_data
15271598

15281599
def get_changelog_message(self):
15291600
return f"{self.request.user} requested review to end enrollment"
15301601

15311602

15321603
class ApproveEndEnrollmentForm(UpdateStatusForm):
1604+
required_status = NimbusExperiment.Status.LIVE
1605+
required_status_next = NimbusExperiment.Status.LIVE
1606+
required_publish_status = NimbusExperiment.PublishStatus.REVIEW
1607+
required_is_paused = True
1608+
15331609
status = NimbusExperiment.Status.LIVE
15341610
status_next = NimbusExperiment.Status.LIVE
15351611
publish_status = NimbusExperiment.PublishStatus.APPROVED
1612+
is_paused = True
15361613

15371614
def get_changelog_message(self):
15381615
return f"{self.request.user} approved the end enrollment request"
@@ -1547,19 +1624,32 @@ def save(self, commit=True):
15471624

15481625

15491626
class LiveToCompleteForm(SlackNotificationMixin, UpdateStatusForm):
1627+
required_status = NimbusExperiment.Status.LIVE
1628+
required_status_next = None
1629+
required_publish_status = NimbusExperiment.PublishStatus.IDLE
1630+
required_is_paused = False
1631+
15501632
status = NimbusExperiment.Status.LIVE
15511633
status_next = NimbusExperiment.Status.COMPLETE
15521634
publish_status = NimbusExperiment.PublishStatus.REVIEW
1635+
is_paused = False
1636+
15531637
slack_action = NimbusConstants.SLACK_ACTION_END_EXPERIMENT_REQUEST
15541638

15551639
def get_changelog_message(self):
15561640
return f"{self.request.user} requested review to end experiment"
15571641

15581642

15591643
class ApproveEndExperimentForm(UpdateStatusForm):
1644+
required_status = NimbusExperiment.Status.LIVE
1645+
required_status_next = NimbusExperiment.Status.COMPLETE
1646+
required_publish_status = NimbusExperiment.PublishStatus.REVIEW
1647+
required_is_paused = None
1648+
15601649
status = NimbusExperiment.Status.LIVE
15611650
status_next = NimbusExperiment.Status.COMPLETE
15621651
publish_status = NimbusExperiment.PublishStatus.APPROVED
1652+
is_paused = True
15631653

15641654
def get_changelog_message(self):
15651655
return f"{self.request.user} approved the end experiment request"
@@ -1574,10 +1664,16 @@ def save(self, commit=True):
15741664

15751665

15761666
class CancelEndEnrollmentForm(UpdateStatusForm):
1667+
required_status = NimbusExperiment.Status.LIVE
1668+
required_status_next = NimbusExperiment.Status.LIVE
1669+
required_publish_status = NimbusExperiment.PublishStatus.REVIEW
1670+
required_is_paused = True
1671+
15771672
status = NimbusExperiment.Status.LIVE
15781673
status_next = None
15791674
publish_status = NimbusExperiment.PublishStatus.IDLE
15801675
is_paused = False
1676+
15811677
changelog_message = forms.CharField(
15821678
required=False, label="Changelog Message", max_length=1000
15831679
)
@@ -1596,9 +1692,15 @@ def get_changelog_message(self):
15961692

15971693

15981694
class CancelEndExperimentForm(UpdateStatusForm):
1695+
required_status = NimbusExperiment.Status.LIVE
1696+
required_status_next = NimbusExperiment.Status.COMPLETE
1697+
required_publish_status = NimbusExperiment.PublishStatus.REVIEW
1698+
required_is_paused = None
1699+
15991700
status = NimbusExperiment.Status.LIVE
16001701
status_next = None
16011702
publish_status = NimbusExperiment.PublishStatus.IDLE
1703+
16021704
changelog_message = forms.CharField(
16031705
required=False, label="Changelog Message", max_length=1000
16041706
)
@@ -1621,19 +1723,33 @@ def get_changelog_message(self):
16211723

16221724

16231725
class LiveToUpdateRolloutForm(SlackNotificationMixin, UpdateStatusForm):
1726+
required_status = NimbusExperiment.Status.LIVE
1727+
required_status_next = None
1728+
required_publish_status = NimbusExperiment.PublishStatus.IDLE
1729+
required_is_paused = False
1730+
16241731
status = NimbusExperiment.Status.LIVE
16251732
status_next = NimbusExperiment.Status.LIVE
16261733
publish_status = NimbusExperiment.PublishStatus.REVIEW
1734+
is_paused = False
1735+
16271736
slack_action = NimbusConstants.SLACK_ACTION_UPDATE_REQUEST
16281737

16291738
def get_changelog_message(self):
16301739
return f"{self.request.user} requested review to update Audience"
16311740

16321741

16331742
class CancelUpdateRolloutForm(UpdateStatusForm):
1743+
required_status = NimbusExperiment.Status.LIVE
1744+
required_status_next = NimbusExperiment.Status.LIVE
1745+
required_publish_status = NimbusExperiment.PublishStatus.REVIEW
1746+
required_is_paused = False
1747+
16341748
status = NimbusExperiment.Status.LIVE
16351749
status_next = None
16361750
publish_status = NimbusExperiment.PublishStatus.IDLE
1751+
is_paused = False
1752+
16371753
changelog_message = forms.CharField(
16381754
required=False, label="Changelog Message", max_length=1000
16391755
)
@@ -1652,9 +1768,15 @@ def get_changelog_message(self):
16521768

16531769

16541770
class ApproveUpdateRolloutForm(UpdateStatusForm):
1771+
required_status = NimbusExperiment.Status.LIVE
1772+
required_status_next = NimbusExperiment.Status.LIVE
1773+
required_publish_status = NimbusExperiment.PublishStatus.REVIEW
1774+
required_is_paused = False
1775+
16551776
status = NimbusExperiment.Status.LIVE
16561777
status_next = NimbusExperiment.Status.LIVE
16571778
publish_status = NimbusExperiment.PublishStatus.APPROVED
1779+
is_paused = False
16581780

16591781
def get_changelog_message(self):
16601782
return f"{self.request.user} approved the update review request"

experimenter/experimenter/nimbus_ui/templates/nimbus_experiments/launch_controls.html

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,16 @@
2929
{% endif %}
3030
{% if update_status_form_errors %}
3131
<div class="alert alert-danger" role="alert">
32-
<p class="mb-1">Could not request an update for this experiment:</p>
33-
{{ update_status_form_errors }}
34-
<p class="mb-0">
35-
Please fix the above issues or ask in <code>#ask-experimenter</code>.
32+
<p class="mb-2">
33+
That action could not be completed because the experiment has changed.
34+
<a class="text-decoration-none"
35+
data-bs-toggle="collapse"
36+
href="#error-details"
37+
role="button"
38+
aria-expanded="false"
39+
aria-controls="error-details">See more <i class="fa-solid fa-chevron-down"></i></a>
3640
</p>
41+
<div class="collapse mt-2" id="error-details">{{ update_status_form_errors }}</div>
3742
</div>
3843
{% endif %}
3944
{% with rejection=experiment.rejection_block %}

0 commit comments

Comments
 (0)