From 5ee09728ad27df506b23b658fb654f09a9741f71 Mon Sep 17 00:00:00 2001
From: Greg Burd <greg@burd.me>
Date: Wed, 17 Jun 2026 17:14:44 -0400
Subject: [PATCH v63 03/10] Add the HOT-indexed on-disk format: inline attr
 bitmap and stubs

Define the on-disk representation a HOT-indexed update and its later
prune/collapse produce, ahead of the code that reads or writes it:

- HEAP_INDEXED_UPDATED (htup_details.h), the t_infomask2 bit marking a
  heap-only tuple whose producing UPDATE also changed an indexed column; and
- access/hot_indexed.h, the inline fixed-size modified-attrs bitmap stored in
  the tail of such a tuple, plus the xid-free "collapse-survivor stub" format
  (HEAP_INDEXED_UPDATED with natts == 0, a forward link, and the segment's
  bitmap) and the accessors both share.

README.HOT-INDEXED introduces the design and the relaxed classic-HOT
invariant; later commits document the eligibility, write, read, and
prune/collapse machinery in their own sections.

Co-authored-by: Greg Burd <greg@burd.me>
Co-authored-by: Nathan Bossart <nathandbossart@gmail.com>
---
 src/backend/access/heap/README.HOT-INDEXED | 532 +++++++++++++++++++++
 src/include/access/hot_indexed.h           | 202 ++++++++
 src/include/access/htup_details.h          |   8 +-
 3 files changed, 741 insertions(+), 1 deletion(-)
 create mode 100644 src/backend/access/heap/README.HOT-INDEXED
 create mode 100644 src/include/access/hot_indexed.h

