From 273c38817079825159978bdbe577dcc40d1ed627 Mon Sep 17 00:00:00 2001 From: Fujii Masao Date: Thu, 22 Oct 2026 20:58:56 +0900 Subject: [PATCH v2] Fix stale visibility maps in incremental backups after heap truncation An incremental backup taken after VACUUM truncates a table can restore stale visibility map bits when both data checksums and wal_log_hints are disabled. If the restored table grows again, the stale bits can cause VACUUM to skip required freezing and advance relfrozenxid past unfrozen tuples, or cause index-only scans to return deleted rows. Heap truncation clears bits for removed heap pages in the last remaining VM page. With both settings disabled, that page is not separately WAL-logged. The WAL summarizer records the VM truncation limit but does not mark the remaining page as modified, allowing incremental backups to reuse its old contents. The truncation record precedes the incremental backup's start LSN, so recovery does not replay it to clear those bits. Fix this by marking the last remaining VM page as modified when summarizing a truncation record, so incremental backups include its current contents. Do this conservatively even when truncation falls on a VM page boundary and the remaining page does not need to change. Note that existing WAL summaries are not regenerated by this fix. Incremental backups taken after updating can therefore still be affected if they use summaries generated before the fix. Take a new full backup from an uncorrupted server after updating and use it as the basis for subsequent incremental backups. This does not repair existing backups or corruption already present in a restored database. Backpatch to PostgreSQL 17, where incremental backup was introduced. Bug: #19775 Author: Ke Co-authored-by: Fujii Masao Co-authored-by: Shihao Zhong Discussion: https://postgr.es/m/19775-087aebfa1a709b09@postgresql.org Discussion: https://postgr.es/m/CAGRkXqSnghF+wbyP-PdfjVLfbejJG3cvZd9hN-gxsvCWj2F64Q@mail.gmail.com Backpatch-through: 17 --- src/backend/postmaster/walsummarizer.c | 19 +++- src/bin/pg_combinebackup/meson.build | 1 + .../pg_combinebackup/t/014_vm_truncation.pl | 90 +++++++++++++++++++ 3 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 src/bin/pg_combinebackup/t/014_vm_truncation.pl diff --git a/src/backend/postmaster/walsummarizer.c b/src/backend/postmaster/walsummarizer.c index 02a4ef8433a..245a592425c 100644 --- a/src/backend/postmaster/walsummarizer.c +++ b/src/backend/postmaster/walsummarizer.c @@ -1452,9 +1452,24 @@ SummarizeSmgrRecord(XLogReaderState *xlogreader, BlockRefTable *brtab) BlockRefTableSetLimitBlock(brtab, &xlrec->rlocator, MAIN_FORKNUM, xlrec->blkno); if ((xlrec->flags & SMGR_TRUNCATE_VM) != 0) + { + BlockNumber vmlimit = visibilitymap_truncation_length(xlrec->blkno); + BlockRefTableSetLimitBlock(brtab, &xlrec->rlocator, - VISIBILITYMAP_FORKNUM, - visibilitymap_truncation_length(xlrec->blkno)); + VISIBILITYMAP_FORKNUM, vmlimit); + + /* + * visibilitymap_prepare_truncate() may clear trailing bits in the + * last remaining VM page without WAL-logging the page. Mark it + * as modified so that incremental backups include its current + * contents. Do this even at a VM page boundary, where no bits + * need clearing. + */ + if (vmlimit > 0) + BlockRefTableMarkBlockModified(brtab, &xlrec->rlocator, + VISIBILITYMAP_FORKNUM, + vmlimit - 1); + } } } diff --git a/src/bin/pg_combinebackup/meson.build b/src/bin/pg_combinebackup/meson.build index 2ae49de5410..084f3da6710 100644 --- a/src/bin/pg_combinebackup/meson.build +++ b/src/bin/pg_combinebackup/meson.build @@ -40,6 +40,7 @@ tests += { 't/011_ib_truncation.pl', 't/012_vm_consistency.pl', 't/013_summary_order.pl', + 't/014_vm_truncation.pl', ], } } diff --git a/src/bin/pg_combinebackup/t/014_vm_truncation.pl b/src/bin/pg_combinebackup/t/014_vm_truncation.pl new file mode 100644 index 00000000000..68a138bb729 --- /dev/null +++ b/src/bin/pg_combinebackup/t/014_vm_truncation.pl @@ -0,0 +1,90 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Test that incremental backups preserve visibility map changes made by +# VACUUM truncation when both data checksums and wal_log_hints are disabled. +# The restored map must not retain set bits for truncated heap pages. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $mode = $ENV{PG_TEST_PG_COMBINEBACKUP_MODE} || '--copy'; + +my $primary = PostgreSQL::Test::Cluster->new('primary'); +# Enable checksums explicitly, then disable them before starting the server. +# This also works when PG_TEST_INITDB_EXTRA_OPTS enables checksums. +$primary->init(allows_streaming => 1, extra => ['--data-checksums']); +$primary->command_ok( + [ 'pg_checksums', '--disable', '--no-sync', '-D', $primary->data_dir ], + 'disable data checksums'); +$primary->append_conf('postgresql.conf', <start; + +# Use uncompressed inline rows large enough to fit only one per heap page. +$primary->safe_psql( + 'postgres', q{ + CREATE EXTENSION pg_visibility; + CREATE TABLE t (id int, pad text STORAGE PLAIN); + INSERT INTO t + SELECT g, repeat('x', current_setting('block_size')::int / 2) + FROM generate_series(1, 8) g; + DELETE FROM t WHERE id > 2; +}); + +# Set both VM bits for all pages, keeping the empty tail pages. +$primary->safe_psql('postgres', 'VACUUM (FREEZE, TRUNCATE false) t'); + +my $heap_blocks_sql = + q{SELECT pg_relation_size('t') / current_setting('block_size')::int}; + +# Check all eight original heap blocks, including those removed by truncation. +my $vm_summary_sql = q{ + SELECT count(*) FILTER (WHERE all_visible), count(*) FILTER (WHERE all_frozen) + FROM generate_series(0, 7) AS b, pg_visibility_map('t', b)}; + +is($primary->safe_psql('postgres', $heap_blocks_sql), + '8', 'empty tail pages remain before full backup'); +is($primary->safe_psql('postgres', $vm_summary_sql), + '8|8', 'all pages have both VM bits set before full backup'); + +$primary->backup('full'); + +$primary->safe_psql('postgres', 'VACUUM (TRUNCATE) t'); +is($primary->safe_psql('postgres', $heap_blocks_sql), + '2', 'VACUUM truncates the empty tail pages'); +is($primary->safe_psql('postgres', $vm_summary_sql), + '2|2', 'VACUUM clears VM bits for the removed heap pages'); +is( $primary->safe_psql( + 'postgres', + q{SELECT pg_relation_size('t', 'vm') / current_setting('block_size')::int} + ), + '1', + 'VM fork still contains one page after truncation'); + +my $full_backup = $primary->backup_dir . '/full'; +$primary->backup('incr', + backup_options => [ '--incremental', "$full_backup/backup_manifest" ]); +$primary->stop; + +my $restored = PostgreSQL::Test::Cluster->new('restored'); +$restored->init_from_backup( + $primary, 'incr', + combine_with_prior => ['full'], + combine_mode => $mode); +$restored->start; + +# Count all VM bits, including those beyond the end of the heap, to +# detect stale bits inherited from the full backup. +is($restored->safe_psql('postgres', $heap_blocks_sql), + '2', 'restored heap has the truncated length'); +is($restored->safe_psql('postgres', $vm_summary_sql), + '2|2', 'restored VM has no stale bits for removed heap pages'); + +$restored->stop; +done_testing(); -- 2.56.0