From 9cfdb482fca30428d483254f2a0721538e464cf3 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 16 Jul 2026 09:21:07 +0530 Subject: [PATCH 1/4] fix(stock): serialize stock writes per (item, warehouse) with a txn advisory lock on postgres Postgres locking reads never see rows a concurrent transaction is inserting (MariaDB's gap locks block the insert, then its locking reads return the fresh row), so two concurrent writers for the same (item, warehouse) compute from the same stale previous SLE and the loser overwrites Bin with a wrong absolute qty. Today only the REPEATABLE READ serialization-failure retry catches this; the gate makes correctness lock-based, covers the empty-history first-transaction case (nothing exists to row-lock), and keeps negative-stock validation accurate against concurrently inserted SLEs. Taken at the top of make_sl_entries (sorted pairs, before the future_sle_exists cache warms) and in update_entries_after.__init__ for the repost paths; re-entrant, released at commit. MariaDB paths unchanged. --- .../test_stock_ledger_entry.py | 15 +++++++++++++++ erpnext/stock/stock_ledger.py | 16 ++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py index d0fcec592ad..0c4c368ad3e 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py @@ -34,6 +34,21 @@ class TestStockLedgerEntry(ERPNextTestSuite, StockTestMixin): create_items() reset("Stock Entry") + def test_stock_write_takes_sle_advisory_gate(self): + if frappe.db.db_type != "postgres": + return + + item = make_item(properties={"is_stock_item": 1}).name + + 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() + make_stock_entry(item_code=item, target="_Test Warehouse - _TC", qty=1, rate=10) + self.assertGreater(held_advisory_locks(), before) + def test_incoming_value_for_transferred_serial_no_is_deterministic(self): """get_incoming_value_for_serial_nos picks the latest SLE (posting_date desc, limit 1) for a serial transferred to another company. posting_date alone is non-total, so two same-date SLEs diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index c1ce66317bc..e97bbd923ab 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -115,6 +115,10 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc from erpnext.controllers.stock_controller import future_sle_exists if sl_entries: + # Sorted so two vouchers touching the same pairs can't take the gates in opposite order. + for pair in sorted({(d.get("item_code"), d.get("warehouse")) for d in sl_entries}): + sle_processing_gate(*pair) + cancelled = sl_entries[0].get("is_cancelled") if cancelled: validate_cancellation(sl_entries) @@ -285,6 +289,16 @@ def repost_gate(item_code, warehouse): return nullcontext() +def sle_processing_gate(item_code, warehouse): + """Serialize all stock writes for an (item, warehouse) on postgres. MariaDB gets this from the + gap locks its previous-SLE locking reads take (which also block, then reveal, concurrent + inserts); postgres locking reads never see rows another transaction is inserting, so without + this gate two concurrent writers compute from the same stale previous SLE and the loser's Bin + write is lost. Txn-scoped and re-entrant; released at commit/rollback.""" + if frappe.db.db_type == "postgres": + frappe.db.transaction_advisory_lock(("stock-sle", item_code, warehouse), timeout=REPOST_LOCK_TIMEOUT) + + def repost_future_sle( items_to_be_repost=None, voucher_type=None, @@ -594,6 +608,8 @@ class update_entries_after: if self.args.sle_id: self.args["name"] = self.args.sle_id + sle_processing_gate(self.item_code, self.args.warehouse) + self.prev_sle_dict = frappe._dict({}) self.company = frappe.get_cached_value("Warehouse", self.args.warehouse, "company") self.set_precision() From 35a9d7b09c4e979192fefd6f0b17713ee5dfda9f Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 16 Jul 2026 09:21:17 +0530 Subject: [PATCH 2/4] fix(accounts): block GL Entry inserts during account rename on postgres The for_update read in _ensure_idle_system only blocks new GL inserts on MariaDB, via the gap lock it takes; a postgres row lock never blocks inserts, so the guard silently degraded to the 5-minute recency check. LOCK TABLE IN EXCLUSIVE MODE blocks writers (not readers) until the rename commits and NOWAIT keeps the wait=False fail-fast, feeding the existing QueryTimeoutError path. --- erpnext/accounts/doctype/account/account.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/account/account.py b/erpnext/accounts/doctype/account/account.py index ebfb2d0bcee..e67b29bc1be 100644 --- a/erpnext/accounts/doctype/account/account.py +++ b/erpnext/accounts/doctype/account/account.py @@ -659,8 +659,15 @@ def _ensure_idle_system(): last_gl_update = None try: - # We also lock inserts to GL entry table with for_update here. - last_gl_update = frappe.db.get_value("GL Entry", {}, "modified", for_update=True, wait=False) + if frappe.db.db_type == "postgres": + # The MariaDB branch blocks new GL inserts via the gap lock its for_update read takes; + # a postgres row lock never blocks inserts, so take an EXCLUSIVE table lock instead -- + # writers block until the rename commits, readers don't. NOWAIT mirrors wait=False. + frappe.db.sql("LOCK TABLE `tabGL Entry` IN EXCLUSIVE MODE NOWAIT") + last_gl_update = frappe.db.get_value("GL Entry", {}, "modified") + else: + # We also lock inserts to GL entry table with for_update here. + last_gl_update = frappe.db.get_value("GL Entry", {}, "modified", for_update=True, wait=False) except frappe.QueryTimeoutError: # wait=False fails immediately if there's an active transaction. last_gl_update = add_to_date(None, seconds=-1) From 897eca895a49dfbb53474a9d1b753ee5fce10ee9 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 16 Jul 2026 09:32:03 +0530 Subject: [PATCH 3/4] fix(stock): fall back gracefully when transaction_advisory_lock is unavailable Same hasattr pattern as repost_gate: an ERPNext ahead of its frappe build keeps the status-quo serialization-failure retries instead of failing every stock submission on postgres. --- erpnext/stock/stock_ledger.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index e97bbd923ab..aaecd0a4e0c 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -294,8 +294,10 @@ def sle_processing_gate(item_code, warehouse): gap locks its previous-SLE locking reads take (which also block, then reveal, concurrent inserts); postgres locking reads never see rows another transaction is inserting, so without this gate two concurrent writers compute from the same stale previous SLE and the loser's Bin - write is lost. Txn-scoped and re-entrant; released at commit/rollback.""" - if frappe.db.db_type == "postgres": + write is lost. Txn-scoped and re-entrant; released at commit/rollback. hasattr keeps a frappe + predating transaction_advisory_lock on the status quo (serialization-failure retries) instead + of breaking every stock submission.""" + if frappe.db.db_type == "postgres" and hasattr(frappe.db, "transaction_advisory_lock"): frappe.db.transaction_advisory_lock(("stock-sle", item_code, warehouse), timeout=REPOST_LOCK_TIMEOUT) From b100e6d41497d6d1151d536bbe1eb93c0526cfcf Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 16 Jul 2026 09:58:09 +0530 Subject: [PATCH 4/4] 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. --- erpnext/stock/doctype/pick_list/pick_list.py | 9 +++++++++ .../stock/doctype/pick_list/test_pick_list.py | 17 +++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 53e515b0f8e..50a3a0ebfb4 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -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() diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index e37cb0a3532..3ca41f29bb3 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -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: