From 9d198cb55b85009aca42ee3c0625a29d18f0fbb5 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 v4] Improve index selection for REPLICA IDENTITY FULL

When multiple usable indexes exist for a relation with REPLICA IDENTITY
FULL, the subscriber now prefers a unique index over a non-unique one.
A unique index guarantees at most one tuple per index scan, avoiding the
need to scan extra tuples when multiple rows match the lookup key.

For non-unique indexes, the existing behavior is preserved: the first
usable index found is returned, since index selectivity cannot be
reasoned about without invoking the planner.

Previously, the first eligible index found was returned without
considering whether a unique index existed.
---
 src/backend/replication/logical/relation.c    | 19 ++++-
 .../subscription/t/032_subscribe_use_index.pl | 71 +++++++++++++++++++
 2 files changed, 87 insertions(+), 3 deletions(-)

diff --git a/src/backend/replication/logical/relation.c b/src/backend/replication/logical/relation.c
index 296cbaede30..927f6176493 100644
--- a/src/backend/replication/logical/relation.c
+++ b/src/backend/replication/logical/relation.c
@@ -789,22 +789,35 @@ static Oid
 FindUsableIndexForReplicaIdentityFull(Relation localrel, AttrMap *attrmap)
 {
 	List	   *idxlist = RelationGetIndexList(localrel);
+	Oid			chosen_idx = InvalidOid;
 
 	foreach_oid(idxoid, idxlist)
 	{
 		bool		isUsableIdx;
+		bool		is_unique;
 		Relation	idxRel;
 
 		idxRel = index_open(idxoid, AccessShareLock);
 		isUsableIdx = IsIndexUsableForReplicaIdentityFull(idxRel, attrmap);
+		is_unique = idxRel->rd_index->indisunique;
 		index_close(idxRel, AccessShareLock);
 
-		/* Return the first eligible index found */
-		if (isUsableIdx)
+		if (!isUsableIdx)
+			continue;
+
+		/*
+		 * A unique index guarantees at most one tuple per index scan.  Return
+		 * it immediately rather than continuing to look for a better
+		 * candidate.  For non-unique indexes we keep scanning, accepting the
+		 * last usable one found.
+		 */
+		if (is_unique)
 			return idxoid;
+
+		chosen_idx = idxoid;
 	}
 
-	return InvalidOid;
+	return chosen_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..657fd52617d 100644
--- a/src/test/subscription/t/032_subscribe_use_index.pl
+++ b/src/test/subscription/t/032_subscribe_use_index.pl
@@ -547,6 +547,77 @@ $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 over non-unique ones
+#
+
+# 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 and a unique index; the unique one 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 over non-unique ones
+# =============================================================================
+
 $node_subscriber->stop('fast');
 $node_publisher->stop('fast');
 
-- 
2.47.3

