From 80a1a72728589ab40df51acb7e7073d5b03588a5 Mon Sep 17 00:00:00 2001
From: Manu <manuelreyesbravo@gmail.com>
Date: Fri, 23 Oct 2026 20:45:19 -0300
Subject: [PATCH v1 2/2] Summarize only replayed WAL on a standby

During recovery, GetLatestLSN() let the WAL summarizer read up to the
walreceiver's flush position whenever that was ahead of replay.  That
WAL is not part of the server's history until it is replayed: recovery
can stop at a recovery target before reaching it, and if the server is
then promoted, the summaries already written for the old timeline
cover WAL from a history the server abandoned.  Incremental backups
trust those summaries; a relation truncation recorded there discards
the references to blocks modified before the switch point, and the
restored blocks silently come from the prior backup.

Summarize only up to the replay position during recovery.  Backups
taken on a standby start at a restartpoint, which is never ahead of
replay, so they need no summaries beyond it.

With this, a standby promoted at a recovery target no longer leaves
old-timeline summaries that cross the switch point, so the tests added
by the previous commit now check that the summarizer stops at replay
and that every incremental backup across the promotion restores the
promoted server's rows.
---
 src/backend/postmaster/walsummarizer.c        | 35 +++++-------
 .../t/014_promote_at_summary_start.pl         | 30 ++++++-----
 .../t/015_summary_past_switch.pl              | 54 ++++++++++++-------
 3 files changed, 64 insertions(+), 55 deletions(-)

diff --git a/src/backend/postmaster/walsummarizer.c b/src/backend/postmaster/walsummarizer.c
index 8333a4eaaa..56d24f5323 100644
--- a/src/backend/postmaster/walsummarizer.c
+++ b/src/backend/postmaster/walsummarizer.c
@@ -846,10 +846,6 @@ GetLatestLSN(TimeLineID *tli)
 	}
 	else
 	{
-		XLogRecPtr	flush_lsn;
-		TimeLineID	flush_tli;
-		XLogRecPtr	replay_lsn;
-		TimeLineID	replay_tli;
 		TimeLineID	insert_tli;
 
 		/*
@@ -868,26 +864,19 @@ GetLatestLSN(TimeLineID *tli)
 		}
 
 		/*
-		 * What we really want to know is how much WAL has been flushed to
-		 * disk, but the only flush position available is the one provided by
-		 * the walreceiver, which may not be running, because this could be
-		 * crash recovery or recovery via restore_command. So use either the
-		 * WAL receiver's flush position or the replay position, whichever is
-		 * further ahead, on the theory that if the WAL has been replayed then
-		 * it must also have been flushed to disk.
+		 * Otherwise, summarize only WAL that has been replayed.  A walreceiver
+		 * may have flushed WAL beyond that point, but it is not yet part of
+		 * this server's history: recovery may stop at a recovery target before
+		 * reaching it, and if the server is then promoted, that WAL belongs to
+		 * a timeline this server abandoned.  A summary covering it would
+		 * describe changes, such as relation truncations, that never happened
+		 * here, and incremental backups would trust it.
+		 *
+		 * Backups taken during recovery start at a restartpoint, which is
+		 * never ahead of replay, so they don't need summaries of anything
+		 * further.
 		 */
-		flush_lsn = GetWalRcvFlushRecPtr(NULL, &flush_tli);
-		replay_lsn = GetXLogReplayRecPtr(&replay_tli);
-		if (flush_lsn > replay_lsn)
-		{
-			*tli = flush_tli;
-			return flush_lsn;
-		}
-		else
-		{
-			*tli = replay_tli;
-			return replay_lsn;
-		}
+		return GetXLogReplayRecPtr(tli);
 	}
 }
 
