From a8de71899d02a44662f101418baf73e9ddc35464 Mon Sep 17 00:00:00 2001
From: Andrey Borodin <amborodin@acm.org>
Date: Tue, 4 Aug 2026 14:00:52 +0500
Subject: [PATCH v1] Reduce WAL volume for heap tuple hint bits

Heap tuple visibility hint bits avoid repeated transaction status lookups.
With wal_log_hints enabled, PostgreSQL currently logs a full-page image
for the first hint bit change to a page after each checkpoint.  This can
generate much more WAL than the hint bits themselves require.

Store tuple offsets and visibility hint bits in a compact WAL record when
checksums are disabled.  WAL replay applies the hints on standbys.  Do not
advance the page LSN, so a later ordinary change still generates the
full-page image required by full_page_writes.  The block reference also
preserves pg_rewind support.

Continue to use full-page images when data checksums are enabled, since a
record without an FPI cannot protect against torn page writes.
---
 doc/src/sgml/config.sgml                      |  13 +-
 src/backend/access/heap/Makefile              |   1 +
 src/backend/access/heap/heapam_hint.c         | 142 ++++++++++++++++++
 src/backend/access/heap/heapam_visibility.c   |  19 ++-
 src/backend/access/heap/meson.build           |   1 +
 src/backend/access/rmgrdesc/Makefile          |   1 +
 src/backend/access/rmgrdesc/heap_hintdesc.c   |  37 +++++
 src/backend/access/rmgrdesc/meson.build       |   1 +
 src/backend/access/transam/README             |   7 +
 src/backend/access/transam/rmgr.c             |   1 +
 src/backend/storage/buffer/bufmgr.c           |  96 +++++++++---
 src/backend/storage/page/README               |  10 +-
 src/backend/utils/misc/guc_parameters.dat     |   2 +-
 src/backend/utils/misc/postgresql.conf.sample |   2 +-
 src/bin/pg_waldump/rmgrdesc.c                 |   1 +
 src/include/access/heapam_hint.h              |  46 ++++++
 src/include/access/rmgrlist.h                 |   1 +
 src/include/storage/bufmgr.h                  |   7 +
 src/test/recovery/Makefile                    |   3 +-
 src/test/recovery/meson.build                 |   1 +
 src/test/recovery/t/056_heap_hint_wal.pl      | 111 ++++++++++++++
 21 files changed, 459 insertions(+), 44 deletions(-)
 create mode 100644 src/backend/access/heap/heapam_hint.c
 create mode 100644 src/backend/access/rmgrdesc/heap_hintdesc.c
 create mode 100644 src/include/access/heapam_hint.h
 create mode 100644 src/test/recovery/t/056_heap_hint_wal.pl

diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index aa7b1bd75d2..74ffe603c07 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -3634,16 +3634,17 @@ include_dir 'conf.d'
       <listitem>
        <para>
         When this parameter is <literal>on</literal>, the <productname>PostgreSQL</productname>
-        server writes the entire content of each disk page to WAL during the
-        first modification of that page after a checkpoint, even for
-        non-critical modifications of so-called hint bits.
+        server writes information about the first modification of each disk
+        page after a checkpoint to WAL, even for non-critical modifications
+        of so-called hint bits.  Heap tuple visibility hint bits are stored in
+        compact WAL records.  Other hint changes cause the entire page to be
+        logged.
        </para>
 
        <para>
         If data checksums are enabled, hint bit updates are always WAL-logged
-        and this setting is ignored. You can use this setting to test how much
-        extra WAL-logging would occur if your database had data checksums
-        enabled.
+        and this setting is ignored.  Full-page images are still used in that
+        case to protect checksummed pages from torn writes.
        </para>
 
        <para>
diff --git a/src/backend/access/heap/Makefile b/src/backend/access/heap/Makefile
index 1d27ccb916e..a0c7b4626c9 100644
--- a/src/backend/access/heap/Makefile
+++ b/src/backend/access/heap/Makefile
@@ -15,6 +15,7 @@ include $(top_builddir)/src/Makefile.global
 OBJS = \
 	heapam.o \
 	heapam_handler.o \
+	heapam_hint.o \
 	heapam_indexscan.o \
 	heapam_visibility.o \
 	heapam_xlog.o \
