From 934cefe8e61d5d427105ab2f0f65579ada219eaa Mon Sep 17 00:00:00 2001
From: Alexander Korotkov <akorotkov@postgresql.org>
Date: Mon, 20 Apr 2026 21:27:29 +0300
Subject: [PATCH v5 6/7] Improve WAIT FOR LSN test coverage

1. Check the values of writtenUpto/flushedUpto initialized by
   RequestXLogStreaming().  Check WAIT FOR LSN succeeds in both
   standby_write / standby_flush modes thanks to replay-position floor in
   GetCurrentLSNForWaitType()

2. Off-by-one boundary checks for the wait predicate target <= currentLSN.
   With replay paused and the walreceiver stopped, verify that targets at
   current and current - 1 succeed immediately, that current + 1 times out,
   and that a waiter at current + 1 wakes once replay actually advances past
   it.

3. Timeline switch on a cascade standby.  A waiter on a cascade standby must
   survive its upstream's promotion: the cascade walreceiver reconnects on
   the new timeline and the wait completes when WAL on the new timeline
   reaches the target.

Reported-by: Andres Freund <andres@anarazel.de>
Discussion: https://postgr.es/m/1957514.1775526774%40sss.pgh.pa.us
Author: Alexander Korotkov <aekorotkov@gmail.com>
---
 src/test/recovery/t/049_wait_for_lsn.pl | 226 ++++++++++++++++++++++++
 1 file changed, 226 insertions(+)

