From 26b4b0419610b90fb12a25d2895848bf1abed1c0 Mon Sep 17 00:00:00 2001
From: Greg Burd <greg@burd.me>
Date: Fri, 16 Oct 2026 12:29:42 -0400
Subject: [PATCH v41 06/10] nbtree: implement amcomparedatums

A btree index stores a column's value, detoasted (and compressed again if
large), and returns it in index-only scans.  An updated row gives the same
entry exactly when the new value is the same, byte for byte once detoasted:
datum_image_eq().

The executor compares values as stored, so it sees a change when an UPDATE
writes back the value a row already has but the old value is stored
compressed or out of line and the new one is not compressed yet.  Every
UPDATE that sends all columns, as ORMs and logical replication do, is like
that for large indexed values, and so was never HOT.  Now it is.

Values the operator class only considers equal, such as numeric 1.0 and
1.00 or strings equal under a nondeterministic collation, still need new
entries: an index-only scan would return the old one.

Columns of unique indexes that foreign keys can reference, and of the
replica identity, are still compared as stored (see the previous commit).
---
 contrib/amcheck/expected/check_btree.out | 98 ++++++++++++++++++++++++
 contrib/amcheck/sql/check_btree.sql      | 46 +++++++++++
 src/backend/access/nbtree/nbtree.c       | 26 ++++++-
 src/include/access/nbtree.h              |  3 +
 4 files changed, 172 insertions(+), 1 deletion(-)

diff --git a/contrib/amcheck/expected/check_btree.out b/contrib/amcheck/expected/check_btree.out
index 6fc1ee97902..4e02954b6f8 100644
--- a/contrib/amcheck/expected/check_btree.out
+++ b/contrib/amcheck/expected/check_btree.out
@@ -366,6 +366,104 @@ SELECT bt_index_check('hot_expr_multi_vip', heapallindexed => true);
 (1 row)
 
 DROP TABLE hot_expr_multi;
