From b5ce8753ec412df399e05f2d25a3c8901ed8ddfe Mon Sep 17 00:00:00 2001
From: Andrey Borodin <amborodin@acm.org>
Date: Fri, 20 Feb 2026 00:05:03 +0500
Subject: [PATCH v4] Prevent archive recovery from reading divergent WAL

When a WAL segment is missing on the target timeline,
XLogFileReadAnyTLI() searches older timelines for a segment with the
same number.  This is safe for segments that precede the target
timeline, but not for the segment containing a switch point.  Its
parent-timeline copy has a valid prefix followed by WAL from the
divergent branch.

After skipping timelines that began in a later segment, try only the
newest eligible timeline.  If its file is unavailable in the archive
and pg_wal, wait for it instead of reading a parent-timeline file.

Add two- and three-timeline recovery tests.

Discussion: https://postgr.es/m/85386EF6-16B7-4D62-86BE-526A10F93825%40yandex-team.ru
Backpatch-through: 14
---
 src/backend/access/transam/xlogrecovery.c     |  16 ++
 src/test/recovery/meson.build                 |   2 +
 .../057_timeline_switch_archive_divergence.pl | 101 ++++++++++++
 .../t/058_timeline_switch_intermediate_tl.pl  | 144 ++++++++++++++++++
 4 files changed, 263 insertions(+)
 create mode 100644 src/test/recovery/t/057_timeline_switch_archive_divergence.pl
 create mode 100644 src/test/recovery/t/058_timeline_switch_intermediate_tl.pl

diff --git a/src/backend/access/transam/xlogrecovery.c b/src/backend/access/transam/xlogrecovery.c
index acac97e89d3..73c923c3c34 100644
--- a/src/backend/access/transam/xlogrecovery.c
+++ b/src/backend/access/transam/xlogrecovery.c
@@ -4299,6 +4299,7 @@ XLogFileRead(XLogSegNo segno, TimeLineID tli,
 static int
 XLogFileReadAnyTLI(XLogSegNo segno, XLogSource source)
 {
+	char		xlogfname[MAXFNAMELEN];
 	char		path[MAXPGPATH];
 	ListCell   *cell;
 	int			fd;
@@ -4381,6 +4382,21 @@ XLogFileReadAnyTLI(XLogSegNo segno, XLogSource source)
 				return fd;
 			}
 		}
+
+		/*
+		 * This is the newest timeline to which this segment can belong.  A
+		 * segment containing the switch point can have a valid prefix on the
+		 * parent timeline, but the rest of that file can contain divergent
+		 * WAL.  Since WAL is opened a segment at a time, recovery must wait
+		 * for the file from this timeline rather than fall back to a parent.
+		 */
+		XLogFileName(xlogfname, tli, segno, wal_segment_size);
+		ereport(DEBUG1,
+				(errmsg("not searching older timelines for WAL segment \"%s\"",
+						xlogfname),
+				 errdetail("The whole segment must be read from timeline %u.",
+						   tli)));
+		break;
 	}
 
 	/* Couldn't find it.  For simplicity, complain about front timeline */
diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build
index 72113c5ac6e..9182b71ae68 100644
--- a/src/test/recovery/meson.build
+++ b/src/test/recovery/meson.build
@@ -65,6 +65,8 @@ tests += {
       't/054_unlogged_sequence_promotion.pl',
       't/055_cascade_reconnect.pl',
       't/056_standby_snapshot_export.pl',
+      't/057_timeline_switch_archive_divergence.pl',
+      't/058_timeline_switch_intermediate_tl.pl',
     ],
   },
 }
