From f5f032bc13877fc6ddce4a56cd0a1723bab273b5 Mon Sep 17 00:00:00 2001
From: Tomas Vondra <tomas.vondra@postgresql.org>
Date: Thu, 12 Nov 2026 19:32:58 +0100
Subject: [PATCH v1 3/4] Allow batching with OLD TABLE transition tables

Tuples routed to a foreign partition are not batched when the statement
captures transition tuples, so that the per-row trigger code can reject
collecting transition tuples from foreign partitions. But routed tuples
can only be captured in a NEW TABLE, whether they come from an INSERT or
from an UPDATE moving rows between partitions.

An OLD TABLE never contains them, as the old version of a row moved by a
cross-partition UPDATE is captured when it's deleted from the source
partition. So disable batching only if the routed tuples may have to be
captured in a NEW TABLE.

This only matters for UPDATE and MERGE (INSERT and COPY FROM capture
transition tuples only for a NEW TABLE). A cross-partition UPDATE on a
partitioned table with only OLD TABLE transition tables batches inserts
into foreign partitions again. So does MERGE with only OLD TABLE
transition tables. MERGE prepares transition capture for all kinds of
actions, so a NEW TABLE for UPDATE still disables batching even for
tuples routed to a foreign partition by an INSERT action.

The regression test with an OLD TABLE now expects a single batched
INSERT for the moved rows, and a new test checks that MERGE with only an
OLD TABLE (for DELETE) batches the tuples routed by an INSERT action.
---
 .../postgres_fdw/expected/postgres_fdw.out    | 26 +++++++++++++++----
 contrib/postgres_fdw/sql/postgres_fdw.sql     | 19 +++++++++++---
 doc/src/sgml/fdwhandler.sgml                  | 10 ++++---
 src/backend/executor/execMain.c               | 19 +++++++++-----
 4 files changed, 56 insertions(+), 18 deletions(-)

diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index 35def829d31..a3598ac576e 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -12276,9 +12276,10 @@ SELECT * FROM cmdlog ORDER BY 1;
 (2 rows)
 
 DROP VIEW batch_cp_upd_view;
--- Transition tables on the parent disable batching for the rows routed to the
--- foreign partition, even if there's only an OLD TABLE: expect an INSERT on
--- batch_cp_upd_test1 for each moved row.
+-- Transition tables on the parent disable batching only if the routed rows
+-- may have to be captured in a NEW TABLE.  An OLD TABLE never contains them
+-- (a moved row is captured when deleting it from the source partition), so
+-- expect a single INSERT on batch_cp_upd_test1 for both moved rows.
 CREATE TRIGGER oldtab_trig AFTER UPDATE ON batch_cp_upd_test
 	REFERENCING OLD TABLE AS oldtab
 	FOR EACH STATEMENT EXECUTE FUNCTION log_stmt();
@@ -12288,10 +12289,9 @@ UPDATE batch_cp_upd_test SET a = 1 WHERE a = 2;
 SELECT * FROM cmdlog ORDER BY 1;
              cmd              
 ------------------------------
- INSERT on batch_cp_upd_test1
  INSERT on batch_cp_upd_test1
  UPDATE on batch_cp_upd_test
-(3 rows)
+(2 rows)
 
 DROP TRIGGER oldtab_trig ON batch_cp_upd_test;
 -- With a NEW TABLE, batching is disabled, and the per-row code refuses to
@@ -12318,6 +12318,22 @@ SELECT * FROM cmdlog ORDER BY 1;
 (2 rows)
 
 DROP TRIGGER newtab_trig ON batch_cp_upd_test;
+-- As with UPDATE, an OLD TABLE doesn't disable batching for MERGE: with only
+-- a DELETE OLD TABLE, expect a single INSERT on batch_cp_upd_test1.
+CREATE TRIGGER oldtab_del_trig AFTER DELETE ON batch_cp_upd_test
+	REFERENCING OLD TABLE AS oldtab
+	FOR EACH STATEMENT EXECUTE FUNCTION log_stmt();
+TRUNCATE cmdlog;
+MERGE INTO batch_cp_upd_test t USING (VALUES (1), (1)) s(a)
+	ON t.a = 2 AND t.a = s.a
+	WHEN NOT MATCHED THEN INSERT VALUES (s.a);
+SELECT * FROM cmdlog ORDER BY 1;
+             cmd              
+------------------------------
+ INSERT on batch_cp_upd_test1
+(1 row)
+
+DROP TRIGGER oldtab_del_trig ON batch_cp_upd_test;
 -- Clean up
 DROP TABLE batch_cp_upd_test;
 DROP TABLE batch_cp_upd_test1;
diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql
index 3ee739d40ef..58e268be8c2 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -4258,9 +4258,10 @@ INSERT INTO batch_cp_upd_view VALUES (1), (1);
 SELECT * FROM cmdlog ORDER BY 1;
 DROP VIEW batch_cp_upd_view;
 
