From 4dee857b92f515a45a98f4fbcc93021fe003b8bd Mon Sep 17 00:00:00 2001 From: sai pranav 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', <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', <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)