diff --git a/src/test/recovery/t/057_timeline_switch_archive_divergence.pl b/src/test/recovery/t/057_timeline_switch_archive_divergence.pl
new file mode 100644
index 00000000000..bc6d68c883f
--- /dev/null
+++ b/src/test/recovery/t/057_timeline_switch_archive_divergence.pl
@@ -0,0 +1,101 @@
+# Copyright (c) 2021-2026, PostgreSQL Global Development Group
+
+# Test that archive recovery does not use a parent timeline's WAL file for
+# the segment containing a switch point when the child's file is absent.
+
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use Test::More;
+use File::Copy qw(copy);
+
+# Create TL1 and take a base backup before the segment containing the future
+# switch point.
+my $node_primary = PostgreSQL::Test::Cluster->new('primary');
+$node_primary->init(allows_streaming => 1, has_archiving => 1);
+$node_primary->start;
+$node_primary->safe_psql('postgres', 'CREATE TABLE t (i int)');
+$node_primary->backup('backup');
+
+# Create a standby that can be promoted to TL2.  It must not archive its WAL,
+# since the test initially needs the TL2 switch-point segment to be absent.
+my $node_standby = PostgreSQL::Test::Cluster->new('standby');
+$node_standby->init_from_backup($node_primary, 'backup', has_streaming => 1);
+$node_standby->append_conf('postgresql.conf', "archive_mode = off");
+$node_standby->start;
+
+# Put the TL1->TL2 switch point in a new, partially filled segment.
+$node_primary->safe_psql('postgres', 'SELECT pg_switch_wal()');
+$node_primary->safe_psql('postgres', 'INSERT INTO t VALUES (1)');
+$node_primary->wait_for_catchup($node_standby);
+$node_standby->promote;
+$node_standby->poll_query_until('postgres', 'SELECT NOT pg_is_in_recovery()')
+  or die "Timed out waiting for promotion";
+
+# Generate valid WAL on TL2, remember a recovery target after its commit, and
+# complete the segment.  Save the file under a name restore_command ignores;
+# it will be made available only after recovery refuses the TL1 copy.
+$node_standby->safe_psql('postgres', 'INSERT INTO t VALUES (2)');
+my $target_lsn =
+  $node_standby->safe_psql('postgres', 'SELECT pg_current_wal_lsn()');
+my $child_walfile = $node_standby->safe_psql('postgres',
+	"SELECT pg_walfile_name('$target_lsn'::pg_lsn)");
+$node_standby->safe_psql('postgres', 'SELECT pg_switch_wal()');
+
+my $archive = $node_primary->archive_dir;
+my $staged_child = "$archive/$child_walfile.ready";
+copy($node_standby->data_dir . "/pg_wal/$child_walfile", $staged_child)
+  or die "Could not stage $child_walfile: $!";
+
+# Continue TL1 past the switch point and archive its divergent version of the
+# same segment.
+$node_primary->safe_psql('postgres', 'INSERT INTO t VALUES (-1)');
+my $parent_walfile = $node_primary->safe_psql('postgres',
+	'SELECT pg_walfile_name(pg_current_wal_lsn())');
+$node_primary->safe_psql('postgres', 'SELECT pg_switch_wal()');
+$node_primary->poll_query_until('postgres',
+	"SELECT last_archived_wal >= '$parent_walfile' FROM pg_stat_archiver")
+  or die "Timed out waiting for TL1 WAL to be archived";
+
+# Publish only the history file.  Thus recovery knows about TL2, while only
+# the divergent TL1 copy of the switch-point segment is initially available.
+copy($node_standby->data_dir . '/pg_wal/00000002.history',
+	"$archive/00000002.history")
+  or die "Could not copy 00000002.history: $!";
+
+ok(-f "$archive/$parent_walfile", 'parent switch-point segment is archived');
+ok(!-f "$archive/$child_walfile", 'child switch-point segment is absent');
+is(substr($parent_walfile, 8), substr($child_walfile, 8),
+	'parent and child files have the same segment number');
+
+$node_primary->stop;
+$node_standby->stop;
+
+my $node_rec = PostgreSQL::Test::Cluster->new('recovering');
+$node_rec->init_from_backup($node_primary, 'backup', has_restoring => 1);
+$node_rec->enable_restoring($node_primary, 1);
+$node_rec->append_conf('postgresql.conf', <<EOM);
+recovery_target_timeline = '2'
+recovery_target_lsn = '$target_lsn'
+recovery_target_action = 'promote'
+wal_retrieve_retry_interval = '100ms'
+log_min_messages = debug1
+EOM
+$node_rec->start;
+
+# Prove that recovery reached the interesting segment and refused to use the
+# older timeline, rather than merely observing that no bad restore occurred.
+$node_rec->wait_for_log(
+	qr/not searching older timelines for WAL segment "\Q$child_walfile\E"/);
+
+# Make the correct file available and prove that recovery can proceed along
+# TL2 to the requested target without replaying TL1's divergent row.
+copy($staged_child, "$archive/$child_walfile")
+  or die "Could not publish $child_walfile: $!";
+$node_rec->poll_query_until('postgres', 'SELECT NOT pg_is_in_recovery()')
+  or die "Timed out waiting for recovery to reach TL2 target";
+is($node_rec->safe_psql('postgres',
+	q{SELECT string_agg(i::text, ',' ORDER BY i) FROM t}),
+	'1,2', 'recovery followed TL2 without replaying divergent TL1 WAL');
+
+done_testing();
diff --git a/src/test/recovery/t/058_timeline_switch_intermediate_tl.pl b/src/test/recovery/t/058_timeline_switch_intermediate_tl.pl
new file mode 100644
index 00000000000..16ddade4d70
--- /dev/null
+++ b/src/test/recovery/t/058_timeline_switch_intermediate_tl.pl
@@ -0,0 +1,144 @@
+# Copyright (c) 2021-2026, PostgreSQL Global Development Group
+
+# Test the same rule across two switches in consecutive segments.  Recovery
+# targeting TL3 must first wait for TL2's copy of the TL1->TL2 switch-point
+# segment, and then for TL3's copy of the TL2->TL3 switch-point segment.
+
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use Test::More;
+use File::Copy qw(copy);
+
+my $node_primary = PostgreSQL::Test::Cluster->new('primary');
+$node_primary->init(allows_streaming => 1, has_archiving => 1);
+$node_primary->start;
+$node_primary->safe_psql('postgres', 'CREATE TABLE t (i int)');
+$node_primary->backup('primary_backup');
+
+# Put the first switch point in a new, partially filled segment.
+$node_primary->safe_psql('postgres', 'SELECT pg_switch_wal()');
+$node_primary->safe_psql('postgres', 'INSERT INTO t VALUES (1)');
+
+my $node_standby1 = PostgreSQL::Test::Cluster->new('standby1');
+$node_standby1->init_from_backup($node_primary, 'primary_backup',
+	has_streaming => 1);
+$node_standby1->append_conf('postgresql.conf', "archive_mode = off");
+$node_standby1->start;
+$node_primary->wait_for_catchup($node_standby1);
+$node_standby1->promote;
+$node_standby1->poll_query_until('postgres',
+	'SELECT NOT pg_is_in_recovery()')
+  or die "Timed out waiting for promotion to TL2";
+
+# Create and stage the correct TL2 copy of the first switch-point segment.
+$node_standby1->safe_psql('postgres', 'INSERT INTO t VALUES (2)');
+my $tl2_lsn =
+  $node_standby1->safe_psql('postgres', 'SELECT pg_current_wal_lsn()');
+my $tl2_walfile = $node_standby1->safe_psql('postgres',
+	"SELECT pg_walfile_name('$tl2_lsn'::pg_lsn)");
+$node_standby1->safe_psql('postgres', 'SELECT pg_switch_wal()');
+
+my $archive = $node_primary->archive_dir;
+my $staged_tl2 = "$archive/$tl2_walfile.ready";
+copy($node_standby1->data_dir . "/pg_wal/$tl2_walfile", $staged_tl2)
+  or die "Could not stage $tl2_walfile: $!";
+
+# Put the second switch point in the next segment.
+$node_standby1->safe_psql('postgres', 'INSERT INTO t VALUES (3)');
+$node_standby1->stop;
+$node_standby1->backup_fs_cold('standby1_backup');
+$node_standby1->start;
+
+my $node_standby2 = PostgreSQL::Test::Cluster->new('standby2');
+$node_standby2->init_from_backup($node_standby1, 'standby1_backup',
+	has_streaming => 1);
+$node_standby2->append_conf('postgresql.conf', "archive_mode = off");
+$node_standby2->start;
+$node_standby1->wait_for_catchup($node_standby2);
+$node_standby2->promote;
+$node_standby2->poll_query_until('postgres',
+	'SELECT NOT pg_is_in_recovery()')
+  or die "Timed out waiting for promotion to TL3";
+
+# Create and stage the correct TL3 copy of the second switch-point segment.
+$node_standby2->safe_psql('postgres', 'INSERT INTO t VALUES (4)');
+my $target_lsn =
+  $node_standby2->safe_psql('postgres', 'SELECT pg_current_wal_lsn()');
+my $tl3_walfile = $node_standby2->safe_psql('postgres',
+	"SELECT pg_walfile_name('$target_lsn'::pg_lsn)");
+$node_standby2->safe_psql('postgres', 'SELECT pg_switch_wal()');
+
+my $staged_tl3 = "$archive/$tl3_walfile.ready";
+copy($node_standby2->data_dir . "/pg_wal/$tl3_walfile", $staged_tl3)
+  or die "Could not stage $tl3_walfile: $!";
+
+# Meanwhile, continue TL1 through both segments and archive its divergent
+# copies.  They must never be used while recovering toward TL3.
+$node_primary->safe_psql('postgres', 'INSERT INTO t VALUES (-1)');
+my $tl1_first_walfile = $node_primary->safe_psql('postgres',
+	'SELECT pg_walfile_name(pg_current_wal_lsn())');
+$node_primary->safe_psql('postgres', 'SELECT pg_switch_wal()');
+$node_primary->safe_psql('postgres', 'INSERT INTO t VALUES (-2)');
+my $tl1_last_walfile = $node_primary->safe_psql('postgres',
+	'SELECT pg_walfile_name(pg_current_wal_lsn())');
+$node_primary->safe_psql('postgres', 'SELECT pg_switch_wal()');
+$node_primary->poll_query_until('postgres',
+	"SELECT last_archived_wal >= '$tl1_last_walfile' FROM pg_stat_archiver")
+  or die "Timed out waiting for divergent TL1 WAL to be archived";
+
+# The TL3 history file contains the complete ancestry.  Publish it without
+# publishing either correct switch-point segment yet.
+copy($node_standby2->data_dir . '/pg_wal/00000003.history',
+	"$archive/00000003.history")
+  or die "Could not copy 00000003.history: $!";
+
+ok(-f "$archive/$tl1_first_walfile",
+	'parent copy of first switch-point segment is archived');
+ok(-f "$archive/$tl1_last_walfile",
+	'parent copy of second switch-point segment is archived');
+ok(!-f "$archive/$tl2_walfile", 'TL2 switch-point segment is absent');
+ok(!-f "$archive/$tl3_walfile", 'TL3 switch-point segment is absent');
+is(substr($tl1_first_walfile, 8), substr($tl2_walfile, 8),
+	'first parent and child files have the same segment number');
+is(substr($tl1_last_walfile, 8), substr($tl3_walfile, 8),
+	'second parent and child files have the same segment number');
+
+$node_primary->stop;
+$node_standby1->stop;
+$node_standby2->stop;
+
+my $node_rec = PostgreSQL::Test::Cluster->new('recovering');
+$node_rec->init_from_backup($node_primary, 'primary_backup',
+	has_restoring => 1);
+$node_rec->enable_restoring($node_primary, 1);
+$node_rec->append_conf('postgresql.conf', <<EOM);
+recovery_target_timeline = '3'
+recovery_target_lsn = '$target_lsn'
+recovery_target_action = 'promote'
+wal_retrieve_retry_interval = '100ms'
+log_min_messages = debug1
+EOM
+$node_rec->start;
+
+# Recovery first reaches the TL1->TL2 switch-point segment.  It must wait for
+# TL2 rather than fall through to the archived TL1 copy.
+$node_rec->wait_for_log(
+	qr/not searching older timelines for WAL segment "\Q$tl2_walfile\E"/);
+copy($staged_tl2, "$archive/$tl2_walfile")
+  or die "Could not publish $tl2_walfile: $!";
+
+# In the following segment TL3 is now the newest eligible timeline.  Recovery
+# must again wait rather than use an older copy.
+$node_rec->wait_for_log(
+	qr/not searching older timelines for WAL segment "\Q$tl3_walfile\E"/);
+copy($staged_tl3, "$archive/$tl3_walfile")
+  or die "Could not publish $tl3_walfile: $!";
+
+$node_rec->poll_query_until('postgres', 'SELECT NOT pg_is_in_recovery()')
+  or die "Timed out waiting for recovery to reach TL3 target";
+is($node_rec->safe_psql('postgres',
+	q{SELECT string_agg(i::text, ',' ORDER BY i) FROM t}),
+	'1,2,3,4', 'recovery followed the complete TL1-TL2-TL3 history');
+
+done_testing();
-- 
That's all, folks. May the source be with you.

