From 159dee88285c1bc8c756fd6bb65f5cee23a1dade Mon Sep 17 00:00:00 2001 From: Nisha Moond Date: Tue, 29 Sep 2026 16:40:18 +0530 Subject: [PATCH v3] Don't match deleted rows on a deferrable PK in update_deleted detection When an UPDATE finds no local row, RelationFindDeletedTupleInfoSeq() searches for a recently deleted one. Without a replica identity index, it fell back to comparing only the primary key columns, even when the primary key was deferrable and so cannot serve as a replica identity. With REPLICA IDENTITY FULL on the publisher, a deleted row that merely shared the key value was matched, and update_deleted was reported instead of update_missing. Fall back to the primary key only if it is not deferrable, so that the whole row is compared. Add a test to 035_conflicts.pl. Author: Hayato Kuroda Author: Nisha Moond Discussion: https://postgr.es/m/CABdArM5ydwdRrpaZyK1q2p3-vY_+pnBtTmkvg_pcM=gHwmH7Kg@mail.gmail.com Backpatch-through: 19 --- src/backend/executor/execReplication.c | 7 ++- src/test/subscription/t/035_conflicts.pl | 63 ++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/backend/executor/execReplication.c b/src/backend/executor/execReplication.c index dd42acc13e2..168d8a68c13 100644 --- a/src/backend/executor/execReplication.c +++ b/src/backend/executor/execReplication.c @@ -593,8 +593,11 @@ RelationFindDeletedTupleInfoSeq(Relation rel, TupleTableSlot *searchslot, indexbitmap = RelationGetIndexAttrBitmap(rel, INDEX_ATTR_BITMAP_IDENTITY_KEY); - /* fallback to PK if no replica identity */ - if (!indexbitmap) + /* + * fallback to PK if no replica identity, but only if the PK is not + * deferrable. + */ + if (!indexbitmap && OidIsValid(RelationGetPrimaryKeyIndex(rel, false))) indexbitmap = RelationGetIndexAttrBitmap(rel, INDEX_ATTR_BITMAP_PRIMARY_KEY); diff --git a/src/test/subscription/t/035_conflicts.pl b/src/test/subscription/t/035_conflicts.pl index 5804e38ed69..e6dc72e8fb9 100644 --- a/src/test/subscription/t/035_conflicts.pl +++ b/src/test/subscription/t/035_conflicts.pl @@ -376,6 +376,69 @@ like( .*The row to be updated was deleted locally in transaction [0-9]+ at .*/, 'update target row was deleted in tab'); +############################################################################### +# Ensure that a deferrable primary key is not used to match deleted tuples in +# a sequential table scan. Such a key cannot serve as a replica identity, so +# the whole tuple must be compared, and a deleted row that only shares the key +# value must not be reported as update_deleted. +############################################################################### + +# Create the table and publish it from node B only, so that local changes on +# node A are not sent back. Skip the initial copy, so that node A never has +# the row from node B. +$node_B->safe_psql( + 'postgres', " + CREATE TABLE tab_defer (a int, b int); + ALTER TABLE tab_defer REPLICA IDENTITY FULL; + INSERT INTO tab_defer VALUES (1, 1);"); +$node_A->safe_psql('postgres', "CREATE TABLE tab_defer (a int, b int)"); +$node_B->safe_psql('postgres', + "ALTER PUBLICATION tap_pub_B ADD TABLE tab_defer"); +$node_A->safe_psql('postgres', + "ALTER SUBSCRIPTION $subname_AB REFRESH PUBLICATION WITH (copy_data = false)" +); +$node_A->wait_for_subscription_sync($node_B, $subname_AB); + +# Disable the logical replication from node B to node A +$node_A->safe_psql('postgres', "ALTER SUBSCRIPTION $subname_AB DISABLE"); + +# Wait for the apply worker to stop +$node_A->poll_query_until('postgres', + "SELECT count(*) = 0 FROM pg_stat_activity WHERE backend_type = 'logical replication apply worker'" +); + +# The primary key is created after the conflict detection slot's xmin, so it +# cannot be used to find deleted tuples and a sequential scan is used instead. +# Then delete a local row that has the same key but a different value. +$node_A->safe_psql( + 'postgres', " + ALTER TABLE tab_defer ADD PRIMARY KEY (a) DEFERRABLE; + INSERT INTO tab_defer VALUES (1, 10); + DELETE FROM tab_defer WHERE a = 1;"); + +$node_B->safe_psql('postgres', "UPDATE tab_defer SET b = 2 WHERE a = 1;"); + +$log_location = -s $node_A->logfile; + +$node_A->safe_psql('postgres', "ALTER SUBSCRIPTION $subname_AB ENABLE;"); +$node_B->wait_for_catchup($subname_AB); + +$logfile = slurp_file($node_A->logfile(), $log_location); +like( + $logfile, + qr/conflict detected on relation "public.tab_defer": conflict=update_missing.* +.*DETAIL:.* Could not find the row to be updated: remote row \(1, 2\), replica identity full \(1, 1\)/, + 'deleted row matching only the deferrable primary key is not reported as update_deleted' +); + +# Clean up +$node_B->safe_psql('postgres', + "ALTER PUBLICATION tap_pub_B DROP TABLE tab_defer"); +$node_A->safe_psql('postgres', + "ALTER SUBSCRIPTION $subname_AB REFRESH PUBLICATION"); +$node_A->safe_psql('postgres', "DROP TABLE tab_defer"); +$node_B->safe_psql('postgres', "DROP TABLE tab_defer"); + ############################################################################### # Check that the xmin value of the conflict detection slot can be advanced when # the subscription has no tables. -- 2.54.0 (Apple Git-157)