From d1b501a7cf5b820febd2ddc42f94d848cee00eac Mon Sep 17 00:00:00 2001
From: Greg Burd <greg@burd.me>
Date: Wed, 17 Jun 2026 21:38:11 -0400
Subject: [PATCH v48 7/9] Add HOT-indexed statistics and the comprehensive test
 suite

Expose the HOT-indexed activity counters maintained by the write path:
pg_stat_all_tables.n_tup_hot_indexed_upd, the per-index
n_tup_hot_indexed_upd_matched / n_tup_hot_indexed_upd_skipped counters in
pg_stat_all_indexes, and pg_relation_hot_indexed_stats() reporting per-relation
HOT-indexed chain composition.  Document them in monitoring.sgml and the
README.

With statistics, prune/collapse, and amcheck recognition all in place, add the
full feature test suite, which uses those facilities to verify behavior:

- hot_indexed_updates (regression): eligibility and classification; selective
  maintenance across multiple/composite indexes; the crossed-attribute read
  path for equality, range, and inequality scans; a key cycled away and back
  (ABA), including across two distinct live rows; TOASTed indexed columns;
  partial-index predicate flips (key and non-key predicate columns);
  trigger-modified indexed columns; exclusion-constraint tables; partitioned
  tables; non-btree access methods (hash, GIN, GiST); a UNIQUE index on a type
  where image equality differs from operator equality; CREATE INDEX / REINDEX
  and DROP INDEX over live chains; prune reclamation, stub mixes, and
  re-collapse across partial VACUUMs; the never-all-visible guard; and DDL
  after a chain exists (ADD COLUMN crossing a bitmap-size boundary, DROP
  COLUMN).
- hot_indexed_adversarial (isolation): concurrent UPDATE / VACUUM / prune and
  index scans, key cycling, aborts, and reader consistency across a concurrent
  collapse.
- 054_hot_indexed_recovery (recovery): WAL replay of the chain and its collapse
  under wal_consistency_checking.
- pg_surgery handling of HOT-indexed tuples and collapse-survivor stubs.

Authored-by: Greg Burd <greg@burd.me>
---
 contrib/pg_surgery/Makefile                   |    1 +
 contrib/pg_surgery/expected/heap_surgery.out  |   29 +
 contrib/pg_surgery/sql/heap_surgery.sql       |   20 +
 doc/src/sgml/monitoring.sgml                  |   57 +
 src/backend/access/heap/Makefile              |    1 +
 src/backend/access/heap/README.HOT-INDEXED    |    3 +
 src/backend/access/heap/hot_indexed_stats.c   |  186 ++
 src/backend/access/heap/meson.build           |    1 +
 src/backend/catalog/system_views.sql          |    4 +
 src/backend/utils/adt/pgstatfuncs.c           |   12 +
 src/include/catalog/pg_proc.dat               |   28 +
 .../expected/hot_indexed_adversarial.out      |  139 ++
 src/test/isolation/isolation_schedule         |    1 +
 .../specs/hot_indexed_adversarial.spec        |  123 ++
 src/test/recovery/Makefile                    |    3 +-
 src/test/recovery/meson.build                 |    1 +
 .../recovery/t/055_hot_indexed_recovery.pl    |  149 ++
 .../regress/expected/hot_indexed_updates.out  | 1706 +++++++++++++++++
 src/test/regress/expected/rules.out           |   12 +
 src/test/regress/parallel_schedule            |    1 +
 src/test/regress/sql/hot_indexed_updates.sql  | 1154 +++++++++++
 21 files changed, 3630 insertions(+), 1 deletion(-)
 create mode 100644 src/backend/access/heap/hot_indexed_stats.c
 create mode 100644 src/test/isolation/expected/hot_indexed_adversarial.out
 create mode 100644 src/test/isolation/specs/hot_indexed_adversarial.spec
 create mode 100644 src/test/recovery/t/055_hot_indexed_recovery.pl
 create mode 100644 src/test/regress/expected/hot_indexed_updates.out
 create mode 100644 src/test/regress/sql/hot_indexed_updates.sql

diff --git a/contrib/pg_surgery/Makefile b/contrib/pg_surgery/Makefile
index a66776c4c41..da752a81147 100644
--- a/contrib/pg_surgery/Makefile
+++ b/contrib/pg_surgery/Makefile
@@ -10,6 +10,7 @@ DATA = pg_surgery--1.0.sql
 PGFILEDESC = "pg_surgery - perform surgery on a damaged relation"
 
 REGRESS = heap_surgery
+EXTRA_INSTALL = contrib/pageinspect
 
 ifdef USE_PGXS
 PG_CONFIG = pg_config
diff --git a/contrib/pg_surgery/expected/heap_surgery.out b/contrib/pg_surgery/expected/heap_surgery.out
index df7d13b0908..9c066f087e7 100644
--- a/contrib/pg_surgery/expected/heap_surgery.out
+++ b/contrib/pg_surgery/expected/heap_surgery.out
@@ -175,6 +175,35 @@ DETAIL:  This operation is not supported for views.
 select heap_force_freeze('vw'::regclass, ARRAY['(0, 1)']::tid[]);
 ERROR:  cannot operate on relation "vw"
 DETAIL:  This operation is not supported for views.
+-- A HOT/SIU chain collapse turns the chain root and each dead entry-bearing
+-- member into an LP_REDIRECT to the live tuple.  pg_surgery operates on real
+-- tuples and must leave the live row reachable after such a collapse.
+create extension pageinspect;
+create table htomb (id int primary key, a int, b int) with (fillfactor = 50);
+create index htomb_a on htomb(a);
+insert into htomb values (1, 10, 20);
+-- Two HOT-indexed updates on an indexed attr, then prune: the dead mid-chain
+-- versions collapse to LP_REDIRECTs to the live tuple.  INDEX_CLEANUP off keeps
+-- the stale btree leaves (and hence the redirects) in place.
+update htomb set a = 11 where id = 1;
+update htomb set a = 12 where id = 1;
+vacuum (index_cleanup off) htomb;
+select n_hot_indexed > 0 as made_hot_indexed
+  from pg_relation_hot_indexed_stats('htomb');
+ made_hot_indexed 
+------------------
+ t
+(1 row)
+
+-- the live row is intact and reachable after the collapse
+select id, a, b from htomb;
+ id | a  | b  
+----+----+----
+  1 | 12 | 20
+(1 row)
+
+drop table htomb;
+drop extension pageinspect;
 -- cleanup.
 drop view vw;
 drop extension pg_surgery;
diff --git a/contrib/pg_surgery/sql/heap_surgery.sql b/contrib/pg_surgery/sql/heap_surgery.sql
index 6526b27535d..7a0f2722dc3 100644
--- a/contrib/pg_surgery/sql/heap_surgery.sql
+++ b/contrib/pg_surgery/sql/heap_surgery.sql
@@ -83,6 +83,26 @@ create view vw as select 1;
 select heap_force_kill('vw'::regclass, ARRAY['(0, 1)']::tid[]);
 select heap_force_freeze('vw'::regclass, ARRAY['(0, 1)']::tid[]);
 
+-- A HOT/SIU chain collapse turns the chain root and each dead entry-bearing
+-- member into an LP_REDIRECT to the live tuple.  pg_surgery operates on real
+-- tuples and must leave the live row reachable after such a collapse.
+create extension pageinspect;
+create table htomb (id int primary key, a int, b int) with (fillfactor = 50);
+create index htomb_a on htomb(a);
+insert into htomb values (1, 10, 20);
+-- Two HOT-indexed updates on an indexed attr, then prune: the dead mid-chain
+-- versions collapse to LP_REDIRECTs to the live tuple.  INDEX_CLEANUP off keeps
+-- the stale btree leaves (and hence the redirects) in place.
+update htomb set a = 11 where id = 1;
+update htomb set a = 12 where id = 1;
+vacuum (index_cleanup off) htomb;
+select n_hot_indexed > 0 as made_hot_indexed
+  from pg_relation_hot_indexed_stats('htomb');
+-- the live row is intact and reachable after the collapse
+select id, a, b from htomb;
+drop table htomb;
+drop extension pageinspect;
+
 -- cleanup.
 drop view vw;
 drop extension pg_surgery;
diff --git a/doc/src/sgml/monitoring.sgml b/doc/src/sgml/monitoring.sgml
index 05bd501c682..325fe2e7694 100644
--- a/doc/src/sgml/monitoring.sgml
+++ b/doc/src/sgml/monitoring.sgml
@@ -4326,6 +4326,19 @@ description | Waiting for a newly initialized WAL file to reach durable storage
       </para></entry>
      </row>
 
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>n_tup_hot_indexed_upd</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of rows updated using the HOT-indexed path: the successor
+       version stays on the same page as a heap-only tuple even though it
+       changed one or more indexed columns, and only the affected indexes
+       receive a new entry.  Every such update is also counted in
+       <structfield>n_tup_hot_upd</structfield>.
+      </para></entry>
+     </row>
+
      <row>
       <entry role="catalog_table_entry"><para role="column_definition">
        <structfield>n_tup_newpage_upd</structfield> <type>bigint</type>
