From aeeb5b3fe037a68e9c962ff7a02135117788d455 Mon Sep 17 00:00:00 2001
From: Greg Burd <greg@burd.me>
Date: Tue, 10 Mar 2026 09:28:15 -0400
Subject: [PATCH v46 1/9] Improve test coverage for heap-only tuple (HOT)
 update behavior

Add regression coverage for the existing Heap-Only Tuple (HOT) update
rules, so that later changes to them show up as changes to the expected
output.  The new hot_updates regression test covers:

- HOT and non-HOT updates
- summarizing (BRIN) indexes, which do not block HOT
- TOAST columns
- partial, multi-column and unique indexes
- partitioned tables
- expression indexes whose input changes while the expression's value
  does or does not, including expressions stored as another type
- partial indexes, with the row inside, outside, entering or leaving the
  predicate
- BEFORE ROW triggers that change an indexed column

A hot_updates test in contrib/pageinspect checks the HOT chain an update
leaves on the page, and that VACUUM collapses it.  It also records that
an index named in SET with its own value does not get the indexUnchanged
hint when the update cannot be HOT, so it splits pages where an index
that gets the hint frees its old entries with bottom-up deletion.

Discussion: https://postgr.es/m/78574B24-BE0A-42C5-8075-3FA9FA63B8FC@amazon.com
---
 contrib/pageinspect/Makefile                  |   3 +-
 contrib/pageinspect/expected/hot_updates.out  | 156 ++++
 contrib/pageinspect/meson.build               |   1 +
 contrib/pageinspect/sql/hot_updates.sql       | 134 ++++
 .../isolation/expected/update-epq-indexes.out |  94 +++
 src/test/isolation/isolation_schedule         |   1 +
 .../isolation/specs/update-epq-indexes.spec   |  55 ++
 src/test/regress/expected/hot_updates.out     | 723 ++++++++++++++++++
 src/test/regress/parallel_schedule            |   2 +-
 src/test/regress/sql/hot_updates.sql          | 468 ++++++++++++
 10 files changed, 1635 insertions(+), 2 deletions(-)
 create mode 100644 contrib/pageinspect/expected/hot_updates.out
 create mode 100644 contrib/pageinspect/sql/hot_updates.sql
 create mode 100644 src/test/isolation/expected/update-epq-indexes.out
 create mode 100644 src/test/isolation/specs/update-epq-indexes.spec
 create mode 100644 src/test/regress/expected/hot_updates.out
 create mode 100644 src/test/regress/sql/hot_updates.sql

diff --git a/contrib/pageinspect/Makefile b/contrib/pageinspect/Makefile
index eae989569d0..231bd82137e 100644
--- a/contrib/pageinspect/Makefile
+++ b/contrib/pageinspect/Makefile
@@ -32,7 +32,8 @@ REGRESS = \
 	gin \
 	gist \
 	hash \
-	oldextversions
+	oldextversions \
+	hot_updates
 
 ifdef USE_PGXS
 PG_CONFIG = pg_config
diff --git a/contrib/pageinspect/expected/hot_updates.out b/contrib/pageinspect/expected/hot_updates.out
new file mode 100644
index 00000000000..b7c26b1cb01
--- /dev/null
+++ b/contrib/pageinspect/expected/hot_updates.out
@@ -0,0 +1,156 @@
+--
+-- HOT_UPDATES
+-- The HOT chain an update leaves on the page.  src/test/regress's
+-- hot_updates test covers which updates are HOT.
+--
+-- oldextversions, which runs just before this, drops the extension; this
+-- creates it again and drops it at the end, leaving things as they were
+CREATE EXTENSION pageinspect;
+-- Sum of committed and in-progress (non-HOT, HOT) update counters.
+CREATE FUNCTION get_hot_count(rel_name text)
+RETURNS TABLE (
+    updates BIGINT,
+    hot BIGINT
+) AS $$
+DECLARE
+    rel_oid oid;
+BEGIN
+    rel_oid := rel_name::regclass::oid;
+    updates := COALESCE(pg_stat_get_tuples_updated(rel_oid), 0) +
+               COALESCE(pg_stat_get_xact_tuples_updated(rel_oid), 0);
+    hot := COALESCE(pg_stat_get_tuples_hot_updated(rel_oid), 0) +
+           COALESCE(pg_stat_get_xact_tuples_hot_updated(rel_oid), 0);
+    RETURN NEXT;
+END;
+$$ LANGUAGE plpgsql;
+-- True iff another live tuple on the same page has its t_ctid pointing at
+-- target_ctid, i.e. target_ctid is a later member of a HOT chain.
+CREATE FUNCTION has_hot_chain(rel_name text, target_ctid tid)
+RETURNS boolean LANGUAGE sql AS $$
+    SELECT EXISTS (
+        SELECT FROM heap_page_items(get_raw_page(rel_name,
+                                                 tid_block(target_ctid)::int))
+        WHERE lp_flags = 1 AND t_ctid = target_ctid
+          AND lp <> tid_offset(target_ctid))
+$$;
+-- The HOT chain through start_ctid, from the live tuple pointing at it if
+-- there is one, following t_ctid on the page until a tuple points at itself.
+CREATE FUNCTION print_hot_chain(rel_name text, start_ctid tid)
+RETURNS TABLE (chain_position int, ctid tid, lp_flags text, t_ctid tid)
+LANGUAGE sql AS $$
+    WITH RECURSIVE items AS (
+        SELECT * FROM heap_page_items(get_raw_page(rel_name,
+                                                   tid_block(start_ctid)::int))
+    ), chain (chain_position, lp) AS (
+        SELECT 0, coalesce((SELECT lp FROM items
+                            WHERE lp_flags = 1 AND t_ctid = start_ctid
+                              AND lp <> tid_offset(start_ctid)),
+                           tid_offset(start_ctid))
+        UNION ALL
+        SELECT c.chain_position + 1, tid_offset(i.t_ctid)
+        FROM chain c JOIN items i ON i.lp = c.lp
+        WHERE tid_block(i.t_ctid) = tid_block(start_ctid)
+          AND tid_offset(i.t_ctid) <> c.lp
+    )
+    SELECT c.chain_position,
+           format('(%s,%s)', tid_block(start_ctid), c.lp)::tid,
+           CASE i.lp_flags WHEN 0 THEN 'unused (0)' WHEN 1 THEN 'normal (1)'
+                           WHEN 2 THEN 'redirect (2)' WHEN 3 THEN 'dead (3)' END,
+           i.t_ctid
+    FROM chain c JOIN items i ON i.lp = c.lp
+    ORDER BY c.chain_position
+$$;
+-- ---------------------------------------------------------------------------
+-- Basic HOT: update of a non-indexed column
+-- ---------------------------------------------------------------------------
+-- (No autovacuum: the chain printed below must not be pruned first.)
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    indexed_col int,
+    non_indexed_col text
+) WITH (fillfactor = 50, autovacuum_enabled = off);
+CREATE INDEX hot_test_indexed_idx ON hot_test(indexed_col);
+INSERT INTO hot_test VALUES (1, 100, 'initial');
+INSERT INTO hot_test VALUES (2, 200, 'initial');
+INSERT INTO hot_test VALUES (3, 300, 'initial');
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT * FROM get_hot_count('hot_test');
+ updates | hot 
+---------+-----
+       0 |   0
+(1 row)
+
+-- Three classic HOT updates (non-indexed col).
+UPDATE hot_test SET non_indexed_col = 'updated1' WHERE id = 1;
+UPDATE hot_test SET non_indexed_col = 'updated2' WHERE id = 2;
+UPDATE hot_test SET non_indexed_col = 'updated3' WHERE id = 3;
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT * FROM get_hot_count('hot_test');
+ updates | hot 
+---------+-----
+       3 |   3
+(1 row)
+
+-- Chain-of-1 on id=1 still has a predecessor line pointer.
+WITH current_tuple AS (SELECT ctid FROM hot_test WHERE id = 1)
+SELECT has_hot_chain('hot_test', current_tuple.ctid) AS has_chain,
+       chain_position, print_hot_chain.ctid, lp_flags, t_ctid
+FROM current_tuple, LATERAL print_hot_chain('hot_test', current_tuple.ctid);
+ has_chain | chain_position | ctid  |  lp_flags  | t_ctid 
+-----------+----------------+-------+------------+--------
+ t         |              0 | (0,1) | normal (1) | (0,4)
+ t         |              1 | (0,4) | normal (1) | (0,4)
+(2 rows)
+
+-- VACUUM collapses the chain.
+VACUUM hot_test;
+WITH current_tuple AS (SELECT ctid FROM hot_test WHERE id = 1)
+SELECT has_hot_chain('hot_test', current_tuple.ctid) AS has_chain,
+       chain_position, print_hot_chain.ctid, lp_flags, t_ctid
+FROM current_tuple, LATERAL print_hot_chain('hot_test', current_tuple.ctid);
+ has_chain | chain_position | ctid  |  lp_flags  | t_ctid 
+-----------+----------------+-------+------------+--------
+ f         |              0 | (0,4) | normal (1) | (0,4)
+(1 row)
+
+DROP TABLE hot_test;
+-- ---------------------------------------------------------------------------
+-- The indexUnchanged hint on updates that cannot be HOT
+-- ---------------------------------------------------------------------------
+-- The pages are full, so most updates are not HOT and add an entry to both
+-- indexes.  Neither index's value changes.  An index that gets the
+-- indexUnchanged hint can free its old entries with bottom-up deletion
+-- before it splits a page; one that doesn't splits.  Index d is never named
+-- in SET and always gets it.  Index a is named, with its own value;
+-- same_size shows whether it gets the hint too.  The table is temporary, so
+-- that other sessions' snapshots cannot keep its old versions alive.
+CREATE TEMP TABLE hint_test (id int, a int, d int, c int)
+    WITH (fillfactor = 100);
+CREATE INDEX hint_test_a ON hint_test (a);
+CREATE INDEX hint_test_d ON hint_test (d);
+INSERT INTO hint_test SELECT g, g, g, 0 FROM generate_series(1, 3000) g;
+UPDATE hint_test SET a = a, c = c + 1;
+UPDATE hint_test SET a = a, c = c + 1;
+UPDATE hint_test SET a = a, c = c + 1;
+SELECT pg_relation_size('hint_test_a') = pg_relation_size('hint_test_d')
+    AS same_size;
+ same_size 
+-----------
+ f
+(1 row)
+
+DROP TABLE hint_test;
+DROP FUNCTION has_hot_chain(text, tid);
+DROP FUNCTION print_hot_chain(text, tid);
+DROP FUNCTION get_hot_count(text);
+DROP EXTENSION pageinspect;
diff --git a/contrib/pageinspect/meson.build b/contrib/pageinspect/meson.build
index c43ea400a4d..105a5b7d68e 100644
--- a/contrib/pageinspect/meson.build
+++ b/contrib/pageinspect/meson.build
@@ -56,6 +56,7 @@ tests += {
       'hash',
       'checksum',
       'oldextversions',
+      'hot_updates',
     ],
   },
 }
