From f9d9641d6612fdc3f44da9cfe388658f8417fcb4 Mon Sep 17 00:00:00 2001
From: Melanie Plageman <melanieplageman@gmail.com>
Date: Tue, 13 Oct 2026 14:50:04 -0400
Subject: [PATCH v5] Forget invalid VM pages during truncation replay

Commit ed62d26caca made heap records that clear visibility map bits
register VM blocks, causing redo to read those blocks with RBM_NORMAL.
If such a record is replayed after the VM fork was already truncated on
disk, recovery records a missing-page reference. Replaying the
truncation only forgot missing pages from the main fork, so recovery
later PANICs with unresolved invalid pages.

Call XLogTruncateRelation() for the VM fork when replaying a VM
truncation.

Reported-by: Jacky Nguyen <nktpro@gmail.com>
Reviewed-by: Kirill Reshke <reshkekirill@gmail.com>
Reviewed-by: Shihao Zhong <zhong950419@gmail.com>
Reviewed-by: Rahul Yadav <rahul@rhyadav.dev>
Discussion: https://postgr.es/m/CAL4mQLAp562c1rCgg2Dqx6TBSdOk6vOvaLFjwFG9E6k4uwvJJw%40mail.gmail.com
Backpatch-through: 17
---
 .../t/001_concurrent_transaction.pl           | 49 +++++++++++++++++++
 src/backend/catalog/storage.c                 | 12 ++++-
 2 files changed, 59 insertions(+), 2 deletions(-)

diff --git a/contrib/pg_visibility/t/001_concurrent_transaction.pl b/contrib/pg_visibility/t/001_concurrent_transaction.pl
index 9911720984e..6df434bf5d4 100644
--- a/contrib/pg_visibility/t/001_concurrent_transaction.pl
+++ b/contrib/pg_visibility/t/001_concurrent_transaction.pl
@@ -3,6 +3,9 @@
 
 # Check that a concurrent transaction doesn't cause false negatives in
 # pg_check_visible() function
+#
+# Also check that a standby can restart after replaying a VM clear followed by
+# a VM truncation.
 use strict;
 use warnings FATAL => 'all';
 use PostgreSQL::Test::Cluster;
@@ -13,6 +16,11 @@ use Test::More;
 # Initialize the primary node
 my $node = PostgreSQL::Test::Cluster->new('main');
 $node->init(allows_streaming => 1);
+$node->append_conf(
+	'postgresql.conf', qq{
+autovacuum = off
+checkpoint_timeout = 1h
+});
 $node->start;
 
 # Initialize the streaming standby
@@ -57,6 +65,47 @@ ok($result eq "", "pg_check_visible() detects no errors");
 # Shutdown
 $bsession->query_safe("COMMIT;");
 $bsession->quit;
+
+# A WAL record clearing a VM bit registers the VM block.  Make sure a standby
+# that crashes after the VM fork was truncated, and so replays the clear again
+# against a missing VM block, doesn't leave an unresolved invalid-page
+# reference behind: the VM truncation record must forget it.
+$node->safe_psql(
+	"postgres",
+	"CREATE TABLE vm_truncate_test AS SELECT generate_series(1, 500) i;\n"
+	  . "VACUUM (FREEZE) vm_truncate_test;");
+
+# Move the standby's restart point past the VM set but before the VM clear.
+$node->safe_psql("postgres", "CHECKPOINT;");
+$node->wait_for_catchup($standby);
+$standby->safe_psql("postgres", "CHECKPOINT;");
+
+# Without full page images, the VM clear is replayed as a plain delta.
+$node->append_conf('postgresql.conf', "full_page_writes = off");
+$node->reload;
+$node->poll_query_until(
+	"postgres",
+	q{SELECT current_setting('full_page_writes') = 'off';});
+
+$node->safe_psql(
+	"postgres",
+	"UPDATE vm_truncate_test SET i = 0 WHERE i = 1;\n"
+	  . "SELECT pg_truncate_visibility_map('vm_truncate_test');");
+$node->wait_for_catchup($standby);
+
+is( $standby->safe_psql(
+		"postgres", "SELECT pg_relation_size('vm_truncate_test', 'vm');"),
+	'0',
+	"VM fork truncated on standby");
+
+# Crash the standby; redo restarts before the VM clear.
+$standby->stop('immediate');
+my $log_offset = -s $standby->logfile;
+ok($standby->start(fail_ok => 1), "standby restarts after VM truncation");
+ok( !$standby->log_contains(
+		qr/WAL contains references to invalid pages/, $log_offset),
+	"no invalid-page PANIC during recovery");
+
 $node->stop;
 $standby->stop;
 
diff --git a/src/backend/catalog/storage.c b/src/backend/catalog/storage.c
index 85d1219b235..3533ca7fcfa 100644
--- a/src/backend/catalog/storage.c
+++ b/src/backend/catalog/storage.c
@@ -1059,10 +1059,18 @@ smgr_redo(XLogReaderState *record)
 				need_fsm_vacuum = true;
 			}
 		}
-		if ((xlrec->flags & SMGR_TRUNCATE_VM) != 0 &&
-			smgrexists(reln, VISIBILITYMAP_FORKNUM))
+		if ((xlrec->flags & SMGR_TRUNCATE_VM) != 0)
 		{
+			/*
+			 * Also tell xlogutils.c about it. Do this even if the VM fork is
+			 * already short enough (e.g. replaying again after a crash), as
+			 * earlier records may have logged invalid pages for it.
+			 */
+			XLogTruncateRelation(xlrec->rlocator, VISIBILITYMAP_FORKNUM,
+								 visibilitymap_truncation_length(xlrec->blkno));
+
 			blocks[nforks] = visibilitymap_prepare_truncate(rel, xlrec->blkno);
+
 			if (BlockNumberIsValid(blocks[nforks]))
 			{
 				forks[nforks] = VISIBILITYMAP_FORKNUM;
-- 
2.43.0

