From 33d94b76d3ad8ecef4a834e66e52de1ace447e57 Mon Sep 17 00:00:00 2001 From: "Paul A. Jungwirth" 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 Author: Paul A. Jungwirth 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