diff --git a/contrib/pageinspect/sql/hot_updates.sql b/contrib/pageinspect/sql/hot_updates.sql
new file mode 100644
index 00000000000..97e09831e04
--- /dev/null
+++ b/contrib/pageinspect/sql/hot_updates.sql
@@ -0,0 +1,134 @@
+--
+-- HOT_UPDATES
+-- The HOT chain an update leaves on the page.  src/test/regress's
+-- hot_updates test covers which updates are HOT.
+--
+
+-- oldextversions, which runs just before this, drops the extension; this
+-- creates it again and drops it at the end, leaving things as they were
+CREATE EXTENSION pageinspect;
+
+-- Sum of committed and in-progress (non-HOT, HOT) update counters.
+CREATE FUNCTION get_hot_count(rel_name text)
+RETURNS TABLE (
+    updates BIGINT,
+    hot BIGINT
+) AS $$
+DECLARE
+    rel_oid oid;
+BEGIN
+    rel_oid := rel_name::regclass::oid;
+    updates := COALESCE(pg_stat_get_tuples_updated(rel_oid), 0) +
+               COALESCE(pg_stat_get_xact_tuples_updated(rel_oid), 0);
+    hot := COALESCE(pg_stat_get_tuples_hot_updated(rel_oid), 0) +
+           COALESCE(pg_stat_get_xact_tuples_hot_updated(rel_oid), 0);
+    RETURN NEXT;
+END;
+$$ LANGUAGE plpgsql;
+
+-- True iff another live tuple on the same page has its t_ctid pointing at
+-- target_ctid, i.e. target_ctid is a later member of a HOT chain.
+CREATE FUNCTION has_hot_chain(rel_name text, target_ctid tid)
+RETURNS boolean LANGUAGE sql AS $$
+    SELECT EXISTS (
+        SELECT FROM heap_page_items(get_raw_page(rel_name,
+                                                 tid_block(target_ctid)::int))
+        WHERE lp_flags = 1 AND t_ctid = target_ctid
+          AND lp <> tid_offset(target_ctid))
+$$;
+
+-- The HOT chain through start_ctid, from the live tuple pointing at it if
+-- there is one, following t_ctid on the page until a tuple points at itself.
+CREATE FUNCTION print_hot_chain(rel_name text, start_ctid tid)
+RETURNS TABLE (chain_position int, ctid tid, lp_flags text, t_ctid tid)
+LANGUAGE sql AS $$
+    WITH RECURSIVE items AS (
+        SELECT * FROM heap_page_items(get_raw_page(rel_name,
+                                                   tid_block(start_ctid)::int))
+    ), chain (chain_position, lp) AS (
+        SELECT 0, coalesce((SELECT lp FROM items
+                            WHERE lp_flags = 1 AND t_ctid = start_ctid
+                              AND lp <> tid_offset(start_ctid)),
+                           tid_offset(start_ctid))
+        UNION ALL
+        SELECT c.chain_position + 1, tid_offset(i.t_ctid)
+        FROM chain c JOIN items i ON i.lp = c.lp
+        WHERE tid_block(i.t_ctid) = tid_block(start_ctid)
+          AND tid_offset(i.t_ctid) <> c.lp
+    )
+    SELECT c.chain_position,
+           format('(%s,%s)', tid_block(start_ctid), c.lp)::tid,
+           CASE i.lp_flags WHEN 0 THEN 'unused (0)' WHEN 1 THEN 'normal (1)'
+                           WHEN 2 THEN 'redirect (2)' WHEN 3 THEN 'dead (3)' END,
+           i.t_ctid
+    FROM chain c JOIN items i ON i.lp = c.lp
+    ORDER BY c.chain_position
+$$;
+
+-- ---------------------------------------------------------------------------
+-- Basic HOT: update of a non-indexed column
+-- ---------------------------------------------------------------------------
+-- (No autovacuum: the chain printed below must not be pruned first.)
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    indexed_col int,
+    non_indexed_col text
+) WITH (fillfactor = 50, autovacuum_enabled = off);
+CREATE INDEX hot_test_indexed_idx ON hot_test(indexed_col);
+
+INSERT INTO hot_test VALUES (1, 100, 'initial');
+INSERT INTO hot_test VALUES (2, 200, 'initial');
+INSERT INTO hot_test VALUES (3, 300, 'initial');
+
+SELECT pg_stat_force_next_flush();
+SELECT * FROM get_hot_count('hot_test');
+
+-- Three classic HOT updates (non-indexed col).
+UPDATE hot_test SET non_indexed_col = 'updated1' WHERE id = 1;
+UPDATE hot_test SET non_indexed_col = 'updated2' WHERE id = 2;
+UPDATE hot_test SET non_indexed_col = 'updated3' WHERE id = 3;
+SELECT pg_stat_force_next_flush();
+SELECT * FROM get_hot_count('hot_test');
+
+-- Chain-of-1 on id=1 still has a predecessor line pointer.
+WITH current_tuple AS (SELECT ctid FROM hot_test WHERE id = 1)
+SELECT has_hot_chain('hot_test', current_tuple.ctid) AS has_chain,
+       chain_position, print_hot_chain.ctid, lp_flags, t_ctid
+FROM current_tuple, LATERAL print_hot_chain('hot_test', current_tuple.ctid);
+
+-- VACUUM collapses the chain.
+VACUUM hot_test;
+
+WITH current_tuple AS (SELECT ctid FROM hot_test WHERE id = 1)
+SELECT has_hot_chain('hot_test', current_tuple.ctid) AS has_chain,
+       chain_position, print_hot_chain.ctid, lp_flags, t_ctid
+FROM current_tuple, LATERAL print_hot_chain('hot_test', current_tuple.ctid);
+
+DROP TABLE hot_test;
+
+-- ---------------------------------------------------------------------------
+-- The indexUnchanged hint on updates that cannot be HOT
+-- ---------------------------------------------------------------------------
+-- The pages are full, so most updates are not HOT and add an entry to both
+-- indexes.  Neither index's value changes.  An index that gets the
+-- indexUnchanged hint can free its old entries with bottom-up deletion
+-- before it splits a page; one that doesn't splits.  Index d is never named
+-- in SET and always gets it.  Index a is named, with its own value;
+-- same_size shows whether it gets the hint too.  The table is temporary, so
+-- that other sessions' snapshots cannot keep its old versions alive.
+CREATE TEMP TABLE hint_test (id int, a int, d int, c int)
+    WITH (fillfactor = 100);
+CREATE INDEX hint_test_a ON hint_test (a);
+CREATE INDEX hint_test_d ON hint_test (d);
+INSERT INTO hint_test SELECT g, g, g, 0 FROM generate_series(1, 3000) g;
+UPDATE hint_test SET a = a, c = c + 1;
+UPDATE hint_test SET a = a, c = c + 1;
+UPDATE hint_test SET a = a, c = c + 1;
+SELECT pg_relation_size('hint_test_a') = pg_relation_size('hint_test_d')
+    AS same_size;
+DROP TABLE hint_test;
+
+DROP FUNCTION has_hot_chain(text, tid);
+DROP FUNCTION print_hot_chain(text, tid);
+DROP FUNCTION get_hot_count(text);
+DROP EXTENSION pageinspect;
diff --git a/src/test/isolation/expected/update-epq-indexes.out b/src/test/isolation/expected/update-epq-indexes.out
new file mode 100644
index 00000000000..3a28bffa596
--- /dev/null
+++ b/src/test/isolation/expected/update-epq-indexes.out
@@ -0,0 +1,94 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s2_begin s2_status s1_status s2_commit s1_check
+step s2_begin: BEGIN;
+step s2_status: UPDATE hot_epq SET status = 'b' WHERE id = 1;
+step s1_status: UPDATE hot_epq SET status = 'a' WHERE id = 1; <waiting ...>
+step s2_commit: COMMIT;
+step s1_status: <... completed>
+step s1_check: 
+	SET enable_seqscan = off;
+	SET enable_bitmapscan = off;
+	SELECT count(*) AS status_a FROM hot_epq WHERE status = 'a';
+	SELECT count(*) AS doc_a FROM hot_epq WHERE doc->>'status' = 'a';
+	SELECT count(*) AS vip FROM hot_epq WHERE vip AND id = 1;
+	RESET enable_seqscan;
+	RESET enable_bitmapscan;
+
+status_a
+--------
+       1
+(1 row)
+
+doc_a
+-----
+    1
+(1 row)
+
+vip
+---
+  1
+(1 row)
+
+
+starting permutation: s2_begin s2_doc s1_doc s2_commit s1_check
+step s2_begin: BEGIN;
+step s2_doc: UPDATE hot_epq SET doc = jsonb_set(doc, ARRAY['status'], to_jsonb('b'::text)) WHERE id = 1;
+step s1_doc: UPDATE hot_epq SET doc = jsonb_set(doc, ARRAY['status'], to_jsonb('a'::text)) WHERE id = 1; <waiting ...>
+step s2_commit: COMMIT;
+step s1_doc: <... completed>
+step s1_check: 
+	SET enable_seqscan = off;
+	SET enable_bitmapscan = off;
+	SELECT count(*) AS status_a FROM hot_epq WHERE status = 'a';
+	SELECT count(*) AS doc_a FROM hot_epq WHERE doc->>'status' = 'a';
+	SELECT count(*) AS vip FROM hot_epq WHERE vip AND id = 1;
+	RESET enable_seqscan;
+	RESET enable_bitmapscan;
+
+status_a
+--------
+       1
+(1 row)
+
+doc_a
+-----
+    1
+(1 row)
+
+vip
+---
+  1
+(1 row)
+
+
+starting permutation: s2_begin s2_vip s1_vip s2_commit s1_check
+step s2_begin: BEGIN;
+step s2_vip: UPDATE hot_epq SET vip = false WHERE id = 1;
+step s1_vip: UPDATE hot_epq SET vip = true WHERE id = 1; <waiting ...>
+step s2_commit: COMMIT;
+step s1_vip: <... completed>
+step s1_check: 
+	SET enable_seqscan = off;
+	SET enable_bitmapscan = off;
+	SELECT count(*) AS status_a FROM hot_epq WHERE status = 'a';
+	SELECT count(*) AS doc_a FROM hot_epq WHERE doc->>'status' = 'a';
+	SELECT count(*) AS vip FROM hot_epq WHERE vip AND id = 1;
+	RESET enable_seqscan;
+	RESET enable_bitmapscan;
+
+status_a
+--------
+       1
+(1 row)
+
+doc_a
+-----
+    1
+(1 row)
+
+vip
+---
+  1
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index f448e4d6e13..b6fcbd73ffe 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -46,6 +46,7 @@ test: eval-plan-qual-dropped-column
 test: eval-plan-qual-resjunk
 test: eval-plan-qual-rowtype-alter
 test: eval-plan-qual-trigger
