From 3dc2babca0ebc73eb0e78af6143b2f4adff0f60f Mon Sep 17 00:00:00 2001
From: Greg Burd <greg@burd.me>
Date: Fri, 16 Oct 2026 12:13:15 -0400
Subject: [PATCH v39 5/8] Add amcomparedatums index AM callback for HOT updates

An UPDATE that changes a column an index stores directly, as a key or
included column, always gets new index entries, even when the index would
store exactly what it already does.  Only the index's access method can
tell: a hash index stores a hash code, a GIN index the keys its operator
class extracts, a btree index the value, detoasted.  Index access methods
for documents such as RUM store keys for a few paths of a document chosen
by operator class options, so to core every change to the document looks
like a change to the index.

Add an optional callback,

    bool amcomparedatums(Relation index, int attnum, Oid typid,
                         Datum oldValue, Datum newValue);

which says whether the new, non-null value of an index column gives
exactly the same index entries as the old one.  ExecUpdateModifiedIdxAttrs
already narrows the set of changed indexed attributes for those used only
through expressions or partial-index predicates, by forming each such
index's datums for the old and new row and comparing them byte for byte.
Treat a column that indexes also store directly the same way, if all of
those indexes have the callback, and ask the callback whenever an index
datum differs byte for byte.  The new relcache bitmap
INDEX_ATTR_BITMAP_COMPARABLE names those columns.

The columns of candidate keys and of the replica identity are left out:
the table AM takes its tuple lock strength and what it logs for logical
decoding from the changed set, and those stay decided by value.  So are the
indexes of exclusion constraints: the constraint is checked on the values
when an entry is inserted, and two values that give the same entries need
not have the same conflicts.

No core access method implements the callback yet, so nothing changes
until one does.  dummy_index_am gets one, configured by its options, to
test the executor side.
---
 contrib/bloom/blutils.c                       |   1 +
 doc/src/sgml/indexam.sgml                     |  46 ++++++++
 doc/src/sgml/storage.sgml                     |   8 +-
 src/backend/access/brin/brin.c                |   1 +
 src/backend/access/gin/ginutil.c              |   1 +
 src/backend/access/gist/gist.c                |   1 +
 src/backend/access/hash/hash.c                |   1 +
 src/backend/access/heap/README.HOT            |  16 ++-
 src/backend/access/nbtree/nbtree.c            |   1 +
 src/backend/access/spgist/spgutils.c          |   1 +
 src/backend/executor/nodeModifyTable.c        |  86 ++++++++++----
 src/backend/utils/cache/relcache.c            |  42 +++++++
 src/include/access/amapi.h                    |   9 ++
 src/include/nodes/execnodes.h                 |   6 +-
 src/include/utils/rel.h                       |   2 +
 src/include/utils/relcache.h                  |   1 +
 src/test/modules/dummy_index_am/Makefile      |   2 +-
 .../modules/dummy_index_am/dummy_index_am.c   |  22 ++++
 .../dummy_index_am/expected/comparedatums.out | 111 ++++++++++++++++++
 src/test/modules/dummy_index_am/meson.build   |   1 +
 .../dummy_index_am/sql/comparedatums.sql      |  61 ++++++++++
 src/tools/pgindent/typedefs.list              |   1 +
 22 files changed, 388 insertions(+), 33 deletions(-)
 create mode 100644 src/test/modules/dummy_index_am/expected/comparedatums.out
 create mode 100644 src/test/modules/dummy_index_am/sql/comparedatums.sql

diff --git a/contrib/bloom/blutils.c b/contrib/bloom/blutils.c
index 5111cdc6dd6..9fa7710739b 100644
--- a/contrib/bloom/blutils.c
+++ b/contrib/bloom/blutils.c
@@ -155,6 +155,7 @@ blhandler(PG_FUNCTION_ARGS)
 		.amparallelrescan = NULL,
 		.amtranslatestrategy = NULL,
 		.amtranslatecmptype = NULL,
+		.amcomparedatums = NULL,
 	};
 
 	PG_RETURN_POINTER(&amroutine);
diff --git a/doc/src/sgml/indexam.sgml b/doc/src/sgml/indexam.sgml
index f48da318530..18b6698e38f 100644
--- a/doc/src/sgml/indexam.sgml
+++ b/doc/src/sgml/indexam.sgml
@@ -180,6 +180,9 @@ typedef struct IndexAmRoutine
     /* interface functions to support planning */
     amtranslate_strategy_function amtranslatestrategy;  /* can be NULL */
     amtranslate_cmptype_function amtranslatecmptype;    /* can be NULL */
+
+    /* interface function to support HOT updates */
+    amcomparedatums_function amcomparedatums;    /* can be NULL */
 } IndexAmRoutine;
 </programlisting>
   </para>
@@ -915,6 +918,49 @@ amtranslatecmptype (CompareType cmptype, Oid opfamily, Oid opcintype);
    fully functional.
   </para>
 
