From 4dee857b92f515a45a98f4fbcc93021fe003b8bd Mon Sep 17 00:00:00 2001
From: sai pranav <rajasaipranav0@gmail.com>
Date: Sat, 24 Oct 2026 01:50:50 +0530
Subject: [PATCH v1] Reject incremental backups whose prior backup outlives a
 timeline switch

When the prior backup's final WAL range is on a timeline that this server
has since switched away from, PrepareForIncrementalBackup() only checked
that range's end LSN against the start of the new backup, not against the
switch point recorded in this server's timeline history.  A prior backup
whose WAL extends past the switch point contains changes that are not part
of this server's history, yet the incremental backup was accepted.

This happens after an ordinary failover with asynchronous replication: a
backup taken on the old primary can end after the last WAL that reached the
standby, which is then promoted.  An incremental backup taken on the new
primary relative to that backup omits blocks the old primary changed after
the switch point, so pg_combinebackup takes them from the prior backup.  The
combined backup restores without complaint but can contain pages from the
other history; in testing, a btree index of the restored cluster pointed to
blocks beyond the end of its own file.

Add the missing check for that case, reusing the wording of the existing
check for non-final timelines, and add a test.

Back-patch to 17, where incremental backup was introduced.
---
 src/backend/backup/basebackup_incremental.c   | 16 ++++
 src/bin/pg_combinebackup/meson.build          |  1 +
 .../pg_combinebackup/t/013_diverged_parent.pl | 94 +++++++++++++++++++
 3 files changed, 111 insertions(+)
 create mode 100644 src/bin/pg_combinebackup/t/013_diverged_parent.pl

diff --git a/src/backend/backup/basebackup_incremental.c b/src/backend/backup/basebackup_incremental.c
index 364b3f87ab..3995f23ad1 100644
--- a/src/backend/backup/basebackup_incremental.c
+++ b/src/backend/backup/basebackup_incremental.c
@@ -434,6 +434,22 @@ PrepareForIncrementalBackup(IncrementalBackupInfo *ib,
 								LSN_FORMAT_ARGS(range->end_lsn),
 								LSN_FORMAT_ARGS(backup_state->startpoint)),
 						 errhint("This can happen for incremental backups on a standby if there was little activity since the previous backup.")));
+
+			/*
+			 * If this server has since switched away from that timeline, the
+			 * prior backup must not require WAL from past the switch point.
+			 * Otherwise it contains changes that are not part of this
+			 * server's history, as when the backup was taken on a primary
+			 * whose last transactions never reached the standby that was
+			 * later promoted.
+			 */
+			if (XLogRecPtrIsValid(tlep[i]->end) && range->end_lsn > tlep[i]->end)
+				ereport(ERROR,
+						(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+						 errmsg("manifest requires WAL from final timeline %u ending at %X/%08X, but this server switched timelines at %X/%08X",
+								range->tli,
+								LSN_FORMAT_ARGS(range->end_lsn),
+								LSN_FORMAT_ARGS(tlep[i]->end))));
 		}
 		else
 		{
diff --git a/src/bin/pg_combinebackup/meson.build b/src/bin/pg_combinebackup/meson.build
index ba1c8cfa3d..f1ae1b3594 100644
--- a/src/bin/pg_combinebackup/meson.build
+++ b/src/bin/pg_combinebackup/meson.build
@@ -40,6 +40,7 @@ tests += {
       't/010_hardlink.pl',
       't/011_ib_truncation.pl',
       't/012_vm_consistency.pl',
+      't/013_diverged_parent.pl',
     ],
   }
 }
