From b5ce8753ec412df399e05f2d25a3c8901ed8ddfe Mon Sep 17 00:00:00 2001 From: Andrey Borodin 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', <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', <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.