From 584dde628233e2f3533c1bf9a95a8e15f43e072a Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <andrew@dunslane.net>
Date: Thu, 11 Jun 2026 15:22:19 -0400
Subject: [PATCH 1/2] Allow tuple visibility checks without hint-bit
 maintenance

HeapTupleSatisfiesVisibility(), HeapTupleSatisfiesUpdate(), and the
other HeapTupleSatisfies* functions may write hint bits to the tuple's
page via the buffer argument. A table AM whose pages are WAL-logged by
some scheme other than heapam's (such as generic WAL) can't tolerate
that: an unlogged write between two WAL operations invalidates the
before-image a later delta is computed against, and standby replay
detects a corrupt page.

Add HeapTupleSatisfiesVisibilityNoHints() and
HeapTupleSatisfiesUpdateNoHints(), which compute the same result but
never touch the page. They pass a private sentinel, NoHintBitsBuffer,
that SetHintBitsExt() recognises and skips on.

NoHintBitsBuffer is negative, so it passes BufferIsLocal(). The one
other place a buffer is used for something besides hint bits is
SNAPSHOT_HISTORIC_MVCC, which needs it to recover the tuple's
relfilelocator in ResolveCminCmaxDuringDecoding(); with
NoHintBitsBuffer that overflows and reads wild memory in a non-assert
build. Reject that snapshot type in
HeapTupleSatisfiesVisibilityNoHints(), and document the
BufferIsLocal() hazard on NoHintBitsBuffer itself. In practice the
reject shouldn't fire: SNAPSHOT_HISTORIC_MVCC is only used for
logical decoding's catalog lookups, always against pg_catalog, which
is always heap.
---
 src/backend/access/heap/heapam_visibility.c | 66 +++++++++++++++++++++
 src/include/access/heapam.h                 |  4 ++
 2 files changed, 70 insertions(+)

diff --git a/src/backend/access/heap/heapam_visibility.c b/src/backend/access/heap/heapam_visibility.c
index 361b76e5065..0785d46ef4d 100644
--- a/src/backend/access/heap/heapam_visibility.c
+++ b/src/backend/access/heap/heapam_visibility.c
@@ -98,6 +98,24 @@ typedef enum SetHintBitsState
 	SHB_ENABLED,
 } SetHintBitsState;
 
+/*
+ * Buffer value that tells SetHintBitsExt() to skip hint-bit maintenance
+ * entirely.  Private to this file; reached only via the NoHints wrapper
+ * functions below.  Not InvalidBuffer, and outside the range of valid
+ * buffer identifiers.
+ *
+ * Beware that it is not outside the range BufferIsLocal() accepts: that
+ * macro is just "buffer < 0", so this value looks like a local buffer to
+ * it, and anything that then indexes a local buffer array with
+ * -buffer - 1 will overflow and read wild memory.  SetHintBitsExt() must
+ * therefore test for this value before touching the buffer manager, and
+ * any code added to the HeapTupleSatisfies* functions that uses the
+ * buffer for something other than hint bits has to cope with it too --
+ * see HeapTupleSatisfiesVisibilityNoHints(), which rejects
+ * SNAPSHOT_HISTORIC_MVCC for exactly that reason.
+ */
+#define NoHintBitsBuffer	((Buffer) PG_INT32_MIN)
+
 /*
  * SetHintBitsExt()
  *
@@ -142,6 +160,10 @@ static inline void
 SetHintBitsExt(HeapTupleHeader tuple, Buffer buffer,
 			   uint16 infomask, TransactionId xid, SetHintBitsState *state)
 {
+	/* Caller asked us not to touch the page; see NoHintBitsBuffer above. */
+	if (buffer == NoHintBitsBuffer)
+		return;
+
 	/*
 	 * In batched mode, if we previously did not get permission to set hint
 	 * bits, don't try again - in all likelihood IO is still going on.
@@ -735,6 +757,18 @@ HeapTupleSatisfiesUpdate(HeapTuple htup, CommandId curcid,
 		return TM_Deleted;		/* deleted by other */
 }
 