+test: update-epq-indexes
 test: eval-plan-qual-partition-prune
 test: inplace-inval
 test: intra-grant-inplace
diff --git a/src/test/isolation/specs/update-epq-indexes.spec b/src/test/isolation/specs/update-epq-indexes.spec
new file mode 100644
index 00000000000..09420c1a060
--- /dev/null
+++ b/src/test/isolation/specs/update-epq-indexes.spec
@@ -0,0 +1,55 @@
+# UPDATEs whose target row is updated concurrently, with a BEFORE ROW UPDATE
+# trigger.  The trigger code locks the latest version and rebuilds the new
+# row from it, so deciding which indexes the UPDATE changes must compare the
+# new row with that latest version, not with the one the UPDATE first read.
+#
+# The row starts with status a, inside the partial index.  s2 changes it and
+# holds the lock; s1 then sets it back and waits.  Compared with the version
+# s1 first read, s1 would change nothing, and every index would keep the
+# entry for the version s2 wrote.
+
+setup
+{
+	CREATE TABLE hot_epq (id int PRIMARY KEY, status text, doc jsonb, vip bool)
+		WITH (fillfactor = 50);
+	CREATE INDEX hot_epq_status ON hot_epq (status);
+	CREATE INDEX hot_epq_doc ON hot_epq ((doc->>'status'));
+	CREATE INDEX hot_epq_vip ON hot_epq (id) WHERE vip;
+	INSERT INTO hot_epq VALUES (1, 'a', jsonb_build_object('status', 'a'), true);
+	CREATE FUNCTION hot_epq_trig() RETURNS trigger LANGUAGE plpgsql AS
+		$$BEGIN RETURN NEW; END$$;
+	CREATE TRIGGER hot_epq_before BEFORE UPDATE ON hot_epq
+		FOR EACH ROW EXECUTE FUNCTION hot_epq_trig();
+}
+
+teardown
+{
+	DROP TABLE hot_epq;
+	DROP FUNCTION hot_epq_trig();
+}
+
+session s1
+step s1_status	{ UPDATE hot_epq SET status = 'a' WHERE id = 1; }
+step s1_doc		{ UPDATE hot_epq SET doc = jsonb_set(doc, ARRAY['status'], to_jsonb('a'::text)) WHERE id = 1; }
+step s1_vip		{ UPDATE hot_epq SET vip = true WHERE id = 1; }
+step s1_check
+{
+	SET enable_seqscan = off;
+	SET enable_bitmapscan = off;
+	SELECT count(*) AS status_a FROM hot_epq WHERE status = 'a';
+	SELECT count(*) AS doc_a FROM hot_epq WHERE doc->>'status' = 'a';
+	SELECT count(*) AS vip FROM hot_epq WHERE vip AND id = 1;
+	RESET enable_seqscan;
+	RESET enable_bitmapscan;
+}
+
+session s2
+step s2_begin	{ BEGIN; }
+step s2_status	{ UPDATE hot_epq SET status = 'b' WHERE id = 1; }
+step s2_doc		{ UPDATE hot_epq SET doc = jsonb_set(doc, ARRAY['status'], to_jsonb('b'::text)) WHERE id = 1; }
+step s2_vip		{ UPDATE hot_epq SET vip = false WHERE id = 1; }
+step s2_commit	{ COMMIT; }
+
+permutation s2_begin s2_status s1_status s2_commit s1_check
+permutation s2_begin s2_doc s1_doc s2_commit s1_check
+permutation s2_begin s2_vip s1_vip s2_commit s1_check
diff --git a/src/test/regress/expected/hot_updates.out b/src/test/regress/expected/hot_updates.out
new file mode 100644
index 00000000000..f83a686f3a9
--- /dev/null
+++ b/src/test/regress/expected/hot_updates.out
@@ -0,0 +1,723 @@
+--
+-- HOT_UPDATES
+-- Test Heap-Only Tuple (HOT) update decisions
+--
+-- Expected output records which UPDATEs are HOT, so that a change to the
+-- rules shows up as a change to this file.  contrib/pageinspect's
+-- hot_updates test looks at the HOT chain itself.
+--
+-- Sum of committed and in-progress (non-HOT, HOT) update counters.
+CREATE FUNCTION get_hot_count(rel_name text)
+RETURNS TABLE (
+    updates BIGINT,
+    hot BIGINT
+) AS $$
+DECLARE
+    rel_oid oid;
+BEGIN
+    rel_oid := rel_name::regclass::oid;
+    updates := COALESCE(pg_stat_get_tuples_updated(rel_oid), 0) +
+               COALESCE(pg_stat_get_xact_tuples_updated(rel_oid), 0);
+    hot := COALESCE(pg_stat_get_tuples_hot_updated(rel_oid), 0) +
+           COALESCE(pg_stat_get_xact_tuples_hot_updated(rel_oid), 0);
+    RETURN NEXT;
+END;
+$$ LANGUAGE plpgsql;
+-- ---------------------------------------------------------------------------
+-- Summarizing indexes (BRIN) do not block HOT
+-- ---------------------------------------------------------------------------
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    ts timestamp,
+    value int,
+    brin_col int
+) WITH (fillfactor = 50);
+CREATE INDEX hot_test_ts_brin ON hot_test USING brin(ts);
+CREATE INDEX hot_test_brin_col_brin ON hot_test USING brin(brin_col);
+INSERT INTO hot_test VALUES (1, '2024-01-01', 100, 1000);
+-- BRIN columns are summarizing; updating them stays classic HOT even
+-- though their values change.
+UPDATE hot_test SET ts = '2024-01-02', brin_col = 2000 WHERE id = 1;
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT * FROM get_hot_count('hot_test');
+ updates | hot 
+---------+-----
+       1 |   1
+(1 row)
+
+-- Non-indexed column: also HOT.
+UPDATE hot_test SET value = 200 WHERE id = 1;
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT * FROM get_hot_count('hot_test');
+ updates | hot 
+---------+-----
+       2 |   2
+(1 row)
+
+DROP TABLE hot_test;
+-- ---------------------------------------------------------------------------
+-- TOAST participates in HOT (non-indexed column paths only)
+-- ---------------------------------------------------------------------------
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    indexed_col int,
+    large_text text,
+    small_text text
+) WITH (fillfactor = 50);
+CREATE INDEX hot_test_idx ON hot_test(indexed_col);
+-- stored out of line at any block size
+ALTER TABLE hot_test ALTER COLUMN large_text SET STORAGE EXTERNAL;
+SELECT current_setting('block_size')::int / 2 AS large_len \gset
+INSERT INTO hot_test VALUES (1, 100, repeat('x', :large_len), 'small');
+SELECT pg_column_toast_chunk_id(large_text) IS NOT NULL AS toasted
+  FROM hot_test;
+ toasted 
+---------
+ t
+(1 row)
+
+-- Non-indexed, non-TOAST column: HOT.
+UPDATE hot_test SET small_text = 'updated';
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT * FROM get_hot_count('hot_test');
+ updates | hot 
+---------+-----
+       1 |   1
+(1 row)
+
+-- TOAST column, indexed_col unchanged: HOT.
+UPDATE hot_test SET large_text = repeat('y', :large_len);
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT * FROM get_hot_count('hot_test');
+ updates | hot 
+---------+-----
+       2 |   2
+(1 row)
+
+DROP TABLE hot_test;
+-- ---------------------------------------------------------------------------
+-- Partial index where update leaves indexed attrs unchanged
+-- ---------------------------------------------------------------------------
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    status text,
+    data text
+) WITH (fillfactor = 50);
+CREATE INDEX hot_test_active_idx ON hot_test(status) WHERE status = 'active';
+INSERT INTO hot_test VALUES (1, 'active', 'data1');
+INSERT INTO hot_test VALUES (2, 'inactive', 'data2');
+INSERT INTO hot_test VALUES (3, 'deleted', 'data3');
+-- Update data on a row whose status matches the partial predicate: HOT.
+UPDATE hot_test SET data = 'updated1' WHERE id = 1;
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT * FROM get_hot_count('hot_test');
+ updates | hot 
+---------+-----
+       1 |   1
+(1 row)
+
+-- Update data on a row outside the predicate: HOT.
+UPDATE hot_test SET data = 'updated2' WHERE id = 2;
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT * FROM get_hot_count('hot_test');
+ updates | hot 
+---------+-----
+       2 |   2
+(1 row)
+
+SELECT id, status FROM hot_test WHERE status = 'active';
+ id | status 
+----+--------
+  1 | active
+(1 row)
+
+DROP TABLE hot_test;
+-- ---------------------------------------------------------------------------
+-- Multi-column btree: update of non-indexed column
+-- ---------------------------------------------------------------------------
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    col_a int,
+    col_b int,
+    col_c int,
+    data text
+) WITH (fillfactor = 50);
+CREATE INDEX hot_test_ab_idx ON hot_test(col_a, col_b);
+INSERT INTO hot_test VALUES (1, 10, 20, 30, 'data');
+-- col_c not in any index: HOT.
+UPDATE hot_test SET col_c = 35;
+-- data not in any index: HOT.
+UPDATE hot_test SET data = 'updated';
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT * FROM get_hot_count('hot_test');
+ updates | hot 
+---------+-----
+       2 |   2
+(1 row)
+
+DROP TABLE hot_test;
+-- ---------------------------------------------------------------------------
+-- Unique index: update of non-indexed column + uniqueness enforcement
+-- ---------------------------------------------------------------------------
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    unique_col int UNIQUE,
+    data text
+) WITH (fillfactor = 50);
+INSERT INTO hot_test VALUES (1, 100, 'data1');
+INSERT INTO hot_test VALUES (2, 200, 'data2');
+UPDATE hot_test SET data = 'updated';
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT * FROM get_hot_count('hot_test');
+ updates | hot 
+---------+-----
+       2 |   2
+(1 row)
+
+SELECT id, unique_col, data FROM hot_test ORDER BY id;
+ id | unique_col |  data   
+----+------------+---------
+  1 |        100 | updated
+  2 |        200 | updated
+(2 rows)
+
+-- Unique constraint still enforced on any path.
+UPDATE hot_test SET unique_col = 100 WHERE id = 2;
+ERROR:  duplicate key value violates unique constraint "hot_test_unique_col_key"
+DETAIL:  Key (unique_col)=(100) already exists.
+DROP TABLE hot_test;
+-- ---------------------------------------------------------------------------
+-- Partitioned tables: HOT within a partition
+-- ---------------------------------------------------------------------------
+CREATE TABLE hot_test_partitioned (
+    id int,
+    partition_key int,
+    indexed_col int,
+    data text,
+    PRIMARY KEY (id, partition_key)
+) PARTITION BY RANGE (partition_key);
+CREATE TABLE hot_test_part1 PARTITION OF hot_test_partitioned
+    FOR VALUES FROM (1) TO (100) WITH (fillfactor = 50);
+CREATE TABLE hot_test_part2 PARTITION OF hot_test_partitioned
+    FOR VALUES FROM (100) TO (200) WITH (fillfactor = 50);
+CREATE INDEX hot_test_part_idx ON hot_test_partitioned(indexed_col);
+INSERT INTO hot_test_partitioned VALUES (1, 50, 100, 'initial1');
+INSERT INTO hot_test_partitioned VALUES (2, 150, 200, 'initial2');
+UPDATE hot_test_partitioned SET data = 'updated1' WHERE id = 1;
+UPDATE hot_test_partitioned SET data = 'updated2' WHERE id = 2;
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT * FROM get_hot_count('hot_test_part1');
+ updates | hot 
+---------+-----
+       1 |   1
+(1 row)
+
+SELECT * FROM get_hot_count('hot_test_part2');
+ updates | hot 
+---------+-----
+       1 |   1
+(1 row)
+
+SELECT id FROM hot_test_partitioned WHERE indexed_col = 100;
+ id 
+----
+  1
+(1 row)
+
+SELECT id FROM hot_test_partitioned WHERE indexed_col = 200;
+ id 
+----
+  2
+(1 row)
+
+DROP TABLE hot_test_partitioned CASCADE;
+-- An update through the parent that writes back an indexed column's value
+-- is HOT.  Routing hands the partition a new tuple whose short text value
+-- has not been packed yet; it is still the same value.
+CREATE TABLE hot_test_partitioned (id int UNIQUE, tag char, n int)
+    PARTITION BY LIST (id);
+CREATE TABLE hot_test_part1 PARTITION OF hot_test_partitioned
+    (tag UNIQUE) FOR VALUES IN (1, 2) WITH (fillfactor = 50);
+INSERT INTO hot_test_partitioned VALUES (1, 'a', 0), (2, 'b', 0);
+INSERT INTO hot_test_partitioned VALUES (1, 'a', 1)
+    ON CONFLICT (id) DO UPDATE SET tag = excluded.tag, n = excluded.n;
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT * FROM get_hot_count('hot_test_part1');
+ updates | hot 
+---------+-----
+       1 |   1
+(1 row)
+
+DROP TABLE hot_test_partitioned CASCADE;
+-- ---------------------------------------------------------------------------
+-- Expression index on a JSONB field
+-- ---------------------------------------------------------------------------
+CREATE TABLE hot_jsonb_test (
+    id int PRIMARY KEY,
+    data jsonb
+) WITH (fillfactor = 50);
+CREATE INDEX hot_jsonb_name_idx ON hot_jsonb_test ((data->>'name'));
+INSERT INTO hot_jsonb_test VALUES
+    (1, '{"name":"Alice","age":30,"city":"NYC"}'),
+    (2, '{"name":"Bob","age":25,"city":"LA"}');
+-- Each UPDATE changes the indexed column but not the indexed expression.
+UPDATE hot_jsonb_test SET data = jsonb_set(data, '{age}', '31') WHERE id = 1;
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT * FROM get_hot_count('hot_jsonb_test');
+ updates | hot 
+---------+-----
+       1 |   0
+(1 row)
+
+UPDATE hot_jsonb_test SET data = data - 'city' WHERE id = 2;
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT * FROM get_hot_count('hot_jsonb_test');
+ updates | hot 
+---------+-----
+       2 |   0
+(1 row)
+
+UPDATE hot_jsonb_test SET data = jsonb_insert(data, '{country}', '"USA"') WHERE id = 2;
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT * FROM get_hot_count('hot_jsonb_test');
+ updates | hot 
+---------+-----
+       3 |   0
+(1 row)
+
+DROP TABLE hot_jsonb_test;
+-- ---------------------------------------------------------------------------
+-- Expression and partial indexes
+--
+-- update_case() runs one UPDATE on a fresh table and reports how many rows
+-- it updated, and how many of those with HOT.
+-- ---------------------------------------------------------------------------
+CREATE FUNCTION update_case(setup text, upd text,
+                            OUT updated bigint, OUT hot bigint)
+LANGUAGE plpgsql AS $$
+BEGIN
+    EXECUTE 'DROP TABLE IF EXISTS hot_expr_test';
+    EXECUTE setup;
+    updated := pg_stat_get_xact_tuples_updated('hot_expr_test'::regclass);
+    hot := pg_stat_get_xact_tuples_hot_updated('hot_expr_test'::regclass);
+    EXECUTE upd;
+    updated := pg_stat_get_xact_tuples_updated('hot_expr_test'::regclass) - updated;
+    hot := pg_stat_get_xact_tuples_hot_updated('hot_expr_test'::regclass) - hot;
+END;
+$$;
+-- Expression value unchanged.
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, s text) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test (lower(s));
+    INSERT INTO hot_expr_test SELECT g, 'abc' || g FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET s = upper(s)$$);
+NOTICE:  table "hot_expr_test" does not exist, skipping
+ updated | hot 
+---------+-----
+      10 |   0
+(1 row)
+
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, a int, b int) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test ((a + b));
+    INSERT INTO hot_expr_test SELECT g, g, 0 FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET a = a - 1, b = b + 1$$);
+ updated | hot 
+---------+-----
+      10 |   0
+(1 row)
+
+-- Expression value changed.
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, d jsonb) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test ((d->>'name'));
+    INSERT INTO hot_expr_test SELECT g, jsonb_build_object('name', 'n' || g)
+      FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET d = jsonb_set(d, '{name}', '"x"')$$);
+ updated | hot 
+---------+-----
+      10 |   0
+(1 row)
+
+-- Equal values that are stored differently.
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, n numeric) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test ((n + 0));
+    INSERT INTO hot_expr_test SELECT g, 3 FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET n = 3.0$$);
+ updated | hot 
+---------+-----
+      10 |   0
+(1 row)
+
+-- The index stores a type other than the expression's: name_ops stores
+-- cstring, and gist point_ops stores box.
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, s text) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test ((lower(s)::name));
+    INSERT INTO hot_expr_test SELECT g, 'abc' || g FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET s = upper(s)$$);
+ updated | hot 
+---------+-----
+      10 |   0
+(1 row)
+
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, p point) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test USING gist ((point(p[0], 0)));
+    INSERT INTO hot_expr_test SELECT g, point(g, g) FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET p = point(p[0], p[1] + 1)$$);
+ updated | hot 
+---------+-----
+      10 |   0
+(1 row)
+
+-- An expression that is a point, stored by the gist index as a box.
+-- Comparing it as the box the index stores rather than as the point it is
+-- crashed the server in 2018 (2877.1541538838@sss.pgh.pa.us).
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, d smallint) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test USING gist ((point(d::integer % 7, 0)));
+    INSERT INTO hot_expr_test SELECT g, 1 FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET d = d + 7$$);
+ updated | hot 
+---------+-----
+      10 |   0
+(1 row)
+
+-- A plain index on the same column as an expression index.
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, s text) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test (lower(s));
+    CREATE INDEX ON hot_expr_test (s);
+    INSERT INTO hot_expr_test SELECT g, 'abc' || g FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET s = upper(s)$$);
+ updated | hot 
+---------+-----
+      10 |   0
+(1 row)
+
+-- A BRIN index whose column changed, and an expression index whose value
+-- did not.
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, s text, b int) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test (lower(s));
+    CREATE INDEX ON hot_expr_test USING brin (b);
+    INSERT INTO hot_expr_test SELECT g, 'abc' || g, g FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET s = upper(s), b = b + 1$$);
+ updated | hot 
+---------+-----
+      10 |   0
+(1 row)
+
+-- A BEFORE ROW trigger that changes the expression's value.
+CREATE FUNCTION hot_expr_trig() RETURNS trigger LANGUAGE plpgsql AS
+  $$BEGIN NEW.s := NEW.s || 'z'; RETURN NEW; END$$;
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, s text, x int) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test (lower(s));
+    CREATE TRIGGER t BEFORE UPDATE ON hot_expr_test
+      FOR EACH ROW EXECUTE FUNCTION hot_expr_trig();
+    INSERT INTO hot_expr_test SELECT g, 'abc' || g, 0 FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET x = 1$$);
+ updated | hot 
+---------+-----
+      10 |   0
+(1 row)
+
+DROP FUNCTION hot_expr_trig() CASCADE;
+NOTICE:  drop cascades to trigger t on table hot_expr_test
+-- Partial index, both versions outside the predicate, changing the key or
+-- the predicate's column.
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, status text, v int) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test (v) WHERE status = 'active';
+    INSERT INTO hot_expr_test SELECT g, 'done', g FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET v = v + 1$$);
+ updated | hot 
+---------+-----
+      10 |   0
+(1 row)
+
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, status text, v int) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test (v) WHERE status = 'active';
+    INSERT INTO hot_expr_test SELECT g, 'done', g FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET status = 'archived'$$);
+ updated | hot 
+---------+-----
+      10 |   0
+(1 row)
+
+-- Both versions inside the predicate with the key unchanged.
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, status text, v int) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test (v) WHERE status <> 'x';
+    INSERT INTO hot_expr_test SELECT g, 'a', g FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET status = 'b'$$);
+ updated | hot 
+---------+-----
+      10 |   0
+(1 row)
+
+-- Entering or leaving the predicate, or changing the key inside it.
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, status text, v int) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test (v) WHERE status = 'active';
+    INSERT INTO hot_expr_test SELECT g, 'active', g FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET status = 'done'$$);
+ updated | hot 
+---------+-----
+      10 |   0
+(1 row)
+
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, status text, v int) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test (v) WHERE status = 'active';
+    INSERT INTO hot_expr_test SELECT g, 'done', g FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET status = 'active'$$);
+ updated | hot 
+---------+-----
+      10 |   0
+(1 row)
+
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, status text, v int) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test (v) WHERE status = 'active';
+    INSERT INTO hot_expr_test SELECT g, 'active', g FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET v = v + 1000$$);
+ updated | hot 
+---------+-----
+      10 |   0
+(1 row)
+
+-- Rows updated outside a partial index must not be found through it.
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, status text, v int) WITH (fillfactor = 50);
+    CREATE INDEX hot_expr_test_v ON hot_expr_test (v) WHERE status = 'active';
+    INSERT INTO hot_expr_test SELECT g, CASE WHEN g <= 5 THEN 'active' ELSE 'done' END, g
+      FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET v = v + 100 WHERE status = 'done'$$);
+ updated | hot 
+---------+-----
+       5 |   0
+(1 row)
+
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+EXPLAIN (COSTS OFF) SELECT id FROM hot_expr_test WHERE status = 'active' AND v > 0;
+                    QUERY PLAN                     
+---------------------------------------------------
+ Index Scan using hot_expr_test_v on hot_expr_test
+   Index Cond: (v > 0)
+(2 rows)
+
+SELECT id FROM hot_expr_test WHERE status = 'active' AND v > 0 ORDER BY v;
+ id 
+----
+  1
+  2
+  3
+  4
+  5
+(5 rows)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+SELECT id FROM hot_expr_test WHERE status = 'active' AND v > 0 ORDER BY v;
+ id 
+----
+  1
+  2
+  3
+  4
+  5
+(5 rows)
+
+DROP TABLE hot_expr_test;
+-- One UPDATE whose rows each need a different index to decide: some change
+-- the expression index, some only enter or leave the partial index created
+-- after it, some change neither.  The partial index must hold exactly the
+-- rows that satisfy its predicate.
+CREATE TABLE hot_expr_multi (id int PRIMARY KEY, doc jsonb) WITH (fillfactor = 30);
+CREATE INDEX hot_expr_multi_status ON hot_expr_multi ((doc->>'status'));
+CREATE INDEX hot_expr_multi_vip ON hot_expr_multi ((doc->>'email'))
+  WHERE (doc->>'vip')::bool;
+INSERT INTO hot_expr_multi
+  SELECT g, jsonb_build_object('status', 'a', 'email', 'e' || g,
+                               'vip', g % 2 = 0, 'n', 0)
+    FROM generate_series(1, 300) g;
+UPDATE hot_expr_multi SET doc = CASE
+    WHEN id % 4 = 0 THEN jsonb_set(doc, '{status}', '"b"')
+    WHEN id % 4 = 1 THEN jsonb_set(doc, '{vip}', to_jsonb(NOT (doc->>'vip')::bool))
+    WHEN id % 4 = 2 THEN jsonb_set(jsonb_set(doc, '{status}', '"c"'),
+                                   '{vip}', to_jsonb(NOT (doc->>'vip')::bool))
+    ELSE jsonb_set(doc, '{n}', '1') END;
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM hot_expr_multi
+ WHERE (doc->>'vip')::bool AND doc->>'email' >= 'e';
+ count 
+-------
+   150
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+SELECT count(*) FROM hot_expr_multi WHERE (doc->>'vip')::bool;
+ count 
+-------
+   150
+(1 row)
+
+DROP TABLE hot_expr_multi;
+-- An index whose concurrent build failed is not ready for inserts, and its
+-- expression need not be computable for every row.  An UPDATE must not
+-- evaluate it.
+CREATE TABLE hot_expr_cic (id int PRIMARY KEY, x int) WITH (fillfactor = 50);
+INSERT INTO hot_expr_cic VALUES (1, 1), (2, 0);
+CREATE INDEX CONCURRENTLY hot_expr_cic_idx ON hot_expr_cic ((100 / x));
+ERROR:  division by zero
+SELECT indisready, indisvalid FROM pg_index
+ WHERE indexrelid = 'hot_expr_cic_idx'::regclass;
+ indisready | indisvalid 
+------------+------------
+ f          | f
+(1 row)
+
+UPDATE hot_expr_cic SET x = 0 WHERE id = 1;
+UPDATE hot_expr_cic SET x = 1 WHERE id = 2;
+SELECT * FROM hot_expr_cic ORDER BY id;
+ id | x 
+----+---
+  1 | 0
+  2 | 1
+(2 rows)
+
+DROP TABLE hot_expr_cic;
+-- Writing back a value that is stored compressed in the old row, and not
+-- yet in the new one.  An SP-GiST index, whose access method cannot compare
+-- entries, and which returns the value in index-only scans.
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, t text) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test USING spgist (t);
+    INSERT INTO hot_expr_test SELECT g, repeat('x', 3000) || g FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET t = repeat('x', 3000) || id$$);
+NOTICE:  table "hot_expr_test" does not exist, skipping
+ updated | hot 
+---------+-----
+      10 |   0
+(1 row)
+
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM hot_expr_test WHERE t = repeat('x', 3000) || 7;
+ count 
+-------
+     1
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+-- The same in a partial index evaluated for another column's change.
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, t text, x int) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test (t) WHERE x >= 0;
+    INSERT INTO hot_expr_test SELECT g, repeat('x', 3000) || g, 0 FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET t = repeat('x', 3000) || id, x = x + 1$$);
+ updated | hot 
+---------+-----
+      10 |   0
+(1 row)
+
+-- The same through an expression that returns the column's value as is.
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, t text, x int) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test ((coalesce(t, ''))) WHERE x >= 0;
+    INSERT INTO hot_expr_test SELECT g, repeat('x', 3000) || g, 0 FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET t = repeat('x', 3000) || id, x = x + 1$$);
+ updated | hot 
+---------+-----
+      10 |   0
+(1 row)
+
+-- ... but not for a column of a unique index, which the table's lock
+-- strength is decided by as stored.
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int, t text) WITH (fillfactor = 50);
+    CREATE UNIQUE INDEX ON hot_expr_test (t);
+    INSERT INTO hot_expr_test SELECT g, repeat('x', 3000) || g FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET t = repeat('x', 3000) || id$$);
+ updated | hot 
+---------+-----
+      10 |   0
+(1 row)
+
+DROP TABLE hot_expr_test;
+DROP FUNCTION update_case(text, text);
+DROP FUNCTION get_hot_count(text);
diff --git a/src/test/regress/parallel_schedule b/src/test/regress/parallel_schedule
index bb13a7b6b8f..6c165fd6cc4 100644
--- a/src/test/regress/parallel_schedule
+++ b/src/test/regress/parallel_schedule
@@ -81,7 +81,7 @@ test: create_table_like alter_generic alter_operator misc async dbsize merge mis
 # collate.linux.utf8 and collate.icu.utf8 tests cannot be run in parallel with each other
 # psql depends on create_am
 # amutils depends on geometry, create_index_spgist, hash_index, brin
