From b48a2713483045d88a21e6ea0efd99e4a9cf6343 Mon Sep 17 00:00:00 2001 From: Andrey Borodin Date: Mon, 24 Aug 2026 13:52:58 +0300 Subject: [PATCH] Demonstrate WAL recycling race in pg_basebackup --create-slot pg_basebackup creates a requested replication slot only after the server has selected and sent the backup startpoint. Nothing retains WAL in that window, so a checkpoint can recycle the startpoint segment before the WAL streamer starts. Add an injection point before the server sends the selected startpoint and a TAP test that recycles the segment while the backup waits there, then observes the resulting missing-WAL failure. --- src/backend/backup/basebackup.c | 6 + src/test/recovery/meson.build | 1 + .../recovery/t/056_basebackup_slot_race.pl | 121 ++++++++++++++++++ 3 files changed, 128 insertions(+) create mode 100644 src/test/recovery/t/056_basebackup_slot_race.pl diff --git a/src/backend/backup/basebackup.c b/src/backend/backup/basebackup.c index e3c04ecd810..656d4f3aeab 100644 --- a/src/backend/backup/basebackup.c +++ b/src/backend/backup/basebackup.c @@ -324,6 +324,12 @@ perform_base_backup(basebackup_options *opt, bbsink *sink, state.bytes_total_is_valid = true; } + /* + * The startpoint has been selected, but the client does not know it + * yet and therefore cannot have created the requested slot. + */ + INJECTION_POINT("basebackup-before-send-startpoint", NULL); + /* notify basebackup sink about start of backup */ bbsink_begin_backup(sink, &state, SINK_BUFFER_LENGTH); diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build index 39ec8c4946d..b61bc60c000 100644 --- a/src/test/recovery/meson.build +++ b/src/test/recovery/meson.build @@ -64,6 +64,7 @@ tests += { 't/053_standby_login_event_trigger.pl', 't/054_unlogged_sequence_promotion.pl', 't/055_cascade_reconnect.pl', + 't/056_basebackup_slot_race.pl', ], }, } diff --git a/src/test/recovery/t/056_basebackup_slot_race.pl b/src/test/recovery/t/056_basebackup_slot_race.pl new file mode 100644 index 00000000000..29da020d762 --- /dev/null +++ b/src/test/recovery/t/056_basebackup_slot_race.pl @@ -0,0 +1,121 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Demonstrate WAL recycling before pg_basebackup creates its requested slot. +# +# The injection point stops BASE_BACKUP after choosing its startpoint but +# before sending it to the client. The test recycles that WAL and then lets +# pg_basebackup create the slot and start its WAL streamer. The test passes +# when pg_basebackup fails with the expected missing-WAL error. + +use strict; +use warnings FATAL => 'all'; +use File::Path qw(rmtree); +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if ($ENV{enable_injection_points} ne 'yes') +{ + plan skip_all => 'Injection points not supported by this build'; +} + +# Small WAL segments make recycling cheap. +my $node = PostgreSQL::Test::Cluster->new('primary'); +$node->init(allows_streaming => 1, extra => [ '--wal-segsize', '1' ]); +$node->append_conf( + 'postgresql.conf', q[ +wal_keep_size = 0 +min_wal_size = 2MB +max_wal_size = 4MB +checkpoint_timeout = 1h +]); +$node->start; + +# injection_points may not be installed under installcheck. +if (!$node->check_extension('injection_points')) +{ + plan skip_all => 'Extension injection_points not installed'; +} +$node->safe_psql('postgres', 'CREATE EXTENSION injection_points;'); + +# Stop BASE_BACKUP before it sends the selected startpoint to the client. +$node->safe_psql('postgres', + "SELECT injection_points_attach('basebackup-before-send-startpoint', 'wait');" +); + +my $backupdir = $node->backup_dir . '/basebackup_race'; +my ($bb_stdout, $bb_stderr) = ('', ''); +my $bb_timeout = + IPC::Run::timeout(3 * $PostgreSQL::Test::Utils::timeout_default); +my $bb = IPC::Run::start( + [ + 'pg_basebackup', + '--pgdata' => $backupdir, + '--wal-method' => 'stream', + '--slot' => 'basebackup_race', + '--create-slot', + '--checkpoint' => 'fast', + '--no-sync', + '-d' => $node->connstr('postgres') + ], + '>' => \$bb_stdout, + '2>' => \$bb_stderr, + $bb_timeout); + +$node->wait_for_event('walsender', 'basebackup-before-send-startpoint'); + +# The client cannot create the slot before it receives the startpoint. +is( $node->safe_psql( + 'postgres', 'SELECT count(*) FROM pg_replication_slots;'), + '0', + 'no replication slot exists while startpoint is unprotected'); + +# do_pg_backup_start() used the current checkpoint's REDO pointer. +my $startpoint_wal = $node->safe_psql('postgres', + 'SELECT pg_walfile_name(redo_lsn) FROM pg_control_checkpoint();'); +note "backup startpoint is in WAL segment $startpoint_wal"; + +is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM pg_ls_waldir() WHERE name = '$startpoint_wal';" + ), + '1', + 'WAL segment containing the backup startpoint exists while waiting'); + +# Nothing retains the startpoint while BASE_BACKUP waits. +$node->advance_wal(10); +$node->safe_psql('postgres', 'CHECKPOINT;'); +is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM pg_ls_waldir() WHERE name = '$startpoint_wal';" + ), + '0', + 'WAL segment containing the backup startpoint was recycled before slot ' + . 'creation' +); + +# Let pg_basebackup create the slot and request the recycled WAL. +$node->safe_psql('postgres', + "SELECT injection_points_wakeup('basebackup-before-send-startpoint');"); +$node->safe_psql('postgres', + "SELECT injection_points_detach('basebackup-before-send-startpoint');"); + +$bb->finish; +note "pg_basebackup stderr:\n$bb_stderr"; + +isnt($bb->result(0), 0, 'pg_basebackup failed as expected (bug reproduced)') + or diag "pg_basebackup stdout: $bb_stdout\npg_basebackup stderr: $bb_stderr"; +like( + $bb_stderr, + qr/requested WAL segment [0-9A-F]+ has already been removed/, + 'WAL streamer failed because the startpoint segment was removed') + or diag "pg_basebackup stderr: $bb_stderr"; + +# The WAL streamer created the slot before noticing the missing segment. +rmtree($backupdir); +$node->safe_psql('postgres', + "SELECT pg_drop_replication_slot(slot_name) FROM pg_replication_slots " + . "WHERE slot_name = 'basebackup_race';" +); + +done_testing(); -- That's all, folks. May the source be with you.