From 2a2a311cc61599f11667e53cf5d6e22403e9dc3c Mon Sep 17 00:00:00 2001 From: Alexander Korotkov Date: Mon, 3 Aug 2026 00:08:36 +0200 Subject: [PATCH v1 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 numeric_literal, REM dependencies are not silently lost during merge. + + Moving rows into the new partition does not emit logical replication + messages, in the same way that CLUSTER or + VACUUM FULL do not. Note that + ALTER TABLE ... MERGE PARTITIONS 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. + + Merging partitions acquires an ACCESS EXCLUSIVE lock on @@ -1386,6 +1396,16 @@ WITH ( MODULUS numeric_literal, REM from the source partition's indexes. + + Moving rows into the new partitions does not emit logical replication + messages, in the same way that CLUSTER or + VACUUM FULL do not. Note that + ALTER TABLE ... SPLIT PARTITION 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. + + Split partition acquires an ACCESS EXCLUSIVE 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)