From 760e45a8a7a3ce5887964b28984e6be75bdee718 Mon Sep 17 00:00:00 2001
From: Greg Burd <greg@burd.me>
Date: Fri, 16 Oct 2026 12:35:16 -0400
Subject: [PATCH v39 7/8] GIN: implement amcomparedatums

A GIN index stores, for each row, the keys its operator class extracts from
the indexed value, and nothing else about the value.  Scans see a row only
as its set of keys: that is all the consistent function is given, and what
it cannot decide from them it leaves to a recheck against the heap tuple.
So an updated row gives the same entries exactly when extractValue gives
the same keys for the new value, compared as GIN compares keys.  Keys equal
under the compare function share one entry whichever came first, so the
compare function is the right test.

Run ginExtractEntries() on both values, which sorts and deduplicates them
and represents empty items and null keys as GIN does, and compare.  The
GinState is cached in ii_AmCache, as gininsert() caches it, so it is built
once per statement.

Updates that become HOT include reordering or repeating the elements of an
array column under array_ops, and changing a tsvector, or a text column
under a to_tsvector() expression index, in ways that keep its lexemes, such
as moving words around.
---
 contrib/amcheck/expected/check_gin.out | 129 +++++++++++++++++++++++++
 contrib/amcheck/sql/check_gin.sql      |  75 ++++++++++++++
 src/backend/access/gin/ginutil.c       |  58 ++++++++++-
 src/include/access/gin_private.h       |   3 +
 4 files changed, 264 insertions(+), 1 deletion(-)

diff --git a/contrib/amcheck/expected/check_gin.out b/contrib/amcheck/expected/check_gin.out
index 8dd01ced8d1..f2f6245fb76 100644
--- a/contrib/amcheck/expected/check_gin.out
+++ b/contrib/amcheck/expected/check_gin.out
@@ -88,3 +88,132 @@ SELECT gin_index_check('gin_check_multicolumn_idx');
 
 -- cleanup
 DROP TABLE gin_check_multicolumn;