+  <para>
+<programlisting>
+bool
+amcomparedatums (Relation indexRelation,
+                 IndexInfo *indexInfo,
+                 int attnum,
+                 Oid typid,
+                 Datum oldValue,
+                 Datum newValue);
+</programlisting>
+   Would inserting a row whose index column <parameter>attnum</parameter>
+   (counting from 1) has the value <parameter>newValue</parameter> make
+   exactly the same index entries as <parameter>oldValue</parameter> made?
+   The values are not null, are of type <parameter>typid</parameter> (the
+   type of the table column or index expression, which need not be the type
+   the index stores), and are not equal byte for byte.  The function is
+   called during <command>UPDATE</command>, before the table access method
+   is, for every index that uses a column whose value changed; if every such
+   index answers <literal>true</literal> for every changed column, the update
+   can be a heap-only tuple (<acronym>HOT</acronym>) update, which makes no new
+   index entries at all (see <xref linkend="storage-hot"/>).  The index then
+   keeps the entries it made for the old value, and reaches the new row
+   through them.  <parameter>indexInfo</parameter> is the one
+   <function>aminsert</function> would get for the new row, so the function
+   may use <literal>ii_AmCache</literal> in the same way.
+  </para>
+
+  <para>
+   So <literal>true</literal> is only correct if no scan can tell the old
+   entries from the ones the new value would make: they must have the same
+   keys, compared as the index compares them, and anything a scan returns
+   from them must be the same.  An index that can return stored values in
+   index-only scans must store identical bytes for both values.  If the
+   function cannot be sure, it must return <literal>false</literal>, which
+   is always correct.  It must not modify the index, and it may be called for
+   an index that is not yet valid.  Columns of unique indexes that can be
+   referenced by a foreign key, and of the replica identity index, are not
+   compared this way, and the function is not called for the index of an
+   exclusion constraint, whose check needs the values themselves.  If <structfield>amcomparedatums</structfield> is
+   <literal>NULL</literal>, a change to a column that the index stores
+   always needs new index entries.
+  </para>
+
  </sect1>
 
  <sect1 id="index-scanning">
diff --git a/doc/src/sgml/storage.sgml b/doc/src/sgml/storage.sgml
index 4523d073a19..15b5dae1752 100644
--- a/doc/src/sgml/storage.sgml
+++ b/doc/src/sgml/storage.sgml
@@ -1120,7 +1120,13 @@ data. Empty in ordinary tables.</entry>
      <literal>SET</literal> clause changes only parts of the column that the
      indexes do not read, for example through <function>jsonb_set</function>
      or array subscripting, this is known without evaluating the indexes'
-     expressions.
+     expressions.  A column that indexes store directly is treated the same
+     way if the access method of each such index can tell whether the new
+     value gives the same index entries as the old one; among the built-in
+     methods, that depends on the method and on the change (see
+     <xref linkend="indexam"/>).  This does not apply to the columns of
+     unique indexes that a foreign key can reference, of the replica
+     identity index, or of exclusion constraints.
      </para>
    </listitem>
    <listitem>
diff --git a/src/backend/access/brin/brin.c b/src/backend/access/brin/brin.c
index 5059da8dce7..83da4850ddc 100644
--- a/src/backend/access/brin/brin.c
+++ b/src/backend/access/brin/brin.c
@@ -307,6 +307,7 @@ brinhandler(PG_FUNCTION_ARGS)
 		.amparallelrescan = NULL,
 		.amtranslatestrategy = NULL,
 		.amtranslatecmptype = NULL,
+		.amcomparedatums = NULL,
 	};
 
 	PG_RETURN_POINTER(&amroutine);
diff --git a/src/backend/access/gin/ginutil.c b/src/backend/access/gin/ginutil.c
index e7cba81d477..b57c016aea2 100644
--- a/src/backend/access/gin/ginutil.c
+++ b/src/backend/access/gin/ginutil.c
@@ -90,6 +90,7 @@ ginhandler(PG_FUNCTION_ARGS)
 		.amestimateparallelscan = NULL,
 		.aminitparallelscan = NULL,
 		.amparallelrescan = NULL,
+		.amcomparedatums = NULL,
 	};
 
 	PG_RETURN_POINTER(&amroutine);
diff --git a/src/backend/access/gist/gist.c b/src/backend/access/gist/gist.c
index 8565e225be7..0d562466821 100644
--- a/src/backend/access/gist/gist.c
+++ b/src/backend/access/gist/gist.c
@@ -112,6 +112,7 @@ gisthandler(PG_FUNCTION_ARGS)
 		.amparallelrescan = NULL,
 		.amtranslatestrategy = NULL,
 		.amtranslatecmptype = gisttranslatecmptype,
+		.amcomparedatums = NULL,
 	};
 
 	PG_RETURN_POINTER(&amroutine);
diff --git a/src/backend/access/hash/hash.c b/src/backend/access/hash/hash.c
index 63846c10840..0b05c4606e1 100644
--- a/src/backend/access/hash/hash.c
+++ b/src/backend/access/hash/hash.c
@@ -123,6 +123,7 @@ hashhandler(PG_FUNCTION_ARGS)
 		.amparallelrescan = NULL,
 		.amtranslatestrategy = hashtranslatestrategy,
 		.amtranslatecmptype = hashtranslatecmptype,
+		.amcomparedatums = NULL,
 	};
 
 	PG_RETURN_POINTER(&amroutine);
diff --git a/src/backend/access/heap/README.HOT b/src/backend/access/heap/README.HOT
index 0377f508055..5c2dfc27f58 100644
--- a/src/backend/access/heap/README.HOT
+++ b/src/backend/access/heap/README.HOT
@@ -41,10 +41,15 @@ A column that indexes reference only through expressions, or only as
 partial indexes, may change if the update changes nothing those indexes
 store: not the value of any of their expressions or columns, as compared
 byte for byte, and not whether the tuple satisfies their predicates.
