Skip to content

Commit 8677ce8

Browse files
committed
feat(nimbus): stricter status transitions
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 c3103c7 commit 8677ce8

File tree

5 files changed

+852
-149
lines changed

5 files changed

+852
-149
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: 135 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1351,10 +1351,41 @@ class UpdateStatusForm(NimbusChangeLogFormMixin, forms.ModelForm):
13511351
publish_status = None
13521352
is_paused = None
13531353

1354+
required_status = None
1355+
required_status_next = None
1356+
required_publish_status = None
1357+
required_is_paused = None
1358+
13541359
class Meta:
13551360
model = NimbusExperiment
13561361
fields = []
13571362

1363+
def clean(self):
1364+
cleaned_data = super().clean()
1365+
1366+
required_state = (
1367+
self.required_status,
1368+
self.required_status_next,
1369+
self.required_publish_status,
1370+
self.required_is_paused,
1371+
)
1372+
current_state = (
1373+
self.instance.status,
1374+
self.instance.status_next,
1375+
self.instance.publish_status,
1376+
self.instance.is_paused,
1377+
)
1378+
1379+
if required_state != current_state:
1380+
raise forms.ValidationError(
1381+
NimbusUIConstants.ERROR_INVALID_STATE_TRANSITION.format(
1382+
required_state=required_state,
1383+
current_state=current_state,
1384+
)
1385+
)
1386+
1387+
return cleaned_data
1388+
13581389
@transaction.atomic
13591390
def save(self, commit=True):
13601391
self.instance.status = self.status
@@ -1386,9 +1417,15 @@ def save(self, commit=True):
13861417

13871418

13881419
class DraftToPreviewForm(UpdateStatusForm):
1420+
required_status = NimbusExperiment.Status.DRAFT
1421+
required_status_next = None
1422+
required_publish_status = NimbusExperiment.PublishStatus.IDLE
1423+
required_is_paused = False
1424+
13891425
status = NimbusExperiment.Status.PREVIEW
1390-
status_next = NimbusExperiment.Status.PREVIEW
1426+
status_next = None
13911427
publish_status = NimbusExperiment.PublishStatus.IDLE
1428+
is_paused = False
13921429

13931430
def get_changelog_message(self):
13941431
return f"{self.request.user} launched experiment to Preview"
@@ -1403,29 +1440,49 @@ def save(self, commit=True):
14031440

14041441

14051442
class DraftToReviewForm(SlackNotificationMixin, UpdateStatusForm):
1443+
required_status = NimbusExperiment.Status.DRAFT
1444+
required_status_next = None
1445+
required_publish_status = NimbusExperiment.PublishStatus.IDLE
1446+
required_is_paused = False
1447+
14061448
status = NimbusExperiment.Status.DRAFT
14071449
status_next = NimbusExperiment.Status.LIVE
14081450
publish_status = NimbusExperiment.PublishStatus.REVIEW
1451+
is_paused = False
1452+
14091453
slack_action = NimbusConstants.SLACK_ACTION_LAUNCH_REQUEST
14101454

14111455
def get_changelog_message(self):
14121456
return f"{self.request.user} requested launch without Preview"
14131457

14141458

14151459
class PreviewToReviewForm(SlackNotificationMixin, UpdateStatusForm):
1460+
required_status = NimbusExperiment.Status.PREVIEW
1461+
required_status_next = None
1462+
required_publish_status = NimbusExperiment.PublishStatus.IDLE
1463+
required_is_paused = False
1464+
14161465
status = NimbusExperiment.Status.DRAFT
14171466
status_next = NimbusExperiment.Status.LIVE
14181467
publish_status = NimbusExperiment.PublishStatus.REVIEW
1468+
is_paused = False
1469+
14191470
slack_action = NimbusConstants.SLACK_ACTION_LAUNCH_REQUEST
14201471

14211472
def get_changelog_message(self):
14221473
return f"{self.request.user} requested launch from Preview"
14231474

14241475

14251476
class PreviewToDraftForm(UpdateStatusForm):
1477+
required_status = NimbusExperiment.Status.PREVIEW
1478+
required_status_next = None
1479+
required_publish_status = NimbusExperiment.PublishStatus.IDLE
1480+
required_is_paused = False
1481+
14261482
status = NimbusExperiment.Status.DRAFT
1427-
status_next = NimbusExperiment.Status.DRAFT
1483+
status_next = None
14281484
publish_status = NimbusExperiment.PublishStatus.IDLE
1485+
is_paused = False
14291486

14301487
def get_changelog_message(self):
14311488
return f"{self.request.user} moved the experiment back to Draft"
@@ -1438,9 +1495,16 @@ def save(self, commit=True):
14381495

14391496

