Skip to content

Commit 8b2cb83

Browse files
authored
TP2000-1434 Prevent packaging queue race conditions (#1267)
* Acquire non-blocking lock on packaged workbasket rows while updating their positions * Add double-click prevention attribute to packaging queue buttons * Acquire blocking lock on all packaged workbasket rows while aggregating the highest queue position * Check that the packaged workbasket is still a queue member before updating queue positions * Bump pre-commit hook version * Apply given lock mode when locking a table * Use TableLock decorator on PackagedWorkBasketManager.create() * Lock rows from inside respective instance methods * Capitalise SQL keyword for consistency
1 parent 8d07ef2 commit 8b2cb83

File tree

7 files changed

+88
-15
lines changed

7 files changed

+88
-15
lines changed

.pre-commit-config.yaml

+3-3
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ repos:
44
hooks:
55
- id: add-trailing-comma
66
- repo: https://github.com/myint/autoflake.git
7-
rev: v2.2.1
7+
rev: v2.3.1
88
hooks:
99
- id: autoflake
1010
args: [
@@ -13,7 +13,7 @@ repos:
1313
"--remove-unused-variable",
1414
]
1515
- repo: https://github.com/pycqa/isort
16-
rev: 5.12.0
16+
rev: 5.13.2
1717
hooks:
1818
- id: isort
1919
args: [--force-single-line-imports]
@@ -28,7 +28,7 @@ repos:
2828
"--pre-summary-newline",
2929
]
3030
- repo: https://github.com/psf/black
31-
rev: 24.3.0
31+
rev: 24.4.2
3232
hooks:
3333
- id: black
3434
- repo: https://github.com/ikamensh/flynt/

common/util.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import magic
2626
import wrapt
2727
from defusedxml.common import DTDForbidden
28+
from django.apps import apps
2829
from django.conf import settings
2930
from django.db import transaction
3031
from django.db.models import F
@@ -435,7 +436,11 @@ def wrapper(wrapped, instance, args, kwargs):
435436
with atomic():
436437
with transaction.get_connection().cursor() as cursor:
437438
for model in models:
438-
cursor.execute(f"LOCK TABLE {model._meta.db_table}")
439+
if isinstance(model, str):
440+
model = apps.get_model(model)
441+
cursor.execute(
442+
f"LOCK TABLE {model._meta.db_table} IN {lock} MODE",
443+
)
439444

440445
return wrapped(*args, **kwargs)
441446

publishing/jinja2/includes/envelope-queue.jinja

+1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
class="govuk-link fake-link process-envelope"
3131
name="process_envelope"
3232
value="{{ obj.pk }}"
33+
data-prevent-double-click="true"
3334
>
3435
Start processing <span class="right-arrow">&#9654;</span>
3536
</button>

publishing/jinja2/includes/packaged-workbasket-queue.jinja

+5
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
class="govuk-link fake-link"
77
name="promote_position"
88
value="{{ obj.pk }}"
9+
data-prevent-double-click="true"
910
>
1011
<img
1112
class="icon"
@@ -21,6 +22,7 @@
2122
class="govuk-link fake-link"
2223
name="demote_position"
2324
value="{{ obj.pk }}"
25+
data-prevent-double-click="true"
2426
>
2527
<img
2628
class="icon"
@@ -106,6 +108,7 @@
106108
class="govuk-link fake-link"
107109
name="demote_position"
108110
value="{{ obj.pk }}"
111+
data-prevent-double-click="true"
109112
>
110113
Move down
111114
</button>
@@ -115,6 +118,7 @@
115118
class="govuk-link fake-link"
116119
name="promote_to_top_position"
117120
value="{{ obj.pk }}"
121+
data-prevent-double-click="true"
118122
>
119123
Move to top
120124
</button>
@@ -128,6 +132,7 @@
128132
class="govuk-link fake-link"
129133
name="remove_from_queue"
130134
value="{{ obj.pk }}"
135+
data-prevent-double-click="true"
131136
>
132137
Remove
133138
</button>

publishing/models/packaged_workbasket.py

+36-10
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from django_fsm import transition
2525

2626
from common.models.mixins import TimestampedMixin
27+
from common.util import TableLock
2728
from notifications.models import EnvelopeAcceptedNotification
2829
from notifications.models import EnvelopeReadyForProcessingNotification
2930
from notifications.models import EnvelopeRejectedNotification
@@ -124,6 +125,7 @@ class PackagedWorkBasketInvalidQueueOperation(Exception):
124125

