From 59baf8c1261da4518020e296480b4bc1dd71c31e Mon Sep 17 00:00:00 2001
Message-ID: <59baf8c1261da4518020e296480b4bc1dd71c31e.1785332874.git.darthunix@gmail.com>
From: Denis Smirnov <darthunix@gmail.com>
Date: Sun, 19 Jul 2026 16:45:48 +0700
Subject: [PATCH v4 1/3] Expose heap page batches through tuple slots

Heap scans in page mode already collect the offsets of all visible
tuples on the current page. Nevertheless, heap_getnextslot() calls
heapgettup_pagemode() separately for every tuple, repeating
scan-navigation work that has already been done.

Add an optional batch interface to TupleTableSlot and implement it for
buffer heap slots. A heap scan can publish the visible tuples from its
current page as a batch and advance through them without calling
heapgettup_pagemode() again. The page remains pinned as before, and the
executor and table AM interfaces continue to return one tuple at a time.

The slot interface also allows attributes to be obtained from a selected
window of batch rows. This provides a foundation for processing heap
tuples in tight loops without exposing heap page layout to executor
nodes or requiring batch-aware table AM callbacks.

Track each batch's generation so that rescans and slot reuse cannot
consume stale contents. A non-NULL batch interface promises that
attributes remain stable while the batch is active; producers whose
attributes can change in place must leave tts_batch NULL.

Batches are published only for page-mode scans without scan keys. Buffer
heap slots expose the batch interface only when explicitly requested.
SeqScan makes that request under an MVCC snapshot, and the slot
implementation decides whether to accept it. Relations known to receive
in-place updates and user catalog tables are excluded.
---
 src/backend/access/heap/heapam.c   | 91 ++++++++++++++++++++++++++++++
 src/backend/access/index/genam.c   |  6 ++
 src/backend/executor/execTuples.c  | 82 ++++++++++++++++++++++++---
 src/backend/executor/nodeSeqscan.c | 16 +++++-
 src/include/executor/tuptable.h    | 80 ++++++++++++++++++++++++++
 src/tools/pgindent/typedefs.list   |  3 +
 6 files changed, 268 insertions(+), 10 deletions(-)

diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c
index 8b488cfd8f6..74b8e636bd2 100644
--- a/src/backend/access/heap/heapam.c
+++ b/src/backend/access/heap/heapam.c
@@ -1471,6 +1471,75 @@ heap_getnext(TableScanDesc sscan, ScanDirection direction)
 	return &scan->rs_ctup;
 }
 
