mirror of
https://github.com/inventree/InvenTree.git
synced 2026-08-10 23:46:21 +00:00
[bug] Fix concurrency issues for allocation (#12478)
* Fix concurrency issues for allocation * Remove duplicate atomic call * Reduce query count
This commit is contained in:
@@ -1599,6 +1599,11 @@ class Build(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Net additional quantity being requested against each StockItem by this call
|
||||||
|
# (regardless of whether it lands in a new or an existing BuildItem row)
|
||||||
|
requested = {}
|
||||||
|
stock_items = {}
|
||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
build_line = item['build_line']
|
build_line = item['build_line']
|
||||||
stock_item = item['stock_item']
|
stock_item = item['stock_item']
|
||||||
@@ -1628,6 +1633,49 @@ class Build(
|
|||||||
# This is a new BuildItem
|
# This is a new BuildItem
|
||||||
to_create[key] = BuildItem(quantity=quantity, **filters)
|
to_create[key] = BuildItem(quantity=quantity, **filters)
|
||||||
|
|
||||||
|
stock_items[stock_item.pk] = stock_item
|
||||||
|
requested[stock_item.pk] = requested.get(stock_item.pk, 0) + quantity
|
||||||
|
|
||||||
|
# Lock each referenced StockItem (in a consistent order, to avoid deadlocks
|
||||||
|
# against other allocation requests touching an overlapping set of items),
|
||||||
|
# and re-validate that the requested allocation still fits within the
|
||||||
|
# (now-locked, now-current) unallocated quantity of each item.
|
||||||
|
#
|
||||||
|
# Both the locking and the allocated-quantity lookup are done in bulk (rather
|
||||||
|
# than one query per StockItem) - critical for keeping the query count bounded
|
||||||
|
# when a single request references a large number of stock items.
|
||||||
|
pks = sorted(requested.keys())
|
||||||
|
|
||||||
|
locked_quantities = dict(
|
||||||
|
stock.models.StockItem.objects
|
||||||
|
.select_for_update()
|
||||||
|
.filter(pk__in=pks)
|
||||||
|
.order_by('pk')
|
||||||
|
.values_list('pk', 'quantity')
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(locked_quantities) != len(pks):
|
||||||
|
raise ValidationError({'stock_item': _('Stock item no longer exists')})
|
||||||
|
|
||||||
|
allocated_quantities = stock.models.StockItem.bulk_allocation_count(
|
||||||
|
stock_items.values()
|
||||||
|
)
|
||||||
|
|
||||||
|
for pk in pks:
|
||||||
|
stock_item = stock_items[pk]
|
||||||
|
stock_item.quantity = locked_quantities[pk]
|
||||||
|
|
||||||
|
available = max(
|
||||||
|
stock_item.quantity - allocated_quantities.get(pk, decimal.Decimal(0)),
|
||||||
|
decimal.Decimal(0),
|
||||||
|
)
|
||||||
|
|
||||||
|
if requested[pk] > available:
|
||||||
|
q = InvenTree.helpers.clean_decimal(available)
|
||||||
|
raise ValidationError({
|
||||||
|
'quantity': _(f'Available quantity ({q}) exceeded')
|
||||||
|
})
|
||||||
|
|
||||||
BuildItem.objects.bulk_create(to_create.values(), batch_size=250)
|
BuildItem.objects.bulk_create(to_create.values(), batch_size=250)
|
||||||
BuildItem.objects.bulk_update(to_update.values(), ['quantity'], batch_size=250)
|
BuildItem.objects.bulk_update(to_update.values(), ['quantity'], batch_size=250)
|
||||||
|
|
||||||
|
|||||||
@@ -2046,3 +2046,131 @@ class BuildSubtractAllocatedStockConcurrencyTest(TransactionTestCase):
|
|||||||
# thread "won" the race for the row lock
|
# thread "won" the race for the row lock
|
||||||
self.assertEqual(self.build_line.consumed, 10)
|
self.assertEqual(self.build_line.consumed, 10)
|
||||||
self.assertFalse(BuildItem.objects.filter(pk=self.build_item.pk).exists())
|
self.assertFalse(BuildItem.objects.filter(pk=self.build_item.pk).exists())
|
||||||
|
|
||||||
|
|
||||||
|
@skipUnlessDBFeature('has_select_for_update')
|
||||||
|
class BuildAllocateStockConcurrencyTest(TransactionTestCase):
|
||||||
|
"""Genuine cross-transaction regression test for Build.allocate_stock().
|
||||||
|
|
||||||
|
Uses two real threads (each with its own database connection) to reproduce
|
||||||
|
the reported race: two concurrent allocation requests, against two
|
||||||
|
different Builds but the *same* StockItem, could each read the item's
|
||||||
|
unallocated quantity before either had committed, and both create a
|
||||||
|
BuildItem for the full quantity - over-allocating the StockItem. Because
|
||||||
|
the requests target different Builds, the pre-existing Build-row lock
|
||||||
|
does not serialize them.
|
||||||
|
|
||||||
|
allocate_stock() now locks the referenced StockItem (select_for_update,
|
||||||
|
via StockItem.lock_quantity()) and re-validates the unallocated quantity
|
||||||
|
under that lock before writing, so only one of two concurrent
|
||||||
|
full-quantity allocation requests against a shared StockItem may succeed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
fixtures = ['users']
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Create two Builds which both require the same shared StockItem."""
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
self.user = get_user_model().objects.get(pk=1)
|
||||||
|
|
||||||
|
self.assembly_a = Part.objects.create(
|
||||||
|
name='Concurrency assembly A',
|
||||||
|
description='Assembly for allocate_stock concurrency test',
|
||||||
|
assembly=True,
|
||||||
|
)
|
||||||
|
self.assembly_b = Part.objects.create(
|
||||||
|
name='Concurrency assembly B',
|
||||||
|
description='Assembly for allocate_stock concurrency test',
|
||||||
|
assembly=True,
|
||||||
|
)
|
||||||
|
self.sub_part = Part.objects.create(
|
||||||
|
name='Shared concurrency component',
|
||||||
|
description='Component for allocate_stock concurrency test',
|
||||||
|
component=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
BomItem.objects.create(part=self.assembly_a, sub_part=self.sub_part, quantity=1)
|
||||||
|
BomItem.objects.create(part=self.assembly_b, sub_part=self.sub_part, quantity=1)
|
||||||
|
|
||||||
|
self.build_a = Build.objects.create(
|
||||||
|
reference=generate_next_build_reference(),
|
||||||
|
part=self.assembly_a,
|
||||||
|
quantity=1,
|
||||||
|
issued_by=self.user,
|
||||||
|
)
|
||||||
|
self.build_b = Build.objects.create(
|
||||||
|
reference=generate_next_build_reference(),
|
||||||
|
part=self.assembly_b,
|
||||||
|
quantity=1,
|
||||||
|
issued_by=self.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.build_line_a = BuildLine.objects.get(build=self.build_a)
|
||||||
|
self.build_line_b = BuildLine.objects.get(build=self.build_b)
|
||||||
|
|
||||||
|
# Only enough stock for *one* of the two full-quantity allocations below
|
||||||
|
self.stock_item = StockItem.objects.create(part=self.sub_part, quantity=5)
|
||||||
|
|
||||||
|
def test_concurrent_allocation_does_not_over_allocate(self):
|
||||||
|
"""Two concurrent full-quantity allocation requests must not both succeed."""
|
||||||
|
start_barrier = threading.Barrier(2, timeout=5)
|
||||||
|
errors = []
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# Wrap StockItem.lock_quantity() so both threads reach the (real,
|
||||||
|
# database-level) row lock at the same time - one wins the lock and
|
||||||
|
# proceeds, the other blocks until the winner's transaction completes.
|
||||||
|
original_lock_quantity = StockItem.lock_quantity
|
||||||
|
|
||||||
|
def synced_lock_quantity(self_item):
|
||||||
|
start_barrier.wait(timeout=5)
|
||||||
|
return original_lock_quantity(self_item)
|
||||||
|
|
||||||
|
def allocate(build, build_line):
|
||||||
|
try:
|
||||||
|
build.allocate_stock([
|
||||||
|
{
|
||||||
|
'build_line': build_line,
|
||||||
|
'stock_item': self.stock_item,
|
||||||
|
'quantity': 5,
|
||||||
|
}
|
||||||
|
])
|
||||||
|
results.append('ok')
|
||||||
|
except ValidationError:
|
||||||
|
results.append('rejected')
|
||||||
|
except Exception as exc: # pragma: no cover - surfaced via errors list
|
||||||
|
errors.append(exc)
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
thread_a = threading.Thread(
|
||||||
|
target=allocate, args=(self.build_a, self.build_line_a)
|
||||||
|
)
|
||||||
|
thread_b = threading.Thread(
|
||||||
|
target=allocate, args=(self.build_b, self.build_line_b)
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch.object(StockItem, 'lock_quantity', synced_lock_quantity):
|
||||||
|
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, [])
|
||||||
|
|
||||||
|
# Exactly one request must have been rejected as over-allocating
|
||||||
|
self.assertEqual(sorted(results), ['ok', 'rejected'])
|
||||||
|
|
||||||
|
total_allocated = (
|
||||||
|
BuildItem.objects.filter(stock_item=self.stock_item).aggregate(
|
||||||
|
q=Sum('quantity')
|
||||||
|
)['q']
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(total_allocated, 5)
|
||||||
|
self.assertLessEqual(total_allocated, self.stock_item.quantity)
|
||||||
|
|||||||
@@ -1738,6 +1738,13 @@ class SalesOrder(TotalPriceMixin, Order):
|
|||||||
serials_unavailable.add(serial)
|
serials_unavailable.add(serial)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Lock the StockItem row, so that concurrent allocation requests are
|
||||||
|
# serialized against each other, and re-validate the unallocated
|
||||||
|
# quantity against the now-current (and now-locked) state
|
||||||
|
if not stock_item.lock_quantity():
|
||||||
|
serials_unavailable.add(serial)
|
||||||
|
continue
|
||||||
|
|
||||||
if stock_item.unallocated_quantity() < 1:
|
if stock_item.unallocated_quantity() < 1:
|
||||||
serials_unavailable.add(serial)
|
serials_unavailable.add(serial)
|
||||||
continue
|
continue
|
||||||
@@ -3185,9 +3192,13 @@ class SalesOrderAllocation(models.Model):
|
|||||||
sales_allocation_count = self.item.sales_order_allocation_count(
|
sales_allocation_count = self.item.sales_order_allocation_count(
|
||||||
exclude_allocations={'pk': self.pk}
|
exclude_allocations={'pk': self.pk}
|
||||||
)
|
)
|
||||||
|
transfer_allocation_count = self.item.transfer_order_allocation_count()
|
||||||
|
|
||||||
total_allocation = (
|
total_allocation = (
|
||||||
build_allocation_count + sales_allocation_count + self.quantity
|
build_allocation_count
|
||||||
|
+ sales_allocation_count
|
||||||
|
+ transfer_allocation_count
|
||||||
|
+ self.quantity
|
||||||
)
|
)
|
||||||
|
|
||||||
if total_allocation > self.item.quantity:
|
if total_allocation > self.item.quantity:
|
||||||
@@ -4287,9 +4298,15 @@ class TransferOrderAllocation(models.Model):
|
|||||||
sales_allocation_count = self.item.sales_order_allocation_count(
|
sales_allocation_count = self.item.sales_order_allocation_count(
|
||||||
exclude_allocations={'pk': self.pk}
|
exclude_allocations={'pk': self.pk}
|
||||||
)
|
)
|
||||||
|
transfer_allocation_count = self.item.transfer_order_allocation_count(
|
||||||
|
exclude_allocations={'pk': self.pk}
|
||||||
|
)
|
||||||
|
|
||||||
total_allocation = (
|
total_allocation = (
|
||||||
build_allocation_count + sales_allocation_count + self.quantity
|
build_allocation_count
|
||||||
|
+ sales_allocation_count
|
||||||
|
+ transfer_allocation_count
|
||||||
|
+ self.quantity
|
||||||
)
|
)
|
||||||
|
|
||||||
if total_allocation > self.item.quantity:
|
if total_allocation > self.item.quantity:
|
||||||
|
|||||||
@@ -1987,10 +1987,20 @@ class SalesOrderShipmentAllocationSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
for entry in items:
|
for entry in items:
|
||||||
|
stock_item = entry.get('stock_item')
|
||||||
|
|
||||||
|
# Lock the StockItem row, so that concurrent allocation requests are
|
||||||
|
# serialized against each other, and full_clean() below re-validates
|
||||||
|
# against the now-current (and now-locked) allocation counts
|
||||||
|
if not stock_item.lock_quantity():
|
||||||
|
raise ValidationError({
|
||||||
|
'stock_item': _('Stock item no longer exists')
|
||||||
|
})
|
||||||
|
|
||||||
# Create a new SalesOrderAllocation
|
# Create a new SalesOrderAllocation
|
||||||
allocation = order.models.SalesOrderAllocation(
|
allocation = order.models.SalesOrderAllocation(
|
||||||
line=entry.get('line_item'),
|
line=entry.get('line_item'),
|
||||||
item=entry.get('stock_item'),
|
item=stock_item,
|
||||||
quantity=entry.get('quantity'),
|
quantity=entry.get('quantity'),
|
||||||
shipment=shipment,
|
shipment=shipment,
|
||||||
)
|
)
|
||||||
@@ -2828,10 +2838,20 @@ class TransferOrderLineItemAllocationSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
for entry in items:
|
for entry in items:
|
||||||
|
stock_item = entry.get('stock_item')
|
||||||
|
|
||||||
|
# Lock the StockItem row, so that concurrent allocation requests are
|
||||||
|
# serialized against each other, and full_clean() below re-validates
|
||||||
|
# against the now-current (and now-locked) allocation counts
|
||||||
|
if not stock_item.lock_quantity():
|
||||||
|
raise ValidationError({
|
||||||
|
'stock_item': _('Stock item no longer exists')
|
||||||
|
})
|
||||||
|
|
||||||
# Create a new TransferOrderAllocation
|
# Create a new TransferOrderAllocation
|
||||||
allocation = order.models.TransferOrderAllocation(
|
allocation = order.models.TransferOrderAllocation(
|
||||||
line=entry.get('line_item'),
|
line=entry.get('line_item'),
|
||||||
item=entry.get('stock_item'),
|
item=stock_item,
|
||||||
quantity=entry.get('quantity'),
|
quantity=entry.get('quantity'),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2965,8 +2985,10 @@ class TransferOrderSerialAllocationSerializer(serializers.Serializer):
|
|||||||
"""Validation for the serializer.
|
"""Validation for the serializer.
|
||||||
|
|
||||||
- Ensure the serial_numbers and quantity fields match
|
- Ensure the serial_numbers and quantity fields match
|
||||||
- Check that all serial numbers exist
|
|
||||||
- Check that the serial numbers are not yet allocated
|
Note: Resolving serial numbers to StockItem objects (and checking their
|
||||||
|
availability) is deferred to save(), where it can be done under a
|
||||||
|
database lock - see save() for details.
|
||||||
"""
|
"""
|
||||||
data = super().validate(data)
|
data = super().validate(data)
|
||||||
|
|
||||||
@@ -2983,11 +3005,29 @@ class TransferOrderSerialAllocationSerializer(serializers.Serializer):
|
|||||||
except DjangoValidationError as e:
|
except DjangoValidationError as e:
|
||||||
raise ValidationError({'serial_numbers': e.messages})
|
raise ValidationError({'serial_numbers': e.messages})
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def save(self):
|
||||||
|
"""Allocate stock items against the transfer order.
|
||||||
|
|
||||||
|
Stock items are resolved from the requested serial numbers, and locked
|
||||||
|
(select_for_update, via StockItem.lock_quantity()) before their
|
||||||
|
availability is checked - this serializes concurrent allocation requests
|
||||||
|
against each other, so two requests cannot both allocate the same
|
||||||
|
serialized StockItem.
|
||||||
|
"""
|
||||||
|
data = self.validated_data
|
||||||
|
|
||||||
|
line_item = data['line_item']
|
||||||
|
serials = data['serials']
|
||||||
|
part = line_item.part
|
||||||
|
|
||||||
serials_not_exist = set()
|
serials_not_exist = set()
|
||||||
serials_unavailable = set()
|
serials_unavailable = set()
|
||||||
stock_items_to_allocate = []
|
allocations = []
|
||||||
|
|
||||||
for serial in data['serials']:
|
for serial in serials:
|
||||||
serial = str(serial).strip()
|
serial = str(serial).strip()
|
||||||
|
|
||||||
items = stock.models.StockItem.objects.filter(
|
items = stock.models.StockItem.objects.filter(
|
||||||
@@ -3004,12 +3044,20 @@ class TransferOrderSerialAllocationSerializer(serializers.Serializer):
|
|||||||
serials_unavailable.add(str(serial))
|
serials_unavailable.add(str(serial))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if not stock_item.lock_quantity():
|
||||||
|
serials_unavailable.add(str(serial))
|
||||||
|
continue
|
||||||
|
|
||||||
if stock_item.unallocated_quantity() < 1:
|
if stock_item.unallocated_quantity() < 1:
|
||||||
serials_unavailable.add(str(serial))
|
serials_unavailable.add(str(serial))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# At this point, the serial number is valid, and can be added to the list
|
# At this point, the serial number is valid, and can be added to the list
|
||||||
stock_items_to_allocate.append(stock_item)
|
allocations.append(
|
||||||
|
order.models.TransferOrderAllocation(
|
||||||
|
line=line_item, item=stock_item, quantity=1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if len(serials_not_exist) > 0:
|
if len(serials_not_exist) > 0:
|
||||||
error_msg = _('No match found for the following serial numbers')
|
error_msg = _('No match found for the following serial numbers')
|
||||||
@@ -3025,26 +3073,4 @@ class TransferOrderSerialAllocationSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
raise ValidationError({'serial_numbers': error_msg})
|
raise ValidationError({'serial_numbers': error_msg})
|
||||||
|
|
||||||
data['stock_items'] = stock_items_to_allocate
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
def save(self):
|
|
||||||
"""Allocate stock items against the transfer order."""
|
|
||||||
data = self.validated_data
|
|
||||||
|
|
||||||
line_item = data['line_item']
|
|
||||||
stock_items = data['stock_items']
|
|
||||||
|
|
||||||
allocations = []
|
|
||||||
|
|
||||||
for stock_item in stock_items:
|
|
||||||
# Create a new TransferOrderAllocation
|
|
||||||
allocations.append(
|
|
||||||
order.models.TransferOrderAllocation(
|
|
||||||
line=line_item, item=stock_item, quantity=1
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
with transaction.atomic():
|
|
||||||
order.models.TransferOrderAllocation.objects.bulk_create(allocations)
|
order.models.TransferOrderAllocation.objects.bulk_create(allocations)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import base64
|
import base64
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
@@ -10,12 +11,14 @@ from unittest import mock
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
|
from django.test import TransactionTestCase, skipUnlessDBFeature
|
||||||
from django.test.utils import CaptureQueriesContext
|
from django.test.utils import CaptureQueriesContext
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from djmoney.money import Money
|
from djmoney.money import Money
|
||||||
from icalendar import Calendar
|
from icalendar import Calendar
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
from rest_framework.exceptions import ValidationError as DRFValidationError
|
||||||
|
|
||||||
from common.currency import currency_codes
|
from common.currency import currency_codes
|
||||||
from common.models import InvenTreeCustomUserStateModel, InvenTreeSetting
|
from common.models import InvenTreeCustomUserStateModel, InvenTreeSetting
|
||||||
@@ -24,6 +27,7 @@ from company.models import Company, SupplierPart, SupplierPriceBreak
|
|||||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||||
from order import models
|
from order import models
|
||||||
from order.models import SalesOrderAllocation, SalesOrderLineItem, SalesOrderShipment
|
from order.models import SalesOrderAllocation, SalesOrderLineItem, SalesOrderShipment
|
||||||
|
from order.serializers import TransferOrderSerialAllocationSerializer
|
||||||
from order.status_codes import (
|
from order.status_codes import (
|
||||||
PurchaseOrderStatus,
|
PurchaseOrderStatus,
|
||||||
ReturnOrderLineStatus,
|
ReturnOrderLineStatus,
|
||||||
@@ -4944,6 +4948,109 @@ class TransferOrderAllocateTest(OrderTest):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@skipUnlessDBFeature('has_select_for_update')
|
||||||
|
class TransferOrderSerialAllocateConcurrencyTest(TransactionTestCase):
|
||||||
|
"""Genuine cross-transaction regression test for serial-based transfer order allocation.
|
||||||
|
|
||||||
|
Uses two real threads (each with its own database connection) to reproduce
|
||||||
|
the reported race: two concurrent requests to allocate the *same* serial
|
||||||
|
number (against two different TransferOrder line items) could both resolve
|
||||||
|
and validate the serialized StockItem as available before either had
|
||||||
|
committed its bulk_create - allocating the same physical unit twice.
|
||||||
|
|
||||||
|
TransferOrderSerialAllocationSerializer.save() now locks each resolved
|
||||||
|
StockItem (select_for_update, via StockItem.lock_quantity()) and
|
||||||
|
re-validates its unallocated quantity under that lock before it is added
|
||||||
|
to the batch that gets created, so only one of two concurrent requests for
|
||||||
|
the same serial number may succeed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
fixtures = ['users']
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Create two TransferOrder lines which both request the same serial number."""
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
self.part = Part.objects.create(
|
||||||
|
name='Concurrency trackable part',
|
||||||
|
description='Part for serial allocation concurrency test',
|
||||||
|
trackable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.order_a = models.TransferOrder.objects.create(reference='TO-CONC-A')
|
||||||
|
self.order_b = models.TransferOrder.objects.create(reference='TO-CONC-B')
|
||||||
|
|
||||||
|
self.line_a = models.TransferOrderLineItem.objects.create(
|
||||||
|
order=self.order_a, part=self.part, quantity=1
|
||||||
|
)
|
||||||
|
self.line_b = models.TransferOrderLineItem.objects.create(
|
||||||
|
order=self.order_b, part=self.part, quantity=1
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only a single physical unit exists for this serial number
|
||||||
|
self.stock_item = StockItem.objects.create(
|
||||||
|
part=self.part, quantity=1, serial='1'
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_concurrent_allocation_does_not_duplicate_serial(self):
|
||||||
|
"""Two concurrent requests for the same serial number must not both succeed."""
|
||||||
|
start_barrier = threading.Barrier(2, timeout=5)
|
||||||
|
errors = []
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# Wrap StockItem.lock_quantity() so both threads reach the (real,
|
||||||
|
# database-level) row lock at the same time - one wins the lock and
|
||||||
|
# proceeds, the other blocks until the winner's transaction completes.
|
||||||
|
original_lock_quantity = StockItem.lock_quantity
|
||||||
|
|
||||||
|
def synced_lock_quantity(self_item):
|
||||||
|
start_barrier.wait(timeout=5)
|
||||||
|
return original_lock_quantity(self_item)
|
||||||
|
|
||||||
|
def allocate(line_item):
|
||||||
|
try:
|
||||||
|
serializer = TransferOrderSerialAllocationSerializer(
|
||||||
|
data={
|
||||||
|
'line_item': line_item.pk,
|
||||||
|
'quantity': 1,
|
||||||
|
'serial_numbers': '1',
|
||||||
|
},
|
||||||
|
context={'order': line_item.order},
|
||||||
|
)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
serializer.save()
|
||||||
|
results.append('ok')
|
||||||
|
except (ValidationError, DRFValidationError):
|
||||||
|
results.append('rejected')
|
||||||
|
except Exception as exc: # pragma: no cover - surfaced via errors list
|
||||||
|
errors.append(exc)
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
thread_a = threading.Thread(target=allocate, args=(self.line_a,))
|
||||||
|
thread_b = threading.Thread(target=allocate, args=(self.line_b,))
|
||||||
|
|
||||||
|
with mock.patch.object(StockItem, 'lock_quantity', synced_lock_quantity):
|
||||||
|
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, [])
|
||||||
|
|
||||||
|
# Exactly one request must have been rejected as unavailable
|
||||||
|
self.assertEqual(sorted(results), ['ok', 'rejected'])
|
||||||
|
|
||||||
|
# The serial number must only have been allocated once
|
||||||
|
self.assertEqual(
|
||||||
|
models.TransferOrderAllocation.objects.filter(item=self.stock_item).count(),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderAutoAllocateAPITest(InvenTreeAPITestCase):
|
class SalesOrderAutoAllocateAPITest(InvenTreeAPITestCase):
|
||||||
"""API integration tests for the SalesOrder auto-allocate endpoint."""
|
"""API integration tests for the SalesOrder auto-allocate endpoint."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
"""Unit tests for the SalesOrder models."""
|
"""Unit tests for the SalesOrder models."""
|
||||||
|
|
||||||
|
import threading
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from unittest import mock
|
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
|
||||||
from django.db.models import Sum
|
from django.db.models import Sum
|
||||||
|
from django.test import TransactionTestCase, skipUnlessDBFeature
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from django_q.models import OrmQ
|
from django_q.models import OrmQ
|
||||||
|
from rest_framework.exceptions import ValidationError as DRFValidationError
|
||||||
|
|
||||||
import order.tasks
|
import order.tasks
|
||||||
from common.models import InvenTreeSetting, NotificationMessage
|
from common.models import InvenTreeSetting, NotificationMessage
|
||||||
@@ -28,6 +32,7 @@ from order.models import (
|
|||||||
SalesOrderLineItem,
|
SalesOrderLineItem,
|
||||||
SalesOrderShipment,
|
SalesOrderShipment,
|
||||||
)
|
)
|
||||||
|
from order.serializers import SalesOrderShipmentAllocationSerializer
|
||||||
from part.models import Part
|
from part.models import Part
|
||||||
from stock.events import StockEvents
|
from stock.events import StockEvents
|
||||||
from stock.models import StockItem, StockItemTracking, StockLocation
|
from stock.models import StockItem, StockItemTracking, StockLocation
|
||||||
@@ -1177,3 +1182,125 @@ class SalesOrderAutoAllocateTest(InvenTreeTestCase):
|
|||||||
allocated_items = [a.item for a in allocs]
|
allocated_items = [a.item for a in allocs]
|
||||||
self.assertNotIn(excluded, allocated_items)
|
self.assertNotIn(excluded, allocated_items)
|
||||||
self.assertIn(included, allocated_items)
|
self.assertIn(included, allocated_items)
|
||||||
|
|
||||||
|
|
||||||
|
@skipUnlessDBFeature('has_select_for_update')
|
||||||
|
class SalesOrderAllocateStockConcurrencyTest(TransactionTestCase):
|
||||||
|
"""Genuine cross-transaction regression test for sales order stock allocation.
|
||||||
|
|
||||||
|
Uses two real threads (each with its own database connection) to reproduce
|
||||||
|
the reported race: two concurrent allocation requests, against two
|
||||||
|
different SalesOrder line items but the *same* StockItem, could each read
|
||||||
|
the item's unallocated quantity before either had committed, and both
|
||||||
|
create a SalesOrderAllocation for the full quantity - over-allocating the
|
||||||
|
StockItem. There was no order-level lock serializing these requests
|
||||||
|
against each other, since they target different orders.
|
||||||
|
|
||||||
|
SalesOrderShipmentAllocationSerializer.save() now locks the referenced
|
||||||
|
StockItem (select_for_update, via StockItem.lock_quantity()) before
|
||||||
|
validating and creating each allocation, so only one of two concurrent
|
||||||
|
full-quantity allocation requests against a shared StockItem may succeed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
fixtures = ['users']
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Create two SalesOrders which both request the same shared StockItem."""
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
self.user = get_user_model().objects.get(pk=1)
|
||||||
|
|
||||||
|
self.customer = Company.objects.create(
|
||||||
|
name='Concurrency customer',
|
||||||
|
description='Customer for allocation concurrency test',
|
||||||
|
is_customer=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.part = Part.objects.create(
|
||||||
|
name='Concurrency salable part',
|
||||||
|
description='Part for allocation concurrency test',
|
||||||
|
salable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.order_a = SalesOrder.objects.create(
|
||||||
|
customer=self.customer, reference='SO-CONC-A'
|
||||||
|
)
|
||||||
|
self.order_b = SalesOrder.objects.create(
|
||||||
|
customer=self.customer, reference='SO-CONC-B'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.line_a = SalesOrderLineItem.objects.create(
|
||||||
|
quantity=5, order=self.order_a, part=self.part
|
||||||
|
)
|
||||||
|
self.line_b = SalesOrderLineItem.objects.create(
|
||||||
|
quantity=5, order=self.order_b, part=self.part
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only enough stock for *one* of the two full-quantity allocations below
|
||||||
|
self.stock_item = StockItem.objects.create(part=self.part, quantity=5)
|
||||||
|
|
||||||
|
def test_concurrent_allocation_does_not_over_allocate(self):
|
||||||
|
"""Two concurrent full-quantity allocation requests must not both succeed."""
|
||||||
|
start_barrier = threading.Barrier(2, timeout=5)
|
||||||
|
errors = []
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# Wrap StockItem.lock_quantity() so both threads reach the (real,
|
||||||
|
# database-level) row lock at the same time - one wins the lock and
|
||||||
|
# proceeds, the other blocks until the winner's transaction completes.
|
||||||
|
original_lock_quantity = StockItem.lock_quantity
|
||||||
|
|
||||||
|
def synced_lock_quantity(self_item):
|
||||||
|
start_barrier.wait(timeout=5)
|
||||||
|
return original_lock_quantity(self_item)
|
||||||
|
|
||||||
|
def allocate(line_item):
|
||||||
|
try:
|
||||||
|
serializer = SalesOrderShipmentAllocationSerializer(
|
||||||
|
data={
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'line_item': line_item.pk,
|
||||||
|
'stock_item': self.stock_item.pk,
|
||||||
|
'quantity': 5,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
context={'order': line_item.order},
|
||||||
|
)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
serializer.save()
|
||||||
|
results.append('ok')
|
||||||
|
except (ValidationError, DRFValidationError):
|
||||||
|
results.append('rejected')
|
||||||
|
except Exception as exc: # pragma: no cover - surfaced via errors list
|
||||||
|
errors.append(exc)
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
thread_a = threading.Thread(target=allocate, args=(self.line_a,))
|
||||||
|
thread_b = threading.Thread(target=allocate, args=(self.line_b,))
|
||||||
|
|
||||||
|
with mock.patch.object(StockItem, 'lock_quantity', synced_lock_quantity):
|
||||||
|
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, [])
|
||||||
|
|
||||||
|
# Exactly one request must have been rejected as over-allocating
|
||||||
|
self.assertEqual(sorted(results), ['ok', 'rejected'])
|
||||||
|
|
||||||
|
total_allocated = (
|
||||||
|
SalesOrderAllocation.objects.filter(item=self.stock_item).aggregate(
|
||||||
|
q=Sum('quantity')
|
||||||
|
)['q']
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(total_allocated, 5)
|
||||||
|
self.assertLessEqual(total_allocated, self.stock_item.quantity)
|
||||||
|
|||||||
Reference in New Issue
Block a user