From 6bd2254a98fd7293c03041d45d57cc5a02ffb446 Mon Sep 17 00:00:00 2001 From: shihao zhong Date: Tue, 20 Oct 2026 21:53:23 -0400 Subject: [PATCH 1/2] Always WAL-log the VM page changed by truncation visibilitymap_prepare_truncate() clears bits in the last remaining visibility map page, but logged that page only when hint bits are WAL-logged. Without that, an incremental backup misses the change and the restored cluster keeps stale all-visible bits. Backpatch-through: 17 --- src/backend/access/heap/visibilitymap.c | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/backend/access/heap/visibilitymap.c b/src/backend/access/heap/visibilitymap.c index 2ac01432a49..813ac035cfd 100644 --- a/src/backend/access/heap/visibilitymap.c +++ b/src/backend/access/heap/visibilitymap.c @@ -532,14 +532,15 @@ visibilitymap_prepare_truncate(Relation rel, BlockNumber nheapblocks) /* * Truncation of a relation is WAL-logged at a higher-level, and we - * will be called at WAL replay. But if checksums are enabled, we need - * to still write a WAL record to protect against a torn page, if the - * page is flushed to disk before the truncation WAL record. We cannot - * use MarkBufferDirtyHint here, because that will not dirty the page - * during recovery. + * will be called at WAL replay. But the truncation WAL record does + * not reference this page, so we still log it here. WAL summaries + * for incremental backup need that, and so does torn page protection + * if the page is flushed to disk before the truncation WAL record. We + * cannot use MarkBufferDirtyHint here, because that will not dirty + * the page during recovery. */ MarkBufferDirty(mapBuffer); - if (!InRecovery && RelationNeedsWAL(rel) && XLogHintBitIsNeeded()) + if (!InRecovery && RelationNeedsWAL(rel)) log_newpage_buffer(mapBuffer, false); END_CRIT_SECTION(); -- 2.37.1 (Apple Git-137.1) From 0ec71ec806661659a32bc1ad601a1d1042201e67 Mon Sep 17 00:00:00 2001 From: shihao zhong Date: Sun, 11 Oct 2026 09:25:36 -0400 Subject: [PATCH 2/2] Test incremental backup after a visibility map truncation Add a truncation case to 012_vm_consistency.pl. The test cluster now runs without wal_log_hints, since that setting hides the problem. --- .../pg_combinebackup/t/012_vm_consistency.pl | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/bin/pg_combinebackup/t/012_vm_consistency.pl b/src/bin/pg_combinebackup/t/012_vm_consistency.pl index 6bf47ebec37..7731e6c6af2 100644 --- a/src/bin/pg_combinebackup/t/012_vm_consistency.pl +++ b/src/bin/pg_combinebackup/t/012_vm_consistency.pl @@ -3,7 +3,8 @@ # Test that heap operations clearing visibility map bits (INSERT, UPDATE, # DELETE, SELECT FOR UPDATE, COPY) correctly register visibility map buffers, # since incremental backups rely on the WAL summarizer, which only tracks -# registered buffers. +# registered buffers. Also test that the WAL summarizer tracks the VM page +# whose tail bits are cleared by a heap truncation. use strict; use warnings FATAL => 'all'; @@ -14,12 +15,15 @@ use Test::More; my $tempdir = PostgreSQL::Test::Utils::tempdir_short(); my $mode = $ENV{PG_TEST_PG_COMBINEBACKUP_MODE} || '--copy'; -# Set up primary with WAL summarization enabled. +# Set up primary with WAL summarization enabled. wal_log_hints is off +# because with it, or with data checksums, truncation logs an image of the +# VM page it modifies, which would hide what the TRUNCATE test checks. my $primary = PostgreSQL::Test::Cluster->new('primary'); $primary->init(allows_streaming => 1); $primary->append_conf('postgresql.conf', <start; @@ -93,6 +97,25 @@ my @tests = ( visible_op => '<', frozen_op => '<', }, + { + label => 'TRUNCATE', + table => 'vm_truncate_test', + # vacuum_truncate = off keeps the empty tail around until the + # explicit VACUUM (TRUNCATE), so that the truncation is the only + # change to the VM between the two backups. + setup => q{CREATE TABLE vm_truncate_test (id int) + WITH (vacuum_truncate = off); + INSERT INTO vm_truncate_test SELECT generate_series(1, 2000); + DELETE FROM vm_truncate_test WHERE id > 1000;}, + modify => q{VACUUM (TRUNCATE) vm_truncate_test}, + # Extend the restored table again with rows nobody can see. Stale VM + # bits for the truncated blocks would cover these new pages. + post_restore => q{BEGIN; + INSERT INTO vm_truncate_test SELECT generate_series(1, 1000); + ROLLBACK;}, + visible_op => '<', + frozen_op => '<', + }, ); sub get_vm_summary @@ -162,6 +185,9 @@ sub validate_restored_vm is($restored_frozen, $primary_frozen, "$test->{label} test: restored all_frozen count matches primary"); + $restored->safe_psql('postgres', $test->{post_restore}) + if defined $test->{post_restore}; + my $corrupt_tids = $restored->safe_psql('postgres', "SELECT count(*) FROM pg_check_visible('$test->{table}')"); is($corrupt_tids, '0', -- 2.37.1 (Apple Git-137.1)