+-- An UPDATE that leaves a GIN index's keys for the row as they are is HOT:
+-- reordering or repeating array elements, or changing the positions in a
+-- tsvector but not its lexemes.  Scans through the index must still find
+-- exactly the rows a sequential scan finds.
+CREATE TABLE gin_hot (id int, tags int[], tsv tsvector, body text)
+  WITH (fillfactor = 10);
+CREATE INDEX gin_hot_tags ON gin_hot USING gin (tags);
+CREATE INDEX gin_hot_tsv ON gin_hot USING gin (tsv);
+CREATE INDEX gin_hot_body ON gin_hot USING gin (to_tsvector('simple', body));
+INSERT INTO gin_hot
+  SELECT g, ARRAY[g % 7, g % 11, g % 13], to_tsvector('simple', 'a b c ' || g),
+         'one two ' || g
+    FROM generate_series(1, 100) g;
+CREATE FUNCTION gin_hot_count(OUT updated bigint, OUT hot bigint)
+LANGUAGE sql AS $$
+  SELECT pg_stat_get_xact_tuples_updated('gin_hot'::regclass),
+         pg_stat_get_xact_tuples_hot_updated('gin_hot'::regclass) $$;
+-- How many rows each tag, lexeme and word matches, through the index and
+-- not, and the number of mismatches.
+CREATE FUNCTION gin_hot_mismatches() RETURNS bigint LANGUAGE plpgsql AS $$
+DECLARE n bigint := 0; k int; w text; a bigint; b bigint;
+BEGIN
+  FOR k IN 0..13 LOOP
+    SET LOCAL enable_seqscan = off; SET LOCAL enable_indexscan = off;
+    SELECT count(*) INTO a FROM gin_hot WHERE tags @> ARRAY[k];
+    SET LOCAL enable_seqscan = on; SET LOCAL enable_bitmapscan = off;
+    SELECT count(*) INTO b FROM gin_hot WHERE tags @> ARRAY[k];
+    RESET enable_bitmapscan; RESET enable_indexscan;
+    IF a <> b THEN n := n + 1; END IF;
+  END LOOP;
+  FOREACH w IN ARRAY ARRAY['a', 'b', 'c', 'd', 'one', 'two', 'three', '7']
+  LOOP
+    SET LOCAL enable_seqscan = off; SET LOCAL enable_indexscan = off;
+    SELECT count(*) INTO a FROM gin_hot WHERE tsv @@ to_tsquery('simple', w);
+    SELECT count(*) INTO b FROM gin_hot
+      WHERE to_tsvector('simple', body) @@ to_tsquery('simple', w);
+    SET LOCAL enable_seqscan = on; SET LOCAL enable_bitmapscan = off;
+    IF a <> (SELECT count(*) FROM gin_hot
+             WHERE tsv @@ to_tsquery('simple', w)) THEN n := n + 1; END IF;
+    IF b <> (SELECT count(*) FROM gin_hot
+             WHERE to_tsvector('simple', body) @@ to_tsquery('simple', w))
+      THEN n := n + 1; END IF;
+    RESET enable_bitmapscan; RESET enable_indexscan;
+  END LOOP;
+  RETURN n;
+END $$;
+BEGIN;
+-- same keys: HOT
+UPDATE gin_hot SET tags = ARRAY[tags[3], tags[1], tags[2], tags[1]];
+SELECT * FROM gin_hot_count();
+ updated | hot 
+---------+-----
+     100 | 100
+(1 row)
+
+UPDATE gin_hot SET tsv = to_tsvector('simple', 'c b a a ' || id);
+SELECT * FROM gin_hot_count();
+ updated | hot 
+---------+-----
+     200 | 200
+(1 row)
+
+UPDATE gin_hot SET body = id || ' two one one';
+SELECT * FROM gin_hot_count();
+ updated | hot 
+---------+-----
+     300 | 300
+(1 row)
+
+-- different keys: not HOT
+UPDATE gin_hot SET tags = tags || 0 WHERE id <= 10;
+SELECT * FROM gin_hot_count();
+ updated | hot 
+---------+-----
+     310 | 301
+(1 row)
+
+UPDATE gin_hot SET tsv = to_tsvector('simple', 'a b d ' || id) WHERE id <= 10;
+SELECT * FROM gin_hot_count();
+ updated | hot 
+---------+-----
+     320 | 301
+(1 row)
+
+UPDATE gin_hot SET body = 'three ' || body WHERE id <= 10;
+SELECT * FROM gin_hot_count();
+ updated | hot 
+---------+-----
+     330 | 301
+(1 row)
+
+-- to and from no keys, and null
+UPDATE gin_hot SET tags = '{}' WHERE id = 11;
+UPDATE gin_hot SET tags = NULL WHERE id = 11;
+UPDATE gin_hot SET tags = '{1}' WHERE id = 11;
+SELECT * FROM gin_hot_count();
+ updated | hot 
+---------+-----
+     333 | 301
+(1 row)
+
+COMMIT;
+SELECT gin_hot_mismatches();
+ gin_hot_mismatches 
+--------------------
+                  0
+(1 row)
+
+SELECT gin_index_check('gin_hot_tags');
+ gin_index_check 
+-----------------
+ 
+(1 row)
+
+SELECT gin_index_check('gin_hot_tsv');
+ gin_index_check 
+-----------------
+ 
+(1 row)
+
+SELECT gin_index_check('gin_hot_body');
+ gin_index_check 
+-----------------
+ 
+(1 row)
+
+DROP TABLE gin_hot;
+DROP FUNCTION gin_hot_count;
+DROP FUNCTION gin_hot_mismatches;
diff --git a/contrib/amcheck/sql/check_gin.sql b/contrib/amcheck/sql/check_gin.sql
index 11caed3d6a8..8eeb8b3939e 100644
--- a/contrib/amcheck/sql/check_gin.sql
+++ b/contrib/amcheck/sql/check_gin.sql
@@ -60,3 +60,78 @@ SELECT gin_index_check('gin_check_multicolumn_idx');
 
 -- cleanup
 DROP TABLE gin_check_multicolumn;
