From 8bd3925c5d137e57e444683ffbe1eb7f99699494 Mon Sep 17 00:00:00 2001
From: Greg Burd <greg@burd.me>
Date: Fri, 16 Oct 2026 12:29:42 -0400
Subject: [PATCH v39 6/8] 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       | 25 +++++-
 src/include/access/nbtree.h              |  3 +
 4 files changed, 171 insertions(+), 1 deletion(-)

diff --git a/contrib/amcheck/expected/check_btree.out b/contrib/amcheck/expected/check_btree.out
index 6fc1ee97902..06a31c6a3f0 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
+  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..9c5925fc1be 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
+  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..193da7a0f7a 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,25 @@ 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.  This catches an UPDATE that writes back the value a row already has
+ * when the old one is stored compressed or out of line and the new one is
+ * not yet, so that their stored forms differ.
+ */
+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..fe50b39a2d7 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, IndexInfo *indexInfo,
+							int attnum, Oid typid,
+							Datum oldValue, Datum newValue);
 
 /*
  * prototypes for internal functions in nbtree.c
-- 
2.50.1

