From e684e0ad114af8c2f71aa620229a33f36c23cc87 Mon Sep 17 00:00:00 2001
From: Nisha Moond <nisha.moond412@gmail.com>
Date: Wed, 30 Sep 2026 15:41:09 +0530
Subject: [PATCH v6 2/2] Add TAP tests for update_deleted detection by
 sequential scan

Add two cases to 035_conflicts.pl where the sequential scan for deleted
rows must report update_deleted or update_missing correctly: a
subscriber table whose only key is a deferrable primary key, and a
replica identity index dropped concurrently during apply.
---
 src/test/subscription/t/035_conflicts.pl | 171 +++++++++++++++++++++++
 1 file changed, 171 insertions(+)

diff --git a/src/test/subscription/t/035_conflicts.pl b/src/test/subscription/t/035_conflicts.pl
index 5804e38ed69..542895fd445 100644
--- a/src/test/subscription/t/035_conflicts.pl
+++ b/src/test/subscription/t/035_conflicts.pl
@@ -376,6 +376,177 @@ 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");
+
+###############################################################################
+# Ensure that a sequential scan for finding deleted tuples keeps using the
+# replica identity selected by the relation map when concurrent DROP INDEX
+# drops that index.
+###############################################################################
+
+SKIP:
+{
+	skip 'Injection points not supported by this build', 2
+	  unless $ENV{enable_injection_points} eq 'yes';
+	skip 'Extension injection_points not installed', 2
+	  unless $node_A->check_extension('injection_points');
+
+	$node_A->safe_psql('postgres', 'CREATE EXTENSION injection_points');
+
+	# Start a transaction and keep it open until the end of the test. It
+	# holds back the apply worker's oldest_nonremovable_xid (see
+	# get_candidate_xid()), so that the index created below is not usable
+	# for finding deleted tuples (see IsIndexUsableForFindingDeletedTuple())
+	# and a sequential scan is used instead, and so that the local deletion
+	# stays recent enough to be reported as update_deleted.
+	my $old_xact = $node_A->background_psql('postgres');
+	$old_xact->query_safe(
+		q[
+		BEGIN;
+		SELECT txid_current();
+	]);
+
+	$node_B->safe_psql(
+		'postgres', q[
+		CREATE TABLE tab_drop_deleted (a int PRIMARY KEY, b int);
+		INSERT INTO tab_drop_deleted VALUES (1, 1);
+		CREATE PUBLICATION pub_drop_deleted FOR TABLE tab_drop_deleted;
+	]);
+	$node_A->safe_psql(
+		'postgres', q[
+		CREATE TABLE tab_drop_deleted (a int NOT NULL, b int);
+		CREATE UNIQUE INDEX tab_drop_deleted_ri ON tab_drop_deleted (a);
+		ALTER TABLE tab_drop_deleted REPLICA IDENTITY
+			USING INDEX tab_drop_deleted_ri;
+	]);
+	$node_A->safe_psql(
+		'postgres',
+		"CREATE SUBSCRIPTION sub_drop_deleted
+		 CONNECTION '$node_B_connstr application_name=drop_deleted'
+		 PUBLICATION pub_drop_deleted
+		 WITH (retain_dead_tuples = true)"
+	);
+	$node_A->wait_for_subscription_sync($node_B, 'drop_deleted');
+
+	# Delete the row locally. The remote update sends only the new tuple
+	# (1, 2), which differs from the deleted row (1, 1) outside the replica
+	# identity, so a sequential scan can find the deleted row only if it
+	# compares the cached index's key columns.
+	$node_A->safe_psql('postgres',
+		'DELETE FROM tab_drop_deleted WHERE a = 1');
+	$node_A->safe_psql('postgres',
+		"SELECT injection_points_attach('apply-update-before-open-indices', 'wait')"
+	);
+	$node_B->safe_psql('postgres',
+		'UPDATE tab_drop_deleted SET b = 2 WHERE a = 1');
+	$node_A->wait_for_event(
+		'logical replication apply worker',
+		'apply-update-before-open-indices');
+
+	$log_location = -s $node_A->logfile;
+
+	# Drop the replica identity index concurrently
+	my $drop = $node_A->background_psql('postgres');
+	$drop->query_until(
+		qr/starting_drop/, q[
+		\echo starting_drop
+		DROP INDEX CONCURRENTLY tab_drop_deleted_ri;
+	]);
+	$node_A->poll_query_until('postgres',
+			"SELECT count(*) = 0 FROM pg_index"
+		  . " WHERE indrelid = 'tab_drop_deleted'::regclass"
+		  . " AND indisreplident")
+	  or die "timed out waiting for the identity index to be invalidated";
+
+	$node_A->safe_psql(
+		'postgres',
+		"SELECT injection_points_detach('apply-update-before-open-indices');
+		 SELECT injection_points_wakeup('apply-update-before-open-indices');"
+	);
+
+	# Ensure update_deleted is detected. Without the cached index key columns,
+	# the sequential scan would compare the whole row and report
+	# update_missing.
+	$node_B->wait_for_catchup('drop_deleted');
+	$logfile = slurp_file($node_A->logfile(), $log_location);
+	like(
+		$logfile,
+		qr/conflict detected on relation "public.tab_drop_deleted": conflict=update_deleted.*
+.*DETAIL:.* Could not find the row to be updated: remote row \(1, 2\).*
+.*The row to be updated was deleted locally in transaction [0-9]+ at .*/,
+		'sequential scan uses cached identity columns after concurrent drop');
+
+	# Clean up
+	ok($drop->quit, 'DROP INDEX CONCURRENTLY completes');
+	$old_xact->quit;
+	$node_A->safe_psql('postgres', 'DROP SUBSCRIPTION sub_drop_deleted');
+	$node_A->safe_psql('postgres', 'DROP TABLE tab_drop_deleted');
+	$node_A->safe_psql('postgres', 'DROP EXTENSION injection_points');
+	$node_B->safe_psql('postgres', 'DROP PUBLICATION pub_drop_deleted');
+	$node_B->safe_psql('postgres', 'DROP TABLE tab_drop_deleted');
+}
+
 ###############################################################################
 # 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)

