Hi,

I found a logical replication issue where updates can silently write
values into the wrong columns when two published tables map to
different ancestors of the same subscriber-side leaf partition.

I reproduced this on master (20devel) at:
61a372b795ddec08c15518096fe1f3e9e9973096

The publisher has two independent tables with different column orders:

    tab6(a, b)
    tab6_1(b, a)

On the subscriber, tab6_1 is a partitioned partition of tab6, with a
single leaf below it. Changes from both published tables therefore reach
the same leaf. The source rows have distinct primary key values.

After inserting through both tables, an UPDATE through tab6 builds the
leaf's mapping. A subsequent UPDATE through tab6_1 incorrectly reuses
that mapping. In the example below, the subscriber contains (a,b)=(222,2)
instead of (2,222), even after replication has caught up.

LogicalRepPartMap is keyed only by the local partition OID.
logicalrep_partition_open() can return a cached entry without checking
whether it belongs to the current remote relation. The new tuple is then
interpreted using the previous remote relation's attribute mapping.

The attached patch resets the cached entry when the remote relation ID
changes. The existing initialization path then rebuilds the remote
metadata, attribute mapping, updatability information and index choice.
It keeps the existing hash key and fast path for repeated operations
from the same remote relation; alternating sources rebuild the mapping.

The patch includes regression coverage in 013_partition.pl for text and
binary transfer, including switching A -> B -> A within a transaction.
On the baseline with only the test changes applied, six assertions fail.
With the fix, all 84 assertions pass. These tests used an optimized build
with assertions disabled and TAP tests enabled. I have not yet checked
older major versions or run the full PostgreSQL test suite.

For reference, the small SQL example below uses two fresh instances and
a publication containing only these two tables. The publisher must have
logical replication enabled. Replace <publisher connection string> with
the connection details for the publisher.

-- Publisher:
CREATE TABLE tab6 (a int PRIMARY KEY, b int);
CREATE TABLE tab6_1 (b int, a int PRIMARY KEY);
CREATE PUBLICATION map_pub FOR TABLE tab6, tab6_1;

-- Subscriber:
CREATE TABLE tab6 (a int PRIMARY KEY, b int) PARTITION BY RANGE (a);
CREATE TABLE tab6_1 (b int, a int PRIMARY KEY) PARTITION BY RANGE (a);
ALTER TABLE tab6 ATTACH PARTITION tab6_1 DEFAULT;
CREATE TABLE tab6_leaf PARTITION OF tab6_1 DEFAULT;
CREATE SUBSCRIPTION map_sub
  CONNECTION '<publisher connection string>'
  PUBLICATION map_pub WITH (copy_data = false);

-- Publisher, after the subscription is ready:
INSERT INTO tab6 (a, b) VALUES (1, 11);
INSERT INTO tab6_1 (a, b) VALUES (2, 22);
-- Wait for replication to catch up before continuing.

UPDATE tab6 SET b = 111 WHERE a = 1;
-- Wait for replication to catch up before continuing.

UPDATE tab6_1 SET b = 222 WHERE a = 2;
-- Wait for replication to catch up before reading the subscriber.

-- Subscriber:
SELECT a, b FROM tab6 ORDER BY a;

Expected:
 a |  b
---+-----
 1 | 111
 2 | 222

Actual without the fix:
  a  |  b
-----+-----
   1 | 111
 222 |   2

For each catch-up wait, obtain a target LSN on the publisher after the
preceding statements have committed:

    SELECT pg_current_wal_insert_lsn();

Then poll the following query on the publisher until it returns true,
substituting that LSN for <target LSN>:

    SELECT replay_lsn >= '<target LSN>'::pg_lsn
      FROM pg_stat_replication
     WHERE application_name = 'map_sub';

The inserts deliver both RELATION messages before the first UPDATE
builds the leaf mapping. The automated regression test handles cluster
setup and catch-up waits itself. To reproduce the failure on a clean
checkout of the baseline, apply only the test portion of the attachment:

    git apply --include=src/test/subscription/t/013_partition.pl \
      /path/to/v1-0001-Fix-logical-replication-partition-mapping-cache.patch

Then, from a configured and built tree with TAP tests enabled, run:

    make -j8 -C src/test/subscription check PROVE_TESTS=t/013_partition.pl

For an out-of-tree build, run the make command from the build directory.
Applying the remaining relation.c change and rebuilding makes the test
pass.

Comments are welcome.

Best regards,
mingjiegao
From 9aa52193332a5708e0d55ad77f51527dd1d225c9 Mon Sep 17 00:00:00 2001
From: mingjiegao <[email protected]>
Date: Sat, 31 Oct 2026 21:10:16 +0800
Subject: [PATCH v1] Fix logical replication partition mapping cache

A subscriber leaf partition can receive changes through more than one
replication target ancestor.  The partition map cache is keyed only by
the local partition OID, but its entries also contain metadata and an
attribute mapping for a particular remote relation.  Reusing an entry
built for another remote relation can cause UPDATE to write incoming
values into the wrong columns when the published tables have different
column orders.

Check the remote relation ID before reusing a partition map entry.  If
it changes, free and clear the entry so that the existing initialization
path rebuilds the remote metadata, attribute mapping, updatability and
index information.  Merely invalidating the local mapping would leave
the previous remote metadata in place.

Add regression coverage with two independent published tables mapping
to ancestors of the same subscriber leaf.  Exercise text and binary
transfer, switches between remote relations within one transaction,
and subsequent deletes.
---
 src/backend/replication/logical/relation.c | 11 ++++
 src/test/subscription/t/013_partition.pl   | 74 ++++++++++++++++++++++
 2 files changed, 85 insertions(+)

