From e054e2c477c2e8303d70635efd174334f9342e71 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 | 85 +++++++++++++++++++ 3 files changed, 103 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 8333a4eaaa3..cf78a29d3f0 100644 --- a/src/backend/postmaster/walsummarizer.c +++ b/src/backend/postmaster/walsummarizer.c @@ -1457,9 +1457,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 10702f36a5d..64da66b338e 100644 --- a/src/bin/pg_combinebackup/meson.build +++ b/src/bin/pg_combinebackup/meson.build @@ -41,6 +41,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..557ac190d71 --- /dev/null +++ b/src/bin/pg_combinebackup/t/014_vm_truncation.pl @@ -0,0 +1,85 @@ +# 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 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