diff --git a/src/bin/pg_combinebackup/t/014_promote_at_summary_start.pl b/src/bin/pg_combinebackup/t/014_promote_at_summary_start.pl
index 9e150f43d6..6fc4a6a856 100644
--- a/src/bin/pg_combinebackup/t/014_promote_at_summary_start.pl
+++ b/src/bin/pg_combinebackup/t/014_promote_at_summary_start.pl
@@ -1,11 +1,10 @@
 # Copyright (c) 2026, PostgreSQL Global Development Group
 #
-# A standby can summarize WAL that it has received but not yet replayed.  If
-# it is promoted at a recovery target that is exactly the start of such a
-# summary, that old-timeline summary covers only WAL that never happened on
-# the promoted server.  An incremental backup taken after promotion must not
-# use it: here it holds a VACUUM truncation that would discard the blocks
-# modified on the old timeline before the switch point.
+# A standby promoted at a recovery target that is exactly the start of a WAL
+# summary, while it has received WAL past the target.  A standby only
+# summarizes replayed WAL, so no old-timeline summary covers that WAL, and an
+# incremental backup taken after promotion restores the promoted server's
+# rows, not the effects of the VACUUM truncation it never replayed.
 
 use strict;
 use warnings FATAL => 'all';
@@ -46,7 +45,7 @@ $primary->safe_psql('postgres', 'CHECKPOINT');
 my $target_lsn = $primary->safe_psql('postgres',
 	'SELECT redo_lsn FROM pg_control_checkpoint()');
 