-The executor works that out before calling the table AM, and passes on
-only the indexed columns whose change matters.  An index that is not yet
-ready for inserts, or whose build failed, counts as changed whenever a
-column it references does; see "CREATE INDEX CONCURRENTLY" below.
+The same holds for a column that indexes also store directly, if every
+index that does so has an access method that can tell, from the old and
+new values, that it would store the same entries (amcomparedatums), and
+the column is in no candidate key, replica identity or exclusion
+constraint.  The executor works that out before calling the table AM,
+and passes on only the indexed columns whose change matters.  An index
+that is not yet ready for inserts, or whose build failed, counts as
+changed whenever a column it references does; see "CREATE INDEX
+CONCURRENTLY" below.
 
 Second, where the modified columns are only used in indexes that do not
 contain tuple IDs, but maintain summaries of the indexed data by block.
@@ -506,7 +511,8 @@ Indexed column
 	actually be stored in the index --- it could be used in a
 	functional index's expression, or used in a partial index
 	predicate.  A change to a column that indexes use only in these
-	ways blocks HOT only if it changes what those indexes store.
+	ways, or whose indexes can compare entries, blocks HOT only if it
+	changes what those indexes store.
 
 Redirecting line pointer
 
diff --git a/src/backend/access/nbtree/nbtree.c b/src/backend/access/nbtree/nbtree.c
index 0abdd7b49f5..754ddaee71d 100644
--- a/src/backend/access/nbtree/nbtree.c
+++ b/src/backend/access/nbtree/nbtree.c
@@ -171,6 +171,7 @@ bthandler(PG_FUNCTION_ARGS)
 		.amparallelrescan = btparallelrescan,
 		.amtranslatestrategy = bttranslatestrategy,
 		.amtranslatecmptype = bttranslatecmptype,
+		.amcomparedatums = NULL,
 	};
 
 	PG_RETURN_POINTER(&amroutine);
diff --git a/src/backend/access/spgist/spgutils.c b/src/backend/access/spgist/spgutils.c
index f2ee333f60d..985a9474c9c 100644
--- a/src/backend/access/spgist/spgutils.c
+++ b/src/backend/access/spgist/spgutils.c
@@ -97,6 +97,7 @@ spghandler(PG_FUNCTION_ARGS)
 		.amparallelrescan = NULL,
 		.amtranslatestrategy = NULL,
 		.amtranslatecmptype = NULL,
+		.amcomparedatums = NULL,
 	};
 
 	PG_RETURN_POINTER(&amroutine);
diff --git a/src/backend/executor/nodeModifyTable.c b/src/backend/executor/nodeModifyTable.c
index 8911e052cd2..2a6a071b80d 100644
--- a/src/backend/executor/nodeModifyTable.c
+++ b/src/backend/executor/nodeModifyTable.c
@@ -54,6 +54,7 @@
 
 #include "postgres.h"
 
+#include "access/amapi.h"
 #include "access/htup_details.h"
 #include "access/tableam.h"
 #include "access/tupconvert.h"
@@ -198,41 +199,57 @@ static TupleTableSlot *ExecMergeNotMatched(ModifyTableContext *context,
 										   bool canSetTag);
 
 /*
- * Do the old and new index datums differ, byte for byte?  Compare each
- * column as the type FormIndexDatum() produced it in: the table column's
- * type or the expression's, which need not be the type the index stores.
+ * Do the old and new index datums differ?  Compare each column as the type
+ * FormIndexDatum() produced it in: the table column's type or the
+ * expression's, which need not be the type the index stores.  Datums that
+ * differ byte for byte may still give the index the same entries; if its
+ * access method can tell (amcomparedatums), ask it.
+ *
+ * Not for an exclusion constraint's index, though: the constraint is checked
+ * when an entry is inserted, with its operators, against the values, and two
+ * values that give the same entries need not have the same conflicts.  (A
+ * value that is the same byte for byte has, so the check is not needed.)
  */
 static bool
