Hi hackers,
 
While working on WAL verification, we found a case where a timeline
history file does not describe the sequence of timeline switches that
actually took place.
 
Suppose timeline 2 forked from timeline 1 at LSN B.  We then perform
PITR with recovery_target_timeline = 'latest', but stop at a restore
point A on timeline 1, where A < B.  PostgreSQL allocates timeline 3,
but currently records recoveryTargetTLI, i.e. timeline 2, as its
parent.  The resulting history looks like this:
 
cat 000002.history:
    1    B    ...
    2    A    ...
 
The switchpoints run backwards.  The actual ancestry is a direct fork
from timeline 1 to timeline 3 at A.  A consumer such as a WAL verifier
cannot reconstruct that ancestry from the history file.
 
The attached patch uses the timeline of the last replayed WAL record
as the parent of the new timeline.  It also adjusts 028_pitr_timelines.pl
to check the generated history file.
 
This came up while looking at the following related discussions [0] & [1]
 
I think this is a correctness issue and should be backpatched.
 
 
[0] https://www.postgresql.org/message-id/flat/CA%2BTgmobr27GpKDZx3_ezW2%2BC5_g18i%2BjSK3sGF_cR-_ESv5N5A%40mail.gmail.com
 
[1] https://www.postgresql.org/message-id/flat/85386EF6-16B7-4D62-86BE-526A10F93825%40yandex-team.ru

 
Regards,
Stepan Filippov,
Yandex Cloud.
 
From 849222d30ec9cce21ca16ed42a3944074d234653 Mon Sep 17 00:00:00 2001
From: debebantur <[email protected]>
Date: Fri, 31 Jul 2026 08:11:23 +0000
Subject: [[PATCH] Fix timeline history after recovery stops on an ancestor] A
 recovery target can be reached before recovery switches to the requested
 timeline.  Using recoveryTargetTLI as the parent in that case creates a
 history file that claims the new timeline forked from a timeline that
 recovery never reached.Use the timeline of the last replayed WAL record as
 the parent instead.

Extend the existing PITR timeline test to verify the resulting history.
---
 src/backend/access/transam/xlog.c         |  2 +-
 src/test/recovery/t/002_archiving.pl      | 14 ++++++++++++++
 src/test/recovery/t/028_pitr_timelines.pl |  9 +++++++++
 3 files changed, 24 insertions(+), 1 deletion(-)

diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index f85b528608..6b0b27d853 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -6408,7 +6408,7 @@ StartupXLOG(void)
 		 * To minimize the window for that, try to do as little as possible
 		 * between here and writing the end-of-recovery record.
 		 */
-		writeTimeLineHistory(newTLI, recoveryTargetTLI,
+		writeTimeLineHistory(newTLI, endOfRecoveryInfo->lastRecTLI,
 							 EndOfLog, endOfRecoveryInfo->recoveryStopReason);
 
 		ereport(LOG,
diff --git a/src/test/recovery/t/002_archiving.pl b/src/test/recovery/t/002_archiving.pl
index aa40f58e6d..a7ff14b3a0 100644
--- a/src/test/recovery/t/002_archiving.pl
+++ b/src/test/recovery/t/002_archiving.pl
@@ -100,6 +100,20 @@ $caughtup_query =
 $node_primary->poll_query_until('postgres', $caughtup_query)
   or die "Timed out while waiting for archiving of 00000002.history";
 
+# Generate and archive some WAL on timeline 2.  This ensures that standby2
+# actually reaches timeline 2, so that its history file is needed again when
+# standby2 is promoted.
+$node_standby->safe_psql('postgres', "CREATE TABLE timeline_2_marker (a int)");
+my $timeline_2_lsn =
+  $node_standby->safe_psql('postgres', "SELECT pg_current_wal_lsn()");
+my $timeline_2_walfile = $node_standby->safe_psql(
+	'postgres', "SELECT pg_walfile_name('$timeline_2_lsn')");
+$node_standby->safe_psql('postgres', "SELECT pg_switch_wal()");
+$caughtup_query =
+  "SELECT size IS NOT NULL FROM pg_stat_file('$primary_archive/$timeline_2_walfile', true)";
+$node_primary->poll_query_until('postgres', $caughtup_query)
+  or die "Timed out while waiting for archiving of $timeline_2_walfile";
+
 # recovery_end_command should have been triggered on promotion.
 ok( -f "$data_dir/$recovery_end_command_file",
 	'recovery_end_command executed after promotion');
diff --git a/src/test/recovery/t/028_pitr_timelines.pl b/src/test/recovery/t/028_pitr_timelines.pl
index ae617f6195..429b6e8220 100644
--- a/src/test/recovery/t/028_pitr_timelines.pl
+++ b/src/test/recovery/t/028_pitr_timelines.pl
@@ -150,6 +150,15 @@ $node_pitr->poll_query_until('postgres',
 # Stop the node.  This archives the last segment.
 $node_pitr->stop();
 
+# The new timeline branched from timeline 1 at the recovery target, before
+# timeline 2 branched from timeline 1.  Its history must therefore name
+# timeline 1 as its direct parent, rather than copying timeline 2's history.
+my $history = slurp_file($node_primary->archive_dir . '/00000003.history');
+like(
+	$history,
+	qr/^1\t[0-9A-F]+\/[0-9A-F]+\t[^\r\n]*(?:\r?\n)?\z/,
+	"new timeline history has the timeline containing the target as parent");
+
 # Test archive recovery on the timeline created by the PITR.  This
 # replays the end-of-recovery record that switches from timeline 1 to
 # 3.
-- 
2.34.1

Reply via email to