14401497
class ReviewToDraftForm(UpdateStatusForm):
1498+
required_status = NimbusExperiment.Status.DRAFT
1499+
required_status_next = NimbusExperiment.Status.LIVE
1500+
required_publish_status = NimbusExperiment.PublishStatus.REVIEW
1501+
required_is_paused = False
1502+
14411503
status = NimbusExperiment.Status.DRAFT
1442-
status_next = NimbusExperiment.Status.DRAFT
1504+
status_next = None
14431505
publish_status = NimbusExperiment.PublishStatus.IDLE
1506+
is_paused = False
1507+
14441508
changelog_message = forms.CharField(
14451509
required=False, label="Changelog Message", max_length=1000
14461510
)
@@ -1459,9 +1523,15 @@ def get_changelog_message(self):
14591523

14601524

14611525
class ReviewToApproveForm(UpdateStatusForm):
1526+
required_status = NimbusExperiment.Status.DRAFT
1527+
required_status_next = NimbusExperiment.Status.LIVE
1528+
required_publish_status = NimbusExperiment.PublishStatus.REVIEW
1529+
required_is_paused = False
1530+
14621531
status = NimbusExperiment.Status.DRAFT
14631532
status_next = NimbusExperiment.Status.LIVE
14641533
publish_status = NimbusExperiment.PublishStatus.APPROVED
1534+
is_paused = False
14651535

14661536
def get_changelog_message(self):
14671537
return f"{self.request.user} approved the review."
@@ -1478,46 +1548,43 @@ def save(self, commit=True):
14781548

14791549

14801550
class LiveToEndEnrollmentForm(SlackNotificationMixin, UpdateStatusForm):
1551+
required_status = NimbusExperiment.Status.LIVE
1552+
required_status_next = None
1553+
required_publish_status = NimbusExperiment.PublishStatus.IDLE
1554+
required_is_paused = False
1555+
14811556
status = NimbusExperiment.Status.LIVE
14821557
status_next = NimbusExperiment.Status.LIVE
14831558
publish_status = NimbusExperiment.PublishStatus.REVIEW
14841559
is_paused = True
1560+
14851561
slack_action = NimbusConstants.SLACK_ACTION_END_ENROLLMENT_REQUEST
14861562

14871563
def clean(self):
1564+
cleaned_data = super().clean()
1565+
14881566
if self.instance and self.instance.is_rollout_dirty:
14891567
raise forms.ValidationError(NimbusExperiment.ERROR_CANNOT_PAUSE_UNPUBLISHED)
14901568

1491-
if not self.instance.should_show_end_enrollment:
1492-
if (
1493-
self.instance.is_draft
1494-
or self.instance.is_preview
1495-
or self.instance.is_complete
1496-
):
1497-
raise forms.ValidationError(NimbusExperiment.ERROR_CANNOT_PAUSE_NOT_LIVE)
1498-
1499-
if not self.instance.is_enrolling:
1500-
raise forms.ValidationError(NimbusExperiment.ERROR_CANNOT_PAUSE_PAUSED)
1501-
1502-
if self.instance.is_rollout and not self.instance.is_firefox_labs_opt_in:
1503-
raise forms.ValidationError(NimbusExperiment.ERROR_CANNOT_PAUSE_ROLLOUT)
1569+
if self.instance.is_rollout and not self.instance.is_firefox_labs_opt_in:
1570+
raise forms.ValidationError(NimbusExperiment.ERROR_CANNOT_PAUSE_ROLLOUT)
15041571

1505-
# The conditions for Experiment.should_show_enrollment have changed
1506-
# but this function has become out of sync.
1507-
raise forms.ValidationError(
1508-
NimbusExperiment.ERROR_CANNOT_PAUSE_INVALID
1509-
) # pragma: no cover
1510-
1511-
return super().clean()
1572+
return cleaned_data
15121573

15131574
def get_changelog_message(self):
15141575
return f"{self.request.user} requested review to end enrollment"
15151576

15161577

15171578
class ApproveEndEnrollmentForm(UpdateStatusForm):
1579+
required_status = NimbusExperiment.Status.LIVE
1580+
required_status_next = NimbusExperiment.Status.LIVE
1581+
required_publish_status = NimbusExperiment.PublishStatus.REVIEW
1582+
required_is_paused = True
1583+
15181584
status = NimbusExperiment.Status.LIVE
15191585
status_next = NimbusExperiment.Status.LIVE
15201586
publish_status = NimbusExperiment.PublishStatus.APPROVED
1587+
is_paused = True
15211588

15221589
def get_changelog_message(self):
15231590
return f"{self.request.user} approved the end enrollment request"
@@ -1532,19 +1599,32 @@ def save(self, commit=True):
15321599

15331600

