Hi,

Table AMs that store heap-format tuples on pages managed by another WAL
scheme (generic WAL, for example) can't tolerate
HeapTupleSatisfiesVisibility() and friends opportunistically writing
hint bits to the buffer: an unlogged write between two WAL operations
invalidates the before-image a later generic-WAL delta is computed
against, and standby replay ends up with a corrupt page.

The attached patch adds HeapTupleSatisfiesVisibilityNoHints() and
HeapTupleSatisfiesUpdateNoHints(), thin wrappers that return the same
verdict without touching the page. The sentinel that makes this work
(NoHintBitsBuffer) stays private to heapam_visibility.c.

Since NoHintBitsBuffer is negative, it passes BufferIsLocal(), so any
code reached from the wrappers that uses the buffer for something
besides hint bits needs to know about it. The one such case is
SNAPSHOT_HISTORIC_MVCC, which needs the buffer to recover the tuple's
relfilelocator; HeapTupleSatisfiesVisibilityNoHints() rejects that
snapshot type outright rather than let the sentinel reach it. In
practice this shouldn't fire: that snapshot type is only used for
logical decoding's catalog lookups, always against pg_catalog, which
is always heap.

(Thanks to Euler Taveira, who helped me with this, particularly with
criticizing an earlier and more invasive proposal.)


cheers


andrew

--
Andrew Dunstan
EDB: https://www.enterprisedb.com
From 584dde628233e2f3533c1bf9a95a8e15f43e072a Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <[email protected]>
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

Reply via email to