From 2a2a311cc61599f11667e53cf5d6e22403e9dc3c Mon Sep 17 00:00:00 2001
From: Alexander Korotkov <akorotkov@postgresql.org>
Date: Mon, 3 Aug 2026 00:08:36 +0200
Subject: [PATCH v2 1/3] Don't logically decode MERGE/SPLIT PARTITION row
 movement

ALTER TABLE ... MERGE/SPLIT PARTITION relocates rows between partitions of the
same partitioned table by re-inserting them into the freshly created
partition(s), using plain heap inserts.  Logical decoding emitted those as
INSERTs into the new partition with no matching DELETEs for the source rows,
which corrupts logical replication subscribers.

Pass TABLE_INSERT_NO_LOGICAL to the movers so the relocation is not decoded,
just as CLUSTER and VACUUM FULL already do for their rewrites.  MERGE/SPLIT
PARTITION is a schema change that is not itself replicated, and the moved rows
still exist on subscribers, so suppressing the inserts keeps them consistent.

Document the behavior in the MERGE PARTITIONS and SPLIT PARTITION
commands descriptions, and add a test_decoding regression test.

Discussion: https://postgr.es/m/CAN4CZFNCU=t09M=+r2t9hHLJuujdM4oQ8hCK_Sx-GpfiwMAicw@mail.gmail.com
---
 contrib/test_decoding/Makefile                |  3 +-
 .../expected/partition_merge_split.out        | 56 +++++++++++++++++++
 contrib/test_decoding/meson.build             |  1 +
 .../sql/partition_merge_split.sql             | 34 +++++++++++
 doc/src/sgml/ref/alter_table.sgml             | 20 +++++++
 src/backend/commands/tablecmds.c              | 20 +++++--
 6 files changed, 129 insertions(+), 5 deletions(-)
 create mode 100644 contrib/test_decoding/expected/partition_merge_split.out
 create mode 100644 contrib/test_decoding/sql/partition_merge_split.sql

diff --git a/contrib/test_decoding/Makefile b/contrib/test_decoding/Makefile
index 0111124399a..ab90cd7fec2 100644
--- a/contrib/test_decoding/Makefile
+++ b/contrib/test_decoding/Makefile
@@ -5,7 +5,8 @@ PGFILEDESC = "test_decoding - example of a logical decoding output plugin"
 
 REGRESS = ddl xact rewrite toast permissions decoding_in_xact \
 	decoding_into_rel binary prepared replorigin time messages \
-	repack spill slot truncate stream stats twophase twophase_stream
+	repack spill slot truncate stream stats twophase twophase_stream \
+	partition_merge_split
 ISOLATION = mxact delayed_startup ondisk_startup concurrent_ddl_dml \
 	oldest_xmin snapshot_transfer subxact_without_top concurrent_stream \
 	twophase_snapshot slot_creation_error catalog_change_snapshot \
