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