diff --git a/src/backend/access/heap/heapam_hint.c b/src/backend/access/heap/heapam_hint.c
new file mode 100644
index 00000000000..b05e87b64a4
--- /dev/null
+++ b/src/backend/access/heap/heapam_hint.c
@@ -0,0 +1,142 @@
+/*-------------------------------------------------------------------------
+ *
+ * heapam_hint.c
+ *	  WAL logging and replay of heap tuple visibility hint bits.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *	  src/backend/access/heap/heapam_hint.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/heapam_hint.h"
+#include "access/xlog.h"
+#include "access/xloginsert.h"
+#include "access/xlogutils.h"
+
+/*
+ * WAL-log heap tuple visibility hint bits.
+ *
+ * Checksums still require a full-page image to protect against torn writes.
+ * Otherwise, store tuple offsets and hint bits in a compact WAL record.  Its
+ * block reference also identifies the changed block for pg_rewind.
+ *
+ * Unlike an FPI_FOR_HINT record, this record does not advance the page LSN.
+ * Hint bits are non-critical changes, so the page need not wait for this
+ * record before being written.  Leaving the LSN alone also ensures that the
+ * next ordinary change after a checkpoint still produces the FPI required by
+ * full_page_writes.
+ */
+XLogRecPtr
+log_heap_hint_bits(Buffer buffer)
+{
+	xl_heap_hint xlrec;
+	xl_heap_hint_tuple tuples[MaxHeapTuplesPerPage];
+	XLogRecPtr	RedoRecPtr;
+	XLogRecPtr	recptr PG_USED_FOR_ASSERTS_ONLY;
+	Page		page = BufferGetPage(buffer);
+	OffsetNumber maxoff = PageGetMaxOffsetNumber(page);
+
+	RedoRecPtr = GetRedoRecPtr();
+	if (PageGetLSN(page) > RedoRecPtr)
+		return InvalidXLogRecPtr;
+
+	/* A record without an FPI cannot protect against torn page writes. */
+	if (DataChecksumsNeedWrite())
+		return XLogSaveBufferForHint(buffer, true);
+
+	xlrec.ntuples = 0;
+	for (OffsetNumber offnum = FirstOffsetNumber;
+		 offnum <= maxoff;
+		 offnum = OffsetNumberNext(offnum))
+	{
+		ItemId		itemid = PageGetItemId(page, offnum);
+		HeapTupleHeader tuple;
+		uint16		infomask;
+
+		if (!ItemIdIsNormal(itemid))
+			continue;
+
+		tuple = (HeapTupleHeader) PageGetItem(page, itemid);
+		infomask = tuple->t_infomask & XL_HEAP_HINT_BITS;
+		if (infomask == 0)
+			continue;
+
+		/* Be defensive about corrupt pages in non-assert builds. */
+		if (xlrec.ntuples == lengthof(tuples))
+			return XLogSaveBufferForHint(buffer, true);
+		tuples[xlrec.ntuples].offnum = offnum;
+		tuples[xlrec.ntuples].infomask = infomask;
+		xlrec.ntuples++;
+	}
+
+	if (xlrec.ntuples == 0)
+		return InvalidXLogRecPtr;
+
+	XLogBeginInsert();
+	XLogRegisterBuffer(0, buffer, REGBUF_STANDARD | REGBUF_NO_IMAGE);
+	XLogRegisterBufData(0, &xlrec, sizeof(xlrec));
+	XLogRegisterBufData(0, tuples,
+						xlrec.ntuples * sizeof(xl_heap_hint_tuple));
+
+	recptr = XLogInsert(RM_HEAP_HINT_ID, XLOG_HEAP_HINT);
+	Assert(XLogRecPtrIsValid(recptr));
+
+	/* The caller must not install this record's LSN on the heap page. */
+	return InvalidXLogRecPtr;
+}
+
+/* Replay a heap hint-bit WAL record. */
+void
+heap_hint_redo(XLogReaderState *record)
+{
+	Buffer		buffer = InvalidBuffer;
+	XLogRedoAction action;
+
+	action = XLogReadBufferForRedo(record, 0, &buffer);
+	if (action == BLK_NEEDS_REDO)
+	{
+		Size		datalen;
+		char	   *data = XLogRecGetBlockData(record, 0, &datalen);
+		xl_heap_hint *xlrec;
+		xl_heap_hint_tuple *tuples;
+		Page		page = BufferGetPage(buffer);
+
+		if (data == NULL || datalen < sizeof(xl_heap_hint))
+			elog(PANIC, "invalid heap hint WAL record");
+
+		xlrec = (xl_heap_hint *) data;
+		if (datalen != sizeof(xl_heap_hint) +
+			xlrec->ntuples * sizeof(xl_heap_hint_tuple))
+			elog(PANIC, "invalid heap hint WAL record length");
+
+		tuples = (xl_heap_hint_tuple *) (data + sizeof(xl_heap_hint));
+		for (int i = 0; i < xlrec->ntuples; i++)
+		{
+			OffsetNumber offnum = tuples[i].offnum;
+			ItemId		itemid;
+			HeapTupleHeader tuple;
+
+			if (offnum < FirstOffsetNumber ||
+				offnum > PageGetMaxOffsetNumber(page))
+				elog(PANIC, "heap hint WAL record offset out of range");
+
+			itemid = PageGetItemId(page, offnum);
+			if (!ItemIdIsNormal(itemid))
+				elog(PANIC, "heap hint WAL record references invalid line pointer");
+			if (tuples[i].infomask & ~XL_HEAP_HINT_BITS)
+				elog(PANIC, "heap hint WAL record contains invalid bits");
+
+			tuple = (HeapTupleHeader) PageGetItem(page, itemid);
+			tuple->t_infomask |= tuples[i].infomask;
+		}
+
+		MarkBufferDirty(buffer);
+	}
+
+	if (BufferIsValid(buffer))
+		UnlockReleaseBuffer(buffer);
+}
diff --git a/src/backend/access/heap/heapam_visibility.c b/src/backend/access/heap/heapam_visibility.c
index 361b76e5065..e785cb837d6 100644
--- a/src/backend/access/heap/heapam_visibility.c
+++ b/src/backend/access/heap/heapam_visibility.c
@@ -67,6 +67,7 @@
 #include "postgres.h"
 
 #include "access/heapam.h"