+
+-- An UPDATE that leaves a GIN index's keys for the row as they are is HOT:
+-- reordering or repeating array elements, or changing the positions in a
+-- tsvector but not its lexemes.  Scans through the index must still find
+-- exactly the rows a sequential scan finds.
+CREATE TABLE gin_hot (id int, tags int[], tsv tsvector, body text)
+  WITH (fillfactor = 10);
+CREATE INDEX gin_hot_tags ON gin_hot USING gin (tags);
+CREATE INDEX gin_hot_tsv ON gin_hot USING gin (tsv);
+CREATE INDEX gin_hot_body ON gin_hot USING gin (to_tsvector('simple', body));
+INSERT INTO gin_hot
+  SELECT g, ARRAY[g % 7, g % 11, g % 13], to_tsvector('simple', 'a b c ' || g),
+         'one two ' || g
+    FROM generate_series(1, 100) g;
+CREATE FUNCTION gin_hot_count(OUT updated bigint, OUT hot bigint)
+LANGUAGE sql AS $$
+  SELECT pg_stat_get_xact_tuples_updated('gin_hot'::regclass),
+         pg_stat_get_xact_tuples_hot_updated('gin_hot'::regclass) $$;
+-- How many rows each tag, lexeme and word matches, through the index and
+-- not, and the number of mismatches.
+CREATE FUNCTION gin_hot_mismatches() RETURNS bigint LANGUAGE plpgsql AS $$
+DECLARE n bigint := 0; k int; w text; a bigint; b bigint;
+BEGIN
+  FOR k IN 0..13 LOOP
+    SET LOCAL enable_seqscan = off; SET LOCAL enable_indexscan = off;
+    SELECT count(*) INTO a FROM gin_hot WHERE tags @> ARRAY[k];
+    SET LOCAL enable_seqscan = on; SET LOCAL enable_bitmapscan = off;
+    SELECT count(*) INTO b FROM gin_hot WHERE tags @> ARRAY[k];
+    RESET enable_bitmapscan; RESET enable_indexscan;
+    IF a <> b THEN n := n + 1; END IF;
+  END LOOP;
+  FOREACH w IN ARRAY ARRAY['a', 'b', 'c', 'd', 'one', 'two', 'three', '7']
+  LOOP
+    SET LOCAL enable_seqscan = off; SET LOCAL enable_indexscan = off;
+    SELECT count(*) INTO a FROM gin_hot WHERE tsv @@ to_tsquery('simple', w);
+    SELECT count(*) INTO b FROM gin_hot
+      WHERE to_tsvector('simple', body) @@ to_tsquery('simple', w);
+    SET LOCAL enable_seqscan = on; SET LOCAL enable_bitmapscan = off;
+    IF a <> (SELECT count(*) FROM gin_hot
+             WHERE tsv @@ to_tsquery('simple', w)) THEN n := n + 1; END IF;
+    IF b <> (SELECT count(*) FROM gin_hot
+             WHERE to_tsvector('simple', body) @@ to_tsquery('simple', w))
+      THEN n := n + 1; END IF;
+    RESET enable_bitmapscan; RESET enable_indexscan;
+  END LOOP;
+  RETURN n;
+END $$;
+BEGIN;
+-- same keys: HOT
+UPDATE gin_hot SET tags = ARRAY[tags[3], tags[1], tags[2], tags[1]];
+SELECT * FROM gin_hot_count();
+UPDATE gin_hot SET tsv = to_tsvector('simple', 'c b a a ' || id);
+SELECT * FROM gin_hot_count();
+UPDATE gin_hot SET body = id || ' two one one';
+SELECT * FROM gin_hot_count();
+-- different keys: not HOT
+UPDATE gin_hot SET tags = tags || 0 WHERE id <= 10;
+SELECT * FROM gin_hot_count();
+UPDATE gin_hot SET tsv = to_tsvector('simple', 'a b d ' || id) WHERE id <= 10;
+SELECT * FROM gin_hot_count();
+UPDATE gin_hot SET body = 'three ' || body WHERE id <= 10;
+SELECT * FROM gin_hot_count();
+-- to and from no keys, and null
+UPDATE gin_hot SET tags = '{}' WHERE id = 11;
+UPDATE gin_hot SET tags = NULL WHERE id = 11;
+UPDATE gin_hot SET tags = '{1}' WHERE id = 11;
+SELECT * FROM gin_hot_count();
+COMMIT;
+SELECT gin_hot_mismatches();
+SELECT gin_index_check('gin_hot_tags');
+SELECT gin_index_check('gin_hot_tsv');
+SELECT gin_index_check('gin_hot_body');
+DROP TABLE gin_hot;
+DROP FUNCTION gin_hot_count;
+DROP FUNCTION gin_hot_mismatches;
diff --git a/src/backend/access/gin/ginutil.c b/src/backend/access/gin/ginutil.c
index b57c016aea2..0b0f2a64cce 100644
--- a/src/backend/access/gin/ginutil.c
+++ b/src/backend/access/gin/ginutil.c
@@ -23,6 +23,7 @@
 #include "commands/progress.h"
 #include "commands/vacuum.h"
 #include "miscadmin.h"