15341601
class LiveToCompleteForm(SlackNotificationMixin, UpdateStatusForm):
1602+
required_status = NimbusExperiment.Status.LIVE
1603+
required_status_next = None
1604+
required_publish_status = NimbusExperiment.PublishStatus.IDLE
1605+
required_is_paused = False
1606+
15351607
status = NimbusExperiment.Status.LIVE
15361608
status_next = NimbusExperiment.Status.COMPLETE
15371609
publish_status = NimbusExperiment.PublishStatus.REVIEW
1610+
is_paused = False
1611+
15381612
slack_action = NimbusConstants.SLACK_ACTION_END_EXPERIMENT_REQUEST
15391613

15401614
def get_changelog_message(self):
15411615
return f"{self.request.user} requested review to end experiment"
15421616

15431617

15441618
class ApproveEndExperimentForm(UpdateStatusForm):
1619+
required_status = NimbusExperiment.Status.LIVE
1620+
required_status_next = NimbusExperiment.Status.COMPLETE
1621+
required_publish_status = NimbusExperiment.PublishStatus.REVIEW
1622+
required_is_paused = True
1623+
15451624
status = NimbusExperiment.Status.LIVE
15461625
status_next = NimbusExperiment.Status.COMPLETE
15471626
publish_status = NimbusExperiment.PublishStatus.APPROVED
1627+
is_paused = True
15481628

15491629
def get_changelog_message(self):
15501630
return f"{self.request.user} approved the end experiment request"
@@ -1559,10 +1639,16 @@ def save(self, commit=True):
15591639

15601640

15611641
class CancelEndEnrollmentForm(UpdateStatusForm):
1642+
required_status = NimbusExperiment.Status.LIVE
1643+
required_status_next = NimbusExperiment.Status.LIVE
1644+
required_publish_status = NimbusExperiment.PublishStatus.REVIEW
1645+
required_is_paused = True
1646+
15621647
status = NimbusExperiment.Status.LIVE
15631648
status_next = None
15641649
publish_status = NimbusExperiment.PublishStatus.IDLE
15651650
is_paused = False
1651+
15661652
changelog_message = forms.CharField(
15671653
required=False, label="Changelog Message", max_length=1000
15681654
)
@@ -1581,9 +1667,15 @@ def get_changelog_message(self):
15811667

15821668

15831669
class CancelEndExperimentForm(UpdateStatusForm):
1670+
required_status = NimbusExperiment.Status.LIVE
1671+
required_status_next = NimbusExperiment.Status.COMPLETE
1672+
required_publish_status = NimbusExperiment.PublishStatus.REVIEW
1673+
required_is_paused = True
1674+
15841675
status = NimbusExperiment.Status.LIVE
15851676
status_next = None
15861677
publish_status = NimbusExperiment.PublishStatus.IDLE
1678+
15871679
changelog_message = forms.CharField(
15881680
required=False, label="Changelog Message", max_length=1000
15891681
)
@@ -1606,19 +1698,33 @@ def get_changelog_message(self):
16061698

16071699

16081700
class LiveToUpdateRolloutForm(SlackNotificationMixin, UpdateStatusForm):
1701+
required_status = NimbusExperiment.Status.LIVE
1702+
required_status_next = None
1703+
required_publish_status = NimbusExperiment.PublishStatus.IDLE
1704+
required_is_paused = False
1705+
16091706
status = NimbusExperiment.Status.LIVE
16101707
status_next = NimbusExperiment.Status.LIVE
16111708
publish_status = NimbusExperiment.PublishStatus.REVIEW
1709+
is_paused = False
1710+
16121711
slack_action = NimbusConstants.SLACK_ACTION_UPDATE_REQUEST
16131712

16141713
def get_changelog_message(self):
16151714
return f"{self.request.user} requested review to update Audience"
16161715

16171716

16181717
class CancelUpdateRolloutForm(UpdateStatusForm):
1718+
required_status = NimbusExperiment.Status.LIVE
1719+
required_status_next = NimbusExperiment.Status.LIVE
1720+
required_publish_status = NimbusExperiment.PublishStatus.REVIEW
1721+
required_is_paused = False
1722+
16191723
status = NimbusExperiment.Status.LIVE
16201724
status_next = None
16211725
publish_status = NimbusExperiment.PublishStatus.IDLE
1726+
is_paused = False
1727+
16221728
changelog_message = forms.CharField(
16231729
required=False, label="Changelog Message", max_length=1000
16241730
)
@@ -1637,9 +1743,15 @@ def get_changelog_message(self):
16371743

16381744

16391745
class ApproveUpdateRolloutForm(UpdateStatusForm):
1746+
required_status = NimbusExperiment.Status.LIVE
1747+
required_status_next = NimbusExperiment.Status.LIVE
1748+
required_publish_status = NimbusExperiment.PublishStatus.REVIEW
1749+
required_is_paused = False
1750+
16401751
status = NimbusExperiment.Status.LIVE
16411752
status_next = NimbusExperiment.Status.LIVE
16421753
publish_status = NimbusExperiment.PublishStatus.APPROVED
1754+
is_paused = False
16431755

16441756
def get_changelog_message(self):
16451757
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)