From 2acff0528777557be07b358105e1cafdb661ed71 Mon Sep 17 00:00:00 2001
From: Peter Geoghegan <pg@bowt.ie>
Date: Sun, 22 Mar 2026 02:36:57 -0400
Subject: [PATCH v32 01/12] Add slot-based table AM index scan interface.

Add table_index_getnext_slot, a new table AM interface that wraps both
plain and index-only index scans that use amgettuple.  Two new heapam
callbacks are introduced -- one for plain scans and one for index-only
scans -- which an upcoming commit that adds the amgetbatch interface
will expand to four.  The appropriate callback is resolved once in
index_scan_begin, and called through a function pointer on the
IndexScanDesc (xs_getnext_slot) when the table_index_getnext_slot shim
function is called from executor nodes.  That way table AMs can create
specialized variants to help the compiler produce more efficient code
(but they should always be able to provide exactly one generic callback,
if that makes sense).

This moves VM checks for index-only scans out of the executor and into
heapam, enabling batching of visibility map lookups (though for now we
continue to just perform retail lookups).  Using the new higher level
slot-based interface greatly simplifies nodeIndexonlyscan.c, which no
longer has to deal with the visibility map directly.  More importantly,
this is a significant architectural improvement: table AMs can now
implement index-only scans that are not tied to heapam's visibility map.

A small minority of callers (2 callers in total) fundamentally need to
pass a TID to the table AM (both perform constraint enforcement).  These
callers don't actually perform index scans (even if their TIDs are taken
from an index), and have no need for most of the index scan machinery.
Switch these callers over to the new fetch_tid interface.  All true
index scan callers now use the new slot-based interface.  (Note that
fetch_tid doesn't perform on-access pruning, but that doesn't seem
desirable anyway; an index page buffer lock will often be held.)

The VISITED_PAGES_LIMIT mechanism used by get_actual_variable_range to
cap scan overhead during planning is reworked to go through a new scan
descriptor interface (xs_visited_pages_limit), rather than tracking the
costs directly and terminating the scan itself, in an ad-hoc way.  This
is necessary because callers that use the new slot-based interface no
longer have direct access to which heap blocks were fetched.  Similarly,
nodeIndexonlyscan.c can no longer use InstrCountTuples2 to count heap
fetches during an EXPLAIN ANALYZE.  EXPLAIN ANALYZE now obtains this
information from a new IndexScanInstrumentation field, which table AMs
are required to maintain.

Though independently useful, this commit is preparatory work for an
upcoming commit that will add an amgetbatch index AM interface, where
the table AM takes full responsibility for managing the progress of
index scans.  That will move most of the implementation of scrollable
cursors out of index AMs and into table AMs, making it essential that
executor nodes pass the current scan direction down to the table AM.

The heapam table_index_getnext_slot callbacks make aggressive use of
forced inlining to ensure that plain and index-only code paths are fully
specialized at compile time despite sharing a common implementation.
Testing has shown this is necessary to keep icache misses to a minimum,
at least with the two upcoming amgetbatch variants.

Author: Peter Geoghegan <pg@bowt.ie>
Reviewed-by: Andres Freund <andres@anarazel.de>
Reviewed-by: Tomas Vondra <tomas@vondra.me>
Discussion: https://postgr.es/m/CAH2-WzmYqhacBH161peAWb5eF=Ja7CFAQ+0jSEMq=qnfLVTOOg@mail.gmail.com
---
 src/include/access/genam.h                    |   6 +-
 src/include/access/heapam.h                   |  28 +-
 src/include/access/relscan.h                  |  58 +--
 src/include/access/tableam.h                  | 229 +++++++-----
 src/include/catalog/pg_opclass.dat            |   4 +-
 src/include/executor/instrument_node.h        |   5 +-
 src/include/nodes/execnodes.h                 |   8 -
 src/backend/access/heap/heapam_handler.c      |  13 +-
 src/backend/access/heap/heapam_indexscan.c    | 346 +++++++++++++++--
 src/backend/access/heap/visibilitymap.c       |  27 +-
 src/backend/access/index/genam.c              |  26 +-
 src/backend/access/index/indexam.c            | 348 ++++++++++--------
 src/backend/access/nbtree/nbtinsert.c         |  10 +-
 src/backend/access/table/tableam.c            |  58 +--
 src/backend/access/table/tableamapi.c         |   8 +-
 src/backend/commands/constraint.c             |  28 +-
 src/backend/commands/explain.c                |  23 +-
 src/backend/commands/repack.c                 |   4 +-
 src/backend/executor/execIndexing.c           |   5 +-
 src/backend/executor/execReplication.c        |   8 +-
 src/backend/executor/nodeBitmapIndexscan.c    |   1 +
 src/backend/executor/nodeIndexonlyscan.c      | 252 +------------
 src/backend/executor/nodeIndexscan.c          |   9 +-
 src/backend/utils/adt/ri_triggers.c           |   8 +-
 src/backend/utils/adt/selfuncs.c              |  94 ++---
 src/test/modules/index/Makefile               |  10 +
 src/test/modules/index/expected/hot_chain.out |  56 +++
 src/test/modules/index/meson.build            |  26 ++
 src/test/modules/index/sql/hot_chain.sql      |  37 ++
 .../modules/index/test_indexscan--1.0.sql     |  13 +
 src/test/modules/index/test_indexscan.c       | 152 ++++++++
 src/test/modules/index/test_indexscan.control |   4 +
 src/test/regress/expected/stats.out           |  62 ++++
 src/test/regress/sql/stats.sql                |  29 ++
 src/tools/pgindent/typedefs.list              |   3 +-
 35 files changed, 1249 insertions(+), 749 deletions(-)
 create mode 100644 src/test/modules/index/expected/hot_chain.out
 create mode 100644 src/test/modules/index/sql/hot_chain.sql
 create mode 100644 src/test/modules/index/test_indexscan--1.0.sql
 create mode 100644 src/test/modules/index/test_indexscan.c
 create mode 100644 src/test/modules/index/test_indexscan.control