--- Transition tables on the parent disable batching for the rows routed to the
--- foreign partition, even if there's only an OLD TABLE: expect an INSERT on
--- batch_cp_upd_test1 for each moved row.
+-- Transition tables on the parent disable batching only if the routed rows
+-- may have to be captured in a NEW TABLE.  An OLD TABLE never contains them
+-- (a moved row is captured when deleting it from the source partition), so
+-- expect a single INSERT on batch_cp_upd_test1 for both moved rows.
 CREATE TRIGGER oldtab_trig AFTER UPDATE ON batch_cp_upd_test
 	REFERENCING OLD TABLE AS oldtab
 	FOR EACH STATEMENT EXECUTE FUNCTION log_stmt();
@@ -4289,6 +4290,18 @@ MERGE INTO batch_cp_upd_test t USING (VALUES (1), (1)) s(a)
 SELECT * FROM cmdlog ORDER BY 1;
 DROP TRIGGER newtab_trig ON batch_cp_upd_test;
 
+-- As with UPDATE, an OLD TABLE doesn't disable batching for MERGE: with only
+-- a DELETE OLD TABLE, expect a single INSERT on batch_cp_upd_test1.
+CREATE TRIGGER oldtab_del_trig AFTER DELETE ON batch_cp_upd_test
+	REFERENCING OLD TABLE AS oldtab
+	FOR EACH STATEMENT EXECUTE FUNCTION log_stmt();
+TRUNCATE cmdlog;
+MERGE INTO batch_cp_upd_test t USING (VALUES (1), (1)) s(a)
+	ON t.a = 2 AND t.a = s.a
+	WHEN NOT MATCHED THEN INSERT VALUES (s.a);
+SELECT * FROM cmdlog ORDER BY 1;
+DROP TRIGGER oldtab_del_trig ON batch_cp_upd_test;
+
 -- Clean up
 DROP TABLE batch_cp_upd_test;
 DROP TABLE batch_cp_upd_test1;
diff --git a/doc/src/sgml/fdwhandler.sgml b/doc/src/sgml/fdwhandler.sgml
index ea288520dfc..2b35b6d475c 100644
--- a/doc/src/sgml/fdwhandler.sgml
+++ b/doc/src/sgml/fdwhandler.sgml
@@ -675,10 +675,12 @@ ExecForeignBatchInsert(EState *estate,
      the statement has a <literal>RETURNING</literal> clause, there are
      <literal>WITH CHECK OPTION</literal> constraints to check, or the foreign
      table has <literal>BEFORE ROW</literal> or <literal>AFTER ROW</literal>
-     <command>INSERT</command> triggers.  Batching is also disabled when
-     transition-tuple capture is active for a parent table during tuple
-     routing.  Statement-level triggers without transition tables do not
-     prevent batching.
+     <command>INSERT</command> triggers.  Batching is also disabled for tuples
+     routed to a foreign-table partition when they may have to be captured in
+     a <literal>NEW TABLE</literal> transition relation of a parent table.
+     Statement-level triggers without transition tables, or with only
+     <literal>OLD TABLE</literal> transition relations, do not prevent
+     batching.
     </para>
 
     <para>
diff --git a/src/backend/executor/execMain.c b/src/backend/executor/execMain.c
index 55bd62dd313..3a469a85b0a 100644
--- a/src/backend/executor/execMain.c
+++ b/src/backend/executor/execMain.c
@@ -1380,11 +1380,16 @@ InitResultRelInfo(ResultRelInfo *resultRelInfo,
  * transition_capture is the statement's transition capture state when routing
  * tuples to a partition, or NULL when inserting into the result relation
  * directly, because a foreign table can't have transition tables of its own.
- *
- * FIXME The transition_capture check also disables batching for OLD
- * TABLE-only UPDATE triggers. This disables batching anytime the transition
- * capture is active - a performance regression. Maybe that's too strict and
- * we should check if tcs_update_new_table is set?
+ * Don't batch if the routed tuples may have to be added to a NEW TABLE (when
+ * inserting, or when an UPDATE moves rows to another partition).  Transition
+ * tuples can't be collected from foreign partitions, so the per-row code
+ * (ExecARInsertTriggers() and ExecARUpdateTriggers()) raises an error, while
+ * batching would silently leave the tuples out of the NEW TABLE.  An OLD
+ * TABLE never contains routed tuples (the old version of a moved row is
+ * captured when it's deleted from the source partition), so it doesn't
+ * prevent batching.  MERGE sets up transition capture for all kinds of
+ * actions, so an UPDATE NEW TABLE disables batching even for tuples routed by
+ * an INSERT action.
  *
  * The FDW's GetForeignModifyBatchSize callback is called only if batching is
  * possible; otherwise the batch size is 1.
@@ -1406,7 +1411,9 @@ ExecInitInsertBatchSize(ResultRelInfo *resultRelInfo,
 		(trigdesc == NULL ||
 		 (!trigdesc->trig_insert_before_row &&
 		  !trigdesc->trig_insert_after_row)) &&
-		transition_capture == NULL)
+		(transition_capture == NULL ||
+		 (!transition_capture->tcs_insert_new_table &&
+		  !transition_capture->tcs_update_new_table)))
 	{
 		batch_size = fdwroutine->GetForeignModifyBatchSize(resultRelInfo);
 
-- 
2.56.0