@@ -4796,6 +4809,27 @@ description | Waiting for a newly initialized WAL file to reach durable storage
       </para></entry>
      </row>
 
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>n_tup_hot_indexed_upd_matched</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of HOT-indexed updates that inserted a new entry into this
+       index (the update changed one of this index's attributes)
+      </para></entry>
+     </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>n_tup_hot_indexed_upd_skipped</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of HOT-indexed updates that skipped this index (the update
+       changed no attribute of this index, so its existing entry continues
+       to resolve the HOT chain)
+      </para></entry>
+     </row>
+
      <row>
       <entry role="catalog_table_entry"><para role="column_definition">
         <structfield>stats_reset</structfield> <type>timestamp with time zone</type>
@@ -5477,6 +5511,29 @@ description | Waiting for a newly initialized WAL file to reach durable storage
      </thead>
 
      <tbody>
+      <row>
+       <entry id="pg-relation-hot-indexed-stats" role="func_table_entry"><para role="func_signature">
+        <indexterm>
+         <primary>pg_relation_hot_indexed_stats</primary>
+        </indexterm>
+        <function>pg_relation_hot_indexed_stats</function> ( <parameter>relation</parameter> <type>regclass</type> )
+        <returnvalue>record</returnvalue>
+        ( <parameter>n_hot_indexed</parameter> <type>bigint</type>,
+        <parameter>n_chains</parameter> <type>bigint</type>,
+        <parameter>avg_chain_len</parameter> <type>double precision</type>,
+        <parameter>max_chain_len</parameter> <type>bigint</type> )
+       </para>
+       <para>
+        Reports HOT-indexed structural statistics for a table by scanning
+        every page under <literal>AccessShareLock</literal>:
+        <parameter>n_hot_indexed</parameter> is the number of live
+        HOT-indexed tuple versions present, and
+        <parameter>n_chains</parameter>, <parameter>avg_chain_len</parameter>
+        and <parameter>max_chain_len</parameter> describe the HOT-indexed
+        chains.  Intended for diagnostics.
+       </para></entry>
+      </row>
+
       <row>
        <!-- See also the entry for this in func.sgml -->
        <entry role="func_table_entry"><para role="func_signature">
diff --git a/src/backend/access/heap/Makefile b/src/backend/access/heap/Makefile
index 1d27ccb916e..dfaf3350736 100644
--- a/src/backend/access/heap/Makefile
+++ b/src/backend/access/heap/Makefile
@@ -20,6 +20,7 @@ OBJS = \
 	heapam_xlog.o \
 	heaptoast.o \
 	hio.o \
+	hot_indexed_stats.o \
 	pruneheap.o \
 	rewriteheap.o \
 	vacuumlazy.o \
diff --git a/src/backend/access/heap/README.HOT-INDEXED b/src/backend/access/heap/README.HOT-INDEXED
index 1f41b0fffe8..2e5e5f4a081 100644
--- a/src/backend/access/heap/README.HOT-INDEXED
+++ b/src/backend/access/heap/README.HOT-INDEXED
@@ -252,6 +252,9 @@ HEAP_INDEXED_UPDATED heap-only tuple whose line pointer is preserved, and
 multiple LP_REDIRECTs forwarding to one live tuple.
 
 Statistics: pg_stat_all_tables.n_tup_hot_indexed_upd counts HOT-indexed
+updates; pg_stat_all_indexes.n_tup_hot_indexed_upd_matched / _skipped count
+per-index recheck outcomes; and pg_relation_hot_indexed_stats() reports
+per-relation HOT-indexed chain counts.
 
 
 Appendices
diff --git a/src/backend/access/heap/hot_indexed_stats.c b/src/backend/access/heap/hot_indexed_stats.c
new file mode 100644
index 00000000000..57ef9461562
--- /dev/null
+++ b/src/backend/access/heap/hot_indexed_stats.c
@@ -0,0 +1,186 @@
+/*-------------------------------------------------------------------------
+ *
+ * hot_indexed_stats.c
+ *	  SQL-callable diagnostic that walks every page of a heap relation and
+ *	  reports hot-indexed-related structural statistics.
+ *
+ * These numbers complement the running pgstat counters
+ * (n_tup_hot_indexed_upd in pg_stat_all_tables): they answer "what is on disk
+ * right now?" rather than "how often did hot-indexed fire during the stats
+ * window?".
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *	  src/backend/access/heap/hot_indexed_stats.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/heapam.h"
+#include "access/htup_details.h"
+#include "catalog/objectaddress.h"
+#include "catalog/pg_type.h"
+#include "fmgr.h"
+#include "funcapi.h"
+#include "miscadmin.h"
+#include "storage/bufmgr.h"
+#include "storage/bufpage.h"
+#include "storage/itemptr.h"
+#include "utils/acl.h"
+#include "utils/builtins.h"
+#include "utils/rel.h"
+
+/*
+ * pg_relation_hot_indexed_stats(regclass) -> record
+ *
+ * Walks every block of the relation's main fork and counts:
+ *   n_hot_indexed   -- live HOT-indexed versions (HEAP_INDEXED_UPDATED, natts>0,
+ *                      carrying an inline-trailing modified-attrs bitmap)
+ *   n_chains        -- LP_REDIRECT items, i.e. HOT chain roots.  Matches
+ *                      the number of distinct HOT chains that have survived
+ *                      the most recent prune.  Root-not-redirect chains
+ *                      (length 1) are not counted here because they are
+ *                      indistinguishable from a non-chain tuple.
+ *   avg_chain_len   -- mean length across chains rooted at an LP_REDIRECT,
+ *                      derived by walking each redirect target to the end
+ *                      of its HEAP_HOT_UPDATED chain.
+ *   max_chain_len   -- longest chain observed.
+ *
+ * The caller must hold SELECT privilege on the relation, like other
+ * relation-inspection functions; it takes only AccessShareLock and short-term
+ * buffer share locks while scanning.
+ */
+Datum
+pg_relation_hot_indexed_stats(PG_FUNCTION_ARGS)
+{
+	Oid			relid = PG_GETARG_OID(0);
+	Relation	rel;
+	AclResult	aclresult;
+	BlockNumber nblocks;
+	BlockNumber blk;
+	int64		n_hot_indexed = 0;
+	int64		n_chains = 0;
+	int64		sum_chain_len = 0;
+	int64		max_chain_len = 0;
+	TupleDesc	tupdesc;
+	Datum		values[4];
+	bool		nulls[4] = {0};
+	HeapTuple	resulttup;
+
+	rel = relation_open(relid, AccessShareLock);
+	if (rel->rd_rel->relkind != RELKIND_RELATION &&
+		rel->rd_rel->relkind != RELKIND_MATVIEW &&
+		rel->rd_rel->relkind != RELKIND_TOASTVALUE)
+		ereport(ERROR,
+				(errcode(ERRCODE_WRONG_OBJECT_TYPE),
+				 errmsg("\"%s\" is not a table, materialized view, or TOAST table",
+						RelationGetRelationName(rel))));
+
+	/* Caller must be able to read the relation. */
+	aclresult = pg_class_aclcheck(relid, GetUserId(), ACL_SELECT);
+	if (aclresult != ACLCHECK_OK)
+		aclcheck_error(aclresult,
+					   get_relkind_objtype(rel->rd_rel->relkind),
+					   RelationGetRelationName(rel));
+
+	nblocks = RelationGetNumberOfBlocks(rel);
+
+	for (blk = 0; blk < nblocks; blk++)
+	{
+		Buffer		buf;
+		Page		page;
+		OffsetNumber off;
+		OffsetNumber maxoff;
+
+		CHECK_FOR_INTERRUPTS();
+
+		buf = ReadBufferExtended(rel, MAIN_FORKNUM, blk, RBM_NORMAL, NULL);
+		LockBuffer(buf, BUFFER_LOCK_SHARE);
+		page = BufferGetPage(buf);
+
+		if (PageIsNew(page) || PageIsEmpty(page))
+		{
+			UnlockReleaseBuffer(buf);
+			continue;
+		}
+
+		maxoff = PageGetMaxOffsetNumber(page);
+		for (off = FirstOffsetNumber; off <= maxoff; off = OffsetNumberNext(off))
+		{
+			ItemId		lp = PageGetItemId(page, off);
+
+			if (!ItemIdIsUsed(lp))
+				continue;
+
+			if (ItemIdIsRedirected(lp))
+			{
+				/* Walk the chain starting at the redirect target. */
+				OffsetNumber cur = ItemIdGetRedirect(lp);
+				int64		len = 0;
+
+				/*
+				 * Walk the same-page HOT chain.  Bound the loop by the page's
+				 * item count so a corrupt cyclic t_ctid cannot spin forever
+				 * under the buffer lock, and check for interrupts each step.
+				 */
+				while (cur >= FirstOffsetNumber && cur <= maxoff && len < maxoff)
+				{
+					ItemId		chain_lp = PageGetItemId(page, cur);
+					HeapTupleHeader thdr;
+
+					CHECK_FOR_INTERRUPTS();
+
+					if (!ItemIdIsNormal(chain_lp))
+						break;
+					thdr = (HeapTupleHeader) PageGetItem(page, chain_lp);
+					len++;
+					if (!(thdr->t_infomask2 & HEAP_HOT_UPDATED))
+						break;
+					/* HOT chains stay on one page; stop if the link leaves it. */
+					if (ItemPointerGetBlockNumber(&thdr->t_ctid) != blk)
+						break;
+					cur = ItemPointerGetOffsetNumber(&thdr->t_ctid);
+				}
+				if (len > 0)
+				{
+					n_chains++;
+					sum_chain_len += len;
+					if (len > max_chain_len)
+						max_chain_len = len;
+				}
+			}
+			else if (ItemIdIsNormal(lp))
+			{
+				HeapTupleHeader thdr = (HeapTupleHeader) PageGetItem(page, lp);
+
+				if ((thdr->t_infomask2 & HEAP_INDEXED_UPDATED) != 0)
+					n_hot_indexed++;
+			}
+		}
+
+		UnlockReleaseBuffer(buf);
+	}
+
+	relation_close(rel, AccessShareLock);
+
+	tupdesc = CreateTemplateTupleDesc(4);
+	TupleDescInitEntry(tupdesc, (AttrNumber) 1, "n_hot_indexed", INT8OID, -1, 0);
+	TupleDescInitEntry(tupdesc, (AttrNumber) 2, "n_chains", INT8OID, -1, 0);
+	TupleDescInitEntry(tupdesc, (AttrNumber) 3, "avg_chain_len", FLOAT8OID, -1, 0);
+	TupleDescInitEntry(tupdesc, (AttrNumber) 4, "max_chain_len", INT8OID, -1, 0);
+	TupleDescFinalize(tupdesc);
+	tupdesc = BlessTupleDesc(tupdesc);
+
+	values[0] = Int64GetDatum(n_hot_indexed);
+	values[1] = Int64GetDatum(n_chains);
+	if (n_chains > 0)
+		values[2] = Float8GetDatum(((double) sum_chain_len) / (double) n_chains);
+	else
+		values[2] = Float8GetDatum(0.0);
+	values[3] = Int64GetDatum(max_chain_len);
+
+	resulttup = heap_form_tuple(tupdesc, values, nulls);
+	PG_RETURN_DATUM(HeapTupleGetDatum(resulttup));
+}
diff --git a/src/backend/access/heap/meson.build b/src/backend/access/heap/meson.build
index 00ec07d7f30..b5c2a8d5cb6 100644
--- a/src/backend/access/heap/meson.build
+++ b/src/backend/access/heap/meson.build
@@ -8,6 +8,7 @@ backend_sources += files(
   'heapam_xlog.c',
   'heaptoast.c',
   'hio.c',
+  'hot_indexed_stats.c',
   'pruneheap.c',
   'rewriteheap.c',
   'vacuumlazy.c',
diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql
index 8f129baec90..4ea1fc00659 100644
--- a/src/backend/catalog/system_views.sql
+++ b/src/backend/catalog/system_views.sql
@@ -730,6 +730,7 @@ CREATE VIEW pg_stat_all_tables AS
             pg_stat_get_tuples_updated(C.oid) AS n_tup_upd,
             pg_stat_get_tuples_deleted(C.oid) AS n_tup_del,
             pg_stat_get_tuples_hot_updated(C.oid) AS n_tup_hot_upd,
+            pg_stat_get_tuples_hot_indexed_updated(C.oid) AS n_tup_hot_indexed_upd,
             pg_stat_get_tuples_newpage_updated(C.oid) AS n_tup_newpage_upd,
             pg_stat_get_live_tuples(C.oid) AS n_live_tup,
             pg_stat_get_dead_tuples(C.oid) AS n_dead_tup,
@@ -768,6 +769,7 @@ CREATE VIEW pg_stat_xact_all_tables AS
             pg_stat_get_xact_tuples_updated(C.oid) AS n_tup_upd,
             pg_stat_get_xact_tuples_deleted(C.oid) AS n_tup_del,
             pg_stat_get_xact_tuples_hot_updated(C.oid) AS n_tup_hot_upd,
+            pg_stat_get_xact_tuples_hot_indexed_updated(C.oid) AS n_tup_hot_indexed_upd,
             pg_stat_get_xact_tuples_newpage_updated(C.oid) AS n_tup_newpage_upd
     FROM pg_class C LEFT JOIN
          pg_index I ON C.oid = I.indrelid
@@ -869,6 +871,8 @@ CREATE VIEW pg_stat_all_indexes AS
             pg_stat_get_lastscan(I.oid) AS last_idx_scan,
             pg_stat_get_tuples_returned(I.oid) AS idx_tup_read,
             pg_stat_get_tuples_fetched(I.oid) AS idx_tup_fetch,
+            pg_stat_get_tuples_hot_indexed_updated_skipped(I.oid) AS n_tup_hot_indexed_upd_skipped,
+            pg_stat_get_tuples_hot_indexed_updated_matched(I.oid) AS n_tup_hot_indexed_upd_matched,
             pg_stat_get_stat_reset_time(I.oid) AS stats_reset
     FROM pg_class C JOIN
             pg_index X ON C.oid = X.indrelid JOIN
diff --git a/src/backend/utils/adt/pgstatfuncs.c b/src/backend/utils/adt/pgstatfuncs.c
index 0c6a20843a5..8ed8e4ff767 100644
--- a/src/backend/utils/adt/pgstatfuncs.c
+++ b/src/backend/utils/adt/pgstatfuncs.c
@@ -93,6 +93,15 @@ PG_STAT_GET_RELENTRY_INT64(tuples_fetched)
 /* pg_stat_get_tuples_hot_updated */
 PG_STAT_GET_RELENTRY_INT64(tuples_hot_updated)
 
+/* pg_stat_get_tuples_hot_indexed_updated */
+PG_STAT_GET_RELENTRY_INT64(tuples_hot_indexed_updated)
+
+/* pg_stat_get_tuples_hot_indexed_updated_skipped */
+PG_STAT_GET_RELENTRY_INT64(tuples_hot_indexed_upd_skipped)
+
+/* pg_stat_get_tuples_hot_indexed_updated_matched */
+PG_STAT_GET_RELENTRY_INT64(tuples_hot_indexed_upd_matched)
+
 /* pg_stat_get_tuples_newpage_updated */
 PG_STAT_GET_RELENTRY_INT64(tuples_newpage_updated)
 
@@ -1888,6 +1897,9 @@ PG_STAT_GET_XACT_RELENTRY_INT64(tuples_fetched)
 /* pg_stat_get_xact_tuples_hot_updated */
 PG_STAT_GET_XACT_RELENTRY_INT64(tuples_hot_updated)
 
+/* pg_stat_get_xact_tuples_hot_indexed_updated */
+PG_STAT_GET_XACT_RELENTRY_INT64(tuples_hot_indexed_updated)
+
 /* pg_stat_get_xact_tuples_newpage_updated */
 PG_STAT_GET_XACT_RELENTRY_INT64(tuples_newpage_updated)
 
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 73bb7fbb430..194dc3787e0 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -5594,6 +5594,29 @@
   proname => 'pg_stat_get_tuples_hot_updated', provolatile => 's',
   proparallel => 'r', prorettype => 'int8', proargtypes => 'oid',
   prosrc => 'pg_stat_get_tuples_hot_updated' },
+{ oid => '9953',
+  descr => 'statistics: number of tuples updated via HOT-indexed (Selective Index Update)',
+  proname => 'pg_stat_get_tuples_hot_indexed_updated', provolatile => 's',
+  proparallel => 'r', prorettype => 'int8', proargtypes => 'oid',
+  prosrc => 'pg_stat_get_tuples_hot_indexed_updated' },
+{ oid => '9956',
+  descr => 'statistics: number of HOT-indexed updates that skipped this index',
+  proname => 'pg_stat_get_tuples_hot_indexed_updated_skipped', provolatile => 's',
+  proparallel => 'r', prorettype => 'int8', proargtypes => 'oid',
+  prosrc => 'pg_stat_get_tuples_hot_indexed_upd_skipped' },
+{ oid => '9957',
+  descr => 'statistics: number of HOT-indexed updates that inserted into this index',
+  proname => 'pg_stat_get_tuples_hot_indexed_updated_matched', provolatile => 's',
+  proparallel => 'r', prorettype => 'int8', proargtypes => 'oid',
+  prosrc => 'pg_stat_get_tuples_hot_indexed_upd_matched' },
+{ oid => '9955',
+  descr => 'HOT-indexed structural stats: HOT-indexed versions and chain lengths',
+  proname => 'pg_relation_hot_indexed_stats', provolatile => 'v',
+  proparallel => 'r', prorettype => 'record', proargtypes => 'regclass',
+  proallargtypes => '{regclass,int8,int8,float8,int8}',
+  proargmodes => '{i,o,o,o,o}',
+  proargnames => '{relation,n_hot_indexed,n_chains,avg_chain_len,max_chain_len}',
+  prosrc => 'pg_relation_hot_indexed_stats' },
 { oid => '6217',
   descr => 'statistics: number of tuples updated onto a new page',
   proname => 'pg_stat_get_tuples_newpage_updated', provolatile => 's',
@@ -6171,6 +6194,11 @@
   proname => 'pg_stat_get_xact_tuples_hot_updated', provolatile => 'v',
   proparallel => 'r', prorettype => 'int8', proargtypes => 'oid',
   prosrc => 'pg_stat_get_xact_tuples_hot_updated' },
+{ oid => '9954',
+  descr => 'statistics: number of HOT-indexed tuple updates in current transaction',
+  proname => 'pg_stat_get_xact_tuples_hot_indexed_updated', provolatile => 'v',
+  proparallel => 'r', prorettype => 'int8', proargtypes => 'oid',
+  prosrc => 'pg_stat_get_xact_tuples_hot_indexed_updated' },
 { oid => '6218',
   descr => 'statistics: number of tuples updated onto a new page in current transaction',
   proname => 'pg_stat_get_xact_tuples_newpage_updated', provolatile => 'v',
diff --git a/src/test/isolation/expected/hot_indexed_adversarial.out b/src/test/isolation/expected/hot_indexed_adversarial.out
new file mode 100644
index 00000000000..22f62801513
--- /dev/null
+++ b/src/test/isolation/expected/hot_indexed_adversarial.out
@@ -0,0 +1,139 @@
+Parsed test spec with 6 sessions
+
+starting permutation: s2_noseq s1_cycle s2_eq10 s2_eq20
+step s2_noseq: SET enable_seqscan = off;
+step s1_cycle: UPDATE hia SET k = 20 WHERE id = 1; UPDATE hia SET k = 10 WHERE id = 1;
+step s2_eq10: SELECT id, k FROM hia WHERE k = 10;
+id| k
+--+--
+ 1|10
+(1 row)
+
+step s2_eq20: SELECT id, k FROM hia WHERE k = 20;
+id|k
+--+-
+(0 rows)
+
+
+starting permutation: s2_noseq s1_cycle s2_range
+step s2_noseq: SET enable_seqscan = off;
+step s1_cycle: UPDATE hia SET k = 20 WHERE id = 1; UPDATE hia SET k = 10 WHERE id = 1;
+step s2_range: SELECT id, k FROM hia WHERE k >= 5 ORDER BY k;
+id| k
+--+--
+ 1|10
+(1 row)
+
+
+starting permutation: s2_noseq s1_begin s1_upd20 s1_abort s2_eq20 s2_eq10
+step s2_noseq: SET enable_seqscan = off;
+step s1_begin: BEGIN;
+step s1_upd20: UPDATE hia SET k = 20 WHERE id = 1;
+step s1_abort: ROLLBACK;
+step s2_eq20: SELECT id, k FROM hia WHERE k = 20;
+id|k
+--+-
+(0 rows)
+
+step s2_eq10: SELECT id, k FROM hia WHERE k = 10;
+id| k
+--+--
+ 1|10
+(1 row)
+
+
+starting permutation: s1_begin s1_uupd20 s2_ins10 s1_commit
+step s1_begin: BEGIN;
+step s1_uupd20: UPDATE hiu SET k = 20 WHERE id = 1;
+step s2_ins10: INSERT INTO hiu VALUES (2, 10, repeat('y', 40)); <waiting ...>
+step s1_commit: COMMIT;
+step s2_ins10: <... completed>
+
+starting permutation: s1_begin s1_uupd20 s1_commit s2_ins10
+step s1_begin: BEGIN;
+step s1_uupd20: UPDATE hiu SET k = 20 WHERE id = 1;
+step s1_commit: COMMIT;
+step s2_ins10: INSERT INTO hiu VALUES (2, 10, repeat('y', 40));
+
+starting permutation: s1_begin s1_uupd20 s1_commit s2_ins20
+step s1_begin: BEGIN;
+step s1_uupd20: UPDATE hiu SET k = 20 WHERE id = 1;
+step s1_commit: COMMIT;
+step s2_ins20: INSERT INTO hiu VALUES (3, 20, repeat('z', 40));
+ERROR:  duplicate key value violates unique constraint "hiu_k"
+
+starting permutation: s3_begin s3_eq10 s2_to30 s2_scan30 s3_eq10 s3_commit
+step s3_begin: BEGIN ISOLATION LEVEL REPEATABLE READ; SET enable_seqscan = off;
+step s3_eq10: SELECT id, k FROM hia WHERE k = 10;
+id| k
+--+--
+ 1|10
+(1 row)
+
+step s2_to30: UPDATE hia SET k = 20 WHERE id = 1; UPDATE hia SET k = 30 WHERE id = 1;
+step s2_scan30: SET enable_seqscan = off; SELECT id, k FROM hia WHERE k = 30;
+id| k
+--+--
+ 1|30
+(1 row)
+
+step s3_eq10: SELECT id, k FROM hia WHERE k = 10;
+id| k
+--+--
+ 1|10
+(1 row)
+
+step s3_commit: COMMIT;
+
+starting permutation: b1_begin b1_snap b2_update b2_vacuum b1_snap b1_commit b3_seq
+step b1_begin: BEGIN;
+step b1_snap: SELECT id, v FROM hib WHERE v = 400;
+id|  v
+--+---
+ 1|400
+(1 row)
+
+step b2_update: UPDATE hib SET v = 500 WHERE id = 1;
+step b2_vacuum: VACUUM (INDEX_CLEANUP off) hib;
+step b1_snap: SELECT id, v FROM hib WHERE v = 400;
+id|v
+--+-
+(0 rows)
+
+step b1_commit: COMMIT;
+step b3_seq: SELECT id, v FROM hib ORDER BY id;
+id|  v
+--+---
+ 1|500
+ 2| 20
+ 3| 30
+ 4| 40
+ 5| 50
+(5 rows)
+
+
+starting permutation: b1_begin b2_update b1_snap b2_vacuum b1_snap b1_commit b3_seq
+step b1_begin: BEGIN;
+step b2_update: UPDATE hib SET v = 500 WHERE id = 1;
+step b1_snap: SELECT id, v FROM hib WHERE v = 400;
+id|v
+--+-
+(0 rows)
+
+step b2_vacuum: VACUUM (INDEX_CLEANUP off) hib;
+step b1_snap: SELECT id, v FROM hib WHERE v = 400;
+id|v
+--+-
+(0 rows)
+
+step b1_commit: COMMIT;
+step b3_seq: SELECT id, v FROM hib ORDER BY id;
+id|  v
+--+---
+ 1|500
+ 2| 20
+ 3| 30
+ 4| 40
+ 5| 50
+(5 rows)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..e9afb48199e 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: hot_indexed_adversarial
diff --git a/src/test/isolation/specs/hot_indexed_adversarial.spec b/src/test/isolation/specs/hot_indexed_adversarial.spec
new file mode 100644
index 00000000000..64705bf37e0
--- /dev/null
+++ b/src/test/isolation/specs/hot_indexed_adversarial.spec
@@ -0,0 +1,123 @@
+# Adversarial correctness tests for HOT-indexed (SIU) updates.
+#
+# Each permutation pins a case that the mid-chain-pointing invariant must
+# satisfy: an index entry points at the heap-only version whose key it
+# matched, and a chain walk that crosses a HOT-indexed hop drops the arriving
+# entry when the crossed-attribute bitmap overlaps the index's key columns
+# (no key comparison).  These are
+# exactly the cases that historically broke write-amplification-reduction
+# designs.
+
+setup
+{
+    CREATE TABLE hia (id int PRIMARY KEY, k int, pad text) WITH (fillfactor = 40);
+    CREATE INDEX hia_k ON hia(k);
+    CREATE TABLE hiu (id int PRIMARY KEY, k int, pad text) WITH (fillfactor = 40);
+    CREATE UNIQUE INDEX hiu_k ON hiu(k);
+    INSERT INTO hia VALUES (1, 10, repeat('x', 40));
+    INSERT INTO hiu VALUES (1, 10, repeat('x', 40));
+
+    -- Table for the concurrent-collapse reader-consistency case (7).
+    CREATE TABLE hib (id int PRIMARY KEY, v int, pad text) WITH (fillfactor = 50);
+    CREATE INDEX hib_v_idx ON hib(v);
+    INSERT INTO hib SELECT g, g * 10, repeat('x', 50) FROM generate_series(1, 5) g;
+    UPDATE hib SET v = 100 WHERE id = 1;
+    UPDATE hib SET v = 200 WHERE id = 1;
+    UPDATE hib SET v = 300 WHERE id = 1;
+    UPDATE hib SET v = 400 WHERE id = 1;
+}
+
+teardown
+{
+    DROP TABLE hia;
+    DROP TABLE hiu;
+    DROP TABLE hib;
+}
+
+session s1
+step s1_begin   { BEGIN; }
+# Cycle the indexed key away and back: 10 -> 20 -> 10.  The original 10 leaf
+# and the freshly-inserted 10 leaf both resolve to the live tuple; the chain
+# walk must drop the stale one so a lookup returns the row exactly once.
+step s1_cycle   { UPDATE hia SET k = 20 WHERE id = 1; UPDATE hia SET k = 10 WHERE id = 1; }
+# A single HOT-indexed update on hia, used by the abort and reader cases.
+step s1_upd20   { UPDATE hia SET k = 20 WHERE id = 1; }
+# A HOT-indexed update on the UNIQUE-indexed table, freeing key 10 for k=20.
+step s1_uupd20  { UPDATE hiu SET k = 20 WHERE id = 1; }
+step s1_commit  { COMMIT; }
+step s1_abort   { ROLLBACK; }
+
+session s2
+# Index-only lookups (forced) that exercise the stale-leaf recheck.
+step s2_noseq   { SET enable_seqscan = off; }
+step s2_eq10    { SELECT id, k FROM hia WHERE k = 10; }
+step s2_eq20    { SELECT id, k FROM hia WHERE k = 20; }
+step s2_range   { SELECT id, k FROM hia WHERE k >= 5 ORDER BY k; }
+# Concurrent unique insert of the key s1 is freeing (10) and of the key s1 is
+# taking (20); _bt_check_unique must filter the stale 10 leaf but still
+# conflict on the live key.
+step s2_ins10   { INSERT INTO hiu VALUES (2, 10, repeat('y', 40)); }
+step s2_ins20   { INSERT INTO hiu VALUES (3, 20, repeat('z', 40)); }
+# Move the indexed key well away from 10 (two HOT-indexed hops) and force an
+# index scan on the new key.  That scan reaches the live tuple through the
+# stale 10 leaf and may try to kill it for bloat reclaim.
+step s2_to30    { UPDATE hia SET k = 20 WHERE id = 1; UPDATE hia SET k = 30 WHERE id = 1; }
+step s2_scan30  { SET enable_seqscan = off; SELECT id, k FROM hia WHERE k = 30; }
+
+# Reader holding an older REPEATABLE READ snapshot that still sees k=10.
+session s3
+step s3_begin   { BEGIN ISOLATION LEVEL REPEATABLE READ; SET enable_seqscan = off; }
+step s3_eq10    { SELECT id, k FROM hia WHERE k = 10; }
+step s3_commit  { COMMIT; }
+
+session b1
+step b1_begin   { BEGIN; }
+# Reader takes a snapshot and reads the chain via the secondary index.
+step b1_snap    { SELECT id, v FROM hib WHERE v = 400; }
+step b1_commit  { COMMIT; }
+
+session b2
+# Force prune/collapse: another HOT-indexed update plus a VACUUM that
+# collapses the dead chain members to LP_REDIRECT forwarders.
+step b2_update  { UPDATE hib SET v = 500 WHERE id = 1; }
+step b2_vacuum  { VACUUM (INDEX_CLEANUP off) hib; }
+
+session b3
+step b3_seq     { SELECT id, v FROM hib ORDER BY id; }
+
+# 1. a->b->a cycle: exactly one row for the cycled-back key, none for the
+#    transient key.
+permutation s2_noseq s1_cycle s2_eq10 s2_eq20
+
+# 2. Range scan returns the live row exactly once across the stale+fresh
+#    leaves left by the cycle.
+permutation s2_noseq s1_cycle s2_range
+
+# 3. Abort of a HOT-indexed update: the new key must not surface and the old
+#    key must still resolve to the (restored) live tuple.
+permutation s2_noseq s1_begin s1_upd20 s1_abort s2_eq20 s2_eq10
+
+# 4. Concurrent unique insert while a HOT-indexed update is in flight.  An
+#    insert of the key s1 is freeing (10) must wait on the uncommitted updater,
+#    then succeed once it commits (the stale 10 leaf is filtered).
+permutation s1_begin s1_uupd20 s2_ins10 s1_commit
+
+# 5. After the update commits, the freed key (10) inserts cleanly and the
+#    taken key (20) conflicts -- the live leaf is honoured, the stale one is not.
+permutation s1_begin s1_uupd20 s1_commit s2_ins10
+permutation s1_begin s1_uupd20 s1_commit s2_ins20
+
+# 6. Snapshot safety of stale-leaf reclaim.  An older REPEATABLE READ reader
+#    still sees k=10; a concurrent session then moves the key to 30 and runs an
+#    index scan that reaches the live tuple through the stale 10 leaf and may
+#    try to reclaim it.  The reclaim is gated on the skipped versions being
+#    dead to all transactions, which s3's held snapshot prevents, so s3 must
+#    still find the row by k=10 after the scan.
+permutation s3_begin s3_eq10 s2_to30 s2_scan30 s3_eq10 s3_commit
+
+# 7. Reader consistency across a concurrent prune/collapse.  s1's index scan,
+#    crossing the collapsed chain after the VACUUM, must still return the row
+#    via the crossed-attribute bitmap; the query must not error and the row count
+#    must be consistent.
+permutation b1_begin b1_snap b2_update b2_vacuum b1_snap b1_commit b3_seq
+permutation b1_begin b2_update b1_snap b2_vacuum b1_snap b1_commit b3_seq
diff --git a/src/test/recovery/Makefile b/src/test/recovery/Makefile
index d41aaaf8ae1..2736caa1a1b 100644
--- a/src/test/recovery/Makefile
+++ b/src/test/recovery/Makefile
@@ -9,7 +9,8 @@
 #
 #-------------------------------------------------------------------------
 
-EXTRA_INSTALL=contrib/pg_prewarm \
+EXTRA_INSTALL=contrib/amcheck \
+	contrib/pg_prewarm \
 	contrib/pg_stat_statements \
 	contrib/test_decoding \
 	src/test/modules/injection_points
diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build
index ad0d85f4189..8dbcda35775 100644
--- a/src/test/recovery/meson.build
+++ b/src/test/recovery/meson.build
@@ -63,6 +63,7 @@ tests += {
       't/052_checkpoint_segment_missing.pl',
       't/053_standby_login_event_trigger.pl',
       't/054_unlogged_sequence_promotion.pl',
+      't/055_hot_indexed_recovery.pl',
     ],
   },
 }
