From d52a61bc97fae44a7239db5331d133f73e06cb56 Mon Sep 17 00:00:00 2001
From: Nikolay Samokhvalov <nik@postgres.ai>
Date: Sat, 5 Sep 2026 09:51:36 +0500
Subject: [PATCH v2 2/2] Test recovery pause on logical slot conflict

Exercise the feature on an archive-only standby.  Verify that logical
decoding advances catalog_xmin and allows recovery to resume, that a
concurrent manual pause is preserved, and that promotion releases a
pause caused by a shared-catalog conflict.

Discussion: https://www.postgresql.org/message-id/flat/967DBEA9-E8B7-4705-AD36-447D839AACA9%40yandex-team.ru
---
 src/test/recovery/meson.build                 |   1 +
 .../t/057_recovery_pause_on_slot_conflict.pl  | 250 ++++++++++++++++++
 2 files changed, 251 insertions(+)
 create mode 100644 src/test/recovery/t/057_recovery_pause_on_slot_conflict.pl

diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build
index 72113c5ac6e..286c7267b56 100644
--- a/src/test/recovery/meson.build
+++ b/src/test/recovery/meson.build
@@ -65,6 +65,7 @@ tests += {
       't/054_unlogged_sequence_promotion.pl',
       't/055_cascade_reconnect.pl',
       't/056_standby_snapshot_export.pl',
+      't/057_recovery_pause_on_slot_conflict.pl',
     ],
   },
 }
