From 9aa52193332a5708e0d55ad77f51527dd1d225c9 Mon Sep 17 00:00:00 2001
From: mingjiegao <mingjie.gmj@gmail.com>
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

