From f29b4fa3c7f47605dc6569a2528cdc4f3ed885b4 Mon Sep 17 00:00:00 2001
From: Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>
Date: Fri, 31 Jul 2026 00:26:13 +0000
Subject: [PATCH v2 v2 2/2] Test subscription REFRESH with concurrently dropped
 relation.

Add a TAP test for the crash fixed in the previous commit. A new
injection point pauses the refresh in the narrow window between
collecting the subscription's local relation list and reading
each relation's name. The test parks a background refresh there,
drops a subscribed table, then wakes the refresh, which crashed
before the fix and now skips the dropped relation.

Author: Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>
Discussion: https://www.postgresql.org/message-id/flat/CAHg%2BQDcd_o3707Ey8c8b7HkE-t14g8c0tk8ME3ctywDsh3ut8g%40mail.gmail.com
---
 src/backend/commands/subscriptioncmds.c       |  8 ++
 src/test/subscription/meson.build             |  1 +
 .../t/039_refresh_concurrent_drop.pl          | 90 +++++++++++++++++++
 3 files changed, 99 insertions(+)
 create mode 100644 src/test/subscription/t/039_refresh_concurrent_drop.pl

diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index 05d46fe05f3..bd815a9ed5b 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -54,6 +54,7 @@
 #include "utils/acl.h"
 #include "utils/builtins.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/lsyscache.h"
 #include "utils/memutils.h"
 #include "utils/pg_lsn.h"
@@ -1139,6 +1140,13 @@ AlterSubscription_refresh(Subscription *sub, bool copy_data,
 		subrel_states = GetSubscriptionRelations(sub->oid, true, true, false);
 		subrel_count = list_length(subrel_states);
 
+		/*
+		 * The local relation OIDs are now captured but not locked, so a
+		 * concurrent drop can make them stale before the origin checks run.
+		 * This injection point lets a test reproduce that window.
+		 */
+		INJECTION_POINT("subscription-refresh-before-origin-check", NULL);
+
 		/*
 		 * Build qsorted arrays of local table oids and sequence oids for
 		 * faster lookup. This can potentially contain all tables and
diff --git a/src/test/subscription/meson.build b/src/test/subscription/meson.build
index e71e95c6297..33b16f9ac0c 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/039_refresh_concurrent_drop.pl',
       't/100_bugs.pl',
     ],
   },
diff --git a/src/test/subscription/t/039_refresh_concurrent_drop.pl b/src/test/subscription/t/039_refresh_concurrent_drop.pl
new file mode 100644
index 00000000000..a01e2f6b355
--- /dev/null
+++ b/src/test/subscription/t/039_refresh_concurrent_drop.pl
@@ -0,0 +1,90 @@
+# Copyright (c) 2025-2026, PostgreSQL Global Development Group
+
+# Test that ALTER SUBSCRIPTION ... REFRESH PUBLICATION skips, rather than
+# crashes on, a subscribed relation dropped concurrently during the refresh.
+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;
+$node_subscriber->start;
+
+my $publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
+
+# The refresh runs on the subscriber, so arm the injection point there.
+$node_subscriber->safe_psql('postgres', 'CREATE EXTENSION injection_points;');
+
+# tab_keep stays, tab_drop is dropped during the refresh.
+$node_publisher->safe_psql(
+	'postgres', q{
+	CREATE TABLE tab_keep (a int);
+	CREATE TABLE tab_drop (a int);
+	CREATE PUBLICATION mypub FOR ALL TABLES;
+});
+
+$node_subscriber->safe_psql(
+	'postgres', q{
+	CREATE TABLE tab_keep (a int);
+	CREATE TABLE tab_drop (a int);
+});
+
+# origin = none is the path that reads each local relation's name.
+$node_subscriber->safe_psql(
+	'postgres', qq{
+	CREATE SUBSCRIPTION mysub CONNECTION '$publisher_connstr'
+		PUBLICATION mypub WITH (copy_data = false, origin = none);
+});
+
+$node_subscriber->wait_for_subscription_sync($node_publisher, 'mysub');
+
+# Pause the next refresh after it collects the local relation list.
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('subscription-refresh-before-origin-check', 'wait');"
+);
+
+# Run the refresh in the background. It blocks at the injection point.
+my $bg = $node_subscriber->background_psql('postgres');
+$bg->query_until(
+	qr/starting_refresh/, q{
+	\echo starting_refresh
+	ALTER SUBSCRIPTION mysub REFRESH PUBLICATION;
+});
+
+$node_subscriber->wait_for_event('client backend',
+	'subscription-refresh-before-origin-check');
+
+# Drop the table while the refresh holds its now-stale OID.
+$node_subscriber->safe_psql('postgres', 'DROP TABLE tab_drop;');
+
+# Wake the refresh. It crashed here before the fix.
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_wakeup('subscription-refresh-before-origin-check');"
+);
+
+$bg->quit;
+
+# A successful query proves the backend survived.
+is($node_subscriber->safe_psql('postgres', 'SELECT 1;'),
+	'1', 'subscriber survived refresh with concurrently dropped table');
+
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_detach('subscription-refresh-before-origin-check');"
+);
+
+$node_subscriber->stop;
+$node_publisher->stop;
+
+done_testing();
-- 
2.47.3