+static inline bool
+heap_page_batch_is_valid(HeapScanDesc scan, TupleTableSlot *slot)
+{
+	BufferHeapTupleTableSlot *bslot;
+	HeapPageBatch *batch;
+
+	if (!TTS_IS_BUFFERTUPLE(slot))
+		return false;
+
+	bslot = (BufferHeapTupleTableSlot *) slot;
+	batch = &bslot->batch;
+	if (slot_getbatch(slot) != &batch->batch ||
+		batch->offsets != scan->rs_vistuples ||
+		batch->batch.ntuples != (int) scan->rs_ntuples ||
+		batch->batch.current != (int) scan->rs_cindex ||
+		bslot->buffer != scan->rs_cbuf)
+		return false;
+
+	Assert(!TTS_EMPTY(slot));
+	Assert(ItemPointerIsValid(&slot->tts_tid));
+	Assert(batch->offsets[batch->batch.current] ==
+		   ItemPointerGetOffsetNumber(&slot->tts_tid));
+	Assert(bslot->base.tuple == &scan->rs_ctup);
+
+	return true;
+}
+
+static inline bool
+heap_page_batch_next(HeapScanDesc scan, ScanDirection direction,
+					 TupleTableSlot *slot)
+{
+	BufferHeapTupleTableSlot *bslot;
+	HeapPageBatch *batch;
+	HeapTuple	tuple = &scan->rs_ctup;
+	Page		page;
+	ItemId		lpp;
+	OffsetNumber lineoff;
+	int			next;
+
+	Assert(direction == ForwardScanDirection ||
+		   direction == BackwardScanDirection);
+
+	if (!heap_page_batch_is_valid(scan, slot))
+		return false;
+
+	bslot = (BufferHeapTupleTableSlot *) slot;
+	batch = &bslot->batch;
+	next = batch->batch.current + direction;
+	if (next < 0 || next >= batch->batch.ntuples)
+		return false;
+
+	page = BufferGetPage(scan->rs_cbuf);
+	lineoff = batch->offsets[next];
+	lpp = PageGetItemId(page, lineoff);
+	Assert(ItemIdIsNormal(lpp));
+
+	tuple->t_data = (HeapTupleHeader) PageGetItem(page, lpp);
+	tuple->t_len = ItemIdGetLength(lpp);
+	ItemPointerSetOffsetNumber(&tuple->t_self, lineoff);
+
+	scan->rs_cindex = next;
+	batch->batch.current = next;
+	tts_buffer_heap_set_tuple(bslot, tuple);
+
+	Assert(heap_page_batch_is_valid(scan, slot));
+
+	return true;
+}
+
 bool
 heap_getnextslot(TableScanDesc sscan, ScanDirection direction, TupleTableSlot *slot)
 {
@@ -1478,6 +1547,12 @@ heap_getnextslot(TableScanDesc sscan, ScanDirection direction, TupleTableSlot *s
 
 	/* Note: no locking manipulations needed */
 
+	if (heap_page_batch_next(scan, direction, slot))
+	{
+		pgstat_count_heap_getnext(scan->rs_base.rs_rd);
+		return true;
+	}
+
 	if (sscan->rs_flags & SO_ALLOW_PAGEMODE)
 		heapgettup_pagemode(scan, direction, sscan->rs_nkeys, sscan->rs_key);
 	else
@@ -1498,6 +1573,22 @@ heap_getnextslot(TableScanDesc sscan, ScanDirection direction, TupleTableSlot *s
 
 	ExecStoreBufferHeapTuple(&scan->rs_ctup, slot,
 							 scan->rs_cbuf);
+
+	if (slot_supports_batch(slot) &&
+		(sscan->rs_flags & SO_ALLOW_PAGEMODE) &&
+		sscan->rs_nkeys == 0)
+	{
+		HeapPageBatch *batch =
+			&((BufferHeapTupleTableSlot *) slot)->batch;
+
+		batch->offsets = scan->rs_vistuples;
+		batch->batch.current = scan->rs_cindex;
+		batch->batch.generation++;
+		batch->batch.ntuples = scan->rs_ntuples;
+
+		Assert(heap_page_batch_is_valid(scan, slot));
+	}
+
 	return true;
 }
 
diff --git a/src/backend/access/index/genam.c b/src/backend/access/index/genam.c
index 1408989c568..f7e6816fc82 100644
--- a/src/backend/access/index/genam.c
+++ b/src/backend/access/index/genam.c
@@ -805,6 +805,12 @@ systable_endscan_ordered(SysScanDesc sysscan)
  * condition.)  If "oldtupcopy" gets non-NULL, you must pass output parameter
  * "state" to systable_inplace_update_finish() or
  * systable_inplace_update_cancel().
+ *
+ * An extension using this function on a non-system relation must either mark
+ * the relation with user_catalog_table before concurrent access begins, or
+ * serialize its in-place updates with all readers. SeqScan does not expose
+ * tuple batches for user catalog tables, because a batch requires stable
+ * backing tuple contents.
  */
 void
 systable_inplace_update_begin(Relation relation,
diff --git a/src/backend/executor/execTuples.c b/src/backend/executor/execTuples.c
index 97ae019d10a..e0903c30aa0 100644
--- a/src/backend/executor/execTuples.c
+++ b/src/backend/executor/execTuples.c
@@ -64,6 +64,7 @@
 #include "catalog/pg_type.h"
 #include "funcapi.h"
 #include "nodes/nodeFuncs.h"
+#include "port/pg_bitutils.h"
 #include "storage/bufmgr.h"
 #include "utils/builtins.h"
 #include "utils/expandeddatum.h"
@@ -707,9 +708,75 @@ tts_minimal_store_tuple(TupleTableSlot *slot, MinimalTuple mtup, bool shouldFree
  * TupleTableSlotOps implementation for BufferHeapTupleTableSlot.
  */
 
+static TupleBatchMask
+tts_buffer_heap_getattrs(TupleTableSlot *slot, AttrNumber attnum,
+						 int first, int nrows, TupleBatchMask rows,
+						 Datum *values)
+{
+	BufferHeapTupleTableSlot *bslot;
+	HeapPageBatch *batch;
+	TupleDesc	tupledesc = slot->tts_tupleDescriptor;
+	TupleBatchMask nulls = 0;
+	Page		page;
+
+	Assert(TTS_IS_BUFFERTUPLE(slot));
+	bslot = (BufferHeapTupleTableSlot *) slot;
+	batch = &bslot->batch;
+
+	Assert(!TTS_EMPTY(slot));
+	Assert(slot_getbatch(slot) == &batch->batch);
+	Assert(batch->offsets != NULL);
+	Assert(BufferIsValid(bslot->buffer));
+	Assert(attnum > 0 && attnum <= tupledesc->natts);
+	Assert(first >= 0);
+	Assert(nrows >= 0 && nrows <= TUPLE_BATCH_MASK_BITS);
+	Assert(nrows <= batch->batch.ntuples);
+	Assert(first <= batch->batch.ntuples - nrows);
+	Assert(nrows == TUPLE_BATCH_MASK_BITS || (rows >> nrows) == 0);
+	Assert(values != NULL);
+
+	if (rows == 0)
+		return nulls;
+
+	page = BufferGetPage(bslot->buffer);
+	while (rows != 0)
+	{
+		int			i = pg_rightmost_one_pos64(rows);
+		OffsetNumber lineoff = batch->offsets[first + i];
+		ItemId		lpp = PageGetItemId(page, lineoff);
+		HeapTupleData tuple;
+		bool		isnull;
+
+		Assert(ItemIdIsNormal(lpp));
+		tuple.t_data = (HeapTupleHeader) PageGetItem(page, lpp);
+		tuple.t_len = ItemIdGetLength(lpp);
+		values[i] = heap_getattr(&tuple, attnum, tupledesc, &isnull);
+		if (isnull)
+			nulls |= UINT64CONST(1) << i;
+
+		rows &= rows - 1;
+	}
+
+	return nulls;
+}
+
+static inline void
+heap_page_batch_reset(HeapPageBatch *batch)
+{
+	batch->batch.ntuples = 0;
+}
+
 static void
 tts_buffer_heap_init(TupleTableSlot *slot)
 {
+	BufferHeapTupleTableSlot *bslot = (BufferHeapTupleTableSlot *) slot;
+
+	Assert(slot->tts_batch == NULL);
+	if (slot->tts_flags & TTS_FLAG_SUPPORTS_BATCH)
+	{
+		bslot->batch.batch.getattrs = tts_buffer_heap_getattrs;
+		slot->tts_batch = &bslot->batch.batch;
+	}
 }
 
 static void
@@ -722,6 +789,8 @@ tts_buffer_heap_clear(TupleTableSlot *slot)
 {
 	BufferHeapTupleTableSlot *bslot = (BufferHeapTupleTableSlot *) slot;
 
+	heap_page_batch_reset(&bslot->batch);
+
 	/*
 	 * Free the memory for heap tuple if allowed. A tuple coming from buffer
 	 * can never be freed. But we may have materialized a tuple from buffer.
@@ -807,6 +876,7 @@ tts_buffer_heap_materialize(TupleTableSlot *slot)
 	MemoryContext oldContext;
 
 	Assert(!TTS_EMPTY(slot));
+	heap_page_batch_reset(&bslot->batch);
 
 	/* If slot has its tuple already materialized, nothing to do. */
 	if (TTS_SHOULDFREE(slot))
@@ -946,6 +1016,8 @@ tts_buffer_heap_store_tuple(TupleTableSlot *slot, HeapTuple tuple,
 {
 	BufferHeapTupleTableSlot *bslot = (BufferHeapTupleTableSlot *) slot;
 
+	heap_page_batch_reset(&bslot->batch);
+
 	if (TTS_SHOULDFREE(slot))
 	{
 		/* materialized slot shouldn't have a buffer to release */
@@ -955,11 +1027,7 @@ tts_buffer_heap_store_tuple(TupleTableSlot *slot, HeapTuple tuple,
 		slot->tts_flags &= ~TTS_FLAG_SHOULDFREE;
 	}
 
-	slot->tts_flags &= ~TTS_FLAG_EMPTY;
-	slot->tts_nvalid = 0;
-	bslot->base.tuple = tuple;
-	bslot->base.off = 0;
-	slot->tts_tid = tuple->t_self;
+	tts_buffer_heap_set_tuple(bslot, tuple);
 
 	/*
 	 * If tuple is on a disk page, keep the page pinned as long as we hold a
@@ -1677,8 +1745,6 @@ ExecStoreBufferHeapTuple(HeapTuple tuple,
 		elog(ERROR, "trying to store an on-disk heap tuple into wrong type of slot");
 	tts_buffer_heap_store_tuple(slot, tuple, buffer, false);
 
-	slot->tts_tableOid = tuple->t_tableOid;
-
 	return slot;
 }
 
@@ -1703,8 +1769,6 @@ ExecStorePinnedBufferHeapTuple(HeapTuple tuple,
 		elog(ERROR, "trying to store an on-disk heap tuple into wrong type of slot");
 	tts_buffer_heap_store_tuple(slot, tuple, buffer, true);
 
-	slot->tts_tableOid = tuple->t_tableOid;
-
 	return slot;
 }
 
diff --git a/src/backend/executor/nodeSeqscan.c b/src/backend/executor/nodeSeqscan.c
index b8c528ca089..a40e1b9e51d 100644
--- a/src/backend/executor/nodeSeqscan.c
+++ b/src/backend/executor/nodeSeqscan.c
@@ -29,11 +29,13 @@
 
 #include "access/relscan.h"
 #include "access/tableam.h"
+#include "catalog/catalog.h"
 #include "executor/execParallel.h"
 #include "executor/execScan.h"
 #include "executor/executor.h"
 #include "executor/nodeSeqscan.h"
 #include "utils/rel.h"
+#include "utils/snapmgr.h"
 
 static TupleTableSlot *SeqNext(SeqScanState *node);
 
@@ -220,6 +222,7 @@ SeqScanState *
 ExecInitSeqScan(SeqScan *node, EState *estate, int eflags)
 {
 	SeqScanState *scanstate;
+	uint16		slot_flags = TTS_FLAG_OBEYS_NOT_NULL_CONSTRAINTS;
 
 	/*
 	 * Once upon a time it was possible to have an outerPlan of a SeqScan, but
@@ -250,11 +253,22 @@ ExecInitSeqScan(SeqScan *node, EState *estate, int eflags)
 							 node->scan.scanrelid,
 							 eflags);
 
+	/*
+	 * Request the batch interface from the slot implementation when relation
+	 * semantics allow its attributes to remain stable. The slot may ignore
+	 * the request if its backing storage cannot provide a stable batch.
+	 */
+	if (estate->es_snapshot != NULL &&
+		IsMVCCSnapshot(estate->es_snapshot) &&
+		!IsInplaceUpdateRelation(scanstate->ss.ss_currentRelation) &&
+		!RelationIsUsedAsCatalogTable(scanstate->ss.ss_currentRelation))
+		slot_flags |= TTS_FLAG_SUPPORTS_BATCH;
+
 	/* and create slot with the appropriate rowtype */
 	ExecInitScanTupleSlot(estate, &scanstate->ss,
 						  RelationGetDescr(scanstate->ss.ss_currentRelation),
 						  table_slot_callbacks(scanstate->ss.ss_currentRelation),
-						  TTS_FLAG_OBEYS_NOT_NULL_CONSTRAINTS);
+						  slot_flags);
 
 	/*
 	 * Initialize result type and projection.
diff --git a/src/include/executor/tuptable.h b/src/include/executor/tuptable.h
index 3db6c9c9bd0..d9117ade80c 100644
--- a/src/include/executor/tuptable.h
+++ b/src/include/executor/tuptable.h
@@ -107,6 +107,9 @@
 #define			TTS_FLAG_FIXED		(1 << 4)
 #define TTS_FIXED(slot) (((slot)->tts_flags & TTS_FLAG_FIXED) != 0)
 
+/* request that the slot implementation expose its optional batch interface */
+#define			TTS_FLAG_SUPPORTS_BATCH		(1 << 5)
+
 /*
  * Defines which of the above flags should never be set in tts_flags when the
  * TupleTableSlot is created.
@@ -115,6 +118,8 @@
 
 struct TupleTableSlotOps;
 typedef struct TupleTableSlotOps TupleTableSlotOps;
+struct TupleTableSlotBatch;
+typedef struct TupleTableSlotBatch TupleTableSlotBatch;
 
 /* base tuple table slot type */
 typedef struct TupleTableSlot
@@ -141,8 +146,60 @@ typedef struct TupleTableSlot
 	MemoryContext tts_mcxt;		/* slot itself is in this context */
 	ItemPointerData tts_tid;	/* stored tuple's tid */
 	Oid			tts_tableOid;	/* table oid of tuple */
+	TupleTableSlotBatch *tts_batch; /* optional batch of tuples */
 } TupleTableSlot;
 
+typedef uint64 TupleBatchMask;
+
+#define TUPLE_BATCH_MASK_BITS \
+	(sizeof(TupleBatchMask) * BITS_PER_BYTE)
+
+/*
+ * A slot implementation accepts TTS_FLAG_SUPPORTS_BATCH by setting tts_batch
+ * during initialization; it may ignore the request by leaving tts_batch NULL.
+ * A non-NULL tts_batch means that batches can be published through the slot.
+ * A batch with zero tuples is inactive; otherwise, 0 <= current < ntuples and
+ * current identifies the slot's tuple in the batch. The batch pointer and
+ * generation together identify the batch contents. Attributes available
+ * through getattrs must remain stable while a batch is active. Slots backed
+ * by storage that can change those attributes in place must leave tts_batch
+ * NULL.
+ *
+ * getattrs requests at most TUPLE_BATCH_MASK_BITS tuples. Bit i in rows and
+ * the returned NULL mask, and values[i], refer to tuple first + i in the
+ * batch.
+ */
+struct TupleTableSlotBatch
+{
+	TupleBatchMask (*getattrs) (TupleTableSlot *slot,
+								AttrNumber attnum, int first, int nrows,
+								TupleBatchMask rows, Datum *values);
+	uint64		generation;
+	int			ntuples;
+	int			current;
+};
+
+static inline bool
+slot_supports_batch(const TupleTableSlot *slot)
+{
+	return slot->tts_batch != NULL;
+}
+
+static inline const TupleTableSlotBatch *
+slot_getbatch(const TupleTableSlot *slot)
+{
+	const TupleTableSlotBatch *batch = slot->tts_batch;
+
+	if (batch == NULL || batch->ntuples <= 0)
+		return NULL;
+
+	Assert(batch->getattrs != NULL);
+	Assert(batch->current >= 0);
+	Assert(batch->current < batch->ntuples);
+
+	return batch;
+}
+
 /* routines for a TupleTableSlot implementation */
 struct TupleTableSlotOps
 {
@@ -282,6 +339,15 @@ typedef struct HeapTupleTableSlot
 	HeapTupleData tupdata;		/* optional workspace for storing tuple */
 } HeapTupleTableSlot;
 
+struct HeapScanDescData;
+
+typedef struct HeapPageBatch
+{
+	TupleTableSlotBatch batch;
+	const OffsetNumber *offsets;
+	struct HeapScanDescData *scan;
+} HeapPageBatch;
+
 /* heap tuple residing in a buffer */
 typedef struct BufferHeapTupleTableSlot
 {
@@ -296,8 +362,22 @@ typedef struct BufferHeapTupleTableSlot
 	 * such a case, since presumably base.tuple is pointing into the buffer.)
 	 */
 	Buffer		buffer;			/* tuple's buffer, or InvalidBuffer */
+	HeapPageBatch batch;
 } BufferHeapTupleTableSlot;
 
+static inline void
+tts_buffer_heap_set_tuple(BufferHeapTupleTableSlot *bslot, HeapTuple tuple)
+{
+	TupleTableSlot *slot = &bslot->base.base;
+
+	slot->tts_flags &= ~TTS_FLAG_EMPTY;
+	slot->tts_nvalid = 0;
+	bslot->base.tuple = tuple;
+	bslot->base.off = 0;
+	slot->tts_tid = tuple->t_self;
+	slot->tts_tableOid = tuple->t_tableOid;
+}
+
 typedef struct MinimalTupleTableSlot
 {
 	pg_node_attr(abstract)
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 85d989f395d..c42f4a3453c 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -1266,6 +1266,7 @@ HeadlineParsedText
 HeadlineWordEntry
 HeapCheckContext
 HeapCheckReadStreamData
+HeapPageBatch
 HeapPageFreeze
 HeapScanDesc
 HeapScanDescData
@@ -3271,6 +3272,7 @@ TsmRoutine
 TupOutputState
 TupSortStatus
 TupStoreStatus
+TupleBatchMask
 TupleConstr
 TupleConversionMap
 TupleDesc
@@ -3281,6 +3283,7 @@ TupleHashTable
 TupleHashTableData
 TupleQueueReader
 TupleTableSlot
+TupleTableSlotBatch
 TupleTableSlotOps
 TuplesortClusterArg
 TuplesortDatumArg
-- 
2.54.0