+#include "access/heapam_hint.h"
 #include "access/htup_details.h"
 #include "access/multixact.h"
 #include "access/tableam.h"
@@ -110,7 +111,7 @@ typedef enum SetHintBitsState
  * The right to set a hint bit can be acquired on a page level with
  * BufferBeginSetHintBits(). Only a single backend gets the right to set hint
  * bits at a time.  Alternatively, if called with a NULL SetHintBitsState*,
- * hint bits are set with BufferSetHintBits16().
+ * hint bits are set with BufferSetHintBits16WithWal().
  *
  * It is only safe to set a transaction-committed hint bit if we know the
  * transaction's commit record is guaranteed to be flushed to disk before the
@@ -166,15 +167,16 @@ SetHintBitsExt(HeapTupleHeader tuple, Buffer buffer,
 	}
 
 	/*
-	 * If we're not operating in batch mode, use BufferSetHintBits16() to mark
-	 * the page dirty, that's cheaper than
-	 * BufferBeginSetHintBits()/BufferFinishSetHintBits(). That's important
-	 * for cases where we set a lot of hint bits on a page individually.
+	 * If we're not operating in batch mode, use BufferSetHintBits16WithWal()
+	 * to mark the page dirty.  That's cheaper than BufferBeginSetHintBits() /
+	 * BufferFinishSetHintBitsWithWal(), which is important for cases where we
+	 * set a lot of hint bits on a page individually.
 	 */
 	if (!state)
 	{
-		BufferSetHintBits16(&tuple->t_infomask,
-							tuple->t_infomask | infomask, buffer);
+		BufferSetHintBits16WithWal(&tuple->t_infomask,
+								   tuple->t_infomask | infomask, buffer,
+								   log_heap_hint_bits);
 		return;
 	}
 
@@ -1713,7 +1715,8 @@ HeapTupleSatisfiesMVCCBatch(Snapshot snapshot, Buffer buffer,
 	}
 
 	if (state == SHB_ENABLED)
-		BufferFinishSetHintBits(buffer, true, true);
+		BufferFinishSetHintBitsWithWal(buffer, true, true,
+									   log_heap_hint_bits);
 
 	return nvis;
 }
