From 33d94b76d3ad8ecef4a834e66e52de1ace447e57 Mon Sep 17 00:00:00 2001
From: "Paul A. Jungwirth" <pj@illuminatedcomputing.com>
Date: Wed, 23 Sep 2026 00:16:57 -0700
Subject: [PATCH v1] Fix wrong replication for multirange WITHOUT OVERLAPS
 indexes

Unlike a btree replica identity, a WITHOUT OVERLAPS constraint index may
require rechecks, since the index tuples may use lossy compression. We were
skipping the recheck, which could cause us to update/delete the wrong row. If
the lookup signals that a recheck is needed, compare the tuples for equality.
But unless REPLICA IDENTITY FULL, we only need to compare the index keys.

Reported-by: Andres Freund <andres@anarazel.de>
Author: Paul A. Jungwirth <pj@illuminatedcomputing.com>
Discussion: https://postgr.es/m/kcyaok346iwocfdourf2oojgtz7ggxmh2ugis7qqhggs4qfzc2@cu6pkox4rfwp
Discussion: https://postgr.es/m/e5nb5jbus2oa3pffmlo7pdvdckmchd54tqld4k3n6huyg5xxqn@7rounyvufjx6
Backpatch-through: 18
---
 src/backend/executor/execReplication.c  | 40 +++++++++++---
 src/test/subscription/t/034_temporal.pl | 71 +++++++++++++++++++++++++
 2 files changed, 103 insertions(+), 8 deletions(-)

