fix(stock): serialize pick list allocation per item on postgres

Two simultaneous allocations for the same item can both claim the same stock
on postgres: the picked-items locking read cannot see the rows another
in-flight creation is inserting, while MariaDB's gap locks make the creations
take turns. Advisory-gate set_item_locations per item (sorted against
deadlocks) so the second allocation waits, then subtracts the first's claim.
MariaDB unchanged.
This commit is contained in:
Mihir Kandoi
2026-07-16 09:58:09 +05:30
parent 897eca895a
commit b100e6d414
2 changed files with 26 additions and 0 deletions

View File

@@ -550,6 +550,15 @@ class PickList(TransactionBase):
def set_item_locations(self, save: bool = False):
self.validate_for_qty()
items = self.aggregate_item_qty()
# Serialize concurrent allocations per item on postgres. MariaDB's gap locks on the
# picked-items locking read below already make two simultaneous allocations take turns;
# postgres locking reads can't see the rows another in-flight allocation is inserting, so
# both could claim the same stock. Sorted so overlapping documents can't deadlock.
if frappe.db.db_type == "postgres" and hasattr(frappe.db, "transaction_advisory_lock"):
for item_code in sorted({d.item_code for d in items}):
frappe.db.transaction_advisory_lock(("pick-allocate", item_code))
picked_items_details = self.get_picked_items_details(items)
self.item_location_map = frappe._dict()

View File

@@ -29,6 +29,23 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestPickList(ERPNextTestSuite):
def test_pick_list_allocation_takes_advisory_gate(self):
if frappe.db.db_type != "postgres":
return
item = make_item(properties={"is_stock_item": 1}).name
make_stock_entry(item=item, to_warehouse="_Test Warehouse - _TC", qty=5, basic_rate=100)
sales_order = make_sales_order(item_code=item, warehouse="_Test Warehouse - _TC", qty=2, rate=100)
def held_advisory_locks():
return frappe.db.sql(
"SELECT count(*) FROM pg_locks WHERE locktype = 'advisory' AND pid = pg_backend_pid()"
)[0][0]
before = held_advisory_locks()
create_pick_list(sales_order.name)
self.assertGreater(held_advisory_locks(), before)
def test_pick_list_picks_warehouse_for_each_item(self):
item_code = make_item().name
try: