From 2c47a0849d0dc3c3701e76d6bc58df9c94fb73be Mon Sep 17 00:00:00 2001
From: Greg Burd <greg@burd.me>
Date: Fri, 16 Oct 2026 12:50:30 -0400
Subject: [PATCH v44 8/8] hash: implement amcomparedatums

A hash index stores only the hash code of the value, and every scan, plain
or bitmap, rechecks the heap tuple.  So an updated row gives the same entry
exactly when the new value has the same hash code as the old one.

Updates that become HOT include changing the display scale of a numeric,
which hash_numeric() ignores, and changing a string to one equal under a
nondeterministic collation.

The executor asks only about values that differ once detoasted.  When the
type's equality implies image equality, as its default btree operator
class's equalimage function says for the index's collation, two such
values are unequal and can have the same hash code only by collision, so
hashcomparedatums() answers without hashing them.  That is most types
(integers, text under a deterministic collation, uuid, bytea, dates and
times); numeric, float, interval, arrays and nondeterministic collations
are still hashed.  For a large value that changes, this saves hashing the
old and new values, on top of the hash the insertion computes.  The answer
is kept in ii_AmCache for the statement.

A test checks that an exclusion constraint using a hash index still sees a
change to a value with the same hash code (interval spans of 1 and 2^32
microseconds collide): the executor does not consult amcomparedatums for
exclusion constraints.
---
 src/backend/access/hash/hash.c           |  49 ++++++-
 src/include/access/hash.h                |   3 +
 src/test/regress/expected/hash_index.out | 163 +++++++++++++++++++++++
 src/test/regress/sql/hash_index.sql      |  80 +++++++++++
 4 files changed, 294 insertions(+), 1 deletion(-)

diff --git a/src/backend/access/hash/hash.c b/src/backend/access/hash/hash.c
index 0b05c4606e1..eb57b4bb079 100644
--- a/src/backend/access/hash/hash.c
+++ b/src/backend/access/hash/hash.c
@@ -20,6 +20,7 @@
 
 #include "access/hash.h"
 #include "access/hash_xlog.h"
+#include "access/nbtree.h"
 #include "access/relscan.h"
 #include "access/stratnum.h"
 #include "access/tableam.h"
@@ -33,7 +34,9 @@
 #include "storage/read_stream.h"
 #include "utils/fmgrprotos.h"
 #include "utils/index_selfuncs.h"
+#include "utils/lsyscache.h"
 #include "utils/rel.h"
+#include "utils/typcache.h"
 
 /* Working state for hashbuild and its callback */
 typedef struct
@@ -123,7 +126,7 @@ hashhandler(PG_FUNCTION_ARGS)
 		.amparallelrescan = NULL,
 		.amtranslatestrategy = hashtranslatestrategy,
 		.amtranslatecmptype = hashtranslatecmptype,
-		.amcomparedatums = NULL,
+		.amcomparedatums = hashcomparedatums,
 	};
 
 	PG_RETURN_POINTER(&amroutine);
@@ -1029,3 +1032,47 @@ hashtranslatecmptype(CompareType cmptype, Oid opfamily)
 		return HTEqualStrategyNumber;
 	return InvalidStrategy;
 }
