From 687fb98240d315082dc47687ad7ac6f119956909 Mon Sep 17 00:00:00 2001
From: Melanie Plageman <melanieplageman@gmail.com>
Date: Thu, 24 Sep 2026 16:42:36 -0400
Subject: [PATCH v1 01/11] Detect and repair a stale all-frozen visibility map
 bit

visibilitymap_set() only ORs in the requested bits. If vacuum finds that
every tuple on a page is visible but not every tuple is frozen, setting
all-visible cannot repair an incorrectly set all-frozen bit. The function
marks the VM buffer dirty when the requested flags differ from the existing
bits, even if the OR does not change those bits, so dirtying the buffer
alone does not correct the corruption.

Add a VM_CORRUPT_STALE_ALL_FROZEN case to heap_page_fix_vm_corruption()
that warns and clears the VM bits when a full scan establishes that the
page will remain all-visible but not all-frozen. The normal path then sets
all-visible again without retaining the stale all-frozen bit.

Backpatch-through: 19
---
 src/backend/access/heap/pruneheap.c | 33 +++++++++++++++++++++++++++++
 1 file changed, 33 insertions(+)

diff --git a/src/backend/access/heap/pruneheap.c b/src/backend/access/heap/pruneheap.c
index 98fba4bb7c1..a8e17fcc8d2 100644
--- a/src/backend/access/heap/pruneheap.c
+++ b/src/backend/access/heap/pruneheap.c
@@ -196,6 +196,8 @@ typedef enum VMCorruptionType
 	VM_CORRUPT_LPDEAD,
 	/* Tuple not visible to all transactions on a page marked all-visible */
 	VM_CORRUPT_TUPLE_VISIBILITY,
+	/* Page marked all-frozen in the VM but not actually all-frozen */
+	VM_CORRUPT_STALE_ALL_FROZEN,
 } VMCorruptionType;
 
 /* Local functions */
@@ -944,6 +946,23 @@ heap_page_fix_vm_corruption(PruneState *prstate, OffsetNumber offnum,
 								relname, prstate->block)));
 			do_clear_vm = true;
 			break;
+
+		case VM_CORRUPT_STALE_ALL_FROZEN:
+
+			/*
+			 * We examined every tuple on the page and found that the page is
+			 * all-visible but not all-frozen, yet the VM marks it all-frozen.
+			 * The page-level PD_ALL_VISIBLE flag is still correct, so only
+			 * the VM is wrong. Clear both the VM bits. All-visible will be
+			 * set again through the normal path.
+			 */
+			ereport(WARNING,
+					(errcode(ERRCODE_DATA_CORRUPTED),
+					 errmsg("page marked all-frozen in the visibility map is not all-frozen"),
+					 errcontext("relation \"%s\", page %u",
+								relname, prstate->block)));
+			do_clear_vm = true;
+			break;
 	}
 
 	Assert(do_clear_heap || do_clear_vm);
@@ -1259,6 +1278,20 @@ heap_page_prune_and_freeze(PruneFreezeParams *params,
 	Assert(!prstate.set_all_visible || prstate.attempt_set_vm);
 	Assert(!prstate.set_all_visible || (prstate.lpdead_items == 0));
 
+	/*
+	 * If we examined every tuple on the page and found that the page is
+	 * all-visible but not all-frozen, yet the VM marks it all-frozen, that
+	 * all-frozen bit is corrupt. Repair the VM. We will set it back to
+	 * all-visible later along with the other changes. Note that this must be
+	 * done after set_all_visible and set_all_frozen are finalized above to
+	 * account for dead items and unfrozen tuples.
+	 */
+	if (prstate.attempt_freeze && prstate.set_all_visible &&
+		!prstate.set_all_frozen &&
+		(prstate.old_vmbits & VISIBILITYMAP_ALL_FROZEN))
+		heap_page_fix_vm_corruption(&prstate, InvalidOffsetNumber,
+									VM_CORRUPT_STALE_ALL_FROZEN);
+
 	do_set_vm = heap_page_will_set_vm(&prstate, params->reason, do_prune, do_freeze);
 
 	/*
-- 
2.43.0

