From db51e97460698b37e7c44bf6d5268e5f353410f3 Mon Sep 17 00:00:00 2001 From: Fujii Masao Date: Thu, 22 Oct 2026 20:58:56 +0900 Subject: [PATCH v1] 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 Reviewed-by: Fujii Masao Discussion: https://postgr.es/m/19775-087aebfa1a709b09@postgresql.org Backpatch-through: 17 --- src/backend/postmaster/walsummarizer.c | 17 +++- src/bin/pg_combinebackup/meson.build | 1 + .../pg_combinebackup/t/013_vm_truncation.pl | 81 +++++++++++++++++++ 3 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 src/bin/pg_combinebackup/t/013_vm_truncation.pl diff --git a/src/backend/postmaster/walsummarizer.c b/src/backend/postmaster/walsummarizer.c index 8333a4eaaa3..b4ef9937499 100644 --- a/src/backend/postmaster/walsummarizer.c +++ b/src/backend/postmaster/walsummarizer.c @@ -1457,9 +1457,22 @@ 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 bits in the last remaining + * VM page without WAL-logging the page. Conservatively mark it as + * modified so that incremental backups include its current contents. + */ + 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 ba1c8cfa3d0..ddc39145822 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_vm_truncation.pl', ], } } diff --git a/src/bin/pg_combinebackup/t/013_vm_truncation.pl b/src/bin/pg_combinebackup/t/013_vm_truncation.pl new file mode 100644 index 00000000000..b313c2ebe81 --- /dev/null +++ b/src/bin/pg_combinebackup/t/013_vm_truncation.pl @@ -0,0 +1,81 @@ +# 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'); +$primary->init(allows_streaming => 1, no_data_checksums => 1); +$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 the empty tail pages without truncating them. +$primary->safe_psql('postgres', 'VACUUM (FREEZE, TRUNCATE false) t'); + +my $heap_blocks_sql = + q{SELECT pg_relation_size('t') / current_setting('block_size')::int}; +my $vm_summary_sql = + q{SELECT all_visible, all_frozen FROM pg_visibility_map_summary('t')}; + +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