From b36746ffb63f20af2bbd636d9ab6beadbee8b9ec Mon Sep 17 00:00:00 2001
From: Hayato Kuroda <kuroda.hayato@fujitsu.com>
Date: Mon, 7 Sep 2026 17:52:21 +0900
Subject: [PATCH] Reproducer: race condition with CREATE SUBSCRIPTION

---
 src/backend/commands/subscriptioncmds.c       |   2 +
 src/test/subscription/meson.build             |   1 +
 .../subscription/t/040_refresh_table_race.pl  | 123 ++++++++++++++++++
 3 files changed, 126 insertions(+)
 create mode 100644 src/test/subscription/t/040_refresh_table_race.pl

diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index abec24c7a85..708262b34ce 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -1840,6 +1840,8 @@ AlterSubscription_refresh_table(Subscription *sub, List *relations)
 	 */
 	CheckRefreshTableNotInOtherSubscriptions(rel, sub, subrelids, relids);
 
+	INJECTION_POINT("subscription-refresh-table-after-subscription-check", NULL);
+
 	/*
 	 * The set is settled, so open what is about to be truncated.  The locks
 	 * are already held.
diff --git a/src/test/subscription/meson.build b/src/test/subscription/meson.build
index 37f480bcac2..49bfbcbfd40 100644
--- a/src/test/subscription/meson.build
+++ b/src/test/subscription/meson.build
@@ -49,6 +49,7 @@ tests += {
       't/037_except.pl',
       't/038_walsnd_shutdown_timeout.pl',
       't/039_refresh_table.pl',
+      't/040_refresh_table_race.pl',
       't/100_bugs.pl',
     ],
   },
diff --git a/src/test/subscription/t/040_refresh_table_race.pl b/src/test/subscription/t/040_refresh_table_race.pl
new file mode 100644
index 00000000000..ddbaf87f84a
--- /dev/null
+++ b/src/test/subscription/t/040_refresh_table_race.pl
@@ -0,0 +1,123 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Test concurrent ALTER SUBSCRIPTION ... REFRESH TABLE operations.
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+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;
+
+# Setup publisher
+$node_publisher->safe_psql(
+	'postgres', qq(
+	CREATE TABLE tab_race (a int primary key) PARTITION BY RANGE (a);
+	CREATE TABLE tab_race_1 PARTITION OF tab_race
+		FOR VALUES FROM (1) TO (12);
+	INSERT INTO tab_race SELECT generate_series(1, 10);
+	CREATE PUBLICATION tap_pub_race_leaf FOR TABLE tab_race_1;
+	CREATE PUBLICATION tap_pub_race_root
+		FOR TABLE tab_race WHERE (a > 5)
+		WITH (publish_via_partition_root = true);
+));
+
+# Setup subscriber
+my $publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
+
+$node_subscriber->safe_psql(
+	'postgres', qq(
+	CREATE EXTENSION injection_points;
+	CREATE TABLE tab_race (a int primary key) PARTITION BY RANGE (a);
+	CREATE TABLE tab_race_1 PARTITION OF tab_race
+		FOR VALUES FROM (1) TO (12);
+	CREATE SUBSCRIPTION tap_sub_race_leaf
+		CONNECTION '$publisher_connstr'
+		PUBLICATION tap_pub_race_leaf;
+));
+$node_subscriber->wait_for_subscription_sync($node_publisher,
+	'tap_sub_race_leaf');
+
+is($node_subscriber->safe_psql('postgres', "SELECT count(*) FROM tab_race_1"),
+   '10',
+   'subscriber has the complete baseline before refresh');
+
+# Disable the leaf subscription before altering the publication
+$node_subscriber->safe_psql('postgres',
+	"ALTER SUBSCRIPTION tap_sub_race_leaf DISABLE");
+$node_subscriber->poll_query_until('postgres',
+	"SELECT count(*) = 0 FROM pg_stat_subscription WHERE subname = 'tap_sub_race_leaf' AND pid IS NOT NULL"
+) or die "Timed out waiting for subscription workers to stop";
+
+# The initial copy above supplied all rows.  Limit subsequent copies to 1..5,
+# so rows 6..10 cannot be restored if the concurrent refresh removes them.
+$node_publisher->safe_psql('postgres',
+	"ALTER PUBLICATION tap_pub_race_leaf SET TABLE tab_race_1 WHERE (a <= 5)");
+
+# Pause after REFRESH TABLE's final check for subscriptions that also feed the
+# leaf.  The refresh holds AccessExclusiveLock on the leaf at this point, but
+# not on its partitioned root.
+$node_subscriber->safe_psql(
+	'postgres',
+	"SELECT injection_points_attach('subscription-refresh-table-after-subscription-check', 'wait')"
+);
+my $refresh = $node_subscriber->background_psql('postgres');
+$refresh->query_until(
+	qr/starting_refresh_table/, q{
+	\echo starting_refresh_table
+	ALTER SUBSCRIPTION tap_sub_race_leaf REFRESH TABLE tab_race_1;
+});
+$node_subscriber->wait_for_event('client backend',
+	'subscription-refresh-table-after-subscription-check');
+
+# This subscription locks only the partitioned root, so it can be registered
+# after the refresh's final check despite feeding the locked leaf via routing.
+$node_subscriber->safe_psql(
+	'postgres', qq(
+	CREATE SUBSCRIPTION tap_sub_race_root
+		CONNECTION '$publisher_connstr'
+		PUBLICATION tap_pub_race_root
+		WITH (copy_data = false, enabled = false);
+));
+is( $node_subscriber->safe_psql(
+		'postgres',
+		"SELECT r.srsubstate FROM pg_subscription_rel r JOIN pg_subscription s ON s.oid = r.srsubid WHERE s.subname = 'tap_sub_race_root'"
+	),
+	'r',
+	'concurrent root subscription is registered as ready');
+
+# Resume the refresh. The state of tab_race_1 will be 'i'
+$node_subscriber->safe_psql(
+	'postgres',
+	"SELECT injection_points_wakeup('subscription-refresh-table-after-subscription-check')"
+);
+ok($refresh->quit, 'concurrent leaf refresh completed');
+
+# Enable both subscriptions and initial sync will be triggered again.
+$node_subscriber->safe_psql(
+	'postgres', qq(
+	ALTER SUBSCRIPTION tap_sub_race_leaf ENABLE;
+	ALTER SUBSCRIPTION tap_sub_race_root ENABLE;
+));
+$node_subscriber->wait_for_subscription_sync($node_publisher,
+	'tap_sub_race_leaf');
+$node_publisher->wait_for_catchup('tap_sub_race_root');
+
+# Verify that the root subscription applies new changes even though it cannot
+# restore rows 6..10 that were removed after it was marked READY.
+$node_publisher->safe_psql('postgres', "INSERT INTO tab_race VALUES (11)");
+$node_publisher->wait_for_catchup('tap_sub_race_root');
+is($node_subscriber->safe_psql('postgres', "SELECT count(*) FROM tab_race_1"),
+   '11',
+   'rows maintained by both subscriptions survive the concurrent refresh');
+
+$node_subscriber->stop('fast');
+$node_publisher->stop('fast');
+
+done_testing();
-- 
2.52.0