+-- An UPDATE that writes back the value a row has, when the old value is
+-- stored compressed or out of line and the new one is not yet, is HOT: the
+-- index would store the same bytes.  Index-only scans must return the new
+-- row's value.
+CREATE TABLE hot_bt_image (id int, t text, e text) WITH (fillfactor = 50);
+ALTER TABLE hot_bt_image ALTER COLUMN e SET STORAGE EXTERNAL;
+CREATE INDEX hot_bt_image_t ON hot_bt_image (t);
+CREATE INDEX hot_bt_image_e ON hot_bt_image (e) INCLUDE (id);
+INSERT INTO hot_bt_image
+  SELECT g, repeat('x', 3000) || g, repeat('y', 2500) || g
+    FROM generate_series(1, 20) g;
+SELECT pg_column_compression(t) IS NOT NULL AS t_compressed,
+       pg_column_size(e) > 2500 AS e_out_of_line
+  FROM hot_bt_image WHERE id = 1;
+ t_compressed | e_out_of_line 
+--------------+---------------
+ t            | t
+(1 row)
+
+CREATE FUNCTION hot_bt_image_count(OUT updated bigint, OUT hot bigint)
+LANGUAGE sql AS $$
+  SELECT pg_stat_get_xact_tuples_updated('hot_bt_image'::regclass),
+         pg_stat_get_xact_tuples_hot_updated('hot_bt_image'::regclass) $$;
+BEGIN;
+UPDATE hot_bt_image SET t = repeat('x', 3000) || id;
+SELECT * FROM hot_bt_image_count();
+ updated | hot 
+---------+-----
+      20 |  20
+(1 row)
+
+UPDATE hot_bt_image SET e = e || '';
+SELECT * FROM hot_bt_image_count();
+ updated | hot 
+---------+-----
+      40 |  40
+(1 row)
+
+UPDATE hot_bt_image SET t = repeat('x', 3000) || (id + 100) WHERE id <= 5;
+SELECT * FROM hot_bt_image_count();
+ updated | hot 
+---------+-----
+      45 |  40
+(1 row)
+
+COMMIT;
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+CREATE FUNCTION hot_bt_image_plan() RETURNS SETOF text LANGUAGE plpgsql AS
+$$ DECLARE l text; BEGIN
+  FOR l IN EXPLAIN (COSTS OFF)
+    SELECT right(t, 3) FROM hot_bt_image WHERE t > repeat('x', 3000)
+  LOOP RETURN NEXT left(l, 40); END LOOP; END $$;
+SELECT * FROM hot_bt_image_plan();
+            hot_bt_image_plan             
+------------------------------------------
+ Index Only Scan using hot_bt_image_t on 
+   Index Cond: (t > 'xxxxxxxxxxxxxxxxxxxx
+(2 rows)
+
+SELECT right(t, 3) FROM hot_bt_image WHERE t > repeat('x', 3000)
+  ORDER BY t COLLATE "C" LIMIT 3;
+ right 
+-------
+ x10
+ 101
+ 102
+(3 rows)
+
+SELECT count(*) FROM hot_bt_image WHERE t > repeat('x', 3000);
+ count 
+-------
+    20
+(1 row)
+
+SELECT count(*) FROM hot_bt_image WHERE e > repeat('y', 2500);
+ count 
+-------
+    20
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+SELECT bt_index_check('hot_bt_image_t', heapallindexed => true);
+ bt_index_check 
+----------------
+ 
+(1 row)
+
+SELECT bt_index_check('hot_bt_image_e', heapallindexed => true);
+ bt_index_check 
+----------------
+ 
+(1 row)
+
+DROP TABLE hot_bt_image;
+DROP FUNCTION hot_bt_image_count;
+DROP FUNCTION hot_bt_image_plan;
 -- cleanup
 DROP TABLE bttest_a;
 DROP TABLE bttest_b;
diff --git a/contrib/amcheck/sql/check_btree.sql b/contrib/amcheck/sql/check_btree.sql
index 4c54aad5d9f..0e03eeef9e0 100644
--- a/contrib/amcheck/sql/check_btree.sql
+++ b/contrib/amcheck/sql/check_btree.sql
@@ -233,6 +233,52 @@ SELECT bt_index_check('hot_expr_multi_status', heapallindexed => true);
 SELECT bt_index_check('hot_expr_multi_vip', heapallindexed => true);
 DROP TABLE hot_expr_multi;
 
+-- An UPDATE that writes back the value a row has, when the old value is
+-- stored compressed or out of line and the new one is not yet, is HOT: the
+-- index would store the same bytes.  Index-only scans must return the new
+-- row's value.
+CREATE TABLE hot_bt_image (id int, t text, e text) WITH (fillfactor = 50);
+ALTER TABLE hot_bt_image ALTER COLUMN e SET STORAGE EXTERNAL;
+CREATE INDEX hot_bt_image_t ON hot_bt_image (t);
+CREATE INDEX hot_bt_image_e ON hot_bt_image (e) INCLUDE (id);
+INSERT INTO hot_bt_image
+  SELECT g, repeat('x', 3000) || g, repeat('y', 2500) || g
+    FROM generate_series(1, 20) g;
+SELECT pg_column_compression(t) IS NOT NULL AS t_compressed,
+       pg_column_size(e) > 2500 AS e_out_of_line
+  FROM hot_bt_image WHERE id = 1;
+CREATE FUNCTION hot_bt_image_count(OUT updated bigint, OUT hot bigint)
+LANGUAGE sql AS $$
+  SELECT pg_stat_get_xact_tuples_updated('hot_bt_image'::regclass),
+         pg_stat_get_xact_tuples_hot_updated('hot_bt_image'::regclass) $$;
+BEGIN;
+UPDATE hot_bt_image SET t = repeat('x', 3000) || id;
+SELECT * FROM hot_bt_image_count();
+UPDATE hot_bt_image SET e = e || '';
+SELECT * FROM hot_bt_image_count();
+UPDATE hot_bt_image SET t = repeat('x', 3000) || (id + 100) WHERE id <= 5;
+SELECT * FROM hot_bt_image_count();
+COMMIT;
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+CREATE FUNCTION hot_bt_image_plan() RETURNS SETOF text LANGUAGE plpgsql AS
+$$ DECLARE l text; BEGIN
+  FOR l IN EXPLAIN (COSTS OFF)
+    SELECT right(t, 3) FROM hot_bt_image WHERE t > repeat('x', 3000)
+  LOOP RETURN NEXT left(l, 40); END LOOP; END $$;
+SELECT * FROM hot_bt_image_plan();
+SELECT right(t, 3) FROM hot_bt_image WHERE t > repeat('x', 3000)
+  ORDER BY t COLLATE "C" LIMIT 3;
+SELECT count(*) FROM hot_bt_image WHERE t > repeat('x', 3000);
+SELECT count(*) FROM hot_bt_image WHERE e > repeat('y', 2500);
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+SELECT bt_index_check('hot_bt_image_t', heapallindexed => true);
+SELECT bt_index_check('hot_bt_image_e', heapallindexed => true);
+DROP TABLE hot_bt_image;
+DROP FUNCTION hot_bt_image_count;
+DROP FUNCTION hot_bt_image_plan;
+
 -- cleanup
 DROP TABLE bttest_a;
 DROP TABLE bttest_b;
diff --git a/src/backend/access/nbtree/nbtree.c b/src/backend/access/nbtree/nbtree.c
index 754ddaee71d..60072140899 100644
--- a/src/backend/access/nbtree/nbtree.c
+++ b/src/backend/access/nbtree/nbtree.c
@@ -35,6 +35,7 @@
 #include "utils/datum.h"
 #include "utils/fmgrprotos.h"
 #include "utils/index_selfuncs.h"
+#include "utils/lsyscache.h"
 #include "utils/memutils.h"
 #include "utils/wait_event.h"
 
@@ -171,7 +172,7 @@ bthandler(PG_FUNCTION_ARGS)
 		.amparallelrescan = btparallelrescan,
 		.amtranslatestrategy = bttranslatestrategy,
 		.amtranslatecmptype = bttranslatecmptype,
-		.amcomparedatums = NULL,
+		.amcomparedatums = btcomparedatums,
 	};
 
 	PG_RETURN_POINTER(&amroutine);