diff --git a/contrib/test_decoding/expected/partition_merge_split.out b/contrib/test_decoding/expected/partition_merge_split.out
new file mode 100644
index 00000000000..63ec5af98d0
--- /dev/null
+++ b/contrib/test_decoding/expected/partition_merge_split.out
@@ -0,0 +1,56 @@
+-- Row movement performed by ALTER TABLE ... MERGE/SPLIT PARTITION must not be
+-- logically decoded: the relocation is physical (like CLUSTER/VACUUM FULL) and
+-- the DDL itself is not replicated, so emitting INSERTs for the moved rows
+-- (without matching DELETEs) would corrupt logical subscribers.
+SET synchronous_commit = on;
+CREATE TABLE part (id int PRIMARY KEY) PARTITION BY RANGE (id);
+CREATE TABLE part_1 PARTITION OF part FOR VALUES FROM (0) TO (10);
+CREATE TABLE part_2 PARTITION OF part FOR VALUES FROM (10) TO (20);
+SELECT 'init' FROM pg_create_logical_replication_slot('regression_slot', 'test_decoding');
+ ?column? 
+----------
+ init
+(1 row)
+
+INSERT INTO part VALUES (1), (11);
+-- Drain the two INSERTs.
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ count 
+-------
+     4
+(1 row)
+
+-- MERGE: the relocation of the rows must not be decoded, so nothing (no
+-- INSERTs, and with skip-empty-xacts no empty transaction either) is emitted.
+ALTER TABLE part MERGE PARTITIONS (part_1, part_2) INTO part_merged;
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ data 
+------
+(0 rows)
+
+-- SPLIT: likewise.
+ALTER TABLE part SPLIT PARTITION part_merged INTO
+  (PARTITION part_1 FOR VALUES FROM (0) TO (10),
+   PARTITION part_2 FOR VALUES FROM (10) TO (20));
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ data 
+------
+(0 rows)
+
+-- A normal INSERT is still decoded afterwards.
+INSERT INTO part VALUES (2);
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+                    data                    
+--------------------------------------------
+ BEGIN
+ table public.part_1: INSERT: id[integer]:2
+ COMMIT
+(3 rows)
+
+SELECT 'stop' FROM pg_drop_replication_slot('regression_slot');
+ ?column? 
+----------
+ stop
+(1 row)
+
+DROP TABLE part;
diff --git a/contrib/test_decoding/meson.build b/contrib/test_decoding/meson.build
index ac655853d26..a504bc00794 100644
--- a/contrib/test_decoding/meson.build
+++ b/contrib/test_decoding/meson.build
@@ -42,6 +42,7 @@ tests += {
       'stats',
       'twophase',
       'twophase_stream',
+      'partition_merge_split',
     ],
     'regress_args': [
       '--temp-config', files('logical.conf'),
diff --git a/contrib/test_decoding/sql/partition_merge_split.sql b/contrib/test_decoding/sql/partition_merge_split.sql
new file mode 100644
index 00000000000..efdd6019ebd
--- /dev/null
+++ b/contrib/test_decoding/sql/partition_merge_split.sql
@@ -0,0 +1,34 @@
+-- Row movement performed by ALTER TABLE ... MERGE/SPLIT PARTITION must not be
+-- logically decoded: the relocation is physical (like CLUSTER/VACUUM FULL) and
+-- the DDL itself is not replicated, so emitting INSERTs for the moved rows
+-- (without matching DELETEs) would corrupt logical subscribers.
+SET synchronous_commit = on;
+
+CREATE TABLE part (id int PRIMARY KEY) PARTITION BY RANGE (id);
+CREATE TABLE part_1 PARTITION OF part FOR VALUES FROM (0) TO (10);
+CREATE TABLE part_2 PARTITION OF part FOR VALUES FROM (10) TO (20);
+
+SELECT 'init' FROM pg_create_logical_replication_slot('regression_slot', 'test_decoding');
+
+INSERT INTO part VALUES (1), (11);
+
+-- Drain the two INSERTs.
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
+-- MERGE: the relocation of the rows must not be decoded, so nothing (no
+-- INSERTs, and with skip-empty-xacts no empty transaction either) is emitted.
+ALTER TABLE part MERGE PARTITIONS (part_1, part_2) INTO part_merged;
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
+-- SPLIT: likewise.
+ALTER TABLE part SPLIT PARTITION part_merged INTO
+  (PARTITION part_1 FOR VALUES FROM (0) TO (10),
+   PARTITION part_2 FOR VALUES FROM (10) TO (20));
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
+-- A normal INSERT is still decoded afterwards.
+INSERT INTO part VALUES (2);
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
+SELECT 'stop' FROM pg_drop_replication_slot('regression_slot');
+DROP TABLE part;
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index ff7071bef5b..aaf4dfd111a 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -1281,6 +1281,16 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
       dependencies are not silently lost during merge.
      </para>
 
+     <para>
+      Moving rows into the new partition does not emit logical replication
+      messages, in the same way that <command>CLUSTER</command> or
+      <command>VACUUM FULL</command> do not.  Note that
+      <command>ALTER TABLE ... MERGE PARTITIONS</command> is a schema change and
+      is not itself replicated to logical replication subscribers; to reflect it
+      on a subscriber, run the equivalent command there, or drop and recreate
+      the affected partitions and refresh the subscription.
+     </para>
+
      <note>
       <para>
        Merging partitions acquires an <literal>ACCESS EXCLUSIVE</literal> lock on
@@ -1386,6 +1396,16 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
       from the source partition's indexes.
      </para>
 
+     <para>
+      Moving rows into the new partitions does not emit logical replication
+      messages, in the same way that <command>CLUSTER</command> or
+      <command>VACUUM FULL</command> do not.  Note that
+      <command>ALTER TABLE ... SPLIT PARTITION</command> is a schema change and
+      is not itself replicated to logical replication subscribers; to reflect it
+      on a subscriber, run the equivalent command there, or drop and recreate
+      the affected partitions and refresh the subscription.
+     </para>
+
      <note>
       <para>
        Split partition acquires an <literal>ACCESS EXCLUSIVE</literal> lock on
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 6d4c457b820..0eb85c1be17 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -23366,8 +23366,16 @@ MergePartitionsMoveRows(List **wqueue, List *mergingPartitions, Relation newPart
 	AlteredTableInfo *tab;
 	ListCell   *ltab;
 
-	/* The FSM is empty, so don't bother using it. */
-	uint32		ti_options = TABLE_INSERT_SKIP_FSM;
+	/*
+	 * The FSM is empty, so don't bother using it.  Also suppress logical
+	 * decoding of these inserts: merging partitions physically relocates rows
+	 * within the same partitioned table, much like CLUSTER or VACUUM FULL.
+	 * The relocation is not a user-level INSERT, and MERGE PARTITIONS is DDL
+	 * that logical replication does not replicate anyway; emitting INSERTs
+	 * for the moved rows (with no matching DELETEs for the source rows) would
+	 * corrupt logical subscribers.
+	 */
+	uint32		ti_options = TABLE_INSERT_SKIP_FSM | TABLE_INSERT_NO_LOGICAL;
 	BulkInsertState bistate;	/* state of bulk inserts for partition */
 	TupleTableSlot *dstslot;
 
@@ -24034,8 +24042,12 @@ static void
 SplitPartitionMoveRows(List **wqueue, Relation rel, Relation splitRel,
 					   List *partlist, List *newPartRels)
 {
-	/* The FSM is empty, so don't bother using it. */
-	uint32		ti_options = TABLE_INSERT_SKIP_FSM;
+	/*
+	 * The FSM is empty, so don't bother using it.  Suppress logical decoding
+	 * of these inserts as well; see the matching comment in
+	 * MergePartitionsMoveRows().
+	 */
+	uint32		ti_options = TABLE_INSERT_SKIP_FSM | TABLE_INSERT_NO_LOGICAL;
 	CommandId	mycid;
 	EState	   *estate;
 	ListCell   *listptr,
-- 
2.50.1 (Apple Git-155)