diff --git a/src/backend/executor/execReplication.c b/src/backend/executor/execReplication.c
index 18da4fe08f6..58a4a1e7d71 100644
--- a/src/backend/executor/execReplication.c
+++ b/src/backend/executor/execReplication.c
@@ -197,6 +197,7 @@ RelationFindReplTupleByIndex(Relation rel, Oid idxoid,
 	Relation	idxrel;
 	bool		found;
 	TypeCacheEntry **eq = NULL;
+	Bitmapset  *indexbitmap = NULL;
 
 	/* Open the index. */
 	idxrel = index_open(idxoid, RowExclusiveLock);
@@ -219,15 +220,26 @@ retry:
 	while (table_index_getnext_slot(scan, ForwardScanDirection, outslot))
 	{
 		/*
-		 * Avoid expensive equality check if the index is primary key or
-		 * replica identity index.
+		 * Avoid expensive equality check if the index is a primary key or
+		 * replica identity index.  But a WITHOUT OVERLAPS key might require a
+		 * recheck (e.g. GiST multirange).
 		 */
-		if (!skipduplicates)
+		if (!skipduplicates || scan->xs_recheck)
 		{
 			if (eq == NULL)
 				eq = palloc0_array(TypeCacheEntry *, outslot->tts_tupleDescriptor->natts);
 
-			if (!tuples_equal(outslot, searchslot, eq, NULL))
+			/* Look up the key columns if we have a PK/RI index. */
+			if (skipduplicates && indexbitmap == NULL)
+			{
+				indexbitmap = RelationGetIndexAttrBitmap(rel,
+														 INDEX_ATTR_BITMAP_IDENTITY_KEY);
+				if (!indexbitmap)
+					indexbitmap = RelationGetIndexAttrBitmap(rel,
+															 INDEX_ATTR_BITMAP_PRIMARY_KEY);
+			}
+
+			if (!tuples_equal(outslot, searchslot, eq, indexbitmap))
 				continue;
 		}
 
@@ -668,6 +680,7 @@ RelationFindDeletedTupleInfoByIndex(Relation rel, Oid idxoid,
 	IndexScanDesc scan;
 	TupleTableSlot *scanslot;
 	TypeCacheEntry **eq = NULL;
+	Bitmapset  *indexbitmap = NULL;
 	TupleDesc	desc PG_USED_FOR_ASSERTS_ONLY = RelationGetDescr(rel);
 
 	Assert(equalTupleDescs(desc, searchslot->tts_tupleDescriptor));
@@ -699,15 +712,26 @@ RelationFindDeletedTupleInfoByIndex(Relation rel, Oid idxoid,
 	while (table_index_getnext_slot(scan, ForwardScanDirection, scanslot))
 	{
 		/*
-		 * Avoid expensive equality check if the index is primary key or
-		 * replica identity index.
+		 * Avoid expensive equality check if the index is a primary key or
+		 * replica identity index.  But a WITHOUT OVERLAPS key might require a
+		 * recheck (e.g. GiST multirange).
 		 */
-		if (!skipduplicates)
+		if (!skipduplicates || scan->xs_recheck)
 		{
 			if (eq == NULL)
 				eq = palloc0_array(TypeCacheEntry *, scanslot->tts_tupleDescriptor->natts);
 
-			if (!tuples_equal(scanslot, searchslot, eq, NULL))
+			/* Look up the key columns if we have a PK/RI index. */
+			if (skipduplicates && indexbitmap == NULL)
+			{
+				indexbitmap = RelationGetIndexAttrBitmap(rel,
+														 INDEX_ATTR_BITMAP_IDENTITY_KEY);
+				if (!indexbitmap)
+					indexbitmap = RelationGetIndexAttrBitmap(rel,
+															 INDEX_ATTR_BITMAP_PRIMARY_KEY);
+			}
+
+			if (!tuples_equal(scanslot, searchslot, eq, indexbitmap))
 				continue;
 		}
 
diff --git a/src/test/subscription/t/034_temporal.pl b/src/test/subscription/t/034_temporal.pl
index 66955e1b799..f8e662ef367 100644
--- a/src/test/subscription/t/034_temporal.pl
+++ b/src/test/subscription/t/034_temporal.pl
@@ -628,4 +628,75 @@ is( $result, qq{[1,2)|[2000-01-01,2010-01-01)|a
 
 drop_everything();
 
+
+# #################################
+# Test with a lossy REPLICA IDENTITY index (multirange key)
+#
+# A temporal key over a multirange column is backed by a lossy GiST index: two
+# distinct multiranges that share a bounding range are indistinguishable in the
+# index, so an index probe can return candidate tuples that are not exact
+# matches.  The apply worker must recheck each candidate against the actual row
+# values, otherwise it could apply the change to the wrong row.
+# #################################
+
+$node_publisher->safe_psql('postgres',
+	"CREATE TABLE temporal_mltrng (id int4multirange, valid_at daterange, a text, PRIMARY KEY (id, valid_at WITHOUT OVERLAPS))"
+);
+$node_subscriber->safe_psql('postgres',
+	"CREATE TABLE temporal_mltrng (id int4multirange, valid_at daterange, a text, PRIMARY KEY (id, valid_at WITHOUT OVERLAPS))"
+);
+
+# Two different multiranges that share the bounding range [1,5), with the same
+# valid_at.  They do not conflict (their ids are not equal), but the GiST index
+# cannot tell them apart without a recheck.
+$node_publisher->safe_psql(
+	'postgres',
+	"INSERT INTO temporal_mltrng (id, valid_at, a)
+   VALUES ('{[1,5)}', '[2000-01-01,2010-01-01)', 'a'),
+          ('{[1,2),[3,5)}', '[2000-01-01,2010-01-01)', 'b')");
+
+$node_publisher->safe_psql('postgres',
+	"CREATE PUBLICATION pub1 FOR ALL TABLES");
+$node_subscriber->safe_psql('postgres',
+	"CREATE SUBSCRIPTION sub1 CONNECTION '$publisher_connstr' PUBLICATION pub1"
+);
+$node_subscriber->wait_for_subscription_sync;
+
+$result = $node_subscriber->safe_psql('postgres',
+	"SELECT id, a FROM temporal_mltrng ORDER BY a");
+is( $result, qq{{[1,5)}|a
+{[1,2),[3,5)}|b}, 'synced temporal_mltrng lossy identity');
+
+# Update each row.  Each apply probes the identity index and gets *both* rows as
+# candidates; without a recheck the wrong row would be updated.
+$node_publisher->safe_psql('postgres',
+	"UPDATE temporal_mltrng SET a = 'a2' WHERE id = '{[1,5)}'");
+$node_publisher->safe_psql('postgres',
+	"UPDATE temporal_mltrng SET a = 'b2' WHERE id = '{[1,2),[3,5)}'");
+
+$node_publisher->wait_for_catchup('sub1');
+
+$result = $node_subscriber->safe_psql('postgres',
+	"SELECT id, a FROM temporal_mltrng ORDER BY a");
+is( $result, qq{{[1,5)}|a2
+{[1,2),[3,5)}|b2}, 'replicated temporal_mltrng UPDATE to correct rows');
+
+# Same for DELETE: remove only one of the two bounding-range twins.
+$node_publisher->safe_psql('postgres',
+	"DELETE FROM temporal_mltrng WHERE id = '{[1,2),[3,5)}'");
+
+$node_publisher->wait_for_catchup('sub1');
+
+$result = $node_subscriber->safe_psql('postgres',
+	"SELECT id, a FROM temporal_mltrng ORDER BY a");
+is($result, qq{{[1,5)}|a2},
+	'replicated temporal_mltrng DELETE of correct row');
+
+# cleanup
+
+$node_publisher->safe_psql('postgres', "DROP TABLE temporal_mltrng");
+$node_subscriber->safe_psql('postgres', "DROP TABLE temporal_mltrng");
+$node_publisher->safe_psql('postgres', "DROP PUBLICATION pub1");
+$node_subscriber->safe_psql('postgres', "DROP SUBSCRIPTION sub1");
+
 done_testing();
-- 
2.45.0