diff --git a/src/backend/replication/logical/relation.c b/src/backend/replication/logical/relation.c
index 6242ce70ad2..b316b69b258 100644
--- a/src/backend/replication/logical/relation.c
+++ b/src/backend/replication/logical/relation.c
@@ -659,6 +659,17 @@ logicalrep_partition_open(LogicalRepRelMapEntry *root,
 
 	entry = &part_entry->relmapentry;
 
+	/*
+	 * A leaf can be reached through different replication target ancestors.
+	 * Its cached mapping belongs to the remote relation it was built for.
+	 */
+	if (found && entry->remoterel.remoteid &&
+		entry->remoterel.remoteid != remoterel->remoteid)
+	{
+		logicalrep_relmap_free_entry(entry);
+		memset(entry, 0, sizeof(LogicalRepRelMapEntry));
+	}
+
 	/*
 	 * We must always overwrite entry->localrel with the latest partition
 	 * Relation pointer, because the Relation pointed to by the old value may
diff --git a/src/test/subscription/t/013_partition.pl b/src/test/subscription/t/013_partition.pl
index 21c3a9e261b..2526aec9cd1 100644
--- a/src/test/subscription/t/013_partition.pl
+++ b/src/test/subscription/t/013_partition.pl
@@ -897,6 +897,80 @@ $result = $node_subscriber2->safe_psql('postgres',
 	"SELECT a, b, c FROM tab5_1 ORDER BY 1");
 is($result, qq(4||1), 'updates of tab5 replicated correctly');
 
+# Independent published tables can target ancestors of the same local leaf.
+# Each remote relation has its own column order and replica identity map.
+$node_publisher->safe_psql(
+	'postgres', q{
+	CREATE TABLE tab6 (a int PRIMARY KEY, b int);
+	CREATE TABLE tab6_1 (b int, a int PRIMARY KEY);
+});
+$node_subscriber2->safe_psql(
+	'postgres', q{
+	CREATE TABLE tab6 (a int PRIMARY KEY, b int) PARTITION BY RANGE (a);
+	CREATE TABLE tab6_1 (b int, a int PRIMARY KEY) PARTITION BY RANGE (a);
+	ALTER TABLE tab6 ATTACH PARTITION tab6_1 DEFAULT;
+	CREATE TABLE tab6_leaf PARTITION OF tab6_1 DEFAULT;
+});
+$node_subscriber2->safe_psql('postgres',
+	"ALTER SUBSCRIPTION sub2 REFRESH PUBLICATION WITH (copy_data = false)");
+$node_subscriber2->wait_for_subscription_sync;
+
+foreach my $binary ('false', 'true')
+{
+	$node_subscriber2->safe_psql('postgres',
+		"ALTER SUBSCRIPTION sub2 SET (binary = $binary)");
+	$node_publisher->safe_psql(
+		'postgres', q{
+		INSERT INTO tab6 (a, b) VALUES (1, 11);
+		INSERT INTO tab6_1 (a, b) VALUES (2, 22);
+	});
+	$node_publisher->wait_for_catchup('sub2');
+
+	# Receive both RELATION messages before warming the leaf's mapping.
+	$node_publisher->safe_psql('postgres',
+		"UPDATE tab6 SET b = 111 WHERE a = 1");
+	$node_publisher->wait_for_catchup('sub2');
+	is( $node_subscriber2->safe_psql(
+			'postgres', "SELECT a, b FROM tab6 ORDER BY a"),
+		qq(1|111
+2|22),
+		"first remote relation maps to shared leaf (binary = $binary)");
+
+	$node_publisher->safe_psql('postgres',
+		"UPDATE tab6_1 SET b = 222 WHERE a = 2");
+	$node_publisher->wait_for_catchup('sub2');
+	is( $node_subscriber2->safe_psql(
+			'postgres', "SELECT a, b FROM tab6 ORDER BY a"),
+		qq(1|111
+2|222),
+		"second remote relation maps to shared leaf (binary = $binary)");
+
+	$node_publisher->safe_psql(
+		'postgres', q{
+		BEGIN;
+		UPDATE tab6 SET b = 112 WHERE a = 1;
+		UPDATE tab6_1 SET b = 223 WHERE a = 2;
+		UPDATE tab6 SET b = 113 WHERE a = 1;
+		COMMIT;
+	});
+	$node_publisher->wait_for_catchup('sub2');
+	is( $node_subscriber2->safe_psql(
+			'postgres', "SELECT a, b FROM tab6 ORDER BY a"),
+		qq(1|113
+2|223),
+		"switch remote mappings within a transaction (binary = $binary)");
+
+	$node_publisher->safe_psql('postgres',
+		"DELETE FROM tab6_1; DELETE FROM tab6");
+	$node_publisher->wait_for_catchup('sub2');
+	is( $node_subscriber2->safe_psql('postgres', "SELECT count(*) FROM tab6"),
+		'0',
+		"delete through both remote mappings (binary = $binary)");
+
+	# Keep the next case independent if an earlier assertion failed.
+	$node_subscriber2->safe_psql('postgres', "TRUNCATE tab6");
+}
+
 # Validate we didn't neglect to cleanup any resources on either subscriber.
 foreach my $node ($node_subscriber1, $node_subscriber2)
 {

base-commit: 61a372b795ddec08c15518096fe1f3e9e9973096
-- 
2.53.0

Reply via email to