-# The standby receives and summarizes this, but never replays it.
+# The standby receives this, but never replays it.
 $primary->safe_psql('postgres', 'DELETE FROM t WHERE id > 2');
 $primary->safe_psql('postgres', 'VACUUM t');
 is($primary->safe_psql('postgres', $size_sql), '2',
@@ -66,12 +65,17 @@ $standby->start;
 $standby->poll_query_until('postgres',
 	"SELECT pg_get_wal_replay_pause_state() = 'paused'")
   or die "timed out waiting for recovery to pause";
-$standby->poll_query_until('postgres', <<EOF)
-SELECT EXISTS (SELECT FROM pg_available_wal_summaries()
-               WHERE tli = 1 AND start_lsn = '$target_lsn'
-               AND end_lsn >= '$end_lsn')
-EOF
-  or die "timed out waiting for a timeline 1 summary past $target_lsn";
+$standby->poll_query_until('postgres',
+	"SELECT pg_last_wal_receive_lsn() >= '$end_lsn'")
+  or die "timed out waiting for the standby to receive up to $end_lsn";
+$standby->poll_query_until('postgres',
+	"SELECT pending_lsn >= pg_last_wal_replay_lsn() FROM pg_get_wal_summarizer_state()")
+  or die "timed out waiting for summarization to reach replay";
+is( $standby->safe_psql(
+		'postgres',
+		"SELECT pending_lsn <= pg_last_wal_replay_lsn() FROM pg_get_wal_summarizer_state()"),
+	't',
+	'the summarizer does not read past replay');
 $primary->stop;
 $standby->promote;
 
diff --git a/src/bin/pg_combinebackup/t/015_summary_past_switch.pl b/src/bin/pg_combinebackup/t/015_summary_past_switch.pl
index b1c0bf8c17..5af1a85e5e 100644
--- a/src/bin/pg_combinebackup/t/015_summary_past_switch.pl
+++ b/src/bin/pg_combinebackup/t/015_summary_past_switch.pl
@@ -1,10 +1,10 @@
 # Copyright (c) 2026, PostgreSQL Global Development Group
 #
-# A streaming standby summarizes the WAL it receives, ahead of replay.  When
-# it is promoted at a recovery target inside a WAL summary, the old-timeline
-# summary covering the target also covers WAL that the promoted server never
-# replayed.  Block references from that WAL are harmless, but a truncation
-# recorded there must not be applied to an incremental backup.
+# A streaming standby promoted at a recovery target inside a WAL summary,
+# after receiving WAL past the target, including a truncation.  A standby only
+# summarizes replayed WAL, so the old-timeline summaries end at the switch
+# point, and incremental backups after promotion restore the promoted
+# server's rows.
 
 use strict;
 use warnings FATAL => 'all';
@@ -57,8 +57,8 @@ $primary->safe_psql('postgres', 'CHECKPOINT');
 my $end_lsn = $primary->safe_psql('postgres',
 	'SELECT redo_lsn FROM pg_control_checkpoint()');
 
-# Start a standby that pauses at the target and has summarized everything
-# the primary wrote, then promote it.
+# Start a standby that pauses at the target and has received everything the
+# primary wrote, then promote it.
 sub promoted_standby
 {
 	my ($name, $target) = @_;
@@ -73,11 +73,17 @@ EOF
 	$node->poll_query_until('postgres',
 		"SELECT pg_get_wal_replay_pause_state() = 'paused'")
 	  or die "timed out waiting for $name to pause";
-	$node->poll_query_until('postgres', <<EOF)
-SELECT EXISTS (SELECT FROM pg_available_wal_summaries()
-               WHERE tli = 1 AND end_lsn >= '$end_lsn')
-EOF
-	  or die "timed out waiting for $name to summarize up to $end_lsn";
+	$node->poll_query_until('postgres',
+		"SELECT pg_last_wal_receive_lsn() >= '$end_lsn'")
+	  or die "timed out waiting for $name to receive up to $end_lsn";
+	$node->poll_query_until('postgres',
+		"SELECT pending_lsn >= pg_last_wal_replay_lsn() FROM pg_get_wal_summarizer_state()")
+	  or die "timed out waiting for $name to summarize up to replay";
+	is( $node->safe_psql(
+			'postgres',
+			"SELECT pending_lsn <= pg_last_wal_replay_lsn() FROM pg_get_wal_summarizer_state()"),
+		't',
+		"$name: the summarizer does not read past replay");
 	$node->promote;
 	return $node;
 }
@@ -90,8 +96,7 @@ $primary->stop;
 # hint bits would WAL-log their blocks again on the new timeline.
 my $contents_sql = "SELECT id, right(pad, 1) FROM t ORDER BY id";
 
-# First standby: the summary past the switch only adds block references, so
-# the incremental backup works and restores the promoted server's rows.
+# First standby: only new rows follow its target.
 $standby1->command_ok(
 	[
 		'pg_basebackup', '--no-sync',
@@ -114,17 +119,28 @@ is($restored->safe_psql('postgres', $contents_sql),
 	$expected1, 'restored rows match the first standby');
 $restored->stop;
 
-# Second standby: the summary past the switch records the truncation, which
-# never happened on this server, so the incremental backup must refuse it.
-$standby2->command_fails_like(
+# Second standby: the truncation after its target was received but never
+# replayed or summarized, so the incremental backup restores the rows.
+$standby2->command_ok(
 	[
 		'pg_basebackup', '--no-sync',
 		'--pgdata' => $primary->backup_dir . '/incr2',
 		'--checkpoint' => 'fast',
 		'--incremental' => "$full_path/backup_manifest",
 	],
-	qr/cannot use WAL summary file .* for an incremental backup/,
-	'incremental backup refuses a truncation past the switch point');
+	'incremental backup when a truncation follows the target');
+my $expected2 = "1|x\n2|x\n3|o\n4|o\n5|x";
+is($standby2->safe_psql('postgres', $contents_sql),
+	$expected2, 'second standby has the rows replayed before the target');
 $standby2->stop;
+my $restored2 = PostgreSQL::Test::Cluster->new('restored2');
+$restored2->init_from_backup(
+	$primary, 'incr2',
+	combine_with_prior => ['full'],
+	combine_mode => $mode);
+$restored2->start;
+is($restored2->safe_psql('postgres', $contents_sql),
+	$expected2, 'restored rows match the second standby');
+$restored2->stop;
 
 done_testing();
-- 
2.56.0

