From 606e7d785f6179e54883ecfe67d8017f0b19d8f5 Mon Sep 17 00:00:00 2001
From: Vignesh C <c_vigneshwaran@apple.com>
Date: Mon, 24 Aug 2026 16:30:34 +0530
Subject: [PATCH] Test to reproduce assertion failure in 
 GetSubscriptionRelations because of concurrent drop table.

Test to reproduce assertion failure in GetSubscriptionRelations because
of concurrent drop table.
---
 src/backend/catalog/pg_subscription.c         |   3 +
 src/test/subscription/meson.build             |   1 +
 .../t/059_subscription_rel_dropped.pl         | 155 ++++++++++++++++++
 3 files changed, 159 insertions(+)
 create mode 100644 src/test/subscription/t/059_subscription_rel_dropped.pl

diff --git a/src/backend/catalog/pg_subscription.c b/src/backend/catalog/pg_subscription.c
index f1e8b624d8e..36870aa9419 100644
--- a/src/backend/catalog/pg_subscription.c
+++ b/src/backend/catalog/pg_subscription.c
@@ -31,6 +31,7 @@
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
+#include "utils/injection_point.h"
 #include "utils/lsyscache.h"
 #include "utils/memutils.h"
 #include "utils/pg_lsn.h"
@@ -684,6 +685,8 @@ GetSubscriptionRelations(Oid subid, bool tables, bool sequences,
 
 		subrel = (Form_pg_subscription_rel) GETSTRUCT(tup);
 
+		INJECTION_POINT("subscription-relations-fetch", NULL);
+
 		/* Relation is either a sequence or a table */
 		relkind = get_rel_relkind(subrel->srrelid);
 		Assert(relkind == RELKIND_SEQUENCE || relkind == RELKIND_RELATION ||
diff --git a/src/test/subscription/meson.build b/src/test/subscription/meson.build
index e71e95c6297..a9e3076b63b 100644
--- a/src/test/subscription/meson.build
+++ b/src/test/subscription/meson.build
@@ -48,6 +48,7 @@ tests += {
       't/036_sequences.pl',
       't/037_except.pl',
       't/038_walsnd_shutdown_timeout.pl',
+      't/059_subscription_rel_dropped.pl',
       't/100_bugs.pl',
     ],
   },
diff --git a/src/test/subscription/t/059_subscription_rel_dropped.pl b/src/test/subscription/t/059_subscription_rel_dropped.pl
new file mode 100644
index 00000000000..de71088152e
--- /dev/null
+++ b/src/test/subscription/t/059_subscription_rel_dropped.pl
@@ -0,0 +1,155 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Test GetSubscriptionRelations() against a relation dropped while it runs.
+#
+# GetSubscriptionRelations() scans pg_subscription_rel with the catalog snapshot
+# taken when the scan started, and then looks up each relation's relkind through
+# the syscache, which takes a newer snapshot.  A relation whose drop commits in
+# between is therefore still visible as a pg_subscription_rel row while its
+# pg_class row is already gone, and get_rel_relkind() returns '\0' for it.
+#
+# That trips
+#
+#   Assert(relkind == RELKIND_SEQUENCE || relkind == RELKIND_RELATION ||
+#          relkind == RELKIND_PARTITIONED_TABLE);
+#
+# in an assert-enabled build.
+
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+if ($ENV{enable_injection_points} ne 'yes')
+{
+	plan skip_all => 'Injection points not supported by this build';
+}
+
+my $node_publisher = PostgreSQL::Test::Cluster->new('publisher');
+$node_publisher->init(allows_streaming => 'logical');
+$node_publisher->start;
+
+my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
+$node_subscriber->init(allows_streaming => 'logical');
+$node_subscriber->start;
+
+if (!$node_subscriber->check_extension('injection_points'))
+{
+	plan skip_all => 'Extension injection_points not installed';
+}
+
+# tab_keep stays in the subscription; tab_drop is the one dropped mid-scan.
+for my $node ($node_publisher, $node_subscriber)
+{
+	$node->safe_psql(
+		'postgres', qq{
+		CREATE TABLE tab_keep (a int PRIMARY KEY);
+		CREATE TABLE tab_drop (a int PRIMARY KEY);
+	});
+}
+
+$node_publisher->safe_psql(
+	'postgres', qq{
+	CREATE PUBLICATION pub FOR TABLE tab_keep, tab_drop;
+	INSERT INTO tab_keep VALUES (1);
+});
+
+my $connstr = $node_publisher->connstr . ' dbname=postgres';
+
+$node_subscriber->safe_psql(
+	'postgres', "
+	CREATE SUBSCRIPTION sub
+	CONNECTION '$connstr application_name=sub'
+	PUBLICATION pub");
+
+$node_subscriber->wait_for_subscription_sync($node_publisher, 'sub');
+
+is($node_subscriber->safe_psql('postgres', "SELECT count(*) FROM tab_keep"),
+	'1', 'initial data replicated');
+
+$node_subscriber->safe_psql('postgres', 'CREATE EXTENSION injection_points');
+
+###############################################################################
+# Park the scan of pg_subscription_rel.
+###############################################################################
+
+my $log_offset = -s $node_subscriber->logfile;
+
+my $refresh = $node_subscriber->background_psql('postgres');
+$refresh->set_query_timer_restart();
+
+# Attach the injection point from the session that will run the REFRESH, and
+# restrict it to that session, so that the apply worker cannot park at it too.
+$refresh->query_safe("SELECT injection_points_set_local()");
+$refresh->query_safe(
+	"SELECT injection_points_attach('subscription-relations-fetch', 'wait')");
+
+# Issue the REFRESH without waiting for it: the \echo marker comes back as soon
+# as psql has read the input, while the command itself blocks in the scan.
+$refresh->query_until(
+	qr/^issued$/m, qq[
+	\\echo issued
+	ALTER SUBSCRIPTION sub REFRESH PUBLICATION;
+]);
+
+$node_subscriber->poll_query_until('postgres',
+	"SELECT count(*) > 0 FROM pg_stat_activity WHERE wait_event = 'subscription-relations-fetch'"
+) or die "timed out waiting for the REFRESH to reach the injection point";
+
+pass('REFRESH is inside the pg_subscription_rel scan');
+
+###############################################################################
+# Drop the table while the scan is parked.  This commits, removing both the
+# pg_class row and the pg_subscription_rel row that the parked scan can still
+# see.
+###############################################################################
+
+$node_subscriber->safe_psql('postgres', "DROP TABLE tab_drop");
+
+is( $node_subscriber->safe_psql(
+		'postgres', qq{
+		SELECT count(*) FROM pg_subscription_rel r
+		  JOIN pg_class c ON c.oid = r.srrelid
+		 WHERE c.relname = 'tab_drop'}),
+	'0',
+	'dropped table is gone from pg_subscription_rel');
+
+# Detach before waking up.  The injection point sits inside the scan loop, so
+# leaving it attached would park the next iteration again, and the wakeup below
+# would have nothing left to release.
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_detach('subscription-relations-fetch')");
+
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_wakeup('subscription-relations-fetch')");
+
+###############################################################################
+# The relkind lookup for the dropped relation now returns '\0'.  The scan must
+# skip that row and let the REFRESH complete.
+#
+# Where the row is not skipped, the assertion in GetSubscriptionRelations()
+# fails, which takes the backend down and makes the postmaster restart the
+# cluster.
+###############################################################################
+
+my $refresh_result;
+
+eval {
+	# Sending another query on the same session waits for the REFRESH to
+	# finish.
+	$refresh_result = $refresh->query_safe("SELECT 'refresh completed'");
+};
+my $refresh_error = $@;
+
+eval { $refresh->quit; };
+
+# The headline check: the scan must not have failed an assertion.
+my $trapped = $node_subscriber->log_contains(qr/TRAP: failed Assert/, $log_offset);
+
+ok(!$trapped, 'no assertion failure during the scan')
+  or diag(
+	"the scan reported a relation that is neither a table nor a sequence; "
+	  . "GetSubscriptionRelations() needs to skip concurrently dropped relations");
+
+done_testing();
-- 
2.55.0