diff --git a/src/test/recovery/t/049_wait_for_lsn.pl b/src/test/recovery/t/049_wait_for_lsn.pl
index d2610cf0856..c386425a7fd 100644
--- a/src/test/recovery/t/049_wait_for_lsn.pl
+++ b/src/test/recovery/t/049_wait_for_lsn.pl
@@ -824,4 +824,230 @@ ok(1,
 $arc_standby->stop;
 $arc_primary->stop;
 
+# 10. Fresh-shmem walreceiver startup (29e7dbf5e4d).
+# RequestXLogStreaming() initializes writtenUpto/flushedUpto to the
+# segment-aligned receiveStart only when receiveStart was invalid.
+# Restart the standby with the primary stopped, so the walreceiver cannot
+# connect and advance these values past the initial one before we observe it.
+
+my $rcv_primary = PostgreSQL::Test::Cluster->new('rcv_primary');
+$rcv_primary->init(allows_streaming => 1);
+# No background WAL during our probes.
+$rcv_primary->append_conf('postgresql.conf', 'autovacuum = off');
+$rcv_primary->start;
+$rcv_primary->safe_psql('postgres',
+	"CREATE TABLE rcv_test AS SELECT generate_series(1,10) AS a");
+
+my $rcv_backup = 'rcv_backup';
+$rcv_primary->backup($rcv_backup);
+
+my $rcv_standby = PostgreSQL::Test::Cluster->new('rcv_standby');
+$rcv_standby->init_from_backup($rcv_primary, $rcv_backup, has_streaming => 1);
+$rcv_standby->start;
+
+# Switch WAL segments mid-stream so the replay ends mid-segment after the
+# upcoming standby restart.  That guarantees the initial value <
+# final replay LSN.
+$rcv_primary->safe_psql('postgres',
+	"INSERT INTO rcv_test VALUES (generate_series(11, 100))");
+$rcv_primary->safe_psql('postgres', "SELECT pg_switch_wal()");
+$rcv_primary->safe_psql('postgres',
+	"INSERT INTO rcv_test VALUES (generate_series(101, 110))");
+$rcv_primary->wait_for_catchup($rcv_standby);
+
+# Restart the standby with the primary down: WalRcvData is initialized, but
+# the walreceiver cannot connect and update writtenUpto/flushedUpto.  So,
+# the initial flushedUpto stays observable via pg_last_wal_receive_lsn()).
+$rcv_standby->stop;
+$rcv_primary->stop;
+$rcv_standby->start;
+
+$rcv_standby->poll_query_until('postgres',
+	"SELECT pg_last_wal_receive_lsn() <> '0/0'::pg_lsn;")
+  or die "walreceiver initial value did not become visible";
+
+# Freeze the replay so the (received, replay] window stays observable.
+$rcv_standby->safe_psql('postgres', "SELECT pg_wal_replay_pause()");
+
+my $rcv_written =
+  $rcv_standby->safe_psql('postgres', "SELECT pg_last_wal_receive_lsn()");
+my $rcv_replay =
+  $rcv_standby->safe_psql('postgres', "SELECT pg_last_wal_replay_lsn()");
+my $rcv_gap = $rcv_standby->safe_psql('postgres',
+	"SELECT pg_wal_lsn_diff('$rcv_replay'::pg_lsn, '$rcv_written'::pg_lsn) > 0"
+);
+ok($rcv_gap eq 't', "replay sits ahead of initial writtenUpto");
+
+# WAIT FOR an $rcv_replay LSN succeeds in standby_write / standby_flush
+# modes thanks to GetCurrentLSNForWaitType() taking replay LSN as the floor.
+foreach my $rcv_mode ('standby_write', 'standby_flush')
+{
+	$output = $rcv_standby->safe_psql(
+		'postgres', qq[
+		WAIT FOR LSN '${rcv_replay}'
+			WITH (MODE '$rcv_mode', timeout '5s', no_throw);]);
+	ok($output eq "success",
+		"$rcv_mode succeeds for already-replayed LSN after standby restart");
+}
+
+# Restore primary and resume replay so section 11 can reuse the
+# clusters.
+$rcv_standby->safe_psql('postgres', "SELECT pg_wal_replay_resume()");
+$rcv_primary->start;
+$rcv_primary->wait_for_catchup($rcv_standby);
+
+# 11. Off-by-one boundary checks for the wait predicate target <=
+# currentLSN.  Stop the walreceiver before pausing replay (stopping
+# after pause can hang -- see section 7d) so both replay and
+# walreceiver positions are frozen.
+stop_walreceiver($rcv_standby);
+$rcv_standby->safe_psql('postgres', "SELECT pg_wal_replay_pause()");
+
+my $boundary_lsn =
+  $rcv_standby->safe_psql('postgres', "SELECT pg_last_wal_replay_lsn()");
+my $boundary_minus = $rcv_standby->safe_psql('postgres',
+	"SELECT ('$boundary_lsn'::pg_lsn - 1)::text");
+my $boundary_plus = $rcv_standby->safe_psql('postgres',
+	"SELECT ('$boundary_lsn'::pg_lsn + 1)::text");
+
+# 11a. target == current LSN succeeds immediately (predicate is <=).
+foreach my $b_mode ('standby_replay', 'standby_write', 'standby_flush')
+{
+	$output = $rcv_standby->safe_psql(
+		'postgres', qq[
+		WAIT FOR LSN '${boundary_lsn}'
+			WITH (MODE '$b_mode', timeout '5s', no_throw);]);
+	ok($output eq "success", "$b_mode: target == current succeeds");
+}
+
+# 11b. target == current - 1 succeeds immediately.
+foreach my $b_mode ('standby_replay', 'standby_write', 'standby_flush')
+{
+	$output = $rcv_standby->safe_psql(
+		'postgres', qq[
+		WAIT FOR LSN '${boundary_minus}'
+			WITH (MODE '$b_mode', timeout '5s', no_throw);]);
+	ok($output eq "success", "$b_mode: target == current - 1 succeeds");
+}
+
+# 11c. target == current + 1 must time out (no early success).
+foreach my $b_mode ('standby_replay', 'standby_write', 'standby_flush')
+{
+	$output = $rcv_standby->safe_psql(
+		'postgres', qq[
+		WAIT FOR LSN '${boundary_plus}'
+			WITH (MODE '$b_mode', timeout '500ms', no_throw);]);
+	ok($output eq "timeout", "$b_mode: target == current + 1 times out");
+}
+
+# 11d. A sleeping waiter at current + 1 wakes once replay advances
+# past it.  Resume replay first (safe: walreceiver is stopped so no
+# new WAL arrives yet), start the waiter, then restart the
+# walreceiver to deliver the new WAL.
+$rcv_standby->safe_psql('postgres', "SELECT pg_wal_replay_resume()");
+
+$rcv_primary->safe_psql('postgres',
+	"INSERT INTO rcv_test VALUES (generate_series(200, 210))");
+
+my $boundary_log_offset = -s $rcv_standby->logfile;
+my $boundary_session = $rcv_standby->background_psql('postgres');
+$boundary_session->query_until(
+	qr/start/, qq[
+	\\echo start
+	WAIT FOR LSN '${boundary_plus}'
+		WITH (MODE 'standby_replay', timeout '30s', no_throw);
+	DO \$\$ BEGIN RAISE LOG 'rcv_boundary_done'; END \$\$;
+]);
+
+$rcv_standby->poll_query_until('postgres',
+	"SELECT count(*) > 0 FROM pg_stat_activity WHERE wait_event = 'WaitForWalReplay'"
+) or die "Boundary waiter did not sleep";
+
+resume_walreceiver($rcv_standby);
+$rcv_standby->wait_for_log(qr/rcv_boundary_done/, $boundary_log_offset);
+$boundary_session->quit;
+
+ok(1, "standby_replay: waiter at current + 1 wakes when replay advances");
+
+$rcv_standby->stop;
+$rcv_primary->stop;
+
+# 12. Timeline switch on a cascade standby.  A WAIT FOR LSN waiter on
+# a cascade standby must survive its upstream's promotion: the
+# cascade walreceiver reconnects on the new timeline and replay
+# continues across the boundary.
+
+my $tl_primary = PostgreSQL::Test::Cluster->new('tl_primary');
+$tl_primary->init(allows_streaming => 1);
+$tl_primary->append_conf('postgresql.conf', 'autovacuum = off');
+$tl_primary->start;
+$tl_primary->safe_psql('postgres',
+	"CREATE TABLE tl_test AS SELECT generate_series(1, 10) AS a");
+
+my $tl_backup = 'tl_backup';
+$tl_primary->backup($tl_backup);
+
+my $tl_standby1 = PostgreSQL::Test::Cluster->new('tl_standby1');
+$tl_standby1->init_from_backup($tl_primary, $tl_backup, has_streaming => 1);
+$tl_standby1->start;
+
+# standby2 cascades from standby1.
+my $tl_backup2 = 'tl_backup2';
+$tl_standby1->backup($tl_backup2);
+
+my $tl_standby2 = PostgreSQL::Test::Cluster->new('tl_standby2');
+$tl_standby2->init_from_backup($tl_standby1, $tl_backup2, has_streaming => 1);
+$tl_standby2->start;
+
+$tl_primary->safe_psql('postgres',
+	"INSERT INTO tl_test VALUES (generate_series(11, 20))");
+$tl_primary->wait_for_catchup($tl_standby1);
+$tl_standby1->wait_for_catchup($tl_standby2);
+
+# Target LSN well past current insert LSN, so reaching it requires
+# WAL produced on the new timeline.  Pause replay on standby2 to
+# guarantee the waiter is asleep when the switch happens.
+my $tl_target = $tl_primary->safe_psql('postgres',
+	"SELECT (pg_current_wal_insert_lsn() + 65536)::text");
+
+$tl_standby2->safe_psql('postgres', "SELECT pg_wal_replay_pause()");
+
+my $tl_log_offset = -s $tl_standby2->logfile;
+my $tl_session = $tl_standby2->background_psql('postgres');
+$tl_session->query_until(
+	qr/start/, qq[
+	\\echo start
+	WAIT FOR LSN '${tl_target}'
+		WITH (MODE 'standby_replay', timeout '60s', no_throw);
+	DO \$\$ BEGIN RAISE LOG 'tl_wait_done'; END \$\$;
+]);
+
+$tl_standby2->poll_query_until('postgres',
+	"SELECT count(*) > 0 FROM pg_stat_activity WHERE wait_event = 'WaitForWalReplay'"
+) or die "Cascade waiter did not sleep before promotion";
+
+# Promote standby1 to TLI 2; produce enough WAL on the new timeline
+# to push past tl_target and force a segment switch.
+$tl_standby1->promote;
+$tl_standby1->safe_psql('postgres',
+	"INSERT INTO tl_test VALUES (generate_series(21, 1020))");
+$tl_standby1->safe_psql('postgres', "SELECT pg_switch_wal()");
+
+$tl_standby2->safe_psql('postgres', "SELECT pg_wal_replay_resume()");
+
+$tl_standby2->poll_query_until('postgres',
+	"SELECT received_tli > 1 FROM pg_stat_wal_receiver")
+  or die "tl_standby2 did not follow upstream timeline switch";
+
+$tl_standby2->wait_for_log(qr/tl_wait_done/, $tl_log_offset);
+$tl_session->quit;
+
+ok(1,
+	"WAIT FOR LSN survives upstream promotion and timeline switch on cascade standby"
+);
+
+$tl_standby2->stop;
+$tl_standby1->stop;
+$tl_primary->stop;
+
 done_testing();
-- 
2.39.5 (Apple Git-154)

