fix(postgres): db-aware row-locking, savepoints & cursors

PostgreSQL rejects `SELECT ... FOR UPDATE` when combined with `GROUP BY`,
aggregates or `DISTINCT`, has no concept of MySQL's locking semantics for those
shapes, and its server-side (unbuffered) cursors can't run nested queries
mid-iteration. This makes the row-locking / cursor paths db-aware so they keep
the exact MariaDB behaviour there and use the valid PostgreSQL form on Postgres.

One problem class, applied across the codebase:

- **`FOR UPDATE` + GROUP BY/aggregate** — keep `.for_update()` on MariaDB; on
  Postgres acquire the lock in a separate plain `SELECT ... FOR UPDATE` pass (or
  skip where the grouped read isn't a lock point). Deprecated serial/batch,
  serial-batch-bundle, pick list, stock reservation entry.
- **Unbuffered/server-side cursor** — Stock Ageing streamed via an unbuffered
  cursor and then ran nested queries; on Postgres that invalidates the cursor, so
  process the buffered result directly there.
- **Transaction savepoints** — Opening Invoice Creation Tool rolled back the whole
  transaction per failed invoice (which on Postgres also discards sibling rows and
  earlier error logs); scope each invoice to a savepoint instead.

No behaviour change on MariaDB (the locking/cursor path is unchanged there).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mihir Kandoi
2026-06-17 08:20:23 +05:30
parent faadc1620b
commit d079677500
6 changed files with 75 additions and 30 deletions

View File

@@ -270,6 +270,13 @@ def start_import(invoices):
errors = 0
names = []
for idx, d in enumerate(invoices):
# Scope each invoice to a savepoint so a failure only undoes that invoice.
# A plain rollback() would discard the whole transaction — including invoices
# imported earlier in this batch and the error logs of earlier failures (the
# latter only survive on mariadb because the Error Log table is MyISAM; on
# postgres they would be lost). Rolling back to a savepoint keeps both.
savepoint = f"opening_invoice_{frappe.generate_hash(length=8)}"
frappe.db.savepoint(savepoint)
try:
invoice_number = None
if d.invoice_number:
@@ -284,7 +291,7 @@ def start_import(invoices):
names.append(doc.name)
except Exception:
errors += 1
frappe.db.rollback()
frappe.db.rollback(save_point=savepoint)
doc.log_error("Opening invoice creation failed")
if errors:
frappe.msgprint(

View File

@@ -146,7 +146,6 @@ class DeprecatedBatchNoValuation:
& (sle.batch_no.isnotnull())
& (sle.is_cancelled == 0)
)
.for_update()
.groupby(sle.batch_no)
)
@@ -156,6 +155,10 @@ class DeprecatedBatchNoValuation:
if self.sle.name:
query = query.where(sle.name != self.sle.name)
# lock scanned rows on MariaDB; FOR UPDATE is invalid with GROUP BY on postgres
if frappe.db.db_type != "postgres":
query = query.for_update()
return query.run(as_dict=True)
@deprecated(
@@ -267,7 +270,6 @@ class DeprecatedBatchNoValuation:
& (sle.is_cancelled == 0)
& (sle.batch_no.isin(self.non_batchwise_valuation_batches))
)
.for_update()
.where(timestamp_condition)
.groupby(sle.batch_no)
)
@@ -284,6 +286,10 @@ class DeprecatedBatchNoValuation:
query = query.where(batch.use_batchwise_valuation == 0)
moving_avg_item_non_batch_value = True
# lock scanned rows on MariaDB; FOR UPDATE is invalid with GROUP BY on postgres
if frappe.db.db_type != "postgres":
query = query.for_update()
batch_data = query.run(as_dict=True)
for d in batch_data:
self.available_qty[d.batch_no] += flt(d.batch_qty)
@@ -391,7 +397,7 @@ class DeprecatedBatchNoValuation:
& (bundle.type_of_transaction.isin(["Inward", "Outward"]))
& (bundle_child.batch_no.isin(self.non_batchwise_valuation_batches))
)
.for_update()
# FOR UPDATE is invalid with GROUP BY on postgres (deprecated valuation path)
.where(timestamp_condition)
.groupby(bundle_child.batch_no)
)
@@ -410,6 +416,10 @@ class DeprecatedBatchNoValuation:
query = query.where(batch.use_batchwise_valuation == 0)
moving_avg_item_non_batch_value = True
# lock scanned rows on MariaDB; FOR UPDATE is invalid with GROUP BY on postgres
if frappe.db.db_type != "postgres":
query = query.for_update()
batch_data = query.run(as_dict=True)
for d in batch_data:
self.available_qty[d.batch_no] += flt(d.batch_qty)