-test: rules psql psql_crosstab psql_pipeline amutils stats_ext collate.linux.utf8 collate.windows.win1252
+test: rules psql psql_crosstab psql_pipeline amutils stats_ext collate.linux.utf8 collate.windows.win1252 hot_updates
 
 # ----------
 # Run these alone so they don't run out of parallel workers
diff --git a/src/test/regress/sql/hot_updates.sql b/src/test/regress/sql/hot_updates.sql
new file mode 100644
index 00000000000..c523c908c6b
--- /dev/null
+++ b/src/test/regress/sql/hot_updates.sql
@@ -0,0 +1,468 @@
+--
+-- HOT_UPDATES
+-- Test Heap-Only Tuple (HOT) update decisions
+--
+-- Expected output records which UPDATEs are HOT, so that a change to the
+-- rules shows up as a change to this file.  contrib/pageinspect's
+-- hot_updates test looks at the HOT chain itself.
+--
+
+-- Sum of committed and in-progress (non-HOT, HOT) update counters.
+CREATE FUNCTION get_hot_count(rel_name text)
+RETURNS TABLE (
+    updates BIGINT,
+    hot BIGINT
+) AS $$
+DECLARE
+    rel_oid oid;
+BEGIN
+    rel_oid := rel_name::regclass::oid;
+    updates := COALESCE(pg_stat_get_tuples_updated(rel_oid), 0) +
+               COALESCE(pg_stat_get_xact_tuples_updated(rel_oid), 0);
+    hot := COALESCE(pg_stat_get_tuples_hot_updated(rel_oid), 0) +
+           COALESCE(pg_stat_get_xact_tuples_hot_updated(rel_oid), 0);
+    RETURN NEXT;
+END;
+$$ LANGUAGE plpgsql;
+
+-- ---------------------------------------------------------------------------
+-- Summarizing indexes (BRIN) do not block HOT
+-- ---------------------------------------------------------------------------
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    ts timestamp,
+    value int,
+    brin_col int
+) WITH (fillfactor = 50);
+CREATE INDEX hot_test_ts_brin ON hot_test USING brin(ts);
+CREATE INDEX hot_test_brin_col_brin ON hot_test USING brin(brin_col);
+
+INSERT INTO hot_test VALUES (1, '2024-01-01', 100, 1000);
+
+-- BRIN columns are summarizing; updating them stays classic HOT even
+-- though their values change.
+UPDATE hot_test SET ts = '2024-01-02', brin_col = 2000 WHERE id = 1;
+SELECT pg_stat_force_next_flush();
+SELECT * FROM get_hot_count('hot_test');
+
+-- Non-indexed column: also HOT.
+UPDATE hot_test SET value = 200 WHERE id = 1;
+SELECT pg_stat_force_next_flush();
+SELECT * FROM get_hot_count('hot_test');
+
+DROP TABLE hot_test;
+
+-- ---------------------------------------------------------------------------
+-- TOAST participates in HOT (non-indexed column paths only)
+-- ---------------------------------------------------------------------------
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    indexed_col int,
+    large_text text,
+    small_text text
+) WITH (fillfactor = 50);
+CREATE INDEX hot_test_idx ON hot_test(indexed_col);
+-- stored out of line at any block size
+ALTER TABLE hot_test ALTER COLUMN large_text SET STORAGE EXTERNAL;
+SELECT current_setting('block_size')::int / 2 AS large_len \gset
+
+INSERT INTO hot_test VALUES (1, 100, repeat('x', :large_len), 'small');
+SELECT pg_column_toast_chunk_id(large_text) IS NOT NULL AS toasted
+  FROM hot_test;
+
+-- Non-indexed, non-TOAST column: HOT.
+UPDATE hot_test SET small_text = 'updated';
+SELECT pg_stat_force_next_flush();
+SELECT * FROM get_hot_count('hot_test');
+
+-- TOAST column, indexed_col unchanged: HOT.
+UPDATE hot_test SET large_text = repeat('y', :large_len);
+SELECT pg_stat_force_next_flush();
+SELECT * FROM get_hot_count('hot_test');
+
+DROP TABLE hot_test;
+
+-- ---------------------------------------------------------------------------
+-- Partial index where update leaves indexed attrs unchanged
+-- ---------------------------------------------------------------------------
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    status text,
+    data text
+) WITH (fillfactor = 50);
+CREATE INDEX hot_test_active_idx ON hot_test(status) WHERE status = 'active';
+
+INSERT INTO hot_test VALUES (1, 'active', 'data1');
+INSERT INTO hot_test VALUES (2, 'inactive', 'data2');
+INSERT INTO hot_test VALUES (3, 'deleted', 'data3');
+
+-- Update data on a row whose status matches the partial predicate: HOT.
+UPDATE hot_test SET data = 'updated1' WHERE id = 1;
+SELECT pg_stat_force_next_flush();
+SELECT * FROM get_hot_count('hot_test');
+
+-- Update data on a row outside the predicate: HOT.
+UPDATE hot_test SET data = 'updated2' WHERE id = 2;
+SELECT pg_stat_force_next_flush();
+SELECT * FROM get_hot_count('hot_test');
+
+SELECT id, status FROM hot_test WHERE status = 'active';
+
+DROP TABLE hot_test;
+
+-- ---------------------------------------------------------------------------
+-- Multi-column btree: update of non-indexed column
+-- ---------------------------------------------------------------------------
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    col_a int,
+    col_b int,
+    col_c int,
+    data text
+) WITH (fillfactor = 50);
+CREATE INDEX hot_test_ab_idx ON hot_test(col_a, col_b);
+
+INSERT INTO hot_test VALUES (1, 10, 20, 30, 'data');
+
+-- col_c not in any index: HOT.
+UPDATE hot_test SET col_c = 35;
+-- data not in any index: HOT.
+UPDATE hot_test SET data = 'updated';
+SELECT pg_stat_force_next_flush();
+SELECT * FROM get_hot_count('hot_test');
+
+DROP TABLE hot_test;
+
+-- ---------------------------------------------------------------------------
+-- Unique index: update of non-indexed column + uniqueness enforcement
+-- ---------------------------------------------------------------------------
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    unique_col int UNIQUE,
+    data text
+) WITH (fillfactor = 50);
+
+INSERT INTO hot_test VALUES (1, 100, 'data1');
+INSERT INTO hot_test VALUES (2, 200, 'data2');
+
+UPDATE hot_test SET data = 'updated';
+SELECT pg_stat_force_next_flush();
+SELECT * FROM get_hot_count('hot_test');
+
+SELECT id, unique_col, data FROM hot_test ORDER BY id;
+
+-- Unique constraint still enforced on any path.
+UPDATE hot_test SET unique_col = 100 WHERE id = 2;
+
+DROP TABLE hot_test;
+
+-- ---------------------------------------------------------------------------
+-- Partitioned tables: HOT within a partition
+-- ---------------------------------------------------------------------------
+CREATE TABLE hot_test_partitioned (
+    id int,
+    partition_key int,
+    indexed_col int,
+    data text,
+    PRIMARY KEY (id, partition_key)
+) PARTITION BY RANGE (partition_key);
+
+CREATE TABLE hot_test_part1 PARTITION OF hot_test_partitioned
+    FOR VALUES FROM (1) TO (100) WITH (fillfactor = 50);
+CREATE TABLE hot_test_part2 PARTITION OF hot_test_partitioned
+    FOR VALUES FROM (100) TO (200) WITH (fillfactor = 50);
+
+CREATE INDEX hot_test_part_idx ON hot_test_partitioned(indexed_col);
+
+INSERT INTO hot_test_partitioned VALUES (1, 50, 100, 'initial1');
+INSERT INTO hot_test_partitioned VALUES (2, 150, 200, 'initial2');
+
+UPDATE hot_test_partitioned SET data = 'updated1' WHERE id = 1;
+UPDATE hot_test_partitioned SET data = 'updated2' WHERE id = 2;
+
+SELECT pg_stat_force_next_flush();
+SELECT * FROM get_hot_count('hot_test_part1');
+SELECT * FROM get_hot_count('hot_test_part2');
+
+SELECT id FROM hot_test_partitioned WHERE indexed_col = 100;
+SELECT id FROM hot_test_partitioned WHERE indexed_col = 200;
+
+DROP TABLE hot_test_partitioned CASCADE;
+
+-- An update through the parent that writes back an indexed column's value
+-- is HOT.  Routing hands the partition a new tuple whose short text value
+-- has not been packed yet; it is still the same value.
+CREATE TABLE hot_test_partitioned (id int UNIQUE, tag char, n int)
+    PARTITION BY LIST (id);
+CREATE TABLE hot_test_part1 PARTITION OF hot_test_partitioned
+    (tag UNIQUE) FOR VALUES IN (1, 2) WITH (fillfactor = 50);
+INSERT INTO hot_test_partitioned VALUES (1, 'a', 0), (2, 'b', 0);
+INSERT INTO hot_test_partitioned VALUES (1, 'a', 1)
+    ON CONFLICT (id) DO UPDATE SET tag = excluded.tag, n = excluded.n;
+SELECT pg_stat_force_next_flush();
+SELECT * FROM get_hot_count('hot_test_part1');
+DROP TABLE hot_test_partitioned CASCADE;
+
+-- ---------------------------------------------------------------------------
+-- Expression index on a JSONB field
+-- ---------------------------------------------------------------------------
+CREATE TABLE hot_jsonb_test (
+    id int PRIMARY KEY,
+    data jsonb
+) WITH (fillfactor = 50);
+CREATE INDEX hot_jsonb_name_idx ON hot_jsonb_test ((data->>'name'));
+
+INSERT INTO hot_jsonb_test VALUES
+    (1, '{"name":"Alice","age":30,"city":"NYC"}'),
+    (2, '{"name":"Bob","age":25,"city":"LA"}');
+
+-- Each UPDATE changes the indexed column but not the indexed expression.
+UPDATE hot_jsonb_test SET data = jsonb_set(data, '{age}', '31') WHERE id = 1;
+SELECT pg_stat_force_next_flush();
+SELECT * FROM get_hot_count('hot_jsonb_test');
+
+UPDATE hot_jsonb_test SET data = data - 'city' WHERE id = 2;
+SELECT pg_stat_force_next_flush();
+SELECT * FROM get_hot_count('hot_jsonb_test');
+
+UPDATE hot_jsonb_test SET data = jsonb_insert(data, '{country}', '"USA"') WHERE id = 2;
+SELECT pg_stat_force_next_flush();
+SELECT * FROM get_hot_count('hot_jsonb_test');
+
+DROP TABLE hot_jsonb_test;
+
+
+-- ---------------------------------------------------------------------------
+-- Expression and partial indexes
+--
+-- update_case() runs one UPDATE on a fresh table and reports how many rows
+-- it updated, and how many of those with HOT.
+-- ---------------------------------------------------------------------------
+CREATE FUNCTION update_case(setup text, upd text,
+                            OUT updated bigint, OUT hot bigint)
+LANGUAGE plpgsql AS $$
+BEGIN
+    EXECUTE 'DROP TABLE IF EXISTS hot_expr_test';
+    EXECUTE setup;
+    updated := pg_stat_get_xact_tuples_updated('hot_expr_test'::regclass);
+    hot := pg_stat_get_xact_tuples_hot_updated('hot_expr_test'::regclass);
+    EXECUTE upd;
+    updated := pg_stat_get_xact_tuples_updated('hot_expr_test'::regclass) - updated;
+    hot := pg_stat_get_xact_tuples_hot_updated('hot_expr_test'::regclass) - hot;
+END;
+$$;
+
+-- Expression value unchanged.
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, s text) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test (lower(s));
+    INSERT INTO hot_expr_test SELECT g, 'abc' || g FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET s = upper(s)$$);
+
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, a int, b int) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test ((a + b));
+    INSERT INTO hot_expr_test SELECT g, g, 0 FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET a = a - 1, b = b + 1$$);
+
+-- Expression value changed.
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, d jsonb) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test ((d->>'name'));
+    INSERT INTO hot_expr_test SELECT g, jsonb_build_object('name', 'n' || g)
+      FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET d = jsonb_set(d, '{name}', '"x"')$$);
+
+-- Equal values that are stored differently.
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, n numeric) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test ((n + 0));
+    INSERT INTO hot_expr_test SELECT g, 3 FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET n = 3.0$$);
+
+-- The index stores a type other than the expression's: name_ops stores
+-- cstring, and gist point_ops stores box.
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, s text) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test ((lower(s)::name));
+    INSERT INTO hot_expr_test SELECT g, 'abc' || g FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET s = upper(s)$$);
+
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, p point) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test USING gist ((point(p[0], 0)));
+    INSERT INTO hot_expr_test SELECT g, point(g, g) FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET p = point(p[0], p[1] + 1)$$);
+
+-- An expression that is a point, stored by the gist index as a box.
+-- Comparing it as the box the index stores rather than as the point it is
+-- crashed the server in 2018 (2877.1541538838@sss.pgh.pa.us).
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, d smallint) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test USING gist ((point(d::integer % 7, 0)));
+    INSERT INTO hot_expr_test SELECT g, 1 FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET d = d + 7$$);
+
+-- A plain index on the same column as an expression index.
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, s text) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test (lower(s));
+    CREATE INDEX ON hot_expr_test (s);
+    INSERT INTO hot_expr_test SELECT g, 'abc' || g FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET s = upper(s)$$);
+
+-- A BRIN index whose column changed, and an expression index whose value
+-- did not.
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, s text, b int) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test (lower(s));
+    CREATE INDEX ON hot_expr_test USING brin (b);
+    INSERT INTO hot_expr_test SELECT g, 'abc' || g, g FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET s = upper(s), b = b + 1$$);
+
+-- A BEFORE ROW trigger that changes the expression's value.
+CREATE FUNCTION hot_expr_trig() RETURNS trigger LANGUAGE plpgsql AS
+  $$BEGIN NEW.s := NEW.s || 'z'; RETURN NEW; END$$;
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, s text, x int) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test (lower(s));
+    CREATE TRIGGER t BEFORE UPDATE ON hot_expr_test
+      FOR EACH ROW EXECUTE FUNCTION hot_expr_trig();
+    INSERT INTO hot_expr_test SELECT g, 'abc' || g, 0 FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET x = 1$$);
+DROP FUNCTION hot_expr_trig() CASCADE;
+
+-- Partial index, both versions outside the predicate, changing the key or
+-- the predicate's column.
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, status text, v int) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test (v) WHERE status = 'active';
+    INSERT INTO hot_expr_test SELECT g, 'done', g FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET v = v + 1$$);
+
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, status text, v int) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test (v) WHERE status = 'active';
+    INSERT INTO hot_expr_test SELECT g, 'done', g FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET status = 'archived'$$);
+
+-- Both versions inside the predicate with the key unchanged.
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, status text, v int) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test (v) WHERE status <> 'x';
+    INSERT INTO hot_expr_test SELECT g, 'a', g FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET status = 'b'$$);
+
+-- Entering or leaving the predicate, or changing the key inside it.
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, status text, v int) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test (v) WHERE status = 'active';
+    INSERT INTO hot_expr_test SELECT g, 'active', g FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET status = 'done'$$);
+
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, status text, v int) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test (v) WHERE status = 'active';
+    INSERT INTO hot_expr_test SELECT g, 'done', g FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET status = 'active'$$);
+
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, status text, v int) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test (v) WHERE status = 'active';
+    INSERT INTO hot_expr_test SELECT g, 'active', g FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET v = v + 1000$$);
+
+-- Rows updated outside a partial index must not be found through it.
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, status text, v int) WITH (fillfactor = 50);
+    CREATE INDEX hot_expr_test_v ON hot_expr_test (v) WHERE status = 'active';
+    INSERT INTO hot_expr_test SELECT g, CASE WHEN g <= 5 THEN 'active' ELSE 'done' END, g
+      FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET v = v + 100 WHERE status = 'done'$$);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+EXPLAIN (COSTS OFF) SELECT id FROM hot_expr_test WHERE status = 'active' AND v > 0;
+SELECT id FROM hot_expr_test WHERE status = 'active' AND v > 0 ORDER BY v;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+SELECT id FROM hot_expr_test WHERE status = 'active' AND v > 0 ORDER BY v;
+
+DROP TABLE hot_expr_test;
+
+-- One UPDATE whose rows each need a different index to decide: some change
+-- the expression index, some only enter or leave the partial index created
+-- after it, some change neither.  The partial index must hold exactly the
+-- rows that satisfy its predicate.
+CREATE TABLE hot_expr_multi (id int PRIMARY KEY, doc jsonb) WITH (fillfactor = 30);
+CREATE INDEX hot_expr_multi_status ON hot_expr_multi ((doc->>'status'));
+CREATE INDEX hot_expr_multi_vip ON hot_expr_multi ((doc->>'email'))
+  WHERE (doc->>'vip')::bool;
+INSERT INTO hot_expr_multi
+  SELECT g, jsonb_build_object('status', 'a', 'email', 'e' || g,
+                               'vip', g % 2 = 0, 'n', 0)
+    FROM generate_series(1, 300) g;
+UPDATE hot_expr_multi SET doc = CASE
+    WHEN id % 4 = 0 THEN jsonb_set(doc, '{status}', '"b"')
+    WHEN id % 4 = 1 THEN jsonb_set(doc, '{vip}', to_jsonb(NOT (doc->>'vip')::bool))
+    WHEN id % 4 = 2 THEN jsonb_set(jsonb_set(doc, '{status}', '"c"'),
+                                   '{vip}', to_jsonb(NOT (doc->>'vip')::bool))
+    ELSE jsonb_set(doc, '{n}', '1') END;
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM hot_expr_multi
+ WHERE (doc->>'vip')::bool AND doc->>'email' >= 'e';
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+SELECT count(*) FROM hot_expr_multi WHERE (doc->>'vip')::bool;
+DROP TABLE hot_expr_multi;
+
+-- An index whose concurrent build failed is not ready for inserts, and its
+-- expression need not be computable for every row.  An UPDATE must not
+-- evaluate it.
+CREATE TABLE hot_expr_cic (id int PRIMARY KEY, x int) WITH (fillfactor = 50);
+INSERT INTO hot_expr_cic VALUES (1, 1), (2, 0);
+CREATE INDEX CONCURRENTLY hot_expr_cic_idx ON hot_expr_cic ((100 / x));
+SELECT indisready, indisvalid FROM pg_index
+ WHERE indexrelid = 'hot_expr_cic_idx'::regclass;
+UPDATE hot_expr_cic SET x = 0 WHERE id = 1;
+UPDATE hot_expr_cic SET x = 1 WHERE id = 2;
+SELECT * FROM hot_expr_cic ORDER BY id;
+DROP TABLE hot_expr_cic;
+
+-- Writing back a value that is stored compressed in the old row, and not
+-- yet in the new one.  An SP-GiST index, whose access method cannot compare
+-- entries, and which returns the value in index-only scans.
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, t text) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test USING spgist (t);
+    INSERT INTO hot_expr_test SELECT g, repeat('x', 3000) || g FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET t = repeat('x', 3000) || id$$);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM hot_expr_test WHERE t = repeat('x', 3000) || 7;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+-- The same in a partial index evaluated for another column's change.
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, t text, x int) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test (t) WHERE x >= 0;
+    INSERT INTO hot_expr_test SELECT g, repeat('x', 3000) || g, 0 FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET t = repeat('x', 3000) || id, x = x + 1$$);
+-- The same through an expression that returns the column's value as is.
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int PRIMARY KEY, t text, x int) WITH (fillfactor = 50);
+    CREATE INDEX ON hot_expr_test ((coalesce(t, ''))) WHERE x >= 0;
+    INSERT INTO hot_expr_test SELECT g, repeat('x', 3000) || g, 0 FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET t = repeat('x', 3000) || id, x = x + 1$$);
+-- ... but not for a column of a unique index, which the table's lock
+-- strength is decided by as stored.
+SELECT * FROM update_case(
+  $$CREATE TABLE hot_expr_test (id int, t text) WITH (fillfactor = 50);
+    CREATE UNIQUE INDEX ON hot_expr_test (t);
+    INSERT INTO hot_expr_test SELECT g, repeat('x', 3000) || g FROM generate_series(1, 10) g$$,
+  $$UPDATE hot_expr_test SET t = repeat('x', 3000) || id$$);
+DROP TABLE hot_expr_test;
+
+DROP FUNCTION update_case(text, text);
+
+DROP FUNCTION get_hot_count(text);

base-commit: 952bab8b74d40fcfd25e1949865c3907e676504e
-- 
2.50.1