+#include "nodes/execnodes.h"
 #include "storage/indexfsm.h"
 #include "utils/builtins.h"
 #include "utils/index_selfuncs.h"
@@ -90,7 +91,7 @@ ginhandler(PG_FUNCTION_ARGS)
 		.amestimateparallelscan = NULL,
 		.aminitparallelscan = NULL,
 		.amparallelrescan = NULL,
-		.amcomparedatums = NULL,
+		.amcomparedatums = gincomparedatums,
 	};
 
 	PG_RETURN_POINTER(&amroutine);
@@ -680,3 +681,58 @@ ginbuildphasename(int64 phasenum)
 			return NULL;
 	}
 }
+
+/*
+ * gincomparedatums() -- would newValue give the same entries as oldValue?
+ *
+ * A GIN index stores, for each item, the keys its operator class extracts,
+ * and nothing else about the item: scans see an item only as the set of its
+ * keys, which is all the consistent function is shown and all a lossy match
+ * leaves to recheck.  So two values give the same entries if they have the
+ * same keys, as GIN compares them.  Keys equal under the compare function
+ * share one entry in the index whichever of them came first, so the compare
+ * function is the right test.  Then, for example, reordering or repeating
+ * the elements of an array, or changing a tsvector's positions but not its
+ * lexemes, makes no new entries.
+ */
+bool
+gincomparedatums(Relation index, IndexInfo *indexInfo, int attnum,
+				 Oid typid, Datum oldValue, Datum newValue)
+{
+	GinState   *ginstate = (GinState *) indexInfo->ii_AmCache;
+	Datum	   *oldEntries;
+	Datum	   *newEntries;
+	GinNullCategory *oldCategories;
+	GinNullCategory *newCategories;
+	int32		noldEntries;
+	int32		nnewEntries;
+
+	/* the same cache as gininsert(), for the same statement */
+	if (ginstate == NULL)
+	{
+		MemoryContext oldCtx = MemoryContextSwitchTo(indexInfo->ii_Context);
+
+		ginstate = palloc_object(GinState);
+		initGinState(ginstate, index);
+		indexInfo->ii_AmCache = ginstate;
+		MemoryContextSwitchTo(oldCtx);
+	}
+
+	oldEntries = ginExtractEntries(ginstate, (OffsetNumber) attnum,
+								   oldValue, false,
+								   &noldEntries, &oldCategories);
+	newEntries = ginExtractEntries(ginstate, (OffsetNumber) attnum,
+								   newValue, false,
+								   &nnewEntries, &newCategories);
+
+	if (noldEntries != nnewEntries)
+		return false;
+	for (int32 i = 0; i < noldEntries; i++)
+	{
+		if (ginCompareEntries(ginstate, (OffsetNumber) attnum,
+							  oldEntries[i], oldCategories[i],
+							  newEntries[i], newCategories[i]) != 0)
+			return false;
+	}
+	return true;
+}
diff --git a/src/include/access/gin_private.h b/src/include/access/gin_private.h
index 3c5fd6ba817..9b6be333d33 100644
--- a/src/include/access/gin_private.h
+++ b/src/include/access/gin_private.h
@@ -106,6 +106,9 @@ extern OffsetNumber gintuple_get_attrnum(GinState *ginstate, IndexTuple tuple);
 extern Datum gintuple_get_key(GinState *ginstate, IndexTuple tuple,
 							  GinNullCategory *category);
 extern char *ginbuildphasename(int64 phasenum);
+extern bool gincomparedatums(Relation index, struct IndexInfo *indexInfo,
+							 int attnum, Oid typid,
+							 Datum oldValue, Datum newValue);
 
 /* gininsert.c */
 extern IndexBuildResult *ginbuild(Relation heap, Relation index,
-- 
2.50.1

