Hardening build outputs (#12376)

- ensure outputs cannot be double completed
This commit is contained in:
Oliver
2026-07-13 18:53:07 +10:00
committed by GitHub
parent 31f97934d0
commit dd05611ea4
3 changed files with 158 additions and 23 deletions
+18
View File
@@ -1041,6 +1041,15 @@ class Build(
if not output: if not output:
raise ValidationError(_('No build output specified')) raise ValidationError(_('No build output specified'))
# Re-check the state of the output itself:
# It may have changed since the scrap request was validated
# (e.g. a duplicated background task, or a concurrent request)
if not output.is_building:
raise ValidationError(_('Build output has already been completed'))
if output.build != self:
raise ValidationError(_('Build output does not match Build Order'))
# If quantity is not specified, assume the entire output quantity # If quantity is not specified, assume the entire output quantity
if quantity is None: if quantity is None:
quantity = output.quantity quantity = output.quantity
@@ -1111,6 +1120,15 @@ class Build(
Raises: Raises:
ValidationError: If the build output cannot be completed, with an appropriate message ValidationError: If the build output cannot be completed, with an appropriate message
""" """
# Re-check the state of the output itself:
# It may have changed since the completion request was validated
# (e.g. a duplicated background task, or a concurrent request)
if not output.is_building:
raise ValidationError(_('Build output has already been completed'))
if output.build != self:
raise ValidationError(_('Build output does not match Build Order'))
prevent_incomplete = get_global_setting( prevent_incomplete = get_global_setting(
'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS' 'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS'
) )
+52 -6
View File
@@ -115,8 +115,20 @@ def delete_build_outputs(build_id: int, output_ids: list, **kwargs):
with transaction.atomic(): with transaction.atomic():
for output_id in output_ids: for output_id in output_ids:
output = StockItem.objects.filter(pk=output_id).first() # Lock the output row, and re-check that it is still "in production" -
if output: # it may have been processed already (e.g. by a duplicated task)
output = StockItem.objects.select_for_update().filter(pk=output_id).first()
if not output:
continue
if not output.is_building:
logger.warning(
'Build output <%s> is no longer in production - skipping deletion',
output.pk,
)
continue
build.delete_output(output) build.delete_output(output)
@@ -149,8 +161,25 @@ def scrap_build_outputs(
with transaction.atomic(): with transaction.atomic():
for item in outputs: for item in outputs:
output = StockItem.objects.filter(pk=item['output_id']).first() # Lock the output row, and re-check that it is still "in production" -
if output: # it may have been processed already (e.g. by a duplicated task)
output = (
StockItem.objects
.select_for_update()
.filter(pk=item['output_id'])
.first()
)
if not output:
continue
if not output.is_building:
logger.warning(
'Build output <%s> is no longer in production - skipping scrap',
output.pk,
)
continue
build.scrap_build_output( build.scrap_build_output(
output, output,
item.get('quantity'), item.get('quantity'),
@@ -194,8 +223,25 @@ def complete_build_outputs(
with transaction.atomic(): with transaction.atomic():
for item in outputs: for item in outputs:
output = StockItem.objects.filter(pk=item['output_id']).first() # Lock the output row, and re-check that it is still "in production" -
if output: # it may have been processed already (e.g. by a duplicated task)
output = (
StockItem.objects
.select_for_update()
.filter(pk=item['output_id'])
.first()
)
if not output:
continue
if not output.is_building:
logger.warning(
'Build output <%s> is no longer in production - skipping completion',
output.pk,
)
continue
build.complete_build_output( build.complete_build_output(
output, output,
user, user,
+72 -1
View File
@@ -26,7 +26,12 @@ from InvenTree.unit_test import (
) )
from order.models import PurchaseOrder, PurchaseOrderLineItem from order.models import PurchaseOrder, PurchaseOrderLineItem
from part.models import BomItem, BomItemSubstitute, Part, PartTestTemplate from part.models import BomItem, BomItemSubstitute, Part, PartTestTemplate
from stock.models import StockItem, StockItemTestResult, StockLocation from stock.models import (
StockItem,
StockItemTestResult,
StockItemTracking,
StockLocation,
)
from stock.status_codes import StockStatus from stock.status_codes import StockStatus
from users.models import Owner from users.models import Owner
@@ -1262,6 +1267,72 @@ class BuildTaskTests(BuildTestBase):
self.build.complete_build_output(self.output_1, None) self.build.complete_build_output(self.output_1, None)
self.build.complete_build_output(self.output_2, None) self.build.complete_build_output(self.output_2, None)
# -----------------------------------------------------------------------
# complete_build_outputs / scrap_build_outputs tasks
# -----------------------------------------------------------------------
def test_complete_outputs_task_is_idempotent(self):
"""Duplicate execution of the output completion task must not double-count.
Regression test: the task never re-checked 'is_building', so a duplicated
(or redelivered) task run completed the same outputs twice - inflating the
'completed' count for the build order and duplicating stock history.
"""
self.build.issue_build()
outputs = [{'output_id': self.output_1.pk}, {'output_id': self.output_2.pk}]
build.tasks.complete_build_outputs(
self.build.pk,
outputs,
self.location.pk,
StockStatus.OK.value,
user_id=self.user.pk,
)
self.build.refresh_from_db()
self.output_1.refresh_from_db()
self.assertEqual(self.build.completed, 10)
self.assertFalse(self.output_1.is_building)
n_tracking = StockItemTracking.objects.count()
# Run the task again (simulating a duplicated / redelivered task)
build.tasks.complete_build_outputs(
self.build.pk,
outputs,
self.location.pk,
StockStatus.OK.value,
user_id=self.user.pk,
)
# The 'completed' count has not been double-counted,
# and no additional stock history has been generated
self.build.refresh_from_db()
self.assertEqual(self.build.completed, 10)
self.assertEqual(StockItemTracking.objects.count(), n_tracking)
def test_complete_output_twice_rejected(self):
"""Completing or scrapping an already-completed output must be rejected.
Regression test: neither complete_build_output() nor scrap_build_output()
re-checked the 'is_building' state of the output.
"""
self.build.issue_build()
self.build.complete_build_output(self.output_1, None)
with self.assertRaises(ValidationError):
self.build.complete_build_output(self.output_1, None)
with self.assertRaises(ValidationError):
self.build.scrap_build_output(self.output_1, None, self.location)
# An output belonging to a *different* build order is also rejected
with self.assertRaises(ValidationError):
self.build.complete_build_output(self.stockitem_wo_required_test, None)
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# cancel_build task # cancel_build task
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------