From 6bd2254a98fd7293c03041d45d57cc5a02ffb446 Mon Sep 17 00:00:00 2001
From: shihao zhong <zhong950419@gmail.com>
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 <zhong950419@gmail.com>
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', <<EOF);
 summarize_wal = on
 autovacuum = off
+wal_log_hints = off
 EOF
 $primary->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)