diff --git a/src/include/access/genam.h b/src/include/access/genam.h
index 68bfe405d..87b063d2f 100644
--- a/src/include/access/genam.h
+++ b/src/include/access/genam.h
@@ -156,6 +156,7 @@ extern void index_insert_cleanup(Relation indexRelation,
 
 extern IndexScanDesc index_beginscan(Relation heapRelation,
 									 Relation indexRelation,
+									 bool index_only_scan,
 									 Snapshot snapshot,
 									 IndexScanInstrumentation *instrument,
 									 int nkeys, int norderbys,
@@ -178,15 +179,13 @@ extern void index_parallelscan_initialize(Relation heapRelation,
 extern void index_parallelrescan(IndexScanDesc scan);
 extern IndexScanDesc index_beginscan_parallel(Relation heaprel,
 											  Relation indexrel,
+											  bool index_only_scan,
 											  IndexScanInstrumentation *instrument,
 											  int nkeys, int norderbys,
 											  ParallelIndexScanDesc pscan,
 											  uint32 flags);
 extern ItemPointer index_getnext_tid(IndexScanDesc scan,
 									 ScanDirection direction);
-extern bool index_fetch_heap(IndexScanDesc scan, TupleTableSlot *slot);
-extern bool index_getnext_slot(IndexScanDesc scan, ScanDirection direction,
-							   TupleTableSlot *slot);
 extern int64 index_getbitmap(IndexScanDesc scan, TIDBitmap *bitmap);
 
 extern IndexBulkDeleteResult *index_bulk_delete(IndexVacuumInfo *info,
@@ -200,6 +199,7 @@ extern RegProcedure index_getprocid(Relation irel, AttrNumber attnum,
 									uint16 procnum);
 extern FmgrInfo *index_getprocinfo(Relation irel, AttrNumber attnum,
 								   uint16 procnum);
+extern void index_fill_ios_slot(IndexScanDesc scan, TupleTableSlot *slot);
 extern void index_store_float8_orderby_distances(IndexScanDesc scan,
 												 Oid *orderByTypes,
 												 IndexOrderByDistance *distances,
diff --git a/src/include/access/heapam.h b/src/include/access/heapam.h
index 5176478c2..49262be9c 100644
--- a/src/include/access/heapam.h
+++ b/src/include/access/heapam.h
@@ -115,12 +115,10 @@ typedef struct BitmapHeapScanDescData
 typedef struct BitmapHeapScanDescData *BitmapHeapScanDesc;
 
 /*
- * Descriptor for fetches from heap via an index.
+ * heapam-specific IndexScanDescData opaque state
  */
-typedef struct IndexFetchHeapData
+typedef struct IndexScanHeapData
 {
-	IndexFetchTableData xs_base;	/* AM independent part of the descriptor */
-
 	/*
 	 * Current heap buffer in scan (and its block number), if any.  NB: if
 	 * xs_blk is not InvalidBlockNumber, we hold a pin in xs_cbuf.
@@ -128,9 +126,13 @@ typedef struct IndexFetchHeapData
 	Buffer		xs_cbuf;
 	BlockNumber xs_blk;
 
-	/* Current heap block's corresponding page in the visibility map */
-	Buffer		xs_vmbuffer;
-} IndexFetchHeapData;
+	/* For visibility map checks (index-only scans and on-access pruning) */
+	Buffer		xs_vmbuffer;	/* visibility map buffer */
+
+	bool		xs_readonly;	/* scan is read-only? */
+
+	uint16		xs_blkswitch_count; /* number of heap blocks fetched */
+} IndexScanHeapData;
 
 /* Result codes for HeapTupleSatisfiesVacuum */
 typedef enum
@@ -430,16 +432,14 @@ extern TransactionId heap_index_delete_tuples(Relation rel,
 											  TM_IndexDeleteOp *delstate);
 
 /* in heap/heapam_indexscan.c */
-extern IndexFetchTableData *heapam_index_fetch_begin(Relation rel, uint32 flags);
-extern void heapam_index_fetch_reset(IndexFetchTableData *scan);
-extern void heapam_index_fetch_end(IndexFetchTableData *scan);
+extern bool heapam_fetch_tid(Relation rel, ItemPointer tid, Snapshot snapshot,
+							 TupleTableSlot *slot, bool *all_dead);
+extern void heapam_index_scan_begin(IndexScanDesc scan, uint32 flags);
+extern void heapam_index_scan_reset(IndexScanDesc scan);
+extern void heapam_index_scan_end(IndexScanDesc scan);
 extern bool heap_hot_search_buffer(ItemPointer tid, Relation relation,
 								   Buffer buffer, Snapshot snapshot, HeapTuple heapTuple,
 								   bool *all_dead, bool first_call);
-extern bool heapam_index_fetch_tuple(struct IndexFetchTableData *scan,
-									 ItemPointer tid, Snapshot snapshot,
-									 TupleTableSlot *slot, bool *heap_continue,
-									 bool *all_dead);
 
 /* in heap/pruneheap.c */
 extern void heap_page_prune_opt(Relation relation, Buffer buffer,
diff --git a/src/include/access/relscan.h b/src/include/access/relscan.h
index 2ea06a67a..9d8a18ac6 100644
--- a/src/include/access/relscan.h
+++ b/src/include/access/relscan.h
@@ -16,6 +16,7 @@
 
 #include "access/htup_details.h"
 #include "access/itup.h"
+#include "access/sdir.h"
 #include "nodes/tidbitmap.h"
 #include "port/atomics.h"
 #include "storage/relfilelocator.h"
@@ -25,6 +26,7 @@
 
 struct ParallelTableScanDescData;
 struct TableScanInstrumentation;
+struct TupleTableSlot;
 
 /*
  * Generic descriptor for table scans. This is the base-class for table scans,
@@ -120,22 +122,6 @@ typedef struct ParallelBlockTableScanWorkerData
 } ParallelBlockTableScanWorkerData;
 typedef struct ParallelBlockTableScanWorkerData *ParallelBlockTableScanWorker;
 
-/*
- * Base class for fetches from a table via an index. This is the base-class
- * for such scans, which needs to be embedded in the respective struct for
- * individual AMs.
- */
-typedef struct IndexFetchTableData
-{
-	Relation	rel;
-
-	/*
-	 * Bitmask of ScanOptions affecting the relation. No SO_INTERNAL_FLAGS are
-	 * permitted.
-	 */
-	uint32		flags;
-} IndexFetchTableData;
-
 struct IndexScanInstrumentation;
 
 /*
@@ -172,10 +158,10 @@ typedef struct IndexScanDescData
 	struct IndexScanInstrumentation *instrument;
 
 	/*
-	 * In an index-only scan, a successful amgettuple call must fill either
-	 * xs_itup (and xs_itupdesc) or xs_hitup (and xs_hitupdesc) to provide the
-	 * data returned by the scan.  It can fill both, in which case the heap
-	 * format will be used.
+	 * In an index-only scan, the index AM fills either xs_itup or xs_hitup
+	 * with the data to be returned by the scan (it can fill both, in which
+	 * case the heap format is used).  The table AM consumes these to fill the
+	 * caller's slot during table_index_getnext_slot.
 	 */
 	IndexTuple	xs_itup;		/* index tuple returned by AM */
 	struct TupleDescData *xs_itupdesc;	/* rowtype descriptor of xs_itup */
@@ -185,10 +171,20 @@ typedef struct IndexScanDescData
 	ItemPointerData xs_heaptid; /* result */
 	bool		xs_heap_continue;	/* T if must keep walking, potential
 									 * further results */
-	IndexFetchTableData *xs_heapfetch;
 
 	bool		xs_recheck;		/* T means scan keys must be rechecked */
 
+	/* Table access method's private state (not used during bitmap scans) */
+	void	   *xs_table_opaque;
+
+	/*
+	 * Resolved table_index_getnext_slot callback, which is set by
+	 * table_index_scan_begin at the start of amgettuple scans
+	 */
+	bool		(*xs_getnext_slot) (struct IndexScanDescData *scan,
+									ScanDirection direction,
+									struct TupleTableSlot *slot);
+
 	/*
 	 * When fetching with an ordering operator, the values of the ORDER BY
 	 * expressions of the last returned tuple, according to the index.  If
@@ -200,6 +196,24 @@ typedef struct IndexScanDescData
 	bool	   *xs_orderbynulls;
 	bool		xs_recheckorderby;
 
+	/*
+	 * Index attributes holding "name" columns stored as cstrings, which the
+	 * table AM re-pads to NAMEDATALEN when filling a slot from xs_itup.  The
+	 * re-padded allocations live in xs_name_cstring_cxt, which the table AM
+	 * resets before filling each slot.
+	 */
+	AttrNumber *xs_name_cstring_attnums;
+	int			xs_name_cstring_count;
+	MemoryContext xs_name_cstring_cxt;
+
+	/*
+	 * An approximate limit on the amount of work, measured in pages touched,
+	 * imposed on the index scan.  The default, 0, means no limit.  Only
+	 * index-only scans may set a limit (plain index scans leave this zero).
+	 * Used by selfuncs.c to bound the cost of get_actual_variable_endpoint().
+	 */
+	uint8		xs_visited_pages_limit;
+
 	/* parallel index scan information, in shared memory */
 	struct ParallelIndexScanDescData *parallel_scan;
 } IndexScanDescData;
@@ -213,8 +227,6 @@ typedef struct ParallelIndexScanDescData
 	char		ps_snapshot_data[FLEXIBLE_ARRAY_MEMBER];
 }			ParallelIndexScanDescData;
 
-struct TupleTableSlot;
-
 /* Struct for storage-or-index scans of system tables */
 typedef struct SysScanDescData
 {
diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h
index f2c36696b..88e375a49 100644
--- a/src/include/access/tableam.h
+++ b/src/include/access/tableam.h
@@ -38,6 +38,7 @@ typedef struct BulkInsertStateData BulkInsertStateData;
 typedef struct IndexInfo IndexInfo;
 typedef struct SampleScanState SampleScanState;
 typedef struct ScanKeyData ScanKeyData;
+typedef struct IndexScanDescData *IndexScanDesc;
 typedef struct ValidateIndexState ValidateIndexState;
 typedef struct VacuumParams VacuumParams;
 
@@ -446,60 +447,65 @@ typedef struct TableAmRoutine
 	 */
 
 	/*
-	 * Prepare to fetch tuples from the relation, as needed when fetching
-	 * tuples for an index scan.  The callback has to return an
-	 * IndexFetchTableData, which the AM will typically embed in a larger
-	 * structure with additional information.
+	 * Prepare for an index scan of the table.  The callback stores its own
+	 * private scan state in the index scan descriptor's xs_table_opaque field
+	 * (an opaque pointer).
 	 *
 	 * flags is a bitmask of ScanOptions affecting underlying table scan
 	 * behavior. See scan_begin() for more information on passing these.
 	 *
-	 * Tuples for an index scan can then be fetched via index_fetch_tuple.
+	 * Callback is responsible for setting IndexScanDesc.xs_getnext_slot to
+	 * the appropriate slot-based callback.  Tuples are then returned through
+	 * the caller's slot, via table_index_getnext_slot().  No separate
+	 * slot-based callback exists in this struct!
+	 *
+	 * In principle a single general-purpose callback (stored here) would
+	 * suffice, but using specialized variants allows the table AM to provide
+	 * minimal code based on conditions that are fixed for the whole scan as
+	 * an optimization (e.g., variants for plain index scans and index-only
+	 * scans, each with fewer branches).
+	 *
+	 * The xs_getnext_slot callback may rely on the slot type that callers are
+	 * required to pass: plain index scans use a slot of the table AM's own
+	 * preferred type (see table_slot_callbacks), while index-only scans
+	 * always use a virtual slot, since the slot is filled from index data
+	 * rather than from the table.
 	 */
-	struct IndexFetchTableData *(*index_fetch_begin) (Relation rel, uint32 flags);
+	void		(*index_scan_begin) (IndexScanDesc scan, uint32 flags);
 
 	/*
-	 * Reset index fetch. Typically this will release cross index fetch
-	 * resources held in IndexFetchTableData.
+	 * Inform the table AM that there's to be either a rescan or a restore of
+	 * a marked position, or that the scan has run out of index entries.
 	 */
-	void		(*index_fetch_reset) (struct IndexFetchTableData *data);
+	void		(*index_scan_reset) (IndexScanDesc scan);
 
 	/*
-	 * Release resources and deallocate index fetch.
+	 * Release resources and deallocate index scan state.
+	 */
+	void		(*index_scan_end) (IndexScanDesc scan);
+
+	/* ------------------------------------------------------------------------
+	 * Callbacks for non-modifying operations on individual tuples
+	 * ------------------------------------------------------------------------
 	 */
-	void		(*index_fetch_end) (struct IndexFetchTableData *data);
 
 	/*
 	 * Fetch tuple at `tid` into `slot`, after doing a visibility test
 	 * according to `snapshot`. If a tuple was found and passed the visibility
 	 * test, return true, false otherwise.
 	 *
-	 * Note that AMs that do not necessarily update indexes when indexed
-	 * columns do not change, need to return the current/correct version of
-	 * the tuple that is visible to the snapshot, even if the tid points to an
-	 * older version of the tuple.
+	 * This is a lower-level callback for single-shot TID lookups used by
+	 * constraint enforcement code (unique checks and similar).
 	 *
-	 * *call_again is false on the first call to index_fetch_tuple for a tid.
-	 * If there potentially is another tuple matching the tid, *call_again
-	 * needs to be set to true by index_fetch_tuple, signaling to the caller
-	 * that index_fetch_tuple should be called again for the same tid.
-	 *
-	 * *all_dead, if all_dead is not NULL, should be set to true by
-	 * index_fetch_tuple iff it is guaranteed that no backend needs to see
-	 * that tuple. Index AMs can use that to avoid returning that tid in
-	 * future searches.
-	 */
-	bool		(*index_fetch_tuple) (struct IndexFetchTableData *scan,
-									  ItemPointer tid,
-									  Snapshot snapshot,
-									  TupleTableSlot *slot,
-									  bool *call_again, bool *all_dead);
-
-
-	/* ------------------------------------------------------------------------
-	 * Callbacks for non-modifying operations on individual tuples
-	 * ------------------------------------------------------------------------
+	 * *all_dead, if all_dead is not NULL, should be set to true by fetch_tid
+	 * iff it is guaranteed that no backend needs to see that tuple. Index AMs
+	 * can use that to avoid returning that tid in future searches.
 	 */
+	bool		(*fetch_tid) (Relation rel,
+							  ItemPointer tid,
+							  Snapshot snapshot,
+							  TupleTableSlot *slot,
+							  bool *all_dead);
 
 	/*
 	 * Fetch tuple at `tid` into `slot`, after doing a visibility test
@@ -1235,15 +1241,16 @@ table_parallelscan_reinitialize(Relation rel, ParallelTableScanDesc pscan)
  */
 
 /*
- * Prepare to fetch tuples from the relation, as needed when fetching tuples
- * for an index scan.
+ * Prepare for an index scan of the relation.  The callback stores its
+ * private scan state in the scan's xs_table_opaque field.
+ *
+ * Tuples for an index scan are then returned through the caller's slot, via
+ * table_index_getnext_slot().
  *
  * flags is a bitmask of ScanOptions. No SO_INTERNAL_FLAGS are permitted.
- *
- * Tuples for an index scan can then be fetched via table_index_fetch_tuple().
  */
-static inline IndexFetchTableData *
-table_index_fetch_begin(Relation rel, uint32 flags)
+static inline void
+table_index_scan_begin(IndexScanDesc scan, uint32 flags)
 {
 	Assert((flags & SO_INTERNAL_FLAGS) == 0);
 
@@ -1255,74 +1262,66 @@ table_index_fetch_begin(Relation rel, uint32 flags)
 	if (unlikely(TransactionIdIsValid(CheckXidAlive) && !bsysscan))
 		elog(ERROR, "scan started during logical decoding");
 
-	return rel->rd_tableam->index_fetch_begin(rel, flags);
+	scan->heapRelation->rd_tableam->index_scan_begin(scan, flags);
 }
 
 /*
- * Reset index fetch. Typically this will release cross index fetch resources
- * held in IndexFetchTableData.
+ * Inform the table AM that there's to be either a rescan or a restore of a
+ * marked position, or that the scan has run out of index entries.
  */
 static inline void
-table_index_fetch_reset(struct IndexFetchTableData *scan)
+table_index_scan_reset(IndexScanDesc scan)
 {
-	scan->rel->rd_tableam->index_fetch_reset(scan);
+	Assert(scan->xs_table_opaque);
+
+	scan->heapRelation->rd_tableam->index_scan_reset(scan);
 }
 
 /*
- * Release resources and deallocate index fetch.
+ * Release resources and deallocate the table AM's private index scan state
+ * (the scan's xs_table_opaque)
  */
 static inline void
-table_index_fetch_end(struct IndexFetchTableData *scan)
+table_index_scan_end(IndexScanDesc scan)
 {
-	scan->rel->rd_tableam->index_fetch_end(scan);
+	Assert(scan->xs_table_opaque);
+
+	scan->heapRelation->rd_tableam->index_scan_end(scan);
 }
 
 /*
- * Fetches, as part of an index scan, tuple at `tid` into `slot`, after doing
- * a visibility test according to `snapshot`. If a tuple was found and passed
- * the visibility test, returns true, false otherwise. Note that *tid may be
- * modified when we return true (see later remarks on multiple row versions
- * reachable via a single index entry).
+ * Return the next tuple from an index scan through `slot`, scanning in the
+ * specified direction.  Returns true if a tuple satisfying the scan keys and
+ * the snapshot was found, false otherwise.
  *
- * *call_again needs to be false on the first call to table_index_fetch_tuple() for
- * a tid. If there potentially is another tuple matching the tid, *call_again
- * will be set to true, signaling that table_index_fetch_tuple() should be called
- * again for the same tid.
+ * For a plain index scan the slot holds the table tuple, and so must be of
+ * the table AM's preferred slot type (see table_slot_callbacks).  For an
+ * index-only scan the table AM instead fills the slot from the index data the
+ * index AM placed in scan->xs_itup/xs_hitup; the slot must be virtual, since
+ * its contents don't come from the table at all.  Caller must not read
+ * xs_itup/xs_hitup IndexScanDesc fields directly.
  *
- * *all_dead, if all_dead is not NULL, will be set to true by
- * table_index_fetch_tuple() iff it is guaranteed that no backend needs to see
- * that tuple. Index AMs can use that to avoid returning that tid in future
- * searches.
+ * Dispatches through scan->xs_getnext_slot, which is resolved once by
+ * the table AM's index_scan_begin callback.
  *
- * The difference between this function and table_tuple_fetch_row_version()
- * is that this function returns the currently visible version of a row if
- * the AM supports storing multiple row versions reachable via a single index
- * entry (like heap's HOT). Whereas table_tuple_fetch_row_version() only
- * evaluates the tuple exactly at `tid`. Outside of index entry ->table tuple
- * lookups, table_tuple_fetch_row_version() is what's usually needed.
+ * On success, resources (like buffer pins) are likely to be held, and will be
+ * released by a future table_index_getnext_slot or table_index_scan_end call.
+ *
+ * Note: caller must check scan->xs_recheck, and perform rechecking of the
+ * scan keys if required.  We do not do that here because we don't have
+ * enough information to do it efficiently in the general case.  Similarly,
+ * for ordered scans, the caller must check scan->xs_recheckorderby and
+ * recheck the ORDER BY expressions for itself.
  */
 static inline bool
-table_index_fetch_tuple(struct IndexFetchTableData *scan,
-						ItemPointer tid,
-						Snapshot snapshot,
-						TupleTableSlot *slot,
-						bool *call_again, bool *all_dead)
+table_index_getnext_slot(IndexScanDesc scan, ScanDirection direction,
+						 TupleTableSlot *slot)
 {
-	return scan->rel->rd_tableam->index_fetch_tuple(scan, tid, snapshot,
-													slot, call_again,
-													all_dead);
-}
+	Assert(TTS_IS_VIRTUAL(slot) || !scan->xs_want_itup);
+	Assert(scan->xs_table_opaque);
 
-/*
- * This is a convenience wrapper around table_index_fetch_tuple() which
- * returns whether there are table tuple items corresponding to an index
- * entry.  This likely is only useful to verify if there's a conflict in a
- * unique index.
- */
-extern bool table_index_fetch_tuple_check(Relation rel,
-										  ItemPointer tid,
-										  Snapshot snapshot,
-										  bool *all_dead);
+	return scan->xs_getnext_slot(scan, direction, slot);
+}
 
 
 /* ------------------------------------------------------------------------
@@ -1331,14 +1330,64 @@ extern bool table_index_fetch_tuple_check(Relation rel,
  */
 
 
+/*
+ * Fetch tuple at `tid` into `slot`, after doing a visibility test according
+ * to `snapshot`. If a tuple was found and passed the visibility test, returns
+ * true, false otherwise.  This is a low-level interface designed for use by
+ * constraint enforcement code, where passing a TID can't be avoided.
+ *
+ * Note that *tid may be modified when we return true (e.g. due to following a
+ * HOT chain in a heapam table).  Caller should consider passing a pointer to
+ * a mutable copy of their original TID to avoid unwanted side-effects.
+ *
+ * If all_dead is not NULL, *all_dead will be set to true here iff it is
+ * guaranteed that no backend needs to see any tuple reachable through
+ * caller's TID.  This means that it is safe to mark an index tuple containing
+ * this TID as LP_DEAD.
+ *
+ * The main difference between table_tuple_fetch_row_version() and this
+ * function is that we return the currently visible version of a row, which
+ * matters with AMs that support storing multiple row versions reachable via a
+ * single TID (e.g., due to heapam's HOT chains).  To reliably evaluate
+ * exactly the tuple at `tid`, call table_tuple_fetch_row_version() instead.
+ */
+static inline bool
+table_fetch_tid(Relation rel,
+				ItemPointer tid,
+				Snapshot snapshot,
+				TupleTableSlot *slot,
+				bool *all_dead)
+{
+	/*
+	 * We don't expect direct calls to table_fetch_tid with valid
+	 * CheckXidAlive for catalog or regular tables.  See detailed comments in
+	 * xact.c where these variables are declared.
+	 */
+	if (unlikely(TransactionIdIsValid(CheckXidAlive) && !bsysscan))
+		elog(ERROR, "unexpected table_fetch_tid call during logical decoding");
+
+	return rel->rd_tableam->fetch_tid(rel, tid, snapshot, slot, all_dead);
+}
+
+/*
+ * Convenience wrapper around table_fetch_tid() for callers that just need to
+ * check if a tuple is visible.
+ *
+ * Caller should note the table_fetch_tid warning about *tid being modified
+ * when we return true in some cases.
+ */
+extern bool table_fetch_tid_check(Relation rel,
+								  ItemPointer tid,
+								  Snapshot snapshot,
+								  bool *all_dead);
+
 /*
  * Fetch tuple at `tid` into `slot`, after doing a visibility test according to
  * `snapshot`. If a tuple was found and passed the visibility test, returns
  * true, false otherwise.
  *
- * See table_index_fetch_tuple's comment about what the difference between
- * these functions is. It is correct to use this function outside of index
- * entry->table tuple lookups.
+ * See table_fetch_tid's comment about what the difference between these
+ * functions is.
  */
 static inline bool
 table_tuple_fetch_row_version(Relation rel,
diff --git a/src/include/catalog/pg_opclass.dat b/src/include/catalog/pg_opclass.dat
index df170b808..94dcddbcb 100644
--- a/src/include/catalog/pg_opclass.dat
+++ b/src/include/catalog/pg_opclass.dat
@@ -94,8 +94,8 @@
 # so we choose to omit that within an index entry.  Here we declare the
 # btree opclass for name as using cstring storage type.  This does require
 # that we pad the cstring out with the full NAMEDATALEN bytes when performing
-# index-only scans.  See corresponding hacks in ExecInitIndexOnlyScan() and
-# StoreIndexTuple().
+# index-only scans.  See corresponding hacks in index_beginscan() and
+# index_fill_ios_slot().
 { opcmethod => 'btree', opcname => 'name_ops', opcfamily => 'btree/text_ops',
   opcintype => 'name', opckeytype => 'cstring' },
 
diff --git a/src/include/executor/instrument_node.h b/src/include/executor/instrument_node.h
index 407699040..41a7d33f1 100644
--- a/src/include/executor/instrument_node.h
+++ b/src/include/executor/instrument_node.h
@@ -98,13 +98,16 @@ AccumulateIOStats(IOStats *dst, IOStats *src)
 
 
 /* ---------------------
- *	Instrumentation information for indexscans (amgettuple and amgetbitmap)
+ *	Instrumentation information for index scans (used by all AM interfaces)
  * ---------------------
  */
 typedef struct IndexScanInstrumentation
 {
 	/* Index search count (incremented with pgstat_count_index_scan call) */
 	uint64		nsearches;
+
+	/* Table tuples fetched count (incremented during index-only scans) */
+	uint64		ntabletuplefetches;
 } IndexScanInstrumentation;
 
 /*
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index e95ac3eda..09281d3e9 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -1791,11 +1791,7 @@ typedef struct IndexScanState
  *		ScanDesc		   index scan descriptor
  *		Instrument		   local index scan instrumentation
  *		SharedInfo		   parallel worker instrumentation (no leader entry)
- *		TableSlot		   slot for holding tuples fetched from the table
- *		VMBuffer		   buffer in use for visibility map testing, if any
  *		PscanLen		   size of parallel index-only scan descriptor
- *		NameCStringAttNums attnums of name typed columns to pad to NAMEDATALEN
- *		NameCStringCount   number of elements in the NameCStringAttNums array
  * ----------------
  */
 typedef struct IndexOnlyScanState
@@ -1814,11 +1810,7 @@ typedef struct IndexOnlyScanState
 	struct IndexScanDescData *ioss_ScanDesc;
 	IndexScanInstrumentation *ioss_Instrument;
 	SharedIndexScanInstrumentation *ioss_SharedInfo;
-	TupleTableSlot *ioss_TableSlot;
-	Buffer		ioss_VMBuffer;
 	Size		ioss_PscanLen;
-	AttrNumber *ioss_NameCStringAttNums;
-	int			ioss_NameCStringCount;
 } IndexOnlyScanState;
 
 /* ----------------
diff --git a/src/backend/access/heap/heapam_handler.c b/src/backend/access/heap/heapam_handler.c
index bf87430cf..3018112c0 100644
--- a/src/backend/access/heap/heapam_handler.c
+++ b/src/backend/access/heap/heapam_handler.c
@@ -673,7 +673,7 @@ heapam_relation_copy_for_cluster(Relation OldHeap, Relation NewHeap,
 
 		tableScan = NULL;
 		heapScan = NULL;
-		indexScan = index_beginscan(OldHeap, OldIndex,
+		indexScan = index_beginscan(OldHeap, OldIndex, false,
 									snapshot ? snapshot : SnapshotAny,
 									NULL, 0, 0,
 									SO_NONE);
@@ -716,7 +716,8 @@ heapam_relation_copy_for_cluster(Relation OldHeap, Relation NewHeap,
 
 		if (indexScan != NULL)
 		{
-			if (!index_getnext_slot(indexScan, ForwardScanDirection, slot))
+			if (!table_index_getnext_slot(indexScan, ForwardScanDirection,
+										  slot))
 				break;
 
 			/* Since we used no scan keys, should never need to recheck */
@@ -2673,10 +2674,9 @@ static const TableAmRoutine heapam_methods = {
 	.parallelscan_initialize = table_block_parallelscan_initialize,
 	.parallelscan_reinitialize = table_block_parallelscan_reinitialize,
 
-	.index_fetch_begin = heapam_index_fetch_begin,
-	.index_fetch_reset = heapam_index_fetch_reset,
-	.index_fetch_end = heapam_index_fetch_end,
-	.index_fetch_tuple = heapam_index_fetch_tuple,
+	.index_scan_begin = heapam_index_scan_begin,
+	.index_scan_reset = heapam_index_scan_reset,
+	.index_scan_end = heapam_index_scan_end,
 
 	.tuple_insert = heapam_tuple_insert,
 	.tuple_insert_speculative = heapam_tuple_insert_speculative,
@@ -2686,6 +2686,7 @@ static const TableAmRoutine heapam_methods = {
 	.tuple_update = heapam_tuple_update,
 	.tuple_lock = heapam_tuple_lock,
 
+	.fetch_tid = heapam_fetch_tid,
 	.tuple_fetch_row_version = heapam_fetch_row_version,
 	.tuple_get_latest_tid = heap_get_latest_tid,
 	.tuple_tid_valid = heapam_tuple_tid_valid,
diff --git a/src/backend/access/heap/heapam_indexscan.c b/src/backend/access/heap/heapam_indexscan.c
index 33d14f1de..a98e4c366 100644
--- a/src/backend/access/heap/heapam_indexscan.c
+++ b/src/backend/access/heap/heapam_indexscan.c
@@ -14,36 +14,102 @@
  */
 #include "postgres.h"
 
+#include "access/amapi.h"
 #include "access/heapam.h"
 #include "access/relscan.h"
+#include "access/visibilitymap.h"
 #include "storage/predicate.h"
+#include "utils/pgstat_internal.h"
 
 
+static bool heapam_index_plain_tuple_getnext_slot(IndexScanDesc scan,
+												  ScanDirection direction,
+												  TupleTableSlot *slot);
+static bool heapam_index_only_tuple_getnext_slot(IndexScanDesc scan,
+												 ScanDirection direction,
+												 TupleTableSlot *slot);
+static pg_always_inline bool heapam_index_getnext_slot(IndexScanDesc scan,
+													   ScanDirection direction,
+													   TupleTableSlot *slot,
+													   bool index_only);
+static pg_always_inline bool heapam_index_heap_fetch(IndexScanDesc scan, IndexScanHeapData *hscan,
+													 TupleTableSlot *slot, bool index_only,
+													 bool *heap_continue, bool *all_dead);
+static pg_noinline void heapam_index_kill_item(IndexScanDesc scan);
+
+/*
+ * Simple, single-shot TID lookup for constraint enforcement code (unique
+ * checks and similar).  This is essentially just a heap_hot_search_buffer
+ * wrapper.
+ *
+ * This isn't actually related to index scans, but keeping it near
+ * heap_hot_search_buffer can help the compiler generate better code.
+ */
+bool
+heapam_fetch_tid(Relation rel, ItemPointer tid, Snapshot snapshot,
+				 TupleTableSlot *slot, bool *all_dead)
+{
+	BufferHeapTupleTableSlot *bslot = (BufferHeapTupleTableSlot *) slot;
+	Buffer		buf;
+	bool		found;
+
+	Assert(TTS_IS_BUFFERTUPLE(slot));
+
+	buf = ReadBuffer(rel, ItemPointerGetBlockNumber(tid));
+
+	LockBuffer(buf, BUFFER_LOCK_SHARE);
+	found = heap_hot_search_buffer(tid, rel, buf, snapshot,
+								   &bslot->base.tupdata, all_dead, true);
+	bslot->base.tupdata.t_self = *tid;
+	LockBuffer(buf, BUFFER_LOCK_UNLOCK);
+
+	if (found)
+	{
+		ExecStorePinnedBufferHeapTuple(&bslot->base.tupdata, slot, buf);
+		Assert(slot->tts_tableOid == RelationGetRelid(rel));
+	}
+	else
+		ReleaseBuffer(buf);
+
+	return found;
+}
+
 /* ------------------------------------------------------------------------
  * Index Scan Callbacks for heap AM
  * ------------------------------------------------------------------------
  */
 
-IndexFetchTableData *
-heapam_index_fetch_begin(Relation rel, uint32 flags)
+void
+heapam_index_scan_begin(IndexScanDesc scan, uint32 flags)
 {
-	IndexFetchHeapData *hscan = palloc0_object(IndexFetchHeapData);
+	IndexScanHeapData *hscan = palloc0_object(IndexScanHeapData);
 
-	hscan->xs_base.rel = rel;
-	hscan->xs_base.flags = flags;
 	hscan->xs_cbuf = InvalidBuffer;
 	hscan->xs_blk = InvalidBlockNumber;
 	hscan->xs_vmbuffer = InvalidBuffer;
 
-	return &hscan->xs_base;
+	/* Remember if scan is read-only */
+	hscan->xs_readonly = (flags & SO_HINT_REL_READ_ONLY) != 0;
+
+	/* Resolve which xs_getnext_slot implementation to use for this scan */
+	if (scan->xs_want_itup)
+		scan->xs_getnext_slot = heapam_index_only_tuple_getnext_slot;
+	else
+		scan->xs_getnext_slot = heapam_index_plain_tuple_getnext_slot;
+
+	/* Expose heapam's private scan state through the scan's opaque pointer */
+	scan->xs_table_opaque = hscan;
 }
 
 void
-heapam_index_fetch_reset(IndexFetchTableData *scan)
+heapam_index_scan_reset(IndexScanDesc scan)
 {
+	IndexScanHeapData *hscan = (IndexScanHeapData *) scan->xs_table_opaque;
+
+	/* Heap fetches from the last rescan don't count towards this limit  */
+	hscan->xs_blkswitch_count = 0;
+
 	/*
-	 * Resets are a no-op.
-	 *
 	 * Deliberately avoid dropping pins now held in xs_cbuf and xs_vmbuffer.
 	 * This saves cycles during certain tight nested loop joins (it can avoid
 	 * repeated pinning and unpinning of the same buffer across rescans).
@@ -51,9 +117,9 @@ heapam_index_fetch_reset(IndexFetchTableData *scan)
 }
 
 void
-heapam_index_fetch_end(IndexFetchTableData *scan)
+heapam_index_scan_end(IndexScanDesc scan)
 {
-	IndexFetchHeapData *hscan = (IndexFetchHeapData *) scan;
+	IndexScanHeapData *hscan = (IndexScanHeapData *) scan->xs_table_opaque;
 
 	/* drop pin if there's a pinned heap page */
 	if (BufferIsValid(hscan->xs_cbuf))
@@ -228,18 +294,189 @@ heap_hot_search_buffer(ItemPointer tid, Relation relation, Buffer buffer,
 	return false;
 }
 
-bool
-heapam_index_fetch_tuple(struct IndexFetchTableData *scan,
-						 ItemPointer tid,
-						 Snapshot snapshot,
-						 TupleTableSlot *slot,
-						 bool *heap_continue, bool *all_dead)
+/* xs_getnext_slot callback: amgettuple, plain index scan */
+static bool
+heapam_index_plain_tuple_getnext_slot(IndexScanDesc scan,
+									  ScanDirection direction,
+									  TupleTableSlot *slot)
 {
-	IndexFetchHeapData *hscan = (IndexFetchHeapData *) scan;
-	BufferHeapTupleTableSlot *bslot = (BufferHeapTupleTableSlot *) slot;
+	Assert(!scan->xs_want_itup);
+	Assert(scan->indexRelation->rd_indam->amgettuple != NULL);
+
+	return heapam_index_getnext_slot(scan, direction, slot, false);
+}
+
+/* xs_getnext_slot callback: amgettuple, index-only scan */
+static bool
+heapam_index_only_tuple_getnext_slot(IndexScanDesc scan,
+									 ScanDirection direction,
+									 TupleTableSlot *slot)
+{
+	Assert(scan->xs_want_itup);
+	Assert(scan->indexRelation->rd_indam->amgettuple != NULL);
+
+	return heapam_index_getnext_slot(scan, direction, slot, true);
+}
+
+/*
+ * Common implementation for both heapam_index_*_getnext_slot variants.
+ *
+ * The result is true if a tuple satisfying the scan keys and the snapshot was
+ * found, false otherwise.  On success the slot is filled: for plain index
+ * scans with the heap tuple; for index-only scans with the index data (from
+ * xs_itup/xs_hitup, via index_fill_ios_slot).
+ *
+ * On success, resources (like buffer pins) are likely to be held, and will be
+ * dropped by a future call here (or by a later call to heapam_index_scan_end
+ * through index_endscan).
+ *
+ * The index_only parameter is a compile-time constant at each call site,
+ * allowing the compiler to specialize the code for each variant.
+ */
+static pg_always_inline bool
+heapam_index_getnext_slot(IndexScanDesc scan, ScanDirection direction,
+						  TupleTableSlot *slot, bool index_only)
+{
+	IndexScanHeapData *hscan = (IndexScanHeapData *) scan->xs_table_opaque;
+	bool	   *heap_continue = &scan->xs_heap_continue;
+	bool		all_visible = false;
+	ItemPointer tid = NULL;
+
+	Assert(TransactionIdIsValid(RecentXmin));
+	Assert(index_only || scan->xs_visited_pages_limit == 0);
+
+	for (;;)
+	{
+		if (!*heap_continue)
+		{
+			/* Get the next TID from the index */
+			tid = index_getnext_tid(scan, direction);
+
+			/* If we're out of index entries, we're done */
+			if (tid == NULL)
+				break;
+
+			/* For index-only scans, check the visibility map */
+			if (index_only)
+				all_visible = VM_ALL_VISIBLE(scan->heapRelation,
+											 ItemPointerGetBlockNumber(tid),
+											 &hscan->xs_vmbuffer);
+		}
+
+		Assert(ItemPointerIsValid(&scan->xs_heaptid));
+
+		if (!index_only || !all_visible)
+		{
+			bool		all_dead;
+
+			/*
+			 * Plain index scan, or index-only scan that requires a heap fetch
+			 * to verify item's visibility
+			 */
+			if (index_only && scan->instrument)
+				scan->instrument->ntabletuplefetches++;
+
+			if (!heapam_index_heap_fetch(scan, hscan,
+										 index_only ? NULL : slot,
+										 index_only, heap_continue,
+										 &all_dead))
+			{
+				/* No visible tuple */
+				if (unlikely(all_dead))
+					heapam_index_kill_item(scan);
+
+				/*
+				 * If caller set a visited-pages limit (only selfuncs.c's
+				 * index-only scans do this), give up once the scan has
+				 * switched heap pages too many times
+				 */
+				if (index_only && unlikely(scan->xs_visited_pages_limit > 0) &&
+					hscan->xs_blkswitch_count > scan->xs_visited_pages_limit)
+					return false;	/* give up */
+
+				continue;		/* try next index entry */
+			}
+
+			/* Found a visible tuple, so its HOT chain can't be all-dead */
+			Assert(!all_dead);
+		}
+		else
+		{
+			/*
+			 * Index-only scan with all-visible item.
+			 *
+			 * We won't access the heap, so we'll need to take a predicate
+			 * lock explicitly, as if we had.  For now we do that at page
+			 * level.
+			 */
+			PredicateLockPage(scan->heapRelation,
+							  ItemPointerGetBlockNumber(tid),
+							  scan->xs_snapshot);
+		}
+
+		/*
+		 * Found a tuple to return.
+		 *
+		 * Index-only scans fill caller's slot from the index data the AM
+		 * returned (in scan->xs_itup or xs_hitup); plain index scans already
+		 * had heapam_index_heap_fetch store the heap tuple (just after it
+		 * reset the scan's slot, which also needs separate handling here).
+		 */
+		if (index_only)
+		{
+			ExecClearTuple(slot);
+			index_fill_ios_slot(scan, slot);
+		}
+
+		return true;
+	}
+
+	return false;
+}
+
+/*
+ * Get the scan's next heap tuple.
+ *
+ * The result is a visible heap tuple associated with the index TID most
+ * recently fetched by our caller in scan->xs_heaptid, or NULL if no more
+ * matching tuples exist.  (There can be more than one matching tuple because
+ * of HOT chains, although when using an MVCC snapshot it should be impossible
+ * for more than one such tuple to exist.)
+ *
+ * On success, the buffer containing the heap tup is pinned.  The pin must be
+ * dropped elsewhere.
+ */
+static pg_always_inline bool
+heapam_index_heap_fetch(IndexScanDesc scan, IndexScanHeapData *hscan,
+						TupleTableSlot *slot, bool index_only,
+						bool *heap_continue, bool *all_dead)
+{
+	Relation	rel = scan->heapRelation;
+	ItemPointer tid = &scan->xs_heaptid;
+	Snapshot	snapshot = scan->xs_snapshot;
+	HeapTupleData idxtupdata;
+	HeapTuple	heapTuple;
 	bool		got_heap_tuple;
 
-	Assert(TTS_IS_BUFFERTUPLE(slot));
+	*all_dead = false;
+
+	if (!index_only)
+	{
+		/* Plain index scans have us store fetched tuple in their slot */
+		BufferHeapTupleTableSlot *bslot = (BufferHeapTupleTableSlot *) slot;
+
+		Assert(TTS_IS_BUFFERTUPLE(slot));
+		heapTuple = &bslot->base.tupdata;
+	}
+	else
+	{
+		/*
+		 * Index-only scans just need us to verify tuple visibility, so don't
+		 * pass us a slot
+		 */
+		pg_assume(slot == NULL);
+		heapTuple = &idxtupdata;
+	}
 
 	/* We can skip the buffer-switching logic if we're on the same page. */
 	if (hscan->xs_blk != ItemPointerGetBlockNumber(tid))
@@ -249,17 +486,21 @@ heapam_index_fetch_tuple(struct IndexFetchTableData *scan,
 		/* Remember this buffer's block number for next time */
 		hscan->xs_blk = ItemPointerGetBlockNumber(tid);
 
+		/*
+		 * We're switching to a new heap block, so count it
+		 */
+		hscan->xs_blkswitch_count++;
+
 		if (BufferIsValid(hscan->xs_cbuf))
 			ReleaseBuffer(hscan->xs_cbuf);
 
-		hscan->xs_cbuf = ReadBuffer(hscan->xs_base.rel, hscan->xs_blk);
+		hscan->xs_cbuf = ReadBuffer(rel, hscan->xs_blk);
 
 		/*
 		 * Prune page when it is pinned for the first time
 		 */
-		heap_page_prune_opt(hscan->xs_base.rel, hscan->xs_cbuf,
-							&hscan->xs_vmbuffer,
-							hscan->xs_base.flags & SO_HINT_REL_READ_ONLY);
+		heap_page_prune_opt(rel, hscan->xs_cbuf, &hscan->xs_vmbuffer,
+							hscan->xs_readonly);
 	}
 
 	Assert(BufferGetBlockNumber(hscan->xs_cbuf) == hscan->xs_blk);
@@ -268,25 +509,42 @@ heapam_index_fetch_tuple(struct IndexFetchTableData *scan,
 	/* Obtain share-lock on the buffer so we can examine visibility */
 	LockBuffer(hscan->xs_cbuf, BUFFER_LOCK_SHARE);
 	got_heap_tuple = heap_hot_search_buffer(tid,
-											hscan->xs_base.rel,
+											rel,
 											hscan->xs_cbuf,
 											snapshot,
-											&bslot->base.tupdata,
+											heapTuple,
 											all_dead,
 											!*heap_continue);
-	bslot->base.tupdata.t_self = *tid;
+	heapTuple->t_self = *tid;
 	LockBuffer(hscan->xs_cbuf, BUFFER_LOCK_UNLOCK);
 
 	if (got_heap_tuple)
 	{
-		/*
-		 * Only in a non-MVCC snapshot can more than one member of the HOT
-		 * chain be visible.
-		 */
-		*heap_continue = !IsMVCCLikeSnapshot(snapshot);
+		if (!index_only)
+		{
+			/*
+			 * Only in a non-MVCC snapshot plain scan can more than one member
+			 * of the HOT chain be visible
+			 */
+			*heap_continue = !IsMVCCLikeSnapshot(snapshot);
 
-		slot->tts_tableOid = RelationGetRelid(scan->rel);
-		ExecStoreBufferHeapTuple(&bslot->base.tupdata, slot, hscan->xs_cbuf);
+			ExecStoreBufferHeapTuple(heapTuple, slot, hscan->xs_cbuf);
+
+			Assert(slot->tts_tableOid == RelationGetRelid(rel));
+		}
+		else
+		{
+			/*
+			 * Index-only scans unconditionally assume that the first member
+			 * of a HOT chain should be considered visible.  This is the
+			 * normal MVCC snapshot behavior, and works well enough for
+			 * non-MVCC index-only scans (currently the only core code that
+			 * uses a non-MVCC index-only scan is selfuncs.c).
+			 */
+			*heap_continue = false;
+		}
+
+		pgstat_count_heap_fetch(scan->indexRelation);
 	}
 	else
 	{
@@ -296,3 +554,23 @@ heapam_index_fetch_tuple(struct IndexFetchTableData *scan,
 
 	return got_heap_tuple;
 }
+
+/*
+ * Called when we scanned a whole HOT chain and found only dead tuples:
+ * arrange for the index AM to kill its entry for that TID.  We do not do this
+ * when in recovery because it may violate MVCC to do so.  See comments in
+ * RelationGetIndexScan().
+ */
+static pg_noinline void
+heapam_index_kill_item(IndexScanDesc scan)
+{
+	if (scan->xactStartedInRecovery)
+		return;
+
+	/*
+	 * Tell amgettuple-based index AM to kill its entry for that TID.  The
+	 * next index_getnext_tid call will pass that along to the index AM,
+	 * before unsetting the flag again.
+	 */
+	scan->kill_prior_tuple = true;
+}
diff --git a/src/backend/access/heap/visibilitymap.c b/src/backend/access/heap/visibilitymap.c
index b152189d4..15bd5cf71 100644
--- a/src/backend/access/heap/visibilitymap.c
+++ b/src/backend/access/heap/visibilitymap.c
@@ -318,7 +318,32 @@ visibilitymap_set(BlockNumber heapBlk,
  * since we don't lock the visibility map page either, it's even possible that
  * someone else could have changed the bit just before we look at it, but yet
  * we might see the old value.  It is the caller's responsibility to deal with
- * all concurrency issues!
+ * all concurrency issues!  In practice it can't be stale enough to matter for
+ * the primary use case: index-only scans that check whether a heap fetch can
+ * be skipped.
+ *
+ * The argument for why it can't be stale enough to matter for the primary use
+ * case is as follows:
+ *
+ * Inserts: we need to detect that a VM bit was cleared by an insert right
+ * away, because the new tuple is present in the index but not yet visible.
+ * Reading the TID from the index page (under a shared lock on the index
+ * buffer) is serialized with the insertion of the TID into the index (under
+ * an exclusive lock on the same index buffer).  Because the VM bit is cleared
+ * before the index is updated, and locking/unlocking of the index page acts
+ * as a full memory barrier, we are sure to see the cleared bit whenever we
+ * see a recently-inserted TID.
+ *
+ * Deletes: the clearing of the VM bit by a delete is NOT serialized with the
+ * index page access, because deletes do not update the index page (only
+ * VACUUM removes the index TID).  So we may see a significantly stale value.
+ * However, we don't need to detect the delete right away, because the tuple
+ * remains visible until the deleting transaction commits or the statement
+ * ends (if it's our own transaction).  In either case, the lock on the VM
+ * buffer will have been released (acting as a write barrier) after clearing
+ * the bit.  And for us to have a snapshot that includes the deleting
+ * transaction (making the tuple invisible), we must have acquired
+ * ProcArrayLock after that time, acting as a read barrier.
  */
 uint8
 visibilitymap_get_status(Relation rel, BlockNumber heapBlk, Buffer *vmbuf)
diff --git a/src/backend/access/index/genam.c b/src/backend/access/index/genam.c
index 1408989c5..5fa2c3cbf 100644
--- a/src/backend/access/index/genam.c
+++ b/src/backend/access/index/genam.c
@@ -32,6 +32,7 @@
 #include "utils/acl.h"
 #include "utils/injection_point.h"
 #include "utils/lsyscache.h"
+#include "utils/memutils.h"
 #include "utils/rel.h"
 #include "utils/rls.h"
 #include "utils/ruleutils.h"
@@ -84,7 +85,7 @@ RelationGetIndexScan(Relation indexRelation, int nkeys, int norderbys)
 	scan = palloc_object(IndexScanDescData);
 
 	scan->heapRelation = NULL;	/* may be set later */
-	scan->xs_heapfetch = NULL;
+	scan->xs_table_opaque = NULL;
 	scan->indexRelation = indexRelation;
 	scan->xs_snapshot = InvalidSnapshot;	/* caller must initialize this */
 	scan->numberOfKeys = nkeys;
@@ -126,6 +127,14 @@ RelationGetIndexScan(Relation indexRelation, int nkeys, int norderbys)
 	scan->xs_hitup = NULL;
 	scan->xs_hitupdesc = NULL;
 
+	scan->xs_getnext_slot = NULL;
+
+	scan->xs_name_cstring_attnums = NULL;
+	scan->xs_name_cstring_count = 0;
+	scan->xs_name_cstring_cxt = NULL;
+
+	scan->xs_visited_pages_limit = 0;
+
 	return scan;
 }
 
@@ -148,6 +157,10 @@ IndexScanEnd(IndexScanDesc scan)
 		pfree(scan->keyData);
 	if (scan->orderByData != NULL)
 		pfree(scan->orderByData);
+	if (scan->xs_name_cstring_attnums != NULL)
+		pfree(scan->xs_name_cstring_attnums);
+	if (scan->xs_name_cstring_cxt != NULL)
+		MemoryContextDelete(scan->xs_name_cstring_cxt);
 
 	pfree(scan);
 }
@@ -454,7 +467,7 @@ systable_beginscan(Relation heapRelation,
 				elog(ERROR, "column is not in index");
 		}
 
-		sysscan->iscan = index_beginscan(heapRelation, irel,
+		sysscan->iscan = index_beginscan(heapRelation, irel, false,
 										 snapshot, NULL, nkeys, 0,
 										 SO_NONE);
 		index_rescan(sysscan->iscan, idxkey, nkeys, NULL, 0);
@@ -518,7 +531,8 @@ systable_getnext(SysScanDesc sysscan)
 
 	if (sysscan->irel)
 	{
-		if (index_getnext_slot(sysscan->iscan, ForwardScanDirection, sysscan->slot))
+		if (table_index_getnext_slot(sysscan->iscan, ForwardScanDirection,
+									 sysscan->slot))
 		{
 			bool		shouldFree;
 
@@ -643,7 +657,7 @@ systable_endscan(SysScanDesc sysscan)
  * we could do a heapscan and sort, but the uses are in places that
  * probably don't need to still work with corrupted catalog indexes.)
  * For the moment, therefore, these functions are merely the thinest of
- * wrappers around index_beginscan/index_getnext_slot.  The main reason for
+ * wrappers around index_beginscan/table_index_getnext_slot.  The main reason for
  * their existence is to centralize possible future support of lossy operators
  * in catalog scans.
  */
@@ -716,7 +730,7 @@ systable_beginscan_ordered(Relation heapRelation,
 	if (TransactionIdIsValid(CheckXidAlive))
 		bsysscan = true;
 
-	sysscan->iscan = index_beginscan(heapRelation, indexRelation,
+	sysscan->iscan = index_beginscan(heapRelation, indexRelation, false,
 									 snapshot, NULL, nkeys, 0,
 									 SO_NONE);
 	index_rescan(sysscan->iscan, idxkey, nkeys, NULL, 0);
@@ -736,7 +750,7 @@ systable_getnext_ordered(SysScanDesc sysscan, ScanDirection direction)
 	HeapTuple	htup = NULL;
 
 	Assert(sysscan->irel);
-	if (index_getnext_slot(sysscan->iscan, direction, sysscan->slot))
+	if (table_index_getnext_slot(sysscan->iscan, direction, sysscan->slot))
 		htup = ExecFetchSlotHeapTuple(sysscan->slot, false, NULL);
 
 	/* See notes in systable_getnext */
diff --git a/src/backend/access/index/indexam.c b/src/backend/access/index/indexam.c
index 7967e9398..d0ad19f11 100644
--- a/src/backend/access/index/indexam.c
+++ b/src/backend/access/index/indexam.c
@@ -24,15 +24,14 @@
  *		index_parallelscan_initialize - initialize parallel scan
  *		index_parallelrescan  - (re)start a parallel scan of an index
  *		index_beginscan_parallel - join parallel index scan
- *		index_getnext_tid	- get the next TID from a scan
- *		index_fetch_heap		- get the scan's next heap tuple
- *		index_getnext_slot	- get the next tuple from a scan
+ *		index_getnext_tid	- amgettuple table AM helper routine
  *		index_getbitmap - get all tuples from a scan
  *		index_bulk_delete	- bulk deletion of index tuples
  *		index_vacuum_cleanup	- post-deletion cleanup of an index
  *		index_can_return	- does index support index-only scans?
  *		index_getprocid - get a support procedure OID
  *		index_getprocinfo - get a support procedure's lookup info
+ *		index_fill_ios_slot - fill an index-only scan's result slot
  *
  * NOTES
  *		This file contains the index_ routines which used
@@ -44,17 +43,21 @@
 #include "postgres.h"
 
 #include "access/amapi.h"
+#include "access/htup_details.h"
 #include "access/relation.h"
 #include "access/reloptions.h"
 #include "access/relscan.h"
 #include "access/tableam.h"
 #include "catalog/index.h"
 #include "catalog/pg_type.h"
+#include "executor/tuptable.h"
 #include "nodes/execnodes.h"
 #include "pgstat.h"
 #include "storage/lmgr.h"
 #include "storage/lock.h"
 #include "storage/predicate.h"
+#include "utils/builtins.h"
+#include "utils/memutils.h"
 #include "utils/ruleutils.h"
 #include "utils/snapmgr.h"
 #include "utils/syscache.h"
@@ -105,9 +108,16 @@ do { \
 			 CppAsString(pname), RelationGetRelationName(scan->indexRelation)); \
 } while(0)
 
-static IndexScanDesc index_beginscan_internal(Relation indexRelation,
-											  int nkeys, int norderbys, Snapshot snapshot,
-											  ParallelIndexScanDesc pscan, bool temp_snap);
+static pg_always_inline IndexScanDesc index_beginscan_internal(Relation indexRelation,
+															   Relation heapRelation,
+															   int nkeys,
+															   int norderbys,
+															   Snapshot snapshot,
+															   ParallelIndexScanDesc pscan,
+															   IndexScanInstrumentation *instrument,
+															   bool index_only_scan,
+															   bool temp_snap,
+															   uint32 flags);
 static inline void validate_relation_as_index(Relation r);
 
 
@@ -256,13 +266,12 @@ index_insert_cleanup(Relation indexRelation,
 IndexScanDesc
 index_beginscan(Relation heapRelation,
 				Relation indexRelation,
+				bool index_only_scan,
 				Snapshot snapshot,
 				IndexScanInstrumentation *instrument,
 				int nkeys, int norderbys,
 				uint32 flags)
 {
-	IndexScanDesc scan;
-
 	Assert(snapshot != InvalidSnapshot);
 
 	/* Check that a historic snapshot is not used for non-catalog tables */
@@ -275,20 +284,10 @@ index_beginscan(Relation heapRelation,
 						RelationGetRelationName(heapRelation))));
 	}
 
-	scan = index_beginscan_internal(indexRelation, nkeys, norderbys, snapshot, NULL, false);
-
-	/*
-	 * Save additional parameters into the scandesc.  Everything else was set
-	 * up by RelationGetIndexScan.
-	 */
-	scan->heapRelation = heapRelation;
-	scan->xs_snapshot = snapshot;
-	scan->instrument = instrument;
-
-	/* prepare to fetch index matches from table */
-	scan->xs_heapfetch = table_index_fetch_begin(heapRelation, flags);
-
-	return scan;
+	return index_beginscan_internal(indexRelation, heapRelation,
+									nkeys, norderbys,
+									snapshot, NULL, instrument,
+									index_only_scan, false, flags);
 }
 
 /*
@@ -303,29 +302,24 @@ index_beginscan_bitmap(Relation indexRelation,
 					   IndexScanInstrumentation *instrument,
 					   int nkeys)
 {
-	IndexScanDesc scan;
-
 	Assert(snapshot != InvalidSnapshot);
+	Assert(IsMVCCLikeSnapshot(snapshot));
 
-	scan = index_beginscan_internal(indexRelation, nkeys, 0, snapshot, NULL, false);
-
-	/*
-	 * Save additional parameters into the scandesc.  Everything else was set
-	 * up by RelationGetIndexScan.
-	 */
-	scan->xs_snapshot = snapshot;
-	scan->instrument = instrument;
-
-	return scan;
+	return index_beginscan_internal(indexRelation, NULL, nkeys, 0, snapshot,
+									NULL, instrument, false, false, SO_NONE);
 }
 
 /*
  * index_beginscan_internal --- common code for index_beginscan variants
+ *
+ * When heapRelation is not NULL, also initializes table AM index scan state.
  */
-static IndexScanDesc
-index_beginscan_internal(Relation indexRelation,
+static pg_always_inline IndexScanDesc
+index_beginscan_internal(Relation indexRelation, Relation heapRelation,
 						 int nkeys, int norderbys, Snapshot snapshot,
-						 ParallelIndexScanDesc pscan, bool temp_snap)
+						 ParallelIndexScanDesc pscan,
+						 IndexScanInstrumentation *instrument,
+						 bool index_only_scan, bool temp_snap, uint32 flags)
 {
 	IndexScanDesc scan;
 
@@ -349,6 +343,66 @@ index_beginscan_internal(Relation indexRelation,
 	scan->parallel_scan = pscan;
 	scan->xs_temp_snap = temp_snap;
 
+	scan->xs_snapshot = snapshot;
+	scan->instrument = instrument;
+
+	/*
+	 * Initialize heap-side scan state when a heap relation is provided.
+	 * Bitmap index scans don't provide one.
+	 */
+	if (heapRelation != NULL)
+	{
+		scan->heapRelation = heapRelation;
+		scan->xs_want_itup = index_only_scan;
+		scan->xs_heap_continue = false;
+
+		/*
+		 * The "name" type's btree opclass stores index keys as cstrings
+		 * rather than names to save space, so keys returned by an index-only
+		 * scan must be re-padded to NAMEDATALEN allocations.  Set up the
+		 * state index_fill_ios_slot uses to do that.  We detect such columns
+		 * generically (stored type CSTRINGOID, opclass input type NAMEOID) in
+		 * case other opclasses adopt the same optimization.
+		 */
+		if (index_only_scan)
+		{
+			int			indnkeyatts = indexRelation->rd_index->indnkeyatts;
+			int			namecount = 0;
+
+			for (int attnum = 0; attnum < indnkeyatts; attnum++)
+			{
+				if (TupleDescAttr(indexRelation->rd_att, attnum)->atttypid == CSTRINGOID &&
+					indexRelation->rd_opcintype[attnum] == NAMEOID)
+					namecount++;
+			}
+
+			if (unlikely(namecount > 0))
+			{
+				int			idx = 0;
+
+				scan->xs_name_cstring_attnums = palloc_array(AttrNumber, namecount);
+				for (int attnum = 0; attnum < indnkeyatts; attnum++)
+				{
+					if (TupleDescAttr(indexRelation->rd_att, attnum)->atttypid == CSTRINGOID &&
+						indexRelation->rd_opcintype[attnum] == NAMEOID)
+						scan->xs_name_cstring_attnums[idx++] = (AttrNumber) attnum;
+				}
+
+				scan->xs_name_cstring_count = namecount;
+				scan->xs_name_cstring_cxt =
+					AllocSetContextCreate(CurrentMemoryContext,
+										  "index-only scan name columns",
+										  ALLOCSET_SMALL_SIZES);
+			}
+		}
+
+		/* set up table AM state for the index scan (sets xs_table_opaque) */
+		table_index_scan_begin(scan, flags);
+
+		/* table AM must set these for us */
+		Assert(scan->xs_getnext_slot != NULL && scan->xs_table_opaque != NULL);
+	}
+
 	return scan;
 }
 
@@ -376,8 +430,8 @@ index_rescan(IndexScanDesc scan,
 	Assert(norderbys == scan->numberOfOrderBys);
 
 	/* reset table AM state for rescan */
-	if (scan->xs_heapfetch)
-		table_index_fetch_reset(scan->xs_heapfetch);
+	if (scan->xs_table_opaque)
+		table_index_scan_reset(scan);
 
 	scan->kill_prior_tuple = false; /* for safety */
 	scan->xs_heap_continue = false;
@@ -397,10 +451,10 @@ index_endscan(IndexScanDesc scan)
 	CHECK_SCAN_PROCEDURE(amendscan);
 
 	/* Release resources (like buffer pins) from table accesses */
-	if (scan->xs_heapfetch)
+	if (scan->xs_table_opaque)
 	{
-		table_index_fetch_end(scan->xs_heapfetch);
-		scan->xs_heapfetch = NULL;
+		table_index_scan_end(scan);
+		scan->xs_table_opaque = NULL;
 	}
 
 	/* End the AM's scan */
@@ -453,8 +507,8 @@ index_restrpos(IndexScanDesc scan)
 	CHECK_SCAN_PROCEDURE(amrestrpos);
 
 	/* reset table AM state for restoring the marked position */
-	if (scan->xs_heapfetch)
-		table_index_fetch_reset(scan->xs_heapfetch);
+	if (scan->xs_table_opaque)
+		table_index_scan_reset(scan);
 
 	scan->kill_prior_tuple = false; /* for safety */
 	scan->xs_heap_continue = false;
@@ -540,8 +594,8 @@ index_parallelrescan(IndexScanDesc scan)
 	SCAN_CHECKS;
 
 	/* reset table AM state for rescan */
-	if (scan->xs_heapfetch)
-		table_index_fetch_reset(scan->xs_heapfetch);
+	if (scan->xs_table_opaque)
+		table_index_scan_reset(scan);
 
 	/* amparallelrescan is optional; assume no-op if not provided by AM */
 	if (scan->indexRelation->rd_indam->amparallelrescan != NULL)
@@ -558,41 +612,33 @@ index_parallelrescan(IndexScanDesc scan)
  */
 IndexScanDesc
 index_beginscan_parallel(Relation heaprel, Relation indexrel,
+						 bool index_only_scan,
 						 IndexScanInstrumentation *instrument,
 						 int nkeys, int norderbys,
 						 ParallelIndexScanDesc pscan,
 						 uint32 flags)
 {
 	Snapshot	snapshot;
-	IndexScanDesc scan;
 
 	Assert(RelFileLocatorEquals(heaprel->rd_locator, pscan->ps_locator));
 	Assert(RelFileLocatorEquals(indexrel->rd_locator, pscan->ps_indexlocator));
 
 	snapshot = RestoreSnapshot(pscan->ps_snapshot_data);
 	RegisterSnapshot(snapshot);
-	scan = index_beginscan_internal(indexrel, nkeys, norderbys, snapshot,
-									pscan, true);
 
-	/*
-	 * Save additional parameters into the scandesc.  Everything else was set
-	 * up by index_beginscan_internal.
-	 */
-	scan->heapRelation = heaprel;
-	scan->xs_snapshot = snapshot;
-	scan->instrument = instrument;
-
-	/* prepare to fetch index matches from table */
-	scan->xs_heapfetch = table_index_fetch_begin(heaprel, flags);
-
-	return scan;
+	return index_beginscan_internal(indexrel, heaprel, nkeys, norderbys,
+									snapshot, pscan, instrument,
+									index_only_scan, true, flags);
 }
 
 /* ----------------
- * index_getnext_tid - get the next TID from a scan
+ * index_getnext_tid - amgettuple interface
  *
  * The result is the next TID satisfying the scan keys,
  * or NULL if no more matching tuples exist.
+ *
+ * This should only be called by table AM amgettuple-based index scan
+ * callbacks.
  * ----------------
  */
 ItemPointer
@@ -622,8 +668,8 @@ index_getnext_tid(IndexScanDesc scan, ScanDirection direction)
 	if (!found)
 	{
 		/* reset table AM state */
-		if (scan->xs_heapfetch)
-			table_index_fetch_reset(scan->xs_heapfetch);
+		if (scan->xs_table_opaque)
+			table_index_scan_reset(scan);
 
 		return NULL;
 	}
@@ -635,97 +681,6 @@ index_getnext_tid(IndexScanDesc scan, ScanDirection direction)
 	return &scan->xs_heaptid;
 }
 
-/* ----------------
- *		index_fetch_heap - get the scan's next heap tuple
- *
- * The result is a visible heap tuple associated with the index TID most
- * recently fetched by index_getnext_tid, or NULL if no more matching tuples
- * exist.  (There can be more than one matching tuple because of HOT chains,
- * although when using an MVCC snapshot it should be impossible for more than
- * one such tuple to exist.)
- *
- * On success, the buffer containing the heap tup is pinned (the pin will be
- * dropped in a future index_getnext_tid, index_fetch_heap or index_endscan
- * call).
- *
- * Note: caller must check scan->xs_recheck, and perform rechecking of the
- * scan keys if required.  We do not do that here because we don't have
- * enough information to do it efficiently in the general case.
- * ----------------
- */
-bool
-index_fetch_heap(IndexScanDesc scan, TupleTableSlot *slot)
-{
-	bool		all_dead = false;
-	bool		found;
-
-	found = table_index_fetch_tuple(scan->xs_heapfetch, &scan->xs_heaptid,
-									scan->xs_snapshot, slot,
-									&scan->xs_heap_continue, &all_dead);
-
-	if (found)
-		pgstat_count_heap_fetch(scan->indexRelation);
-
-	/*
-	 * If we scanned a whole HOT chain and found only dead tuples, tell index
-	 * AM to kill its entry for that TID (this will take effect in the next
-	 * amgettuple call, in index_getnext_tid).  We do not do this when in
-	 * recovery because it may violate MVCC to do so.  See comments in
-	 * RelationGetIndexScan().
-	 */
-	if (!scan->xactStartedInRecovery)
-		scan->kill_prior_tuple = all_dead;
-
-	return found;
-}
-
-/* ----------------
- *		index_getnext_slot - get the next tuple from a scan
- *
- * The result is true if a tuple satisfying the scan keys and the snapshot was
- * found, false otherwise.  The tuple is stored in the specified slot.
- *
- * On success, resources (like buffer pins) are likely to be held, and will be
- * dropped by a future index_getnext_tid, index_fetch_heap or index_endscan
- * call).
- *
- * Note: caller must check scan->xs_recheck, and perform rechecking of the
- * scan keys if required.  We do not do that here because we don't have
- * enough information to do it efficiently in the general case.
- * ----------------
- */
-bool
-index_getnext_slot(IndexScanDesc scan, ScanDirection direction, TupleTableSlot *slot)
-{
-	for (;;)
-	{
-		if (!scan->xs_heap_continue)
-		{
-			ItemPointer tid;
-
-			/* Time to fetch the next TID from the index */
-			tid = index_getnext_tid(scan, direction);
-
-			/* If we're out of index entries, we're done */
-			if (tid == NULL)
-				break;
-
-			Assert(ItemPointerEquals(tid, &scan->xs_heaptid));
-		}
-
-		/*
-		 * Fetch the next (or only) visible heap tuple for this index entry.
-		 * If we don't find anything, loop around and grab the next TID from
-		 * the index.
-		 */
-		Assert(ItemPointerIsValid(&scan->xs_heaptid));
-		if (index_fetch_heap(scan, slot))
-			return true;
-	}
-
-	return false;
-}
-
 /* ----------------
  *		index_getbitmap - get all tuples at once from an index scan
  *
@@ -941,6 +896,89 @@ index_getprocinfo(Relation irel,
 	return locinfo;
 }
 
+/* ----------------
+ *		index_fill_ios_slot
+ *
+ *		Fill an index-only scan's result slot from the data the index AM
+ *		returned.
+ *
+ *		The data is provided in either HeapTuple (xs_hitup) or IndexTuple
+ *		(xs_itup) format.  An amgettuple index AM may fill both, in which
+ *		case the heap format is used, since it's a bit cheaper to fill a
+ *		slot from.
+ *
+ *		This is only intended to be called by table AMs, in their
+ *		xs_getnext_slot callbacks.
+ * ----------------
+ */
+void
+index_fill_ios_slot(IndexScanDesc scan, TupleTableSlot *slot)
+{
+	/* Caller is required to call ExecClearTuple before calling here */
+	Assert(TTS_EMPTY(slot));
+
+	/*
+	 * We must deform the tuple using the tupdesc the index AM formed it with
+	 * (xs_hitupdesc or xs_itupdesc), not the slot's tupdesc.  The datums
+	 * returned by the index AM must be binary compatible, but the descriptors
+	 * may align each column differently in certain rare cases. (Actually,
+	 * btree's "name" opclass stores cstring tuples that _aren't_ even binary
+	 * compatible, in the strictest sense.  We directly handle that here.)
+	 */
+	if (scan->xs_hitup)
+	{
+		Assert(slot->tts_tupleDescriptor->natts == scan->xs_hitupdesc->natts);
+
+		heap_deform_tuple(scan->xs_hitup, scan->xs_hitupdesc,
+						  slot->tts_values, slot->tts_isnull);
+	}
+	else if (scan->xs_itup)
+	{
+		Assert(slot->tts_tupleDescriptor->natts == scan->xs_itupdesc->natts);
+
+		index_deform_tuple(scan->xs_itup, scan->xs_itupdesc,
+						   slot->tts_values, slot->tts_isnull);
+
+		/*
+		 * Copy all name columns stored as cstrings back into a NAMEDATALEN
+		 * byte sized allocation.  We mark this branch as unlikely as
+		 * generally "name" is used only for the system catalogs and this
+		 * would have to be a user query running on those or some other user
+		 * table with an index on a name column.
+		 */
+		if (unlikely(scan->xs_name_cstring_attnums != NULL))
+		{
+			/* free the previous tuple's name allocations */
+			MemoryContextReset(scan->xs_name_cstring_cxt);
+
+			for (int idx = 0; idx < scan->xs_name_cstring_count; idx++)
+			{
+				int			attnum = scan->xs_name_cstring_attnums[idx];
+				Name		name;
+
+				/* skip null Datums */
+				if (slot->tts_isnull[attnum])
+					continue;
+
+				/*
+				 * allocate the NAMEDATALEN and copy the datum into that
+				 * memory
+				 */
+				name = (Name) MemoryContextAlloc(scan->xs_name_cstring_cxt,
+												 NAMEDATALEN);
+
+				/* use namestrcpy to zero-pad all trailing bytes */
+				namestrcpy(name, DatumGetCString(slot->tts_values[attnum]));
+				slot->tts_values[attnum] = NameGetDatum(name);
+			}
+		}
+	}
+	else
+		elog(ERROR, "no data returned for index-only scan");
+
+	ExecStoreVirtualTuple(slot);
+}
+
 /* ----------------
  *		index_store_float8_orderby_distances
  *
diff --git a/src/backend/access/nbtree/nbtinsert.c b/src/backend/access/nbtree/nbtinsert.c
index c8af97dd2..f1b55fb20 100644
--- a/src/backend/access/nbtree/nbtinsert.c
+++ b/src/backend/access/nbtree/nbtinsert.c
@@ -560,9 +560,9 @@ _bt_check_unique(Relation rel, BTInsertState insertstate, Relation heapRel,
 				 * with optimizations like heap's HOT, we have just a single
 				 * index entry for the entire chain.
 				 */
-				else if (table_index_fetch_tuple_check(heapRel, &htid,
-													   &SnapshotDirty,
-													   &all_dead))
+				else if (table_fetch_tid_check(heapRel, &htid,
+											   &SnapshotDirty,
+											   &all_dead))
 				{
 					TransactionId xwait;
 
@@ -618,8 +618,8 @@ _bt_check_unique(Relation rel, BTInsertState insertstate, Relation heapRel,
 					 * entry.
 					 */
 					htid = itup->t_tid;
-					if (table_index_fetch_tuple_check(heapRel, &htid,
-													  SnapshotSelf, NULL))
+					if (table_fetch_tid_check(heapRel, &htid,
+											  SnapshotSelf, NULL))
 					{
 						/* Normal case --- it's still live */
 					}
diff --git a/src/backend/access/table/tableam.c b/src/backend/access/table/tableam.c
index 68ff0966f..5ae72d37b 100644
--- a/src/backend/access/table/tableam.c
+++ b/src/backend/access/table/tableam.c
@@ -222,49 +222,31 @@ table_beginscan_parallel_tidrange(Relation relation,
 }
 
 
-/* ----------------------------------------------------------------------------
- * Index scan related functions.
- * ----------------------------------------------------------------------------
- */
-
-/*
- * To perform that check simply start an index scan, create the necessary
- * slot, do the heap lookup, and shut everything down again. This could be
- * optimized, but is unlikely to matter from a performance POV. If there
- * frequently are live index pointers also matching a unique index key, the
- * CPU overhead of this routine is unlikely to matter.
- *
- * Note that *tid may be modified when we return true if the AM supports
- * storing multiple row versions reachable via a single index entry (like
- * heap's HOT).
- */
-bool
-table_index_fetch_tuple_check(Relation rel,
-							  ItemPointer tid,
-							  Snapshot snapshot,
-							  bool *all_dead)
-{
-	IndexFetchTableData *scan;
-	TupleTableSlot *slot;
-	bool		call_again = false;
-	bool		found;
-
-	slot = table_slot_create(rel, NULL);
-	scan = table_index_fetch_begin(rel, SO_NONE);
-	found = table_index_fetch_tuple(scan, tid, snapshot, slot, &call_again,
-									all_dead);
-	table_index_fetch_end(scan);
-	ExecDropSingleTupleTableSlot(slot);
-
-	return found;
-}
-
-
 /* ------------------------------------------------------------------------
  * Functions for non-modifying operations on individual tuples
  * ------------------------------------------------------------------------
  */
 
+/*
+ * Caller should note the table_fetch_tid warning about *tid being modified
+ * when we return true in some cases
+ */
+bool
+table_fetch_tid_check(Relation rel,
+					  ItemPointer tid,
+					  Snapshot snapshot,
+					  bool *all_dead)
+{
+	TupleTableSlot *slot;
+	bool		found;
+
+	slot = table_slot_create(rel, NULL);
+	found = table_fetch_tid(rel, tid, snapshot, slot, all_dead);
+	ExecDropSingleTupleTableSlot(slot);
+
+	return found;
+}
+
 void
 table_tuple_get_latest_tid(TableScanDesc scan, ItemPointer tid)
 {
diff --git a/src/backend/access/table/tableamapi.c b/src/backend/access/table/tableamapi.c
index 5450a27fa..72d2c662b 100644
--- a/src/backend/access/table/tableamapi.c
+++ b/src/backend/access/table/tableamapi.c
@@ -50,11 +50,11 @@ GetTableAmRoutine(Oid amhandler)
 	Assert(routine->parallelscan_initialize != NULL);
 	Assert(routine->parallelscan_reinitialize != NULL);
 
-	Assert(routine->index_fetch_begin != NULL);
-	Assert(routine->index_fetch_reset != NULL);
-	Assert(routine->index_fetch_end != NULL);
-	Assert(routine->index_fetch_tuple != NULL);
+	Assert(routine->index_scan_begin != NULL);
+	Assert(routine->index_scan_reset != NULL);
+	Assert(routine->index_scan_end != NULL);
 
+	Assert(routine->fetch_tid != NULL);
 	Assert(routine->tuple_fetch_row_version != NULL);
 	Assert(routine->tuple_tid_valid != NULL);
 	Assert(routine->tuple_get_latest_tid != NULL);
diff --git a/src/backend/commands/constraint.c b/src/backend/commands/constraint.c
index 421d8c359..7aff48124 100644
--- a/src/backend/commands/constraint.c
+++ b/src/backend/commands/constraint.c
@@ -105,23 +105,14 @@ unique_key_recheck(PG_FUNCTION_ARGS)
 	 * removed.
 	 */
 	tmptid = checktid;
+	if (!table_fetch_tid(trigdata->tg_relation, &tmptid, SnapshotSelf,
+						 slot, NULL))
 	{
-		IndexFetchTableData *scan = table_index_fetch_begin(trigdata->tg_relation,
-															SO_NONE);
-		bool		call_again = false;
-
-		if (!table_index_fetch_tuple(scan, &tmptid, SnapshotSelf, slot,
-									 &call_again, NULL))
-		{
-			/*
-			 * All rows referenced by the index entry are dead, so skip the
-			 * check.
-			 */
-			ExecDropSingleTupleTableSlot(slot);
-			table_index_fetch_end(scan);
-			return PointerGetDatum(NULL);
-		}
-		table_index_fetch_end(scan);
+		/*
+		 * All rows referenced by the index entry are dead, so skip the check
+		 */
+		ExecDropSingleTupleTableSlot(slot);
+		return PointerGetDatum(NULL);
 	}
 
 	/*
@@ -168,9 +159,8 @@ unique_key_recheck(PG_FUNCTION_ARGS)
 		/*
 		 * Note: this is not a real insert; it is a check that the index entry
 		 * that has already been inserted is unique.  Passing the tuple's tid
-		 * (i.e. unmodified by table_index_fetch_tuple()) is correct even if
-		 * the row is now dead, because that is the TID the index will know
-		 * about.
+		 * (i.e. unmodified by table_fetch_tid()) is correct even if the row
+		 * is now dead, because that is the TID the index will know about.
 		 */
 		index_insert(indexRel, values, isnull, &checktid,
 					 trigdata->tg_relation, UNIQUE_CHECK_EXISTING,
diff --git a/src/backend/commands/explain.c b/src/backend/commands/explain.c
index a40d03d35..41c928456 100644
--- a/src/backend/commands/explain.c
+++ b/src/backend/commands/explain.c
@@ -137,7 +137,7 @@ static void show_recursive_union_info(RecursiveUnionState *rstate,
 static void show_memoize_info(MemoizeState *mstate, List *ancestors,
 							  ExplainState *es);
 static void show_hashagg_info(AggState *aggstate, ExplainState *es);
-static void show_indexsearches_info(PlanState *planstate, ExplainState *es);
+static void show_indexscan_info(PlanState *planstate, ExplainState *es);
 static void show_tidbitmap_info(BitmapHeapScanState *planstate,
 								ExplainState *es);
 static void show_scan_io_usage(ScanState *planstate,
@@ -1978,7 +1978,7 @@ ExplainNode(PlanState *planstate, List *ancestors,
 			if (plan->qual)
 				show_instrumentation_count("Rows Removed by Filter", 1,
 										   planstate, es);
-			show_indexsearches_info(planstate, es);
+			show_indexscan_info(planstate, es);
 			break;
 		case T_IndexOnlyScan:
 			show_scan_qual(((IndexOnlyScan *) plan)->indexqual,
@@ -1992,15 +1992,12 @@ ExplainNode(PlanState *planstate, List *ancestors,
 			if (plan->qual)
 				show_instrumentation_count("Rows Removed by Filter", 1,
 										   planstate, es);
-			if (es->analyze)
-				ExplainPropertyFloat("Heap Fetches", NULL,
-									 planstate->instrument->ntuples2, 0, es);
-			show_indexsearches_info(planstate, es);
+			show_indexscan_info(planstate, es);
 			break;
 		case T_BitmapIndexScan:
 			show_scan_qual(((BitmapIndexScan *) plan)->indexqualorig,
 						   "Index Cond", planstate, ancestors, es);
-			show_indexsearches_info(planstate, es);
+			show_indexscan_info(planstate, es);
 			break;
 		case T_BitmapHeapScan:
 			show_scan_qual(((BitmapHeapScan *) plan)->bitmapqualorig,
@@ -3867,15 +3864,16 @@ show_hashagg_info(AggState *aggstate, ExplainState *es)
 }
 
 /*
- * Show the total number of index searches for a
+ * Show index scan related executor instrumentation for a
  * IndexScan/IndexOnlyScan/BitmapIndexScan node
  */
 static void
-show_indexsearches_info(PlanState *planstate, ExplainState *es)
+show_indexscan_info(PlanState *planstate, ExplainState *es)
 {
 	Plan	   *plan = planstate->plan;
 	SharedIndexScanInstrumentation *SharedInfo = NULL;
-	uint64		nsearches = 0;
+	uint64		nsearches = 0,
+				ntabletuplefetches = 0;
 
 	if (!es->analyze)
 		return;
@@ -3896,6 +3894,7 @@ show_indexsearches_info(PlanState *planstate, ExplainState *es)
 				IndexOnlyScanState *indexstate = ((IndexOnlyScanState *) planstate);
 
 				nsearches = indexstate->ioss_Instrument->nsearches;
+				ntabletuplefetches = indexstate->ioss_Instrument->ntabletuplefetches;
 				SharedInfo = indexstate->ioss_SharedInfo;
 				break;
 			}
@@ -3919,9 +3918,13 @@ show_indexsearches_info(PlanState *planstate, ExplainState *es)
 			IndexScanInstrumentation *winstrument = &SharedInfo->winstrument[i];
 
 			nsearches += winstrument->nsearches;
+			ntabletuplefetches += winstrument->ntabletuplefetches;
 		}
 	}
 
+	if (nodeTag(plan) == T_IndexOnlyScan)
+		ExplainPropertyUInteger("Heap Fetches", NULL, ntabletuplefetches, es);
+
 	ExplainPropertyUInteger("Index Searches", NULL, nsearches, es);
 }
 
diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index dde56fb1e..9ffa8248f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -2894,10 +2894,10 @@ find_target_tuple(Relation rel, ChangeContext *chgcxt, TupleTableSlot *locator,
 	}
 
 	/* XXX no instrumentation for now */
-	scan = index_beginscan(rel, chgcxt->cc_ident_index, GetActiveSnapshot(),
+	scan = index_beginscan(rel, chgcxt->cc_ident_index, false, GetActiveSnapshot(),
 						   NULL, chgcxt->cc_ident_key_nentries, 0, 0);
 	index_rescan(scan, chgcxt->cc_ident_key, chgcxt->cc_ident_key_nentries, NULL, 0);
-	while (index_getnext_slot(scan, ForwardScanDirection, retrieved))
+	while (table_index_getnext_slot(scan, ForwardScanDirection, retrieved))
 	{
 		/* Be wary of temporal constraints */
 		if (scan->xs_recheck && !identity_key_equal(chgcxt, locator, retrieved))
diff --git a/src/backend/executor/execIndexing.c b/src/backend/executor/execIndexing.c
index eb3838129..9866611e3 100644
--- a/src/backend/executor/execIndexing.c
+++ b/src/backend/executor/execIndexing.c
@@ -823,12 +823,13 @@ check_exclusion_or_unique_constraint(Relation heap, Relation index,
 retry:
 	conflict = false;
 	found_self = false;
-	index_scan = index_beginscan(heap, index,
+	index_scan = index_beginscan(heap, index, false,
 								 &DirtySnapshot, NULL, indnkeyatts, 0,
 								 SO_NONE);
 	index_rescan(index_scan, scankeys, indnkeyatts, NULL, 0);
 
-	while (index_getnext_slot(index_scan, ForwardScanDirection, existing_slot))
+	while (table_index_getnext_slot(index_scan, ForwardScanDirection,
+									existing_slot))
 	{
 		TransactionId xwait;
 		XLTW_Oper	reason_wait;
diff --git a/src/backend/executor/execReplication.c b/src/backend/executor/execReplication.c
index b2ca5cbf1..fd9efd947 100644
--- a/src/backend/executor/execReplication.c
+++ b/src/backend/executor/execReplication.c
@@ -205,7 +205,7 @@ RelationFindReplTupleByIndex(Relation rel, Oid idxoid,
 	skey_attoff = build_replindex_scan_key(skey, rel, idxrel, searchslot);
 
 	/* Start an index scan. */
-	scan = index_beginscan(rel, idxrel,
+	scan = index_beginscan(rel, idxrel, false,
 						   &snap, NULL, skey_attoff, 0, SO_NONE);
 
 retry:
@@ -214,7 +214,7 @@ retry:
 	index_rescan(scan, skey, skey_attoff, NULL, 0);
 
 	/* Try to find the tuple */
-	while (index_getnext_slot(scan, ForwardScanDirection, outslot))
+	while (table_index_getnext_slot(scan, ForwardScanDirection, outslot))
 	{
 		/*
 		 * Avoid expensive equality check if the index is primary key or
@@ -669,13 +669,13 @@ RelationFindDeletedTupleInfoByIndex(Relation rel, Oid idxoid,
 	 * not yet committed or those just committed prior to the scan are
 	 * excluded in update_most_recent_deletion_info().
 	 */
-	scan = index_beginscan(rel, idxrel,
+	scan = index_beginscan(rel, idxrel, false,
 						   SnapshotAny, NULL, skey_attoff, 0, SO_NONE);
 
 	index_rescan(scan, skey, skey_attoff, NULL, 0);
 
 	/* Try to find the tuple */
-	while (index_getnext_slot(scan, ForwardScanDirection, scanslot))
+	while (table_index_getnext_slot(scan, ForwardScanDirection, scanslot))
 	{
 		/*
 		 * Avoid expensive equality check if the index is primary key or
diff --git a/src/backend/executor/nodeBitmapIndexscan.c b/src/backend/executor/nodeBitmapIndexscan.c
index 7978514e1..90b010f9b 100644
--- a/src/backend/executor/nodeBitmapIndexscan.c
+++ b/src/backend/executor/nodeBitmapIndexscan.c
@@ -204,6 +204,7 @@ ExecEndBitmapIndexScan(BitmapIndexScanState *node)
 		 * which will have a new BitmapIndexScanState and zeroed stats.
 		 */
 		winstrument->nsearches += node->biss_Instrument->nsearches;
+		Assert(node->biss_Instrument->ntabletuplefetches == 0);
 	}
 
 	/*
diff --git a/src/backend/executor/nodeIndexonlyscan.c b/src/backend/executor/nodeIndexonlyscan.c
index 8afcbde6b..68a230327 100644
--- a/src/backend/executor/nodeIndexonlyscan.c
+++ b/src/backend/executor/nodeIndexonlyscan.c
@@ -31,26 +31,17 @@
 #include "postgres.h"
 
 #include "access/genam.h"
-#include "access/htup_details.h"
 #include "access/relscan.h"
 #include "access/tableam.h"
-#include "access/tupdesc.h"
-#include "access/visibilitymap.h"
-#include "catalog/pg_type.h"
 #include "executor/executor.h"
 #include "executor/instrument.h"
 #include "executor/nodeIndexonlyscan.h"
 #include "executor/nodeIndexscan.h"
 #include "miscadmin.h"
-#include "storage/bufmgr.h"
-#include "storage/predicate.h"
-#include "utils/builtins.h"
 #include "utils/rel.h"
 
 
 static TupleTableSlot *IndexOnlyNext(IndexOnlyScanState *node);
-static void StoreIndexTuple(IndexOnlyScanState *node, TupleTableSlot *slot,
-							IndexScanDesc scandesc);
 
 
 /* ----------------------------------------------------------------
@@ -67,7 +58,6 @@ IndexOnlyNext(IndexOnlyScanState *node)
 	ScanDirection direction;
 	IndexScanDesc scandesc;
 	TupleTableSlot *slot;
-	ItemPointer tid;
 
 	/*
 	 * extract necessary information from index scan node
@@ -93,6 +83,7 @@ IndexOnlyNext(IndexOnlyScanState *node)
 		 */
 		scandesc = index_beginscan(node->ss.ss_currentRelation,
 								   node->ioss_RelationDesc,
+								   true,
 								   estate->es_snapshot,
 								   node->ioss_Instrument,
 								   node->ioss_NumScanKeys,
@@ -101,11 +92,7 @@ IndexOnlyNext(IndexOnlyScanState *node)
 								   SO_HINT_REL_READ_ONLY : SO_NONE);
 
 		node->ioss_ScanDesc = scandesc;
-
-
-		/* Set it up for index-only scan */
-		node->ioss_ScanDesc->xs_want_itup = true;
-		node->ioss_VMBuffer = InvalidBuffer;
+		Assert(node->ioss_ScanDesc->xs_want_itup);
 
 		/*
 		 * If no run-time keys to calculate or they are ready, go ahead and
@@ -122,81 +109,10 @@ IndexOnlyNext(IndexOnlyScanState *node)
 	/*
 	 * OK, now that we have what we need, fetch the next tuple.
 	 */
-	while ((tid = index_getnext_tid(scandesc, direction)) != NULL)
+	while (table_index_getnext_slot(scandesc, direction, slot))
 	{
-		bool		tuple_from_heap = false;
-
 		CHECK_FOR_INTERRUPTS();
 
-		/*
-		 * We can skip the heap fetch if the TID references a heap page on
-		 * which all tuples are known visible to everybody.  In any case,
-		 * we'll use the index tuple not the heap tuple as the data source.
-		 *
-		 * Note on Memory Ordering Effects: visibilitymap_get_status does not
-		 * lock the visibility map buffer, and therefore the result we read
-		 * here could be slightly stale.  However, it can't be stale enough to
-		 * matter.
-		 *
-		 * We need to detect clearing a VM bit due to an insert right away,
-		 * because the tuple is present in the index page but not visible. The
-		 * reading of the TID by this scan (using a shared lock on the index
-		 * buffer) is serialized with the insert of the TID into the index
-		 * (using an exclusive lock on the index buffer). Because the VM bit
-		 * is cleared before updating the index, and locking/unlocking of the
-		 * index page acts as a full memory barrier, we are sure to see the
-		 * cleared bit if we see a recently-inserted TID.
-		 *
-		 * Deletes do not update the index page (only VACUUM will clear out
-		 * the TID), so the clearing of the VM bit by a delete is not
-		 * serialized with this test below, and we may see a value that is
-		 * significantly stale. However, we don't care about the delete right
-		 * away, because the tuple is still visible until the deleting
-		 * transaction commits or the statement ends (if it's our
-		 * transaction). In either case, the lock on the VM buffer will have
-		 * been released (acting as a write barrier) after clearing the bit.
-		 * And for us to have a snapshot that includes the deleting
-		 * transaction (making the tuple invisible), we must have acquired
-		 * ProcArrayLock after that time, acting as a read barrier.
-		 *
-		 * It's worth going through this complexity to avoid needing to lock
-		 * the VM buffer, which could cause significant contention.
-		 */
-		if (!VM_ALL_VISIBLE(scandesc->heapRelation,
-							ItemPointerGetBlockNumber(tid),
-							&node->ioss_VMBuffer))
-		{
-			/*
-			 * Rats, we have to visit the heap to check visibility.
-			 */
-			InstrCountTuples2(node, 1);
-			if (!index_fetch_heap(scandesc, node->ioss_TableSlot))
-				continue;		/* no visible tuple, try next index entry */
-
-			ExecClearTuple(node->ioss_TableSlot);
-
-			/*
-			 * Only MVCC snapshots are supported here, so there should be no
-			 * need to keep following the HOT chain once a visible entry has
-			 * been found.  If we did want to allow that, we'd need to keep
-			 * more state to remember not to call index_getnext_tid next time.
-			 */
-			if (scandesc->xs_heap_continue)
-				elog(ERROR, "non-MVCC snapshots are not supported in index-only scans");
-
-			/*
-			 * Note: at this point we are holding a pin on the heap page, as
-			 * recorded in scandesc->xs_cbuf.  We could release that pin now,
-			 * but it's not clear whether it's a win to do so.  The next index
-			 * entry might require a visit to the same heap page.
-			 */
-
-			tuple_from_heap = true;
-		}
-
-		/* Fill the scan tuple slot with data from the index */
-		StoreIndexTuple(node, slot, scandesc);
-
 		/*
 		 * If the index was lossy, we have to recheck the index quals.
 		 */
@@ -223,16 +139,6 @@ IndexOnlyNext(IndexOnlyScanState *node)
 			ereport(ERROR,
 					(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 					 errmsg("lossy distance functions are not supported in index-only scans")));
-
-		/*
-		 * If we didn't access the heap, then we'll need to take a predicate
-		 * lock explicitly, as if we had.  For now we do that at page level.
-		 */
-		if (!tuple_from_heap)
-			PredicateLockPage(scandesc->heapRelation,
-							  ItemPointerGetBlockNumber(tid),
-							  estate->es_snapshot);
-
 		return slot;
 	}
 
@@ -243,85 +149,6 @@ IndexOnlyNext(IndexOnlyScanState *node)
 	return ExecClearTuple(slot);
 }
 
-/*
- * StoreIndexTuple
- *		Fill the slot with the data the index AM returned.
- *
- * The data might be provided in either HeapTuple (xs_hitup) or IndexTuple
- * (xs_itup) format.  Conceivably an index AM might fill both fields, in which
- * case we prefer the heap format, since it's probably a bit cheaper to fill a
- * slot from.
- *
- * At some point this might be generally-useful functionality, but
- * right now we don't need it elsewhere.
- */
-static void
-StoreIndexTuple(IndexOnlyScanState *node, TupleTableSlot *slot,
-				IndexScanDesc scandesc)
-{
-	ExecClearTuple(slot);
-
-	/*
-	 * We must deform the tuple using the tupdesc the index AM formed it with
-	 * (xs_hitupdesc or xs_itupdesc), not the slot's tupdesc.  The datums
-	 * returned by the index AM must be binary compatible, but the descriptors
-	 * may align each column differently in certain rare cases. (Actually,
-	 * btree's "name" opclass stores cstring tuples that _aren't_ even binary
-	 * compatible, in the strictest sense.  We directly handle that here.)
-	 */
-	if (scandesc->xs_hitup)
-	{
-		Assert(slot->tts_tupleDescriptor->natts == scandesc->xs_hitupdesc->natts);
-
-		heap_deform_tuple(scandesc->xs_hitup, scandesc->xs_hitupdesc,
-						  slot->tts_values, slot->tts_isnull);
-	}
-	else if (scandesc->xs_itup)
-	{
-		Assert(slot->tts_tupleDescriptor->natts == scandesc->xs_itupdesc->natts);
-
-		index_deform_tuple(scandesc->xs_itup, scandesc->xs_itupdesc,
-						   slot->tts_values, slot->tts_isnull);
-
-		/*
-		 * Copy all name columns stored as cstrings back into a NAMEDATALEN
-		 * byte sized allocation.  We mark this branch as unlikely as
-		 * generally "name" is used only for the system catalogs and this
-		 * would have to be a user query running on those or some other user
-		 * table with an index on a name column.
-		 */
-		if (unlikely(node->ioss_NameCStringAttNums != NULL))
-		{
-			int			attcount = node->ioss_NameCStringCount;
-
-			for (int idx = 0; idx < attcount; idx++)
-			{
-				int			attnum = node->ioss_NameCStringAttNums[idx];
-				Name		name;
-
-				/* skip null Datums */
-				if (slot->tts_isnull[attnum])
-					continue;
-
-				/*
-				 * allocate the NAMEDATALEN and copy the datum into that
-				 * memory
-				 */
-				name = (Name) MemoryContextAlloc(node->ss.ps.ps_ExprContext->ecxt_per_tuple_memory,
-												 NAMEDATALEN);
-
-				/* use namestrcpy to zero-pad all trailing bytes */
-				namestrcpy(name, DatumGetCString(slot->tts_values[attnum]));
-				slot->tts_values[attnum] = NameGetDatum(name);
-			}
-		}
-	}
-	else
-		elog(ERROR, "no data returned for index-only scan");
-
-	ExecStoreVirtualTuple(slot);
-}
-
 /*
  * IndexOnlyRecheck -- access method routine to recheck a tuple in EvalPlanQual
  *
@@ -415,13 +242,6 @@ ExecEndIndexOnlyScan(IndexOnlyScanState *node)
 	indexRelationDesc = node->ioss_RelationDesc;
 	indexScanDesc = node->ioss_ScanDesc;
 
-	/* Release VM buffer pin, if any. */
-	if (node->ioss_VMBuffer != InvalidBuffer)
-	{
-		ReleaseBuffer(node->ioss_VMBuffer);
-		node->ioss_VMBuffer = InvalidBuffer;
-	}
-
 	/*
 	 * When ending a parallel worker, copy the statistics gathered by the
 	 * worker back into shared memory so that it can be picked up by the main
@@ -441,6 +261,7 @@ ExecEndIndexOnlyScan(IndexOnlyScanState *node)
 		 * which will have a new IndexOnlyScanState and zeroed stats.
 		 */
 		winstrument->nsearches += node->ioss_Instrument->nsearches;
+		winstrument->ntabletuplefetches += node->ioss_Instrument->ntabletuplefetches;
 	}
 
 	/*
@@ -540,8 +361,6 @@ ExecInitIndexOnlyScan(IndexOnlyScan *node, EState *estate, int eflags)
 	Relation	indexRelation;
 	LOCKMODE	lockmode;
 	TupleDesc	tupDesc;
-	int			indnkeyatts;
-	int			namecount;
 
 	/*
 	 * create state structure
@@ -570,23 +389,15 @@ ExecInitIndexOnlyScan(IndexOnlyScan *node, EState *estate, int eflags)
 	 * Build the scan tuple type using the indextlist generated by the
 	 * planner.  We use this, rather than the index's physical tuple
 	 * descriptor, because the latter contains storage column types not the
-	 * types of the original datums.  (It's the AM's responsibility to return
-	 * suitable data anyway.)
+	 * types of the original datums.  (It's the index AM's responsibility to
+	 * return suitable data anyway.  The table AM calls index_fill_ios_slot to
+	 * fill the slot with that data.)
 	 */
 	tupDesc = ExecTypeFromTL(node->indextlist);
 	ExecInitScanTupleSlot(estate, &indexstate->ss, tupDesc,
 						  &TTSOpsVirtual,
 						  0);
 
-	/*
-	 * We need another slot, in a format that's suitable for the table AM, for
-	 * when we need to fetch a tuple from the table for rechecking visibility.
-	 */
-	indexstate->ioss_TableSlot =
-		ExecAllocTableSlot(&estate->es_tupleTable,
-						   RelationGetDescr(currentRelation),
-						   table_slot_callbacks(currentRelation), 0);
-
 	/*
 	 * Initialize result type and projection info.  The node's targetlist will
 	 * contain Vars with varno = INDEX_VAR, referencing the scan tuple.
@@ -676,48 +487,6 @@ ExecInitIndexOnlyScan(IndexOnlyScan *node, EState *estate, int eflags)
 		indexstate->ioss_RuntimeContext = NULL;
 	}
 
-	indexstate->ioss_NameCStringAttNums = NULL;
-	indnkeyatts = indexRelation->rd_index->indnkeyatts;
-	namecount = 0;
-
-	/*
-	 * The "name" type for btree uses text_ops which results in storing
-	 * cstrings in the indexed keys rather than names.  Here we detect that in
-	 * a generic way in case other index AMs want to do the same optimization.
-	 * Check for opclasses with an opcintype of NAMEOID and an index tuple
-	 * descriptor with CSTRINGOID.  If any of these are found, create an array
-	 * marking the index attribute number of each of them.  StoreIndexTuple()
-	 * handles copying the name Datums into a NAMEDATALEN-byte allocation.
-	 */
-
-	/* First, count the number of such index keys */
-	for (int attnum = 0; attnum < indnkeyatts; attnum++)
-	{
-		if (TupleDescAttr(indexRelation->rd_att, attnum)->atttypid == CSTRINGOID &&
-			indexRelation->rd_opcintype[attnum] == NAMEOID)
-			namecount++;
-	}
-
-	if (namecount > 0)
-	{
-		int			idx = 0;
-
-		/*
-		 * Now create an array to mark the attribute numbers of the keys that
-		 * need to be converted from cstring to name.
-		 */
-		indexstate->ioss_NameCStringAttNums = palloc_array(AttrNumber, namecount);
-
-		for (int attnum = 0; attnum < indnkeyatts; attnum++)
-		{
-			if (TupleDescAttr(indexRelation->rd_att, attnum)->atttypid == CSTRINGOID &&
-				indexRelation->rd_opcintype[attnum] == NAMEOID)
-				indexstate->ioss_NameCStringAttNums[idx++] = (AttrNumber) attnum;
-		}
-	}
-
-	indexstate->ioss_NameCStringCount = namecount;
-
 	/*
 	 * all done.
 	 */
@@ -773,14 +542,14 @@ ExecIndexOnlyScanInitializeDSM(IndexOnlyScanState *node,
 	node->ioss_ScanDesc =
 		index_beginscan_parallel(node->ss.ss_currentRelation,
 								 node->ioss_RelationDesc,
+								 true,
 								 node->ioss_Instrument,
 								 node->ioss_NumScanKeys,
 								 node->ioss_NumOrderByKeys,
 								 piscan,
 								 ScanRelIsReadOnly(&node->ss) ?
 								 SO_HINT_REL_READ_ONLY : SO_NONE);
-	node->ioss_ScanDesc->xs_want_itup = true;
-	node->ioss_VMBuffer = InvalidBuffer;
+	Assert(node->ioss_ScanDesc->xs_want_itup);
 
 	/*
 	 * If no run-time keys to calculate or they are ready, go ahead and pass
@@ -823,13 +592,14 @@ ExecIndexOnlyScanInitializeWorker(IndexOnlyScanState *node,
 	node->ioss_ScanDesc =
 		index_beginscan_parallel(node->ss.ss_currentRelation,
 								 node->ioss_RelationDesc,
+								 true,
 								 node->ioss_Instrument,
 								 node->ioss_NumScanKeys,
 								 node->ioss_NumOrderByKeys,
 								 piscan,
 								 ScanRelIsReadOnly(&node->ss) ?
 								 SO_HINT_REL_READ_ONLY : SO_NONE);
-	node->ioss_ScanDesc->xs_want_itup = true;
+	Assert(node->ioss_ScanDesc->xs_want_itup);
 
 	/*
 	 * If no run-time keys to calculate or they are ready, go ahead and pass
diff --git a/src/backend/executor/nodeIndexscan.c b/src/backend/executor/nodeIndexscan.c
index 39f6691ee..887a9071d 100644
--- a/src/backend/executor/nodeIndexscan.c
+++ b/src/backend/executor/nodeIndexscan.c
@@ -110,6 +110,7 @@ IndexNext(IndexScanState *node)
 		 */
 		scandesc = index_beginscan(node->ss.ss_currentRelation,
 								   node->iss_RelationDesc,
+								   false,
 								   estate->es_snapshot,
 								   node->iss_Instrument,
 								   node->iss_NumScanKeys,
@@ -132,7 +133,7 @@ IndexNext(IndexScanState *node)
 	/*
 	 * ok, now that we have what we need, fetch the next tuple.
 	 */
-	while (index_getnext_slot(scandesc, direction, slot))
+	while (table_index_getnext_slot(scandesc, direction, slot))
 	{
 		CHECK_FOR_INTERRUPTS();
 
@@ -208,6 +209,7 @@ IndexNextWithReorder(IndexScanState *node)
 		 */
 		scandesc = index_beginscan(node->ss.ss_currentRelation,
 								   node->iss_RelationDesc,
+								   false,
 								   estate->es_snapshot,
 								   node->iss_Instrument,
 								   node->iss_NumScanKeys,
@@ -266,7 +268,7 @@ IndexNextWithReorder(IndexScanState *node)
 		 * Fetch next tuple from the index.
 		 */
 next_indextuple:
-		if (!index_getnext_slot(scandesc, ForwardScanDirection, slot))
+		if (!table_index_getnext_slot(scandesc, ForwardScanDirection, slot))
 		{
 			/*
 			 * No more tuples from the index.  But we still need to drain any
@@ -818,6 +820,7 @@ ExecEndIndexScan(IndexScanState *node)
 		 * which will have a new IndexOnlyScanState and zeroed stats.
 		 */
 		winstrument->nsearches += node->iss_Instrument->nsearches;
+		Assert(node->iss_Instrument->ntabletuplefetches == 0);
 	}
 
 	/*
@@ -1706,6 +1709,7 @@ ExecIndexScanInitializeDSM(IndexScanState *node,
 	node->iss_ScanDesc =
 		index_beginscan_parallel(node->ss.ss_currentRelation,
 								 node->iss_RelationDesc,
+								 false,
 								 node->iss_Instrument,
 								 node->iss_NumScanKeys,
 								 node->iss_NumOrderByKeys,
@@ -1754,6 +1758,7 @@ ExecIndexScanInitializeWorker(IndexScanState *node,
 	node->iss_ScanDesc =
 		index_beginscan_parallel(node->ss.ss_currentRelation,
 								 node->iss_RelationDesc,
+								 false,
 								 node->iss_Instrument,
 								 node->iss_NumScanKeys,
 								 node->iss_NumOrderByKeys,
diff --git a/src/backend/utils/adt/ri_triggers.c b/src/backend/utils/adt/ri_triggers.c
index 6f201ef0f..3f6ee30df 100644
--- a/src/backend/utils/adt/ri_triggers.c
+++ b/src/backend/utils/adt/ri_triggers.c
@@ -2842,7 +2842,7 @@ ri_FastPathCheck(RI_ConstraintInfo *riinfo,
 	 * btree this has no functional consequence, but it keeps the ordering
 	 * correct for out-of-tree access methods.
 	 */
-	scandesc = index_beginscan(pk_rel, idx_rel,
+	scandesc = index_beginscan(pk_rel, idx_rel, false,
 							   snapshot, NULL,
 							   riinfo->nkeys, 0,
 							   SO_NONE);
@@ -2993,7 +2993,7 @@ ri_FastPathBatchFlush(RI_FastPathEntry *fpentry, Relation fk_rel,
 	 * btree this has no functional consequence, but it keeps the ordering
 	 * correct for out-of-tree access methods.
 	 */
-	scandesc = index_beginscan(pk_rel, idx_rel, snapshot, NULL,
+	scandesc = index_beginscan(pk_rel, idx_rel, false, snapshot, NULL,
 							   riinfo->nkeys, 0, SO_NONE);
 
 	if (riinfo->fpmeta == NULL)
@@ -3189,7 +3189,7 @@ ri_FastPathFlushArray(RI_FastPathEntry *fpentry, TupleTableSlot *fk_slot,
 	 * Walk all matches.  The index AM returns them in index order.  For each
 	 * match, find which batch item(s) it satisfies.
 	 */
-	while (index_getnext_slot(scandesc, ForwardScanDirection, pk_slot))
+	while (table_index_getnext_slot(scandesc, ForwardScanDirection, pk_slot))
 	{
 		Datum		found_val;
 		bool		found_null;
@@ -3272,7 +3272,7 @@ ri_FastPathProbeOne(Relation pk_rel, Relation idx_rel,
 
 	index_rescan(scandesc, skey, nkeys, NULL, 0);
 
-	if (index_getnext_slot(scandesc, ForwardScanDirection, slot))
+	if (table_index_getnext_slot(scandesc, ForwardScanDirection, slot))
 	{
 		bool		concurrently_updated;
 
diff --git a/src/backend/utils/adt/selfuncs.c b/src/backend/utils/adt/selfuncs.c
index 41465769e..b9659222b 100644
--- a/src/backend/utils/adt/selfuncs.c
+++ b/src/backend/utils/adt/selfuncs.c
@@ -102,7 +102,6 @@
 #include "access/gin.h"
 #include "access/table.h"
 #include "access/tableam.h"
-#include "access/visibilitymap.h"
 #include "catalog/pg_collation.h"
 #include "catalog/pg_operator.h"
 #include "catalog/pg_statistic.h"
@@ -122,7 +121,6 @@
 #include "parser/parsetree.h"
 #include "rewrite/rewriteManip.h"
 #include "statistics/statistics.h"
-#include "storage/bufmgr.h"
 #include "utils/acl.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
@@ -268,7 +266,7 @@ static bool get_actual_variable_endpoint(Relation heapRel,
 										 ScanKey scankeys,
 										 int16 typLen,
 										 bool typByVal,
-										 TupleTableSlot *tableslot,
+										 TupleTableSlot *slot,
 										 MemoryContext outercontext,
 										 Datum *endpointDatum);
 static RelOptInfo *find_join_input_rel(PlannerInfo *root, Relids relids);
@@ -7142,7 +7140,8 @@ get_actual_variable_range(PlannerInfo *root, VariableStatData *vardata,
 			indexRel = index_open(index->indexoid, NoLock);
 
 			/* build some stuff needed for indexscan execution */
-			slot = table_slot_create(heapRel, NULL);
+			slot = MakeSingleTupleTableSlot(RelationGetDescr(indexRel),
+											&TTSOpsVirtual);
 			get_typlenbyval(vardata->atttype, &typLen, &typByVal);
 
 			/* set up an IS NOT NULL scan key so that we ignore nulls */
@@ -7214,8 +7213,7 @@ get_actual_variable_range(PlannerInfo *root, VariableStatData *vardata,
  *
  * scankeys is a 1-element scankey array set up to reject nulls.
  * typLen/typByVal describe the datatype of the index's first column.
- * tableslot is a slot suitable to hold table tuples, in case we need
- * to probe the heap.
+ * slot is a virtual slot to receive each index tuple's values.
  * (We could compute these values locally, but that would mean computing them
  * twice when get_actual_variable_range needs both the min and the max.)
  *
@@ -7229,19 +7227,15 @@ get_actual_variable_endpoint(Relation heapRel,
 							 ScanKey scankeys,
 							 int16 typLen,
 							 bool typByVal,
-							 TupleTableSlot *tableslot,
+							 TupleTableSlot *slot,
 							 MemoryContext outercontext,
 							 Datum *endpointDatum)
 {
 	bool		have_data = false;
 	SnapshotData SnapshotNonVacuumable;
 	IndexScanDesc index_scan;
-	Buffer		vmbuffer = InvalidBuffer;
-	BlockNumber last_heap_block = InvalidBlockNumber;
-	int			n_visited_heap_pages = 0;
-	ItemPointer tid;
-	Datum		values[INDEX_MAX_KEYS];
-	bool		isnull[INDEX_MAX_KEYS];
+	Datum		val;
+	bool		isnull;
 	MemoryContext oldcontext;
 
 	/*
@@ -7287,95 +7281,49 @@ get_actual_variable_endpoint(Relation heapRel,
 	 * a huge amount of time here, so we give up once we've read too many heap
 	 * pages.  When we fail for that reason, the caller will end up using
 	 * whatever extremal value is recorded in pg_statistic.
+	 *
+	 * We set xs_visited_pages_limit to tell the table AM to give up once it
+	 * has switched heap pages this many times without finding a visible
+	 * tuple.
 	 */
+#define VISITED_PAGES_LIMIT 100
 	InitNonVacuumableSnapshot(SnapshotNonVacuumable,
 							  GlobalVisTestFor(heapRel));
 
-	index_scan = index_beginscan(heapRel, indexRel,
+	index_scan = index_beginscan(heapRel, indexRel, true,
 								 &SnapshotNonVacuumable, NULL,
 								 1, 0,
 								 SO_NONE);
-	/* Set it up for index-only scan */
-	index_scan->xs_want_itup = true;
+	Assert(index_scan->xs_want_itup);
+	index_scan->xs_visited_pages_limit = VISITED_PAGES_LIMIT;
 	index_rescan(index_scan, scankeys, 1, NULL, 0);
 
 	/* Fetch first/next tuple in specified direction */
-	while ((tid = index_getnext_tid(index_scan, indexscandir)) != NULL)
+	while (table_index_getnext_slot(index_scan, indexscandir, slot))
 	{
-		BlockNumber block = ItemPointerGetBlockNumber(tid);
-
-		if (!VM_ALL_VISIBLE(heapRel,
-							block,
-							&vmbuffer))
-		{
-			/* Rats, we have to visit the heap to check visibility */
-			if (!index_fetch_heap(index_scan, tableslot))
-			{
-				/*
-				 * No visible tuple for this index entry, so we need to
-				 * advance to the next entry.  Before doing so, count heap
-				 * page fetches and give up if we've done too many.
-				 *
-				 * We don't charge a page fetch if this is the same heap page
-				 * as the previous tuple.  This is on the conservative side,
-				 * since other recently-accessed pages are probably still in
-				 * buffers too; but it's good enough for this heuristic.
-				 */
-#define VISITED_PAGES_LIMIT 100
-
-				if (block != last_heap_block)
-				{
-					last_heap_block = block;
-					n_visited_heap_pages++;
-					if (n_visited_heap_pages > VISITED_PAGES_LIMIT)
-						break;
-				}
-
-				continue;		/* no visible tuple, try next index entry */
-			}
-
-			/* We don't actually need the heap tuple for anything */
-			ExecClearTuple(tableslot);
-
-			/*
-			 * We don't care whether there's more than one visible tuple in
-			 * the HOT chain; if any are visible, that's good enough.
-			 */
-		}
-
-		/*
-		 * We expect that the index will return data in IndexTuple not
-		 * HeapTuple format.
-		 */
-		if (!index_scan->xs_itup)
-			elog(ERROR, "no data returned for index-only scan");
-
 		/*
 		 * We do not yet support recheck here.
 		 */
 		if (index_scan->xs_recheck)
 			break;
 
-		/* OK to deconstruct the index tuple */
-		index_deform_tuple(index_scan->xs_itup,
-						   index_scan->xs_itupdesc,
-						   values, isnull);
+		/* Read the index's first column value out of the slot */
+		val = slot_getattr(slot, 1, &isnull);
 
 		/* Shouldn't have got a null, but be careful */
-		if (isnull[0])
+		if (isnull)
 			elog(ERROR, "found unexpected null value in index \"%s\"",
 				 RelationGetRelationName(indexRel));
 
 		/* Copy the index column value out to caller's context */
 		oldcontext = MemoryContextSwitchTo(outercontext);
-		*endpointDatum = datumCopy(values[0], typByVal, typLen);
+		*endpointDatum = datumCopy(val, typByVal, typLen);
 		MemoryContextSwitchTo(oldcontext);
 		have_data = true;
 		break;
 	}
 
-	if (vmbuffer != InvalidBuffer)
-		ReleaseBuffer(vmbuffer);
+	ExecClearTuple(slot);		/* defensive, slot can be reused by later scan */
 	index_endscan(index_scan);
 
 	return have_data;
diff --git a/src/test/modules/index/Makefile b/src/test/modules/index/Makefile
index 29047044e..83dd09745 100644
--- a/src/test/modules/index/Makefile
+++ b/src/test/modules/index/Makefile
@@ -1,7 +1,17 @@
 # src/test/modules/index/Makefile
 
+MODULE_big = test_indexscan
+OBJS = \
+	$(WIN32RES) \
+	test_indexscan.o
+EXTENSION = test_indexscan
+DATA = test_indexscan--1.0.sql
+PGFILEDESC = "test_indexscan - test index scan internals"
+
 EXTRA_INSTALL = contrib/btree_gin contrib/btree_gist
 
+REGRESS = hot_chain
+
 ISOLATION = killtuples
 
 ifdef USE_PGXS
diff --git a/src/test/modules/index/expected/hot_chain.out b/src/test/modules/index/expected/hot_chain.out
new file mode 100644
index 000000000..0e7832a84
--- /dev/null
+++ b/src/test/modules/index/expected/hot_chain.out
@@ -0,0 +1,56 @@
+-- Non-MVCC index scans must return every visible member of a HOT chain.
+-- Verify that table_index_getnext_slot gets that right in a variety of cases.
+CREATE EXTENSION test_indexscan;
+-- Single-page table; all TIDs below are deterministic: a fresh table gets
+-- block 0, heap_insert/heap_update assign line pointers sequentially, and the
+-- rows are tiny to avoid any toasting.
+CREATE TABLE hot_chain_tab (id int, filler text) WITH (autovacuum_enabled = off);
+CREATE INDEX hot_chain_idx ON hot_chain_tab (id);
+INSERT INTO hot_chain_tab VALUES (1, 'r1v1'), (2, 'r2v1');  -- (0,1) (0,2)
+BEGIN;
+-- Create HOT chains.  These updates touch only the non-indexed column
+-- "filler", and every chain member stays visible to SnapshotAny because the
+-- deleting transaction (us) is still open.
+UPDATE hot_chain_tab SET filler = 'r1v2' WHERE id = 1;   -- (0,3)
+UPDATE hot_chain_tab SET filler = 'r1v3' WHERE id = 1;   -- (0,4)
+UPDATE hot_chain_tab SET filler = 'r2v2' WHERE id = 2;   -- (0,5)
+-- Verify that all three updates were HOT using backend-local xact counter:
+SELECT pg_stat_get_xact_tuples_hot_updated('hot_chain_tab'::regclass) AS hot_updated;
+ hot_updated 
+-------------
+           3
+(1 row)
+
+-- SnapshotAny: every chain member
+SELECT * FROM index_scan_tids('hot_chain_idx', 'any');
+ index_scan_tids 
+-----------------
+ (0,1)
+ (0,3)
+ (0,4)
+ (0,2)
+ (0,5)
+(5 rows)
+
+-- MVCC scan, for contrast: only the newest member of each chain
+SELECT * FROM index_scan_tids('hot_chain_idx', 'mvcc');
+ index_scan_tids 
+-----------------
+ (0,4)
+ (0,5)
+(2 rows)
+
+-- backward: index entries in reverse key order, chains still in ASC chain order
+SELECT * FROM index_scan_tids('hot_chain_idx', 'any', 'backward');
+ index_scan_tids 
+-----------------
+ (0,2)
+ (0,5)
+ (0,1)
+ (0,3)
+ (0,4)
+(5 rows)
+
+COMMIT;
+DROP TABLE hot_chain_tab;
+DROP EXTENSION test_indexscan;
diff --git a/src/test/modules/index/meson.build b/src/test/modules/index/meson.build
index 834ce081f..5f5630230 100644
--- a/src/test/modules/index/meson.build
+++ b/src/test/modules/index/meson.build
@@ -1,9 +1,35 @@
 # Copyright (c) 2025-2026, PostgreSQL Global Development Group
 
+test_indexscan_sources = files(
+  'test_indexscan.c',
+)
+
+if host_system == 'windows'
+  test_indexscan_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'test_indexscan',
+    '--FILEDESC', 'test_indexscan - test index scan internals',])
+endif
+
+test_indexscan = shared_module('test_indexscan',
+  test_indexscan_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += test_indexscan
+
+test_install_data += files(
+  'test_indexscan.control',
+  'test_indexscan--1.0.sql',
+)
+
 tests += {
   'name': 'index',
   'sd': meson.current_source_dir(),
   'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'hot_chain',
+    ],
+  },
   'isolation': {
     'specs': [
       'killtuples',
diff --git a/src/test/modules/index/sql/hot_chain.sql b/src/test/modules/index/sql/hot_chain.sql
new file mode 100644
index 000000000..dcbdb93b7
--- /dev/null
+++ b/src/test/modules/index/sql/hot_chain.sql
@@ -0,0 +1,37 @@
+-- Non-MVCC index scans must return every visible member of a HOT chain.
+-- Verify that table_index_getnext_slot gets that right in a variety of cases.
+
+CREATE EXTENSION test_indexscan;
+
+-- Single-page table; all TIDs below are deterministic: a fresh table gets
+-- block 0, heap_insert/heap_update assign line pointers sequentially, and the
+-- rows are tiny to avoid any toasting.
+CREATE TABLE hot_chain_tab (id int, filler text) WITH (autovacuum_enabled = off);
+CREATE INDEX hot_chain_idx ON hot_chain_tab (id);
+INSERT INTO hot_chain_tab VALUES (1, 'r1v1'), (2, 'r2v1');  -- (0,1) (0,2)
+
+BEGIN;
+
+-- Create HOT chains.  These updates touch only the non-indexed column
+-- "filler", and every chain member stays visible to SnapshotAny because the
+-- deleting transaction (us) is still open.
+UPDATE hot_chain_tab SET filler = 'r1v2' WHERE id = 1;   -- (0,3)
+UPDATE hot_chain_tab SET filler = 'r1v3' WHERE id = 1;   -- (0,4)
+UPDATE hot_chain_tab SET filler = 'r2v2' WHERE id = 2;   -- (0,5)
+
+-- Verify that all three updates were HOT using backend-local xact counter:
+SELECT pg_stat_get_xact_tuples_hot_updated('hot_chain_tab'::regclass) AS hot_updated;
+
+-- SnapshotAny: every chain member
+SELECT * FROM index_scan_tids('hot_chain_idx', 'any');
+
+-- MVCC scan, for contrast: only the newest member of each chain
+SELECT * FROM index_scan_tids('hot_chain_idx', 'mvcc');
+
+-- backward: index entries in reverse key order, chains still in ASC chain order
+SELECT * FROM index_scan_tids('hot_chain_idx', 'any', 'backward');
+
+COMMIT;
+
+DROP TABLE hot_chain_tab;
+DROP EXTENSION test_indexscan;
diff --git a/src/test/modules/index/test_indexscan--1.0.sql b/src/test/modules/index/test_indexscan--1.0.sql
new file mode 100644
index 000000000..b662d7054
--- /dev/null
+++ b/src/test/modules/index/test_indexscan--1.0.sql
@@ -0,0 +1,13 @@
+/* src/test/modules/index/test_indexscan--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION test_indexscan" to load this file. \quit
+
+-- Scan indexrel with zero scan keys and the given snapshot type ('mvcc',
+-- 'any', 'self', 'dirty', 'nonvacuumable') and direction ('forward',
+-- 'backward'), returning the heap TID of every tuple the scan yields, in
+-- scan order.
+CREATE FUNCTION index_scan_tids(indexrel regclass, snaptype text,
+                                dir text DEFAULT 'forward')
+RETURNS SETOF pg_catalog.tid
+STRICT AS 'MODULE_PATHNAME' LANGUAGE C;
diff --git a/src/test/modules/index/test_indexscan.c b/src/test/modules/index/test_indexscan.c
new file mode 100644
index 000000000..bb299ca33
--- /dev/null
+++ b/src/test/modules/index/test_indexscan.c
@@ -0,0 +1,152 @@
+/*--------------------------------------------------------------------------
+ *
+ * test_indexscan.c
+ *		Test helpers for low-level index scan behavior.
+ *
+ * Copyright (c) 2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *		src/test/modules/index/test_indexscan.c
+ *
+ * -------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/amapi.h"
+#include "access/genam.h"
+#include "access/relscan.h"
+#include "access/table.h"
+#include "access/tableam.h"
+#include "catalog/index.h"
+#include "executor/tuptable.h"
+#include "fmgr.h"
+#include "funcapi.h"
+#include "miscadmin.h"
+#include "storage/itemptr.h"
+#include "utils/builtins.h"
+#include "utils/rel.h"
+#include "utils/tuplestore.h"
+
+PG_MODULE_MAGIC;
+
+/*
+ * index_scan_tids(indexrel regclass, snaptype text, dir text) RETURNS SETOF tid
+ *
+ * Scan indexrel with zero scan keys, using a snapshot of the given type and
+ * the given scan direction, and return the heap TID of every tuple that the
+ * scan returns, in scan order.
+ */
+PG_FUNCTION_INFO_V1(index_scan_tids);
+Datum
+index_scan_tids(PG_FUNCTION_ARGS)
+{
+	Oid			indexoid = PG_GETARG_OID(0);
+	char	   *snaptype = text_to_cstring(PG_GETARG_TEXT_PP(1));
+	char	   *dirstr = text_to_cstring(PG_GETARG_TEXT_PP(2));
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	Oid			heapoid;
+	Relation	heaprel;
+	Relation	indexrel;
+	ScanDirection dir;
+	SnapshotData snapdata;
+	Snapshot	snapshot;
+	IndexScanDesc scan;
+	TupleTableSlot *slot;
+
+	if (!superuser())
+		ereport(ERROR,
+				(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+				 errmsg("must be superuser to use index scan test functions")));
+
+	InitMaterializedSRF(fcinfo, MAT_SRF_USE_EXPECTED_DESC);
+
+	if (strcmp(dirstr, "forward") == 0)
+		dir = ForwardScanDirection;
+	else if (strcmp(dirstr, "backward") == 0)
+		dir = BackwardScanDirection;
+	else
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				 errmsg("invalid scan direction \"%s\"", dirstr)));
+
+	/*
+	 * Lock the heap before the index to avoid deadlock.  IndexGetRelation()
+	 * runs without a lock, so if the OID isn't an index it returns
+	 * InvalidOid; defer the complaint to index_open() below, which gives a
+	 * better message.
+	 */
+	heapoid = IndexGetRelation(indexoid, true);
+	if (OidIsValid(heapoid))
+		heaprel = table_open(heapoid, AccessShareLock);
+	else
+		heaprel = NULL;
+
+	indexrel = index_open(indexoid, AccessShareLock);
+
+	/*
+	 * Since the IndexGetRelation() call above ran without a lock, recheck now
+	 * that both relations are locked: a concurrent drop and recreate could
+	 * have left us with the wrong heap.
+	 */
+	if (heaprel == NULL || heapoid != IndexGetRelation(indexoid, false))
+		ereport(ERROR,
+				(errcode(ERRCODE_UNDEFINED_TABLE),
+				 errmsg("could not open parent table of index \"%s\"",
+						RelationGetRelationName(indexrel))));
+
+	if (indexrel->rd_indam->amgettuple == NULL)
+		ereport(ERROR,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg("index \"%s\" does not support plain index scans",
+						RelationGetRelationName(indexrel))));
+
+	if (strcmp(snaptype, "mvcc") == 0)
+		snapshot = GetActiveSnapshot();
+	else if (strcmp(snaptype, "any") == 0)
+		snapshot = SnapshotAny;
+	else if (strcmp(snaptype, "self") == 0)
+		snapshot = SnapshotSelf;
+	else if (strcmp(snaptype, "dirty") == 0)
+	{
+		InitDirtySnapshot(snapdata);
+		snapshot = &snapdata;
+	}
+	else if (strcmp(snaptype, "nonvacuumable") == 0)
+	{
+		InitNonVacuumableSnapshot(snapdata, GlobalVisTestFor(heaprel));
+		snapshot = &snapdata;
+	}
+	else
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				 errmsg("invalid snapshot type \"%s\"", snaptype)));
+
+	slot = table_slot_create(heaprel, NULL);
+
+	scan = index_beginscan(heaprel, indexrel, false, snapshot, NULL,
+						   0, 0, SO_NONE);
+	index_rescan(scan, NULL, 0, NULL, 0);
+
+	while (table_index_getnext_slot(scan, dir, slot))
+	{
+		ItemPointerData tid = slot->tts_tid;
+		Datum		values[1];
+		bool		nulls[1];
+
+		/* with zero scan keys, no AM should ever request a recheck */
+		if (scan->xs_recheck)
+			elog(ERROR, "unexpected recheck request from keyless index scan");
+
+		values[0] = ItemPointerGetDatum(&tid);
+		nulls[0] = false;
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+							 values, nulls);
+	}
+
+	index_endscan(scan);
+	ExecDropSingleTupleTableSlot(slot);
+	index_close(indexrel, AccessShareLock);
+	table_close(heaprel, AccessShareLock);
+
+	return (Datum) 0;
+}
diff --git a/src/test/modules/index/test_indexscan.control b/src/test/modules/index/test_indexscan.control
new file mode 100644
index 000000000..bfab27bf0
--- /dev/null
+++ b/src/test/modules/index/test_indexscan.control
@@ -0,0 +1,4 @@
+comment = 'helper function for low-level index scan behavior'
+default_version = '1.0'
+module_pathname = '$libdir/test_indexscan'
+relocatable = true
diff --git a/src/test/regress/expected/stats.out b/src/test/regress/expected/stats.out
index e230356de..f58d94ab7 100644
--- a/src/test/regress/expected/stats.out
+++ b/src/test/regress/expected/stats.out
@@ -913,6 +913,68 @@ SELECT idx_scan, stats_reset IS NOT NULL AS has_stats_reset
         0 | t
 (1 row)
 
+-- Test pg_stat_all_indexes.idx_tup_read counter
+CREATE TEMPORARY TABLE test_idx_tup_read AS
+  SELECT g AS a FROM generate_series(1, 200) g;
+CREATE INDEX ON test_idx_tup_read(a);
+SET enable_seqscan = off;
+SET enable_indexonlyscan = off;
+-- plain index scan
+SET enable_bitmapscan = off;
+EXPLAIN (COSTS off) SELECT count(*) FROM test_idx_tup_read WHERE a BETWEEN 1 AND 200;
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Aggregate
+   ->  Index Scan using test_idx_tup_read_a_idx on test_idx_tup_read
+         Index Cond: ((a >= 1) AND (a <= 200))
+(3 rows)
+
+SELECT count(*) FROM test_idx_tup_read WHERE a BETWEEN 1 AND 200;
+ count 
+-------
+   200
+(1 row)
+
+-- bitmap index scan
+SET enable_indexscan = off;
+SET enable_bitmapscan = on;
+EXPLAIN (COSTS off) SELECT count(*) FROM test_idx_tup_read WHERE a BETWEEN 1 AND 200;
+                        QUERY PLAN                        
+----------------------------------------------------------
+ Aggregate
+   ->  Bitmap Heap Scan on test_idx_tup_read
+         Recheck Cond: ((a >= 1) AND (a <= 200))
+         ->  Bitmap Index Scan on test_idx_tup_read_a_idx
+               Index Cond: ((a >= 1) AND (a <= 200))
+(5 rows)
+
+SELECT count(*) FROM test_idx_tup_read WHERE a BETWEEN 1 AND 200;
+ count 
+-------
+   200
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_indexonlyscan;
+RESET enable_indexscan;
+RESET enable_bitmapscan;
+-- We expect a total of 400 tuples read (200 from plain index scan, 200 from
+-- bitmap index scan).  However, we only expect 200 tuple fetches, because
+-- bitmap index scans/heap scans don't affect the relevant counter.
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT idx_tup_read, idx_tup_fetch FROM pg_stat_all_indexes
+  WHERE indexrelid = 'test_idx_tup_read_a_idx'::regclass;
+ idx_tup_read | idx_tup_fetch 
+--------------+---------------
+          400 |           200
+(1 row)
+
+DROP TABLE test_idx_tup_read;
 -----
 -- Test reset of some stats for shared table
 -----
diff --git a/src/test/regress/sql/stats.sql b/src/test/regress/sql/stats.sql
index 4c265d124..248267e22 100644
--- a/src/test/regress/sql/stats.sql
+++ b/src/test/regress/sql/stats.sql
@@ -403,6 +403,35 @@ SELECT pg_stat_reset_single_table_counters('test_last_scan_pkey'::regclass);
 SELECT idx_scan, stats_reset IS NOT NULL AS has_stats_reset
   FROM pg_stat_all_indexes WHERE indexrelid = 'test_last_scan_pkey'::regclass;
 
+-- Test pg_stat_all_indexes.idx_tup_read counter
+CREATE TEMPORARY TABLE test_idx_tup_read AS
+  SELECT g AS a FROM generate_series(1, 200) g;
+CREATE INDEX ON test_idx_tup_read(a);
+
+SET enable_seqscan = off;
+SET enable_indexonlyscan = off;
+-- plain index scan
+SET enable_bitmapscan = off;
+EXPLAIN (COSTS off) SELECT count(*) FROM test_idx_tup_read WHERE a BETWEEN 1 AND 200;
+SELECT count(*) FROM test_idx_tup_read WHERE a BETWEEN 1 AND 200;
+-- bitmap index scan
+SET enable_indexscan = off;
+SET enable_bitmapscan = on;
+EXPLAIN (COSTS off) SELECT count(*) FROM test_idx_tup_read WHERE a BETWEEN 1 AND 200;
+SELECT count(*) FROM test_idx_tup_read WHERE a BETWEEN 1 AND 200;
+RESET enable_seqscan;
+RESET enable_indexonlyscan;
+RESET enable_indexscan;
+RESET enable_bitmapscan;
+
+-- We expect a total of 400 tuples read (200 from plain index scan, 200 from
+-- bitmap index scan).  However, we only expect 200 tuple fetches, because
+-- bitmap index scans/heap scans don't affect the relevant counter.
+SELECT pg_stat_force_next_flush();
+SELECT idx_tup_read, idx_tup_fetch FROM pg_stat_all_indexes
+  WHERE indexrelid = 'test_idx_tup_read_a_idx'::regclass;
+DROP TABLE test_idx_tup_read;
+
 -----
 -- Test reset of some stats for shared table
 -----
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index eb3dd5481..3eb70548c 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -1335,8 +1335,6 @@ IndexDeleteCounts
 IndexDeletePrefetchState
 IndexDoCheckCallback
 IndexElem
-IndexFetchHeapData
-IndexFetchTableData
 IndexInfo
 IndexList
 IndexOnlyScan
@@ -1348,6 +1346,7 @@ IndexRuntimeKeyInfo
 IndexScan
 IndexScanDesc
 IndexScanDescData
+IndexScanHeapData
 IndexScanInstrumentation
 IndexScanState
 IndexStateFlagsAction
-- 
2.53.0