diff --git a/src/backend/access/heap/README.HOT-INDEXED b/src/backend/access/heap/README.HOT-INDEXED
new file mode 100644
index 00000000000..51caacc62f1
--- /dev/null
+++ b/src/backend/access/heap/README.HOT-INDEXED
@@ -0,0 +1,532 @@
+HOT-indexed updates (Selective Index Update, "HOT/SIU")
+=======================================================
+
+Classic HOT (see README.HOT) keeps an UPDATE off the indexes only when no
+indexed column changed: the new tuple is a heap-only tuple appended to the
+chain, and every index entry continues to resolve, via the same-page t_ctid
+chain, to a tuple whose indexed values still equal the entry's key.
+
+HOT-indexed (Selective Index Update, SIU) relaxes that to: an UPDATE may
+change indexed columns and still be a heap-only tuple on the same page,
+provided new index entries are inserted only into the indexes whose key
+attributes actually changed.  The indexes whose attributes did not change are
+left untouched, which is where the write-amplification saving comes from.
+
+The price is that a chain may now contain tuples with different index keys, so
+an index entry for an old key can chain-resolve to a live tuple whose current
+key differs.  Such an entry is STALE.  The read side detects and drops stale
+entries by testing the heap's crossed-attribute bitmap against the index's
+key columns.
+
+This file documents the eligibility rules, the write path, the on-chain
+representation, the read-side staleness test, prune/collapse, vacuum reclamation,
+recovery, the logical-replication apply gating, and the statistics.
+
+
+The classic-HOT invariant we are relaxing
+------------------------------------------
+
+Classic HOT relies on two properties:
+
+  (a) every live index entry resolves, via a same-page t_ctid chain, to the
+      chain's root line pointer; and
+
+  (b) the indexed values of every tuple on the chain equal the key of every
+      index entry that reaches it.
+
+HOT-indexed keeps (a) but breaks (b): after a HOT-indexed update the chain
+holds tuples with different keys, and a new index entry is planted that points
+at the *specific* heap-only tuple whose key it matches -- not necessarily the
+root.
+
+
+The HOT-indexed invariant (the new contract)
+--------------------------------------------
+
+  An index entry points at the heap-only tuple version whose indexed key it
+  matched at insertion time.  A chain walk that reaches a live tuple by
+  crossing a HOT-indexed hop *after* the entry's own target may be stale: if
+  the union of those crossed hops' modified attributes overlaps the index's
+  key columns, the entry is stale and the tuple is dropped from that scan.
+  The row is
+  re-supplied by the fresh entry that the same update inserted for the new
+  key.
+
+This is what makes dropping a stale entry safe: the live row is always
+reachable through exactly one non-stale entry per index.
+
+
+Eligibility: HeapUpdateHotAllowable
+-----------------------------------
+
+The executor computes modified_idx_attrs (the indexed attributes this UPDATE
+changed, attribute numbers offset by FirstLowInvalidHeapAttributeNumber) and
+passes it to heap_update via table_tuple_update.  HeapUpdateHotAllowable
+classifies the update:
+
+  HEAP_UPDATE_ALL_INDEXES
+        HOT is not permitted; the new tuple goes on a fresh TID and every
+        index gets a new entry.
+  HEAP_HEAP_ONLY_UPDATE
+        no non-summarizing indexed attribute changed, so no index needs a
+        new entry (classic HOT).
+  HEAP_SELECTIVE_INDEX_UPDATE
+        at least one non-summarizing index's attribute changed, but the
+        update may stay on the HOT chain and maintain only the changed
+        indexes selectively.
+
+A non-summarizing indexed attribute changing yields HEAP_SELECTIVE_INDEX_UPDATE
+unless one of these forces HEAP_UPDATE_ALL_INDEXES:
+
+  1. The logical-replication apply path, gated per subscription (see "Logical
+     replication" below).
+  2. An UPDATE touching an attribute referenced by an expression index
+     (selective maintenance of expression indexes is not implemented yet).
+  3. An UPDATE that changes *every* indexed attribute: there is no index to
+     skip, so a plain non-HOT update is cheaper.
+
+System catalogs stay classic-HOT only: a catalog UPDATE that changes a
+non-summarizing indexed attribute falls back to HEAP_UPDATE_ALL_INDEXES, because
+catalog reads go through many paths not all proven safe against stale chain
+entries.  This is the pre-HOT-indexed behaviour for such updates.
+
+INDEX_ATTR_BITMAP_INDEXED (cached in rd_indexedattr) is the set of columns
+referenced by non-summarizing indexes plus, folded in, the columns referenced
+only by summarizing indexes, so that a change to a summarizing-only column is
+seen by the modified-attribute comparison (its index is maintained via the
+classic-HOT summarizing path).  Read-side staleness is filtered by the
+crossed-attribute bitmap, which is access-method agnostic, so a change to a
+column covered by any index is HOT-indexed regardless of the index's access
+method.  Summarizing indexes (e.g. BRIN) keep no per-row leaf that can go
+stale and are maintained via the summarizing path.
+
+
+The write path
+--------------
+
+For HEAP_SELECTIVE_INDEX_UPDATE, heap_update:
+
+  - stores the new tuple as a heap-only tuple on the same page, linked into
+    the chain via t_ctid, exactly like classic HOT; and
+  - sets HEAP_INDEXED_UPDATED (t_infomask2 bit 0x0800) on the new tuple to
+    mark that the chain now carries differing keys.
+
+There is no separate on-page meta-item: the bit on the heap-only tuple is the
+entire on-disk footprint.  As for classic HOT, if the new tuple does not fit
+on the page the update falls back to a non-HOT (new-page) update.
+
+The inline modified-attrs bitmap is ceil(natts/8) bytes, sized by the tuple's
+OWN attribute count at write time (HeapTupleHeaderGetNatts), not the relation's
+current natts.  ADD COLUMN raises the relation's natts without rewriting
+existing tuples, so one chain can hold hops whose bitmaps were sized for
+different (smaller) natts; every consumer locates and sizes a hop's bitmap
+from that hop's own write-time natts (HotIndexedTupleBitmapNatts in
+access/hot_indexed.h).  A collapse-survivor stub overwrites natts with its 0
+sentinel, so it preserves its write-time natts in the unused block-number half
+of t_ctid (the offset half is the forward link).  Bit positions are attribute
+based and identical across sizes, so a smaller bitmap simply ORs into the low
+bytes of a larger crossed-attribute accumulator.  DROP COLUMN keeps the attnum
+slot (it never renumbers), so existing bitmaps stay aligned.
+
+After the update, table_tuple_update reports update_all_indexes = false (the
+tuple is heap-only).  The executor then maintains indexes selectively:
+ExecSetIndexUnchanged marks each index whose key attributes did not change as
+unchanged, and ExecInsertIndexTuples inserts a fresh entry only into the
+indexes that did change.  Each such entry points at the new tuple's own TID.
+
+
+The chain and the two kinds of leaf entry
+------------------------------------------
+
+After a HOT-indexed update there are, for a changed index, two kinds of leaf
+entry reaching the chain:
+
+  - the pre-update entry for the OLD key, still pointing at an older chain
+    member (now stale once the walk crosses the HOT-indexed hop); and
+  - the fresh entry for the NEW key, pointing at the new heap-only tuple.
+
+Index build and REINDEX index a live HOT-indexed tuple under its OWN TID (not
+the chain root), so the freshly built entry has no hop after it and is never
+treated as stale.
+
+
+Read-side correctness: the crossed-attribute bitmap
+---------------------------------------------------
+
+heap_hot_search_buffer walks the chain from the entry's target to the live
+visible tuple.  Each hop it crosses after the entry's own target -- a live
+HOT-indexed member, a collapse-survivor stub, or a collapsed (redirected)
+prefix -- contributes that hop's inline modified-attrs bitmap to a running
+union, IndexFetchTableData.xs_hot_indexed_crossed, and sets
+*hot_indexed_recheck to flag that the walk crossed at least one such hop.
+
+The index-access layer (index_fetch_heap) tests that union against the
+arriving index's key columns.  Any overlap means a crossed hop changed one of
+this index's inputs, so the entry's stored key no longer matches the live
+tuple: IndexScanDesc.xs_hot_indexed_stale is set, and IndexScan,
+IndexOnlyScan, CLUSTER, and the logical-replication replica-identity lookups
+drop the tuple.  If the union is disjoint from the index's key columns, none
+of the index's inputs changed across the chain, so the entry is current and
+the row is returned.
+
+The union is complete: every crossed live hop and stub contributes its
+bitmap, and chain collapse only ever reclaims a member whose attributes are a
+subset of the surviving later hops (see "Prune and chain collapse"), so a
+reader crossing the survivors still sees every collapsed hop's attributes.
+Disjointness therefore reliably means the entry is current.
+
+This needs no value comparison and no leaf key, so it serves equality, range,
+and inequality scans uniformly, works for any access method whose columns are
+eligible for HOT-indexed updates, and is correct even when a key is cycled
+away and back (X -> Y -> X): the update that restored the value planted a
+fresh entry pointing at its own live tuple, whose walk crosses no later
+key-changing hop, so that entry uniquely returns the row while the stale
+ancestor entry -- whose walk does cross the changing hops -- is dropped.
+
+The read mechanism never reconstructs or compares an index key, so it needs no
+per-access-method support.  (nbtree keeps an internal leaf-key comparison,
+_bt_heap_keys_equal_leaf, used only by _bt_check_unique to tell a stale chain
+entry from a live duplicate during a unique insert; it is not part of the read
+path.)
+
+Unique checks.  _bt_check_unique fetches the conflicting tuple under
+SnapshotDirty and, when the chain walk crossed a HOT-indexed hop, compares the
+live tuple's current key against the arriving leaf with the index's own
+ordering procedure (_bt_heap_keys_equal_leaf, using BTORDER_PROC under each
+column's collation).  This recheck is reached only for an index receiving a
+fresh entry during a HOT-indexed update; HeapUpdateHotAllowable disqualifies
+any UPDATE that touches an expression-index attribute, so the index here never
+has an expression key column (every key column is a plain attribute), and the
+comparison reads attribute values straight from the heap slot -- no expression
+evaluation or executor state is needed.  Using the opclass comparator -- not a
+bitwise image comparison -- means a key
+that was cycled away and back (X -> Y -> X) does not raise a spurious
+duplicate against its own stale leaf, while a genuinely live duplicate (equal
+under the opclass even if not bitwise-identical, e.g. numeric 1.0 vs 1.00) is
+still detected.  (Appendix A motivates this recheck in detail.)
+
+
+Bitmap scans: the mid-chain TID and BitmapAnd/BitmapOr
+-----------------------------------------------------
+
+The crossed-attribute test above runs on the way to the heap.  A bitmap scan
+has an earlier hazard it cannot reach: BitmapAnd/BitmapOr combine two indexes'
+TID sets in tidbitmap.c at raw block+offset granularity, before either side
+touches the heap.  A HOT-indexed fresh entry points at the mid-chain new tuple
+while an unchanged index's entry for the same row points at the chain root, so
+for "WHERE changed_col = x AND unchanged_col = y" the two bitmap index scans
+contribute different TIDs for the one live row, the exact-mode intersection
+finds nothing in common, and the row is dropped -- a false negative (bitmap
+scans tolerate false positives, not false negatives).
+
+A fresh entry's stored heap TID therefore carries ItemPointerSIUMaybeStaleFlag,
+bit 14 of the offset field (see storage/itemptr.h).  MaxOffsetNumber needs at
+most 14 bits even at the largest configurable BLCKSZ, so the bit is free for
+any real offset; the two reserved offset sentinels (SpecTokenOffsetNumber,
+MovedPartitionsOffsetNumber) already set it as part of their own encoding, so
+ItemPointerGetOffsetNumber's strip is range-gated to leave them intact.  The
+flag is set only on the local TID copy handed to a fresh entry's index_insert
+(ExecInsertIndexTuples), never on the heap tuple's own t_ctid and never on a
+classic-HOT or plain entry.  ItemPointerGetOffsetNumber and ItemPointerCompare
+strip it by default, so the plain index-scan heap lookup, the unique check,
+tuplesort, and the tid opclass all see the real offset; only
+ItemPointerGetOffsetNumberNoCheck exposes the raw value.
+
+tbm_add_tuples -- the single choke point every amgetbitmap funnels exact heap
+TIDs through -- checks the flag and, when set, adds the whole page as lossy
+(tbm_add_page) instead of the single exact offset.  A lossy page survives any
+AND/OR against an exact-mode page (tbm_intersect_page's own case analysis) and
+forces a recheck, so BitmapHeapScan resolves the chain and the crossed-
+attribute test above makes the final call.  Because this lives in
+tbm_add_tuples, no index access method needs any HOT-indexed-specific code:
+btree, hash, GIN, GiST, SP-GiST, contrib/bloom, and out-of-tree AMs are all
+correct, and a TID that never carries the flag takes the identical old path.
+GIN's unrelated page-level lossy sentinel (ItemPointerIsLossyPage) is not
+affected.
+
+The cost is precision, not correctness: a heap page carrying a live fresh
+entry contributes lossy (a whole-page recheck on that bitmap scan) until the
+chain collapses; it is bounded and self-healing via prune/VACUUM.  pageinspect
+1.14's bt_page_items reports the real offset (earlier versions showed the
+marker as an inflated offset) and adds a hot_indexed boolean column.
+
+
+Prune and chain collapse
+-------------------------
+
+Because a HOT-indexed update plants an index entry pointing at a mid-chain
+heap-only tuple's own TID, classic HOT's assumption that mid-chain line
+pointers have no external references no longer holds.  Pruning therefore must
+not reclaim such a line pointer while a not-yet-swept index entry can still
+arrive at it.
+
+heap_prune_chain collapses a run of dead chain members to a single
+LP_REDIRECT that forwards to the first live tuple, and preserves the line
+pointer of a live HOT-indexed member (heap_prune_item_preserves_hot_indexed)
+so a reader arriving via a stale entry still finds a walkable hop.  More than
+one LP_REDIRECT may forward to the same live tuple.  The redirect lifecycle
+reuses the existing prune WAL records; there is no new on-disk format.
+
+
+Vacuum reclamation
+------------------
+
+VACUUM's index cleanup sweeps the stale index entries.  The collapse back to
+classic HOT is driven by prune, not by VACUUM's second pass: once a chain is
+fully dead, a later prune (heap_prune_chain / heap_prune_chain_find_live)
+reclaims its members and re-points the root redirect straight at first_live.
+Re-pointing a redirect preserves reachability (every walker still reaches
+first_live), so it is safe under the exclusive lock prune already holds.
+
+VACUUM's second pass (lazy_vacuum_heap_page) does not itself re-point
+redirects or reclaim stubs; it performs the usual LP_DEAD -> LP_UNUSED
+conversion and leaves the HOT-indexed collapse to prune.
+
+A page that still carries a preserved HOT-indexed member or a collapse-survivor
+stub is deliberately left non-all-visible, so that an index-only scan
+heap-fetches through the chain and the crossed-attribute bitmap can filter
+stale entries (enforced in heap_prune_record_redirect, the stub recorders, and
+heap_page_would_be_all_visible).
+
+
+amcheck and statistics
+----------------------
+
+verify_heapam treats the HOT-indexed artifacts as legitimate: a live
+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.
+
+
+Logical replication
+-------------------
+
+A HOT-indexed update of a replica-identity attribute on a subscriber leaves a
+stale index leaf; the apply worker's replica-identity lookups tolerate that
+only when the indexed attributes are covered by the replica identity.  The
+per-subscription hot_indexed_on_apply option (pg_subscription.subhotindexedonapply,
+off / subset_only / always; subset_only is the default) controls this:
+HeapUpdateHotAllowable consults GetHotIndexedApplyMode on the apply path and
+falls back to non-HOT when the indexed attributes are not exactly the primary
+key (off) or not a subset of it (subset_only).
+
+
+Recovery
+--------
+
+HOT-indexed updates and the prune/collapse use the existing heap UPDATE and
+prune/freeze WAL records, so crash recovery replays them with no new record
+types.  src/test/recovery/t/054_hot_indexed_recovery.pl builds a chain,
+crashes without a checkpoint (forcing WAL redo), and verifies the chain walk,
+verify_heapam, and vacuum reclamation after restart, with
+wal_consistency_checking = 'all' comparing each replayed page to its FPI.
+
+
+Adversarial tests
+-----------------
+
+src/test/isolation/specs/hot_indexed_adversarial.spec exercises the cases the
+invariant must satisfy under concurrency: key cycling (X->Y->X), aborted
+HOT-indexed updates, concurrent unique inserts against a freed/taken key,
+snapshot-safe reclaim of stale leaves, and reader consistency across a
+concurrent prune/collapse.
+
+
+Appendices
+----------
+
+Appendix A: Why the unique-check path needs a value comparison at all
+---------------------------------------------------------------------
+
+This is the one place HOT-indexed does compare a key value, even though the
+read path deliberately avoids one.  The rest of this appendix explains why the
+comparison is needed, why it must use the opclass comparator rather than a
+bitwise one, and why the ABA case is what forces the issue.
+
+1. The setup: why a unique insert can even reach a stale leaf
+
+Under classic HOT, an index has exactly one leaf entry per logical row, and
+every leaf entry's key matches the live tuple it chain-resolves to.  So
+_bt_check_unique can trust: "if I find a leaf whose key equals my new key, and
+it resolves to a live tuple, that's a genuine duplicate."
+
+HOT/SIU breaks that one-to-one correspondence.  A HOT-indexed UPDATE that
+changes column a from X to Y:
+ - inserts a fresh leaf entry (Y -> new tuple) into idx_a, and
+ - leaves the old leaf entry (X -> old chain root) in place.
+
+That old (X -> root) entry is now stale: it still chain-resolves to a live
+tuple, but that live tuple's current a is Y, not X.  The read path handles this
+with the crossed-attribute bitmap (no value comparison needed): if the walk
+from the entry's target to the live tuple crosses a hop that changed a, the
+entry is stale and dropped.
+
+When you now INSERT a row with a = X, _bt_check_unique scans idx_a for key X
+and finds that stale (X -> root) leaf.  It must decide: is this a real
+conflict?
+
+2. Why the read-path bitmap is not sufficient here
+
+The read path's logic is: "this entry crossed a hop that changed a => stale =>
+drop it, the fresh entry will supply the row."  For scans that's correct and
+complete, because every live row has exactly one non-stale entry that
+re-supplies it.
+
+But a unique check is asking a different question.  It is not "should I return
+this row?" -- it is "does the live tuple this entry resolves to conflict with
+the key I'm inserting?"  The bitmap can only tell you "an indexed attribute
+changed somewhere on the chain."  It cannot tell you what the live value is
+now, and that is exactly what you need to know to detect a duplicate.
+
+This is the crux of the ABA problem.  Consider:
+
+  INSERT (a=10)                  LP[1] a=10   (root)
+  UPDATE a=11   (HOT-indexed)    LP[2] a=11   bitmap {a}, leaf (11)->LP[2]
+  UPDATE a=10   (HOT-indexed)    LP[3] a=10   bitmap {a}, leaf (10)->LP[3], live
+
+idx_a now has leaves (10)->LP[1] [stale ancestor], (11)->LP[2] [stale], and
+(10)->LP[3] [fresh, live].
+
+Now INSERT (a=10), a genuine duplicate of the live row.  _bt_check_unique scans
+for key 10 and finds the (10)->LP[1] stale ancestor entry.  The chain walk from
+LP[1] to the live tuple LP[3] crosses hops that changed a (10->11, then
+11->10), so the bitmap says "stale."  If the unique check trusted the bitmap
+alone it would skip (10)->LP[1] as stale and miss the real duplicate.  The
+bitmap is fooled because a changed (so the bit is set) even though it changed
+back to the same value: "an attribute changed on the chain" is not "the live
+value differs from this leaf's key."  Under ABA they diverge.
+
+The sharper case is concurrency.  While the restoring UPDATE (a: 11 -> 10) is
+in flight, it has written its new heap tuple but not yet inserted the fresh
+(10)->LP[3] leaf.  A concurrent INSERT (a=10) running its _bt_check_unique scan
+in that window sees only the stale (10)->LP[1] ancestor.  The value recheck
+below makes that hit resolve to xwait on the in-flight updater (via
+_bt_doinsert's wait-and-recheck), so the inserter re-checks after the updater
+commits and finds the conflict.  A bitmap-only verdict would skip the ancestor
+before reaching the xwait logic and admit a duplicate -- which is why the
+recheck is a correctness requirement, not merely an optimization.
+
+3. Why a value comparison fixes it, and why it must be the opclass comparator
+
+So the unique path needs to look at the actual live value, not just "did
+something change."  _bt_check_unique fetches the conflicting tuple under
+SnapshotDirty and, when hi_recheck says a HOT-indexed hop was crossed, calls
+_bt_heap_keys_equal_leaf to compare the live tuple's current key against the
+arriving leaf's stored key:
+
+ - live key equals the leaf key -> genuine duplicate (or an in-flight conflict
+   reached as xwait) -- correct: ABA back to X is a real conflict with a new X.
+ - live key differs -> the leaf is truly stale -> skip it (the fresh entry
+   handles the real row).
+
+Which equality?  Two candidates:
+
+Bitwise/image comparison (datum_image_eq) compares raw bytes.  That is wrong
+for unique checking in the dangerous direction.  Uniqueness in PostgreSQL is
+defined by the index opclass's equality operator, not byte identity, and many
+types have values equal under the opclass but byte-distinct:
+ - numeric: 1.0 and 1.00 are opclass-equal, different on-disk bytes.
+ - float8: -0.0 and +0.0 are equal, different bit patterns.
+ - text/citext under a nondeterministic collation: canonically-equivalent
+   strings that are not byte-identical.
+
+A bitwise comparison would conclude "not equal => stale => skip" for a live
+1.00 versus an inserted 1.0 and miss a genuine violation -- a correctness hole
+as bad as the ABA one.
+
+So _bt_heap_keys_equal_leaf uses the index's own BTORDER_PROC (btree support
+function 1) under each key column's collation, the same machinery _bt_compare
+and _bt_mkscankey use to define equality for the index.  A zero result means
+"equal as the index defines equality," which is precisely the unique-violation
+condition, and the verdict agrees with the index's own notion of uniqueness in
+both directions.
+
+4. Why no expression evaluation is needed
+
+_bt_heap_keys_equal_leaf reads each key column straight from the heap slot
+(slot_getattr) and compares it to the leaf datum; it does not evaluate indexed
+expressions and needs no executor state.  That is sufficient because the
+recheck is only ever reached for an index receiving a fresh entry during a
+HOT-indexed update, and HeapUpdateHotAllowable disqualifies any UPDATE that
+touches an attribute referenced by an expression index
+(INDEX_ATTR_BITMAP_EXPRESSION captures every such attribute).  So a HOT-indexed
+chain never has a crossed hop affecting an expression index, the index reaching
+the recheck never has an expression key column (every indkey is a real
+attribute number), and there is nothing to evaluate.  If selective maintenance
+of expression indexes is implemented in the future, this is where an
+expression-evaluating comparison (e.g. FormIndexDatum) would be reintroduced.
+
+5. Why the asymmetry (bitmap on read, value recheck on unique) is intentional
+
+It looks like two different answers to the same question, but the questions
+differ:
+
+ - Read/scan path: "should this row be returned?"  A stale entry is redundant
+   (the fresh entry supplies the row), so the conservative bitmap verdict is
+   sufficient -- worst case under ABA you drop a redundant entry and the fresh
+   one still returns the row.  No value comparison, so reads stay
+   access-method-agnostic and cheap.
+ - Unique-check path: "is this a conflict?"  A wrong "stale" verdict here does
+   not just drop a redundant entry; it silently admits a duplicate, corrupting
+   the constraint.  It cannot tolerate the bitmap's false "stale" under ABA and
+   must consult the live value (or wait on an in-flight updater) via the
+   opclass comparator.
+
+The bitmap is a filter (a necessary condition: "could be stale"); the opclass
+recheck is the authority (the sufficient condition: "is the live key actually
+different, or is a conflicting update in flight").  The unique path layers the
+authority on top of the filter precisely because its error mode is
+unforgiving.
+
+In one sentence: the unique check compares the live tuple's current key to the
+arriving leaf with the index's own equality (not bytes) because the
+crossed-attribute bitmap can only say "something changed" -- true under an
+X->Y->X cycle even though the value is back to X -- and only an opclass-correct
+value comparison (which also routes an in-flight restoring update to xwait) can
+both recognize the cycled-back value as a genuine duplicate and catch
+duplicates that are opclass-equal but not byte-identical, either of which a
+bitmap or a bitwise comparison would get wrong.
+
+6. Why the executor's modified-attrs comparison uses datum_image_eq while the
+   heap side uses datumIsEqual
+
+The "modified indexed attributes" set (which drives the HOT vs SIU vs
+update-all-indexes decision) is computed on two different paths that are meant
+to be *functionally* equivalent -- they answer the same question, "which
+indexed attributes changed value?" -- but deliberately use different Datum
+comparators:
+
+ - Executor path: ExecUpdateModifiedIdxAttrs -> ExecCompareSlotAttrs compares
+   two TupleTableSlots with datum_image_eq.
+ - Non-executor path: simple_heap_update -> HeapUpdateModifiedIdxAttrs ->
+   heap_attr_equals compares two HeapTuples' Datums with datumIsEqual.
+
+The difference is intentional and correct, for three reasons:
+
+ - No TOAST at the executor stage.  ExecCompareSlotAttrs runs on slot Datums
+   that are the logical, in-memory values of the new/old tuple, before any
+   table AM's storage path has run.  TOAST (compression/externalization) is a
+   heapam storage detail applied later, inside the table AM, so the "two
+   physically different varlena encodings of the same logical value" case that
+   would make datum_image_eq and datumIsEqual disagree cannot arise here.
+ - AM-agnosticism.  The executor must not assume how any table AM stores
+   variable-length data.  datum_image_eq is the storage-neutral "are these two
+   logical values image-identical" test for slot Datums; datumIsEqual encodes a
+   byte-layout assumption that belongs on the heap side, not in the executor.
+ - Cost.  datumIsEqual on the slot path would force a slot->Datum transform
+   with heap semantics on a hot path, per attribute per updated row, for no
+   benefit.
+
+Either comparator errs only toward reporting a value "changed" when it might not
+have (the safe direction here: a redundant fresh index entry is tolerated by
+the SIU read path, a skipped one is not), so the two paths reach the same
+HOT/SIU decision for the same value transition.  This mirrors the read-vs-
+unique asymmetry above: use the cheapest correct comparison the layer's
+guarantees allow, no more.  HeapUpdateModifiedIdxAttrs/heap_attr_equals exist
+only for simple_heap_update()'s benefit (catalog and other non-executor
+updates) and are targeted for eventual removal once catalog updates track their
+own changes; nothing new should depend on them as public API.
diff --git a/src/include/access/hot_indexed.h b/src/include/access/hot_indexed.h
new file mode 100644
index 00000000000..a5378531e91
--- /dev/null
+++ b/src/include/access/hot_indexed.h
@@ -0,0 +1,202 @@
+/*-------------------------------------------------------------------------
+ *
+ * hot_indexed.h
+ *	  Inline-trailing modified-attributes bitmap for HOT-indexed (HOT/SIU)
+ *	  tuples.
+ *
+ * A heap tuple produced by a HOT-indexed UPDATE has HEAP_INDEXED_UPDATED set
+ * in t_infomask2 and carries, appended after its normal attribute data, a
+ * fixed-size bitmap recording which heap attributes changed at this chain hop
+ * (relative to the prior chain member).  Bit (attnum - 1) corresponds to user
+ * attribute attnum.
+ *
+ * The bitmap is ceil(natts / 8) bytes, where natts is the tuple's own
+ * attribute count at the time it was written (HeapTupleHeaderGetNatts for a
+ * live tuple; preserved separately for a stub, see below).  Its length is not
+ * stored as such; it occupies the final HotIndexedBitmapBytes(natts) bytes of
+ * the line pointer's item and is located via ItemIdGetLength(), past the
+ * attribute data: routines that deform the tuple stop at natts and never see
+ * it.
+ *
+ * Because ADD COLUMN raises the relation's natts without rewriting existing
+ * tuples, a chain can hold tuples whose bitmaps were sized for different
+ * (smaller) natts than the relation has now.  Consumers therefore size and
+ * locate a tuple's bitmap from that tuple's OWN write-time natts
+ * (HotIndexedTupleBitmapNatts), never from the relation's current natts.
+ * Bit positions are attribute based and identical across sizes, so a smaller
+ * bitmap simply ORs into the low bytes of a larger accumulator.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/access/hot_indexed.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef HOT_INDEXED_H
+#define HOT_INDEXED_H
+
+#include "access/htup_details.h"
+
+/*
+ * Number of bytes in the trailing modified-attrs bitmap for a relation with
+ * natts user attributes (one bit per attribute, attnum - 1).
+ */
+static inline Size
+HotIndexedBitmapBytes(int natts)
+{
+	Assert(natts >= 0);
+	return (Size) ((natts + 7) / 8);
+}
+
+/*
+ * Read-only pointer to the trailing modified-attrs bitmap of a HOT-indexed
+ * tuple.  item_len is ItemIdGetLength() of the tuple's line pointer; natts is
+ * the tuple's OWN write-time attribute count (HotIndexedTupleBitmapNatts()),
+ * NOT the relation's current natts -- see the invariant at the top of this
+ * file: after ADD COLUMN a chain can hold tuples whose bitmaps were sized for
+ * a smaller natts, and locating the bitmap from the relation's current natts
+ * would read past the tuple's data.  The caller must have verified that the
+ * tuple has HEAP_INDEXED_UPDATED set.
+ */
+static inline const uint8 *
+HotIndexedGetModifiedBitmap(const HeapTupleHeaderData *htup,
+							Size item_len, int natts)
+{
+	return (const uint8 *) ((const char *) htup +
+							item_len - HotIndexedBitmapBytes(natts));
+}
+
+/* Writable variant, for OR-ing the union onto a surviving redirect target. */
+static inline uint8 *
+HotIndexedGetModifiedBitmapRW(HeapTupleHeaderData *htup,
+							  Size item_len, int natts)
+{
+	return (uint8 *) ((char *) htup +
+					  item_len - HotIndexedBitmapBytes(natts));
+}
+
+/* True if user attribute attnum (1-based) is set in the bitmap. */
+static inline bool
+HotIndexedAttrIsModified(const uint8 *bitmap, AttrNumber attnum)
+{
+	int			bit = attnum - 1;
+
+	Assert(attnum >= 1);
+	return (bitmap[bit / 8] & (1 << (bit % 8))) != 0;
+}
+
+/* Set user attribute attnum (1-based) in the bitmap. */
+static inline void
+HotIndexedSetAttrModified(uint8 *bitmap, AttrNumber attnum)
+{
+	int			bit = attnum - 1;
+
+	Assert(attnum >= 1);
+	bitmap[bit / 8] |= (uint8) (1 << (bit % 8));
+}
+
+/* OR the first nbytes of src into dst.  dst must be at least nbytes long; it
+ * may be longer (sized for a larger natts) -- bit positions are attribute
+ * based and identical across sizes, so OR-ing only src's bytes is correct. */
+static inline void
+HotIndexedBitmapUnion(uint8 *dst, const uint8 *src, int src_natts)
+{
+	Size		nbytes = HotIndexedBitmapBytes(src_natts);
+
+	for (Size i = 0; i < nbytes; i++)
+		dst[i] |= src[i];
+}
+
+/*
+ * Is every bit set in sub also set in super?  sub is sized for sub_natts;
+ * super must be at least that long.  Used by prune to decide a collapse
+ * survivor is reclaimable: when the attributes a dead member changed are all
+ * changed again by later hops, every index entry pointing at that member is
+ * superseded (stale), so it carries no live entry.
+ */
+static inline bool
+HotIndexedBitmapIsSubset(const uint8 *sub, const uint8 *super, int sub_natts)
+{
+	Size		nbytes = HotIndexedBitmapBytes(sub_natts);
+
+	for (Size i = 0; i < nbytes; i++)
+		if ((sub[i] & ~super[i]) != 0)
+			return false;
+	return true;
+}
+
+/*
+ * Stub line pointers (collapse survivors).
+ *
+ * When prune collapses a dead HOT-indexed chain it cannot keep the dead key
+ * tuples as live data-bearing tuples (their XIDs would hold back
+ * relfrozenxid).  Each preserved dead key tuple is instead converted to an
+ * xid-free "stub": an LP_NORMAL item whose HeapTupleHeader is frozen and
+ * marked not-a-real-tuple (HEAP_INDEXED_UPDATED set, HeapTupleHeaderGetNatts
+ * == 0), whose t_ctid.offnum forwards to the next key tuple on the page, and
+ * whose payload is the modified-attrs bitmap for the segment it represents --
+ * located, like a live tuple's inline bitmap, in the final
+ * HotIndexedBitmapBytes(natts) bytes of the item, so the same accessors work
+ * for both.  Because the natts field is overwritten with the 0 sentinel, the
+ * stub's write-time natts (needed to size/locate that bitmap) is preserved in
+ * the otherwise-unused block-number half of t_ctid; HotIndexedStubGetForward
+ * reads only the offset half.
+ *
+ * A stub is signature-skipped (not visibility-skipped) by every consumer that
+ * walks LP_NORMAL items.
+ */
+static inline bool
+HotIndexedHeaderIsStub(const HeapTupleHeaderData *tup)
+{
+	return (tup->t_infomask2 & HEAP_INDEXED_UPDATED) != 0 &&
+		HeapTupleHeaderGetNatts(tup) == 0;
+}
+
+/* Offset of the next key tuple this stub forwards to (same page). */
+static inline OffsetNumber
+HotIndexedStubGetForward(const HeapTupleHeaderData *tup)
+{
+	return ItemPointerGetOffsetNumberNoCheck(&tup->t_ctid);
+}
+
+/*
+ * Bitmap sizing across the relation's lifetime.
+ *
+ * The trailing bitmap is ceil(natts / 8) bytes, where natts is the relation's
+ * attribute count *at the time the tuple was written*.  ADD COLUMN raises the
+ * relation's natts without rewriting existing tuples, so a chain can hold
+ * tuples whose bitmaps were sized for different (smaller) natts than the
+ * relation has now.  Every consumer must therefore size and locate a tuple's
+ * bitmap from that tuple's own write-time natts, never from the relation's
+ * current natts.
+ *
+ * For a live HOT-indexed tuple the write-time natts is HeapTupleHeaderGetNatts
+ * (a freshly formed UPDATE tuple stores the full relation natts, which is what
+ * heap_form_hot_indexed_tuple sized the bitmap with).  A stub overwrites natts
+ * with 0 as its sentinel, so it preserves its write-time natts in the unused
+ * block-number half of t_ctid instead (the offset half holds the forward
+ * link).
+ */
+static inline void
+HotIndexedStubSetBitmapNatts(HeapTupleHeaderData *tup, int natts)
+{
+	BlockIdSet(&tup->t_ctid.ip_blkid, (BlockNumber) natts);
+}
+
+static inline int
+HotIndexedStubGetBitmapNatts(const HeapTupleHeaderData *tup)
+{
+	return (int) BlockIdGetBlockNumber(&tup->t_ctid.ip_blkid);
+}
+
+/* Write-time natts of any HOT-indexed item (live tuple or stub). */
+static inline int
+HotIndexedTupleBitmapNatts(const HeapTupleHeaderData *tup)
+{
+	if (HotIndexedHeaderIsStub(tup))
+		return HotIndexedStubGetBitmapNatts(tup);
+	return HeapTupleHeaderGetNatts(tup);
+}
+
+#endif							/* HOT_INDEXED_H */
diff --git a/src/include/access/htup_details.h b/src/include/access/htup_details.h
index 77a6c48fd71..7eb7f86f5ed 100644
--- a/src/include/access/htup_details.h
+++ b/src/include/access/htup_details.h
@@ -289,7 +289,13 @@ HEAP_XMAX_IS_KEYSHR_LOCKED(uint16 infomask)
  * information stored in t_infomask2:
  */
 #define HEAP_NATTS_MASK			0x07FF	/* 11 bits for number of attributes */
-/* bits 0x1800 are available */
+#define HEAP_INDEXED_UPDATED	0x0800	/* HOT tuple produced by an UPDATE
+										 * that also changed an indexed
+										 * attribute (HOT/SIU); index scans
+										 * that reach it via a chain recheck
+										 * the arriving leaf key against the
+										 * live tuple. */
+/* bit 0x1000 is available */
 #define HEAP_KEYS_UPDATED		0x2000	/* tuple was updated and key cols
 										 * modified, or tuple deleted */
 #define HEAP_HOT_UPDATED		0x4000	/* tuple was HOT-updated */
-- 
2.50.1

