What's New
Frappe feat framework Editor's pick

Add transaction-scoped advisory lock

MI @mihir-kandoi · #40621 · merged 6 Jul 2026

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's pg_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 same QueryTimeoutError after timeout seconds.
  • The base class raises NotImplementedError: MariaDB's GET_LOCK is session-only and non-transactional, so a cross-engine emulation would have wrong release semantics — callers gate on frappe.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.