mirror of
https://github.com/inventree/InvenTree.git
synced 2026-07-21 22:23:03 +00:00
Locking fixes (#12435)
* Secure row locks when trimming allocated stock * Secure row locks when completing a SalesOrder * Regression testing * Further improvements and regression testing * Ignore locking test for sqlite
This commit is contained in:
@@ -681,6 +681,16 @@ class Build(
|
|||||||
"""Action to be taken when a build is completed."""
|
"""Action to be taken when a build is completed."""
|
||||||
import build.tasks
|
import build.tasks
|
||||||
|
|
||||||
|
# Lock this build against concurrent transitions, and re-read the status
|
||||||
|
# from the database. Without this, two simultaneous completion requests
|
||||||
|
# could both offload the completion task (duplicate events and
|
||||||
|
# notifications; the stock-consuming side effects are themselves
|
||||||
|
# guarded against double-processing - see Build.subtract_allocated_stock()).
|
||||||
|
self.status = Build.objects.select_for_update().get(pk=self.pk).status
|
||||||
|
|
||||||
|
if self.status == BuildStatus.COMPLETE.value:
|
||||||
|
return
|
||||||
|
|
||||||
trim_allocated_stock = kwargs.pop('trim_allocated_stock', False)
|
trim_allocated_stock = kwargs.pop('trim_allocated_stock', False)
|
||||||
user = kwargs.pop('user', None)
|
user = kwargs.pop('user', None)
|
||||||
|
|
||||||
@@ -730,6 +740,12 @@ class Build(
|
|||||||
|
|
||||||
def _action_issue(self, *args, **kwargs):
|
def _action_issue(self, *args, **kwargs):
|
||||||
"""Perform the action to mark this order as PRODUCTION."""
|
"""Perform the action to mark this order as PRODUCTION."""
|
||||||
|
# Lock this build against concurrent transitions, and re-read the status
|
||||||
|
# from the database. Without this, two simultaneous requests can both
|
||||||
|
# observe an eligible status, and each would run the issue side effects
|
||||||
|
# (duplicate events and offloaded stock-check tasks).
|
||||||
|
self.status = Build.objects.select_for_update().get(pk=self.pk).status
|
||||||
|
|
||||||
if self.can_issue:
|
if self.can_issue:
|
||||||
self.status = BuildStatus.PRODUCTION.value
|
self.status = BuildStatus.PRODUCTION.value
|
||||||
self.save()
|
self.save()
|
||||||
@@ -757,6 +773,12 @@ class Build(
|
|||||||
|
|
||||||
def _action_hold(self, *args, **kwargs):
|
def _action_hold(self, *args, **kwargs):
|
||||||
"""Action to be taken when a build is placed on hold."""
|
"""Action to be taken when a build is placed on hold."""
|
||||||
|
# Lock this build against concurrent transitions, and re-read the status
|
||||||
|
# from the database. Without this, two simultaneous requests can both
|
||||||
|
# observe an eligible status, and each would run the hold side effects
|
||||||
|
# (duplicate events).
|
||||||
|
self.status = Build.objects.select_for_update().get(pk=self.pk).status
|
||||||
|
|
||||||
if self.can_hold:
|
if self.can_hold:
|
||||||
self.status = BuildStatus.ON_HOLD.value
|
self.status = BuildStatus.ON_HOLD.value
|
||||||
self.save()
|
self.save()
|
||||||
@@ -784,6 +806,16 @@ class Build(
|
|||||||
"""Action to be taken when a build is cancelled."""
|
"""Action to be taken when a build is cancelled."""
|
||||||
import build.tasks
|
import build.tasks
|
||||||
|
|
||||||
|
# Lock this build against concurrent transitions, and re-read the status
|
||||||
|
# from the database. Without this, two simultaneous cancellation requests
|
||||||
|
# could both offload the cancellation task (duplicate events and
|
||||||
|
# notifications; the stock-consuming side effects are themselves
|
||||||
|
# guarded against double-processing - see Build.subtract_allocated_stock()).
|
||||||
|
self.status = Build.objects.select_for_update().get(pk=self.pk).status
|
||||||
|
|
||||||
|
if self.status == BuildStatus.CANCELLED.value:
|
||||||
|
return
|
||||||
|
|
||||||
user = kwargs.pop('user', None)
|
user = kwargs.pop('user', None)
|
||||||
|
|
||||||
remove_allocated_stock = kwargs.get('remove_allocated_stock', False)
|
remove_allocated_stock = kwargs.get('remove_allocated_stock', False)
|
||||||
@@ -986,7 +1018,17 @@ class Build(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Find BuildItem objects to trim
|
# Find BuildItem objects to trim
|
||||||
for item in BuildItem.objects.filter(build_line=build_line):
|
# Lock these rows (in pk order, to avoid deadlocks against other
|
||||||
|
# allocation operations) so a concurrent allocation update cannot
|
||||||
|
# be silently overwritten by the quantity decrement below
|
||||||
|
items = (
|
||||||
|
BuildItem.objects
|
||||||
|
.select_for_update()
|
||||||
|
.filter(build_line=build_line)
|
||||||
|
.order_by('pk')
|
||||||
|
)
|
||||||
|
|
||||||
|
for item in items:
|
||||||
# Previous item completed the job
|
# Previous item completed the job
|
||||||
if reduce_by <= 0:
|
if reduce_by <= 0:
|
||||||
break
|
break
|
||||||
@@ -1013,11 +1055,20 @@ class Build(
|
|||||||
"""Returns a QuerySet object of all BuildItem objects which point back to this Build."""
|
"""Returns a QuerySet object of all BuildItem objects which point back to this Build."""
|
||||||
return BuildItem.objects.filter(build_line__build=self)
|
return BuildItem.objects.filter(build_line__build=self)
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
def subtract_allocated_stock(self, user) -> None:
|
def subtract_allocated_stock(self, user) -> None:
|
||||||
"""Removes the allocated untracked items from stock."""
|
"""Removes the allocated untracked items from stock."""
|
||||||
# Find all BuildItem objects which point to this build
|
# Find all BuildItem objects which point to this build
|
||||||
items = self.allocated_stock.filter(
|
# Lock these rows (in pk order, to avoid deadlocks against other
|
||||||
build_line__bom_item__sub_part__trackable=False
|
# allocation operations), so a duplicated call to this method (e.g. a
|
||||||
|
# redelivered background task, or a completion and cancellation racing
|
||||||
|
# against each other) cannot process - and double-consume - the same
|
||||||
|
# allocations
|
||||||
|
items = list(
|
||||||
|
self.allocated_stock
|
||||||
|
.filter(build_line__bom_item__sub_part__trackable=False)
|
||||||
|
.select_for_update()
|
||||||
|
.order_by('pk')
|
||||||
)
|
)
|
||||||
|
|
||||||
# Remove stock
|
# Remove stock
|
||||||
@@ -1025,7 +1076,7 @@ class Build(
|
|||||||
item.complete_allocation(user=user)
|
item.complete_allocation(user=user)
|
||||||
|
|
||||||
# Delete allocation
|
# Delete allocation
|
||||||
items.all().delete()
|
BuildItem.objects.filter(pk__in=[item.pk for item in items]).delete()
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def scrap_build_output(
|
def scrap_build_output(
|
||||||
@@ -2043,6 +2094,15 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel):
|
|||||||
|
|
||||||
item = self.stock_item
|
item = self.stock_item
|
||||||
|
|
||||||
|
# Lock the stock item's row and refresh its quantity, so the clamp and
|
||||||
|
# split decision below operate on the current committed quantity rather
|
||||||
|
# than a stale in-memory copy (concurrent allocations may have consumed
|
||||||
|
# stock from this same item in the meantime)
|
||||||
|
if not item.lock_quantity():
|
||||||
|
# The stock item no longer exists - remove this (now invalid) allocation
|
||||||
|
self.delete()
|
||||||
|
return
|
||||||
|
|
||||||
# Ensure we are not allocating more than available
|
# Ensure we are not allocating more than available
|
||||||
if quantity > item.quantity:
|
if quantity > item.quantity:
|
||||||
quantity = item.quantity
|
quantity = item.quantity
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
"""Unit tests for the 'build' models."""
|
"""Unit tests for the 'build' models."""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db import connection, transaction
|
||||||
from django.db.models import Sum
|
from django.db.models import Sum
|
||||||
|
from django.test import TransactionTestCase, skipUnlessDBFeature
|
||||||
from django.test.utils import override_settings
|
from django.test.utils import override_settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
@@ -586,6 +591,39 @@ class BuildTest(BuildTestBase):
|
|||||||
initial_output_count,
|
initial_output_count,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_cancel_stale_instance_is_noop(self):
|
||||||
|
"""A second cancellation attempt with a stale build instance must be a no-op.
|
||||||
|
|
||||||
|
Regression test: _action_cancel() offloaded the cancellation task
|
||||||
|
unconditionally, using no idempotency check at all, so two concurrent
|
||||||
|
cancellation requests (each holding its own instance of the build) could
|
||||||
|
both offload the cancellation task (duplicate events and notifications).
|
||||||
|
The status is now re-read (under lock) from the database, and a build
|
||||||
|
which is already cancelled is skipped.
|
||||||
|
"""
|
||||||
|
self.build.issue_build()
|
||||||
|
self.allocate_stock(None, {self.stock_1_2: 50})
|
||||||
|
|
||||||
|
# Two "concurrent" requests each hold their own instance of the build
|
||||||
|
build_a = Build.objects.get(pk=self.build.pk)
|
||||||
|
build_b = Build.objects.get(pk=self.build.pk)
|
||||||
|
|
||||||
|
build_a.cancel_build(None)
|
||||||
|
|
||||||
|
self.build.refresh_from_db()
|
||||||
|
self.assertEqual(self.build.status, status.BuildStatus.CANCELLED)
|
||||||
|
|
||||||
|
# The second (stale) instance still believes the build is in production -
|
||||||
|
# cancellation must be skipped based on the database state
|
||||||
|
self.assertEqual(build_b.status, status.BuildStatus.PRODUCTION)
|
||||||
|
|
||||||
|
with mock.patch('build.models.trigger_event') as trigger:
|
||||||
|
build_b.cancel_build(None)
|
||||||
|
trigger.assert_not_called()
|
||||||
|
|
||||||
|
self.build.refresh_from_db()
|
||||||
|
self.assertEqual(self.build.status, status.BuildStatus.CANCELLED)
|
||||||
|
|
||||||
def test_complete(self):
|
def test_complete(self):
|
||||||
"""Test completion of a build output."""
|
"""Test completion of a build output."""
|
||||||
self.stock_1_1.quantity = 1000
|
self.stock_1_1.quantity = 1000
|
||||||
@@ -763,6 +801,46 @@ class BuildTest(BuildTestBase):
|
|||||||
self.line_1.refresh_from_db()
|
self.line_1.refresh_from_db()
|
||||||
self.assertEqual(self.line_1.consumed, 0)
|
self.assertEqual(self.line_1.consumed, 0)
|
||||||
|
|
||||||
|
def test_complete_allocation_stale_item_instance(self):
|
||||||
|
"""complete_allocation() must not clamp/split using a stale stock_item.quantity.
|
||||||
|
|
||||||
|
Regression test: the allocated quantity was clamped, and the choice between
|
||||||
|
consuming the item directly versus splitting off a portion of it, was made
|
||||||
|
using self.stock_item.quantity as cached in memory on the BuildItem. If a
|
||||||
|
concurrent operation reduced the stock item's quantity after the BuildItem
|
||||||
|
was loaded, the stale value could select the "split" branch when only the
|
||||||
|
entire (now-reduced) item is actually available - causing splitStock() to
|
||||||
|
reject the now-oversized split. The stock item's row is now locked and its
|
||||||
|
quantity refreshed before the clamp/split decision is made.
|
||||||
|
"""
|
||||||
|
self.build.issue_build()
|
||||||
|
|
||||||
|
# A partial allocation - less than the full stock item quantity
|
||||||
|
alloc = BuildItem.objects.create(
|
||||||
|
build_line=self.line_1, stock_item=self.stock_1_2, quantity=60
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load a second copy of the allocation, caching the stock item's original quantity
|
||||||
|
stale_alloc = BuildItem.objects.get(pk=alloc.pk)
|
||||||
|
self.assertEqual(stale_alloc.stock_item.quantity, 100)
|
||||||
|
|
||||||
|
# Reduce the available stock *after* the stale copy was loaded
|
||||||
|
# (simulating a concurrent stock adjustment elsewhere)
|
||||||
|
self.stock_1_2.quantity = 40
|
||||||
|
self.stock_1_2.save()
|
||||||
|
|
||||||
|
# Completing the (stale) allocation must not raise, and must consume only
|
||||||
|
# the quantity which is actually available
|
||||||
|
stale_alloc.complete_allocation(user=self.user)
|
||||||
|
|
||||||
|
self.stock_1_2.refresh_from_db()
|
||||||
|
self.line_1.refresh_from_db()
|
||||||
|
|
||||||
|
# The entire (now-reduced) item is consumed directly - no split occurs
|
||||||
|
self.assertEqual(self.stock_1_2.quantity, 40)
|
||||||
|
self.assertEqual(self.stock_1_2.consumed_by, self.build)
|
||||||
|
self.assertEqual(self.line_1.consumed, 40)
|
||||||
|
|
||||||
def test_complete_with_required_tests(self):
|
def test_complete_with_required_tests(self):
|
||||||
"""Test the prevention completion when a required test is missing feature."""
|
"""Test the prevention completion when a required test is missing feature."""
|
||||||
# with required tests incompleted the save should fail
|
# with required tests incompleted the save should fail
|
||||||
@@ -1671,3 +1749,218 @@ class BuildTaskTests(BuildTestBase):
|
|||||||
self.assertFalse(output.is_building)
|
self.assertFalse(output.is_building)
|
||||||
# status=None should resolve to StockStatus.OK (the model default)
|
# status=None should resolve to StockStatus.OK (the model default)
|
||||||
self.assertEqual(output.status, StockStatus.OK.value)
|
self.assertEqual(output.status, StockStatus.OK.value)
|
||||||
|
|
||||||
|
|
||||||
|
@skipUnlessDBFeature('has_select_for_update')
|
||||||
|
class BuildTrimAllocatedStockConcurrencyTest(TransactionTestCase):
|
||||||
|
"""Genuine cross-transaction regression test for Build.trim_allocated_stock().
|
||||||
|
|
||||||
|
Uses two real threads (each with its own database connection) to reproduce
|
||||||
|
an actual overlapping-transaction race, rather than the "stale in-memory
|
||||||
|
instance" trick used elsewhere - trim_allocated_stock() always re-queries the
|
||||||
|
BuildItem rows fresh, so a stale Python object cannot reproduce this bug.
|
||||||
|
"""
|
||||||
|
|
||||||
|
fixtures = ['users']
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Create a minimal build/allocation setup for the concurrency test."""
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
self.user = get_user_model().objects.get(pk=1)
|
||||||
|
|
||||||
|
self.assembly = Part.objects.create(
|
||||||
|
name='Concurrency assembly',
|
||||||
|
description='Assembly for trim_allocated_stock concurrency test',
|
||||||
|
assembly=True,
|
||||||
|
)
|
||||||
|
self.sub_part = Part.objects.create(
|
||||||
|
name='Concurrency component',
|
||||||
|
description='Component for trim_allocated_stock concurrency test',
|
||||||
|
component=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
BomItem.objects.create(part=self.assembly, sub_part=self.sub_part, quantity=1)
|
||||||
|
|
||||||
|
self.build = Build.objects.create(
|
||||||
|
reference=generate_next_build_reference(),
|
||||||
|
part=self.assembly,
|
||||||
|
quantity=1,
|
||||||
|
issued_by=self.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.build_line = BuildLine.objects.get(build=self.build)
|
||||||
|
|
||||||
|
self.stock_item = StockItem.objects.create(part=self.sub_part, quantity=100)
|
||||||
|
|
||||||
|
# Over-allocate: only 1 is required, but 10 have been allocated
|
||||||
|
self.build_item = BuildItem.objects.create(
|
||||||
|
build_line=self.build_line, stock_item=self.stock_item, quantity=10
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_trim_allocated_stock_does_not_lose_concurrent_allocation(self):
|
||||||
|
"""trim_allocated_stock() must not lose a concurrent allocation update.
|
||||||
|
|
||||||
|
Regression test: BuildItem rows were read via a plain (unlocked) queryset,
|
||||||
|
reduced in Python, and written back with bulk_update(). A concurrent request
|
||||||
|
that increased the same BuildItem's quantity (e.g. a new allocation, which
|
||||||
|
locks the row via select_for_update while saving - see
|
||||||
|
BuildAllocationSerializer) could commit in between the read and the write,
|
||||||
|
and would be silently discarded by the stale trim calculation. The
|
||||||
|
BuildItem rows are now locked (select_for_update) before being read, so
|
||||||
|
the trim always operates on the latest committed quantity.
|
||||||
|
"""
|
||||||
|
lock_acquired = threading.Event()
|
||||||
|
release_lock = threading.Event()
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
def concurrent_allocation_increase():
|
||||||
|
"""Simulate a second request increasing the allocation.
|
||||||
|
|
||||||
|
Holds a row-level lock on the BuildItem (as the real allocation
|
||||||
|
endpoint does) until told to proceed, so the trim thread below is
|
||||||
|
forced to block on the same row.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
item = BuildItem.objects.select_for_update().get(
|
||||||
|
pk=self.build_item.pk
|
||||||
|
)
|
||||||
|
lock_acquired.set()
|
||||||
|
release_lock.wait(timeout=5)
|
||||||
|
item.quantity += 5
|
||||||
|
item.save()
|
||||||
|
except Exception as exc: # pragma: no cover - surfaced via errors list
|
||||||
|
errors.append(exc)
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def run_trim():
|
||||||
|
try:
|
||||||
|
self.build.trim_allocated_stock()
|
||||||
|
except Exception as exc: # pragma: no cover - surfaced via errors list
|
||||||
|
errors.append(exc)
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
alloc_thread = threading.Thread(target=concurrent_allocation_increase)
|
||||||
|
alloc_thread.start()
|
||||||
|
|
||||||
|
# Wait until the other thread holds the row lock
|
||||||
|
self.assertTrue(lock_acquired.wait(timeout=5))
|
||||||
|
|
||||||
|
trim_thread = threading.Thread(target=run_trim)
|
||||||
|
trim_thread.start()
|
||||||
|
|
||||||
|
# Give the trim thread a moment to reach (and block on) the row lock
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
# Release the lock - the concurrent allocation increase commits first
|
||||||
|
release_lock.set()
|
||||||
|
|
||||||
|
alloc_thread.join(timeout=5)
|
||||||
|
trim_thread.join(timeout=5)
|
||||||
|
|
||||||
|
self.assertFalse(alloc_thread.is_alive())
|
||||||
|
self.assertFalse(trim_thread.is_alive())
|
||||||
|
self.assertEqual(errors, [])
|
||||||
|
|
||||||
|
self.build_item.refresh_from_db()
|
||||||
|
|
||||||
|
# The concurrent increase (+5, giving 15) must not have been lost: the
|
||||||
|
# trim reduces by 9 (10 originally allocated - 1 actually needed) from
|
||||||
|
# whatever is actually committed, leaving 15 - 9 = 6. A lost update
|
||||||
|
# would instead leave 15 (the trim's reduction discarded) or 1 (the
|
||||||
|
# concurrent increase discarded).
|
||||||
|
self.assertEqual(self.build_item.quantity, 6)
|
||||||
|
|
||||||
|
|
||||||
|
@skipUnlessDBFeature('has_select_for_update')
|
||||||
|
class BuildSubtractAllocatedStockConcurrencyTest(TransactionTestCase):
|
||||||
|
"""Genuine cross-transaction regression test for Build.subtract_allocated_stock().
|
||||||
|
|
||||||
|
Uses two real threads (each with its own database connection) to reproduce
|
||||||
|
duplicated/overlapping execution - e.g. a redelivered 'complete_build' or
|
||||||
|
'cancel_build' background task - which cannot be reproduced with a single
|
||||||
|
stale Python instance, since this method always re-queries the BuildItem
|
||||||
|
rows fresh.
|
||||||
|
"""
|
||||||
|
|
||||||
|
fixtures = ['users']
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Create a minimal build/allocation setup for the concurrency test."""
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
self.user = get_user_model().objects.get(pk=1)
|
||||||
|
|
||||||
|
self.assembly = Part.objects.create(
|
||||||
|
name='Concurrency assembly 2',
|
||||||
|
description='Assembly for subtract_allocated_stock concurrency test',
|
||||||
|
assembly=True,
|
||||||
|
)
|
||||||
|
self.sub_part = Part.objects.create(
|
||||||
|
name='Concurrency component 2',
|
||||||
|
description='Component for subtract_allocated_stock concurrency test',
|
||||||
|
component=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
BomItem.objects.create(part=self.assembly, sub_part=self.sub_part, quantity=1)
|
||||||
|
|
||||||
|
self.build = Build.objects.create(
|
||||||
|
reference=generate_next_build_reference(),
|
||||||
|
part=self.assembly,
|
||||||
|
quantity=1,
|
||||||
|
issued_by=self.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.build_line = BuildLine.objects.get(build=self.build)
|
||||||
|
|
||||||
|
self.stock_item = StockItem.objects.create(part=self.sub_part, quantity=10)
|
||||||
|
|
||||||
|
self.build_item = BuildItem.objects.create(
|
||||||
|
build_line=self.build_line, stock_item=self.stock_item, quantity=10
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_subtract_allocated_stock_is_not_processed_twice(self):
|
||||||
|
"""A duplicated/concurrent call to subtract_allocated_stock() must not double-consume.
|
||||||
|
|
||||||
|
Regression test: BuildItem allocation rows were read via a plain (unlocked)
|
||||||
|
queryset before being consumed and deleted. Two overlapping calls to this
|
||||||
|
method (e.g. a redelivered completion/cancellation background task) could
|
||||||
|
each read the same still-existing allocation and both call
|
||||||
|
complete_allocation() on it - double-counting the BuildLine's 'consumed'
|
||||||
|
quantity. The BuildItem rows are now locked (select_for_update) before
|
||||||
|
being processed, so a duplicate call finds nothing left to do.
|
||||||
|
"""
|
||||||
|
start_barrier = threading.Barrier(2, timeout=5)
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
def run_subtract():
|
||||||
|
try:
|
||||||
|
start_barrier.wait()
|
||||||
|
self.build.subtract_allocated_stock(self.user)
|
||||||
|
except Exception as exc: # pragma: no cover - surfaced via errors list
|
||||||
|
errors.append(exc)
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
thread_a = threading.Thread(target=run_subtract)
|
||||||
|
thread_b = threading.Thread(target=run_subtract)
|
||||||
|
|
||||||
|
thread_a.start()
|
||||||
|
thread_b.start()
|
||||||
|
|
||||||
|
thread_a.join(timeout=5)
|
||||||
|
thread_b.join(timeout=5)
|
||||||
|
|
||||||
|
self.assertFalse(thread_a.is_alive())
|
||||||
|
self.assertFalse(thread_b.is_alive())
|
||||||
|
self.assertEqual(errors, [])
|
||||||
|
|
||||||
|
self.build_line.refresh_from_db()
|
||||||
|
|
||||||
|
# The allocation must only have been consumed once, no matter which
|
||||||
|
# thread "won" the race for the row lock
|
||||||
|
self.assertEqual(self.build_line.consumed, 10)
|
||||||
|
self.assertFalse(BuildItem.objects.filter(pk=self.build_item.pk).exists())
|
||||||
|
|||||||
@@ -851,6 +851,12 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
|||||||
|
|
||||||
Order must be currently PENDING.
|
Order must be currently PENDING.
|
||||||
"""
|
"""
|
||||||
|
# Lock this order against concurrent transitions, and re-read the status
|
||||||
|
# from the database. Without this, two simultaneous requests can both
|
||||||
|
# observe an eligible status, and each would run the transition side
|
||||||
|
# effects (duplicate events and notifications).
|
||||||
|
self.status = PurchaseOrder.objects.select_for_update().get(pk=self.pk).status
|
||||||
|
|
||||||
if self.can_issue:
|
if self.can_issue:
|
||||||
self.status = PurchaseOrderStatus.PLACED.value
|
self.status = PurchaseOrderStatus.PLACED.value
|
||||||
self.issue_date = InvenTree.helpers.current_date()
|
self.issue_date = InvenTree.helpers.current_date()
|
||||||
@@ -872,6 +878,12 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
|||||||
|
|
||||||
Order must be currently PLACED.
|
Order must be currently PLACED.
|
||||||
"""
|
"""
|
||||||
|
# Lock this order against concurrent transitions, and re-read the status
|
||||||
|
# from the database. Without this, two simultaneous requests can both
|
||||||
|
# observe status=PLACED, and each would run the completion side effects
|
||||||
|
# (duplicate events and pricing-update scheduling).
|
||||||
|
self.status = PurchaseOrder.objects.select_for_update().get(pk=self.pk).status
|
||||||
|
|
||||||
if self.status == PurchaseOrderStatus.PLACED:
|
if self.status == PurchaseOrderStatus.PLACED:
|
||||||
self.status = PurchaseOrderStatus.COMPLETE.value
|
self.status = PurchaseOrderStatus.COMPLETE.value
|
||||||
self.complete_date = InvenTree.helpers.current_date()
|
self.complete_date = InvenTree.helpers.current_date()
|
||||||
@@ -953,6 +965,12 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
|||||||
|
|
||||||
def _action_cancel(self, *args, **kwargs):
|
def _action_cancel(self, *args, **kwargs):
|
||||||
"""Marks the PurchaseOrder as CANCELLED."""
|
"""Marks the PurchaseOrder as CANCELLED."""
|
||||||
|
# Lock this order against concurrent transitions, and re-read the status
|
||||||
|
# from the database. Without this, two simultaneous requests can both
|
||||||
|
# observe an eligible status, and each would run the cancellation side
|
||||||
|
# effects (duplicate events and notifications).
|
||||||
|
self.status = PurchaseOrder.objects.select_for_update().get(pk=self.pk).status
|
||||||
|
|
||||||
if self.can_cancel:
|
if self.can_cancel:
|
||||||
self.status = PurchaseOrderStatus.CANCELLED.value
|
self.status = PurchaseOrderStatus.CANCELLED.value
|
||||||
self.save()
|
self.save()
|
||||||
@@ -978,6 +996,12 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
|||||||
|
|
||||||
def _action_hold(self, *args, **kwargs):
|
def _action_hold(self, *args, **kwargs):
|
||||||
"""Mark this purchase order as 'on hold'."""
|
"""Mark this purchase order as 'on hold'."""
|
||||||
|
# Lock this order against concurrent transitions, and re-read the status
|
||||||
|
# from the database. Without this, two simultaneous requests can both
|
||||||
|
# observe an eligible status, and each would run the hold side effects
|
||||||
|
# (duplicate events).
|
||||||
|
self.status = PurchaseOrder.objects.select_for_update().get(pk=self.pk).status
|
||||||
|
|
||||||
if self.can_hold:
|
if self.can_hold:
|
||||||
self.status = PurchaseOrderStatus.ON_HOLD.value
|
self.status = PurchaseOrderStatus.ON_HOLD.value
|
||||||
self.save()
|
self.save()
|
||||||
@@ -1807,6 +1831,12 @@ class SalesOrder(TotalPriceMixin, Order):
|
|||||||
|
|
||||||
def _action_place(self, *args, **kwargs):
|
def _action_place(self, *args, **kwargs):
|
||||||
"""Change this order from 'PENDING' to 'IN_PROGRESS'."""
|
"""Change this order from 'PENDING' to 'IN_PROGRESS'."""
|
||||||
|
# Lock this order against concurrent transitions, and re-read the status
|
||||||
|
# from the database. Without this, two simultaneous requests can both
|
||||||
|
# observe an eligible status, and each would run the transition side
|
||||||
|
# effects (duplicate events and notifications).
|
||||||
|
self.status = SalesOrder.objects.select_for_update().get(pk=self.pk).status
|
||||||
|
|
||||||
if self.can_issue:
|
if self.can_issue:
|
||||||
self.status = SalesOrderStatus.IN_PROGRESS.value
|
self.status = SalesOrderStatus.IN_PROGRESS.value
|
||||||
self.issue_date = InvenTree.helpers.current_date()
|
self.issue_date = InvenTree.helpers.current_date()
|
||||||
@@ -1833,6 +1863,12 @@ class SalesOrder(TotalPriceMixin, Order):
|
|||||||
|
|
||||||
def _action_hold(self, *args, **kwargs):
|
def _action_hold(self, *args, **kwargs):
|
||||||
"""Mark this sales order as 'on hold'."""
|
"""Mark this sales order as 'on hold'."""
|
||||||
|
# Lock this order against concurrent transitions, and re-read the status
|
||||||
|
# from the database. Without this, two simultaneous requests can both
|
||||||
|
# observe an eligible status, and each would run the hold side effects
|
||||||
|
# (duplicate events).
|
||||||
|
self.status = SalesOrder.objects.select_for_update().get(pk=self.pk).status
|
||||||
|
|
||||||
if self.can_hold:
|
if self.can_hold:
|
||||||
self.status = SalesOrderStatus.ON_HOLD.value
|
self.status = SalesOrderStatus.ON_HOLD.value
|
||||||
self.save()
|
self.save()
|
||||||
@@ -1844,6 +1880,13 @@ class SalesOrder(TotalPriceMixin, Order):
|
|||||||
"""Mark this order as "complete."""
|
"""Mark this order as "complete."""
|
||||||
user = kwargs.pop('user', None)
|
user = kwargs.pop('user', None)
|
||||||
|
|
||||||
|
# Lock this order against concurrent completion, and re-read the status
|
||||||
|
# from the database. Without this, two simultaneous completion requests
|
||||||
|
# can both observe an "open" status, and each would run the completion
|
||||||
|
# side effects (duplicate events, pricing updates, and shipped quantity
|
||||||
|
# updates against the virtual line items).
|
||||||
|
self.status = SalesOrder.objects.select_for_update().get(pk=self.pk).status
|
||||||
|
|
||||||
if not self.can_complete(**kwargs):
|
if not self.can_complete(**kwargs):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -1888,6 +1931,13 @@ class SalesOrder(TotalPriceMixin, Order):
|
|||||||
- Mark the order as 'cancelled'
|
- Mark the order as 'cancelled'
|
||||||
- Delete any StockItems which have been allocated
|
- Delete any StockItems which have been allocated
|
||||||
"""
|
"""
|
||||||
|
# Lock this order against concurrent transitions, and re-read the status
|
||||||
|
# from the database. Without this, two simultaneous requests can both
|
||||||
|
# observe an "open" status, and each would run the cancellation side
|
||||||
|
# effects (duplicate events; the allocation deletion itself is
|
||||||
|
# idempotent).
|
||||||
|
self.status = SalesOrder.objects.select_for_update().get(pk=self.pk).status
|
||||||
|
|
||||||
if not self.can_cancel:
|
if not self.can_cancel:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -3176,6 +3226,12 @@ class ReturnOrder(TotalPriceMixin, Order):
|
|||||||
|
|
||||||
def _action_hold(self, *args, **kwargs):
|
def _action_hold(self, *args, **kwargs):
|
||||||
"""Mark this order as 'on hold' (if allowed)."""
|
"""Mark this order as 'on hold' (if allowed)."""
|
||||||
|
# Lock this order against concurrent transitions, and re-read the status
|
||||||
|
# from the database. Without this, two simultaneous requests can both
|
||||||
|
# observe an eligible status, and each would run the hold side effects
|
||||||
|
# (duplicate events).
|
||||||
|
self.status = ReturnOrder.objects.select_for_update().get(pk=self.pk).status
|
||||||
|
|
||||||
if self.can_hold:
|
if self.can_hold:
|
||||||
self.status = ReturnOrderStatus.ON_HOLD.value
|
self.status = ReturnOrderStatus.ON_HOLD.value
|
||||||
self.save()
|
self.save()
|
||||||
@@ -3189,6 +3245,12 @@ class ReturnOrder(TotalPriceMixin, Order):
|
|||||||
|
|
||||||
def _action_cancel(self, *args, **kwargs):
|
def _action_cancel(self, *args, **kwargs):
|
||||||
"""Cancel this ReturnOrder (if not already cancelled)."""
|
"""Cancel this ReturnOrder (if not already cancelled)."""
|
||||||
|
# Lock this order against concurrent transitions, and re-read the status
|
||||||
|
# from the database. Without this, two simultaneous requests can both
|
||||||
|
# observe an eligible status, and each would run the cancellation side
|
||||||
|
# effects (duplicate events and notifications).
|
||||||
|
self.status = ReturnOrder.objects.select_for_update().get(pk=self.pk).status
|
||||||
|
|
||||||
if self.can_cancel:
|
if self.can_cancel:
|
||||||
self.status = ReturnOrderStatus.CANCELLED.value
|
self.status = ReturnOrderStatus.CANCELLED.value
|
||||||
self.save()
|
self.save()
|
||||||
@@ -3233,6 +3295,12 @@ class ReturnOrder(TotalPriceMixin, Order):
|
|||||||
|
|
||||||
def _action_place(self, *args, **kwargs):
|
def _action_place(self, *args, **kwargs):
|
||||||
"""Issue this ReturnOrder (if currently pending)."""
|
"""Issue this ReturnOrder (if currently pending)."""
|
||||||
|
# Lock this order against concurrent transitions, and re-read the status
|
||||||
|
# from the database. Without this, two simultaneous requests can both
|
||||||
|
# observe an eligible status, and each would run the issue side effects
|
||||||
|
# (duplicate events and notifications).
|
||||||
|
self.status = ReturnOrder.objects.select_for_update().get(pk=self.pk).status
|
||||||
|
|
||||||
if self.can_issue:
|
if self.can_issue:
|
||||||
self.status = ReturnOrderStatus.IN_PROGRESS.value
|
self.status = ReturnOrderStatus.IN_PROGRESS.value
|
||||||
self.issue_date = InvenTree.helpers.current_date()
|
self.issue_date = InvenTree.helpers.current_date()
|
||||||
@@ -3704,6 +3772,12 @@ class TransferOrder(Order):
|
|||||||
|
|
||||||
Order must be currently PENDING.
|
Order must be currently PENDING.
|
||||||
"""
|
"""
|
||||||
|
# Lock this order against concurrent transitions, and re-read the status
|
||||||
|
# from the database. Without this, two simultaneous requests can both
|
||||||
|
# observe an eligible status, and each would run the issue side effects
|
||||||
|
# (duplicate events and notifications).
|
||||||
|
self.status = TransferOrder.objects.select_for_update().get(pk=self.pk).status
|
||||||
|
|
||||||
if self.can_issue:
|
if self.can_issue:
|
||||||
self.status = TransferOrderStatus.ISSUED.value
|
self.status = TransferOrderStatus.ISSUED.value
|
||||||
self.issue_date = InvenTree.helpers.current_date()
|
self.issue_date = InvenTree.helpers.current_date()
|
||||||
@@ -3730,6 +3804,12 @@ class TransferOrder(Order):
|
|||||||
|
|
||||||
def _action_hold(self, *args, **kwargs):
|
def _action_hold(self, *args, **kwargs):
|
||||||
"""Mark this transfer order as 'on hold'."""
|
"""Mark this transfer order as 'on hold'."""
|
||||||
|
# Lock this order against concurrent transitions, and re-read the status
|
||||||
|
# from the database. Without this, two simultaneous requests can both
|
||||||
|
# observe an eligible status, and each would run the hold side effects
|
||||||
|
# (duplicate events).
|
||||||
|
self.status = TransferOrder.objects.select_for_update().get(pk=self.pk).status
|
||||||
|
|
||||||
if self.can_hold:
|
if self.can_hold:
|
||||||
self.status = TransferOrderStatus.ON_HOLD.value
|
self.status = TransferOrderStatus.ON_HOLD.value
|
||||||
self.save()
|
self.save()
|
||||||
@@ -3809,6 +3889,13 @@ class TransferOrder(Order):
|
|||||||
- Mark the order as 'cancelled'
|
- Mark the order as 'cancelled'
|
||||||
- Delete any StockItems which have been allocated
|
- Delete any StockItems which have been allocated
|
||||||
"""
|
"""
|
||||||
|
# Lock this order against concurrent transitions, and re-read the status
|
||||||
|
# from the database. Without this, two simultaneous requests can both
|
||||||
|
# observe an "open" status, and each would run the cancellation side
|
||||||
|
# effects (duplicate events; the allocation deletion itself is
|
||||||
|
# idempotent).
|
||||||
|
self.status = TransferOrder.objects.select_for_update().get(pk=self.pk).status
|
||||||
|
|
||||||
if not self.can_cancel:
|
if not self.can_cancel:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -4077,6 +4164,12 @@ class TransferOrderAllocation(models.Model):
|
|||||||
self.item: stock.models.StockItem # for type hints
|
self.item: stock.models.StockItem # for type hints
|
||||||
self.line: TransferOrderLineItem # for type hints
|
self.line: TransferOrderLineItem # for type hints
|
||||||
|
|
||||||
|
# Lock the stock item's row and refresh its quantity, so the branch
|
||||||
|
# selected below (consume / split / move) is chosen using the current
|
||||||
|
# committed quantity rather than a stale in-memory copy
|
||||||
|
if not self.item.lock_quantity():
|
||||||
|
raise ValidationError(_('Stock item no longer exists'))
|
||||||
|
|
||||||
# The allocation is the only thing linking this stock item to the transfer
|
# The allocation is the only thing linking this stock item to the transfer
|
||||||
# As a result, we must keep the allocation present even after completion
|
# As a result, we must keep the allocation present even after completion
|
||||||
# This means allocations to transfer orders don't affect "available" stock
|
# This means allocations to transfer orders don't affect "available" stock
|
||||||
|
|||||||
@@ -675,6 +675,38 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
|
|
||||||
self.assertEqual(po.status, PurchaseOrderStatus.COMPLETE)
|
self.assertEqual(po.status, PurchaseOrderStatus.COMPLETE)
|
||||||
|
|
||||||
|
def test_po_complete_stale_instance_is_noop(self):
|
||||||
|
"""A second completion attempt with a stale order instance must be a no-op.
|
||||||
|
|
||||||
|
Regression test: _action_complete() checked 'status' on the caller's
|
||||||
|
(potentially stale) instance, so two concurrent completion requests could
|
||||||
|
both run the completion side effects (duplicate COMPLETED events and
|
||||||
|
duplicate pricing-update scheduling). The status is now re-read (under
|
||||||
|
lock) from the database before the check.
|
||||||
|
"""
|
||||||
|
po = models.PurchaseOrder.objects.get(pk=3)
|
||||||
|
self.assertEqual(po.status, PurchaseOrderStatus.PLACED)
|
||||||
|
|
||||||
|
# Two "concurrent" requests each hold their own instance of the order
|
||||||
|
po_a = models.PurchaseOrder.objects.get(pk=po.pk)
|
||||||
|
po_b = models.PurchaseOrder.objects.get(pk=po.pk)
|
||||||
|
|
||||||
|
po_a.complete_order()
|
||||||
|
|
||||||
|
po.refresh_from_db()
|
||||||
|
self.assertEqual(po.status, PurchaseOrderStatus.COMPLETE)
|
||||||
|
|
||||||
|
# The second (stale) instance still believes the order is PLACED -
|
||||||
|
# completion must be skipped based on the database state
|
||||||
|
self.assertEqual(po_b.status, PurchaseOrderStatus.PLACED)
|
||||||
|
|
||||||
|
with mock.patch('order.models.trigger_event') as trigger:
|
||||||
|
po_b.complete_order()
|
||||||
|
trigger.assert_not_called()
|
||||||
|
|
||||||
|
po.refresh_from_db()
|
||||||
|
self.assertEqual(po.status, PurchaseOrderStatus.COMPLETE)
|
||||||
|
|
||||||
def test_po_issue(self):
|
def test_po_issue(self):
|
||||||
"""Test the PurchaseOrderIssue API endpoint."""
|
"""Test the PurchaseOrderIssue API endpoint."""
|
||||||
po = models.PurchaseOrder.objects.get(pk=2)
|
po = models.PurchaseOrder.objects.get(pk=2)
|
||||||
@@ -3828,6 +3860,38 @@ class TransferOrderTest(OrderTest):
|
|||||||
|
|
||||||
self.assertEqual(to.status, TransferOrderStatus.CANCELLED)
|
self.assertEqual(to.status, TransferOrderStatus.CANCELLED)
|
||||||
|
|
||||||
|
def test_transfer_order_cancel_stale_instance_is_noop(self):
|
||||||
|
"""A second cancellation attempt with a stale order instance must be a no-op.
|
||||||
|
|
||||||
|
Regression test: _action_cancel() checked 'can_cancel' (derived from
|
||||||
|
'status') on the caller's (potentially stale) instance, so two concurrent
|
||||||
|
cancellation requests could both run the cancellation side effects
|
||||||
|
(duplicate CANCELLED events). The status is now re-read (under lock)
|
||||||
|
from the database before the check.
|
||||||
|
"""
|
||||||
|
to = models.TransferOrder.objects.get(pk=1)
|
||||||
|
self.assertEqual(to.status, TransferOrderStatus.PENDING)
|
||||||
|
|
||||||
|
# Two "concurrent" requests each hold their own instance of the order
|
||||||
|
instance_a = models.TransferOrder.objects.get(pk=to.pk)
|
||||||
|
instance_b = models.TransferOrder.objects.get(pk=to.pk)
|
||||||
|
|
||||||
|
instance_a.cancel_order()
|
||||||
|
|
||||||
|
to.refresh_from_db()
|
||||||
|
self.assertEqual(to.status, TransferOrderStatus.CANCELLED)
|
||||||
|
|
||||||
|
# The second (stale) instance still believes the order is PENDING -
|
||||||
|
# cancellation must be skipped based on the database state
|
||||||
|
self.assertEqual(instance_b.status, TransferOrderStatus.PENDING)
|
||||||
|
|
||||||
|
with mock.patch('order.models.trigger_event') as trigger:
|
||||||
|
instance_b.cancel_order()
|
||||||
|
trigger.assert_not_called()
|
||||||
|
|
||||||
|
to.refresh_from_db()
|
||||||
|
self.assertEqual(to.status, TransferOrderStatus.CANCELLED)
|
||||||
|
|
||||||
def test_transfer_order_hold(self):
|
def test_transfer_order_hold(self):
|
||||||
"""Test API endpoint for holdling a TransferOrder."""
|
"""Test API endpoint for holdling a TransferOrder."""
|
||||||
to = models.TransferOrder.objects.get(pk=1)
|
to = models.TransferOrder.objects.get(pk=1)
|
||||||
@@ -4155,6 +4219,60 @@ class TransferOrderTest(OrderTest):
|
|||||||
self.assertEqual(line.transferred, 4)
|
self.assertEqual(line.transferred, 4)
|
||||||
self.assertEqual(item.quantity, 0)
|
self.assertEqual(item.quantity, 0)
|
||||||
|
|
||||||
|
def test_transfer_order_partial_allocation_stale_item_instance(self):
|
||||||
|
"""A partial allocation must not fail completion if stock is reduced concurrently.
|
||||||
|
|
||||||
|
Regression test: transfer_quantity (and the choice between the "move" and
|
||||||
|
"split" branches) was computed from self.item.quantity as cached in memory
|
||||||
|
on the allocation instance. If the stock item's quantity was reduced by a
|
||||||
|
concurrent operation after the allocation was loaded, the stale value could
|
||||||
|
select the "split" branch when only a "move" of the entire (now-reduced)
|
||||||
|
item is actually possible - causing splitStock() to reject the now-oversized
|
||||||
|
split, and the whole order completion to fail. The stock item's row is now
|
||||||
|
locked and its quantity refreshed before the branch is chosen.
|
||||||
|
"""
|
||||||
|
self.assignRole('transfer_order.add')
|
||||||
|
destination = StockLocation.objects.first()
|
||||||
|
|
||||||
|
to = models.TransferOrder.objects.create(
|
||||||
|
reference='TO-STALE-ITEM', description='Test TO', destination=destination
|
||||||
|
)
|
||||||
|
|
||||||
|
part = Part.objects.exclude(virtual=True).first()
|
||||||
|
|
||||||
|
line = models.TransferOrderLineItem.objects.create(
|
||||||
|
order=to, part=part, quantity=6
|
||||||
|
)
|
||||||
|
|
||||||
|
to.issue_order()
|
||||||
|
|
||||||
|
item = StockItem.objects.create(part=part, quantity=10, batch='to-stale-item')
|
||||||
|
|
||||||
|
# A partial allocation - less than the full stock item quantity
|
||||||
|
allocation = models.TransferOrderAllocation.objects.create(
|
||||||
|
quantity=6, line=line, item=item
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load a second copy of the allocation, caching the item's original quantity
|
||||||
|
stale_allocation = models.TransferOrderAllocation.objects.get(pk=allocation.pk)
|
||||||
|
self.assertEqual(stale_allocation.item.quantity, 10)
|
||||||
|
|
||||||
|
# Reduce the available stock *after* the stale copy was loaded
|
||||||
|
# (simulating a concurrent stock adjustment)
|
||||||
|
item.quantity = 4
|
||||||
|
item.save()
|
||||||
|
|
||||||
|
# Completing the (stale) allocation must not raise, and must transfer
|
||||||
|
# only the quantity which is actually available
|
||||||
|
stale_allocation.complete_allocation(None)
|
||||||
|
|
||||||
|
item.refresh_from_db()
|
||||||
|
line.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEqual(item.location, destination)
|
||||||
|
self.assertEqual(item.quantity, 4)
|
||||||
|
self.assertEqual(line.transferred, 4)
|
||||||
|
|
||||||
def test_transfer_order_complete_stale_instance(self):
|
def test_transfer_order_complete_stale_instance(self):
|
||||||
"""A second completion attempt with a stale instance must be rejected.
|
"""A second completion attempt with a stale instance must be rejected.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Unit tests for the SalesOrder models."""
|
"""Unit tests for the SalesOrder models."""
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
@@ -279,6 +280,39 @@ class SalesOrderTest(InvenTreeAPITestCase):
|
|||||||
result = self.order.ship_order(None)
|
result = self.order.ship_order(None)
|
||||||
self.assertFalse(result)
|
self.assertFalse(result)
|
||||||
|
|
||||||
|
def test_order_cancel_stale_instance_is_noop(self):
|
||||||
|
"""A second cancellation attempt with a stale order instance must be a no-op.
|
||||||
|
|
||||||
|
Regression test: _action_cancel() checked 'status' on the caller's
|
||||||
|
(potentially stale) instance, so two concurrent cancellation requests
|
||||||
|
could both run the cancellation side effects (duplicate CANCELLED
|
||||||
|
events). The status is now re-read (under lock) from the database
|
||||||
|
before the check.
|
||||||
|
"""
|
||||||
|
self.allocate_stock(True)
|
||||||
|
self.assertEqual(SalesOrderAllocation.objects.count(), 2)
|
||||||
|
|
||||||
|
# Two "concurrent" requests each hold their own instance of the order
|
||||||
|
order_a = SalesOrder.objects.get(pk=self.order.pk)
|
||||||
|
order_b = SalesOrder.objects.get(pk=self.order.pk)
|
||||||
|
|
||||||
|
order_a.cancel_order()
|
||||||
|
|
||||||
|
self.order.refresh_from_db()
|
||||||
|
self.assertEqual(self.order.status, status.SalesOrderStatus.CANCELLED)
|
||||||
|
self.assertEqual(SalesOrderAllocation.objects.count(), 0)
|
||||||
|
|
||||||
|
# The second (stale) instance still believes the order is PENDING -
|
||||||
|
# cancellation must be skipped based on the database state
|
||||||
|
self.assertEqual(order_b.status, status.SalesOrderStatus.PENDING)
|
||||||
|
|
||||||
|
with mock.patch('order.models.trigger_event') as trigger:
|
||||||
|
order_b.cancel_order()
|
||||||
|
trigger.assert_not_called()
|
||||||
|
|
||||||
|
self.order.refresh_from_db()
|
||||||
|
self.assertEqual(self.order.status, status.SalesOrderStatus.CANCELLED)
|
||||||
|
|
||||||
def test_complete_order(self):
|
def test_complete_order(self):
|
||||||
"""Allocate line items, then ship the order."""
|
"""Allocate line items, then ship the order."""
|
||||||
# Assert some stuff before we run the test
|
# Assert some stuff before we run the test
|
||||||
@@ -368,6 +402,44 @@ class SalesOrderTest(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(self.line.fulfilled_quantity(), 50)
|
self.assertEqual(self.line.fulfilled_quantity(), 50)
|
||||||
self.assertEqual(self.line.allocated_quantity(), 50)
|
self.assertEqual(self.line.allocated_quantity(), 50)
|
||||||
|
|
||||||
|
def test_complete_order_stale_instance_is_noop(self):
|
||||||
|
"""A second completion attempt with a stale order instance must be a no-op.
|
||||||
|
|
||||||
|
Regression test: _action_complete() checked 'status' on the caller's
|
||||||
|
(potentially stale) instance, so two concurrent completion requests could
|
||||||
|
both run the completion side effects (duplicate COMPLETED events and
|
||||||
|
duplicate pricing-update scheduling). The status is now re-read (under
|
||||||
|
lock) from the database before the check.
|
||||||
|
"""
|
||||||
|
self.allocate_stock(True)
|
||||||
|
self.shipment.complete_shipment(None)
|
||||||
|
|
||||||
|
# Ship the order (PENDING -> SHIPPED)
|
||||||
|
self.assertTrue(self.order.ship_order(None))
|
||||||
|
self.order.refresh_from_db()
|
||||||
|
self.assertEqual(self.order.status, status.SalesOrderStatus.SHIPPED)
|
||||||
|
|
||||||
|
# Two "concurrent" requests each hold their own instance of the order,
|
||||||
|
# both about to transition SHIPPED -> COMPLETE
|
||||||
|
order_a = SalesOrder.objects.get(pk=self.order.pk)
|
||||||
|
order_b = SalesOrder.objects.get(pk=self.order.pk)
|
||||||
|
|
||||||
|
self.assertTrue(order_a.complete_order(None))
|
||||||
|
|
||||||
|
self.order.refresh_from_db()
|
||||||
|
self.assertEqual(self.order.status, status.SalesOrderStatus.COMPLETE)
|
||||||
|
|
||||||
|
# The second (stale) instance still believes the order is SHIPPED -
|
||||||
|
# completion must be skipped based on the database state
|
||||||
|
self.assertEqual(order_b.status, status.SalesOrderStatus.SHIPPED)
|
||||||
|
|
||||||
|
with mock.patch('order.models.trigger_event') as trigger:
|
||||||
|
self.assertFalse(order_b.complete_order(None))
|
||||||
|
trigger.assert_not_called()
|
||||||
|
|
||||||
|
self.order.refresh_from_db()
|
||||||
|
self.assertEqual(self.order.status, status.SalesOrderStatus.COMPLETE)
|
||||||
|
|
||||||
def test_complete_shipment_task_is_idempotent(self):
|
def test_complete_shipment_task_is_idempotent(self):
|
||||||
"""Duplicate execution of the shipment completion task must not double-ship.
|
"""Duplicate execution of the shipment completion task must not double-ship.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user