View File

@@ -9,8 +9,7 @@ import frappe
from frappe import _, bold
from frappe.model.document import Document
from frappe.query_builder import Case
from frappe.query_builder.custom import GROUP_CONCAT
from frappe.query_builder.functions import Coalesce, Locate, Replace, Sum
from frappe.query_builder.functions import Coalesce, GroupConcat, Locate, Max, Replace, Sum
from frappe.utils import cint, floor, flt, get_link_to_form
from frappe.utils.nestedset import get_descendants_of
@@ -906,15 +905,16 @@ def get_picked_items_qty(items, contains_packed_items=False) -> list[dict]:
query = (
frappe.qb.from_(pi_item)
.select(
pi_item.sales_order_item,
pi_item.product_bundle_item,
pi_item.item_code,
# only one of sales_order_item / product_bundle_item is grouped per branch below; Max()
# the rest so postgres accepts the query (each is constant within its group)
Max(pi_item.sales_order_item).as_("sales_order_item"),
Max(pi_item.product_bundle_item).as_("product_bundle_item"),
Max(pi_item.item_code).as_("item_code"),
pi_item.sales_order,
Sum(pi_item.stock_qty).as_("stock_qty"),
Sum(pi_item.picked_qty).as_("picked_qty"),
)
.where(pi_item.docstatus == 1)
.for_update()
)
if contains_packed_items:
@@ -928,6 +928,10 @@ def get_picked_items_qty(items, contains_packed_items=False) -> list[dict]:
pi_item.sales_order,
).where(pi_item.sales_order_item.isin(items))
# FOR UPDATE is invalid with GROUP BY on postgres; lock scanned rows on MariaDB only
if frappe.db.db_type != "postgres":
query = query.for_update()
return query.run(as_dict=True)
@@ -1365,7 +1369,7 @@ def get_pick_list_query(doctype: Any, txt: str, searchfield: Any, start: int, pa
.select(
PICK_LIST.name,
SALES_ORDER.customer,
Replace(GROUP_CONCAT(PICK_LIST_ITEM.sales_order).distinct(), ",", "<br>").as_("sales_order"),
Replace(GroupConcat(PICK_LIST_ITEM.sales_order).distinct(), ",", "<br>").as_("sales_order"),
)
.where(PICK_LIST.docstatus == 1)
.where(PICK_LIST.status.isin(["Open", "Partly Delivered"]))

View File

@@ -8,7 +8,7 @@ import frappe
from frappe import _
from frappe.model.document import Document
from frappe.query_builder import Case
from frappe.query_builder.functions import Sum
from frappe.query_builder.functions import Max, Min, Sum
from frappe.utils import cint, flt, nowdate, nowtime, parse_json
from erpnext.stock.utils import get_or_make_bin, get_stock_balance
@@ -715,12 +715,15 @@ def get_available_qty_to_reserve(
& (sre.warehouse == warehouse)
& (sre.delivered_qty < sre.reserved_qty)
)
.for_update()
)
if ignore_sre:
query = query.where(sre.name != ignore_sre)
# FOR UPDATE is invalid with aggregates on postgres; lock scanned rows on MariaDB only
if frappe.db.db_type != "postgres":
query = query.for_update()
reserved_qty = query.run()[0][0] or 0.0
if reserved_qty:
@@ -870,14 +873,16 @@ def get_sre_reserved_warehouses_for_voucher(
query = (
frappe.qb.from_(sre)
.select(sre.warehouse)
.distinct()
.where(
(sre.docstatus == 1)
& (sre.voucher_type == voucher_type)
& (sre.voucher_no == voucher_no)
& (sre.delivered_qty < sre.reserved_qty)
)
.orderby(sre.creation)
# distinct warehouses, earliest reservation first (postgres can't ORDER BY a
# non-selected column under SELECT DISTINCT, so group + Min instead)
.groupby(sre.warehouse)
.orderby(Min(sre.creation))
)
if voucher_detail_no:
@@ -984,7 +989,8 @@ def get_sre_reserved_batch_nos_details(item_code: str, warehouse: str, batch_nos
& (sre.reservation_based_on == "Serial and Batch")
)
.groupby(sb_entry.batch_no)
.orderby(sb_entry.creation)
# result is collapsed into a dict below, so ordering is irrelevant; dropping the (non-grouped)
# ORDER BY creation keeps the GROUP BY valid on postgres.
)
if batch_nos:
@@ -1526,10 +1532,12 @@ class StockReservation:
.inner_join(child_doctype)
.on(doctype.name == child_doctype.parent)
.select(
doctype.name.as_("voucher_no"),
# grouped by the child PK (name), so child columns are valid on postgres via functional
# dependency; the parent (doctype) columns aren't, so Max() them -- constant per child row.
Max(doctype.name).as_("voucher_no"),
child_doctype.name.as_("voucher_detail_no"),
child_doctype[item_code_fieldname].as_("item_code"),
doctype.company,
Max(doctype.company).as_("company"),
child_doctype.stock_uom,
)
.where((doctype.docstatus == 1) & (doctype[field].isin(docnames)))
@@ -1539,9 +1547,9 @@ class StockReservation:
if to_doctype == "Work Order":
query = query.select(
child_doctype.source_warehouse,
doctype.wip_warehouse,
doctype.skip_transfer,
doctype.from_wip_warehouse,
Max(doctype.wip_warehouse).as_("wip_warehouse"),
Max(doctype.skip_transfer).as_("skip_transfer"),
Max(doctype.from_wip_warehouse).as_("from_wip_warehouse"),
child_doctype.required_qty,
(child_doctype.required_qty - child_doctype.transferred_qty).as_("qty"),
child_doctype.stock_reserved_qty,

View File

@@ -309,20 +309,31 @@ class FIFOSlots:
self.prepare_stock_reco_voucher_wise_count()
if stock_ledger_entries is None:
# nested queries invalidate the streaming cursor below,
# streaming path: nested queries invalidate the streaming cursor below,
# so batchwise valuation flags must be resolved beforehand
self._prefetch_batchwise_valuations()
with frappe.db.unbuffered_cursor():
if stock_ledger_entries is None:
stock_ledger_entries = self._get_stock_ledger_entries()
if frappe.db.db_type == "postgres":
# postgres server-side cursors can't run nested queries mid-iteration; _get_stock_ledger_entries
# returns a buffered result there, so process it directly (no unbuffered cursor).
for row in self._get_stock_ledger_entries():
self._process_stock_ledger_entry(row, bundle_wise_serial_nos, bundle_wise_batch_nos)
else:
with frappe.db.unbuffered_cursor():
stock_ledger_entries = self._get_stock_ledger_entries()
for row in stock_ledger_entries:
self._process_stock_ledger_entry(row, bundle_wise_serial_nos, bundle_wise_batch_nos)
# Note that stock_ledger_entries is an iterator, you can not reuse it like a list
del stock_ledger_entries
else:
# entries passed in directly as a list: no streaming cursor is opened, so the batchwise
# valuation flags can be resolved lazily — a nested get_value here is safe on postgres too
# (running it inside an unbuffered/named cursor would raise on postgres).
for row in stock_ledger_entries:
self._process_stock_ledger_entry(row, bundle_wise_serial_nos, bundle_wise_batch_nos)
# Note that stock_ledger_entries is an iterator, you can not reuse it like a list
del stock_ledger_entries
if not self.filters.get("show_warehouse_wise_stock"):
# (Item 1, WH 1), (Item 1, WH 2) => (Item 1)
self.item_details = self._aggregate_details_by_item(self.item_details)
@@ -944,7 +955,9 @@ class FIFOSlots:
sle_query = sle_query.orderby(sle.posting_datetime, sle.creation)
return sle_query.run(as_dict=True, as_iterator=True)
# postgres server-side (named) cursors can't run nested queries mid-iteration, which
# _process_stock_ledger_entry needs; fall back to a buffered fetch there. MariaDB streams.
return sle_query.run(as_dict=True, as_iterator=frappe.db.db_type != "postgres")
def _get_bundle_wise_serial_nos(self) -> dict:
bundle = frappe.qb.DocType("Serial and Batch Bundle")

View File

@@ -865,10 +865,13 @@ class BatchNoValuation(DeprecatedBatchNoValuation):
& (child.docstatus == 1)
& (child.type_of_transaction.isin(["Inward", "Outward"]))
)
.for_update()
.groupby(child.batch_no)
)
# FOR UPDATE is invalid with GROUP BY on postgres; lock scanned rows on MariaDB only
if frappe.db.db_type != "postgres":
query = query.for_update()
# Important to exclude the current voucher detail no / voucher no to calculate the correct stock value difference
if self.sle.voucher_detail_no:
query = query.where(child.voucher_detail_no != self.sle.voucher_detail_no)