@@ -1853,3 +1854,26 @@ bttranslatecmptype(CompareType cmptype, Oid opfamily)
 			return InvalidStrategy;
 	}
 }
+
+/*
+ * btcomparedatums() -- would newValue give the same index entry as oldValue?
+ *
+ * A btree index stores the value itself, detoasted (and maybe compressed
+ * again), and returns it in index-only scans, so the entries are the same
+ * only if the values are the same byte for byte once detoasted.  Values the
+ * operator class merely considers equal, such as numeric 1.0 and 1.00, are
+ * not.  An UPDATE that writes back the value a row already has gives the
+ * same entry even when the old value is stored compressed or out of line
+ * and the new one is not yet, which the executor's byte comparison of the
+ * stored forms counts as a change.
+ */
+bool
+btcomparedatums(Relation index, IndexInfo *indexInfo, int attnum,
+				Oid typid, Datum oldValue, Datum newValue)
+{
+	int16		typlen;
+	bool		typbyval;
+
+	get_typlenbyval(typid, &typlen, &typbyval);
+	return datum_image_eq(oldValue, newValue, typbyval, typlen);
+}
diff --git a/src/include/access/nbtree.h b/src/include/access/nbtree.h
index 3097e9bb1af..9948c3b96d9 100644
--- a/src/include/access/nbtree.h
+++ b/src/include/access/nbtree.h
@@ -1179,6 +1179,9 @@ extern int	btgettreeheight(Relation rel);
 
 extern CompareType bttranslatestrategy(StrategyNumber strategy, Oid opfamily);
 extern StrategyNumber bttranslatecmptype(CompareType cmptype, Oid opfamily);
+extern bool btcomparedatums(Relation index, struct IndexInfo *indexInfo,
+							int attnum, Oid typid,
+							Datum oldValue, Datum newValue);
 
 /*
  * prototypes for internal functions in nbtree.c
-- 
2.50.1