diff --git a/src/test/recovery/t/057_recovery_pause_on_slot_conflict.pl b/src/test/recovery/t/057_recovery_pause_on_slot_conflict.pl
new file mode 100644
index 00000000000..594e795853c
--- /dev/null
+++ b/src/test/recovery/t/057_recovery_pause_on_slot_conflict.pl
@@ -0,0 +1,250 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Test recovery_pause_on_logical_slot_conflict with an archive-fed standby.
+
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+use Time::HiRes qw(time usleep);
+
+sub drain_slot
+{
+	my ($node) = @_;
+	my $decoded = 0;
+	my $last_catalog_xmin;
+
+	# A decoding pass can confirm one xmin candidate and expose the next one.
+	# Continue until a complete pass produces neither output nor a new horizon.
+	for (1 .. 100)
+	{
+		my $count = $node->safe_psql('postgres', qq[
+SELECT count(*)
+FROM pg_logical_slot_get_changes('archive_slot', NULL, NULL)
+]);
+		my $catalog_xmin = $node->safe_psql('postgres', qq[
+SELECT catalog_xmin::text
+FROM pg_replication_slots
+WHERE slot_name = 'archive_slot'
+]);
+
+		$decoded += $count;
+		return $decoded
+		  if $count == 0
+		  && defined($last_catalog_xmin)
+		  && $catalog_xmin eq $last_catalog_xmin;
+
+		$last_catalog_xmin = $catalog_xmin;
+	}
+
+	die "logical slot did not finish advancing its catalog_xmin";
+}
+
+my $primary = PostgreSQL::Test::Cluster->new('primary');
+$primary->init(allows_streaming => 'logical', has_archiving => 1);
+$primary->append_conf('postgresql.conf', qq[
+wal_level = logical
+autovacuum = off
+]);
+$primary->start;
+
+$primary->safe_psql('postgres', qq[
+CREATE TABLE events (id integer PRIMARY KEY, payload text);
+ALTER TABLE events REPLICA IDENTITY FULL;
+INSERT INTO events VALUES (0, 'seed');
+]);
+
+my $backup_name = 'backup';
+$primary->backup($backup_name);
+
+# Supply post-backup WAL before starting the archive-fed standby.
+$primary->safe_psql('postgres', 'SELECT pg_log_standby_snapshot()');
+my $initial_lsn = $primary->safe_psql('postgres',
+	'SELECT pg_current_wal_insert_lsn()');
+my $initial_seg = $primary->safe_psql('postgres',
+	"SELECT pg_walfile_name('$initial_lsn')");
+$primary->safe_psql('postgres', 'SELECT pg_switch_wal()');
+$primary->poll_query_until('postgres', qq[
+SELECT last_archived_wal >= '$initial_seg' FROM pg_stat_archiver
+]) or die "timed out waiting for initial WAL segment to be archived";
+
+my $standby = PostgreSQL::Test::Cluster->new('standby');
+$standby->init_from_backup($primary, $backup_name,
+	has_streaming => 0, has_restoring => 1);
+$standby->append_conf('postgresql.conf', qq[
+hot_standby = on
+recovery_pause_on_logical_slot_conflict = on
+max_standby_archive_delay = -1
+]);
+$standby->start;
+$standby->poll_query_until('postgres',
+	"SELECT pg_last_wal_replay_lsn() >= '$initial_lsn'")
+  or die "standby did not replay the initial WAL";
+
+# Slot creation needs a future running-xacts record to reach consistency.
+# Start it first, then archive such a record, rather than depending on timing
+# between startup replay and the slot-creation query.
+my $slot_session = $standby->background_psql('postgres');
+$slot_session->query_until(qr/slot creation started/, qq[
+\\echo slot creation started
+SELECT pg_create_logical_replication_slot('archive_slot', 'test_decoding');
+]);
+
+$primary->safe_psql('postgres', 'SELECT pg_log_standby_snapshot()');
+my $slot_lsn = $primary->safe_psql('postgres',
+	'SELECT pg_current_wal_insert_lsn()');
+my $slot_seg = $primary->safe_psql('postgres',
+	"SELECT pg_walfile_name('$slot_lsn')");
+$primary->safe_psql('postgres', 'SELECT pg_switch_wal()');
+$primary->poll_query_until('postgres', qq[
+SELECT last_archived_wal >= '$slot_seg' FROM pg_stat_archiver
+]) or die "timed out waiting for slot-creation WAL to be archived";
+$slot_session->quit;
+
+my $initial_catalog_xmin = $standby->safe_psql('postgres', qq[
+SELECT catalog_xmin::text
+FROM pg_replication_slots
+WHERE slot_name = 'archive_slot'
+]);
+ok($initial_catalog_xmin ne '', 'logical slot reached consistency');
+
+# Put a newer running-xacts record before the catalog cleanup.  Consuming up
+# to the pause can therefore give logical decoding a safe, newer catalog_xmin.
+$primary->safe_psql('postgres', qq[
+INSERT INTO events
+SELECT g, 'payload-' || g FROM generate_series(1, 3000) AS g;
+]);
+for my $i (1 .. 20)
+{
+	$primary->safe_psql('postgres',
+		"CREATE TABLE churn_$i (a int); DROP TABLE churn_$i");
+	$primary->safe_psql('postgres', 'SELECT pg_log_standby_snapshot()');
+	$primary->safe_psql('postgres',
+		"INSERT INTO events VALUES (10000 + $i, 'advance xid')");
+}
+$primary->safe_psql('postgres', 'ANALYZE events');
+$primary->safe_psql('postgres', 'SELECT pg_log_standby_snapshot()');
+$primary->safe_psql('postgres',
+	"INSERT INTO events VALUES (20001, 'advance xid')");
+$primary->safe_psql('postgres', 'ANALYZE events');
+$primary->safe_psql('postgres', 'SELECT pg_log_standby_snapshot()');
+$primary->safe_psql('postgres',
+	"INSERT INTO events VALUES (20002, 'advance xid')");
+$primary->safe_psql('postgres', qq[
+VACUUM pg_class;
+VACUUM pg_attribute;
+VACUUM pg_type;
+VACUUM pg_depend;
+VACUUM pg_statistic;
+]);
+
+my $target_lsn = $primary->safe_psql('postgres',
+	'SELECT pg_current_wal_insert_lsn()');
+my $target_seg = $primary->safe_psql('postgres',
+	"SELECT pg_walfile_name('$target_lsn')");
+$primary->safe_psql('postgres', 'SELECT pg_switch_wal()');
+$primary->poll_query_until('postgres', qq[
+SELECT last_archived_wal >= '$target_seg' FROM pg_stat_archiver
+]) or die "timed out waiting for conflict WAL to be archived";
+
+my $decoded = 0;
+my $pauses = 0;
+my $reached_target = 0;
+my $deadline = time() + 60;
+my %handled_pause;
+my $manual_pause_tested = 0;
+
+while (time() < $deadline)
+{
+	my $state = $standby->safe_psql('postgres',
+		'SELECT pg_get_wal_replay_pause_state()');
+
+	if ($state eq 'paused' || $state eq 'pause requested')
+	{
+		my $pause_lsn = $standby->safe_psql('postgres',
+			'SELECT pg_last_wal_replay_lsn()');
+
+		if (!$handled_pause{$pause_lsn}++)
+		{
+			my $preserve_manual_pause = !$manual_pause_tested;
+			my $log_offset;
+
+			if ($preserve_manual_pause)
+			{
+				$standby->safe_psql('postgres',
+					'SELECT pg_wal_replay_pause()');
+				$log_offset = -s $standby->logfile;
+			}
+
+			$decoded += drain_slot($standby);
+			$pauses++;
+
+			if ($preserve_manual_pause)
+			{
+				$standby->wait_for_log(
+					qr/recovery remains paused due to a separate pause request/,
+					$log_offset);
+				is($standby->safe_psql('postgres',
+						'SELECT pg_get_wal_replay_pause_state()'),
+					'paused', 'auto-resume preserved a concurrent manual pause');
+				$standby->safe_psql('postgres',
+					'SELECT pg_wal_replay_resume()');
+				$manual_pause_tested = 1;
+			}
+		}
+	}
+
+	$reached_target = $standby->safe_psql('postgres',
+		"SELECT pg_last_wal_replay_lsn() >= '$target_lsn'") eq 't';
+	last if $reached_target;
+	usleep(100_000);
+}
+
+ok($reached_target, 'standby replayed past the catalog cleanup');
+cmp_ok($pauses, '>=', 1, 'recovery paused for a logical slot conflict');
+cmp_ok($decoded, '>=', 3000, 'decoded changes while recovery was paused');
+
+my ($invalidation_reason, $final_catalog_xmin) = split /\|/,
+  $standby->safe_psql('postgres', qq[
+SELECT coalesce(invalidation_reason, 'valid') || '|' || catalog_xmin::text
+FROM pg_replication_slots
+WHERE slot_name = 'archive_slot'
+]);
+is($invalidation_reason, 'valid', 'logical slot was not invalidated');
+cmp_ok($final_catalog_xmin, '>', $initial_catalog_xmin,
+	'logical decoding advanced catalog_xmin');
+
+# A shared-catalog conflict applies to logical slots in every database.  A
+# promotion request must release that pause without requiring a separate
+# pg_wal_replay_resume() call.
+$primary->safe_psql('postgres', qq[
+INSERT INTO events VALUES (3001, 'more WAL');
+CREATE ROLE archive_pause_test;
+DROP ROLE archive_pause_test;
+SELECT pg_log_standby_snapshot();
+INSERT INTO events VALUES (3002, 'advance xid');
+VACUUM pg_authid;
+]);
+my $promote_seg = $primary->safe_psql('postgres',
+	'SELECT pg_walfile_name(pg_current_wal_insert_lsn())');
+$primary->safe_psql('postgres', 'SELECT pg_switch_wal()');
+$primary->poll_query_until('postgres', qq[
+SELECT last_archived_wal >= '$promote_seg' FROM pg_stat_archiver
+]) or die "timed out waiting for promotion-test WAL to be archived";
+
+$standby->poll_query_until('postgres',
+	"SELECT pg_get_wal_replay_pause_state() = 'paused'")
+  or die "recovery did not pause before the promotion test";
+
+my $started = time();
+$standby->promote;
+is($standby->safe_psql('postgres', 'SELECT pg_is_in_recovery()'), 'f',
+	'standby promoted while conflict pause was active');
+cmp_ok(time() - $started, '<', 10, 'promotion released the conflict pause');
+
+$standby->stop;
+$primary->stop;
+
+done_testing();
-- 
That's all, folks. May the source be with you.