-IndexDatumsDiffer(Relation heapRel, IndexInfo *indexInfo,
+IndexDatumsDiffer(Relation heapRel, Relation indexRel, IndexInfo *indexInfo,
 				  const Datum *old_values, const bool *old_isnull,
 				  const Datum *new_values, const bool *new_isnull)
 {
 	ListCell   *expr = list_head(indexInfo->ii_Expressions);
+	amcomparedatums_function cmp = indexRel->rd_index->indisexclusion ? NULL :
+		indexRel->rd_indam->amcomparedatums;
 
 	for (int i = 0; i < indexInfo->ii_NumIndexAttrs; i++)
 	{
 		AttrNumber	attnum = indexInfo->ii_IndexAttrNumbers[i];
+		Oid			typid;
 		int16		typlen;
 		bool		typbyval;
 
 		if (attnum != 0)
 		{
-			CompactAttribute *att = TupleDescCompactAttr(RelationGetDescr(heapRel),
-														 attnum - 1);
+			Form_pg_attribute att = TupleDescAttr(RelationGetDescr(heapRel),
+												  attnum - 1);
 
+			typid = att->atttypid;
 			typlen = att->attlen;
 			typbyval = att->attbyval;
 		}
 		else
 		{
-			get_typlenbyval(exprType(lfirst(expr)), &typlen, &typbyval);
+			typid = exprType(lfirst(expr));
+			get_typlenbyval(typid, &typlen, &typbyval);
 			expr = lnext(indexInfo->ii_Expressions, expr);
 		}
 
 		if (old_isnull[i] != new_isnull[i])
 			return true;
-		if (!old_isnull[i] &&
-			!datumIsEqual(old_values[i], new_values[i], typbyval, typlen))
+		if (old_isnull[i] ||
+			datumIsEqual(old_values[i], new_values[i], typbyval, typlen))
+			continue;
+		if (cmp == NULL ||
+			!cmp(indexRel, indexInfo, i + 1, typid,
+				 old_values[i], new_values[i]))
 			return true;
 	}
 	return false;
@@ -246,9 +263,9 @@ IndexDatumsDiffer(Relation heapRel, IndexInfo *indexInfo,
  * reset for each row.
  */
 static bool
-IndexEntryChanges(ResultRelInfo *resultRelInfo, IndexInfo *indexInfo,
-				  TupleTableSlot *old_tts, TupleTableSlot *new_tts,
-				  EState *estate)
+IndexEntryChanges(ResultRelInfo *resultRelInfo, Relation indexRel,
+				  IndexInfo *indexInfo, TupleTableSlot *old_tts,
+				  TupleTableSlot *new_tts, EState *estate)
 {
 	ExprContext *econtext = GetPerTupleExprContext(estate);
 	TupleTableSlot *save_scantuple = econtext->ecxt_scantuple;
@@ -292,16 +309,18 @@ IndexEntryChanges(ResultRelInfo *resultRelInfo, IndexInfo *indexInfo,
 	FormIndexDatum(indexInfo, new_tts, estate, new_values, new_isnull);
 	econtext->ecxt_scantuple = save_scantuple;
 
-	result = IndexDatumsDiffer(resultRelInfo->ri_RelationDesc, indexInfo,
-							   old_values, old_isnull, new_values, new_isnull);
+	result = IndexDatumsDiffer(resultRelInfo->ri_RelationDesc, indexRel,
+							   indexInfo, old_values, old_isnull,
+							   new_values, new_isnull);
 	MemoryContextSwitchTo(oldcxt);
 	return result;
 }
 
 /*
  * Return the attributes of the i'th index that INDEX_ATTR_BITMAP_EXPRESSION
- * can contain: those it references through its expressions, and for a
- * partial index all those it references.  Builds the per-index cache on
+ * or INDEX_ATTR_BITMAP_COMPARABLE can contain: those it references through
+ * its expressions, and all those it references if it is a partial index or
+ * its access method can compare entries.  Builds the per-index cache on
  * first use.
  */
 static Bitmapset *
@@ -319,9 +338,12 @@ IndexExprAttrs(ResultRelInfo *resultRelInfo, int i, EState *estate)
 			Bitmapset  *attrs = NULL;
 
 			pull_varattnos((Node *) ii->ii_Expressions, 1, &attrs);
-			if (ii->ii_Predicate != NIL)
+			pull_varattnos((Node *) ii->ii_Predicate, 1, &attrs);
+			if (ii->ii_Predicate != NIL ||
+				(resultRelInfo->ri_IndexRelationDescs[j] != NULL &&
+				 resultRelInfo->ri_IndexRelationDescs[j]->rd_indam->amcomparedatums != NULL &&
+				 !resultRelInfo->ri_IndexRelationDescs[j]->rd_index->indisexclusion))
 			{
-				pull_varattnos((Node *) ii->ii_Predicate, 1, &attrs);
 				for (int k = 0; k < ii->ii_NumIndexAttrs; k++)
 				{
 					if (ii->ii_IndexAttrNumbers[k] != 0)
@@ -374,7 +396,9 @@ IndexUnaffected(ResultRelInfo *resultRelInfo, int i, Bitmapset *mine,
 #ifdef USE_ASSERT_CHECKING
 	if (proven)
 	{
-		if (IndexEntryChanges(resultRelInfo, indexInfo, old_tts, new_tts,
+		if (IndexEntryChanges(resultRelInfo,
+							  resultRelInfo->ri_IndexRelationDescs[i],
+							  indexInfo, old_tts, new_tts,
 							  estate))
 			elog(ERROR, "index \"%s\" proven unaffected by UPDATE, but its entry changed",
 				 RelationGetRelationName(resultRelInfo->ri_IndexRelationDescs[i]));
@@ -385,7 +409,9 @@ IndexUnaffected(ResultRelInfo *resultRelInfo, int i, Bitmapset *mine,
 		return true;
 #endif
 
-	return !IndexEntryChanges(resultRelInfo, indexInfo, old_tts, new_tts,
+	return !IndexEntryChanges(resultRelInfo,
+							  resultRelInfo->ri_IndexRelationDescs[i],
+							  indexInfo, old_tts, new_tts,
 							  estate);
 }
 
@@ -419,6 +445,12 @@ IndexUnaffected(ResultRelInfo *resultRelInfo, int i, Bitmapset *mine,
  * update as if it had not changed the attribute: no index needs a new
  * entry because of it.
  *
+ * An attribute that non-summarizing indexes also reference directly, as key
+ * or included columns, is a candidate too if all of those indexes' access
+ * methods can compare entries (INDEX_ATTR_BITMAP_COMPARABLE): such an index
+ * is unaffected when, column by column, its datums are equal or its access
+ * method's amcomparedatums says they give the same entries.
+ *
  * An index that is not ready for inserts or not valid counts as affected by
  * any change to an attribute it references, without evaluating anything.
  * Such an index is being built, or its build failed, so the old row may be
@@ -426,9 +458,10 @@ IndexUnaffected(ResultRelInfo *resultRelInfo, int i, Bitmapset *mine,
  * would make the UPDATE fail.  It still constrains HOT, as README.HOT
  * requires.
  *
- * Narrowing never removes an attribute of a key or replica identity index,
- * as those have neither expressions nor predicates, so the table AM may
- * also take its lock mode and replica identity decisions from the result.
+ * Narrowing never removes an attribute of a key or replica identity index:
+ * those have neither expressions nor predicates, and their columns are left
+ * out of INDEX_ATTR_BITMAP_COMPARABLE.  So the table AM may also take its
+ * lock mode and replica identity decisions from the result.
  */
 Bitmapset *
 ExecUpdateModifiedIdxAttrs(ResultRelInfo *resultRelInfo,
@@ -491,6 +524,13 @@ ExecUpdateModifiedIdxAttrs(ResultRelInfo *resultRelInfo,
 	 */
 	exprattrs = RelationGetIndexAttrBitmap(relation,
 										   INDEX_ATTR_BITMAP_EXPRESSION);
+	{
+		Bitmapset  *cmpattrs = RelationGetIndexAttrBitmap(relation,
+														  INDEX_ATTR_BITMAP_COMPARABLE);
+
+		exprattrs = bms_add_members(exprattrs, cmpattrs);
+		bms_free(cmpattrs);
+	}
 	candidates = bms_intersect(attrs, exprattrs);
 	if (bms_is_empty(candidates))
 	{
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index 14e3f507f09..0cde339774a 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -2478,6 +2478,7 @@ RelationDestroyRelation(Relation relation, bool remember_tupdesc)
 	bms_free(relation->rd_indexedattr);
 	bms_free(relation->rd_summarizedattr);
 	bms_free(relation->rd_exprattr);
+	bms_free(relation->rd_cmpattr);
 	if (relation->rd_pubdesc)
 		pfree(relation->rd_pubdesc);
 	if (relation->rd_options)
@@ -5355,6 +5356,9 @@ RelationGetIndexAttrBitmap(Relation relation, IndexAttrBitmapKind attrKind)
 	Bitmapset  *summarizedattrs;	/* columns only in summarizing indexes */
 	Bitmapset  *exprattrs;		/* columns of expression or partial
 								 * non-summarizing indexes */
+	Bitmapset  *cmpattrs;		/* plain columns of indexes whose AM can
+								 * compare entries (amcomparedatums) */
+	Bitmapset  *nocmpattrs;		/* plain columns of indexes whose AM cannot */
 	Bitmapset  *plainattrs;		/* columns of other non-summarizing indexes,
 								 * and plain columns of expression indexes */
 	List	   *indexoidlist;
@@ -5381,6 +5385,8 @@ RelationGetIndexAttrBitmap(Relation relation, IndexAttrBitmapKind attrKind)
 				return bms_copy(relation->rd_summarizedattr);
 			case INDEX_ATTR_BITMAP_EXPRESSION:
 				return bms_copy(relation->rd_exprattr);
+			case INDEX_ATTR_BITMAP_COMPARABLE:
+				return bms_copy(relation->rd_cmpattr);
 			default:
 				elog(ERROR, "unknown attrKind %u", attrKind);
 		}
@@ -5426,6 +5432,8 @@ restart:
 	indexedattrs = NULL;
 	summarizedattrs = NULL;
 	exprattrs = NULL;
+	cmpattrs = NULL;
+	nocmpattrs = NULL;
 	plainattrs = NULL;
 	foreach(l, indexoidlist)
 	{
@@ -5518,6 +5526,13 @@ restart:
 					else
 						plainattrs = bms_add_member(plainattrs,
 													attrnum - FirstLowInvalidHeapAttributeNumber);
+					if (indexDesc->rd_indam->amcomparedatums != NULL &&
+						!indexDesc->rd_index->indisexclusion)
+						cmpattrs = bms_add_member(cmpattrs,
+												  attrnum - FirstLowInvalidHeapAttributeNumber);
+					else
+						nocmpattrs = bms_add_member(nocmpattrs,
+													attrnum - FirstLowInvalidHeapAttributeNumber);
 				}
 
 				if (isKey && i < indexDesc->rd_index->indnkeyatts)
@@ -5575,6 +5590,8 @@ restart:
 		bms_free(indexedattrs);
 		bms_free(summarizedattrs);
 		bms_free(exprattrs);
+		bms_free(cmpattrs);
+		bms_free(nocmpattrs);
 		bms_free(plainattrs);
 
 		goto restart;
@@ -5592,6 +5609,26 @@ restart:
 	exprattrs = bms_del_members(exprattrs, summarizedattrs);
 	bms_free(plainattrs);
 
+	/*
+	 * Record what attributes non-summarizing indexes also reference directly,
+	 * as key or included columns, only in indexes whose access method can
+	 * tell whether a new value gives the same entries (amcomparedatums).  A
+	 * change to one of these needs new index entries only if, for some index,
+	 * it does not.  Like those above, and for the same reason, these leave
+	 * out what summarizing indexes reference.  They also leave out the
+	 * columns of candidate keys and the replica identity, whose changes are
+	 * decided by value as before: the table AM takes its tuple lock strength
+	 * and what it logs for logical decoding from them.  Indexes of exclusion
+	 * constraints count as unable to compare: the constraint is checked on
+	 * the values when an entry is inserted, and values that give the same
+	 * entries need not have the same conflicts.
+	 */
+	cmpattrs = bms_del_members(cmpattrs, nocmpattrs);
+	cmpattrs = bms_del_members(cmpattrs, summarizedattrs);
+	cmpattrs = bms_del_members(cmpattrs, uindexattrs);
+	cmpattrs = bms_del_members(cmpattrs, idindexattrs);
+	bms_free(nocmpattrs);
+
 	/*
 	 * Record what attributes are only referenced by summarizing indexes. Then
 	 * add that into the other indexed attributes to track all referenced
@@ -5614,6 +5651,8 @@ restart:
 	relation->rd_summarizedattr = NULL;
 	bms_free(relation->rd_exprattr);
 	relation->rd_exprattr = NULL;
+	bms_free(relation->rd_cmpattr);
+	relation->rd_cmpattr = NULL;
 
 	/*
 	 * Now save copies of the bitmaps in the relcache entry.  We intentionally
@@ -5629,6 +5668,7 @@ restart:
 	relation->rd_indexedattr = bms_copy(indexedattrs);
 	relation->rd_summarizedattr = bms_copy(summarizedattrs);
 	relation->rd_exprattr = bms_copy(exprattrs);
+	relation->rd_cmpattr = bms_copy(cmpattrs);
 	relation->rd_attrsvalid = true;
 	MemoryContextSwitchTo(oldcxt);
 
@@ -5647,6 +5687,8 @@ restart:
 			return summarizedattrs;
 		case INDEX_ATTR_BITMAP_EXPRESSION:
 			return exprattrs;
+		case INDEX_ATTR_BITMAP_COMPARABLE:
+			return cmpattrs;
 		default:
 			elog(ERROR, "unknown attrKind %u", attrKind);
 			return NULL;
diff --git a/src/include/access/amapi.h b/src/include/access/amapi.h
index 79240333530..0c395d74a50 100644
--- a/src/include/access/amapi.h
+++ b/src/include/access/amapi.h
@@ -109,6 +109,12 @@ typedef CompareType (*amtranslate_strategy_function) (StrategyNumber strategy, O
 /* translate general operator types to AM-specific strategies */
 typedef StrategyNumber (*amtranslate_cmptype_function) (CompareType cmptype, Oid opfamily);
 
+/* would two non-null values of an index column give the same entries? */
+typedef bool (*amcomparedatums_function) (Relation indexRelation,
+										  IndexInfo *indexInfo,
+										  int attnum, Oid typid,
+										  Datum oldValue, Datum newValue);
+
 /* build new index */
 typedef IndexBuildResult *(*ambuild_function) (Relation heapRelation,
 											   Relation indexRelation,
@@ -323,6 +329,9 @@ typedef struct IndexAmRoutine
 	/* interface functions to support planning */
 	amtranslate_strategy_function amtranslatestrategy;	/* can be NULL */
 	amtranslate_cmptype_function amtranslatecmptype;	/* can be NULL */
+
+	/* interface function to support HOT updates */
+	amcomparedatums_function amcomparedatums;	/* can be NULL */
 } IndexAmRoutine;
 
 
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index bf1cfc2aeee..d1e317d0d78 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -504,9 +504,9 @@ typedef struct ResultRelInfo
 
 	/*
 	 * For UPDATE, the table attributes each index references through
-	 * expressions or its predicate, offset by
-	 * FirstLowInvalidHeapAttributeNumber.  Built on first use; NULL until
-	 * then.
+	 * expressions or its predicate, and directly if its access method has
+	 * amcomparedatums, offset by FirstLowInvalidHeapAttributeNumber.  Built
+	 * on first use; NULL until then.
 	 */
 	Bitmapset **ri_IndexExprAttrs;
 
diff --git a/src/include/utils/rel.h b/src/include/utils/rel.h
index cc75759063a..c3167513ad9 100644
--- a/src/include/utils/rel.h
+++ b/src/include/utils/rel.h
@@ -166,6 +166,8 @@ typedef struct RelationData
 	Bitmapset  *rd_summarizedattr;	/* cols indexed by summarizing indexes */
 	Bitmapset  *rd_exprattr;	/* cols indexed only via expressions or
 								 * partial indexes */
+	Bitmapset  *rd_cmpattr;		/* cols indexed directly only by indexes with
+								 * amcomparedatums */
 
 	PublicationDesc *rd_pubdesc;	/* publication descriptor, or NULL */
 
diff --git a/src/include/utils/relcache.h b/src/include/utils/relcache.h
index 413baad07ac..f37f8c8f149 100644
--- a/src/include/utils/relcache.h
+++ b/src/include/utils/relcache.h
@@ -73,6 +73,7 @@ typedef enum IndexAttrBitmapKind
 	INDEX_ATTR_BITMAP_INDEXED,
 	INDEX_ATTR_BITMAP_SUMMARIZED,
 	INDEX_ATTR_BITMAP_EXPRESSION,
+	INDEX_ATTR_BITMAP_COMPARABLE,
 } IndexAttrBitmapKind;
 
 extern Bitmapset *RelationGetIndexAttrBitmap(Relation relation,
diff --git a/src/test/modules/dummy_index_am/Makefile b/src/test/modules/dummy_index_am/Makefile
index aaf544a3854..6ca1f96d655 100644
--- a/src/test/modules/dummy_index_am/Makefile
+++ b/src/test/modules/dummy_index_am/Makefile
@@ -6,7 +6,7 @@ EXTENSION = dummy_index_am
 DATA = dummy_index_am--1.0.sql
 PGFILEDESC = "dummy_index_am - index access method template"
 
-REGRESS = reloptions
+REGRESS = comparedatums reloptions
 
 ifdef USE_PGXS
 PG_CONFIG = pg_config
diff --git a/src/test/modules/dummy_index_am/dummy_index_am.c b/src/test/modules/dummy_index_am/dummy_index_am.c
index 31f8d2b8161..b0d6befd41e 100644
--- a/src/test/modules/dummy_index_am/dummy_index_am.c
+++ b/src/test/modules/dummy_index_am/dummy_index_am.c
@@ -16,8 +16,10 @@
 #include "access/amapi.h"
 #include "access/reloptions.h"
 #include "catalog/index.h"
+#include "catalog/pg_type.h"
 #include "commands/vacuum.h"
 #include "nodes/pathnodes.h"
+#include "utils/rel.h"
 
 PG_MODULE_MAGIC;
 
@@ -244,6 +246,25 @@ dioptions(Datum reloptions, bool validate)
 									  di_relopt_tab, lengthof(di_relopt_tab));
 }
 
+/*
+ * Would newValue give the same entries as oldValue?  The dummy index stores
+ * nothing, so any answer is correct.  For testing the executor's use of
+ * amcomparedatums: with option_real set to 1, int4 values that are equal
+ * modulo option_int count as giving the same entries.
+ */
+static bool
+dicomparedatums(Relation index, IndexInfo *indexInfo, int attnum,
+				Oid typid, Datum oldValue, Datum newValue)
+{
+	DummyIndexOptions *opts = (DummyIndexOptions *) index->rd_options;
+
+	if (opts == NULL || opts->option_real != 1 || opts->option_int < 2 ||
+		typid != INT4OID)
+		return false;
+	return DatumGetInt32(oldValue) % opts->option_int ==
+		DatumGetInt32(newValue) % opts->option_int;
+}
+
 /*
  * Validator for index AM.
  */
@@ -341,6 +362,7 @@ dihandler(PG_FUNCTION_ARGS)
 		.amestimateparallelscan = NULL,
 		.aminitparallelscan = NULL,
 		.amparallelrescan = NULL,
+		.amcomparedatums = dicomparedatums,
 	};
 
 	PG_RETURN_POINTER(&amroutine);
diff --git a/src/test/modules/dummy_index_am/expected/comparedatums.out b/src/test/modules/dummy_index_am/expected/comparedatums.out
new file mode 100644
index 00000000000..4e26fc02e67
--- /dev/null
+++ b/src/test/modules/dummy_index_am/expected/comparedatums.out
@@ -0,0 +1,111 @@
+-- Test the executor's use of amcomparedatums on UPDATE: a column that an
+-- index stores directly does not block HOT if the index's access method
+-- says the new value gives the same entries.
+CREATE EXTENSION dummy_index_am;
+CREATE TABLE dummy_cmp (id int, a int, b int) WITH (fillfactor = 50);
+INSERT INTO dummy_cmp SELECT g, g, g FROM generate_series(1, 10) g;
+-- With option_real = 1, dummy_index_am says int4 values equal modulo
+-- option_int give the same entries.  A btree index on another column, and
+-- no index on b.
+CREATE INDEX dummy_cmp_a ON dummy_cmp USING dummy_index_am (a)
+  WITH (option_real = 1, option_int = 10);
+CREATE INDEX dummy_cmp_id ON dummy_cmp (id);
+CREATE FUNCTION dummy_cmp_hot(OUT updated bigint, OUT hot bigint)
+LANGUAGE sql AS $$
+  SELECT pg_stat_get_xact_tuples_updated('dummy_cmp'::regclass),
+         pg_stat_get_xact_tuples_hot_updated('dummy_cmp'::regclass) $$;
+-- count from zero: flush what earlier transactions counted
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+BEGIN;
+-- a + 10 is equal modulo 10: HOT
+UPDATE dummy_cmp SET a = a + 10;
+SELECT * FROM dummy_cmp_hot();
+ updated | hot 
+---------+-----
+      10 |  10
+(1 row)
+
+-- a + 1 is not: not HOT
+UPDATE dummy_cmp SET a = a + 1;
+SELECT * FROM dummy_cmp_hot();
+ updated | hot 
+---------+-----
+      20 |  10
+(1 row)
+
+-- b is not indexed: HOT
+UPDATE dummy_cmp SET b = b + 1;
+SELECT * FROM dummy_cmp_hot();
+ updated | hot 
+---------+-----
+      30 |  20
+(1 row)
+
+-- id is in a btree index, which cannot compare: not HOT, even though a
+-- changes in a way dummy_index_am accepts
+UPDATE dummy_cmp SET a = a + 10, id = id + 100;
+SELECT * FROM dummy_cmp_hot();
+ updated | hot 
+---------+-----
+      40 |  20
+(1 row)
+
+-- to and from null always needs new entries
+UPDATE dummy_cmp SET a = NULL WHERE id = 101;
+UPDATE dummy_cmp SET a = 2 WHERE id = 101;
+SELECT * FROM dummy_cmp_hot();
+ updated | hot 
+---------+-----
+      42 |  20
+(1 row)
+
+COMMIT;
+-- A column also used by an index with no amcomparedatums is decided as
+-- before.
+CREATE INDEX dummy_cmp_a2 ON dummy_cmp (a);
+-- count from zero: flush what earlier transactions counted
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+BEGIN;
+UPDATE dummy_cmp SET a = a + 10;
+SELECT * FROM dummy_cmp_hot();
+ updated | hot 
+---------+-----
+      10 |   0
+(1 row)
+
+COMMIT;
+DROP INDEX dummy_cmp_a2;
+-- The columns of a unique index that a foreign key could reference are
+-- never compared this way, nor are those of the replica identity.
+CREATE UNIQUE INDEX dummy_cmp_id_u ON dummy_cmp (id);
+CREATE INDEX dummy_cmp_id_d ON dummy_cmp USING dummy_index_am (id)
+  WITH (option_real = 1, option_int = 100);
+-- count from zero: flush what earlier transactions counted
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+BEGIN;
+UPDATE dummy_cmp SET id = id + 100;
+SELECT * FROM dummy_cmp_hot();
+ updated | hot 
+---------+-----
+      10 |   0
+(1 row)
+
+COMMIT;
+DROP TABLE dummy_cmp;
+DROP FUNCTION dummy_cmp_hot;
+DROP EXTENSION dummy_index_am;
diff --git a/src/test/modules/dummy_index_am/meson.build b/src/test/modules/dummy_index_am/meson.build
index 9cedfd2cf8b..a4174c2632c 100644
--- a/src/test/modules/dummy_index_am/meson.build
+++ b/src/test/modules/dummy_index_am/meson.build
@@ -27,6 +27,7 @@ tests += {
   'bd': meson.current_build_dir(),
   'regress': {
     'sql': [
+      'comparedatums',
       'reloptions',
     ],
   },
diff --git a/src/test/modules/dummy_index_am/sql/comparedatums.sql b/src/test/modules/dummy_index_am/sql/comparedatums.sql
new file mode 100644
index 00000000000..5b6d6709c11
--- /dev/null
+++ b/src/test/modules/dummy_index_am/sql/comparedatums.sql
@@ -0,0 +1,61 @@
+-- Test the executor's use of amcomparedatums on UPDATE: a column that an
+-- index stores directly does not block HOT if the index's access method
+-- says the new value gives the same entries.
+CREATE EXTENSION dummy_index_am;
+CREATE TABLE dummy_cmp (id int, a int, b int) WITH (fillfactor = 50);
+INSERT INTO dummy_cmp SELECT g, g, g FROM generate_series(1, 10) g;
+-- With option_real = 1, dummy_index_am says int4 values equal modulo
+-- option_int give the same entries.  A btree index on another column, and
+-- no index on b.
+CREATE INDEX dummy_cmp_a ON dummy_cmp USING dummy_index_am (a)
+  WITH (option_real = 1, option_int = 10);
+CREATE INDEX dummy_cmp_id ON dummy_cmp (id);
+CREATE FUNCTION dummy_cmp_hot(OUT updated bigint, OUT hot bigint)
+LANGUAGE sql AS $$
+  SELECT pg_stat_get_xact_tuples_updated('dummy_cmp'::regclass),
+         pg_stat_get_xact_tuples_hot_updated('dummy_cmp'::regclass) $$;
+-- count from zero: flush what earlier transactions counted
+SELECT pg_stat_force_next_flush();
+BEGIN;
+-- a + 10 is equal modulo 10: HOT
+UPDATE dummy_cmp SET a = a + 10;
+SELECT * FROM dummy_cmp_hot();
+-- a + 1 is not: not HOT
+UPDATE dummy_cmp SET a = a + 1;
+SELECT * FROM dummy_cmp_hot();
+-- b is not indexed: HOT
+UPDATE dummy_cmp SET b = b + 1;
+SELECT * FROM dummy_cmp_hot();
+-- id is in a btree index, which cannot compare: not HOT, even though a
+-- changes in a way dummy_index_am accepts
+UPDATE dummy_cmp SET a = a + 10, id = id + 100;
+SELECT * FROM dummy_cmp_hot();
+-- to and from null always needs new entries
+UPDATE dummy_cmp SET a = NULL WHERE id = 101;
+UPDATE dummy_cmp SET a = 2 WHERE id = 101;
+SELECT * FROM dummy_cmp_hot();
+COMMIT;
+-- A column also used by an index with no amcomparedatums is decided as
+-- before.
+CREATE INDEX dummy_cmp_a2 ON dummy_cmp (a);
+-- count from zero: flush what earlier transactions counted
+SELECT pg_stat_force_next_flush();
+BEGIN;
+UPDATE dummy_cmp SET a = a + 10;
+SELECT * FROM dummy_cmp_hot();
+COMMIT;
+DROP INDEX dummy_cmp_a2;
+-- The columns of a unique index that a foreign key could reference are
+-- never compared this way, nor are those of the replica identity.
+CREATE UNIQUE INDEX dummy_cmp_id_u ON dummy_cmp (id);
+CREATE INDEX dummy_cmp_id_d ON dummy_cmp USING dummy_index_am (id)
+  WITH (option_real = 1, option_int = 100);
+-- count from zero: flush what earlier transactions counted
+SELECT pg_stat_force_next_flush();
+BEGIN;
+UPDATE dummy_cmp SET id = id + 100;
+SELECT * FROM dummy_cmp_hot();
+COMMIT;
+DROP TABLE dummy_cmp;
+DROP FUNCTION dummy_cmp_hot;
+DROP EXTENSION dummy_index_am;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index bccd3cac465..16e12f0854f 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -3572,6 +3572,7 @@ ambuildempty_function
 ambuildphasename_function
 ambulkdelete_function
 amcanreturn_function
+amcomparedatums_function
 amcostestimate_function
 amendscan_function
 amestimateparallelscan_function
-- 
2.50.1