+/*
+ * HeapTupleSatisfiesUpdateNoHints
+ *		Like HeapTupleSatisfiesUpdate(), but never writes hint bits.
+ *
+ * See HeapTupleSatisfiesVisibilityNoHints().
+ */
+TM_Result
+HeapTupleSatisfiesUpdateNoHints(HeapTuple htup, CommandId curcid)
+{
+	return HeapTupleSatisfiesUpdate(htup, curcid, NoHintBitsBuffer);
+}
+
 /*
  * HeapTupleSatisfiesDirty
  *		True iff heap tuple is valid including effects of open transactions.
@@ -1751,3 +1785,35 @@ HeapTupleSatisfiesVisibility(HeapTuple htup, Snapshot snapshot, Buffer buffer)
 
 	return false;				/* keep compiler quiet */
 }
+
+/*
+ * HeapTupleSatisfiesVisibilityNoHints
+ *		Like HeapTupleSatisfiesVisibility(), but never writes hint bits.
+ *
+ * For table AMs whose pages must not be modified outside their own WAL
+ * scheme (e.g. generic WAL).  The verdict is unaffected, since hint bits
+ * are only a cache of pg_xact state.
+ *
+ * SNAPSHOT_HISTORIC_MVCC is not supported here: HeapTupleSatisfiesHistoricMVCC()
+ * needs the buffer for more than hint bits, namely to recover the tuple's
+ * relfilelocator for the combo CID lookup in ResolveCminCmaxDuringDecoding().
+ * There is no buffer to give it, so reject that snapshot type rather than let
+ * NoHintBitsBuffer reach BufferGetTag().
+ *
+ * In practice a table AM's own tuple_satisfies_snapshot callback should
+ * never see SNAPSHOT_HISTORIC_MVCC: it is only installed by
+ * SetupHistoricSnapshot() for the logical-decoding catalog lookups that
+ * relcache.c performs to interpret schema changes mid-transaction, and
+ * those lookups are always against pg_catalog, which is always heap.
+ * Decoding itself reconstructs tuples from the WAL record rather than
+ * scanning the user's table.  The reject above is a backstop, not a
+ * restriction expected to fire.
+ */
+bool
+HeapTupleSatisfiesVisibilityNoHints(HeapTuple htup, Snapshot snapshot)
+{
+	if (snapshot->snapshot_type == SNAPSHOT_HISTORIC_MVCC)
+		elog(ERROR, "historic MVCC snapshots require a buffer");
+
+	return HeapTupleSatisfiesVisibility(htup, snapshot, NoHintBitsBuffer);
+}
diff --git a/src/include/access/heapam.h b/src/include/access/heapam.h
index 5176478c295..77355bd489c 100644
--- a/src/include/access/heapam.h
+++ b/src/include/access/heapam.h
@@ -478,8 +478,12 @@ extern bool heap_page_is_all_visible(Relation rel, Buffer buf,
 /* in heap/heapam_visibility.c */
 extern bool HeapTupleSatisfiesVisibility(HeapTuple htup, Snapshot snapshot,
 										 Buffer buffer);
+extern bool HeapTupleSatisfiesVisibilityNoHints(HeapTuple htup,
+												Snapshot snapshot);
 extern TM_Result HeapTupleSatisfiesUpdate(HeapTuple htup, CommandId curcid,
 										  Buffer buffer);
+extern TM_Result HeapTupleSatisfiesUpdateNoHints(HeapTuple htup,
+												 CommandId curcid);
 extern HTSV_Result HeapTupleSatisfiesVacuum(HeapTuple htup, TransactionId OldestXmin,
 											Buffer buffer);
 extern HTSV_Result HeapTupleSatisfiesVacuumHorizon(HeapTuple htup, Buffer buffer,
-- 
2.43.0