diff --git a/src/backend/access/heap/meson.build b/src/backend/access/heap/meson.build
index 00ec07d7f30..f93f1aa2214 100644
--- a/src/backend/access/heap/meson.build
+++ b/src/backend/access/heap/meson.build
@@ -3,6 +3,7 @@
 backend_sources += files(
   'heapam.c',
   'heapam_handler.c',
+  'heapam_hint.c',
   'heapam_indexscan.c',
   'heapam_visibility.c',
   'heapam_xlog.c',
diff --git a/src/backend/access/rmgrdesc/Makefile b/src/backend/access/rmgrdesc/Makefile
index cd95eec37f1..20c0592297f 100644
--- a/src/backend/access/rmgrdesc/Makefile
+++ b/src/backend/access/rmgrdesc/Makefile
@@ -17,6 +17,7 @@ OBJS = \
 	gindesc.o \
 	gistdesc.o \
 	hashdesc.o \
+	heap_hintdesc.o \
 	heapdesc.o \
 	logicalmsgdesc.o \
 	mxactdesc.o \
diff --git a/src/backend/access/rmgrdesc/heap_hintdesc.c b/src/backend/access/rmgrdesc/heap_hintdesc.c
new file mode 100644
index 00000000000..d921f5afc14
--- /dev/null
+++ b/src/backend/access/rmgrdesc/heap_hintdesc.c
@@ -0,0 +1,37 @@
+/*-------------------------------------------------------------------------
+ *
+ * heap_hintdesc.c
+ *	  rmgr descriptor routines for heap hint-bit WAL records.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *	  src/backend/access/rmgrdesc/heap_hintdesc.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/heapam_hint.h"
+
+void
+heap_hint_desc(StringInfo buf, XLogReaderState *record)
+{
+	if (XLogRecHasBlockData(record, 0))
+	{
+		Size		datalen;
+		xl_heap_hint *xlrec = (xl_heap_hint *)
+			XLogRecGetBlockData(record, 0, &datalen);
+
+		appendStringInfo(buf, "ntuples: %u", xlrec->ntuples);
+	}
+}
+
+const char *
+heap_hint_identify(uint8 info)
+{
+	if ((info & ~XLR_INFO_MASK) == XLOG_HEAP_HINT)
+		return "HINT";
+
+	return NULL;
+}
diff --git a/src/backend/access/rmgrdesc/meson.build b/src/backend/access/rmgrdesc/meson.build
index d9000ccd9fd..62a1da4d743 100644
--- a/src/backend/access/rmgrdesc/meson.build
+++ b/src/backend/access/rmgrdesc/meson.build
@@ -10,6 +10,7 @@ rmgr_desc_sources = files(
   'gindesc.c',
   'gistdesc.c',
   'hashdesc.c',
+  'heap_hintdesc.c',
   'heapdesc.c',
   'logicalmsgdesc.c',
   'mxactdesc.c',
diff --git a/src/backend/access/transam/README b/src/backend/access/transam/README
index 231106270fd..dc03f617a6f 100644
--- a/src/backend/access/transam/README
+++ b/src/backend/access/transam/README
@@ -641,6 +641,13 @@ that includes the hint. We do this to avoid a partial page write, when we
 write the dirtied page. WAL is not written during recovery, so we simply skip
 dirtying blocks because of hints when in recovery.
 
+When wal_log_hints is enabled without checksums, heap tuple visibility hints
+are stored in compact WAL records containing tuple offsets and hint bits.
+Such a record deliberately does not advance the page LSN.  Its block reference
+is sufficient for pg_rewind, while leaving the LSN unchanged ensures that the
+next ordinary page change still generates the full-page image required after
+a checkpoint.  Other kinds of hints still use XLOG_FPI_FOR_HINT.
+
 If you do decide to optimise away a WAL record, then any calls to
 MarkBufferDirty() must be replaced by MarkBufferDirtyHint(),
 otherwise you will expose the risk of partial page writes.
diff --git a/src/backend/access/transam/rmgr.c b/src/backend/access/transam/rmgr.c
index 4fda03a3cfc..d9501a3837f 100644
--- a/src/backend/access/transam/rmgr.c
+++ b/src/backend/access/transam/rmgr.c
@@ -26,6 +26,7 @@
 #include "access/ginxlog.h"
 #include "access/gistxlog.h"
 #include "access/hash_xlog.h"
+#include "access/heapam_hint.h"
 #include "access/heapam_xlog.h"
 #include "access/multixact.h"
 #include "access/nbtxlog.h"
diff --git a/src/backend/storage/buffer/bufmgr.c b/src/backend/storage/buffer/bufmgr.c
index 169829eb020..f9b2189caf9 100644
--- a/src/backend/storage/buffer/bufmgr.c
+++ b/src/backend/storage/buffer/bufmgr.c
@@ -5717,7 +5717,8 @@ IncrBufferRefCount(Buffer buffer)
  */
 static inline void
 MarkSharedBufferDirtyHint(Buffer buffer, BufferDesc *bufHdr, uint64 lockstate,
-						  bool buffer_std)
+						  bool buffer_std,
+						  BufferHintWalLogger wal_logger)
 {
 	Page		page = BufferGetPage(buffer);
 
@@ -5739,17 +5740,18 @@ MarkSharedBufferDirtyHint(Buffer buffer, BufferDesc *bufHdr, uint64 lockstate,
 	if (unlikely(!(lockstate & BM_DIRTY)))
 	{
 		XLogRecPtr	lsn = InvalidXLogRecPtr;
-		bool		wal_log = false;
+		bool		wal_log_needed = false;
 		uint64		buf_state;
 
 		/*
-		 * If we need to protect hint bit updates from torn writes, WAL-log a
-		 * full page image of the page. This full page image is only necessary
-		 * if the hint bit update is the first change to the page since the
-		 * last checkpoint.
+		 * If we need to WAL-log hint bit updates, log either a full-page
+		 * image or an access-method-specific record.  This is only necessary
+		 * when the hint update is the first change to the page since the last
+		 * checkpoint.
 		 *
-		 * We don't check full_page_writes here because that logic is included
-		 * when we call XLogInsert() since the value changes dynamically.
+		 * XLogSaveBufferForHint() leaves the dynamic full_page_writes check
+		 * to XLogInsert().  An alternative logger is responsible for deciding
+		 * whether its change needs a full-page image.
 		 */
 		if (XLogHintBitIsNeeded() && (lockstate & BM_PERMANENT))
 		{
@@ -5765,17 +5767,17 @@ MarkSharedBufferDirtyHint(Buffer buffer, BufferDesc *bufHdr, uint64 lockstate,
 				RelFileLocatorSkippingWAL(BufTagGetRelFileLocator(&bufHdr->tag)))
 				return;
 
-			wal_log = true;
+			wal_log_needed = true;
 		}
 
 		/*
-		 * We must mark the page dirty before we emit the WAL record, as per
-		 * the usual rules, to ensure that BufferSync()/SyncOneBuffer() try to
-		 * flush the buffer, even if we haven't inserted the WAL record yet.
-		 * As we hold at least a share-exclusive lock, checkpoints will wait
-		 * for this backend to be done with the buffer before continuing. If
-		 * we did it the other way round, a checkpoint could start between
-		 * writing the WAL record and marking the buffer dirty.
+		 * We must mark the page dirty before we emit any WAL record to ensure
+		 * that BufferSync()/SyncOneBuffer() tries to flush the buffer, even
+		 * if we haven't inserted the WAL record yet. As we hold at least a
+		 * share-exclusive lock, checkpoints will wait for this backend to be
+		 * done with the buffer before continuing. If we did it the other way
+		 * round, a checkpoint could start between writing the WAL record and
+		 * marking the buffer dirty.
 		 */
 		buf_state = LockBufHdr(bufHdr);
 
@@ -5791,13 +5793,14 @@ MarkSharedBufferDirtyHint(Buffer buffer, BufferDesc *bufHdr, uint64 lockstate,
 
 		/*
 		 * If the block is already dirty because we either made a change or
-		 * set a hint already, then we don't need to write a full page image.
+		 * set a hint already, then we don't need to write another WAL record.
 		 * Note that aggressive cleaning of blocks dirtied by hint bit setting
 		 * would increase the call rate. Bulk setting of hint bits would
 		 * reduce the call rate...
 		 */
-		if (wal_log)
-			lsn = XLogSaveBufferForHint(buffer, buffer_std);
+		if (wal_log_needed)
+			lsn = wal_logger ? wal_logger(buffer) :
+				XLogSaveBufferForHint(buffer, buffer_std);
 
 		if (XLogRecPtrIsValid(lsn))
 		{
@@ -5832,7 +5835,7 @@ MarkSharedBufferDirtyHint(Buffer buffer, BufferDesc *bufHdr, uint64 lockstate,
  *
  * This is essentially the same as MarkBufferDirty, except:
  *
- * 1. The caller does not write WAL; so if checksums are enabled, we may need
+ * 1. The change is non-critical.  If checksums are enabled, we may still need
  *	  to write an XLOG_FPI_FOR_HINT WAL record to protect against torn pages.
  * 2. The caller might have only a share-exclusive-lock instead of an
  *	  exclusive-lock on the buffer's content lock.
@@ -5858,7 +5861,7 @@ MarkBufferDirtyHint(Buffer buffer, bool buffer_std)
 
 	MarkSharedBufferDirtyHint(buffer, bufHdr,
 							  pg_atomic_read_u64(&bufHdr->state),
-							  buffer_std);
+							  buffer_std, NULL);
 }
 
 /*
@@ -7126,6 +7129,35 @@ BufferFinishSetHintBits(Buffer buffer, bool mark_dirty, bool buffer_std)
 		MarkBufferDirtyHint(buffer, buffer_std);
 }
 
+/*
+ * Like BufferFinishSetHintBits(), but use the supplied callback instead of
+ * XLogSaveBufferForHint() when WAL logging is needed.
+ */
+void
+BufferFinishSetHintBitsWithWal(Buffer buffer, bool mark_dirty, bool buffer_std,
+							   BufferHintWalLogger wal_logger)
+{
+	BufferDesc *buf_hdr;
+
+	if (BufferIsLocal(buffer))
+	{
+		if (mark_dirty)
+			MarkLocalBufferDirty(buffer);
+		return;
+	}
+
+	Assert(BufferIsLockedByMeInMode(buffer, BUFFER_LOCK_SHARE_EXCLUSIVE) ||
+		   BufferIsLockedByMeInMode(buffer, BUFFER_LOCK_EXCLUSIVE));
+
+	if (!mark_dirty)
+		return;
+
+	buf_hdr = GetBufferDescriptor(buffer - 1);
+	MarkSharedBufferDirtyHint(buffer, buf_hdr,
+							  pg_atomic_read_u64(&buf_hdr->state),
+							  buffer_std, wal_logger);
+}
+
 /*
  * Try to set hint bits on a single 16bit value in a buffer.
  *
@@ -7138,8 +7170,9 @@ BufferFinishSetHintBits(Buffer buffer, bool mark_dirty, bool buffer_std)
  * BufferFinishSetHintBits() when setting hints once in a buffer, but slower
  * than the former when setting hint bits multiple times in the same buffer.
  */
-bool
-BufferSetHintBits16(uint16 *ptr, uint16 val, Buffer buffer)
+static bool
+BufferSetHintBits16Internal(uint16 *ptr, uint16 val, Buffer buffer,
+							BufferHintWalLogger wal_logger)
 {
 	BufferDesc *buf_hdr;
 	uint64		lockstate;
@@ -7166,7 +7199,8 @@ BufferSetHintBits16(uint16 *ptr, uint16 val, Buffer buffer)
 	{
 		*ptr = val;
 
-		MarkSharedBufferDirtyHint(buffer, buf_hdr, lockstate, true);
+		MarkSharedBufferDirtyHint(buffer, buf_hdr, lockstate, true,
+								  wal_logger);
 
 		return true;
 	}
@@ -7174,6 +7208,20 @@ BufferSetHintBits16(uint16 *ptr, uint16 val, Buffer buffer)
 	return false;
 }
 
+bool
+BufferSetHintBits16(uint16 *ptr, uint16 val, Buffer buffer)
+{
+	return BufferSetHintBits16Internal(ptr, val, buffer, NULL);
+}
+
+bool
+BufferSetHintBits16WithWal(uint16 *ptr, uint16 val, Buffer buffer,
+						   BufferHintWalLogger wal_logger)
+{
+	Assert(wal_logger != NULL);
+	return BufferSetHintBits16Internal(ptr, val, buffer, wal_logger);
+}
+
 
 /*
  *	Functions for buffer I/O handling
diff --git a/src/backend/storage/page/README b/src/backend/storage/page/README
index 73c36a63908..59c47ae79f2 100644
--- a/src/backend/storage/page/README
+++ b/src/backend/storage/page/README
@@ -43,9 +43,13 @@ otherwise clean page can allow torn pages; this doesn't normally matter since
 they are just hints, but when the page has checksums, then losing a few bits
 would cause the checksum to be invalid.  So if we have full_page_writes = on
 and checksums enabled then we must write a WAL record specifically so that we
-record a full page image in WAL.  Hint bits updates should be protected using
-MarkBufferDirtyHint(), which is responsible for writing the full-page image
-when necessary.
+record a full page image in WAL.  Hint bit updates should use the buffer hint
+interfaces, which are responsible for writing the full-page image when
+necessary.  When checksums are disabled, heap tuple visibility hints can
+instead be stored in compact WAL records containing tuple offsets and hint
+bits.  Those records provide the block references needed by tools such as
+pg_rewind without advancing the page LSN, so a later ordinary page change
+still generates the full-page image required after a checkpoint.
 
 Note that when we write a page checksum we include the hopefully zeroed bytes
 that form the hole in the centre of a standard page. Thus, when we read the
diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat
index adb72361ce0..2efe12cafb8 100644
--- a/src/backend/utils/misc/guc_parameters.dat
+++ b/src/backend/utils/misc/guc_parameters.dat
@@ -3515,7 +3515,7 @@
 },
 
 { name => 'wal_log_hints', type => 'bool', context => 'PGC_POSTMASTER', group => 'WAL_SETTINGS',
-  short_desc => 'Writes full pages to WAL when first modified after a checkpoint, even for a non-critical modification.',
+  short_desc => 'Writes hint bit changes to WAL when first modified after a checkpoint.',
   variable => 'wal_log_hints',
   boot_val => 'false',
 },
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index 7958653077b..5a11f66ab44 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -259,7 +259,7 @@
                                         #   fsync_writethrough
                                         #   open_sync
 #full_page_writes = on                  # recover from partial page writes
-#wal_log_hints = off                    # also do full page writes of non-critical updates
+#wal_log_hints = off                    # also log non-critical updates
                                         # (change requires restart)
 #wal_compression = off                  # enables compression of full-page writes;
                                         # off, pglz (or "on"), lz4, or zstd
diff --git a/src/bin/pg_waldump/rmgrdesc.c b/src/bin/pg_waldump/rmgrdesc.c
index 931ab8b979e..97058c00c44 100644
--- a/src/bin/pg_waldump/rmgrdesc.c
+++ b/src/bin/pg_waldump/rmgrdesc.c
@@ -15,6 +15,7 @@
 #include "access/ginxlog.h"
 #include "access/gistxlog.h"
 #include "access/hash_xlog.h"
+#include "access/heapam_hint.h"
 #include "access/heapam_xlog.h"
 #include "access/multixact.h"
 #include "access/nbtxlog.h"
diff --git a/src/include/access/heapam_hint.h b/src/include/access/heapam_hint.h
new file mode 100644
index 00000000000..01baf87bbea
--- /dev/null
+++ b/src/include/access/heapam_hint.h
@@ -0,0 +1,46 @@
+/*-------------------------------------------------------------------------
+ *
+ * heapam_hint.h
+ *	  WAL definitions for heap tuple visibility hint bits.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ *
+ * src/include/access/heapam_hint.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef HEAPAM_HINT_H
+#define HEAPAM_HINT_H
+
+#include "access/htup_details.h"
+#include "access/xlogreader.h"
+#include "storage/buf.h"
+
+#define XLOG_HEAP_HINT		0x00
+
+/*
+ * A hint-bit WAL record contains the hint bits of every normal tuple on the
+ * page.  Recording all of them keeps the insertion interface independent of
+ * whether a caller set one hint bit or batched a whole page's worth.
+ */
+typedef struct xl_heap_hint
+{
+	uint16		ntuples;
+}			xl_heap_hint;
+
+typedef struct xl_heap_hint_tuple
+{
+	OffsetNumber offnum;
+	uint16		infomask;
+}			xl_heap_hint_tuple;
+
+#define XL_HEAP_HINT_BITS \
+	(HEAP_XMIN_COMMITTED | HEAP_XMIN_INVALID | \
+	 HEAP_XMAX_COMMITTED | HEAP_XMAX_INVALID)
+
+extern XLogRecPtr log_heap_hint_bits(Buffer buffer);
+extern void heap_hint_redo(XLogReaderState *record);
+extern void heap_hint_desc(StringInfo buf, XLogReaderState *record);
+extern const char *heap_hint_identify(uint8 info);
+
+#endif							/* HEAPAM_HINT_H */
diff --git a/src/include/access/rmgrlist.h b/src/include/access/rmgrlist.h
index ae32ef16d67..8ae7b840384 100644
--- a/src/include/access/rmgrlist.h
+++ b/src/include/access/rmgrlist.h
@@ -48,3 +48,4 @@ PG_RMGR(RM_REPLORIGIN_ID, "ReplicationOrigin", replorigin_redo, replorigin_desc,
 PG_RMGR(RM_GENERIC_ID, "Generic", generic_redo, generic_desc, generic_identify, NULL, NULL, generic_mask, NULL)
 PG_RMGR(RM_LOGICALMSG_ID, "LogicalMessage", logicalmsg_redo, logicalmsg_desc, logicalmsg_identify, NULL, NULL, NULL, logicalmsg_decode)
 PG_RMGR(RM_XLOG2_ID, "XLOG2", xlog2_redo, xlog2_desc, xlog2_identify, NULL, NULL, NULL, xlog2_decode)
+PG_RMGR(RM_HEAP_HINT_ID, "HeapHint", heap_hint_redo, heap_hint_desc, heap_hint_identify, NULL, NULL, heap_mask, NULL)
diff --git a/src/include/storage/bufmgr.h b/src/include/storage/bufmgr.h
index 6837b35fc6d..70be728dd2e 100644
--- a/src/include/storage/bufmgr.h
+++ b/src/include/storage/bufmgr.h
@@ -316,9 +316,16 @@ extern void BufferGetTag(Buffer buffer, RelFileLocator *rlocator,
 
 extern void MarkBufferDirtyHint(Buffer buffer, bool buffer_std);
 
+typedef XLogRecPtr (*BufferHintWalLogger) (Buffer buffer);
+
 extern bool BufferSetHintBits16(uint16 *ptr, uint16 val, Buffer buffer);
+extern bool BufferSetHintBits16WithWal(uint16 *ptr, uint16 val, Buffer buffer,
+									   BufferHintWalLogger wal_logger);
 extern bool BufferBeginSetHintBits(Buffer buffer);
 extern void BufferFinishSetHintBits(Buffer buffer, bool mark_dirty, bool buffer_std);
+extern void BufferFinishSetHintBitsWithWal(Buffer buffer, bool mark_dirty,
+										   bool buffer_std,
+										   BufferHintWalLogger wal_logger);
 
 extern void UnlockBuffers(void);
 extern void UnlockBuffer(Buffer buffer);
diff --git a/src/test/recovery/Makefile b/src/test/recovery/Makefile
index d41aaaf8ae1..a7d5b8ff99b 100644
--- a/src/test/recovery/Makefile
+++ b/src/test/recovery/Makefile
@@ -9,7 +9,8 @@
 #
 #-------------------------------------------------------------------------
 
-EXTRA_INSTALL=contrib/pg_prewarm \
+EXTRA_INSTALL=contrib/pageinspect \
+	contrib/pg_prewarm \
 	contrib/pg_stat_statements \
 	contrib/test_decoding \
 	src/test/modules/injection_points
diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build
index 39ec8c4946d..4e72a58bf64 100644
--- a/src/test/recovery/meson.build
+++ b/src/test/recovery/meson.build
@@ -64,6 +64,7 @@ tests += {
       't/053_standby_login_event_trigger.pl',
       't/054_unlogged_sequence_promotion.pl',
       't/055_cascade_reconnect.pl',
+      't/056_heap_hint_wal.pl',
     ],
   },
 }
diff --git a/src/test/recovery/t/056_heap_hint_wal.pl b/src/test/recovery/t/056_heap_hint_wal.pl
new file mode 100644
index 00000000000..46d3ee1a356
--- /dev/null
+++ b/src/test/recovery/t/056_heap_hint_wal.pl
@@ -0,0 +1,111 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init;
+command_ok(
+	[ 'pg_checksums', '--disable', '-D', $node->data_dir ],
+	'disabled data checksums');
+$node->append_conf(
+	'postgresql.conf', qq(
+full_page_writes = on
+wal_log_hints = on
+bgwriter_lru_maxpages = 0
+checkpoint_timeout = '1h'
+));
+$node->start;
+
+$node->safe_psql('postgres',
+	'CREATE EXTENSION pageinspect; '
+	. 'CREATE TABLE hints AS SELECT g FROM generate_series(1, 100) g');
+
+# Ensure that the heap page is clean and no longer in shared buffers.  The
+# first scan after the restart will therefore WAL-log its visibility hints.
+$node->restart;
+
+my $start_lsn = $node->safe_psql('postgres',
+	'SELECT pg_current_wal_insert_lsn()');
+my $page_lsn = $node->safe_psql('postgres',
+	q[SELECT lsn FROM page_header(get_raw_page('hints', 0))]);
+is($node->safe_psql('postgres', 'SELECT count(*) FROM hints'),
+	'100', 'scanned all tuples');
+is($node->safe_psql('postgres',
+	q[SELECT lsn FROM page_header(get_raw_page('hints', 0))]),
+	$page_lsn, 'heap hint WAL does not advance the page LSN');
+my $end_lsn = $node->safe_psql('postgres',
+	'SELECT pg_current_wal_insert_lsn()');
+my $relfilenode = $node->safe_psql('postgres',
+	q[SELECT pg_relation_filenode('hints'::regclass)]);
+
+# Make the WAL segment available to pg_waldump.
+$node->safe_psql('postgres', 'SELECT pg_switch_wal()');
+
+my ($stdout, $stderr) = run_command(
+	[
+		'pg_waldump', '-p', $node->data_dir,
+		'-s', $start_lsn, '-e', $end_lsn,
+		'-r', 'HeapHint', '-b'
+	]);
+
+is($stderr, '', 'pg_waldump produced no diagnostics');
+like(
+	$stdout,
+	qr/desc: HINT ntuples: 100\n\s+blkref #0: rel \d+\/\d+\/$relfilenode fork main blk 0/,
+	'heap visibility hints use a compact WAL record');
+unlike(
+	$stdout,
+	qr/\(FPW\)/,
+	'heap hint WAL record has no full-page image');
+
+# Exercise replay of the heap hint WAL record.
+$node->stop('immediate');
+$node->start;
+is($node->safe_psql('postgres', q[
+	SELECT bool_and((t_infomask & 2304) = 2304)
+	FROM heap_page_items(get_raw_page('hints', 0))]),
+	't', 'replay restored heap visibility hint bits');
+is($node->safe_psql('postgres', 'SELECT count(*) FROM hints'),
+	'100', 'table is readable after replaying heap hint WAL');
+
+$node->stop;
+
+command_ok(
+	[ 'pg_checksums', '--enable', '-D', $node->data_dir ],
+	'enabled data checksums');
+$node->start;
+$node->safe_psql('postgres',
+	'CREATE TABLE checksum_hints AS SELECT g FROM generate_series(1, 100) g');
+$node->restart;
+
+$start_lsn = $node->safe_psql('postgres',
+	'SELECT pg_current_wal_insert_lsn()');
+is($node->safe_psql('postgres', 'SELECT count(*) FROM checksum_hints'),
+	'100', 'scanned all tuples on a checksummed page');
+$end_lsn = $node->safe_psql('postgres',
+	'SELECT pg_current_wal_insert_lsn()');
+$relfilenode = $node->safe_psql('postgres',
+	q[SELECT pg_relation_filenode('checksum_hints'::regclass)]);
+$node->safe_psql('postgres', 'SELECT pg_switch_wal()');
+
+($stdout, $stderr) = run_command(
+	[
+		'pg_waldump', '-p', $node->data_dir,
+		'-s', $start_lsn, '-e', $end_lsn,
+		'-r', 'XLOG', '-b'
+	]);
+
+is($stderr, '', 'pg_waldump produced no checksum diagnostics');
+like(
+	$stdout,
+	qr/desc: FPI_FOR_HINT\s*\n\s+blkref #0: rel \d+\/\d+\/$relfilenode fork main blk 0 \(FPW\)/,
+	'checksummed heap hint update uses a full-page image');
+
+$node->stop;
+
+done_testing();
-- 
2.50.1 (Apple Git-155)