+
+/*
+ * hashcomparedatums() -- would newValue give the same index entry as oldValue?
+ *
+ * A hash index stores only the hash code of the value, and every scan
+ * rechecks the heap tuple, so the entries are the same if the hash codes
+ * are.  Then, for example, numeric 1.0 and 1.00, or strings equal under a
+ * case-insensitive collation, make no new entries.
+ *
+ * We are only asked about values that differ once detoasted.  If the type's
+ * equality implies image equality, as its default btree operator class's
+ * equalimage function says for the index's collation, such values are
+ * unequal, and their hash codes can be the same only by collision: say so
+ * without hashing them.  The answer is kept in ii_AmCache for the statement.
+ */
+bool
+hashcomparedatums(Relation index, IndexInfo *indexInfo, int attnum,
+				  Oid typid, int16 typlen, bool typbyval,
+				  Datum oldValue, Datum newValue)
+{
+	bool	   *imageeq = (bool *) indexInfo->ii_AmCache;
+
+	Assert(attnum == 1);
+	if (imageeq == NULL)
+	{
+		TypeCacheEntry *tce = lookup_type_cache(index->rd_opcintype[0],
+												TYPECACHE_BTREE_OPFAMILY);
+		Oid			proc = InvalidOid;
+
+		if (OidIsValid(tce->btree_opf))
+			proc = get_opfamily_proc(tce->btree_opf, tce->btree_opintype,
+									 tce->btree_opintype, BTEQUALIMAGE_PROC);
+		imageeq = MemoryContextAlloc(indexInfo->ii_Context, sizeof(bool));
+		*imageeq = OidIsValid(proc) &&
+			DatumGetBool(OidFunctionCall1Coll(proc, index->rd_indcollation[0],
+											  ObjectIdGetDatum(tce->btree_opintype)));
+		indexInfo->ii_AmCache = imageeq;
+	}
+	if (*imageeq)
+		return false;
+
+	return _hash_datum2hashkey(index, oldValue) ==
+		_hash_datum2hashkey(index, newValue);
+}
diff --git a/src/include/access/hash.h b/src/include/access/hash.h
index a8702f0e5ea..2ff2aac9822 100644
--- a/src/include/access/hash.h
+++ b/src/include/access/hash.h
@@ -389,6 +389,9 @@ extern void hashadjustmembers(Oid opfamilyoid,
 
 extern CompareType hashtranslatestrategy(StrategyNumber strategy, Oid opfamily);
 extern StrategyNumber hashtranslatecmptype(CompareType cmptype, Oid opfamily);
+extern bool hashcomparedatums(Relation index, struct IndexInfo *indexInfo,
+							  int attnum, Oid typid, int16 typlen,
+							  bool typbyval, Datum oldValue, Datum newValue);
 
 /* private routines */
 
diff --git a/src/test/regress/expected/hash_index.out b/src/test/regress/expected/hash_index.out
index 0b099b9e771..548a3207b64 100644
--- a/src/test/regress/expected/hash_index.out
+++ b/src/test/regress/expected/hash_index.out
@@ -375,3 +375,166 @@ CREATE INDEX hash_f8_index2 ON hash_f8_heap USING hash (random float8_ops)
 	WITH (fillfactor=101);
 ERROR:  value 101 out of bounds for option "fillfactor"
 DETAIL:  Valid values are between "10" and "100".
+--
+-- An UPDATE to a value with the same hash code is HOT: the index stores
+-- only the code, and scans recheck the heap tuple.
+--
+CREATE TABLE hash_hot (id int, n numeric) WITH (fillfactor = 10);
+CREATE INDEX hash_hot_n ON hash_hot USING hash (n);
+CREATE INDEX hash_hot_n2 ON hash_hot USING hash ((n * 2));
+INSERT INTO hash_hot SELECT g, g FROM generate_series(1, 50) g;
+-- (pg_stat_get_xact_* count this transaction's updates, and earlier
+-- transactions' not yet reported)
+CREATE FUNCTION hash_hot_count(OUT updated bigint, OUT hot bigint)
+LANGUAGE sql AS $$
+  SELECT pg_stat_get_xact_tuples_updated('hash_hot'::regclass),
+         pg_stat_get_xact_tuples_hot_updated('hash_hot'::regclass) $$;
+BEGIN;
+-- numeric hashes ignore the display scale, in the column and in the
+-- expression: HOT
+UPDATE hash_hot SET n = n + 0.00;
+SELECT * FROM hash_hot_count();
+ updated | hot 
+---------+-----
+      50 |  50
+(1 row)
+
+-- a different value: not HOT
+UPDATE hash_hot SET n = n + 100 WHERE id <= 5;
+SELECT * FROM hash_hot_count();
+ updated | hot 
+---------+-----
+      55 |  50
+(1 row)
+
+COMMIT;
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+EXPLAIN (COSTS OFF) SELECT id, n FROM hash_hot WHERE n = 7;
+               QUERY PLAN                
+-----------------------------------------
+ Index Scan using hash_hot_n on hash_hot
+   Index Cond: (n = '7'::numeric)
+(2 rows)
+
+SELECT id, n FROM hash_hot WHERE n = 7;
+ id |  n   
+----+------
+  7 | 7.00
+(1 row)
+
+SELECT id, n FROM hash_hot WHERE n = 7.000;
+ id |  n   
+----+------
+  7 | 7.00
+(1 row)
+
+SELECT id, n FROM hash_hot WHERE n = 3;
+ id | n 
+----+---
+(0 rows)
+
+SELECT id, n FROM hash_hot WHERE n = 103;
+ id |   n    
+----+--------
+  3 | 103.00
+(1 row)
+
+SELECT id, n FROM hash_hot WHERE n * 2 = 14;
+ id |  n   
+----+------
+  7 | 7.00
+(1 row)
+
+RESET enable_bitmapscan;
+SET enable_indexscan = off;
+SELECT count(*) FROM hash_hot h WHERE n = (SELECT n FROM hash_hot WHERE id = h.id);
+ count 
+-------
+    50
+(1 row)
+
+RESET enable_indexscan;
+RESET enable_seqscan;
+DROP TABLE hash_hot;
+-- text's equality implies image equality, so a value that changes needs a
+-- new entry; the index is not asked to hash the two
+CREATE TABLE hash_hot (id int, t text) WITH (fillfactor = 10);
+CREATE INDEX hash_hot_t ON hash_hot USING hash (t);
+INSERT INTO hash_hot SELECT g, 'v' || g FROM generate_series(1, 50) g;
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+BEGIN;
+UPDATE hash_hot SET t = t || 'x' WHERE id <= 5;
+SELECT * FROM hash_hot_count();
+ updated | hot 
+---------+-----
+       5 |   0
+(1 row)
+
+COMMIT;
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT id, t FROM hash_hot WHERE t = 'v3x';
+ id |  t  
+----+-----
+  3 | v3x
+(1 row)
+
+SELECT id, t FROM hash_hot WHERE t = 'v3';
+ id | t 
+----+---
+(0 rows)
+
+RESET enable_bitmapscan;
+RESET enable_seqscan;
+DROP TABLE hash_hot;
+-- An exclusion constraint is checked on the values, so a change to a value
+-- with the same hash code must still make a new entry and be checked.
+-- An interval hashes as hashint8() of its span in microseconds, which folds
+-- the high half into the low, so 1 and 2^32 microseconds collide.  (int8
+-- would not do: its equality implies image equality, so the index is not
+-- asked about two different int8 values at all.)
+SELECT interval_hash('00:00:00.000001') = interval_hash('01:11:34.967296') AS same_hash;
+ same_hash 
+-----------
+ t
+(1 row)
+
+CREATE TABLE hash_hot (id int, k interval, EXCLUDE USING hash (k WITH =))
+  WITH (fillfactor = 10);
+INSERT INTO hash_hot VALUES (1, '00:00:00.000001'), (2, '01:11:34.967296');
+UPDATE hash_hot SET k = '01:11:34.967296' WHERE id = 1;
+ERROR:  conflicting key value violates exclusion constraint "hash_hot_k_excl"
+DETAIL:  Key (k)=(@ 1 hour 11 mins 34.967296 secs) conflicts with existing key (k)=(@ 1 hour 11 mins 34.967296 secs).
+-- pg_stat_get_xact_* also count earlier transactions' updates not yet
+-- reported; report them, so the counts below start from zero
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+BEGIN;
+DELETE FROM hash_hot WHERE id = 2;
+-- without a conflict, the update is checked and is not HOT
+UPDATE hash_hot SET k = '01:11:34.967296' WHERE id = 1;
+SELECT * FROM hash_hot_count();
+ updated | hot 
+---------+-----
+       1 |   0
+(1 row)
+
+COMMIT;
+SELECT * FROM hash_hot;
+ id |                k                
+----+---------------------------------
+  1 | @ 1 hour 11 mins 34.967296 secs
+(1 row)
+
+DROP TABLE hash_hot;
+DROP FUNCTION hash_hot_count;
diff --git a/src/test/regress/sql/hash_index.sql b/src/test/regress/sql/hash_index.sql
index 3ebdaeaf172..5c0b4b6b75b 100644
--- a/src/test/regress/sql/hash_index.sql
+++ b/src/test/regress/sql/hash_index.sql
@@ -351,3 +351,83 @@ CREATE INDEX hash_f8_index2 ON hash_f8_heap USING hash (random float8_ops)
 	WITH (fillfactor=9);
 CREATE INDEX hash_f8_index2 ON hash_f8_heap USING hash (random float8_ops)
 	WITH (fillfactor=101);
+
+--
+-- An UPDATE to a value with the same hash code is HOT: the index stores
+-- only the code, and scans recheck the heap tuple.
+--
+CREATE TABLE hash_hot (id int, n numeric) WITH (fillfactor = 10);
+CREATE INDEX hash_hot_n ON hash_hot USING hash (n);
+CREATE INDEX hash_hot_n2 ON hash_hot USING hash ((n * 2));
+INSERT INTO hash_hot SELECT g, g FROM generate_series(1, 50) g;
+-- (pg_stat_get_xact_* count this transaction's updates, and earlier
+-- transactions' not yet reported)
+CREATE FUNCTION hash_hot_count(OUT updated bigint, OUT hot bigint)
+LANGUAGE sql AS $$
+  SELECT pg_stat_get_xact_tuples_updated('hash_hot'::regclass),
+         pg_stat_get_xact_tuples_hot_updated('hash_hot'::regclass) $$;
+BEGIN;
+-- numeric hashes ignore the display scale, in the column and in the
+-- expression: HOT
+UPDATE hash_hot SET n = n + 0.00;
+SELECT * FROM hash_hot_count();
+-- a different value: not HOT
+UPDATE hash_hot SET n = n + 100 WHERE id <= 5;
+SELECT * FROM hash_hot_count();
+COMMIT;
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+EXPLAIN (COSTS OFF) SELECT id, n FROM hash_hot WHERE n = 7;
+SELECT id, n FROM hash_hot WHERE n = 7;
+SELECT id, n FROM hash_hot WHERE n = 7.000;
+SELECT id, n FROM hash_hot WHERE n = 3;
+SELECT id, n FROM hash_hot WHERE n = 103;
+SELECT id, n FROM hash_hot WHERE n * 2 = 14;
+RESET enable_bitmapscan;
+SET enable_indexscan = off;
+SELECT count(*) FROM hash_hot h WHERE n = (SELECT n FROM hash_hot WHERE id = h.id);
+RESET enable_indexscan;
+RESET enable_seqscan;
+DROP TABLE hash_hot;
+
+-- text's equality implies image equality, so a value that changes needs a
+-- new entry; the index is not asked to hash the two
+CREATE TABLE hash_hot (id int, t text) WITH (fillfactor = 10);
+CREATE INDEX hash_hot_t ON hash_hot USING hash (t);
+INSERT INTO hash_hot SELECT g, 'v' || g FROM generate_series(1, 50) g;
+SELECT pg_stat_force_next_flush();
+BEGIN;
+UPDATE hash_hot SET t = t || 'x' WHERE id <= 5;
+SELECT * FROM hash_hot_count();
+COMMIT;
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT id, t FROM hash_hot WHERE t = 'v3x';
+SELECT id, t FROM hash_hot WHERE t = 'v3';
+RESET enable_bitmapscan;
+RESET enable_seqscan;
+DROP TABLE hash_hot;
+
+-- An exclusion constraint is checked on the values, so a change to a value
+-- with the same hash code must still make a new entry and be checked.
+-- An interval hashes as hashint8() of its span in microseconds, which folds
+-- the high half into the low, so 1 and 2^32 microseconds collide.  (int8
+-- would not do: its equality implies image equality, so the index is not
+-- asked about two different int8 values at all.)
+SELECT interval_hash('00:00:00.000001') = interval_hash('01:11:34.967296') AS same_hash;
+CREATE TABLE hash_hot (id int, k interval, EXCLUDE USING hash (k WITH =))
+  WITH (fillfactor = 10);
+INSERT INTO hash_hot VALUES (1, '00:00:00.000001'), (2, '01:11:34.967296');
+UPDATE hash_hot SET k = '01:11:34.967296' WHERE id = 1;
+-- pg_stat_get_xact_* also count earlier transactions' updates not yet
+-- reported; report them, so the counts below start from zero
+SELECT pg_stat_force_next_flush();
+BEGIN;
+DELETE FROM hash_hot WHERE id = 2;
+-- without a conflict, the update is checked and is not HOT
+UPDATE hash_hot SET k = '01:11:34.967296' WHERE id = 1;
+SELECT * FROM hash_hot_count();
+COMMIT;
+SELECT * FROM hash_hot;
+DROP TABLE hash_hot;
+DROP FUNCTION hash_hot_count;
-- 
2.50.1