diff --git a/src/test/recovery/t/055_hot_indexed_recovery.pl b/src/test/recovery/t/055_hot_indexed_recovery.pl
new file mode 100644
index 00000000000..b6f077b3159
--- /dev/null
+++ b/src/test/recovery/t/055_hot_indexed_recovery.pl
@@ -0,0 +1,149 @@
+
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Crash-recovery coverage for HOT-indexed (HOT/SIU) chains.
+#
+# Build a HOT-indexed chain by repeatedly UPDATEing a single row,
+# changing one indexed (non-PK) column each time.  Force a prune so the
+# dead chain members collapse to LP_REDIRECT forwarders (with the live
+# HOT-indexed version visible via pg_relation_hot_indexed_stats as
+# n_hot_indexed > 0).  Crash-recover the primary with stop('immediate')
+# so the collapsed chain comes back from WAL or from the FPI.  After
+# restart, verify:
+#
+#   1. an index lookup walking the chain returns the live tuple,
+#   2. pg_amcheck (verify_heapam) reports no errors on the relation,
+#   3. VACUUM reclaims the collapsed chain (n_hot_indexed drops to 0).
+#
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init;
+# Disable autovacuum to keep the chain shape stable up to the explicit
+# prune we trigger below.
+$node->append_conf('postgresql.conf', q{autovacuum = off
+wal_consistency_checking = 'all'});
+$node->start;
+
+# amcheck (verify_heapam) is shipped as a contrib extension; we use it
+# from SQL after the crash-restart cycle.
+$node->safe_psql('postgres', q{CREATE EXTENSION amcheck});
+
+# Wide-ish table: PK + four indexed columns plus a non-indexed payload
+# so HOT-indexed updates have width to amortise.  fillfactor = 50 keeps
+# free space on-page for HOT-indexed continuations.
+$node->safe_psql('postgres', q{
+	CREATE TABLE hi_recov (
+		id      int PRIMARY KEY,
+		c1      int,
+		c2      int,
+		c3      int,
+		c4      int,
+		payload text
+	) WITH (fillfactor = 50);
+	CREATE INDEX hi_recov_c1 ON hi_recov(c1);
+	CREATE INDEX hi_recov_c2 ON hi_recov(c2);
+	CREATE INDEX hi_recov_c3 ON hi_recov(c3);
+	CREATE INDEX hi_recov_c4 ON hi_recov(c4);
+	INSERT INTO hi_recov VALUES (1, 100, 200, 300, 400, 'payload');
+});
+
+# Build a HOT-indexed chain: five UPDATEs, each touching one indexed
+# column.  Every UPDATE keeps the new version on-page and plants a fresh
+# index entry because c1 is indexed and changed.  Use a SQL transaction-
+# range loop so each UPDATE is its own xact (xmin/xmax distinct).
+for my $i (1 .. 5)
+{
+	my $newval = 100 + $i;
+	$node->safe_psql('postgres',
+		"UPDATE hi_recov SET c1 = $newval WHERE id = 1");
+}
+
+my $pre_prune = $node->safe_psql('postgres',
+	q{SELECT n_hot_indexed FROM pg_relation_hot_indexed_stats('hi_recov')});
+cmp_ok($pre_prune, '>', 0,
+	'HOT-indexed chain has at least one live HOT-indexed version before prune');
+
+# Force a prune.  The chain has dead heap-only members from the early
+# UPDATEs (their xmins are now committed and below the snapshot horizon).
+# A SELECT under default isolation visits the page; under
+# default_statistics_target etc. that's not enough on its own to trigger
+# prune.  The reliable way to drive opportunistic prune is a query that
+# exercises the heap_page_prune_opt path, which fires from an indexscan
+# that finds the page non-all-visible.  Use a sequential scan plus a
+# subsequent UPDATE that itself looks for free space (heap_update calls
+# heap_page_prune_opt).
+$node->safe_psql('postgres', q{
+	SET enable_indexscan = off;
+	SELECT count(*) FROM hi_recov;
+	UPDATE hi_recov SET payload = 'pruned' WHERE id = 1;
+});
+
+# Read the chain state after the prune: the live HOT-indexed version
+# remains while dead members collapse to LP_REDIRECT forwarders.
+my $post_prune = $node->safe_psql('postgres',
+	q{SELECT n_hot_indexed FROM pg_relation_hot_indexed_stats('hi_recov')});
+cmp_ok($post_prune, '>', 0,
+	'live HOT-indexed version survives opportunistic prune');
+
+# Crash-restart.  stop('immediate') is the standard "kill -9" simulation
+# used elsewhere in src/test/recovery/.  We intentionally do NOT issue a
+# CHECKPOINT first: that would advance the redo point past the HOT-indexed
+# UPDATE/prune records and leave nothing for recovery to replay.  Crashing
+# without a checkpoint forces startup redo to reconstruct the collapsed
+# chain from WAL, and wal_consistency_checking = 'all' (set above) compares
+# each replayed page against its full-page image, catching any divergence
+# between the write path and the redo path.
+$node->stop('immediate');
+$node->start;
+
+# 1. Chain walk via the indexed column on the live row returns the
+#    correct (and only the correct) tuple.  c1 = 105 was the last
+#    UPDATE, so the live tuple has c1 = 105 and c2..c4 unchanged.
+my $live = $node->safe_psql('postgres', q{
+	SET enable_seqscan = off;
+	SELECT id, c1, c2, c3, c4, payload FROM hi_recov WHERE c1 = 105;
+});
+is($live, "1|105|200|300|400|pruned",
+	'index lookup on chain returns the post-prune live tuple');
+
+# Older c1 values are not reachable: every stale btree entry that
+# chain-resolves across a HOT/SIU hop must be dropped by the read-side
+# crossed-attribute bitmap.
+my $stale_count = $node->safe_psql('postgres',
+	q{SELECT count(*) FROM hi_recov WHERE c1 = 100});
+is($stale_count, '0',
+	'stale btree entries are filtered by the crossed-attribute bitmap');
+
+# 2. verify_heapam reports no errors on the relation (skip_option =
+#    'all-frozen' is the default; we want to scan everything).
+my $heapcheck = $node->safe_psql('postgres', q{
+	SELECT count(*) FROM verify_heapam('hi_recov',
+	                                   skip := 'none',
+	                                   check_toast := false);
+});
+is($heapcheck, '0',
+	'verify_heapam reports zero errors after crash recovery');
+
+# 3. Reclamation: after the live row is deleted, two VACUUM (FREEZE)
+#    passes drive prune to revisit the page and reclaim the now-fully-dead
+#    collapsed chain (the first removes the dead row's index entries and
+#    reduces its LP; the second reclaims the unreferenced members and
+#    re-points the redirect).  After that, n_hot_indexed must be zero.
+$node->safe_psql('postgres', q{DELETE FROM hi_recov WHERE id = 1});
+$node->safe_psql('postgres',
+	q{VACUUM (FREEZE, DISABLE_PAGE_SKIPPING) hi_recov});
+$node->safe_psql('postgres',
+	q{VACUUM (FREEZE, DISABLE_PAGE_SKIPPING) hi_recov});
+my $final = $node->safe_psql('postgres',
+	q{SELECT n_hot_indexed FROM pg_relation_hot_indexed_stats('hi_recov')});
+is($final, '0',
+	'two VACUUM (FREEZE) passes after DELETE reclaim the chain post-recovery');
+
+$node->stop;
+
+done_testing();
diff --git a/src/test/regress/expected/hot_indexed_updates.out b/src/test/regress/expected/hot_indexed_updates.out
new file mode 100644
index 00000000000..a8a7fe3479e
--- /dev/null
+++ b/src/test/regress/expected/hot_indexed_updates.out
@@ -0,0 +1,1706 @@
+--
+-- HOT_INDEXED_UPDATES
+-- Test HOT-indexed update (hot-indexed), aka HOT-indexed, behaviour
+--
+-- Every UPDATE in this file modifies at least one non-summarizing
+-- indexed attribute.  On a pre-hot-indexed server all of these would be
+-- non-HOT; on the hot-indexed branch each eligible update stays on-page and
+-- inserts into only the indexes whose attributes actually changed.
+--
+-- We verify four things:
+--   (A) pg_stat counters: HOT and hot-indexed counts increment as expected
+--   (B) index lookups return the new value and not the stale value
+--       for EQUALITY queries (the read-side staleness test drops a
+--       leaf whose covered attribute changed on the way to the live tuple)
+--   (C) pg_relation_hot_indexed_stats reports the HOT-indexed versions we expect
+--   (D) **RANGE/INEQUALITY** queries return the correct number of
+--       tuples -- this is the class of bugs where a stale btree
+--       entry's key is still reachable via a looser scan key; the
+--       crossed-attribute bitmap drops the stale arrival because the index's
+--       attribute changed between that leaf's target and the live tuple
+--
+CREATE EXTENSION IF NOT EXISTS pageinspect;
+CREATE OR REPLACE FUNCTION get_hot_count(rel_name text)
+RETURNS TABLE (updates BIGINT, hot BIGINT) AS $$
+DECLARE rel_oid oid;
+BEGIN
+    rel_oid := rel_name::regclass::oid;
+    updates := COALESCE(pg_stat_get_tuples_updated(rel_oid), 0) +
+               COALESCE(pg_stat_get_xact_tuples_updated(rel_oid), 0);
+    hot := COALESCE(pg_stat_get_tuples_hot_updated(rel_oid), 0) +
+           COALESCE(pg_stat_get_xact_tuples_hot_updated(rel_oid), 0);
+    RETURN NEXT;
+END;
+$$ LANGUAGE plpgsql;
+CREATE OR REPLACE FUNCTION get_hi_count(rel_name text)
+RETURNS TABLE (updates BIGINT, hot BIGINT, hot_idx BIGINT) AS $$
+DECLARE rel_oid oid;
+BEGIN
+    rel_oid := rel_name::regclass::oid;
+    updates := COALESCE(pg_stat_get_tuples_updated(rel_oid), 0) +
+               COALESCE(pg_stat_get_xact_tuples_updated(rel_oid), 0);
+    hot := COALESCE(pg_stat_get_tuples_hot_updated(rel_oid), 0) +
+           COALESCE(pg_stat_get_xact_tuples_hot_updated(rel_oid), 0);
+    hot_idx := COALESCE(pg_stat_get_tuples_hot_indexed_updated(rel_oid), 0) +
+           COALESCE(pg_stat_get_xact_tuples_hot_indexed_updated(rel_oid), 0);
+    RETURN NEXT;
+END;
+$$ LANGUAGE plpgsql;
+-- ---------------------------------------------------------------------------
+-- 1. Basic hot-indexed: modifying an indexed column stays HOT and counts as hot-indexed
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_basic (
+    id int PRIMARY KEY,
+    indexed_col int,
+    non_indexed_col text
+) WITH (fillfactor = 50);
+CREATE INDEX hi_basic_idx ON hi_basic(indexed_col);
+INSERT INTO hi_basic VALUES (1, 100, 'initial');
+-- Pre-hot-indexed this would be non-HOT.  Under hot-indexed it's HOT-indexed; both the
+-- HOT counter and the hot-indexed counter advance.
+UPDATE hi_basic SET indexed_col = 150 WHERE id = 1;
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT * FROM get_hi_count('hi_basic');
+ updates | hot | hot_idx 
+---------+-----+---------
+       1 |   1 |       1
+(1 row)
+
+-- The new value is reachable via the index.
+SET enable_seqscan = off;
+EXPLAIN (COSTS OFF) SELECT id, indexed_col FROM hi_basic WHERE indexed_col = 150;
+               QUERY PLAN                
+-----------------------------------------
+ Bitmap Heap Scan on hi_basic
+   Recheck Cond: (indexed_col = 150)
+   ->  Bitmap Index Scan on hi_basic_idx
+         Index Cond: (indexed_col = 150)
+(4 rows)
+
+SELECT id, indexed_col FROM hi_basic WHERE indexed_col = 150;
+ id | indexed_col 
+----+-------------
+  1 |         150
+(1 row)
+
+-- The old value is not reachable through this index: the stale btree
+-- entry (indexed_col=100) walks to the current tuple via the hot-indexed hop,
+-- nodeIndexscan re-evaluates `indexed_col = 100` against the current
+-- tuple (indexed_col=150), and the row is correctly dropped.  This is
+-- the equality-lookup case the crossed-attribute bitmap handles.
+EXPLAIN (COSTS OFF) SELECT id FROM hi_basic WHERE indexed_col = 100;
+               QUERY PLAN                
+-----------------------------------------
+ Bitmap Heap Scan on hi_basic
+   Recheck Cond: (indexed_col = 100)
+   ->  Bitmap Index Scan on hi_basic_idx
+         Index Cond: (indexed_col = 100)
+(4 rows)
+
+SELECT id FROM hi_basic WHERE indexed_col = 100;
+ id 
+----
+(0 rows)
+
+RESET enable_seqscan;
+-- pg_relation_hot_indexed_stats sees one HOT-indexed version, zero HOT redirects (the
+-- chain has not yet been pruned so no LP_REDIRECT exists).
+SELECT n_hot_indexed, n_chains, avg_chain_len, max_chain_len
+FROM pg_relation_hot_indexed_stats('hi_basic');
+ n_hot_indexed | n_chains | avg_chain_len | max_chain_len 
+---------------+----------+---------------+---------------
+             1 |        0 |             0 |             0
+(1 row)
+
+DROP TABLE hi_basic;
+-- ---------------------------------------------------------------------------
+-- 2. RANGE/INEQUALITY correctness after hot-indexed on an indexed column
+--
+-- This is the test class that catches the hot-indexed false-dup bug: a stale
+-- btree entry whose key value still satisfies the range predicate,
+-- reachable via the hot-indexed chain hop.
+--
+-- To exercise the bug we must force an IndexScan plan (the
+-- IndexOnlyScan path permissively drops every hot-indexed-reachable index-only
+-- hit; the BitmapHeapScan path dedups by TID).  We include a payload
+-- column not present in the PK so the planner must heap-fetch.
+--
+-- The read-side crossed-attribute bitmap makes the IndexScan return the correct
+-- count of 1: the stale entry ('1','5') chain-walks to the live tuple across
+-- the b-changing hop, and because the PK covers b the overlap is non-empty, so
+-- the stale leaf is dropped.  The fresh entry ('1','15') points directly at the
+-- live tuple (no hop after it) and is kept.  The ORDER BY likewise returns the
+-- single live row.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_range (
+    a int,
+    b int,
+    payload text,
+    PRIMARY KEY (a, b)
+) WITH (fillfactor = 50);
+INSERT INTO hi_range VALUES (1, 5, 'hi');
+-- hot-indexed update on the second PK column: stale btree entry ('1','5')
+-- remains, new entry ('1','15') inserted.  The stale entry points at
+-- the chain root; the fresh entry points directly at the new
+-- heap-only tuple.
+UPDATE hi_range SET b = 15 WHERE a = 1 AND b = 5;
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+-- IndexScan: payload IS NOT NULL forces heap fetch, no IndexOnlyScan.
+-- The stale ('1','5') leaf is dropped by the crossed-attribute bitmap, so this
+-- returns 1.
+EXPLAIN (COSTS OFF)
+SELECT count(*) FROM hi_range WHERE a = 1 AND b < 100 AND payload IS NOT NULL;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Aggregate
+   ->  Index Scan using hi_range_pkey on hi_range
+         Index Cond: ((a = 1) AND (b < 100))
+         Filter: (payload IS NOT NULL)
+(4 rows)
+
+SELECT count(*) FROM hi_range WHERE a = 1 AND b < 100 AND payload IS NOT NULL;
+ count 
+-------
+     1
+(1 row)
+
+SELECT a, b FROM hi_range WHERE a = 1 AND payload IS NOT NULL ORDER BY b;
+ a | b  
+---+----
+ 1 | 15
+(1 row)
+
+-- IndexOnlyScan: the page holds a preserved HOT-indexed member so it is never all-visible; IOS
+-- performs the heap fetch and the crossed-attribute bitmap drops the stale ('1','5')
+-- leaf, so count = 1.
+EXPLAIN (COSTS OFF) SELECT count(*) FROM hi_range WHERE a = 1 AND b < 100;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Aggregate
+   ->  Index Only Scan using hi_range_pkey on hi_range
+         Index Cond: ((a = 1) AND (b < 100))
+(3 rows)
+
+SELECT count(*) FROM hi_range WHERE a = 1 AND b < 100;
+ count 
+-------
+     1
+(1 row)
+
+-- BitmapHeapScan: TID dedup collapses the stale and fresh hits.
+SET enable_indexscan = off;
+SET enable_indexonlyscan = off;
+RESET enable_bitmapscan;
+EXPLAIN (COSTS OFF) SELECT count(*) FROM hi_range WHERE a = 1 AND b < 100;
+                    QUERY PLAN                     
+---------------------------------------------------
+ Aggregate
+   ->  Bitmap Heap Scan on hi_range
+         Recheck Cond: ((a = 1) AND (b < 100))
+         ->  Bitmap Index Scan on hi_range_pkey
+               Index Cond: ((a = 1) AND (b < 100))
+(5 rows)
+
+SELECT count(*) FROM hi_range WHERE a = 1 AND b < 100;
+ count 
+-------
+     1
+(1 row)
+
+RESET enable_indexscan;
+RESET enable_indexonlyscan;
+-- SeqScan: reads the heap directly, sees exactly one live tuple.
+RESET enable_seqscan;
+SET enable_indexscan = off;
+SET enable_indexonlyscan = off;
+SET enable_bitmapscan = off;
+EXPLAIN (COSTS OFF) SELECT count(*) FROM hi_range WHERE a = 1 AND b < 100;
+               QUERY PLAN                
+-----------------------------------------
+ Aggregate
+   ->  Seq Scan on hi_range
+         Filter: ((b < 100) AND (a = 1))
+(3 rows)
+
+SELECT count(*) FROM hi_range WHERE a = 1 AND b < 100;
+ count 
+-------
+     1
+(1 row)
+
+RESET enable_indexscan;
+RESET enable_indexonlyscan;
+RESET enable_bitmapscan;
+-- Same shape on a secondary (non-PK) btree: another hot-indexed update on b.
+CREATE INDEX hi_range_b_idx ON hi_range(b);
+UPDATE hi_range SET b = 25 WHERE a = 1 AND b = 15;
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+-- IndexScan path on the secondary index; same fix applies.
+SELECT count(*) FROM hi_range WHERE b BETWEEN 0 AND 100 AND payload IS NOT NULL;
+ count 
+-------
+     1
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE hi_range;
+-- ---------------------------------------------------------------------------
+-- 3. All-or-none on a multi-indexed table: hot-indexed only touches indexes
+--    whose attributes changed
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_multi (
+    id int PRIMARY KEY,
+    col_a int,
+    col_b int,
+    col_c int,
+    non_indexed text
+) WITH (fillfactor = 50);
+CREATE INDEX hi_multi_a_idx ON hi_multi(col_a);
+CREATE INDEX hi_multi_b_idx ON hi_multi(col_b);
+CREATE INDEX hi_multi_c_idx ON hi_multi(col_c);
+INSERT INTO hi_multi VALUES (1, 10, 20, 30, 'initial');
+-- col_a only: under hot-indexed this is HOT-indexed, and only hi_multi_a_idx
+-- gets a new entry.  hi_multi_b_idx / hi_multi_c_idx keep pointing
+-- at the chain root.
+UPDATE hi_multi SET col_a = 15 WHERE id = 1;
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT * FROM get_hi_count('hi_multi');
+ updates | hot | hot_idx 
+---------+-----+---------
+       1 |   1 |       1
+(1 row)
+
+-- Lookups on all three indexes return the row.
+SET enable_seqscan = off;
+SELECT id FROM hi_multi WHERE col_a = 15;
+ id 
+----
+  1
+(1 row)
+
+SELECT id FROM hi_multi WHERE col_b = 20;
+ id 
+----
+  1
+(1 row)
+
+SELECT id FROM hi_multi WHERE col_c = 30;
+ id 
+----
+  1
+(1 row)
+
+-- Old col_a value is unreachable by equality (stale entry dropped by the
+-- read-side crossed-attribute bitmap).
+SELECT id FROM hi_multi WHERE col_a = 10;
+ id 
+----
+(0 rows)
+
+RESET enable_seqscan;
+DROP TABLE hi_multi;
+-- ---------------------------------------------------------------------------
+-- 4. Multi-column btree: hot-indexed on part of a composite key
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_composite (
+    id int PRIMARY KEY,
+    col_a int,
+    col_b int,
+    data text
+) WITH (fillfactor = 50);
+CREATE INDEX hi_composite_ab_idx ON hi_composite(col_a, col_b);
+INSERT INTO hi_composite VALUES (1, 10, 20, 'data');
+-- col_a is part of the composite key: hot-indexed.
+UPDATE hi_composite SET col_a = 15;
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT * FROM get_hi_count('hi_composite');
+ updates | hot | hot_idx 
+---------+-----+---------
+       1 |   1 |       1
+(1 row)
+
+-- Reset and then update col_b (also part of the key).
+UPDATE hi_composite SET col_a = 10;
+UPDATE hi_composite SET col_b = 25;
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT * FROM get_hi_count('hi_composite');
+ updates | hot | hot_idx 
+---------+-----+---------
+       3 |   3 |       3
+(1 row)
+
+DROP TABLE hi_composite;
+-- ---------------------------------------------------------------------------
+-- 5. Partial index: status transition out-of-predicate
+--
+-- 'status' is a partial-index predicate column.  A change to a predicate
+-- column can flip a row in or out of the index, which the read-side key
+-- recheck cannot detect, so HeapUpdateHotAllowable conservatively disqualifies
+-- HOT-indexed for any predicate-column change (even this out-of-predicate ->
+-- out-of-predicate case).  The update is therefore non-HOT, and the partial
+-- index correctly stays empty for these non-'active' rows.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_partial (
+    id int PRIMARY KEY,
+    status text,
+    data text
+) WITH (fillfactor = 50);
+CREATE INDEX hi_partial_active_idx ON hi_partial(status) WHERE status = 'active';
+INSERT INTO hi_partial VALUES (1, 'active', 'data1');
+INSERT INTO hi_partial VALUES (2, 'inactive', 'data2');
+INSERT INTO hi_partial VALUES (3, 'deleted', 'data3');
+-- out -> out transition on the predicate column: HOT-indexed keeps it on-page,
+-- and the partial index gets no entry (the row satisfies the predicate neither
+-- before nor after the update).
+UPDATE hi_partial SET status = 'deleted' WHERE id = 2;
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT * FROM get_hi_count('hi_partial');
+ updates | hot | hot_idx 
+---------+-----+---------
+       1 |   1 |       1
+(1 row)
+
+-- The partial index still correctly answers "active" queries.
+SELECT id, status FROM hi_partial WHERE status = 'active';
+ id | status 
+----+--------
+  1 | active
+(1 row)
+
+DROP TABLE hi_partial;
+-- ---------------------------------------------------------------------------
+-- 6. Partition: hot-indexed inside one partition
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_part (
+    id int,
+    partition_key int,
+    indexed_col int,
+    data text,
+    PRIMARY KEY (id, partition_key)
+) PARTITION BY RANGE (partition_key);
+CREATE TABLE hi_part_1 PARTITION OF hi_part
+    FOR VALUES FROM (1) TO (100) WITH (fillfactor = 50);
+CREATE INDEX hi_part_idx ON hi_part(indexed_col);
+INSERT INTO hi_part VALUES (1, 50, 100, 'data');
+UPDATE hi_part SET indexed_col = 150 WHERE id = 1;
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT * FROM get_hi_count('hi_part_1');
+ updates | hot | hot_idx 
+---------+-----+---------
+       1 |   1 |       1
+(1 row)
+
+SET enable_seqscan = off;
+SELECT id FROM hi_part WHERE indexed_col = 150;
+ id 
+----
+  1
+(1 row)
+
+SELECT id FROM hi_part WHERE indexed_col = 100;
+ id 
+----
+(0 rows)
+
+RESET enable_seqscan;
+DROP TABLE hi_part CASCADE;
+-- ---------------------------------------------------------------------------
+-- 7. Trigger modifies indexed column: hot-indexed, not non-HOT
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_trigger (
+    id int PRIMARY KEY,
+    triggered_col int,
+    data text
+) WITH (fillfactor = 50);
+CREATE INDEX hi_trigger_idx ON hi_trigger(triggered_col);
+CREATE OR REPLACE FUNCTION hi_trigger_bump()
+RETURNS TRIGGER AS $$
+BEGIN
+    NEW.triggered_col = NEW.triggered_col + 1;
+    RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+CREATE TRIGGER before_update_bump
+    BEFORE UPDATE ON hi_trigger
+    FOR EACH ROW
+    EXECUTE FUNCTION hi_trigger_bump();
+INSERT INTO hi_trigger VALUES (1, 100, 'initial');
+-- UPDATE's SET clause doesn't touch the indexed column, but the
+-- trigger modifies it via heap_modify_tuple.  hot-indexed must detect this
+-- and keep the tuple on-page (HEAP_INDEXED_UPDATED) plus a new btree entry.
+UPDATE hi_trigger SET data = 'updated' WHERE id = 1;
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT * FROM get_hi_count('hi_trigger');
+ updates | hot | hot_idx 
+---------+-----+---------
+       1 |   1 |       1
+(1 row)
+
+SELECT triggered_col FROM hi_trigger WHERE id = 1;
+ triggered_col 
+---------------
+           101
+(1 row)
+
+-- New value reachable.
+SET enable_seqscan = off;
+SELECT id FROM hi_trigger WHERE triggered_col = 101;
+ id 
+----
+  1
+(1 row)
+
+SELECT id FROM hi_trigger WHERE triggered_col = 100;
+ id 
+----
+(0 rows)
+
+RESET enable_seqscan;
+DROP TABLE hi_trigger CASCADE;
+DROP FUNCTION hi_trigger_bump();
+-- ---------------------------------------------------------------------------
+-- 8. JSONB expression index: HOT-indexed is not yet supported on expression
+--    indexes, so the update falls back to a non-HOT update (hot_idx = 0).
+--    Reads stay correct.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_jsonb (
+    id int PRIMARY KEY,
+    data jsonb
+) WITH (fillfactor = 50);
+CREATE INDEX hi_jsonb_name_idx ON hi_jsonb ((data->>'name'));
+INSERT INTO hi_jsonb VALUES (1, '{"name":"Alice","age":30}');
+-- Changing the indexed expression's value (name): expression indexes are not
+-- yet supported, so this is a non-HOT update.
+UPDATE hi_jsonb SET data = jsonb_set(data, '{name}', '"Alice2"') WHERE id = 1;
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT * FROM get_hi_count('hi_jsonb');
+ updates | hot | hot_idx 
+---------+-----+---------
+       1 |   0 |       0
+(1 row)
+
+SET enable_seqscan = off;
+SELECT id FROM hi_jsonb WHERE data->>'name' = 'Alice2';
+ id 
+----
+  1
+(1 row)
+
+SELECT id FROM hi_jsonb WHERE data->>'name' = 'Alice';
+ id 
+----
+(0 rows)
+
+RESET enable_seqscan;
+DROP TABLE hi_jsonb;
+-- ---------------------------------------------------------------------------
+-- 9. GIN index with changed extracted keys: hot-indexed
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_gin (
+    id int PRIMARY KEY,
+    tags text[]
+) WITH (fillfactor = 50);
+CREATE INDEX hi_gin_tags_idx ON hi_gin USING gin (tags);
+INSERT INTO hi_gin VALUES (1, ARRAY['tag1', 'tag2']);
+-- Adding a tag yields a different extracted-key set: hot-indexed.
+UPDATE hi_gin SET tags = ARRAY['tag1', 'tag2', 'tag5'] WHERE id = 1;
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT * FROM get_hi_count('hi_gin');
+ updates | hot | hot_idx 
+---------+-----+---------
+       1 |   1 |       1
+(1 row)
+
+SET enable_seqscan = off;
+SELECT id FROM hi_gin WHERE tags @> ARRAY['tag5'];
+ id 
+----
+  1
+(1 row)
+
+RESET enable_seqscan;
+DROP TABLE hi_gin;
+-- ---------------------------------------------------------------------------
+-- 10. Per-index HOT-indexed counters: skipped vs matched
+--
+-- A table with two independent secondary indexes.  An UPDATE touches a
+-- column covered by only one of them; the HOT-indexed path must insert
+-- into that one index and skip the other.  pg_stat_all_indexes reports
+-- matched>0 on the updated index and skipped>0 on the untouched index.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hotidx_perindex (
+    id int PRIMARY KEY,
+    a int,
+    b int
+) WITH (fillfactor = 50);
+CREATE INDEX hotidx_perindex_a ON hotidx_perindex(a);
+CREATE INDEX hotidx_perindex_b ON hotidx_perindex(b);
+INSERT INTO hotidx_perindex VALUES (1, 100, 200);
+-- Modify only column a.  HOT-indexed inserts into hotidx_perindex_a and
+-- skips hotidx_perindex_b (primary key indrelid is the table itself and
+-- also unchanged, so it counts as skipped too).
+UPDATE hotidx_perindex SET a = 101 WHERE id = 1;
+-- Force flush of pending stats to the shared entry.
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT indexrelname,
+       n_tup_hot_indexed_upd_matched AS matched,
+       n_tup_hot_indexed_upd_skipped AS skipped
+  FROM pg_stat_all_indexes
+ WHERE relname = 'hotidx_perindex'
+ ORDER BY indexrelname;
+     indexrelname     | matched | skipped 
+----------------------+---------+---------
+ hotidx_perindex_a    |       1 |       0
+ hotidx_perindex_b    |       0 |       1
+ hotidx_perindex_pkey |       0 |       1
+(3 rows)
+
+-- A second UPDATE touching only b inverts the assignment.
+UPDATE hotidx_perindex SET b = 201 WHERE id = 1;
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT indexrelname,
+       n_tup_hot_indexed_upd_matched AS matched,
+       n_tup_hot_indexed_upd_skipped AS skipped
+  FROM pg_stat_all_indexes
+ WHERE relname = 'hotidx_perindex'
+ ORDER BY indexrelname;
+     indexrelname     | matched | skipped 
+----------------------+---------+---------
+ hotidx_perindex_a    |       1 |       1
+ hotidx_perindex_b    |       1 |       1
+ hotidx_perindex_pkey |       0 |       2
+(3 rows)
+
+-- Invariant: matched + skipped == owning table's n_tup_hot_indexed_upd.
+SELECT indexrelname,
+       n_tup_hot_indexed_upd_matched + n_tup_hot_indexed_upd_skipped AS total,
+       (SELECT n_tup_hot_indexed_upd FROM pg_stat_all_tables
+         WHERE relname = 'hotidx_perindex') AS table_hot_idx_upd
+  FROM pg_stat_all_indexes
+ WHERE relname = 'hotidx_perindex'
+ ORDER BY indexrelname;
+     indexrelname     | total | table_hot_idx_upd 
+----------------------+-------+-------------------
+ hotidx_perindex_a    |     2 |                 2
+ hotidx_perindex_b    |     2 |                 2
+ hotidx_perindex_pkey |     2 |                 2
+(3 rows)
+
+-- Boolean assertion of the same invariant.  This is the canonical form
+-- reviewers asked for: every index entry is either matched (the index
+-- got a fresh insert this UPDATE) or skipped (HOT-indexed correctly
+-- avoided an insert because the index's attrs did not change).  If the
+-- two counters drift apart from the table-level n_tup_hot_indexed_upd we
+-- have either lost a per-index increment or double-counted one.
+SELECT bool_and((n_tup_hot_indexed_upd_matched + n_tup_hot_indexed_upd_skipped) =
+                (SELECT n_tup_hot_indexed_upd FROM pg_stat_all_tables
+                  WHERE relname = 'hotidx_perindex'))
+         AS perindex_invariant_holds
+  FROM pg_stat_all_indexes
+ WHERE relname = 'hotidx_perindex';
+ perindex_invariant_holds 
+--------------------------
+ t
+(1 row)
+
+DROP TABLE hotidx_perindex;
+-- ---------------------------------------------------------------------------
+-- 11. Long hot-loop UPDATE stays compact and HOT-indexed
+--
+-- A long run of HOT-indexed UPDATEs to a single row stays compact: prune
+-- collapses each dead version to a redirect to the live tuple and reuses its
+-- slot, so the row never leaves its original page and the chain does not grow
+-- unbounded.  Every UPDATE that changes the indexed column (and leaves another
+-- index, here the PK, unchanged) takes the HOT-indexed path.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_chaincap (
+    id int PRIMARY KEY,
+    a int
+) WITH (fillfactor = 10);
+CREATE INDEX hi_chaincap_a_idx ON hi_chaincap(a);
+INSERT INTO hi_chaincap VALUES (1, 0);
+DO $$
+DECLARE
+    i int;
+BEGIN
+    FOR i IN 1 .. 200 LOOP
+        UPDATE hi_chaincap SET a = i WHERE id = 1;
+    END LOOP;
+END $$;
+-- After 200 UPDATEs the row's value is 200.
+SELECT a FROM hi_chaincap WHERE id = 1;
+  a  
+-----
+ 200
+(1 row)
+
+-- Every UPDATE took the HOT-indexed path (the PK index is unchanged, so it is
+-- skipped), so n_tup_hot_indexed_upd advanced.
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT hot_idx > 0 AS hot_indexed_fired
+  FROM get_hi_count('hi_chaincap');
+ hot_indexed_fired 
+-------------------
+ t
+(1 row)
+
+-- The heap stayed compact: prune+collapse reclaimed the dead versions, so the
+-- single live row still occupies just one page.
+SELECT relpages <= 1 AS heap_stayed_compact
+  FROM pg_class WHERE relname = 'hi_chaincap';
+ heap_stayed_compact 
+---------------------
+ t
+(1 row)
+
+DROP TABLE hi_chaincap;
+-- ---------------------------------------------------------------------------
+-- 12. Reclamation of a collapsed HOT-indexed chain by prune
+--
+-- A dead HOT-indexed chain member is preserved at prune time (its stale leaf
+-- may still exist) and the chain collapses to an LP_REDIRECT forwarder; the
+-- index cleanup pass then sweeps the stale leaf, and a second VACUUM reclaims
+-- the now-unreferenced member and re-points the redirect.  So the chain is
+-- fully reclaimed after the second VACUUM, not the first.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_reclaim (
+    id int PRIMARY KEY,
+    a int
+) WITH (fillfactor = 50);
+CREATE INDEX hi_reclaim_a_idx ON hi_reclaim(a);
+INSERT INTO hi_reclaim VALUES (1, 100);
+-- Generate a collapsed chain via a HOT-indexed update.
+UPDATE hi_reclaim SET a = 200 WHERE id = 1;
+SELECT n_hot_indexed >= 1 AS hot_indexed_present_before_reclaim
+  FROM pg_relation_hot_indexed_stats('hi_reclaim');
+ hot_indexed_present_before_reclaim 
+------------------------------------
+ t
+(1 row)
+
+-- Delete the live tuple.  The first VACUUM collapses the dead chain and sweeps
+-- the stale leaf; the second reclaims the now-unreferenced members.
+DELETE FROM hi_reclaim WHERE id = 1;
+VACUUM hi_reclaim;
+VACUUM hi_reclaim;
+SELECT n_hot_indexed AS hot_indexed_after_reclaim,
+       n_chains AS chains_after_reclaim
+  FROM pg_relation_hot_indexed_stats('hi_reclaim');
+ hot_indexed_after_reclaim | chains_after_reclaim 
+---------------------------+----------------------
+                         0 |                    0
+(1 row)
+
+DROP TABLE hi_reclaim;
+-- ---------------------------------------------------------------------------
+-- 13. Page with a preserved HOT-indexed member is never marked all-visible
+--
+-- pruneheap deliberately leaves PD_ALL_VISIBLE clear on any page that still
+-- carries a preserved HOT-indexed member: an index-only scan must heap-fetch
+-- through the chain so the read-side crossed-attribute bitmap can filter stale btree
+-- entries.
+--
+-- We force the freeze path with VACUUM (FREEZE, DISABLE_PAGE_SKIPPING) and
+-- then read pd_flags via pageinspect.page_header.  The page must still carry
+-- a HOT-indexed member (n_hot_indexed > 0) AND must not have PD_ALL_VISIBLE
+-- (0x0004).
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_vm (
+    id int PRIMARY KEY,
+    a int
+) WITH (fillfactor = 50);
+CREATE INDEX hi_vm_a_idx ON hi_vm(a);
+INSERT INTO hi_vm VALUES (1, 1);
+-- Two HOT-indexed updates leave a multi-hop chain, so a preserved HOT-indexed
+-- member remains on the page after prune, which is what this test needs.
+UPDATE hi_vm SET a = 2 WHERE id = 1;
+UPDATE hi_vm SET a = 3 WHERE id = 1;
+-- Force the all-visible bit decision: VACUUM with DISABLE_PAGE_SKIPPING
+-- considers every page; FREEZE pushes hint bits hard.  After this, any
+-- page bearing a preserved HOT-indexed member must still report all_visible = 0.
+VACUUM (FREEZE, DISABLE_PAGE_SKIPPING) hi_vm;
+SELECT n_hot_indexed >= 1 AS hot_indexed_present
+  FROM pg_relation_hot_indexed_stats('hi_vm');
+ hot_indexed_present 
+---------------------
+ t
+(1 row)
+
+-- PD_ALL_VISIBLE = 0x0004.  Must be 0 on a page with a preserved member.
+SELECT (flags & 4) = 0 AS not_marked_all_visible
+  FROM page_header(get_raw_page('hi_vm', 0));
+ not_marked_all_visible 
+------------------------
+ t
+(1 row)
+
+DROP TABLE hi_vm;
+-- ---------------------------------------------------------------------------
+-- 14. Cycle-key dedup: column rename a -> b -> a stays correct
+--
+-- A rename does not rewrite heap or index entries; it only updates the
+-- catalog.  The relcache invalidation must trigger a fresh attribute
+-- bitmap and the HOT-indexed predicate must compare attribute *numbers*,
+-- not attribute *names*.  After two renames that net to identity, every
+-- subsequent UPDATE must continue to drive the HOT-indexed path.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_cycle (
+    id int PRIMARY KEY,
+    a int
+) WITH (fillfactor = 50);
+CREATE INDEX hi_cycle_a_idx ON hi_cycle(a);
+INSERT INTO hi_cycle VALUES (1, 100);
+-- Cycle the column name and confirm both intermediate forms drive HOT-indexed.
+ALTER TABLE hi_cycle RENAME COLUMN a TO b;
+UPDATE hi_cycle SET b = 200 WHERE id = 1;
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT hot_idx > 0 AS hot_indexed_after_first_rename
+  FROM get_hi_count('hi_cycle');
+ hot_indexed_after_first_rename 
+--------------------------------
+ t
+(1 row)
+
+ALTER TABLE hi_cycle RENAME COLUMN b TO a;
+UPDATE hi_cycle SET a = 300 WHERE id = 1;
+-- Lookup via the index returns the current value, not any of the
+-- pre-rename values.
+SET enable_seqscan = off;
+SELECT id, a FROM hi_cycle WHERE a = 300;
+ id |  a  
+----+-----
+  1 | 300
+(1 row)
+
+SELECT id FROM hi_cycle WHERE a = 100;
+ id 
+----
+(0 rows)
+
+SELECT id FROM hi_cycle WHERE a = 200;
+ id 
+----
+(0 rows)
+
+RESET enable_seqscan;
+DROP TABLE hi_cycle;
+-- ---------------------------------------------------------------------------
+-- 15. Summarizing-only column UPDATE produces CLASSIC, not INDEXED
+--
+-- HeapUpdateHotAllowable returns HEAP_UPDATE_HOT when every
+-- modified indexed attribute is covered only by summarizing indexes.
+-- A BRIN-only column is the canonical case: the BRIN index gets a
+-- new summary entry via aminsert, but no per-update btree entry is
+-- needed and HOT-indexed does not fire.  The signal is
+-- n_tup_hot_upd > 0 with n_tup_hot_indexed_upd unchanged.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_brin (
+    id int PRIMARY KEY,
+    bcol int
+) WITH (fillfactor = 50);
+CREATE INDEX hi_brin_idx ON hi_brin USING brin(bcol);
+INSERT INTO hi_brin VALUES (1, 100);
+-- Capture the HOT-indexed counter before, drive a BRIN-only update,
+-- and assert that classic HOT advanced while HOT-indexed did not.
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT hot_idx AS hot_idx_before FROM get_hi_count('hi_brin') \gset
+UPDATE hi_brin SET bcol = 200 WHERE id = 1;
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT (hot - 0) > 0 AS classic_hot_fired,
+       hot_idx = :hot_idx_before AS hot_indexed_did_not_fire
+  FROM get_hi_count('hi_brin');
+ classic_hot_fired | hot_indexed_did_not_fire 
+-------------------+--------------------------
+ t                 | t
+(1 row)
+
+-- The BRIN index sees the new value via aminsert.
+SELECT bcol FROM hi_brin WHERE id = 1;
+ bcol 
+------
+  200
+(1 row)
+
+DROP TABLE hi_brin;
+-- ---------------------------------------------------------------------------
+-- 16. UNIQUE index on a type where image equality != operator equality
+--
+-- numeric 1.0 and 1.00 are equal under the btree opclass but have
+-- different on-disk images.  A HOT-indexed update 1.0 -> 1.00 inserts a
+-- fresh leaf carrying the live image and leaves a stale leaf for 1.0
+-- (the hop's modified-attrs bitmap marks k changed, since modified-column
+-- detection is image-based).  A later INSERT of a value equal under the
+-- opclass must still be detected as a duplicate: the unique check reaches
+-- the live tuple through the fresh leaf, which points directly at it (no hop
+-- after it, so the overlap is empty and the leaf is a genuine conflict); the
+-- stale 1.0 leaf is skipped because the k-changing hop overlaps the unique
+-- index's attribute.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_unum (k numeric UNIQUE, j int) WITH (fillfactor = 50);
+CREATE INDEX hi_unum_j ON hi_unum(j);             -- 2nd indexed attr, kept fixed
+INSERT INTO hi_unum VALUES (1.0, 100);
+UPDATE hi_unum SET k = 1.00 WHERE j = 100;        -- HOT-indexed: 1.0 -> 1.00
+SELECT n_hot_indexed > 0 AS made_hot_indexed
+  FROM pg_relation_hot_indexed_stats('hi_unum');
+ made_hot_indexed 
+------------------
+ t
+(1 row)
+
+-- A numerically-equal insert must conflict (the fresh leaf catches it):
+INSERT INTO hi_unum VALUES (1.0, 1);              -- expect duplicate key error
+ERROR:  duplicate key value violates unique constraint "hi_unum_k_key"
+DETAIL:  Key (k)=(1.0) already exists.
+-- A genuinely different value is accepted:
+INSERT INTO hi_unum VALUES (2.0, 2);
+SELECT k, j FROM hi_unum ORDER BY j;
+  k   |  j  
+------+-----
+  2.0 |   2
+ 1.00 | 100
+(2 rows)
+
+DROP TABLE hi_unum;
+-- ---------------------------------------------------------------------------
+-- 17. CREATE INDEX and REINDEX over live HOT-indexed chains
+--
+-- A freshly built or rebuilt index must reflect current values, never a
+-- stale chain member: the build scans live tuples only and points each
+-- HOT-indexed live tuple's entry at its own TID, so the new entries have no
+-- hop after them and the crossed-attribute bitmap keeps them.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_reindex (id int PRIMARY KEY, a int, b int) WITH (fillfactor = 50);
+CREATE INDEX hi_reindex_a ON hi_reindex(a);
+INSERT INTO hi_reindex SELECT g, g, g FROM generate_series(1, 6) g;
+UPDATE hi_reindex SET a = a + 100;                -- HOT-indexed on a
+UPDATE hi_reindex SET a = a + 100;                -- again -> longer chains
+SELECT n_hot_indexed > 0 AS made_hot_indexed
+  FROM pg_relation_hot_indexed_stats('hi_reindex');
+ made_hot_indexed 
+------------------
+ t
+(1 row)
+
+-- Build a NEW index and REINDEX the existing one over the live chains.
+CREATE INDEX hi_reindex_b ON hi_reindex(b);
+REINDEX INDEX hi_reindex_a;
+SET enable_seqscan = off;
+SELECT id, a FROM hi_reindex WHERE a = 204;       -- current value -> id 4
+ id |  a  
+----+-----
+  4 | 204
+(1 row)
+
+SELECT count(*) FROM hi_reindex WHERE a = 4;      -- obsolete value -> 0
+ count 
+-------
+     0
+(1 row)
+
+SELECT id FROM hi_reindex WHERE b = 2;            -- via freshly built index -> 2
+ id 
+----
+  2
+(1 row)
+
+RESET enable_seqscan;
+DROP TABLE hi_reindex;
+-- ---------------------------------------------------------------------------
+-- 18. DROP every index over live HOT-indexed chains, then VACUUM
+--
+-- After all indexes are dropped, heap pages may still carry preserved
+-- HOT-indexed members left by earlier updates.  VACUUM of such a no-index
+-- relation must complete without error, and reads must stay correct via the
+-- redirect forwarders.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_dropidx (id int PRIMARY KEY, a int) WITH (fillfactor = 50);
+CREATE INDEX hi_dropidx_a ON hi_dropidx(a);
+INSERT INTO hi_dropidx SELECT g, g FROM generate_series(1, 6) g;
+UPDATE hi_dropidx SET a = a + 100;                -- HOT-indexed on a
+UPDATE hi_dropidx SET a = a + 100;                -- again -> longer chains
+SELECT n_hot_indexed > 0 AS made_hot_indexed
+  FROM pg_relation_hot_indexed_stats('hi_dropidx');
+ made_hot_indexed 
+------------------
+ t
+(1 row)
+
+-- Drop every index, leaving preserved HOT-indexed members with no index to sweep.
+DROP INDEX hi_dropidx_a;
+ALTER TABLE hi_dropidx DROP CONSTRAINT hi_dropidx_pkey;
+-- Must not crash on the no-index path; two passes exercise the second-pass
+-- reclaim guard as well.
+VACUUM hi_dropidx;
+VACUUM hi_dropidx;
+-- Reads remain correct after the indexes are gone.
+SELECT id, a FROM hi_dropidx ORDER BY id;
+ id |  a  
+----+-----
+  1 | 201
+  2 | 202
+  3 | 203
+  4 | 204
+  5 | 205
+  6 | 206
+(6 rows)
+
+DROP TABLE hi_dropidx;
+-- ---------------------------------------------------------------------------
+-- 19. Re-collapse of a data-redirect chain across partial VACUUMs
+--
+-- A chain that collapses to a HOT-indexed data redirect, is vacuumed with
+-- INDEX_CLEANUP off (so the stale leaves and the redirect survive), then
+-- receives further HOT-indexed updates that re-collapse the chain and
+-- re-point the redirect at a new live tuple, must not leave the redirect
+-- dangling.  A subsequent full VACUUM must complete without error, leave the
+-- heap consistent (verify_heapam reports nothing), and reads must stay
+-- correct.  (Regression: an earlier revision crashed reclaiming a mid-chain
+-- member while a data redirect still pointed past it.)
+-- ---------------------------------------------------------------------------
+CREATE EXTENSION IF NOT EXISTS amcheck;
+CREATE TABLE hi_recollapse (id int PRIMARY KEY, a int) WITH (fillfactor = 50);
+CREATE INDEX hi_recollapse_a ON hi_recollapse(a);
+INSERT INTO hi_recollapse VALUES (1, 1);
+-- First chain: two HOT-indexed updates, then prune to a data redirect while
+-- leaving the stale btree leaves in place (INDEX_CLEANUP off).
+UPDATE hi_recollapse SET a = 2 WHERE id = 1;
+UPDATE hi_recollapse SET a = 3 WHERE id = 1;
+VACUUM (INDEX_CLEANUP off) hi_recollapse;
+-- Re-collapse: more HOT-indexed updates extend the chain past the redirect
+-- target; the next prune re-points the data redirect at the new first live
+-- tuple and extends its union.
+UPDATE hi_recollapse SET a = 4 WHERE id = 1;
+UPDATE hi_recollapse SET a = 5 WHERE id = 1;
+VACUUM (INDEX_CLEANUP off) hi_recollapse;
+-- Full vacuum now reclaims the dead chain; the re-pointed redirect must not
+-- dangle.  Two passes also exercise the redirect re-point second pass.
+VACUUM hi_recollapse;
+VACUUM hi_recollapse;
+-- Heap must be structurally consistent (no rows == no corruption).
+SELECT * FROM verify_heapam('hi_recollapse');
+ blkno | offnum | attnum | msg 
+-------+--------+--------+-----
+(0 rows)
+
+SET enable_seqscan = off;
+SELECT id, a FROM hi_recollapse WHERE a = 5;     -- current value -> id 1
+ id | a 
+----+---
+  1 | 5
+(1 row)
+
+SELECT count(*) FROM hi_recollapse WHERE a = 3;  -- obsolete value -> 0
+ count 
+-------
+     0
+(1 row)
+
+RESET enable_seqscan;
+SELECT id, a FROM hi_recollapse ORDER BY id;
+ id | a 
+----+---
+  1 | 5
+(1 row)
+
+DROP TABLE hi_recollapse;
+-- ---------------------------------------------------------------------------
+-- 20. Index deletion over an entry that points at a data-redirect root
+--
+-- A data redirect is an LP_REDIRECT that carries a bitmap, so it reports
+-- lp_len > 0 (ItemIdHasStorage true) even though it is not a normal tuple.
+-- index_delete_check_htid must treat it as a redirect, not read its blob as a
+-- HeapTupleHeader.  Reproduce: collapse a chain root to a data redirect while
+-- keeping the stale leaf that points at it (INDEX_CLEANUP off), then insert
+-- many duplicates of the stale key so btree bottom-up deletion runs
+-- heap_index_delete_tuples over that stale entry.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_iddel (id int, a int) WITH (fillfactor = 50);
+CREATE INDEX hi_iddel_a ON hi_iddel(a);
+INSERT INTO hi_iddel VALUES (1, 1);
+UPDATE hi_iddel SET a = a + 1 WHERE id = 1;          -- HOT-indexed
+UPDATE hi_iddel SET a = a + 1 WHERE id = 1;          -- multi-hop chain
+VACUUM (INDEX_CLEANUP off) hi_iddel;                 -- root -> data redirect, keep stale a=1 leaf
+-- Many duplicates of the stale key fill the leaf and trigger bottom-up
+-- deletion, which feeds the stale a=1 entry (htid -> the data-redirect root)
+-- to heap_index_delete_tuples.  Must not crash or misread the blob.
+INSERT INTO hi_iddel SELECT g, 1 FROM generate_series(2, 3000) g;
+VACUUM hi_iddel;
+SELECT * FROM verify_heapam('hi_iddel');
+ blkno | offnum | attnum | msg 
+-------+--------+--------+-----
+(0 rows)
+
+SET enable_seqscan = off;
+SELECT id, a FROM hi_iddel WHERE id = 1;             -- current value -> a = 3
+ id | a 
+----+---
+  1 | 3
+(1 row)
+
+RESET enable_seqscan;
+DROP TABLE hi_iddel;
+-- ---------------------------------------------------------------------------
+-- 21. A change to a column covered by a non-btree index AM is HOT-indexed
+--
+-- A HOT-indexed update leaves a stale pre-update leaf that the read side
+-- filters via the crossed-attribute bitmap, which is access-method agnostic.
+-- A column covered by a non-btree index (here a GiST index on a point column)
+-- is therefore HOT-indexed like any other, and the GiST index still returns
+-- correct results across the chain.  A change to a btree-only column on the
+-- same table is likewise HOT-indexed.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_nonbtree (id int PRIMARY KEY, tag int, p point)
+    WITH (fillfactor = 10);
+CREATE INDEX hi_nonbtree_tag ON hi_nonbtree (tag);          -- btree index
+CREATE INDEX hi_nonbtree_p ON hi_nonbtree USING gist (p);   -- GiST, non-btree
+INSERT INTO hi_nonbtree SELECT g, g, point(g, g)
+    FROM generate_series(1, 200) g;
+-- Change the GiST-covered column first: HOT-indexed (hot_idx = 200).
+UPDATE hi_nonbtree SET p = point(p[0] + 1000, p[1] + 1000);
+SELECT hot_idx AS gist_col_hot_indexed FROM get_hi_count('hi_nonbtree');
+ gist_col_hot_indexed 
+----------------------
+                  200
+(1 row)
+
+-- The GiST index must return correct results: the old positions are gone and
+-- every row is found at its new position (no stale leaf surfaces an old key).
+SET enable_seqscan = off;
+SELECT count(*) AS at_old_positions
+    FROM hi_nonbtree WHERE p <@ box(point(0, 0), point(300, 300));
+ at_old_positions 
+------------------
+                0
+(1 row)
+
+SELECT count(*) AS at_new_positions
+    FROM hi_nonbtree WHERE p <@ box(point(1000, 1000), point(1300, 1300));
+ at_new_positions 
+------------------
+              200
+(1 row)
+
+RESET enable_seqscan;
+-- Changing the btree-only column (p unchanged) stays HOT-indexed.
+UPDATE hi_nonbtree SET tag = tag + 1000;
+SELECT hot_idx > 0 AS btree_col_is_hot_indexed FROM get_hi_count('hi_nonbtree');
+ btree_col_is_hot_indexed 
+--------------------------
+ t
+(1 row)
+
+DROP TABLE hi_nonbtree;
+-- ---------------------------------------------------------------------------
+-- 22. ABA on a unique key across two distinct live rows: a key cycled away
+-- and back must still collide with another row that holds it.  The stale
+-- leaves left by the cycle must not let a genuine duplicate slip past the
+-- uniqueness check -- the read-side recheck compares the live key, not just
+-- a changed-attribute bitmap.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_aba (k int, v int) WITH (fillfactor = 50);
+CREATE UNIQUE INDEX hi_aba_k ON hi_aba (k);
+CREATE INDEX hi_aba_v ON hi_aba (v);
+INSERT INTO hi_aba VALUES (1, 10), (2, 20);
+-- Cycle row1's unique key 1 -> 3 -> 1 (v unchanged, so each step is
+-- HOT-indexed and leaves stale entries in hi_aba_k).
+UPDATE hi_aba SET k = 3 WHERE v = 10;
+UPDATE hi_aba SET k = 1 WHERE v = 10;
+SELECT hot_idx > 0 AS cycled_hot_indexed FROM get_hi_count('hi_aba');
+ cycled_hot_indexed 
+--------------------
+ t
+(1 row)
+
+-- row1 is live at k = 1 again.  Moving row2 onto k = 1 must raise a unique
+-- violation despite the stale '1' leaves from the cycle.
+UPDATE hi_aba SET k = 1 WHERE v = 20;
+ERROR:  duplicate key value violates unique constraint "hi_aba_k"
+DETAIL:  Key (k)=(1) already exists.
+DROP TABLE hi_aba;
+-- ---------------------------------------------------------------------------
+-- 23. Partial index whose predicate references a non-key column.  Flipping the
+-- row out of the predicate while leaving the indexed key unchanged is
+-- HOT-indexed: the predicate column is part of the index's attribute set, so
+-- the crossed-attribute bitmap drops the now-stale partial-index entry on read
+-- (no value recheck is involved).
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_partpred (id int PRIMARY KEY, k int, active boolean)
+    WITH (fillfactor = 50);
+CREATE INDEX hi_partpred_k ON hi_partpred (k) WHERE active;
+INSERT INTO hi_partpred VALUES (1, 100, true);
+-- Flip the predicate column 'active' true -> false; the index key k is
+-- unchanged.  The row no longer satisfies the predicate, so its partial-index
+-- entry must be removed, not left pointing into the chain.
+UPDATE hi_partpred SET active = false WHERE id = 1;
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT hot, hot_idx FROM get_hi_count('hi_partpred');
+ hot | hot_idx 
+-----+---------
+   1 |       1
+(1 row)
+
+-- The partial index must not surface the row now that active = false.
+-- A query whose qual exactly matches the partial predicate uses the index
+-- without re-filtering 'active' on the heap, so a stale entry would surface.
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+EXPLAIN (COSTS OFF) SELECT id FROM hi_partpred WHERE active;
+                  QUERY PLAN                   
+-----------------------------------------------
+ Index Scan using hi_partpred_k on hi_partpred
+(1 row)
+
+SELECT id FROM hi_partpred WHERE k = 100 AND active;
+ id 
+----
+(0 rows)
+
+SELECT id FROM hi_partpred WHERE active;
+ id 
+----
+(0 rows)
+
+RESET enable_bitmapscan;
+RESET enable_seqscan;
+DROP TABLE hi_partpred;
+-- ---------------------------------------------------------------------------
+-- 24. Reclaim + stub mix.  Repeated updates of column a followed by an update
+-- of column b build a chain whose prune reclaims the members whose change was
+-- superseded (a changed again) and keeps stubs for those that were not, so a
+-- root redirect ends up pointing at a stub and a later walk crosses mid-chain
+-- stubs.  Reads through each index and amcheck must stay correct across the
+-- collapse, and a second round must walk the existing stubs without severing
+-- the chain.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_stubmix (id int PRIMARY KEY, a int, b int) WITH (fillfactor = 50);
+CREATE INDEX hi_stubmix_a ON hi_stubmix (a);
+CREATE INDEX hi_stubmix_b ON hi_stubmix (b);
+INSERT INTO hi_stubmix VALUES (1, 10, 100);
+UPDATE hi_stubmix SET a = 11 WHERE id = 1;   -- changes a
+UPDATE hi_stubmix SET a = 12 WHERE id = 1;   -- changes a again (supersedes)
+UPDATE hi_stubmix SET b = 101 WHERE id = 1;  -- changes b
+VACUUM hi_stubmix;
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT id, a, b FROM hi_stubmix WHERE a = 12;   -- current a
+ id | a  |  b  
+----+----+-----
+  1 | 12 | 101
+(1 row)
+
+SELECT id, a, b FROM hi_stubmix WHERE b = 101;  -- current b
+ id | a  |  b  
+----+----+-----
+  1 | 12 | 101
+(1 row)
+
+SELECT id FROM hi_stubmix WHERE a = 10;         -- stale a: 0 rows
+ id 
+----
+(0 rows)
+
+RESET enable_bitmapscan;
+RESET enable_seqscan;
+SELECT * FROM verify_heapam('hi_stubmix');      -- no corruption across stubs
+ blkno | offnum | attnum | msg 
+-------+--------+--------+-----
+(0 rows)
+
+-- A second round must walk the existing stubs (no priorXmax sever).
+UPDATE hi_stubmix SET a = 13 WHERE id = 1;
+VACUUM hi_stubmix;
+SELECT id, a, b FROM hi_stubmix WHERE a = 13;
+ id | a  |  b  
+----+----+-----
+  1 | 13 | 101
+(1 row)
+
+SELECT * FROM verify_heapam('hi_stubmix');
+ blkno | offnum | attnum | msg 
+-------+--------+--------+-----
+(0 rows)
+
+DROP TABLE hi_stubmix;
+-- ---------------------------------------------------------------------------
+-- 23. Exclusion-constraint tables are HOT-indexed-eligible.
+--
+-- An exclusion constraint is enforced by check_exclusion_or_unique_constraint,
+-- which rechecks each candidate against the live tuple's current index-form
+-- with the constraint's own operators, so a stale entry left by a HOT-indexed
+-- update is skipped while the live key always has its own entry.  Updating a
+-- non-constrained indexed column is HOT-indexed (the GiST exclusion index is
+-- skipped), and the constraint stays correct.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_excl (
+    id int PRIMARY KEY,
+    tag int,
+    during int4range,
+    EXCLUDE USING gist (during WITH &&)
+) WITH (fillfactor = 10);
+CREATE INDEX hi_excl_tag ON hi_excl(tag);
+INSERT INTO hi_excl VALUES (1, 100, int4range(1, 10)), (2, 200, int4range(20, 30));
+-- Update a non-constrained indexed column: HOT-indexed (GiST exclusion index
+-- and PK skipped), and the exclusion constraint is still enforced.
+UPDATE hi_excl SET tag = tag + 1 WHERE id = 1;
+SELECT hot_idx > 0 AS tag_update_hot_indexed FROM get_hi_count('hi_excl');
+ tag_update_hot_indexed 
+------------------------
+ t
+(1 row)
+
+INSERT INTO hi_excl VALUES (3, 300, int4range(5, 15));  -- overlaps id=1's (1,10)
+ERROR:  conflicting key value violates exclusion constraint "hi_excl_during_excl"
+DETAIL:  Key (during)=([5,15)) conflicts with existing key (during)=([1,10)).
+-- Move id=1's range away (this updates the GiST index, leaving a stale entry
+-- for the old (1,10) range).  A range overlapping only the OLD range now
+-- inserts cleanly (the stale entry is skipped); one overlapping the NEW range
+-- still conflicts.
+UPDATE hi_excl SET during = int4range(100, 110) WHERE id = 1;
+INSERT INTO hi_excl VALUES (4, 400, int4range(5, 15));   -- only overlapped old range: OK
+INSERT INTO hi_excl VALUES (5, 500, int4range(105, 115));-- overlaps new (100,110): conflict
+ERROR:  conflicting key value violates exclusion constraint "hi_excl_during_excl"
+DETAIL:  Key (during)=([105,115)) conflicts with existing key (during)=([100,110)).
+DROP TABLE hi_excl;
+-- ---------------------------------------------------------------------------
+-- 25. TOAST interaction.  An indexed column stored out-of-line must behave
+-- correctly across HOT-indexed updates: an entry kept across an update of a
+-- different column still resolves to the (unchanged) toasted value, and after
+-- the toasted column itself is changed the stale entry is dropped by the
+-- crossed-attribute bitmap (no value comparison or detoasting is needed).
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_toast (id int PRIMARY KEY, big text, tag int) WITH (fillfactor = 50);
+ALTER TABLE hi_toast ALTER COLUMN big SET STORAGE EXTERNAL;  -- no compression
+CREATE INDEX hi_toast_big ON hi_toast (big);
+CREATE INDEX hi_toast_tag ON hi_toast (tag);
+INSERT INTO hi_toast VALUES (1, repeat('A', 2000), 10);
+-- The big value is stored out-of-line.
+SELECT pg_column_size(big) > 1500 AS big_is_external FROM hi_toast WHERE id = 1;
+ big_is_external 
+-----------------
+ t
+(1 row)
+
+-- HOT-indexed update of tag leaves big (and its index entry) unchanged.
+UPDATE hi_toast SET tag = 11 WHERE id = 1;
+SELECT hot_idx > 0 AS tag_update_hot_indexed FROM get_hi_count('hi_toast');
+ tag_update_hot_indexed 
+------------------------
+ t
+(1 row)
+
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT id, tag, length(big) FROM hi_toast WHERE big = repeat('A', 2000);
+ id | tag | length 
+----+-----+--------
+  1 |  11 |   2000
+(1 row)
+
+-- HOT-indexed update of the toasted indexed column itself: the old entry is
+-- now stale because the crossed-attribute bitmap shows big changed.
+UPDATE hi_toast SET big = repeat('B', 2000) WHERE id = 1;
+SELECT id FROM hi_toast WHERE big = repeat('A', 2000);   -- stale: 0 rows
+ id 
+----
+(0 rows)
+
+SELECT id, length(big) FROM hi_toast WHERE big = repeat('B', 2000);  -- current
+ id | length 
+----+--------
+  1 |   2000
+(1 row)
+
+RESET enable_bitmapscan;
+RESET enable_seqscan;
+SELECT * FROM verify_heapam('hi_toast');
+ blkno | offnum | attnum | msg 
+-------+--------+--------+-----
+(0 rows)
+
+DROP TABLE hi_toast;
+-- ---------------------------------------------------------------------------
+-- 26. ABA on an indexed column.  A HOT-indexed update that sets an indexed
+-- column to a value an earlier chain member already held leaves two leaves
+-- with that same key, both chain-resolving to the live tuple.  A value-based
+-- recheck cannot tell them apart and would return the row twice; the
+-- crossed-attribute bitmap drops the stale ancestor leaf (its walk crosses the
+-- key-changing hops) and keeps only the fresh entry, so a forced index scan
+-- returns the row exactly once.  REINDEX must not change that.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_aba (id int PRIMARY KEY, k int, v int) WITH (fillfactor = 50);
+CREATE INDEX hi_aba_k ON hi_aba (k);
+CREATE INDEX hi_aba_v ON hi_aba (v);
+INSERT INTO hi_aba VALUES (1, 1, 100);
+UPDATE hi_aba SET k = 3 WHERE id = 1;   -- HOT-indexed: k changed, v kept
+UPDATE hi_aba SET k = 1 WHERE id = 1;   -- HOT-indexed: k cycled back (ABA)
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) AS k1_once FROM hi_aba WHERE k = 1;     -- exactly 1
+ k1_once 
+---------
+       1
+(1 row)
+
+SELECT count(*) AS k3_gone FROM hi_aba WHERE k = 3;     -- 0 (stale dropped)
+ k3_gone 
+---------
+       0
+(1 row)
+
+REINDEX INDEX hi_aba_k;
+SELECT count(*) AS k1_after_reindex FROM hi_aba WHERE k = 1;  -- still 1
+ k1_after_reindex 
+------------------
+                1
+(1 row)
+
+RESET enable_bitmapscan;
+RESET enable_seqscan;
+SELECT * FROM verify_heapam('hi_aba');
+ blkno | offnum | attnum | msg 
+-------+--------+--------+-----
+(0 rows)
+
+DROP TABLE hi_aba;
+-- ---------------------------------------------------------------------------
+-- 27. Partial index, predicate column changed but the row STAYS in the index
+-- (predicate still true, key unchanged).  The update is HOT-indexed; selective
+-- maintenance re-inserts a fresh entry (the predicate column changed and still
+-- holds), so the row is still returned -- the bitmap drops the older entry and
+-- the fresh one re-supplies it.  Guards against a "lost row" from over-eager
+-- dropping.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_partstay (id int PRIMARY KEY, k int, n int) WITH (fillfactor = 50);
+CREATE INDEX hi_partstay_k ON hi_partstay (k) WHERE n > 0;
+CREATE INDEX hi_partstay_id2 ON hi_partstay (id);
+INSERT INTO hi_partstay VALUES (1, 5, 3);
+UPDATE hi_partstay SET n = 7 WHERE id = 1;   -- n 3->7, still > 0, k unchanged
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT hot_idx > 0 AS stay_is_hot_indexed FROM get_hi_count('hi_partstay');
+ stay_is_hot_indexed 
+---------------------
+ t
+(1 row)
+
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) AS stay_rows FROM hi_partstay WHERE k = 5 AND n > 0;   -- want 1
+ stay_rows 
+-----------
+         1
+(1 row)
+
+RESET enable_bitmapscan;
+RESET enable_seqscan;
+DROP TABLE hi_partstay;
+-- ---------------------------------------------------------------------------
+-- 28. Partitioned table.  A within-partition UPDATE of one indexed column is
+-- HOT-indexed on the leaf partition's heap exactly as for a non-partitioned
+-- table; a cross-partition update is a delete+insert and never HOT.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_part (id int, a int, b int) PARTITION BY RANGE (id);
+CREATE TABLE hi_part1 PARTITION OF hi_part FOR VALUES FROM (0) TO (100)
+    WITH (fillfactor = 50);
+CREATE INDEX hi_part_a ON hi_part (a);
+CREATE INDEX hi_part_b ON hi_part (b);
+INSERT INTO hi_part VALUES (1, 10, 20);
+UPDATE hi_part SET a = 11 WHERE id = 1;   -- one indexed col, within partition
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT hot_idx > 0 AS part_is_hot_indexed FROM get_hi_count('hi_part1');
+ part_is_hot_indexed 
+---------------------
+ t
+(1 row)
+
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) AS a11 FROM hi_part WHERE a = 11;   -- want 1
+ a11 
+-----
+   1
+(1 row)
+
+SELECT count(*) AS a10 FROM hi_part WHERE a = 10;   -- want 0 (stale dropped)
+ a10 
+-----
+   0
+(1 row)
+
+RESET enable_bitmapscan;
+RESET enable_seqscan;
+SELECT * FROM verify_heapam('hi_part1');
+ blkno | offnum | attnum | msg 
+-------+--------+--------+-----
+(0 rows)
+
+DROP TABLE hi_part;
+-- ---------------------------------------------------------------------------
+-- 29. Non-btree access method (hash).  Read-side staleness is access-method
+-- agnostic (the crossed-attribute bitmap), so any index AM's column is
+-- HOT-indexed.  Hash is the sharpest case: its scans recheck the heap value,
+-- which alone cannot disambiguate a value cycled away and back (ABA) -- the
+-- bitmap drops the stale ancestor so the row is returned exactly once.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_hash (id int PRIMARY KEY, v int, w int) WITH (fillfactor = 50);
+CREATE INDEX hi_hash_v ON hi_hash USING hash (v);
+CREATE INDEX hi_hash_w ON hi_hash (w);
+INSERT INTO hi_hash VALUES (1, 10, 100);
+UPDATE hi_hash SET v = 99 WHERE id = 1;
+UPDATE hi_hash SET v = 10 WHERE id = 1;   -- ABA: 10 -> 99 -> 10, w unchanged
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT hot_idx > 0 AS hash_is_hot_indexed FROM get_hi_count('hi_hash');
+ hash_is_hot_indexed 
+---------------------
+ t
+(1 row)
+
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) AS hash_v10 FROM hi_hash WHERE v = 10;   -- want 1 (no duplicate)
+ hash_v10 
+----------
+        1
+(1 row)
+
+SELECT count(*) AS hash_v99 FROM hi_hash WHERE v = 99;   -- want 0 (stale dropped)
+ hash_v99 
+----------
+        0
+(1 row)
+
+RESET enable_bitmapscan;
+RESET enable_seqscan;
+DROP TABLE hi_hash;
+-- ---------------------------------------------------------------------------
+-- 30. DDL after a HOT-indexed chain exists.  The per-hop modified-attrs
+-- bitmap on the page is keyed by physical attribute number and sized by the
+-- relation's natts AT WRITE TIME.  Indexes added/dropped after the chain
+-- forms, and ADD/DROP COLUMN, must not corrupt the read-side staleness test.
+-- The sharp case is ADD COLUMN crossing an 8-attribute boundary, which grows
+-- ceil(natts/8): readers must locate each hop's bitmap from that hop's own
+-- write-time natts (HeapTupleHeaderGetNatts / the stub's stashed natts), not
+-- the relation's current natts.
+-- ---------------------------------------------------------------------------
+-- Exactly 8 attributes (c1..c7 + payload) so adding the 9th flips the bitmap
+-- from 1 byte to 2.  c7 is the column we churn; c2 is an unchanged indexed
+-- column whose leaf must stay current.
+CREATE TABLE hi_ddl (
+    c1 int PRIMARY KEY, c2 int, c3 int, c4 int,
+    c5 int, c6 int, c7 int, payload text
+) WITH (fillfactor = 50);
+CREATE INDEX hi_ddl_c2 ON hi_ddl(c2);
+CREATE INDEX hi_ddl_c7 ON hi_ddl(c7);
+INSERT INTO hi_ddl VALUES (1, 10, 20, 30, 40, 50, 70, 'p');
+-- Form a HOT-indexed chain on c7 BEFORE any further DDL.
+UPDATE hi_ddl SET c7 = 71 WHERE c1 = 1;
+UPDATE hi_ddl SET c7 = 72 WHERE c1 = 1;
+-- (a) CREATE INDEX after the chain exists: the new index is built against the
+-- live tuple under its own TID, so its entry is never stale.
+CREATE INDEX hi_ddl_c3 ON hi_ddl(c3);
+-- (b) ADD COLUMN crossing the 8-attribute boundary (natts 8 -> 9).  Existing
+-- hops keep their 1-byte bitmaps; the relation now wants 2.  Reads through the
+-- old chain must still be correct.
+ALTER TABLE hi_ddl ADD COLUMN c9 int;
+CREATE INDEX hi_ddl_c9 ON hi_ddl(c9);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SET enable_indexonlyscan = off;
+-- Live c7 is 72.  The c7 index must return the live row for 72 and drop the
+-- stale leaves for 70 and 71 (offsets misread would corrupt this).
+SELECT count(*) AS c7_eq_72 FROM hi_ddl WHERE c7 = 72 AND payload IS NOT NULL;
+ c7_eq_72 
+----------
+        1
+(1 row)
+
+SELECT count(*) AS c7_eq_70_stale FROM hi_ddl WHERE c7 = 70 AND payload IS NOT NULL;
+ c7_eq_70_stale 
+----------------
+              0
+(1 row)
+
+SELECT count(*) AS c7_eq_71_stale FROM hi_ddl WHERE c7 = 71 AND payload IS NOT NULL;
+ c7_eq_71_stale 
+----------------
+              0
+(1 row)
+
+-- c2 never changed across the chain: its leaf must NOT be judged stale even
+-- though a crossed hop changed c7.  A misread bitmap could spuriously flag it.
+SELECT count(*) AS c2_eq_10_current FROM hi_ddl WHERE c2 = 10 AND payload IS NOT NULL;
+ c2_eq_10_current 
+------------------
+                1
+(1 row)
+
+-- (c) Continue churning c7 AFTER the ADD COLUMN: the new hop's bitmap is sized
+-- for natts 9 (2 bytes); the old hops are 1 byte.  A chain with mixed-size
+-- bitmaps must still resolve correctly.
+UPDATE hi_ddl SET c7 = 73 WHERE c1 = 1;
+SELECT count(*) AS c7_eq_73 FROM hi_ddl WHERE c7 = 73 AND payload IS NOT NULL;
+ c7_eq_73 
+----------
+        1
+(1 row)
+
+SELECT count(*) AS c7_eq_72_now_stale FROM hi_ddl WHERE c7 = 72 AND payload IS NOT NULL;
+ c7_eq_72_now_stale 
+--------------------
+                  0
+(1 row)
+
+-- (d) Collapse the chain to stubs via VACUUM, then read again: the stub must
+-- preserve its write-time natts so its bitmap stays locatable post-ADD COLUMN.
+UPDATE hi_ddl SET c7 = 74 WHERE c1 = 1;
+VACUUM (INDEX_CLEANUP off) hi_ddl;
+SELECT count(*) AS c7_eq_74_after_vacuum FROM hi_ddl WHERE c7 = 74 AND payload IS NOT NULL;
+ c7_eq_74_after_vacuum 
+-----------------------
+                     1
+(1 row)
+
+SELECT count(*) AS c2_eq_10_after_vacuum FROM hi_ddl WHERE c2 = 10 AND payload IS NOT NULL;
+ c2_eq_10_after_vacuum 
+-----------------------
+                     1
+(1 row)
+
+-- (e) DROP COLUMN keeps the attnum slot (no renumber), so bitmaps stay aligned.
+ALTER TABLE hi_ddl DROP COLUMN c4;
+SELECT count(*) AS c7_after_drop FROM hi_ddl WHERE c7 = 74 AND payload IS NOT NULL;
+ c7_after_drop 
+---------------
+             1
+(1 row)
+
+SELECT count(*) AS c2_after_drop FROM hi_ddl WHERE c2 = 10 AND payload IS NOT NULL;
+ c2_after_drop 
+---------------
+             1
+(1 row)
+
+-- (f) DROP INDEX on the churned column: remaining indexes still resolve.
+DROP INDEX hi_ddl_c7;
+SELECT count(*) AS c2_after_dropidx FROM hi_ddl WHERE c2 = 10 AND payload IS NOT NULL;
+ c2_after_dropidx 
+------------------
+                1
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+RESET enable_indexonlyscan;
+-- The seqscan truth confirms the live row; the count assertions above (read
+-- through the post-DDL indexes) match it, which is what would break if a
+-- mis-sized bitmap corrupted the staleness verdict.
+SELECT c1, c2, c7 FROM hi_ddl WHERE c1 = 1;
+ c1 | c2 | c7 
+----+----+----
+  1 | 10 | 74
+(1 row)
+
+DROP TABLE hi_ddl;
+-- ---------------------------------------------------------------------------
+-- Cleanup
+-- ---------------------------------------------------------------------------
+DROP FUNCTION get_hi_count(text);
+DROP FUNCTION get_hot_count(text);
+DROP EXTENSION pageinspect;
diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out
index a65a5bf0c4f..b9826de31a6 100644
--- a/src/test/regress/expected/rules.out
+++ b/src/test/regress/expected/rules.out
@@ -1810,6 +1810,8 @@ pg_stat_all_indexes| SELECT c.oid AS relid,
     pg_stat_get_lastscan(i.oid) AS last_idx_scan,
     pg_stat_get_tuples_returned(i.oid) AS idx_tup_read,
     pg_stat_get_tuples_fetched(i.oid) AS idx_tup_fetch,
+    pg_stat_get_tuples_hot_indexed_updated_skipped(i.oid) AS n_tup_hot_indexed_upd_skipped,
+    pg_stat_get_tuples_hot_indexed_updated_matched(i.oid) AS n_tup_hot_indexed_upd_matched,
     pg_stat_get_stat_reset_time(i.oid) AS stats_reset
    FROM (((pg_class c
      JOIN pg_index x ON ((c.oid = x.indrelid)))
@@ -1829,6 +1831,7 @@ pg_stat_all_tables| SELECT c.oid AS relid,
     pg_stat_get_tuples_updated(c.oid) AS n_tup_upd,
     pg_stat_get_tuples_deleted(c.oid) AS n_tup_del,
     pg_stat_get_tuples_hot_updated(c.oid) AS n_tup_hot_upd,
+    pg_stat_get_tuples_hot_indexed_updated(c.oid) AS n_tup_hot_indexed_upd,
     pg_stat_get_tuples_newpage_updated(c.oid) AS n_tup_newpage_upd,
     pg_stat_get_live_tuples(c.oid) AS n_live_tup,
     pg_stat_get_dead_tuples(c.oid) AS n_dead_tup,
@@ -2324,6 +2327,8 @@ pg_stat_sys_indexes| SELECT relid,
     last_idx_scan,
     idx_tup_read,
     idx_tup_fetch,
+    n_tup_hot_indexed_upd_skipped,
+    n_tup_hot_indexed_upd_matched,
     stats_reset
    FROM pg_stat_all_indexes
   WHERE ((schemaname = ANY (ARRAY['pg_catalog'::name, 'information_schema'::name])) OR (schemaname ~ '^pg_toast'::text));
@@ -2340,6 +2345,7 @@ pg_stat_sys_tables| SELECT relid,
     n_tup_upd,
     n_tup_del,
     n_tup_hot_upd,
+    n_tup_hot_indexed_upd,
     n_tup_newpage_upd,
     n_live_tup,
     n_dead_tup,
@@ -2379,6 +2385,8 @@ pg_stat_user_indexes| SELECT relid,
     last_idx_scan,
     idx_tup_read,
     idx_tup_fetch,
+    n_tup_hot_indexed_upd_skipped,
+    n_tup_hot_indexed_upd_matched,
     stats_reset
    FROM pg_stat_all_indexes
   WHERE ((schemaname <> ALL (ARRAY['pg_catalog'::name, 'information_schema'::name])) AND (schemaname !~ '^pg_toast'::text));
@@ -2395,6 +2403,7 @@ pg_stat_user_tables| SELECT relid,
     n_tup_upd,
     n_tup_del,
     n_tup_hot_upd,
+    n_tup_hot_indexed_upd,
     n_tup_newpage_upd,
     n_live_tup,
     n_dead_tup,
@@ -2450,6 +2459,7 @@ pg_stat_xact_all_tables| SELECT c.oid AS relid,
     pg_stat_get_xact_tuples_updated(c.oid) AS n_tup_upd,
     pg_stat_get_xact_tuples_deleted(c.oid) AS n_tup_del,
     pg_stat_get_xact_tuples_hot_updated(c.oid) AS n_tup_hot_upd,
+    pg_stat_get_xact_tuples_hot_indexed_updated(c.oid) AS n_tup_hot_indexed_upd,
     pg_stat_get_xact_tuples_newpage_updated(c.oid) AS n_tup_newpage_upd
    FROM ((pg_class c
      LEFT JOIN pg_index i ON ((c.oid = i.indrelid)))
@@ -2467,6 +2477,7 @@ pg_stat_xact_sys_tables| SELECT relid,
     n_tup_upd,
     n_tup_del,
     n_tup_hot_upd,
+    n_tup_hot_indexed_upd,
     n_tup_newpage_upd
    FROM pg_stat_xact_all_tables
   WHERE ((schemaname = ANY (ARRAY['pg_catalog'::name, 'information_schema'::name])) OR (schemaname ~ '^pg_toast'::text));
@@ -2490,6 +2501,7 @@ pg_stat_xact_user_tables| SELECT relid,
     n_tup_upd,
     n_tup_del,
     n_tup_hot_upd,
+    n_tup_hot_indexed_upd,
     n_tup_newpage_upd
    FROM pg_stat_xact_all_tables
   WHERE ((schemaname <> ALL (ARRAY['pg_catalog'::name, 'information_schema'::name])) AND (schemaname !~ '^pg_toast'::text));
diff --git a/src/test/regress/parallel_schedule b/src/test/regress/parallel_schedule
index bd95cc24977..4fde5b6b0c6 100644
--- a/src/test/regress/parallel_schedule
+++ b/src/test/regress/parallel_schedule
@@ -147,6 +147,7 @@ test: fast_default
 # HOT updates tests
 # ----------
 test: hot_updates
+test: hot_indexed_updates
 
 # run tablespace test at the end because it drops the tablespace created during
 # setup that other tests may use.
diff --git a/src/test/regress/sql/hot_indexed_updates.sql b/src/test/regress/sql/hot_indexed_updates.sql
new file mode 100644
index 00000000000..de8d1622c17
--- /dev/null
+++ b/src/test/regress/sql/hot_indexed_updates.sql
@@ -0,0 +1,1154 @@
+--
+-- HOT_INDEXED_UPDATES
+-- Test HOT-indexed update (hot-indexed), aka HOT-indexed, behaviour
+--
+-- Every UPDATE in this file modifies at least one non-summarizing
+-- indexed attribute.  On a pre-hot-indexed server all of these would be
+-- non-HOT; on the hot-indexed branch each eligible update stays on-page and
+-- inserts into only the indexes whose attributes actually changed.
+--
+-- We verify four things:
+--   (A) pg_stat counters: HOT and hot-indexed counts increment as expected
+--   (B) index lookups return the new value and not the stale value
+--       for EQUALITY queries (the read-side staleness test drops a
+--       leaf whose covered attribute changed on the way to the live tuple)
+--   (C) pg_relation_hot_indexed_stats reports the HOT-indexed versions we expect
+--   (D) **RANGE/INEQUALITY** queries return the correct number of
+--       tuples -- this is the class of bugs where a stale btree
+--       entry's key is still reachable via a looser scan key; the
+--       crossed-attribute bitmap drops the stale arrival because the index's
+--       attribute changed between that leaf's target and the live tuple
+--
+
+CREATE EXTENSION IF NOT EXISTS pageinspect;
+
+CREATE OR REPLACE FUNCTION get_hot_count(rel_name text)
+RETURNS TABLE (updates BIGINT, hot BIGINT) AS $$
+DECLARE rel_oid oid;
+BEGIN
+    rel_oid := rel_name::regclass::oid;
+    updates := COALESCE(pg_stat_get_tuples_updated(rel_oid), 0) +
+               COALESCE(pg_stat_get_xact_tuples_updated(rel_oid), 0);
+    hot := COALESCE(pg_stat_get_tuples_hot_updated(rel_oid), 0) +
+           COALESCE(pg_stat_get_xact_tuples_hot_updated(rel_oid), 0);
+    RETURN NEXT;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE OR REPLACE FUNCTION get_hi_count(rel_name text)
+RETURNS TABLE (updates BIGINT, hot BIGINT, hot_idx BIGINT) AS $$
+DECLARE rel_oid oid;
+BEGIN
+    rel_oid := rel_name::regclass::oid;
+    updates := COALESCE(pg_stat_get_tuples_updated(rel_oid), 0) +
+               COALESCE(pg_stat_get_xact_tuples_updated(rel_oid), 0);
+    hot := COALESCE(pg_stat_get_tuples_hot_updated(rel_oid), 0) +
+           COALESCE(pg_stat_get_xact_tuples_hot_updated(rel_oid), 0);
+    hot_idx := COALESCE(pg_stat_get_tuples_hot_indexed_updated(rel_oid), 0) +
+           COALESCE(pg_stat_get_xact_tuples_hot_indexed_updated(rel_oid), 0);
+    RETURN NEXT;
+END;
+$$ LANGUAGE plpgsql;
+
+
+-- ---------------------------------------------------------------------------
+-- 1. Basic hot-indexed: modifying an indexed column stays HOT and counts as hot-indexed
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_basic (
+    id int PRIMARY KEY,
+    indexed_col int,
+    non_indexed_col text
+) WITH (fillfactor = 50);
+CREATE INDEX hi_basic_idx ON hi_basic(indexed_col);
+
+INSERT INTO hi_basic VALUES (1, 100, 'initial');
+
+-- Pre-hot-indexed this would be non-HOT.  Under hot-indexed it's HOT-indexed; both the
+-- HOT counter and the hot-indexed counter advance.
+UPDATE hi_basic SET indexed_col = 150 WHERE id = 1;
+SELECT pg_stat_force_next_flush();
+SELECT * FROM get_hi_count('hi_basic');
+
+-- The new value is reachable via the index.
+SET enable_seqscan = off;
+EXPLAIN (COSTS OFF) SELECT id, indexed_col FROM hi_basic WHERE indexed_col = 150;
+SELECT id, indexed_col FROM hi_basic WHERE indexed_col = 150;
+
+-- The old value is not reachable through this index: the stale btree
+-- entry (indexed_col=100) walks to the current tuple via the hot-indexed hop,
+-- nodeIndexscan re-evaluates `indexed_col = 100` against the current
+-- tuple (indexed_col=150), and the row is correctly dropped.  This is
+-- the equality-lookup case the crossed-attribute bitmap handles.
+EXPLAIN (COSTS OFF) SELECT id FROM hi_basic WHERE indexed_col = 100;
+SELECT id FROM hi_basic WHERE indexed_col = 100;
+RESET enable_seqscan;
+
+-- pg_relation_hot_indexed_stats sees one HOT-indexed version, zero HOT redirects (the
+-- chain has not yet been pruned so no LP_REDIRECT exists).
+SELECT n_hot_indexed, n_chains, avg_chain_len, max_chain_len
+FROM pg_relation_hot_indexed_stats('hi_basic');
+
+DROP TABLE hi_basic;
+
+-- ---------------------------------------------------------------------------
+-- 2. RANGE/INEQUALITY correctness after hot-indexed on an indexed column
+--
+-- This is the test class that catches the hot-indexed false-dup bug: a stale
+-- btree entry whose key value still satisfies the range predicate,
+-- reachable via the hot-indexed chain hop.
+--
+-- To exercise the bug we must force an IndexScan plan (the
+-- IndexOnlyScan path permissively drops every hot-indexed-reachable index-only
+-- hit; the BitmapHeapScan path dedups by TID).  We include a payload
+-- column not present in the PK so the planner must heap-fetch.
+--
+-- The read-side crossed-attribute bitmap makes the IndexScan return the correct
+-- count of 1: the stale entry ('1','5') chain-walks to the live tuple across
+-- the b-changing hop, and because the PK covers b the overlap is non-empty, so
+-- the stale leaf is dropped.  The fresh entry ('1','15') points directly at the
+-- live tuple (no hop after it) and is kept.  The ORDER BY likewise returns the
+-- single live row.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_range (
+    a int,
+    b int,
+    payload text,
+    PRIMARY KEY (a, b)
+) WITH (fillfactor = 50);
+
+INSERT INTO hi_range VALUES (1, 5, 'hi');
+
+-- hot-indexed update on the second PK column: stale btree entry ('1','5')
+-- remains, new entry ('1','15') inserted.  The stale entry points at
+-- the chain root; the fresh entry points directly at the new
+-- heap-only tuple.
+UPDATE hi_range SET b = 15 WHERE a = 1 AND b = 5;
+
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+
+-- IndexScan: payload IS NOT NULL forces heap fetch, no IndexOnlyScan.
+-- The stale ('1','5') leaf is dropped by the crossed-attribute bitmap, so this
+-- returns 1.
+EXPLAIN (COSTS OFF)
+SELECT count(*) FROM hi_range WHERE a = 1 AND b < 100 AND payload IS NOT NULL;
+SELECT count(*) FROM hi_range WHERE a = 1 AND b < 100 AND payload IS NOT NULL;
+SELECT a, b FROM hi_range WHERE a = 1 AND payload IS NOT NULL ORDER BY b;
+
+-- IndexOnlyScan: the page holds a preserved HOT-indexed member so it is never all-visible; IOS
+-- performs the heap fetch and the crossed-attribute bitmap drops the stale ('1','5')
+-- leaf, so count = 1.
+EXPLAIN (COSTS OFF) SELECT count(*) FROM hi_range WHERE a = 1 AND b < 100;
+SELECT count(*) FROM hi_range WHERE a = 1 AND b < 100;
+
+-- BitmapHeapScan: TID dedup collapses the stale and fresh hits.
+SET enable_indexscan = off;
+SET enable_indexonlyscan = off;
+RESET enable_bitmapscan;
+EXPLAIN (COSTS OFF) SELECT count(*) FROM hi_range WHERE a = 1 AND b < 100;
+SELECT count(*) FROM hi_range WHERE a = 1 AND b < 100;
+RESET enable_indexscan;
+RESET enable_indexonlyscan;
+
+-- SeqScan: reads the heap directly, sees exactly one live tuple.
+RESET enable_seqscan;
+SET enable_indexscan = off;
+SET enable_indexonlyscan = off;
+SET enable_bitmapscan = off;
+EXPLAIN (COSTS OFF) SELECT count(*) FROM hi_range WHERE a = 1 AND b < 100;
+SELECT count(*) FROM hi_range WHERE a = 1 AND b < 100;
+RESET enable_indexscan;
+RESET enable_indexonlyscan;
+RESET enable_bitmapscan;
+
+-- Same shape on a secondary (non-PK) btree: another hot-indexed update on b.
+CREATE INDEX hi_range_b_idx ON hi_range(b);
+UPDATE hi_range SET b = 25 WHERE a = 1 AND b = 15;
+
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+-- IndexScan path on the secondary index; same fix applies.
+SELECT count(*) FROM hi_range WHERE b BETWEEN 0 AND 100 AND payload IS NOT NULL;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+
+DROP TABLE hi_range;
+
+-- ---------------------------------------------------------------------------
+-- 3. All-or-none on a multi-indexed table: hot-indexed only touches indexes
+--    whose attributes changed
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_multi (
+    id int PRIMARY KEY,
+    col_a int,
+    col_b int,
+    col_c int,
+    non_indexed text
+) WITH (fillfactor = 50);
+CREATE INDEX hi_multi_a_idx ON hi_multi(col_a);
+CREATE INDEX hi_multi_b_idx ON hi_multi(col_b);
+CREATE INDEX hi_multi_c_idx ON hi_multi(col_c);
+
+INSERT INTO hi_multi VALUES (1, 10, 20, 30, 'initial');
+
+-- col_a only: under hot-indexed this is HOT-indexed, and only hi_multi_a_idx
+-- gets a new entry.  hi_multi_b_idx / hi_multi_c_idx keep pointing
+-- at the chain root.
+UPDATE hi_multi SET col_a = 15 WHERE id = 1;
+SELECT pg_stat_force_next_flush();
+SELECT * FROM get_hi_count('hi_multi');
+
+-- Lookups on all three indexes return the row.
+SET enable_seqscan = off;
+SELECT id FROM hi_multi WHERE col_a = 15;
+SELECT id FROM hi_multi WHERE col_b = 20;
+SELECT id FROM hi_multi WHERE col_c = 30;
+
+-- Old col_a value is unreachable by equality (stale entry dropped by the
+-- read-side crossed-attribute bitmap).
+SELECT id FROM hi_multi WHERE col_a = 10;
+RESET enable_seqscan;
+
+DROP TABLE hi_multi;
+
+-- ---------------------------------------------------------------------------
+-- 4. Multi-column btree: hot-indexed on part of a composite key
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_composite (
+    id int PRIMARY KEY,
+    col_a int,
+    col_b int,
+    data text
+) WITH (fillfactor = 50);
+CREATE INDEX hi_composite_ab_idx ON hi_composite(col_a, col_b);
+
+INSERT INTO hi_composite VALUES (1, 10, 20, 'data');
+
+-- col_a is part of the composite key: hot-indexed.
+UPDATE hi_composite SET col_a = 15;
+SELECT pg_stat_force_next_flush();
+SELECT * FROM get_hi_count('hi_composite');
+
+-- Reset and then update col_b (also part of the key).
+UPDATE hi_composite SET col_a = 10;
+UPDATE hi_composite SET col_b = 25;
+SELECT pg_stat_force_next_flush();
+SELECT * FROM get_hi_count('hi_composite');
+
+DROP TABLE hi_composite;
+
+-- ---------------------------------------------------------------------------
+-- 5. Partial index: status transition out-of-predicate
+--
+-- 'status' is a partial-index predicate column.  A change to a predicate
+-- column can flip a row in or out of the index, which the read-side key
+-- recheck cannot detect, so HeapUpdateHotAllowable conservatively disqualifies
+-- HOT-indexed for any predicate-column change (even this out-of-predicate ->
+-- out-of-predicate case).  The update is therefore non-HOT, and the partial
+-- index correctly stays empty for these non-'active' rows.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_partial (
+    id int PRIMARY KEY,
+    status text,
+    data text
+) WITH (fillfactor = 50);
+CREATE INDEX hi_partial_active_idx ON hi_partial(status) WHERE status = 'active';
+
+INSERT INTO hi_partial VALUES (1, 'active', 'data1');
+INSERT INTO hi_partial VALUES (2, 'inactive', 'data2');
+INSERT INTO hi_partial VALUES (3, 'deleted', 'data3');
+
+-- out -> out transition on the predicate column: HOT-indexed keeps it on-page,
+-- and the partial index gets no entry (the row satisfies the predicate neither
+-- before nor after the update).
+UPDATE hi_partial SET status = 'deleted' WHERE id = 2;
+SELECT pg_stat_force_next_flush();
+SELECT * FROM get_hi_count('hi_partial');
+
+-- The partial index still correctly answers "active" queries.
+SELECT id, status FROM hi_partial WHERE status = 'active';
+
+DROP TABLE hi_partial;
+
+-- ---------------------------------------------------------------------------
+-- 6. Partition: hot-indexed inside one partition
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_part (
+    id int,
+    partition_key int,
+    indexed_col int,
+    data text,
+    PRIMARY KEY (id, partition_key)
+) PARTITION BY RANGE (partition_key);
+CREATE TABLE hi_part_1 PARTITION OF hi_part
+    FOR VALUES FROM (1) TO (100) WITH (fillfactor = 50);
+CREATE INDEX hi_part_idx ON hi_part(indexed_col);
+
+INSERT INTO hi_part VALUES (1, 50, 100, 'data');
+
+UPDATE hi_part SET indexed_col = 150 WHERE id = 1;
+SELECT pg_stat_force_next_flush();
+SELECT * FROM get_hi_count('hi_part_1');
+
+SET enable_seqscan = off;
+SELECT id FROM hi_part WHERE indexed_col = 150;
+SELECT id FROM hi_part WHERE indexed_col = 100;
+RESET enable_seqscan;
+
+DROP TABLE hi_part CASCADE;
+
+-- ---------------------------------------------------------------------------
+-- 7. Trigger modifies indexed column: hot-indexed, not non-HOT
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_trigger (
+    id int PRIMARY KEY,
+    triggered_col int,
+    data text
+) WITH (fillfactor = 50);
+CREATE INDEX hi_trigger_idx ON hi_trigger(triggered_col);
+
+CREATE OR REPLACE FUNCTION hi_trigger_bump()
+RETURNS TRIGGER AS $$
+BEGIN
+    NEW.triggered_col = NEW.triggered_col + 1;
+    RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER before_update_bump
+    BEFORE UPDATE ON hi_trigger
+    FOR EACH ROW
+    EXECUTE FUNCTION hi_trigger_bump();
+
+INSERT INTO hi_trigger VALUES (1, 100, 'initial');
+
+-- UPDATE's SET clause doesn't touch the indexed column, but the
+-- trigger modifies it via heap_modify_tuple.  hot-indexed must detect this
+-- and keep the tuple on-page (HEAP_INDEXED_UPDATED) plus a new btree entry.
+UPDATE hi_trigger SET data = 'updated' WHERE id = 1;
+SELECT pg_stat_force_next_flush();
+SELECT * FROM get_hi_count('hi_trigger');
+SELECT triggered_col FROM hi_trigger WHERE id = 1;
+
+-- New value reachable.
+SET enable_seqscan = off;
+SELECT id FROM hi_trigger WHERE triggered_col = 101;
+SELECT id FROM hi_trigger WHERE triggered_col = 100;
+RESET enable_seqscan;
+
+DROP TABLE hi_trigger CASCADE;
+DROP FUNCTION hi_trigger_bump();
+
+-- ---------------------------------------------------------------------------
+-- 8. JSONB expression index: HOT-indexed is not yet supported on expression
+--    indexes, so the update falls back to a non-HOT update (hot_idx = 0).
+--    Reads stay correct.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_jsonb (
+    id int PRIMARY KEY,
+    data jsonb
+) WITH (fillfactor = 50);
+CREATE INDEX hi_jsonb_name_idx ON hi_jsonb ((data->>'name'));
+
+INSERT INTO hi_jsonb VALUES (1, '{"name":"Alice","age":30}');
+
+-- Changing the indexed expression's value (name): expression indexes are not
+-- yet supported, so this is a non-HOT update.
+UPDATE hi_jsonb SET data = jsonb_set(data, '{name}', '"Alice2"') WHERE id = 1;
+SELECT pg_stat_force_next_flush();
+SELECT * FROM get_hi_count('hi_jsonb');
+
+SET enable_seqscan = off;
+SELECT id FROM hi_jsonb WHERE data->>'name' = 'Alice2';
+SELECT id FROM hi_jsonb WHERE data->>'name' = 'Alice';
+RESET enable_seqscan;
+
+DROP TABLE hi_jsonb;
+
+-- ---------------------------------------------------------------------------
+-- 9. GIN index with changed extracted keys: hot-indexed
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_gin (
+    id int PRIMARY KEY,
+    tags text[]
+) WITH (fillfactor = 50);
+CREATE INDEX hi_gin_tags_idx ON hi_gin USING gin (tags);
+
+INSERT INTO hi_gin VALUES (1, ARRAY['tag1', 'tag2']);
+
+-- Adding a tag yields a different extracted-key set: hot-indexed.
+UPDATE hi_gin SET tags = ARRAY['tag1', 'tag2', 'tag5'] WHERE id = 1;
+SELECT pg_stat_force_next_flush();
+SELECT * FROM get_hi_count('hi_gin');
+
+SET enable_seqscan = off;
+SELECT id FROM hi_gin WHERE tags @> ARRAY['tag5'];
+RESET enable_seqscan;
+
+DROP TABLE hi_gin;
+
+-- ---------------------------------------------------------------------------
+-- 10. Per-index HOT-indexed counters: skipped vs matched
+--
+-- A table with two independent secondary indexes.  An UPDATE touches a
+-- column covered by only one of them; the HOT-indexed path must insert
+-- into that one index and skip the other.  pg_stat_all_indexes reports
+-- matched>0 on the updated index and skipped>0 on the untouched index.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hotidx_perindex (
+    id int PRIMARY KEY,
+    a int,
+    b int
+) WITH (fillfactor = 50);
+CREATE INDEX hotidx_perindex_a ON hotidx_perindex(a);
+CREATE INDEX hotidx_perindex_b ON hotidx_perindex(b);
+
+INSERT INTO hotidx_perindex VALUES (1, 100, 200);
+
+-- Modify only column a.  HOT-indexed inserts into hotidx_perindex_a and
+-- skips hotidx_perindex_b (primary key indrelid is the table itself and
+-- also unchanged, so it counts as skipped too).
+UPDATE hotidx_perindex SET a = 101 WHERE id = 1;
+
+-- Force flush of pending stats to the shared entry.
+SELECT pg_stat_force_next_flush();
+
+SELECT indexrelname,
+       n_tup_hot_indexed_upd_matched AS matched,
+       n_tup_hot_indexed_upd_skipped AS skipped
+  FROM pg_stat_all_indexes
+ WHERE relname = 'hotidx_perindex'
+ ORDER BY indexrelname;
+
+-- A second UPDATE touching only b inverts the assignment.
+UPDATE hotidx_perindex SET b = 201 WHERE id = 1;
+SELECT pg_stat_force_next_flush();
+
+SELECT indexrelname,
+       n_tup_hot_indexed_upd_matched AS matched,
+       n_tup_hot_indexed_upd_skipped AS skipped
+  FROM pg_stat_all_indexes
+ WHERE relname = 'hotidx_perindex'
+ ORDER BY indexrelname;
+
+-- Invariant: matched + skipped == owning table's n_tup_hot_indexed_upd.
+SELECT indexrelname,
+       n_tup_hot_indexed_upd_matched + n_tup_hot_indexed_upd_skipped AS total,
+       (SELECT n_tup_hot_indexed_upd FROM pg_stat_all_tables
+         WHERE relname = 'hotidx_perindex') AS table_hot_idx_upd
+  FROM pg_stat_all_indexes
+ WHERE relname = 'hotidx_perindex'
+ ORDER BY indexrelname;
+
+-- Boolean assertion of the same invariant.  This is the canonical form
+-- reviewers asked for: every index entry is either matched (the index
+-- got a fresh insert this UPDATE) or skipped (HOT-indexed correctly
+-- avoided an insert because the index's attrs did not change).  If the
+-- two counters drift apart from the table-level n_tup_hot_indexed_upd we
+-- have either lost a per-index increment or double-counted one.
+SELECT bool_and((n_tup_hot_indexed_upd_matched + n_tup_hot_indexed_upd_skipped) =
+                (SELECT n_tup_hot_indexed_upd FROM pg_stat_all_tables
+                  WHERE relname = 'hotidx_perindex'))
+         AS perindex_invariant_holds
+  FROM pg_stat_all_indexes
+ WHERE relname = 'hotidx_perindex';
+
+DROP TABLE hotidx_perindex;
+
+-- ---------------------------------------------------------------------------
+-- 11. Long hot-loop UPDATE stays compact and HOT-indexed
+--
+-- A long run of HOT-indexed UPDATEs to a single row stays compact: prune
+-- collapses each dead version to a redirect to the live tuple and reuses its
+-- slot, so the row never leaves its original page and the chain does not grow
+-- unbounded.  Every UPDATE that changes the indexed column (and leaves another
+-- index, here the PK, unchanged) takes the HOT-indexed path.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_chaincap (
+    id int PRIMARY KEY,
+    a int
+) WITH (fillfactor = 10);
+CREATE INDEX hi_chaincap_a_idx ON hi_chaincap(a);
+
+INSERT INTO hi_chaincap VALUES (1, 0);
+
+DO $$
+DECLARE
+    i int;
+BEGIN
+    FOR i IN 1 .. 200 LOOP
+        UPDATE hi_chaincap SET a = i WHERE id = 1;
+    END LOOP;
+END $$;
+
+-- After 200 UPDATEs the row's value is 200.
+SELECT a FROM hi_chaincap WHERE id = 1;
+
+-- Every UPDATE took the HOT-indexed path (the PK index is unchanged, so it is
+-- skipped), so n_tup_hot_indexed_upd advanced.
+SELECT pg_stat_force_next_flush();
+SELECT hot_idx > 0 AS hot_indexed_fired
+  FROM get_hi_count('hi_chaincap');
+
+-- The heap stayed compact: prune+collapse reclaimed the dead versions, so the
+-- single live row still occupies just one page.
+SELECT relpages <= 1 AS heap_stayed_compact
+  FROM pg_class WHERE relname = 'hi_chaincap';
+
+DROP TABLE hi_chaincap;
+
+-- ---------------------------------------------------------------------------
+-- 12. Reclamation of a collapsed HOT-indexed chain by prune
+--
+-- A dead HOT-indexed chain member is preserved at prune time (its stale leaf
+-- may still exist) and the chain collapses to an LP_REDIRECT forwarder; the
+-- index cleanup pass then sweeps the stale leaf, and a second VACUUM reclaims
+-- the now-unreferenced member and re-points the redirect.  So the chain is
+-- fully reclaimed after the second VACUUM, not the first.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_reclaim (
+    id int PRIMARY KEY,
+    a int
+) WITH (fillfactor = 50);
+CREATE INDEX hi_reclaim_a_idx ON hi_reclaim(a);
+
+INSERT INTO hi_reclaim VALUES (1, 100);
+-- Generate a collapsed chain via a HOT-indexed update.
+UPDATE hi_reclaim SET a = 200 WHERE id = 1;
+SELECT n_hot_indexed >= 1 AS hot_indexed_present_before_reclaim
+  FROM pg_relation_hot_indexed_stats('hi_reclaim');
+
+-- Delete the live tuple.  The first VACUUM collapses the dead chain and sweeps
+-- the stale leaf; the second reclaims the now-unreferenced members.
+DELETE FROM hi_reclaim WHERE id = 1;
+VACUUM hi_reclaim;
+VACUUM hi_reclaim;
+
+SELECT n_hot_indexed AS hot_indexed_after_reclaim,
+       n_chains AS chains_after_reclaim
+  FROM pg_relation_hot_indexed_stats('hi_reclaim');
+
+DROP TABLE hi_reclaim;
+
+-- ---------------------------------------------------------------------------
+-- 13. Page with a preserved HOT-indexed member is never marked all-visible
+--
+-- pruneheap deliberately leaves PD_ALL_VISIBLE clear on any page that still
+-- carries a preserved HOT-indexed member: an index-only scan must heap-fetch
+-- through the chain so the read-side crossed-attribute bitmap can filter stale btree
+-- entries.
+--
+-- We force the freeze path with VACUUM (FREEZE, DISABLE_PAGE_SKIPPING) and
+-- then read pd_flags via pageinspect.page_header.  The page must still carry
+-- a HOT-indexed member (n_hot_indexed > 0) AND must not have PD_ALL_VISIBLE
+-- (0x0004).
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_vm (
+    id int PRIMARY KEY,
+    a int
+) WITH (fillfactor = 50);
+CREATE INDEX hi_vm_a_idx ON hi_vm(a);
+
+INSERT INTO hi_vm VALUES (1, 1);
+-- Two HOT-indexed updates leave a multi-hop chain, so a preserved HOT-indexed
+-- member remains on the page after prune, which is what this test needs.
+UPDATE hi_vm SET a = 2 WHERE id = 1;
+UPDATE hi_vm SET a = 3 WHERE id = 1;
+
+-- Force the all-visible bit decision: VACUUM with DISABLE_PAGE_SKIPPING
+-- considers every page; FREEZE pushes hint bits hard.  After this, any
+-- page bearing a preserved HOT-indexed member must still report all_visible = 0.
+VACUUM (FREEZE, DISABLE_PAGE_SKIPPING) hi_vm;
+
+SELECT n_hot_indexed >= 1 AS hot_indexed_present
+  FROM pg_relation_hot_indexed_stats('hi_vm');
+
+-- PD_ALL_VISIBLE = 0x0004.  Must be 0 on a page with a preserved member.
+SELECT (flags & 4) = 0 AS not_marked_all_visible
+  FROM page_header(get_raw_page('hi_vm', 0));
+
+DROP TABLE hi_vm;
+
+-- ---------------------------------------------------------------------------
+-- 14. Cycle-key dedup: column rename a -> b -> a stays correct
+--
+-- A rename does not rewrite heap or index entries; it only updates the
+-- catalog.  The relcache invalidation must trigger a fresh attribute
+-- bitmap and the HOT-indexed predicate must compare attribute *numbers*,
+-- not attribute *names*.  After two renames that net to identity, every
+-- subsequent UPDATE must continue to drive the HOT-indexed path.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_cycle (
+    id int PRIMARY KEY,
+    a int
+) WITH (fillfactor = 50);
+CREATE INDEX hi_cycle_a_idx ON hi_cycle(a);
+
+INSERT INTO hi_cycle VALUES (1, 100);
+
+-- Cycle the column name and confirm both intermediate forms drive HOT-indexed.
+ALTER TABLE hi_cycle RENAME COLUMN a TO b;
+UPDATE hi_cycle SET b = 200 WHERE id = 1;
+SELECT pg_stat_force_next_flush();
+SELECT hot_idx > 0 AS hot_indexed_after_first_rename
+  FROM get_hi_count('hi_cycle');
+
+ALTER TABLE hi_cycle RENAME COLUMN b TO a;
+UPDATE hi_cycle SET a = 300 WHERE id = 1;
+-- Lookup via the index returns the current value, not any of the
+-- pre-rename values.
+SET enable_seqscan = off;
+SELECT id, a FROM hi_cycle WHERE a = 300;
+SELECT id FROM hi_cycle WHERE a = 100;
+SELECT id FROM hi_cycle WHERE a = 200;
+RESET enable_seqscan;
+
+DROP TABLE hi_cycle;
+
+-- ---------------------------------------------------------------------------
+-- 15. Summarizing-only column UPDATE produces CLASSIC, not INDEXED
+--
+-- HeapUpdateHotAllowable returns HEAP_UPDATE_HOT when every
+-- modified indexed attribute is covered only by summarizing indexes.
+-- A BRIN-only column is the canonical case: the BRIN index gets a
+-- new summary entry via aminsert, but no per-update btree entry is
+-- needed and HOT-indexed does not fire.  The signal is
+-- n_tup_hot_upd > 0 with n_tup_hot_indexed_upd unchanged.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_brin (
+    id int PRIMARY KEY,
+    bcol int
+) WITH (fillfactor = 50);
+CREATE INDEX hi_brin_idx ON hi_brin USING brin(bcol);
+
+INSERT INTO hi_brin VALUES (1, 100);
+
+-- Capture the HOT-indexed counter before, drive a BRIN-only update,
+-- and assert that classic HOT advanced while HOT-indexed did not.
+SELECT pg_stat_force_next_flush();
+SELECT hot_idx AS hot_idx_before FROM get_hi_count('hi_brin') \gset
+UPDATE hi_brin SET bcol = 200 WHERE id = 1;
+SELECT pg_stat_force_next_flush();
+SELECT (hot - 0) > 0 AS classic_hot_fired,
+       hot_idx = :hot_idx_before AS hot_indexed_did_not_fire
+  FROM get_hi_count('hi_brin');
+
+-- The BRIN index sees the new value via aminsert.
+SELECT bcol FROM hi_brin WHERE id = 1;
+
+DROP TABLE hi_brin;
+
+-- ---------------------------------------------------------------------------
+-- 16. UNIQUE index on a type where image equality != operator equality
+--
+-- numeric 1.0 and 1.00 are equal under the btree opclass but have
+-- different on-disk images.  A HOT-indexed update 1.0 -> 1.00 inserts a
+-- fresh leaf carrying the live image and leaves a stale leaf for 1.0
+-- (the hop's modified-attrs bitmap marks k changed, since modified-column
+-- detection is image-based).  A later INSERT of a value equal under the
+-- opclass must still be detected as a duplicate: the unique check reaches
+-- the live tuple through the fresh leaf, which points directly at it (no hop
+-- after it, so the overlap is empty and the leaf is a genuine conflict); the
+-- stale 1.0 leaf is skipped because the k-changing hop overlaps the unique
+-- index's attribute.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_unum (k numeric UNIQUE, j int) WITH (fillfactor = 50);
+CREATE INDEX hi_unum_j ON hi_unum(j);             -- 2nd indexed attr, kept fixed
+INSERT INTO hi_unum VALUES (1.0, 100);
+UPDATE hi_unum SET k = 1.00 WHERE j = 100;        -- HOT-indexed: 1.0 -> 1.00
+SELECT n_hot_indexed > 0 AS made_hot_indexed
+  FROM pg_relation_hot_indexed_stats('hi_unum');
+-- A numerically-equal insert must conflict (the fresh leaf catches it):
+INSERT INTO hi_unum VALUES (1.0, 1);              -- expect duplicate key error
+-- A genuinely different value is accepted:
+INSERT INTO hi_unum VALUES (2.0, 2);
+SELECT k, j FROM hi_unum ORDER BY j;
+DROP TABLE hi_unum;
+
+-- ---------------------------------------------------------------------------
+-- 17. CREATE INDEX and REINDEX over live HOT-indexed chains
+--
+-- A freshly built or rebuilt index must reflect current values, never a
+-- stale chain member: the build scans live tuples only and points each
+-- HOT-indexed live tuple's entry at its own TID, so the new entries have no
+-- hop after them and the crossed-attribute bitmap keeps them.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_reindex (id int PRIMARY KEY, a int, b int) WITH (fillfactor = 50);
+CREATE INDEX hi_reindex_a ON hi_reindex(a);
+INSERT INTO hi_reindex SELECT g, g, g FROM generate_series(1, 6) g;
+UPDATE hi_reindex SET a = a + 100;                -- HOT-indexed on a
+UPDATE hi_reindex SET a = a + 100;                -- again -> longer chains
+SELECT n_hot_indexed > 0 AS made_hot_indexed
+  FROM pg_relation_hot_indexed_stats('hi_reindex');
+-- Build a NEW index and REINDEX the existing one over the live chains.
+CREATE INDEX hi_reindex_b ON hi_reindex(b);
+REINDEX INDEX hi_reindex_a;
+SET enable_seqscan = off;
+SELECT id, a FROM hi_reindex WHERE a = 204;       -- current value -> id 4
+SELECT count(*) FROM hi_reindex WHERE a = 4;      -- obsolete value -> 0
+SELECT id FROM hi_reindex WHERE b = 2;            -- via freshly built index -> 2
+RESET enable_seqscan;
+DROP TABLE hi_reindex;
+
+-- ---------------------------------------------------------------------------
+-- 18. DROP every index over live HOT-indexed chains, then VACUUM
+--
+-- After all indexes are dropped, heap pages may still carry preserved
+-- HOT-indexed members left by earlier updates.  VACUUM of such a no-index
+-- relation must complete without error, and reads must stay correct via the
+-- redirect forwarders.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_dropidx (id int PRIMARY KEY, a int) WITH (fillfactor = 50);
+CREATE INDEX hi_dropidx_a ON hi_dropidx(a);
+INSERT INTO hi_dropidx SELECT g, g FROM generate_series(1, 6) g;
+UPDATE hi_dropidx SET a = a + 100;                -- HOT-indexed on a
+UPDATE hi_dropidx SET a = a + 100;                -- again -> longer chains
+SELECT n_hot_indexed > 0 AS made_hot_indexed
+  FROM pg_relation_hot_indexed_stats('hi_dropidx');
+-- Drop every index, leaving preserved HOT-indexed members with no index to sweep.
+DROP INDEX hi_dropidx_a;
+ALTER TABLE hi_dropidx DROP CONSTRAINT hi_dropidx_pkey;
+-- Must not crash on the no-index path; two passes exercise the second-pass
+-- reclaim guard as well.
+VACUUM hi_dropidx;
+VACUUM hi_dropidx;
+-- Reads remain correct after the indexes are gone.
+SELECT id, a FROM hi_dropidx ORDER BY id;
+DROP TABLE hi_dropidx;
+
+-- ---------------------------------------------------------------------------
+-- 19. Re-collapse of a data-redirect chain across partial VACUUMs
+--
+-- A chain that collapses to a HOT-indexed data redirect, is vacuumed with
+-- INDEX_CLEANUP off (so the stale leaves and the redirect survive), then
+-- receives further HOT-indexed updates that re-collapse the chain and
+-- re-point the redirect at a new live tuple, must not leave the redirect
+-- dangling.  A subsequent full VACUUM must complete without error, leave the
+-- heap consistent (verify_heapam reports nothing), and reads must stay
+-- correct.  (Regression: an earlier revision crashed reclaiming a mid-chain
+-- member while a data redirect still pointed past it.)
+-- ---------------------------------------------------------------------------
+CREATE EXTENSION IF NOT EXISTS amcheck;
+CREATE TABLE hi_recollapse (id int PRIMARY KEY, a int) WITH (fillfactor = 50);
+CREATE INDEX hi_recollapse_a ON hi_recollapse(a);
+INSERT INTO hi_recollapse VALUES (1, 1);
+-- First chain: two HOT-indexed updates, then prune to a data redirect while
+-- leaving the stale btree leaves in place (INDEX_CLEANUP off).
+UPDATE hi_recollapse SET a = 2 WHERE id = 1;
+UPDATE hi_recollapse SET a = 3 WHERE id = 1;
+VACUUM (INDEX_CLEANUP off) hi_recollapse;
+-- Re-collapse: more HOT-indexed updates extend the chain past the redirect
+-- target; the next prune re-points the data redirect at the new first live
+-- tuple and extends its union.
+UPDATE hi_recollapse SET a = 4 WHERE id = 1;
+UPDATE hi_recollapse SET a = 5 WHERE id = 1;
+VACUUM (INDEX_CLEANUP off) hi_recollapse;
+-- Full vacuum now reclaims the dead chain; the re-pointed redirect must not
+-- dangle.  Two passes also exercise the redirect re-point second pass.
+VACUUM hi_recollapse;
+VACUUM hi_recollapse;
+-- Heap must be structurally consistent (no rows == no corruption).
+SELECT * FROM verify_heapam('hi_recollapse');
+SET enable_seqscan = off;
+SELECT id, a FROM hi_recollapse WHERE a = 5;     -- current value -> id 1
+SELECT count(*) FROM hi_recollapse WHERE a = 3;  -- obsolete value -> 0
+RESET enable_seqscan;
+SELECT id, a FROM hi_recollapse ORDER BY id;
+DROP TABLE hi_recollapse;
+
+-- ---------------------------------------------------------------------------
+-- 20. Index deletion over an entry that points at a data-redirect root
+--
+-- A data redirect is an LP_REDIRECT that carries a bitmap, so it reports
+-- lp_len > 0 (ItemIdHasStorage true) even though it is not a normal tuple.
+-- index_delete_check_htid must treat it as a redirect, not read its blob as a
+-- HeapTupleHeader.  Reproduce: collapse a chain root to a data redirect while
+-- keeping the stale leaf that points at it (INDEX_CLEANUP off), then insert
+-- many duplicates of the stale key so btree bottom-up deletion runs
+-- heap_index_delete_tuples over that stale entry.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_iddel (id int, a int) WITH (fillfactor = 50);
+CREATE INDEX hi_iddel_a ON hi_iddel(a);
+INSERT INTO hi_iddel VALUES (1, 1);
+UPDATE hi_iddel SET a = a + 1 WHERE id = 1;          -- HOT-indexed
+UPDATE hi_iddel SET a = a + 1 WHERE id = 1;          -- multi-hop chain
+VACUUM (INDEX_CLEANUP off) hi_iddel;                 -- root -> data redirect, keep stale a=1 leaf
+-- Many duplicates of the stale key fill the leaf and trigger bottom-up
+-- deletion, which feeds the stale a=1 entry (htid -> the data-redirect root)
+-- to heap_index_delete_tuples.  Must not crash or misread the blob.
+INSERT INTO hi_iddel SELECT g, 1 FROM generate_series(2, 3000) g;
+VACUUM hi_iddel;
+SELECT * FROM verify_heapam('hi_iddel');
+SET enable_seqscan = off;
+SELECT id, a FROM hi_iddel WHERE id = 1;             -- current value -> a = 3
+RESET enable_seqscan;
+DROP TABLE hi_iddel;
+
+-- ---------------------------------------------------------------------------
+-- 21. A change to a column covered by a non-btree index AM is HOT-indexed
+--
+-- A HOT-indexed update leaves a stale pre-update leaf that the read side
+-- filters via the crossed-attribute bitmap, which is access-method agnostic.
+-- A column covered by a non-btree index (here a GiST index on a point column)
+-- is therefore HOT-indexed like any other, and the GiST index still returns
+-- correct results across the chain.  A change to a btree-only column on the
+-- same table is likewise HOT-indexed.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_nonbtree (id int PRIMARY KEY, tag int, p point)
+    WITH (fillfactor = 10);
+CREATE INDEX hi_nonbtree_tag ON hi_nonbtree (tag);          -- btree index
+CREATE INDEX hi_nonbtree_p ON hi_nonbtree USING gist (p);   -- GiST, non-btree
+INSERT INTO hi_nonbtree SELECT g, g, point(g, g)
+    FROM generate_series(1, 200) g;
+
+-- Change the GiST-covered column first: HOT-indexed (hot_idx = 200).
+UPDATE hi_nonbtree SET p = point(p[0] + 1000, p[1] + 1000);
+SELECT hot_idx AS gist_col_hot_indexed FROM get_hi_count('hi_nonbtree');
+
+-- The GiST index must return correct results: the old positions are gone and
+-- every row is found at its new position (no stale leaf surfaces an old key).
+SET enable_seqscan = off;
+SELECT count(*) AS at_old_positions
+    FROM hi_nonbtree WHERE p <@ box(point(0, 0), point(300, 300));
+SELECT count(*) AS at_new_positions
+    FROM hi_nonbtree WHERE p <@ box(point(1000, 1000), point(1300, 1300));
+RESET enable_seqscan;
+
+-- Changing the btree-only column (p unchanged) stays HOT-indexed.
+UPDATE hi_nonbtree SET tag = tag + 1000;
+SELECT hot_idx > 0 AS btree_col_is_hot_indexed FROM get_hi_count('hi_nonbtree');
+DROP TABLE hi_nonbtree;
+
+-- ---------------------------------------------------------------------------
+-- 22. ABA on a unique key across two distinct live rows: a key cycled away
+-- and back must still collide with another row that holds it.  The stale
+-- leaves left by the cycle must not let a genuine duplicate slip past the
+-- uniqueness check -- the read-side recheck compares the live key, not just
+-- a changed-attribute bitmap.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_aba (k int, v int) WITH (fillfactor = 50);
+CREATE UNIQUE INDEX hi_aba_k ON hi_aba (k);
+CREATE INDEX hi_aba_v ON hi_aba (v);
+INSERT INTO hi_aba VALUES (1, 10), (2, 20);
+
+-- Cycle row1's unique key 1 -> 3 -> 1 (v unchanged, so each step is
+-- HOT-indexed and leaves stale entries in hi_aba_k).
+UPDATE hi_aba SET k = 3 WHERE v = 10;
+UPDATE hi_aba SET k = 1 WHERE v = 10;
+SELECT hot_idx > 0 AS cycled_hot_indexed FROM get_hi_count('hi_aba');
+
+-- row1 is live at k = 1 again.  Moving row2 onto k = 1 must raise a unique
+-- violation despite the stale '1' leaves from the cycle.
+UPDATE hi_aba SET k = 1 WHERE v = 20;
+DROP TABLE hi_aba;
+
+-- ---------------------------------------------------------------------------
+-- 23. Partial index whose predicate references a non-key column.  Flipping the
+-- row out of the predicate while leaving the indexed key unchanged is
+-- HOT-indexed: the predicate column is part of the index's attribute set, so
+-- the crossed-attribute bitmap drops the now-stale partial-index entry on read
+-- (no value recheck is involved).
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_partpred (id int PRIMARY KEY, k int, active boolean)
+    WITH (fillfactor = 50);
+CREATE INDEX hi_partpred_k ON hi_partpred (k) WHERE active;
+INSERT INTO hi_partpred VALUES (1, 100, true);
+
+-- Flip the predicate column 'active' true -> false; the index key k is
+-- unchanged.  The row no longer satisfies the predicate, so its partial-index
+-- entry must be removed, not left pointing into the chain.
+UPDATE hi_partpred SET active = false WHERE id = 1;
+SELECT pg_stat_force_next_flush();
+SELECT hot, hot_idx FROM get_hi_count('hi_partpred');
+
+-- The partial index must not surface the row now that active = false.
+-- A query whose qual exactly matches the partial predicate uses the index
+-- without re-filtering 'active' on the heap, so a stale entry would surface.
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+EXPLAIN (COSTS OFF) SELECT id FROM hi_partpred WHERE active;
+SELECT id FROM hi_partpred WHERE k = 100 AND active;
+SELECT id FROM hi_partpred WHERE active;
+RESET enable_bitmapscan;
+RESET enable_seqscan;
+DROP TABLE hi_partpred;
+
+-- ---------------------------------------------------------------------------
+-- 24. Reclaim + stub mix.  Repeated updates of column a followed by an update
+-- of column b build a chain whose prune reclaims the members whose change was
+-- superseded (a changed again) and keeps stubs for those that were not, so a
+-- root redirect ends up pointing at a stub and a later walk crosses mid-chain
+-- stubs.  Reads through each index and amcheck must stay correct across the
+-- collapse, and a second round must walk the existing stubs without severing
+-- the chain.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_stubmix (id int PRIMARY KEY, a int, b int) WITH (fillfactor = 50);
+CREATE INDEX hi_stubmix_a ON hi_stubmix (a);
+CREATE INDEX hi_stubmix_b ON hi_stubmix (b);
+INSERT INTO hi_stubmix VALUES (1, 10, 100);
+UPDATE hi_stubmix SET a = 11 WHERE id = 1;   -- changes a
+UPDATE hi_stubmix SET a = 12 WHERE id = 1;   -- changes a again (supersedes)
+UPDATE hi_stubmix SET b = 101 WHERE id = 1;  -- changes b
+VACUUM hi_stubmix;
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT id, a, b FROM hi_stubmix WHERE a = 12;   -- current a
+SELECT id, a, b FROM hi_stubmix WHERE b = 101;  -- current b
+SELECT id FROM hi_stubmix WHERE a = 10;         -- stale a: 0 rows
+RESET enable_bitmapscan;
+RESET enable_seqscan;
+SELECT * FROM verify_heapam('hi_stubmix');      -- no corruption across stubs
+-- A second round must walk the existing stubs (no priorXmax sever).
+UPDATE hi_stubmix SET a = 13 WHERE id = 1;
+VACUUM hi_stubmix;
+SELECT id, a, b FROM hi_stubmix WHERE a = 13;
+SELECT * FROM verify_heapam('hi_stubmix');
+DROP TABLE hi_stubmix;
+
+-- ---------------------------------------------------------------------------
+-- 23. Exclusion-constraint tables are HOT-indexed-eligible.
+--
+-- An exclusion constraint is enforced by check_exclusion_or_unique_constraint,
+-- which rechecks each candidate against the live tuple's current index-form
+-- with the constraint's own operators, so a stale entry left by a HOT-indexed
+-- update is skipped while the live key always has its own entry.  Updating a
+-- non-constrained indexed column is HOT-indexed (the GiST exclusion index is
+-- skipped), and the constraint stays correct.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_excl (
+    id int PRIMARY KEY,
+    tag int,
+    during int4range,
+    EXCLUDE USING gist (during WITH &&)
+) WITH (fillfactor = 10);
+CREATE INDEX hi_excl_tag ON hi_excl(tag);
+INSERT INTO hi_excl VALUES (1, 100, int4range(1, 10)), (2, 200, int4range(20, 30));
+
+-- Update a non-constrained indexed column: HOT-indexed (GiST exclusion index
+-- and PK skipped), and the exclusion constraint is still enforced.
+UPDATE hi_excl SET tag = tag + 1 WHERE id = 1;
+SELECT hot_idx > 0 AS tag_update_hot_indexed FROM get_hi_count('hi_excl');
+INSERT INTO hi_excl VALUES (3, 300, int4range(5, 15));  -- overlaps id=1's (1,10)
+
+-- Move id=1's range away (this updates the GiST index, leaving a stale entry
+-- for the old (1,10) range).  A range overlapping only the OLD range now
+-- inserts cleanly (the stale entry is skipped); one overlapping the NEW range
+-- still conflicts.
+UPDATE hi_excl SET during = int4range(100, 110) WHERE id = 1;
+INSERT INTO hi_excl VALUES (4, 400, int4range(5, 15));   -- only overlapped old range: OK
+INSERT INTO hi_excl VALUES (5, 500, int4range(105, 115));-- overlaps new (100,110): conflict
+DROP TABLE hi_excl;
+
+-- ---------------------------------------------------------------------------
+-- 25. TOAST interaction.  An indexed column stored out-of-line must behave
+-- correctly across HOT-indexed updates: an entry kept across an update of a
+-- different column still resolves to the (unchanged) toasted value, and after
+-- the toasted column itself is changed the stale entry is dropped by the
+-- crossed-attribute bitmap (no value comparison or detoasting is needed).
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_toast (id int PRIMARY KEY, big text, tag int) WITH (fillfactor = 50);
+ALTER TABLE hi_toast ALTER COLUMN big SET STORAGE EXTERNAL;  -- no compression
+CREATE INDEX hi_toast_big ON hi_toast (big);
+CREATE INDEX hi_toast_tag ON hi_toast (tag);
+INSERT INTO hi_toast VALUES (1, repeat('A', 2000), 10);
+-- The big value is stored out-of-line.
+SELECT pg_column_size(big) > 1500 AS big_is_external FROM hi_toast WHERE id = 1;
+-- HOT-indexed update of tag leaves big (and its index entry) unchanged.
+UPDATE hi_toast SET tag = 11 WHERE id = 1;
+SELECT hot_idx > 0 AS tag_update_hot_indexed FROM get_hi_count('hi_toast');
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT id, tag, length(big) FROM hi_toast WHERE big = repeat('A', 2000);
+-- HOT-indexed update of the toasted indexed column itself: the old entry is
+-- now stale because the crossed-attribute bitmap shows big changed.
+UPDATE hi_toast SET big = repeat('B', 2000) WHERE id = 1;
+SELECT id FROM hi_toast WHERE big = repeat('A', 2000);   -- stale: 0 rows
+SELECT id, length(big) FROM hi_toast WHERE big = repeat('B', 2000);  -- current
+RESET enable_bitmapscan;
+RESET enable_seqscan;
+SELECT * FROM verify_heapam('hi_toast');
+DROP TABLE hi_toast;
+
+-- ---------------------------------------------------------------------------
+-- 26. ABA on an indexed column.  A HOT-indexed update that sets an indexed
+-- column to a value an earlier chain member already held leaves two leaves
+-- with that same key, both chain-resolving to the live tuple.  A value-based
+-- recheck cannot tell them apart and would return the row twice; the
+-- crossed-attribute bitmap drops the stale ancestor leaf (its walk crosses the
+-- key-changing hops) and keeps only the fresh entry, so a forced index scan
+-- returns the row exactly once.  REINDEX must not change that.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_aba (id int PRIMARY KEY, k int, v int) WITH (fillfactor = 50);
+CREATE INDEX hi_aba_k ON hi_aba (k);
+CREATE INDEX hi_aba_v ON hi_aba (v);
+INSERT INTO hi_aba VALUES (1, 1, 100);
+UPDATE hi_aba SET k = 3 WHERE id = 1;   -- HOT-indexed: k changed, v kept
+UPDATE hi_aba SET k = 1 WHERE id = 1;   -- HOT-indexed: k cycled back (ABA)
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) AS k1_once FROM hi_aba WHERE k = 1;     -- exactly 1
+SELECT count(*) AS k3_gone FROM hi_aba WHERE k = 3;     -- 0 (stale dropped)
+REINDEX INDEX hi_aba_k;
+SELECT count(*) AS k1_after_reindex FROM hi_aba WHERE k = 1;  -- still 1
+RESET enable_bitmapscan;
+RESET enable_seqscan;
+SELECT * FROM verify_heapam('hi_aba');
+DROP TABLE hi_aba;
+
+-- ---------------------------------------------------------------------------
+-- 27. Partial index, predicate column changed but the row STAYS in the index
+-- (predicate still true, key unchanged).  The update is HOT-indexed; selective
+-- maintenance re-inserts a fresh entry (the predicate column changed and still
+-- holds), so the row is still returned -- the bitmap drops the older entry and
+-- the fresh one re-supplies it.  Guards against a "lost row" from over-eager
+-- dropping.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_partstay (id int PRIMARY KEY, k int, n int) WITH (fillfactor = 50);
+CREATE INDEX hi_partstay_k ON hi_partstay (k) WHERE n > 0;
+CREATE INDEX hi_partstay_id2 ON hi_partstay (id);
+INSERT INTO hi_partstay VALUES (1, 5, 3);
+UPDATE hi_partstay SET n = 7 WHERE id = 1;   -- n 3->7, still > 0, k unchanged
+SELECT pg_stat_force_next_flush();
+SELECT hot_idx > 0 AS stay_is_hot_indexed FROM get_hi_count('hi_partstay');
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) AS stay_rows FROM hi_partstay WHERE k = 5 AND n > 0;   -- want 1
+RESET enable_bitmapscan;
+RESET enable_seqscan;
+DROP TABLE hi_partstay;
+
+-- ---------------------------------------------------------------------------
+-- 28. Partitioned table.  A within-partition UPDATE of one indexed column is
+-- HOT-indexed on the leaf partition's heap exactly as for a non-partitioned
+-- table; a cross-partition update is a delete+insert and never HOT.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_part (id int, a int, b int) PARTITION BY RANGE (id);
+CREATE TABLE hi_part1 PARTITION OF hi_part FOR VALUES FROM (0) TO (100)
+    WITH (fillfactor = 50);
+CREATE INDEX hi_part_a ON hi_part (a);
+CREATE INDEX hi_part_b ON hi_part (b);
+INSERT INTO hi_part VALUES (1, 10, 20);
+UPDATE hi_part SET a = 11 WHERE id = 1;   -- one indexed col, within partition
+SELECT pg_stat_force_next_flush();
+SELECT hot_idx > 0 AS part_is_hot_indexed FROM get_hi_count('hi_part1');
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) AS a11 FROM hi_part WHERE a = 11;   -- want 1
+SELECT count(*) AS a10 FROM hi_part WHERE a = 10;   -- want 0 (stale dropped)
+RESET enable_bitmapscan;
+RESET enable_seqscan;
+SELECT * FROM verify_heapam('hi_part1');
+DROP TABLE hi_part;
+
+-- ---------------------------------------------------------------------------
+-- 29. Non-btree access method (hash).  Read-side staleness is access-method
+-- agnostic (the crossed-attribute bitmap), so any index AM's column is
+-- HOT-indexed.  Hash is the sharpest case: its scans recheck the heap value,
+-- which alone cannot disambiguate a value cycled away and back (ABA) -- the
+-- bitmap drops the stale ancestor so the row is returned exactly once.
+-- ---------------------------------------------------------------------------
+CREATE TABLE hi_hash (id int PRIMARY KEY, v int, w int) WITH (fillfactor = 50);
+CREATE INDEX hi_hash_v ON hi_hash USING hash (v);
+CREATE INDEX hi_hash_w ON hi_hash (w);
+INSERT INTO hi_hash VALUES (1, 10, 100);
+UPDATE hi_hash SET v = 99 WHERE id = 1;
+UPDATE hi_hash SET v = 10 WHERE id = 1;   -- ABA: 10 -> 99 -> 10, w unchanged
+SELECT pg_stat_force_next_flush();
+SELECT hot_idx > 0 AS hash_is_hot_indexed FROM get_hi_count('hi_hash');
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) AS hash_v10 FROM hi_hash WHERE v = 10;   -- want 1 (no duplicate)
+SELECT count(*) AS hash_v99 FROM hi_hash WHERE v = 99;   -- want 0 (stale dropped)
+RESET enable_bitmapscan;
+RESET enable_seqscan;
+DROP TABLE hi_hash;
+
+-- ---------------------------------------------------------------------------
+-- 30. DDL after a HOT-indexed chain exists.  The per-hop modified-attrs
+-- bitmap on the page is keyed by physical attribute number and sized by the
+-- relation's natts AT WRITE TIME.  Indexes added/dropped after the chain
+-- forms, and ADD/DROP COLUMN, must not corrupt the read-side staleness test.
+-- The sharp case is ADD COLUMN crossing an 8-attribute boundary, which grows
+-- ceil(natts/8): readers must locate each hop's bitmap from that hop's own
+-- write-time natts (HeapTupleHeaderGetNatts / the stub's stashed natts), not
+-- the relation's current natts.
+-- ---------------------------------------------------------------------------
+-- Exactly 8 attributes (c1..c7 + payload) so adding the 9th flips the bitmap
+-- from 1 byte to 2.  c7 is the column we churn; c2 is an unchanged indexed
+-- column whose leaf must stay current.
+CREATE TABLE hi_ddl (
+    c1 int PRIMARY KEY, c2 int, c3 int, c4 int,
+    c5 int, c6 int, c7 int, payload text
+) WITH (fillfactor = 50);
+CREATE INDEX hi_ddl_c2 ON hi_ddl(c2);
+CREATE INDEX hi_ddl_c7 ON hi_ddl(c7);
+INSERT INTO hi_ddl VALUES (1, 10, 20, 30, 40, 50, 70, 'p');
+
+-- Form a HOT-indexed chain on c7 BEFORE any further DDL.
+UPDATE hi_ddl SET c7 = 71 WHERE c1 = 1;
+UPDATE hi_ddl SET c7 = 72 WHERE c1 = 1;
+
+-- (a) CREATE INDEX after the chain exists: the new index is built against the
+-- live tuple under its own TID, so its entry is never stale.
+CREATE INDEX hi_ddl_c3 ON hi_ddl(c3);
+
+-- (b) ADD COLUMN crossing the 8-attribute boundary (natts 8 -> 9).  Existing
+-- hops keep their 1-byte bitmaps; the relation now wants 2.  Reads through the
+-- old chain must still be correct.
+ALTER TABLE hi_ddl ADD COLUMN c9 int;
+CREATE INDEX hi_ddl_c9 ON hi_ddl(c9);
+
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SET enable_indexonlyscan = off;
+
+-- Live c7 is 72.  The c7 index must return the live row for 72 and drop the
+-- stale leaves for 70 and 71 (offsets misread would corrupt this).
+SELECT count(*) AS c7_eq_72 FROM hi_ddl WHERE c7 = 72 AND payload IS NOT NULL;
+SELECT count(*) AS c7_eq_70_stale FROM hi_ddl WHERE c7 = 70 AND payload IS NOT NULL;
+SELECT count(*) AS c7_eq_71_stale FROM hi_ddl WHERE c7 = 71 AND payload IS NOT NULL;
+
+-- c2 never changed across the chain: its leaf must NOT be judged stale even
+-- though a crossed hop changed c7.  A misread bitmap could spuriously flag it.
+SELECT count(*) AS c2_eq_10_current FROM hi_ddl WHERE c2 = 10 AND payload IS NOT NULL;
+
+-- (c) Continue churning c7 AFTER the ADD COLUMN: the new hop's bitmap is sized
+-- for natts 9 (2 bytes); the old hops are 1 byte.  A chain with mixed-size
+-- bitmaps must still resolve correctly.
+UPDATE hi_ddl SET c7 = 73 WHERE c1 = 1;
+SELECT count(*) AS c7_eq_73 FROM hi_ddl WHERE c7 = 73 AND payload IS NOT NULL;
+SELECT count(*) AS c7_eq_72_now_stale FROM hi_ddl WHERE c7 = 72 AND payload IS NOT NULL;
+
+-- (d) Collapse the chain to stubs via VACUUM, then read again: the stub must
+-- preserve its write-time natts so its bitmap stays locatable post-ADD COLUMN.
+UPDATE hi_ddl SET c7 = 74 WHERE c1 = 1;
+VACUUM (INDEX_CLEANUP off) hi_ddl;
+SELECT count(*) AS c7_eq_74_after_vacuum FROM hi_ddl WHERE c7 = 74 AND payload IS NOT NULL;
+SELECT count(*) AS c2_eq_10_after_vacuum FROM hi_ddl WHERE c2 = 10 AND payload IS NOT NULL;
+
+-- (e) DROP COLUMN keeps the attnum slot (no renumber), so bitmaps stay aligned.
+ALTER TABLE hi_ddl DROP COLUMN c4;
+SELECT count(*) AS c7_after_drop FROM hi_ddl WHERE c7 = 74 AND payload IS NOT NULL;
+SELECT count(*) AS c2_after_drop FROM hi_ddl WHERE c2 = 10 AND payload IS NOT NULL;
+
+-- (f) DROP INDEX on the churned column: remaining indexes still resolve.
+DROP INDEX hi_ddl_c7;
+SELECT count(*) AS c2_after_dropidx FROM hi_ddl WHERE c2 = 10 AND payload IS NOT NULL;
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+RESET enable_indexonlyscan;
+
+-- The seqscan truth confirms the live row; the count assertions above (read
+-- through the post-DDL indexes) match it, which is what would break if a
+-- mis-sized bitmap corrupted the staleness verdict.
+SELECT c1, c2, c7 FROM hi_ddl WHERE c1 = 1;
+
+DROP TABLE hi_ddl;
+
+-- ---------------------------------------------------------------------------
+-- Cleanup
+-- ---------------------------------------------------------------------------
+DROP FUNCTION get_hi_count(text);
+DROP FUNCTION get_hot_count(text);
+DROP EXTENSION pageinspect;
-- 
2.50.1

