v50-0007-Add-HOT-indexed-statistics-and-the-comprehensive.patch
text/x-patch
Filename: v50-0007-Add-HOT-indexed-statistics-and-the-comprehensive.patch
Type: text/x-patch
Part: 6
Patch
Format: format-patch
Series: patch v50-0007
Subject: Add HOT-indexed statistics and the comprehensive test suite
| File | + | − |
|---|---|---|
| contrib/pg_surgery/expected/heap_surgery.out | 83 | 0 |
| contrib/pg_surgery/heap_surgery.c | 16 | 0 |
| contrib/pg_surgery/Makefile | 1 | 0 |
| contrib/pg_surgery/sql/heap_surgery.sql | 53 | 0 |
| doc/src/sgml/monitoring.sgml | 57 | 0 |
| src/backend/access/heap/hot_indexed_stats.c | 186 | 0 |
| src/backend/access/heap/Makefile | 1 | 0 |
| src/backend/access/heap/meson.build | 1 | 0 |
| src/backend/access/heap/README.HOT-INDEXED | 3 | 0 |
| src/backend/catalog/system_views.sql | 4 | 0 |
| src/backend/utils/adt/pgstatfuncs.c | 12 | 0 |
| src/include/catalog/pg_proc.dat | 28 | 0 |
| src/test/isolation/expected/hot_indexed_adversarial.out | 139 | 0 |
| src/test/isolation/isolation_schedule | 1 | 0 |
| src/test/isolation/specs/hot_indexed_adversarial.spec | 123 | 0 |
| src/test/recovery/Makefile | 2 | 1 |
| src/test/recovery/meson.build | 1 | 0 |
| src/test/recovery/t/055_hot_indexed_recovery.pl | 149 | 0 |
| src/test/regress/expected/hot_indexed_updates.out | 1706 | 0 |
| src/test/regress/expected/rules.out | 12 | 0 |
| src/test/regress/parallel_schedule | 1 | 0 |
| src/test/regress/sql/hot_indexed_updates.sql | 1154 | 0 |
From 3c9ff7c3f358dcfc6aa3209bcde8e0dbf500a4ac Mon Sep 17 00:00:00 2001
From: Greg Burd <greg@burd.me>
Date: Wed, 17 Jun 2026 21:38:11 -0400
Subject: [PATCH v50 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 | 83 +
contrib/pg_surgery/heap_surgery.c | 16 +
contrib/pg_surgery/sql/heap_surgery.sql | 53 +
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 +++++++++++
22 files changed, 3733 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..1a5fef5b2bb 100644
--- a/contrib/pg_surgery/expected/heap_surgery.out
+++ b/contrib/pg_surgery/expected/heap_surgery.out
@@ -175,6 +175,89 @@ 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;
+-- A collapse that keeps a *stub* (an xid-free forwarding LP_NORMAL item with
+-- natts == 0), not just redirects: update two different indexed columns so the
+-- first dead member's changed-attr bitmap is not subsumed by later hops and is
+-- preserved as a stub. pg_surgery must skip such a stub -- forcing a
+-- freeze/kill would overwrite its t_ctid forward link and corrupt the chain.
+create table hstub (id int primary key, a int, b int) with (fillfactor = 50);
+create index hstub_a on hstub(a);
+create index hstub_b on hstub(b);
+insert into hstub values (1, 10, 100);
+update hstub set a = 11 where id = 1; -- changes a
+update hstub set a = 12 where id = 1; -- changes a again (supersedes)
+update hstub set b = 101 where id = 1; -- changes b -> first hop's {a} kept as stub
+vacuum (index_cleanup off) hstub;
+-- Locate the stub: an LP_NORMAL item carrying HEAP_INDEXED_UPDATED (0x0800 in
+-- t_infomask2) with zero live attributes (t_infomask2 natts bits == 0).
+select lp as stub_off
+ from heap_page_items(get_raw_page('hstub', 0))
+ where lp_flags = 1 -- LP_NORMAL
+ and (t_infomask2 & 2048) <> 0 -- HEAP_INDEXED_UPDATED
+ and (t_infomask2 & 2047) = 0 -- natts == 0 (stub sentinel)
+ \gset
+-- Force kill/freeze on the stub's tid: both must be refused with a NOTICE.
+select heap_force_kill('hstub'::regclass, ARRAY[('(0,' || :'stub_off' || ')')]::tid[]);
+NOTICE: skipping tid (0, 3) for relation "hstub" because it is a HOT-indexed collapse stub
+ heap_force_kill
+-----------------
+
+(1 row)
+
+select heap_force_freeze('hstub'::regclass, ARRAY[('(0,' || :'stub_off' || ')')]::tid[]);
+NOTICE: skipping tid (0, 3) for relation "hstub" because it is a HOT-indexed collapse stub
+ heap_force_freeze
+-------------------
+
+(1 row)
+
+-- The chain is untouched: the live row is still reachable through each index.
+set enable_seqscan = off;
+set enable_bitmapscan = off;
+select id, a, b from hstub where a = 12;
+ id | a | b
+----+----+-----
+ 1 | 12 | 101
+(1 row)
+
+select id, a, b from hstub where b = 101;
+ id | a | b
+----+----+-----
+ 1 | 12 | 101
+(1 row)
+
+reset enable_bitmapscan;
+reset enable_seqscan;
+drop table hstub;
+drop extension pageinspect;
-- cleanup.
drop view vw;
drop extension pg_surgery;
diff --git a/contrib/pg_surgery/heap_surgery.c b/contrib/pg_surgery/heap_surgery.c
index b8ce1095782..7fc653cc334 100644
--- a/contrib/pg_surgery/heap_surgery.c
+++ b/contrib/pg_surgery/heap_surgery.c
@@ -13,6 +13,7 @@
#include "postgres.h"
#include "access/htup_details.h"
+#include "access/hot_indexed.h"
#include "access/relation.h"
#include "access/visibilitymap.h"
#include "access/xloginsert.h"
@@ -226,6 +227,21 @@ heap_force_common(FunctionCallInfo fcinfo, HeapTupleForceOption heap_force_opt)
blkno, offno, RelationGetRelationName(rel))));
continue;
}
+ else if (HotIndexedHeaderIsStub((HeapTupleHeader) PageGetItem(page, itemid)))
+ {
+ /*
+ * A HOT-indexed collapse-survivor stub is an xid-free
+ * forwarding node, not a real tuple: its t_ctid carries the
+ * chain forward link and the write-time natts, and it has no
+ * attribute data. Forcing a kill or freeze would overwrite
+ * t_ctid and clear its xact bits, breaking the chain walk and
+ * corrupting the heap. Skip it, as we do for redirects.
+ */
+ ereport(NOTICE,
+ (errmsg("skipping tid (%u, %u) for relation \"%s\" because it is a HOT-indexed collapse stub",
+ blkno, offno, RelationGetRelationName(rel))));
+ continue;
+ }
/* Mark it for processing. */
Assert(offno <= MaxHeapTuplesPerPage);
diff --git a/contrib/pg_surgery/sql/heap_surgery.sql b/contrib/pg_surgery/sql/heap_surgery.sql
index 6526b27535d..be4fed2af65 100644
--- a/contrib/pg_surgery/sql/heap_surgery.sql
+++ b/contrib/pg_surgery/sql/heap_surgery.sql
@@ -83,6 +83,59 @@ 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;
+
+-- A collapse that keeps a *stub* (an xid-free forwarding LP_NORMAL item with
+-- natts == 0), not just redirects: update two different indexed columns so the
+-- first dead member's changed-attr bitmap is not subsumed by later hops and is
+-- preserved as a stub. pg_surgery must skip such a stub -- forcing a
+-- freeze/kill would overwrite its t_ctid forward link and corrupt the chain.
+create table hstub (id int primary key, a int, b int) with (fillfactor = 50);
+create index hstub_a on hstub(a);
+create index hstub_b on hstub(b);
+insert into hstub values (1, 10, 100);
+update hstub set a = 11 where id = 1; -- changes a
+update hstub set a = 12 where id = 1; -- changes a again (supersedes)
+update hstub set b = 101 where id = 1; -- changes b -> first hop's {a} kept as stub
+vacuum (index_cleanup off) hstub;
+-- Locate the stub: an LP_NORMAL item carrying HEAP_INDEXED_UPDATED (0x0800 in
+-- t_infomask2) with zero live attributes (t_infomask2 natts bits == 0).
+select lp as stub_off
+ from heap_page_items(get_raw_page('hstub', 0))
+ where lp_flags = 1 -- LP_NORMAL
+ and (t_infomask2 & 2048) <> 0 -- HEAP_INDEXED_UPDATED
+ and (t_infomask2 & 2047) = 0 -- natts == 0 (stub sentinel)
+ \gset
+-- Force kill/freeze on the stub's tid: both must be refused with a NOTICE.
+select heap_force_kill('hstub'::regclass, ARRAY[('(0,' || :'stub_off' || ')')]::tid[]);
+select heap_force_freeze('hstub'::regclass, ARRAY[('(0,' || :'stub_off' || ')')]::tid[]);
+-- The chain is untouched: the live row is still reachable through each index.
+set enable_seqscan = off;
+set enable_bitmapscan = off;
+select id, a, b from hstub where a = 12;
+select id, a, b from hstub where b = 101;
+reset enable_bitmapscan;
+reset enable_seqscan;
+drop table hstub;
+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 12b9ee20d4a..f34aa648b59 100644
--- a/doc/src/sgml/monitoring.sgml
+++ b/doc/src/sgml/monitoring.sgml
@@ -4457,6 +4457,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>
@@ -4927,6 +4940,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>
@@ -5608,6 +5642,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 6c1c5545cb5..02c8a049a32 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 b5f27b0463c..629e0f52db2 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 3cb84359adf..175fa88eb57 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',
@@ -6179,6 +6202,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..1d82298f5cc
--- /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_HEAP_ONLY_UPDATE 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 e266f501d87..5b2cefeb597 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,
@@ -2332,6 +2335,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));
@@ -2348,6 +2353,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,
@@ -2387,6 +2393,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));
@@ -2403,6 +2411,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,
@@ -2458,6 +2467,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)))
@@ -2475,6 +2485,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));
@@ -2498,6 +2509,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..083eeb9fc07
--- /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_HEAP_ONLY_UPDATE 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