From 481ba1507e6cad3f04110c2ed06cc19382e6aa63 Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Tue, 10 Mar 2026 09:28:15 -0400 Subject: [PATCH v43 1/8] 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. Discussion: https://postgr.es/m/78574B24-BE0A-42C5-8075-3FA9FA63B8FC@amazon.com --- contrib/pageinspect/Makefile | 3 +- contrib/pageinspect/expected/hot_updates.out | 130 ++++ contrib/pageinspect/meson.build | 1 + contrib/pageinspect/sql/hot_updates.sql | 112 +++ .../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, 1587 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..9ce6a34f4e3 --- /dev/null +++ b/contrib/pageinspect/expected/hot_updates.out @@ -0,0 +1,130 @@ +-- +-- 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; +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..14df5bdb180 --- /dev/null +++ b/contrib/pageinspect/sql/hot_updates.sql @@ -0,0 +1,112 @@ +-- +-- 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; + +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; +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; +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; +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 bf9a2037c70..d7da899aa85 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -43,6 +43,7 @@ test: fk-fastpath-null-key test: subxid-overflow test: eval-plan-qual 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 75063f87a4a..bb8e5f958c1 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: 871dee0ccbe292cf4c2874aa7b460d242262d242 -- 2.50.1