From efc021670d34d2b8bd87944202b9fb41f678d0ca Mon Sep 17 00:00:00 2001
From: Vignesh C <vignesh21@gmail.com>
Date: Tue, 28 Jul 2026 11:22:40 +0530
Subject: [PATCH v1 2/2] Add a TAP test to reproduce sequence sync lock table
 exhaustion

Add a regression test that reproduces the failure where the sequence
synchronization worker exhausts the subscriber's shared lock table while
gathering sequences to synchronize.

The test configures a small shared lock table and creates enough sequences
in the INIT state so that the worker opens all of them with
RowExclusiveLock in a single transaction, exhausting the available lock
entries. It then verifies that the worker fails with the expected "out of
shared memory" error and the corresponding hint to increase
max_locks_per_transaction.
---
 src/test/subscription/meson.build             |   1 +
 .../t/039_sequencesync_lock_exhaustion.pl     | 120 ++++++++++++++++++
 2 files changed, 121 insertions(+)
 create mode 100644 src/test/subscription/t/039_sequencesync_lock_exhaustion.pl

diff --git a/src/test/subscription/meson.build b/src/test/subscription/meson.build
index e71e95c6297..94cc9d0a112 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_sequencesync_lock_exhaustion.pl',
       't/100_bugs.pl',
     ],
   },
diff --git a/src/test/subscription/t/039_sequencesync_lock_exhaustion.pl b/src/test/subscription/t/039_sequencesync_lock_exhaustion.pl
new file mode 100644
index 00000000000..e00efaf493a
--- /dev/null
+++ b/src/test/subscription/t/039_sequencesync_lock_exhaustion.pl
@@ -0,0 +1,120 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Test that the sequencesync worker's initial "gathering" scan can exhaust the
+# subscriber's shared lock table.
+#
+# LogicalRepSyncSequences() (sequencesync.c) walks every srsubstate='i' relation
+# of the subscription and opens each one with try_table_open(..., RowExclusiveLock)
+# in a *single* transaction, retaining all of those locks until that transaction
+# commits.  With enough sequences in INIT state and a small shared lock table,
+# that one transaction runs out of lock-table space and fails with:
+#
+#     out of shared memory
+#     HINT:  You might need to increase "max_locks_per_transaction".
+
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# Enough sequences that the gathering scan's per-relation RowExclusiveLocks
+# overflow the small shared lock table configured further below.  With that
+# configuration the table holds 210 entries and each backend has only 16
+# fast-path slots, so 300 sequences overflow it by a wide margin.
+my $nseqs = 300;
+
+# Initialize publisher node
+my $node_publisher = PostgreSQL::Test::Cluster->new('publisher');
+$node_publisher->init(allows_streaming => 'logical');
+$node_publisher->start;
+
+# Initialize subscriber node.  It starts with the default (large) lock table so
+# the initial synchronization of all sequences succeeds; the lock table is
+# shrunk afterwards, before the REFRESH that is expected to fail.
+my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
+$node_subscriber->init;
+$node_subscriber->start;
+
+# Create the sequences on both sides.
+my $create_seqs =
+  join('', map { "CREATE SEQUENCE regress_s$_;\n" } (1 .. $nseqs));
+$node_publisher->safe_psql('postgres', $create_seqs);
+$node_subscriber->safe_psql('postgres', $create_seqs);
+
+# Advance each sequence on the publisher so there is real state to copy.
+$node_publisher->safe_psql('postgres',
+	join('', map { "SELECT nextval('regress_s$_');\n" } (1 .. $nseqs)));
+
+# Setup logical replication.
+my $publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
+$node_publisher->safe_psql('postgres',
+	"CREATE PUBLICATION regress_seq_pub FOR ALL SEQUENCES");
+$node_subscriber->safe_psql('postgres',
+	"CREATE SUBSCRIPTION regress_seq_sub CONNECTION '$publisher_connstr' PUBLICATION regress_seq_pub"
+);
+
+# The initial sync of all sequences must succeed with the default lock table.
+my $synced_query =
+  "SELECT count(1) = 0 FROM pg_subscription_rel WHERE srsubstate NOT IN ('r');";
+$node_subscriber->poll_query_until('postgres', $synced_query)
+  or die "Timed out while waiting for subscriber to synchronize sequences";
+
+is( $node_subscriber->safe_psql(
+		'postgres',
+		"SELECT count(*) FROM pg_subscription_rel WHERE srsubstate = 'r'"),
+	$nseqs,
+	"all $nseqs sequences initially synchronized");
+
+##########
+# Shrink the subscriber's shared lock table below the number of sequences, then
+# REFRESH SEQUENCES so the gathering scan tries to lock all of them in one
+# transaction and runs out of shared memory.
+##########
+
+# NLOCKENTS() = max_locks_per_transaction * (MaxBackends + max_prepared_xacts).
+# With the settings below:
+#   MaxBackends = max_connections (10) + autovacuum_worker_slots (1)
+#               + max_worker_processes (8) + max_wal_senders (0)
+#               + NUM_SPECIAL_WORKER_PROCS (2)
+#             = 21
+#   max_prepared_xacts = 0
+#   => NLOCKENTS() = 10 * 21 = 210, well below $nseqs (300).
+$node_subscriber->append_conf(
+	'postgresql.conf', qq(
+max_locks_per_transaction = 10
+max_connections = 10
+autovacuum_worker_slots = 1
+max_worker_processes = 8
+max_wal_senders = 0
+));
+$node_subscriber->restart;
+
+my $log_offset = -s $node_subscriber->logfile;
+
+# REFRESH SEQUENCES resets every sequence to INIT.  Note that this foreground
+# command only updates catalog rows and does not lock the sequence relations,
+# so it succeeds; it is the background sequencesync worker's gathering scan that
+# holds a RowExclusiveLock on every INIT sequence at once and overflows the
+# shared lock table.
+$node_subscriber->safe_psql('postgres',
+	"ALTER SUBSCRIPTION regress_seq_sub REFRESH SEQUENCES");
+
+$node_subscriber->wait_for_log(qr/out of shared memory/, $log_offset);
+$node_subscriber->wait_for_log(
+	qr/You might need to increase "max_locks_per_transaction"/, $log_offset);
+
+pass('sequencesync gathering scan exhausts the shared lock table');
+
+# The scan fails before any sequence is marked READY, so synchronization makes
+# no progress: every sequence is still in a non-ready state.
+is( $node_subscriber->safe_psql(
+		'postgres',
+		"SELECT count(*) FROM pg_subscription_rel WHERE srsubstate = 'r'"),
+	'0',
+	'no sequence reaches READY while the lock table is exhausted');
+
+$node_subscriber->stop('fast');
+$node_publisher->stop('fast');
+
+done_testing();
-- 
2.50.1 (Apple Git-155)

