Add transaction-scoped advisory lock
A framework primitive earns its keep when a real consumer lands with it — this one closed a Postgres write-amplification problem the same day it merged.
Locking "everything that happened before" is an expensive way to serialize writers. On Postgres, ERPNext's batch valuation did exactly that: every outward batch movement row-locked the item's entire stock ledger history — a lock-marker write on each historical tuple, growing with history forever, and locking nothing at all when the history was empty. Frappe already had a session-scoped frappe.db.advisory_lock, but it survives intermediate commits and needs an explicit unlock — the wrong shape for guarding a single validate-then-commit section.
What changed
- New
frappe.db.transaction_advisory_lock(key, timeout=10)wraps Postgres'spg_advisory_xact_lock: released automatically when the transaction commits or rolls back, re-entrant within a transaction, and it participates in deadlock detection alongside row locks. - Key hashing and the polling loop are now helpers shared with the session-scoped
advisory_lock, with the sameQueryTimeoutErroraftertimeoutseconds. - The base class raises
NotImplementedError: MariaDB'sGET_LOCKis session-only and non-transactional, so a cross-engine emulation would have wrong release semantics — callers gate onfrappe.db.db_type.
if frappe.db.db_type == "postgres":
frappe.db.transaction_advisory_lock(("batch-valuation", item_code, warehouse))
# held until commit/rollback -- no explicit unlock
The primitive shipped with its first consumer: erpnext#56905, merged the same day, replaces all four history-wide lock statements in batch valuation with one advisory lock at the valuation entry point — a single in-memory lock instead of a write per historical row. MariaDB is byte-identical throughout; its gap locks already cover these races.