diff --git a/src/bin/pg_combinebackup/t/013_diverged_parent.pl b/src/bin/pg_combinebackup/t/013_diverged_parent.pl
new file mode 100644
index 0000000000..8834daa077
--- /dev/null
+++ b/src/bin/pg_combinebackup/t/013_diverged_parent.pl
@@ -0,0 +1,94 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+#
+# Test that an incremental backup is refused when the prior backup requires
+# WAL from past the point where this server's history left that timeline.
+# That happens after a failover: the last backup taken on the old primary can
+# include transactions that never reached the standby that was promoted.
+
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# Set up a new database instance.
+my $node1 = PostgreSQL::Test::Cluster->new('node1');
+$node1->init(has_archiving => 1, allows_streaming => 1);
+$node1->append_conf('postgresql.conf', 'summarize_wal = on');
+$node1->start;
+
+$node1->safe_psql('postgres', <<EOM);
+CREATE TABLE mytable (a int, b text);
+INSERT INTO mytable VALUES (1, 'avocado');
+EOM
+
+# Take a full backup to create the standby from.
+$node1->backup('standby_base');
+
+# Take the full backup that will later be used as the prior backup.
+my $parentpath = $node1->backup_dir . '/parent';
+$node1->command_ok(
+	[
+		'pg_basebackup',
+		'--pgdata' => $parentpath,
+		'--no-sync',
+		'--checkpoint' => 'fast',
+	],
+	"full backup from node1");
+
+my $manifest = slurp_file("$parentpath/backup_manifest");
+$manifest =~ /"Start-LSN": "([0-9A-F\/]+)", "End-LSN": "([0-9A-F\/]+)"/
+  or die "could not find WAL range in backup manifest";
+my ($parent_start, $parent_end) = ($1, $2);
+
+# Create a standby that stops replaying just after the start of that backup,
+# then promote it. Its timeline switch falls between the start and the end of
+# the WAL range that the backup requires.
+my $node2 = PostgreSQL::Test::Cluster->new('node2');
+$node2->init_from_backup($node1, 'standby_base', has_streaming => 1);
+$node2->append_conf('postgresql.conf', <<EOM);
+recovery_target_lsn = '$parent_start'
+recovery_target_action = 'pause'
+EOM
+$node2->start();
+$node2->poll_query_until('postgres',
+	"SELECT pg_get_wal_replay_pause_state() = 'paused';");
+$node2->safe_psql('postgres', "SELECT pg_promote()");
+$node2->poll_query_until('postgres', "SELECT pg_is_in_recovery() = 'f';");
+
+# Generate WAL on the new timeline until we are past the end of the prior
+# backup, so that the existing "this backup starts at" check does not apply.
+$node2->safe_psql('postgres', "INSERT INTO mytable VALUES (2, 'blackberry')");
+while ($node2->safe_psql('postgres',
+		"SELECT pg_current_wal_lsn() <= '$parent_end'::pg_lsn") eq 't')
+{
+	$node2->safe_psql('postgres', "SELECT pg_switch_wal()");
+	$node2->safe_psql('postgres', "INSERT INTO mytable VALUES (3, 'cherry')");
+}
+
+# An incremental backup relative to a backup taken before the switch point
+# still works.
+$node2->command_ok(
+	[
+		'pg_basebackup',
+		'--pgdata' => $node1->backup_dir . '/incr_ok',
+		'--no-sync',
+		'--checkpoint' => 'fast',
+		'--incremental' => $node1->backup_dir . '/standby_base/backup_manifest',
+	],
+	"incremental backup relative to a backup from before the switch");
+
+# An incremental backup relative to the backup that extends past the switch
+# point must be refused.
+$node2->command_fails_like(
+	[
+		'pg_basebackup',
+		'--pgdata' => $node1->backup_dir . '/incr_bad',
+		'--no-sync',
+		'--checkpoint' => 'fast',
+		'--incremental' => $parentpath . '/backup_manifest',
+	],
+	qr/manifest requires WAL from final timeline 1 ending at .*, but this server switched timelines at/,
+	"incremental backup relative to a backup that extends past the switch");
+
+done_testing();
-- 
2.50.1 (Apple Git-155)