125126
class PackagedWorkBasketManager(Manager):
126127
@atomic
128+
@TableLock.acquire_lock("publishing.PackagedWorkBasket", lock=TableLock.EXCLUSIVE)
127129
def create(self, workbasket: WorkBasket, **kwargs):
128130
"""Create a new instance, associating with workbasket."""
129131
if workbasket.status in WorkflowStatus.unchecked_statuses():
@@ -437,6 +439,7 @@ def begin_processing_condition_no_instances_currently_processing(self) -> bool:
437439

438440
return not PackagedWorkBasket.objects.currently_processing()
439441

442+
@atomic
440443
@pop_top_after
441444
@save_after
442445
@transition(
@@ -466,6 +469,7 @@ def begin_processing(self):
466469
multiple instances it's necessary for this method to perform a save()
467470
operation upon successful transitions.
468471
"""
472+
PackagedWorkBasket.objects.select_for_update(nowait=True).get(pk=self.pk)
469473
self.processing_started_at = datetime.now()
470474
self.save()
471475

@@ -621,7 +625,9 @@ def pop_top(self) -> "PackagedWorkBasket":
621625
"because it is not at position 1.",
622626
)
623627

624-
PackagedWorkBasket.objects.filter(position__gt=0).update(
628+
PackagedWorkBasket.objects.select_for_update(nowait=True).filter(
629+
position__gt=0,
630+
).update(
625631
position=F("position") - 1,
626632
)
627633
self.refresh_from_db()
@@ -638,6 +644,10 @@ def remove_from_queue(self) -> "PackagedWorkBasket":
638644
Management of the queued instance's `processing_state` is not altered by
639645
this function and should be managed separately by the caller.
640646
"""
647+
648+
PackagedWorkBasket.objects.select_for_update(nowait=True).get(pk=self.pk)
649+
self.refresh_from_db()
650+
641651
if self.position == 0:
642652
raise PackagedWorkBasketInvalidQueueOperation(
643653
"Unable to remove instance with a position value of 0 from "
@@ -648,7 +658,9 @@ def remove_from_queue(self) -> "PackagedWorkBasket":
648658
self.position = 0
649659
self.save()
650660

651-
PackagedWorkBasket.objects.filter(position__gt=current_position).update(
661+
PackagedWorkBasket.objects.select_for_update(nowait=True).filter(
662+
position__gt=current_position,
663+
).update(
652664
position=F("position") - 1,
653665
)
654666
self.refresh_from_db()
@@ -661,17 +673,21 @@ def promote_to_top_position(self) -> "PackagedWorkBasket":
661673
"""Promote the instance to the top position of the package processing
662674
queue so that it occupies position 1."""
663675

664-
if self.position == 1:
676+
PackagedWorkBasket.objects.select_for_update(nowait=True).get(pk=self.pk)
677+
self.refresh_from_db()
678+
679+
if self.position <= 1:
665680
return self
666681

667682
position = self.position
668683

669-
PackagedWorkBasket.objects.filter(
684+
PackagedWorkBasket.objects.select_for_update(nowait=True).filter(
670685
Q(position__gte=1) & Q(position__lt=position),
671686
).update(position=F("position") + 1)
672687

673688
self.position = 1
674689
self.save()
690+
self.refresh_from_db()
675691

676692
return self
677693

@@ -681,10 +697,15 @@ def promote_position(self) -> "PackagedWorkBasket":
681697
"""Promote the instance by one position up the package processing
682698
queue."""
683699

684-
if self.position == 1:
685-
return
700+
PackagedWorkBasket.objects.select_for_update(nowait=True).get(pk=self.pk)
701+
self.refresh_from_db()
686702

687-
obj_to_swap = PackagedWorkBasket.objects.get(position=self.position - 1)
703+
if self.position <= 1:
704+
return self
705+
706+
obj_to_swap = PackagedWorkBasket.objects.select_for_update(nowait=True).get(
707+
position=self.position - 1,
708+
)
688709
obj_to_swap.position += 1
689710
self.position -= 1
690711
PackagedWorkBasket.objects.bulk_update(
@@ -701,10 +722,15 @@ def demote_position(self) -> "PackagedWorkBasket":
701722
"""Demote the instance by one position down the package processing
702723
queue."""
703724

704-
if self.position == PackagedWorkBasket.objects.max_position():
705-
return
725+
PackagedWorkBasket.objects.select_for_update(nowait=True).get(pk=self.pk)
726+
self.refresh_from_db()
706727

707-
obj_to_swap = PackagedWorkBasket.objects.get(position=self.position + 1)
728+
if self.position in {0, PackagedWorkBasket.objects.max_position()}:
729+
return self
730+
731+
obj_to_swap = PackagedWorkBasket.objects.select_for_update(nowait=True).get(
732+
position=self.position + 1,
733+
)
708734
obj_to_swap.position -= 1
709735
self.position += 1
710736
PackagedWorkBasket.objects.bulk_update(

publishing/tests/test_models.py

+31
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,37 @@ def test_demote_position():
333333
assert initially_second.position == 1
334334

335335

336+
def test_cannot_promote_or_demote_removed_packaged_workbasket():
337+
"""Tests that packaged workbasket positions remain unchanged after
338+
attempting to reposition a packaged workbasket that has since been removed
339+
from the queue."""
340+
with patch(
341+
"publishing.tasks.create_xml_envelope_file.apply_async",
342+
return_value=MagicMock(id=factory.Faker("uuid4")),
343+
):
344+
factories.PackagedWorkBasketFactory()
345+
346+
with patch(
347+
"publishing.tasks.create_xml_envelope_file.apply_async",
348+
return_value=MagicMock(id=factory.Faker("uuid4")),
349+
):
350+
factories.PackagedWorkBasketFactory()
351+
352+
queued_pwb = PackagedWorkBasket.objects.get(position=1)
353+
removed_pwb = PackagedWorkBasket.objects.get(position=2)
354+
removed_pwb.abandon()
355+
356+
removed_pwb = removed_pwb.promote_to_top_position()
357+
assert removed_pwb.position == 0
358+
queued_pwb.refresh_from_db()
359+
assert queued_pwb.position == 1
360+
361+
removed_pwb = removed_pwb.demote_position()
362+
assert removed_pwb.position == 0
363+
queued_pwb.refresh_from_db()
364+
assert queued_pwb.position == 1
365+
366+
336367
def test_pause_and_unpause_queue(unpause_queue):
337368
assert not OperationalStatus.is_queue_paused()
338369
OperationalStatus.pause_queue(user=None)

publishing/views.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from django.conf import settings
44
from django.contrib.auth.mixins import PermissionRequiredMixin
55
from django.core.exceptions import ValidationError
6+
from django.db import OperationalError
67
from django.db.transaction import atomic
78
from django.http import HttpResponse
89
from django.shortcuts import redirect
@@ -101,6 +102,7 @@ def _promote_position(self, pk):
101102
except (
102103
PackagedWorkBasket.DoesNotExist,
103104
PackagedWorkBasketInvalidQueueOperation,
105+
OperationalError,
104106
):
105107
# Nothing to do in the case of these exceptions.
106108
pass
@@ -113,6 +115,7 @@ def _demote_position(self, pk):
113115
except (
114116
PackagedWorkBasket.DoesNotExist,
115117
PackagedWorkBasketInvalidQueueOperation,
118+
OperationalError,
116119
):
117120
# Nothing to do in the case of these exceptions.
118121
pass
@@ -125,6 +128,7 @@ def _promote_to_top_position(self, pk):
125128
except (
126129
PackagedWorkBasket.DoesNotExist,
127130
PackagedWorkBasketInvalidQueueOperation,
131+
OperationalError,
128132
):
129133
# Nothing to do in the case of these exceptions.
130134
pass
@@ -142,6 +146,7 @@ def _remove_from_queue(self, pk):
142146
PackagedWorkBasket.DoesNotExist,
143147
PackagedWorkBasketInvalidQueueOperation,
144148
TransitionNotAllowed,
149+
OperationalError,
145150
):
146151
# Nothing to do in the case of these exceptions.
147152
return self.view_url
@@ -189,7 +194,7 @@ def _process_envelope(self, pk):
189194
packaged_work_basket = PackagedWorkBasket.objects.get(pk=pk)
190195
try:
191196
packaged_work_basket.begin_processing()
192-
except TransitionNotAllowed:
197+
except (TransitionNotAllowed, OperationalError):
193198
# No error page right now, just reshow the list view.
194199
pass
195200

0 commit comments

Comments
 (0)