From 14b6224db4240e522087ae0c90620c7e6a072e2c Mon Sep 17 00:00:00 2001
From: Ethan Mertz <ethan.mertz@gmail.com>
Date: Tue, 16 Jun 2026 14:07:01 +0000
Subject: [PATCH v3] Improve index selection for REPLICA IDENTITY FULL

When multiple usable indexes exist for a relation with REPLICA IDENTITY
FULL, the subscriber now prefers unique indexes over non-unique ones
(since a unique index guarantees at most one tuple per index scan), and
among unique indexes, prefers those with fewer key columns.

For non-unique indexes, the first usable one found is accepted without
further ranking, since more key columns can narrow the search space and
fewer columns is not necessarily better.

Previously, the first eligible index found was returned without
considering whether a better candidate existed.
---
 src/backend/replication/logical/relation.c    | 41 ++++++++---
 .../subscription/t/032_subscribe_use_index.pl | 72 +++++++++++++++++++
 2 files changed, 103 insertions(+), 10 deletions(-)

diff --git a/src/backend/replication/logical/relation.c b/src/backend/replication/logical/relation.c
index 296cbaede30..7717e79b290 100644
--- a/src/backend/replication/logical/relation.c
+++ b/src/backend/replication/logical/relation.c
@@ -789,22 +789,43 @@ static Oid
 FindUsableIndexForReplicaIdentityFull(Relation localrel, AttrMap *attrmap)
 {
 	List	   *idxlist = RelationGetIndexList(localrel);
+	Oid			best_idx = InvalidOid;
+	bool		best_is_unique = false;
+	int			best_nkeyatts = PG_INT32_MAX;
 
 	foreach_oid(idxoid, idxlist)
 	{
-		bool		isUsableIdx;
-		Relation	idxRel;
-
-		idxRel = index_open(idxoid, AccessShareLock);
-		isUsableIdx = IsIndexUsableForReplicaIdentityFull(idxRel, attrmap);
-		index_close(idxRel, AccessShareLock);
+		bool		is_usable;
+		bool		is_unique;
+		int			nkeyatts;
+		Relation	idxrel;
+
+		idxrel = index_open(idxoid, AccessShareLock);
+		is_usable = IsIndexUsableForReplicaIdentityFull(idxrel, attrmap);
+		is_unique = idxrel->rd_index->indisunique;
+		nkeyatts = idxrel->rd_index->indnkeyatts;
+		index_close(idxrel, AccessShareLock);
+
+		if (!is_usable)
+			continue;
 
-		/* Return the first eligible index found */
-		if (isUsableIdx)
-			return idxoid;
+		/*
+		 * Prefer unique indexes (at most one index scan per tuple match), and
+		 * among those prefer fewer key columns.  For non-unique indexes we
+		 * just accept the first one found, since more keys can narrow the
+		 * search space and fewer columns is not necessarily better.
+		 */
+		if (best_idx == InvalidOid ||
+			(is_unique && !best_is_unique) ||
+			(best_is_unique && is_unique && nkeyatts < best_nkeyatts))
+		{
+			best_idx = idxoid;
+			best_is_unique = is_unique;
+			best_nkeyatts = nkeyatts;
+		}
 	}
 
-	return InvalidOid;
+	return best_idx;
 }
 
 /*
diff --git a/src/test/subscription/t/032_subscribe_use_index.pl b/src/test/subscription/t/032_subscribe_use_index.pl
index c755c1a7518..1ca7a05bbed 100644
--- a/src/test/subscription/t/032_subscribe_use_index.pl
+++ b/src/test/subscription/t/032_subscribe_use_index.pl
@@ -547,6 +547,78 @@ $node_subscriber->safe_psql('postgres', "DROP TABLE test_replica_id_full");
 # Testcase end: Subscription can use hash index
 # =============================================================================
 
+# =============================================================================
+# Testcase start: Index selection prefers unique indexes and fewer key columns
+#
+
+# create tables pub and sub
+$node_publisher->safe_psql('postgres',
+	"CREATE TABLE test_idx_select (a int, b int, c int)");
+$node_publisher->safe_psql('postgres',
+	"ALTER TABLE test_idx_select REPLICA IDENTITY FULL");
+$node_subscriber->safe_psql('postgres',
+	"CREATE TABLE test_idx_select (a int, b int, c int)");
+
+# create a non-unique index on (a, b, c) and a unique index on (a, b)
+# the unique index with fewer columns should be preferred
+$node_subscriber->safe_psql('postgres',
+	"CREATE INDEX test_idx_select_nonuniq ON test_idx_select(a, b, c)");
+$node_subscriber->safe_psql('postgres',
+	"CREATE UNIQUE INDEX test_idx_select_uniq ON test_idx_select(a, b)");
+
+# create pub/sub
+$node_publisher->safe_psql('postgres',
+	"CREATE PUBLICATION tap_pub_idx_select FOR TABLE test_idx_select");
+$node_subscriber->safe_psql('postgres',
+	"CREATE SUBSCRIPTION tap_sub_idx_select CONNECTION '$publisher_connstr application_name=$appname' PUBLICATION tap_pub_idx_select"
+);
+
+# wait for initial table synchronization to finish
+$node_subscriber->wait_for_subscription_sync($node_publisher, $appname);
+
+# capture idx_scan before the update
+$node_subscriber->safe_psql('postgres', "SELECT pg_stat_force_next_flush()");
+my $idx_scan_before = $node_subscriber->safe_psql('postgres',
+	"SELECT idx_scan FROM pg_stat_all_indexes WHERE indexrelname = 'test_idx_select_uniq'");
+
+# insert and update a row
+$node_publisher->safe_psql('postgres',
+	"INSERT INTO test_idx_select VALUES (1, 2, 3)");
+$node_publisher->safe_psql('postgres',
+	"UPDATE test_idx_select SET c = 4 WHERE a = 1");
+
+# wait until the unique index is used on the subscriber
+$node_publisher->wait_for_catchup($appname);
+$node_subscriber->poll_query_until('postgres',
+	qq{SELECT idx_scan > $idx_scan_before FROM pg_stat_all_indexes, pg_stat_force_next_flush() WHERE indexrelname = 'test_idx_select_uniq';}
+  )
+  or die
+  "Timed out while waiting for check subscriber tap_sub_idx_select updates one row via unique index";
+
+# make sure that the non-unique index was not used
+$node_subscriber->safe_psql('postgres', "SELECT pg_stat_force_next_flush()");
+$result = $node_subscriber->safe_psql('postgres',
+	"SELECT idx_scan FROM pg_stat_all_indexes WHERE indexrelname = 'test_idx_select_nonuniq'");
+is($result, qq(0),
+	'ensure non-unique index is not used when unique index is available');
+
+# make sure that the subscriber has the correct data
+$result = $node_subscriber->safe_psql('postgres',
+	"SELECT c FROM test_idx_select WHERE a = 1");
+is($result, qq(4),
+	'ensure subscriber has the correct data at the end of the test');
+
+# cleanup pub
+$node_publisher->safe_psql('postgres', "DROP PUBLICATION tap_pub_idx_select");
+$node_publisher->safe_psql('postgres', "DROP TABLE test_idx_select");
+
+# cleanup sub
+$node_subscriber->safe_psql('postgres', "DROP SUBSCRIPTION tap_sub_idx_select");
+$node_subscriber->safe_psql('postgres', "DROP TABLE test_idx_select");
+
+# Testcase end: Index selection prefers unique indexes and fewer key columns
+# =============================================================================
+
 $node_subscriber->stop('fast');
 $node_publisher->stop('fast');
 
-- 
2.47.3

