From c09fd7d1825965db3698fad8b8b32b625155e45a Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Tue, 10 Mar 2026 09:28:15 -0400 Subject: [PATCH v35 1/3] Add tests to cover a variety of heap HOT update behaviors This commit introduces test infrastructure for verifying Heap-Only Tuple (HOT) update functionality in PostgreSQL. It provides a baseline for demonstrating and validating HOT update behavior. Regression tests: - Basic HOT vs non-HOT update decisions - All-or-none property for multiple indexes - Partial indexes and predicate handling - BRIN (summarizing) indexes allowing HOT updates - TOAST column handling with HOT - Unique constraints behavior - Multi-column indexes - Partitioned table HOT updates Isolation tests: - HOT chain formation and maintenance - Concurrent HOT update scenarios - Index scan behavior with HOT chains --- .../isolation/expected/hot_updates_chain.out | 144 +++ .../expected/hot_updates_concurrent.out | 143 +++ .../expected/hot_updates_index_scan.out | 132 +++ src/test/isolation/isolation_schedule | 3 + .../isolation/specs/hot_updates_chain.spec | 110 ++ .../specs/hot_updates_concurrent.spec | 107 ++ .../specs/hot_updates_index_scan.spec | 94 ++ src/test/regress/expected/hot_updates.out | 950 ++++++++++++++++++ src/test/regress/parallel_schedule | 5 + src/test/regress/sql/hot_updates.sql | 692 +++++++++++++ 10 files changed, 2380 insertions(+) create mode 100644 src/test/isolation/expected/hot_updates_chain.out create mode 100644 src/test/isolation/expected/hot_updates_concurrent.out create mode 100644 src/test/isolation/expected/hot_updates_index_scan.out create mode 100644 src/test/isolation/specs/hot_updates_chain.spec create mode 100644 src/test/isolation/specs/hot_updates_concurrent.spec create mode 100644 src/test/isolation/specs/hot_updates_index_scan.spec create mode 100644 src/test/regress/expected/hot_updates.out create mode 100644 src/test/regress/sql/hot_updates.sql diff --git a/src/test/isolation/expected/hot_updates_chain.out b/src/test/isolation/expected/hot_updates_chain.out new file mode 100644 index 00000000000..503252009ea --- /dev/null +++ b/src/test/isolation/expected/hot_updates_chain.out @@ -0,0 +1,144 @@ +Parsed test spec with 5 sessions + +starting permutation: s1_begin s1_hot_update1 s1_hot_update2 s1_hot_update3 s1_commit s1_select s1_verify_hot +step s1_begin: BEGIN; +step s1_hot_update1: UPDATE hot_test SET non_indexed_col = 'update1' WHERE id = 1; +step s1_hot_update2: UPDATE hot_test SET non_indexed_col = 'update2' WHERE id = 1; +step s1_hot_update3: UPDATE hot_test SET non_indexed_col = 'update3' WHERE id = 1; +step s1_commit: COMMIT; +step s1_select: SELECT * FROM hot_test WHERE id = 1; +id|indexed_col|non_indexed_col +--+-----------+--------------- + 1| 100|update3 +(1 row) + +step s1_verify_hot: + -- Check for HOT chain: LP_REDIRECT or tuple with t_ctid pointing to same page + SELECT COUNT(*) > 0 AS has_hot_chain + FROM heap_page_items(get_raw_page('hot_test', 0)) + WHERE lp_flags = 2 -- LP_REDIRECT indicates HOT chain + OR (t_ctid IS NOT NULL + AND (t_ctid::text::point)[0]::int = 0 -- same page + AND t_ctid != ('(0,' || lp || ')')::tid); -- different offset + +has_hot_chain +------------- +t +(1 row) + + +starting permutation: s2_begin s2_select_before s1_begin s1_hot_update1 s1_hot_update2 s1_commit s2_select_after s2_commit +step s2_begin: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2_select_before: SELECT non_indexed_col FROM hot_test WHERE id = 1; +non_indexed_col +--------------- +initial +(1 row) + +step s1_begin: BEGIN; +step s1_hot_update1: UPDATE hot_test SET non_indexed_col = 'update1' WHERE id = 1; +step s1_hot_update2: UPDATE hot_test SET non_indexed_col = 'update2' WHERE id = 1; +step s1_commit: COMMIT; +step s2_select_after: SELECT non_indexed_col FROM hot_test WHERE id = 1; +non_indexed_col +--------------- +initial +(1 row) + +step s2_commit: COMMIT; + +starting permutation: s1_begin s1_hot_update1 s1_hot_update2 s1_commit s3_begin s3_non_hot_update s3_commit s1_select +step s1_begin: BEGIN; +step s1_hot_update1: UPDATE hot_test SET non_indexed_col = 'update1' WHERE id = 1; +step s1_hot_update2: UPDATE hot_test SET non_indexed_col = 'update2' WHERE id = 1; +step s1_commit: COMMIT; +step s3_begin: BEGIN; +step s3_non_hot_update: UPDATE hot_test SET indexed_col = 150 WHERE id = 1; +step s3_commit: COMMIT; +step s1_select: SELECT * FROM hot_test WHERE id = 1; +id|indexed_col|non_indexed_col +--+-----------+--------------- + 1| 150|update2 +(1 row) + + +starting permutation: s1_begin s1_hot_update1 s1_commit s3_begin s3_non_hot_update s3_commit s4_begin s4_hot_after_non_hot s4_commit s4_select s4_verify_hot +step s1_begin: BEGIN; +step s1_hot_update1: UPDATE hot_test SET non_indexed_col = 'update1' WHERE id = 1; +step s1_commit: COMMIT; +step s3_begin: BEGIN; +step s3_non_hot_update: UPDATE hot_test SET indexed_col = 150 WHERE id = 1; +step s3_commit: COMMIT; +step s4_begin: BEGIN; +step s4_hot_after_non_hot: UPDATE hot_test SET non_indexed_col = 'after_non_hot' WHERE id = 1; +step s4_commit: COMMIT; +step s4_select: SELECT * FROM hot_test WHERE id = 1; +id|indexed_col|non_indexed_col +--+-----------+--------------- + 1| 150|after_non_hot +(1 row) + +step s4_verify_hot: + -- Check for new HOT chain after non-HOT update broke the previous chain + SELECT COUNT(*) > 0 AS has_hot_chain + FROM heap_page_items(get_raw_page('hot_test', 0)) + WHERE lp_flags = 2 + OR (t_ctid IS NOT NULL + AND (t_ctid::text::point)[0]::int = 0 + AND t_ctid != ('(0,' || lp || ')')::tid); + +has_hot_chain +------------- +t +(1 row) + + +starting permutation: s1_begin s1_hot_update1 s1_hot_update2 s5_begin s5_hot_update_row2_1 s5_hot_update_row2_2 s1_commit s5_commit s1_select s5_select s1_verify_hot s5_verify_hot +step s1_begin: BEGIN; +step s1_hot_update1: UPDATE hot_test SET non_indexed_col = 'update1' WHERE id = 1; +step s1_hot_update2: UPDATE hot_test SET non_indexed_col = 'update2' WHERE id = 1; +step s5_begin: BEGIN; +step s5_hot_update_row2_1: UPDATE hot_test SET non_indexed_col = 'row2_update1' WHERE id = 2; +step s5_hot_update_row2_2: UPDATE hot_test SET non_indexed_col = 'row2_update2' WHERE id = 2; +step s1_commit: COMMIT; +step s5_commit: COMMIT; +step s1_select: SELECT * FROM hot_test WHERE id = 1; +id|indexed_col|non_indexed_col +--+-----------+--------------- + 1| 100|update2 +(1 row) + +step s5_select: SELECT * FROM hot_test WHERE id = 2; +id|indexed_col|non_indexed_col +--+-----------+--------------- + 2| 200|row2_update2 +(1 row) + +step s1_verify_hot: + -- Check for HOT chain: LP_REDIRECT or tuple with t_ctid pointing to same page + SELECT COUNT(*) > 0 AS has_hot_chain + FROM heap_page_items(get_raw_page('hot_test', 0)) + WHERE lp_flags = 2 -- LP_REDIRECT indicates HOT chain + OR (t_ctid IS NOT NULL + AND (t_ctid::text::point)[0]::int = 0 -- same page + AND t_ctid != ('(0,' || lp || ')')::tid); -- different offset + +has_hot_chain +------------- +t +(1 row) + +step s5_verify_hot: + -- Check for HOT chain on page 0 + SELECT COUNT(*) > 0 AS has_hot_chain + FROM heap_page_items(get_raw_page('hot_test', 0)) + WHERE lp_flags = 2 + OR (t_ctid IS NOT NULL + AND (t_ctid::text::point)[0]::int = 0 + AND t_ctid != ('(0,' || lp || ')')::tid); + +has_hot_chain +------------- +t +(1 row) + diff --git a/src/test/isolation/expected/hot_updates_concurrent.out b/src/test/isolation/expected/hot_updates_concurrent.out new file mode 100644 index 00000000000..b1a8b0cb7b2 --- /dev/null +++ b/src/test/isolation/expected/hot_updates_concurrent.out @@ -0,0 +1,143 @@ +Parsed test spec with 4 sessions + +starting permutation: s1_begin s1_hot_update s2_begin s2_hot_update s1_commit s2_commit s1_select s2_select s2_verify_hot +step s1_begin: BEGIN; +step s1_hot_update: UPDATE hot_test SET non_indexed_col = 'updated_s1' WHERE id = 1; +step s2_begin: BEGIN; +step s2_hot_update: UPDATE hot_test SET non_indexed_col = 'updated_s2' WHERE id = 1; +step s1_commit: COMMIT; +step s2_hot_update: <... completed> +step s2_commit: COMMIT; +step s1_select: SELECT * FROM hot_test WHERE id = 1; +id|indexed_col|non_indexed_col +--+-----------+--------------- + 1| 100|updated_s2 +(1 row) + +step s2_select: SELECT * FROM hot_test WHERE id = 1; +id|indexed_col|non_indexed_col +--+-----------+--------------- + 1| 100|updated_s2 +(1 row) + +step s2_verify_hot: + -- Check for HOT chain: look for LP_REDIRECT (lp_flags=2) or tuple with t_ctid pointing to same page + SELECT COUNT(*) > 0 AS has_hot_chain + FROM heap_page_items(get_raw_page('hot_test', 0)) + WHERE lp_flags = 2 -- LP_REDIRECT indicates HOT chain + OR (t_ctid IS NOT NULL + AND (t_ctid::text::point)[0]::int = 0 -- same page + AND t_ctid != ('(0,' || lp || ')')::tid); -- different offset + +has_hot_chain +------------- +t +(1 row) + + +starting permutation: s1_begin s1_hot_update s3_begin s3_non_hot_update s1_commit s3_commit s3_select s3_verify_index +step s1_begin: BEGIN; +step s1_hot_update: UPDATE hot_test SET non_indexed_col = 'updated_s1' WHERE id = 1; +step s3_begin: BEGIN; +step s3_non_hot_update: UPDATE hot_test SET indexed_col = 150 WHERE id = 1; +step s1_commit: COMMIT; +step s3_non_hot_update: <... completed> +step s3_commit: COMMIT; +step s3_select: SELECT * FROM hot_test WHERE id = 1; +id|indexed_col|non_indexed_col +--+-----------+--------------- + 1| 150|updated_s1 +(1 row) + +step s3_verify_index: + -- Verify index was updated (proves non-HOT) + SELECT COUNT(*) = 1 AS index_updated FROM hot_test WHERE indexed_col = 150; + SELECT COUNT(*) = 0 AS old_value_gone FROM hot_test WHERE indexed_col = 100; + +index_updated +------------- +t +(1 row) + +old_value_gone +-------------- +t +(1 row) + + +starting permutation: s3_begin s3_non_hot_update s1_begin s1_hot_update s3_commit s1_commit s1_select s1_verify_hot +step s3_begin: BEGIN; +step s3_non_hot_update: UPDATE hot_test SET indexed_col = 150 WHERE id = 1; +step s1_begin: BEGIN; +step s1_hot_update: UPDATE hot_test SET non_indexed_col = 'updated_s1' WHERE id = 1; +step s3_commit: COMMIT; +step s1_hot_update: <... completed> +step s1_commit: COMMIT; +step s1_select: SELECT * FROM hot_test WHERE id = 1; +id|indexed_col|non_indexed_col +--+-----------+--------------- + 1| 150|updated_s1 +(1 row) + +step s1_verify_hot: + -- Check for HOT chain: look for LP_REDIRECT (lp_flags=2) or tuple with t_ctid pointing to same page + SELECT COUNT(*) > 0 AS has_hot_chain + FROM heap_page_items(get_raw_page('hot_test', 0)) + WHERE lp_flags = 2 -- LP_REDIRECT indicates HOT chain + OR (t_ctid IS NOT NULL + AND (t_ctid::text::point)[0]::int = 0 -- same page + AND t_ctid != ('(0,' || lp || ')')::tid); -- different offset + +has_hot_chain +------------- +t +(1 row) + + +starting permutation: s1_begin s1_hot_update s4_begin s4_hot_update_row2 s1_commit s4_commit s1_select s4_select s1_verify_hot s4_verify_hot +step s1_begin: BEGIN; +step s1_hot_update: UPDATE hot_test SET non_indexed_col = 'updated_s1' WHERE id = 1; +step s4_begin: BEGIN; +step s4_hot_update_row2: UPDATE hot_test SET non_indexed_col = 'updated_s4' WHERE id = 2; +step s1_commit: COMMIT; +step s4_commit: COMMIT; +step s1_select: SELECT * FROM hot_test WHERE id = 1; +id|indexed_col|non_indexed_col +--+-----------+--------------- + 1| 100|updated_s1 +(1 row) + +step s4_select: SELECT * FROM hot_test WHERE id = 2; +id|indexed_col|non_indexed_col +--+-----------+--------------- + 2| 200|updated_s4 +(1 row) + +step s1_verify_hot: + -- Check for HOT chain: look for LP_REDIRECT (lp_flags=2) or tuple with t_ctid pointing to same page + SELECT COUNT(*) > 0 AS has_hot_chain + FROM heap_page_items(get_raw_page('hot_test', 0)) + WHERE lp_flags = 2 -- LP_REDIRECT indicates HOT chain + OR (t_ctid IS NOT NULL + AND (t_ctid::text::point)[0]::int = 0 -- same page + AND t_ctid != ('(0,' || lp || ')')::tid); -- different offset + +has_hot_chain +------------- +t +(1 row) + +step s4_verify_hot: + -- Check for HOT chain on page 0 + SELECT COUNT(*) > 0 AS has_hot_chain + FROM heap_page_items(get_raw_page('hot_test', 0)) + WHERE lp_flags = 2 + OR (t_ctid IS NOT NULL + AND (t_ctid::text::point)[0]::int = 0 + AND t_ctid != ('(0,' || lp || ')')::tid); + +has_hot_chain +------------- +t +(1 row) + diff --git a/src/test/isolation/expected/hot_updates_index_scan.out b/src/test/isolation/expected/hot_updates_index_scan.out new file mode 100644 index 00000000000..7d8e9ff8857 --- /dev/null +++ b/src/test/isolation/expected/hot_updates_index_scan.out @@ -0,0 +1,132 @@ +Parsed test spec with 4 sessions + +starting permutation: s1_begin s1_hot_update s2_begin s2_index_scan s1_commit s2_commit +step s1_begin: BEGIN; +step s1_hot_update: UPDATE hot_test SET non_indexed_col = 'hot_updated' WHERE id = 50; +step s2_begin: BEGIN; +step s2_index_scan: SELECT * FROM hot_test WHERE indexed_col = 500; +id|indexed_col|non_indexed_col +--+-----------+--------------- +50| 500|initial50 +(1 row) + +step s1_commit: COMMIT; +step s2_commit: COMMIT; + +starting permutation: s1_begin s1_non_hot_update s1_commit s2_begin s2_index_scan_new s2_commit s2_verify_index +step s1_begin: BEGIN; +step s1_non_hot_update: UPDATE hot_test SET indexed_col = 555 WHERE id = 50; +step s1_commit: COMMIT; +step s2_begin: BEGIN; +step s2_index_scan_new: SELECT * FROM hot_test WHERE indexed_col = 555; +id|indexed_col|non_indexed_col +--+-----------+--------------- +50| 555|initial50 +(1 row) + +step s2_commit: COMMIT; +step s2_verify_index: + -- After non-HOT update, verify index reflects the change + SELECT COUNT(*) = 1 AS found_new_value FROM hot_test WHERE indexed_col = 555; + SELECT COUNT(*) = 0 AS old_value_gone FROM hot_test WHERE indexed_col = 500; + +found_new_value +--------------- +t +(1 row) + +old_value_gone +-------------- +t +(1 row) + + +starting permutation: s3_begin s3_select_for_update s1_begin s1_hot_update s3_commit s1_commit s1_verify_hot +step s3_begin: BEGIN; +step s3_select_for_update: SELECT * FROM hot_test WHERE id = 50 FOR UPDATE; +id|indexed_col|non_indexed_col +--+-----------+--------------- +50| 500|initial50 +(1 row) + +step s1_begin: BEGIN; +step s1_hot_update: UPDATE hot_test SET non_indexed_col = 'hot_updated' WHERE id = 50; +step s3_commit: COMMIT; +step s1_hot_update: <... completed> +step s1_commit: COMMIT; +step s1_verify_hot: + -- Verify HOT chain exists for row with id=50 + -- Use actual ctid to find the correct page + SELECT EXISTS ( + SELECT 1 FROM heap_page_items( + get_raw_page('hot_test', (SELECT (ctid::text::point)[0]::int FROM hot_test WHERE id = 50)) + ) + WHERE lp_flags = 2 + OR (t_ctid IS NOT NULL + AND t_ctid != ('(' || (SELECT (ctid::text::point)[0]::int FROM hot_test WHERE id = 50) || ',' || lp || ')')::tid + AND (t_ctid::text::point)[0]::int = (SELECT (ctid::text::point)[0]::int FROM hot_test WHERE id = 50)) + ) AS has_hot_chain; + +has_hot_chain +------------- +t +(1 row) + + +starting permutation: s1_begin s1_hot_update s3_begin s3_select_for_update s1_commit s3_commit +step s1_begin: BEGIN; +step s1_hot_update: UPDATE hot_test SET non_indexed_col = 'hot_updated' WHERE id = 50; +step s3_begin: BEGIN; +step s3_select_for_update: SELECT * FROM hot_test WHERE id = 50 FOR UPDATE; +step s1_commit: COMMIT; +step s3_select_for_update: <... completed> +id|indexed_col|non_indexed_col +--+-----------+--------------- +50| 500|hot_updated +(1 row) + +step s3_commit: COMMIT; + +starting permutation: s4_begin s4_select_for_key_share s1_begin s1_hot_update s4_commit s1_commit s1_verify_hot +step s4_begin: BEGIN; +step s4_select_for_key_share: SELECT * FROM hot_test WHERE id = 50 FOR KEY SHARE; +id|indexed_col|non_indexed_col +--+-----------+--------------- +50| 500|initial50 +(1 row) + +step s1_begin: BEGIN; +step s1_hot_update: UPDATE hot_test SET non_indexed_col = 'hot_updated' WHERE id = 50; +step s4_commit: COMMIT; +step s1_commit: COMMIT; +step s1_verify_hot: + -- Verify HOT chain exists for row with id=50 + -- Use actual ctid to find the correct page + SELECT EXISTS ( + SELECT 1 FROM heap_page_items( + get_raw_page('hot_test', (SELECT (ctid::text::point)[0]::int FROM hot_test WHERE id = 50)) + ) + WHERE lp_flags = 2 + OR (t_ctid IS NOT NULL + AND t_ctid != ('(' || (SELECT (ctid::text::point)[0]::int FROM hot_test WHERE id = 50) || ',' || lp || ')')::tid + AND (t_ctid::text::point)[0]::int = (SELECT (ctid::text::point)[0]::int FROM hot_test WHERE id = 50)) + ) AS has_hot_chain; + +has_hot_chain +------------- +t +(1 row) + + +starting permutation: s4_begin s4_select_for_key_share s1_begin s1_non_hot_update s4_commit s1_commit +step s4_begin: BEGIN; +step s4_select_for_key_share: SELECT * FROM hot_test WHERE id = 50 FOR KEY SHARE; +id|indexed_col|non_indexed_col +--+-----------+--------------- +50| 500|initial50 +(1 row) + +step s1_begin: BEGIN; +step s1_non_hot_update: UPDATE hot_test SET indexed_col = 555 WHERE id = 50; +step s4_commit: COMMIT; +step s1_commit: COMMIT; diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index 4e466580cd4..46525b0a62a 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -19,6 +19,9 @@ test: multiple-row-versions test: index-only-scan test: index-only-bitmapscan test: predicate-lock-hot-tuple +test: hot_updates_concurrent +test: hot_updates_index_scan +test: hot_updates_chain test: update-conflict-out test: deadlock-simple test: deadlock-hard diff --git a/src/test/isolation/specs/hot_updates_chain.spec b/src/test/isolation/specs/hot_updates_chain.spec new file mode 100644 index 00000000000..85cd2176133 --- /dev/null +++ b/src/test/isolation/specs/hot_updates_chain.spec @@ -0,0 +1,110 @@ +# Test HOT update chains and their interaction with VACUUM and page pruning +# +# This test verifies that HOT update chains are correctly maintained when +# multiple HOT updates occur on the same row, and that VACUUM correctly +# handles HOT chains. + +setup +{ + CREATE EXTENSION IF NOT EXISTS pageinspect; + + CREATE TABLE hot_test ( + id int PRIMARY KEY, + indexed_col int, + non_indexed_col text + ); + + 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'); +} + +teardown +{ + DROP TABLE hot_test; + DROP EXTENSION pageinspect; +} + +# Session 1: Create HOT chain with multiple updates +session s1 +step s1_begin { BEGIN; } +step s1_hot_update1 { UPDATE hot_test SET non_indexed_col = 'update1' WHERE id = 1; } +step s1_hot_update2 { UPDATE hot_test SET non_indexed_col = 'update2' WHERE id = 1; } +step s1_hot_update3 { UPDATE hot_test SET non_indexed_col = 'update3' WHERE id = 1; } +step s1_commit { COMMIT; } +step s1_select { SELECT * FROM hot_test WHERE id = 1; } +step s1_verify_hot { + -- Check for HOT chain: LP_REDIRECT or tuple with t_ctid pointing to same page + SELECT COUNT(*) > 0 AS has_hot_chain + FROM heap_page_items(get_raw_page('hot_test', 0)) + WHERE lp_flags = 2 -- LP_REDIRECT indicates HOT chain + OR (t_ctid IS NOT NULL + AND (t_ctid::text::point)[0]::int = 0 -- same page + AND t_ctid != ('(0,' || lp || ')')::tid); -- different offset +} + +# Session 2: Read while HOT chain is being built +session s2 +step s2_begin { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step s2_select_before { SELECT non_indexed_col FROM hot_test WHERE id = 1; } +step s2_select_after { SELECT non_indexed_col FROM hot_test WHERE id = 1; } +step s2_commit { COMMIT; } + +# Session 3: Break HOT chain with non-HOT update +session s3 +step s3_begin { BEGIN; } +step s3_non_hot_update { UPDATE hot_test SET indexed_col = 150 WHERE id = 1; } +step s3_commit { COMMIT; } + +# Session 4: Try to build HOT chain after non-HOT update +session s4 +step s4_begin { BEGIN; } +step s4_hot_after_non_hot { UPDATE hot_test SET non_indexed_col = 'after_non_hot' WHERE id = 1; } +step s4_commit { COMMIT; } +step s4_select { SELECT * FROM hot_test WHERE id = 1; } +step s4_verify_hot { + -- Check for new HOT chain after non-HOT update broke the previous chain + SELECT COUNT(*) > 0 AS has_hot_chain + FROM heap_page_items(get_raw_page('hot_test', 0)) + WHERE lp_flags = 2 + OR (t_ctid IS NOT NULL + AND (t_ctid::text::point)[0]::int = 0 + AND t_ctid != ('(0,' || lp || ')')::tid); +} + +# Session 5: Multiple sessions building separate HOT chains on different rows +session s5 +step s5_begin { BEGIN; } +step s5_hot_update_row2_1 { UPDATE hot_test SET non_indexed_col = 'row2_update1' WHERE id = 2; } +step s5_hot_update_row2_2 { UPDATE hot_test SET non_indexed_col = 'row2_update2' WHERE id = 2; } +step s5_commit { COMMIT; } +step s5_select { SELECT * FROM hot_test WHERE id = 2; } +step s5_verify_hot { + -- Check for HOT chain on page 0 + SELECT COUNT(*) > 0 AS has_hot_chain + FROM heap_page_items(get_raw_page('hot_test', 0)) + WHERE lp_flags = 2 + OR (t_ctid IS NOT NULL + AND (t_ctid::text::point)[0]::int = 0 + AND t_ctid != ('(0,' || lp || ')')::tid); +} + +# Build HOT chain within single transaction +# All updates should form a HOT chain +permutation s1_begin s1_hot_update1 s1_hot_update2 s1_hot_update3 s1_commit s1_select s1_verify_hot + +# REPEATABLE READ should see consistent snapshot across HOT chain updates +# Session 2 starts before updates, should see 'initial' throughout +permutation s2_begin s2_select_before s1_begin s1_hot_update1 s1_hot_update2 s1_commit s2_select_after s2_commit + +# HOT chain followed by non-HOT update +# Non-HOT update breaks the HOT chain +permutation s1_begin s1_hot_update1 s1_hot_update2 s1_commit s3_begin s3_non_hot_update s3_commit s1_select + +# HOT update after non-HOT update can start new HOT chain +# After breaking chain with indexed column update, new HOT updates can start fresh chain +permutation s1_begin s1_hot_update1 s1_commit s3_begin s3_non_hot_update s3_commit s4_begin s4_hot_after_non_hot s4_commit s4_select s4_verify_hot + +# Multiple sessions building separate HOT chains on different rows +permutation s1_begin s1_hot_update1 s1_hot_update2 s5_begin s5_hot_update_row2_1 s5_hot_update_row2_2 s1_commit s5_commit s1_select s5_select s1_verify_hot s5_verify_hot diff --git a/src/test/isolation/specs/hot_updates_concurrent.spec b/src/test/isolation/specs/hot_updates_concurrent.spec new file mode 100644 index 00000000000..eac78d62ac5 --- /dev/null +++ b/src/test/isolation/specs/hot_updates_concurrent.spec @@ -0,0 +1,107 @@ +# Test concurrent HOT updates and validate HOT chains +# +# This test verifies that HOT updates work correctly when multiple sessions +# are updating the same table concurrently, and validates that HOT chains +# are actually created using heap_page_items(). + +setup +{ + CREATE EXTENSION IF NOT EXISTS pageinspect; + + CREATE TABLE hot_test ( + id int PRIMARY KEY, + indexed_col int, + non_indexed_col text + ); + + CREATE INDEX hot_test_indexed_idx ON hot_test(indexed_col); + + INSERT INTO hot_test VALUES (1, 100, 'initial1'); + INSERT INTO hot_test VALUES (2, 200, 'initial2'); + INSERT INTO hot_test VALUES (3, 300, 'initial3'); +} + +teardown +{ + DROP TABLE hot_test; + DROP EXTENSION pageinspect; +} + +# Session 1: HOT update (modify non-indexed column) +session s1 +step s1_begin { BEGIN; } +step s1_hot_update { UPDATE hot_test SET non_indexed_col = 'updated_s1' WHERE id = 1; } +step s1_commit { COMMIT; } +step s1_select { SELECT * FROM hot_test WHERE id = 1; } +step s1_verify_hot { + -- Check for HOT chain: look for LP_REDIRECT (lp_flags=2) or tuple with t_ctid pointing to same page + SELECT COUNT(*) > 0 AS has_hot_chain + FROM heap_page_items(get_raw_page('hot_test', 0)) + WHERE lp_flags = 2 -- LP_REDIRECT indicates HOT chain + OR (t_ctid IS NOT NULL + AND (t_ctid::text::point)[0]::int = 0 -- same page + AND t_ctid != ('(0,' || lp || ')')::tid); -- different offset +} + +# Session 2: HOT update (modify non-indexed column on same row) +session s2 +step s2_begin { BEGIN; } +step s2_hot_update { UPDATE hot_test SET non_indexed_col = 'updated_s2' WHERE id = 1; } +step s2_commit { COMMIT; } +step s2_select { SELECT * FROM hot_test WHERE id = 1; } +step s2_verify_hot { + -- Check for HOT chain: look for LP_REDIRECT (lp_flags=2) or tuple with t_ctid pointing to same page + SELECT COUNT(*) > 0 AS has_hot_chain + FROM heap_page_items(get_raw_page('hot_test', 0)) + WHERE lp_flags = 2 -- LP_REDIRECT indicates HOT chain + OR (t_ctid IS NOT NULL + AND (t_ctid::text::point)[0]::int = 0 -- same page + AND t_ctid != ('(0,' || lp || ')')::tid); -- different offset +} + +# Session 3: Non-HOT update (modify indexed column) +session s3 +step s3_begin { BEGIN; } +step s3_non_hot_update { UPDATE hot_test SET indexed_col = 150 WHERE id = 1; } +step s3_commit { COMMIT; } +step s3_select { SELECT * FROM hot_test WHERE id = 1; } +step s3_verify_index { + -- Verify index was updated (proves non-HOT) + SELECT COUNT(*) = 1 AS index_updated FROM hot_test WHERE indexed_col = 150; + SELECT COUNT(*) = 0 AS old_value_gone FROM hot_test WHERE indexed_col = 100; +} + +# Session 4: Concurrent HOT updates on different rows +session s4 +step s4_begin { BEGIN; } +step s4_hot_update_row2 { UPDATE hot_test SET non_indexed_col = 'updated_s4' WHERE id = 2; } +step s4_commit { COMMIT; } +step s4_select { SELECT * FROM hot_test WHERE id = 2; } +step s4_verify_hot { + -- Check for HOT chain on page 0 + SELECT COUNT(*) > 0 AS has_hot_chain + FROM heap_page_items(get_raw_page('hot_test', 0)) + WHERE lp_flags = 2 + OR (t_ctid IS NOT NULL + AND (t_ctid::text::point)[0]::int = 0 + AND t_ctid != ('(0,' || lp || ')')::tid); +} + +# Two sessions both doing HOT updates on same row +# Second session should block until first commits +# Both should create HOT chains +permutation s1_begin s1_hot_update s2_begin s2_hot_update s1_commit s2_commit s1_select s2_select s2_verify_hot + +# HOT update followed by non-HOT update +# Non-HOT update should wait for HOT update to commit +# First update is HOT, second is non-HOT (index updated) +permutation s1_begin s1_hot_update s3_begin s3_non_hot_update s1_commit s3_commit s3_select s3_verify_index + +# Non-HOT update followed by HOT update +# HOT update should wait for non-HOT update to commit +# First update is non-HOT (index), second is HOT +permutation s3_begin s3_non_hot_update s1_begin s1_hot_update s3_commit s1_commit s1_select s1_verify_hot + +# Concurrent HOT updates on different rows (should not block) +# Both sessions should be able to create HOT chains independently +permutation s1_begin s1_hot_update s4_begin s4_hot_update_row2 s1_commit s4_commit s1_select s4_select s1_verify_hot s4_verify_hot diff --git a/src/test/isolation/specs/hot_updates_index_scan.spec b/src/test/isolation/specs/hot_updates_index_scan.spec new file mode 100644 index 00000000000..70c3dae5166 --- /dev/null +++ b/src/test/isolation/specs/hot_updates_index_scan.spec @@ -0,0 +1,94 @@ +# Test HOT updates interaction with index scans and SELECT FOR UPDATE +# +# This test verifies that HOT updates are correctly handled when concurrent +# sessions are performing index scans, using SELECT FOR UPDATE, and validates +# HOT chains using heap_page_items(). + +setup +{ + CREATE EXTENSION IF NOT EXISTS pageinspect; + + CREATE TABLE hot_test ( + id int PRIMARY KEY, + indexed_col int, + non_indexed_col text + ); + + CREATE INDEX hot_test_indexed_idx ON hot_test(indexed_col); + + INSERT INTO hot_test SELECT i, i * 10, 'initial' || i FROM generate_series(1, 100) i; +} + +teardown +{ + DROP TABLE hot_test; + DROP EXTENSION pageinspect; +} + +# Session 1: Perform HOT update +session s1 +step s1_begin { BEGIN; } +step s1_hot_update { UPDATE hot_test SET non_indexed_col = 'hot_updated' WHERE id = 50; } +step s1_non_hot_update { UPDATE hot_test SET indexed_col = 555 WHERE id = 50; } +step s1_commit { COMMIT; } +step s1_verify_hot { + -- Verify HOT chain exists for row with id=50 + -- Use actual ctid to find the correct page + SELECT EXISTS ( + SELECT 1 FROM heap_page_items( + get_raw_page('hot_test', (SELECT (ctid::text::point)[0]::int FROM hot_test WHERE id = 50)) + ) + WHERE lp_flags = 2 + OR (t_ctid IS NOT NULL + AND t_ctid != ('(' || (SELECT (ctid::text::point)[0]::int FROM hot_test WHERE id = 50) || ',' || lp || ')')::tid + AND (t_ctid::text::point)[0]::int = (SELECT (ctid::text::point)[0]::int FROM hot_test WHERE id = 50)) + ) AS has_hot_chain; +} + +# Session 2: Index scan while HOT update in progress +session s2 +step s2_begin { BEGIN; } +step s2_index_scan { SELECT * FROM hot_test WHERE indexed_col = 500; } +step s2_index_scan_new { SELECT * FROM hot_test WHERE indexed_col = 555; } +step s2_commit { COMMIT; } +step s2_verify_index { + -- After non-HOT update, verify index reflects the change + SELECT COUNT(*) = 1 AS found_new_value FROM hot_test WHERE indexed_col = 555; + SELECT COUNT(*) = 0 AS old_value_gone FROM hot_test WHERE indexed_col = 500; +} + +# Session 3: SELECT FOR UPDATE +session s3 +step s3_begin { BEGIN; } +step s3_select_for_update { SELECT * FROM hot_test WHERE id = 50 FOR UPDATE; } +step s3_commit { COMMIT; } + +# Session 4: SELECT FOR KEY SHARE (should not block HOT update of non-key column) +session s4 +step s4_begin { BEGIN; } +step s4_select_for_key_share { SELECT * FROM hot_test WHERE id = 50 FOR KEY SHARE; } +step s4_commit { COMMIT; } + +# Index scan should see consistent snapshot during HOT update +# Index scan starts before HOT update commits +permutation s1_begin s1_hot_update s2_begin s2_index_scan s1_commit s2_commit + +# Index scan after non-HOT update should see new index entry +# Index scan starts after non-HOT update commits +permutation s1_begin s1_non_hot_update s1_commit s2_begin s2_index_scan_new s2_commit s2_verify_index + +# SELECT FOR UPDATE blocks HOT update +# FOR UPDATE should block the UPDATE until SELECT commits +permutation s3_begin s3_select_for_update s1_begin s1_hot_update s3_commit s1_commit s1_verify_hot + +# HOT update blocks SELECT FOR UPDATE +# SELECT FOR UPDATE should wait for HOT update to commit +permutation s1_begin s1_hot_update s3_begin s3_select_for_update s1_commit s3_commit + +# SELECT FOR KEY SHARE should not block HOT update (non-key column) +# HOT update of non-indexed column should not conflict with FOR KEY SHARE +permutation s4_begin s4_select_for_key_share s1_begin s1_hot_update s4_commit s1_commit s1_verify_hot + +# Non-HOT update (key column) should block after FOR KEY SHARE +# Non-HOT update of indexed column should wait for FOR KEY SHARE +permutation s4_begin s4_select_for_key_share s1_begin s1_non_hot_update s4_commit s1_commit diff --git a/src/test/regress/expected/hot_updates.out b/src/test/regress/expected/hot_updates.out new file mode 100644 index 00000000000..e99a51966ce --- /dev/null +++ b/src/test/regress/expected/hot_updates.out @@ -0,0 +1,950 @@ +-- Load required extensions +CREATE EXTENSION IF NOT EXISTS pageinspect; +-- Function to get HOT update count +CREATE OR REPLACE 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; + + -- Read both committed and transaction-local stats + -- In autocommit mode (default for regression tests), this works correctly + -- Note: In explicit transactions (BEGIN/COMMIT), committed stats already + -- include flushed updates, so this would double-count. For explicit + -- transaction testing, call pg_stat_force_next_flush() before this function. + 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; +-- Check if a tuple is part of a HOT chain (has a predecessor on same page) +CREATE OR REPLACE FUNCTION has_hot_chain(rel_name text, target_ctid tid) +RETURNS boolean AS $$ +DECLARE + block_num int; + page_item record; +BEGIN + block_num := (target_ctid::text::point)[0]::int; + + -- Look for a different tuple on the same page that points to our target tuple + FOR page_item IN + SELECT lp, lp_flags, t_ctid + FROM heap_page_items(get_raw_page(rel_name, block_num)) + WHERE lp_flags = 1 + AND t_ctid IS NOT NULL + AND t_ctid = target_ctid + AND ('(' || block_num::text || ',' || lp::text || ')')::tid != target_ctid + LOOP + RETURN true; + END LOOP; + + RETURN false; +END; +$$ LANGUAGE plpgsql; +-- Print the HOT chain starting from a given tuple +CREATE OR REPLACE FUNCTION print_hot_chain(rel_name text, start_ctid tid) +RETURNS TABLE(chain_position int, ctid tid, lp_flags text, t_ctid tid, chain_end boolean) AS +$$ +#variable_conflict use_column +DECLARE + block_num int; + line_ptr int; + current_ctid tid := start_ctid; + next_ctid tid; + position int := 0; + max_iterations int := 100; + page_item record; + found_predecessor boolean := false; + flags_name text; +BEGIN + block_num := (start_ctid::text::point)[0]::int; + + -- Find the predecessor (old tuple pointing to our start_ctid) + FOR page_item IN + SELECT lp, lp_flags, t_ctid + FROM heap_page_items(get_raw_page(rel_name, block_num)) + WHERE lp_flags = 1 + AND t_ctid = start_ctid + LOOP + current_ctid := ('(' || block_num::text || ',' || page_item.lp::text || ')')::tid; + found_predecessor := true; + EXIT; + END LOOP; + + -- If no predecessor found, start with the given ctid + IF NOT found_predecessor THEN + current_ctid := start_ctid; + END IF; + + -- Follow the chain forward + WHILE position < max_iterations LOOP + line_ptr := (current_ctid::text::point)[1]::int; + + FOR page_item IN + SELECT lp, lp_flags, t_ctid + FROM heap_page_items(get_raw_page(rel_name, block_num)) + WHERE lp = line_ptr + LOOP + -- Map lp_flags to names + flags_name := CASE page_item.lp_flags + WHEN 0 THEN 'unused (0)' + WHEN 1 THEN 'normal (1)' + WHEN 2 THEN 'redirect (2)' + WHEN 3 THEN 'dead (3)' + ELSE 'unknown (' || page_item.lp_flags::text || ')' + END; + + RETURN QUERY SELECT + position, + current_ctid, + flags_name, + page_item.t_ctid, + (page_item.t_ctid IS NULL OR page_item.t_ctid = current_ctid)::boolean + ; + + IF page_item.t_ctid IS NULL OR page_item.t_ctid = current_ctid THEN + RETURN; + END IF; + + next_ctid := page_item.t_ctid; + + IF (next_ctid::text::point)[0]::int != block_num THEN + RETURN; + END IF; + + current_ctid := next_ctid; + position := position + 1; + END LOOP; + + IF position = 0 THEN + RETURN; + END IF; + END LOOP; +END; +$$ LANGUAGE plpgsql; +-- Basic HOT update functionality +CREATE TABLE hot_test ( + id int PRIMARY KEY, + indexed_col int, + non_indexed_col text +) USING heap WITH (fillfactor = 50); +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'); +-- Get baseline +SELECT * FROM get_hot_count('hot_test'); + updates | hot +---------+----- + 0 | 0 +(1 row) + +-- Should be HOT updates (only non-indexed column modified) +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; +-- Verify HOT updates occurred +SELECT * FROM get_hot_count('hot_test'); + updates | hot +---------+----- + 3 | 3 +(1 row) + +-- Dump the HOT chain for tuple with id == 1 +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) + +-- Trigger optimistic heap page pruning +SELECT ctid, * FROM hot_test; + ctid | id | indexed_col | non_indexed_col +-------+----+-------------+----------------- + (0,4) | 1 | 100 | updated1 + (0,5) | 2 | 200 | updated2 + (0,6) | 3 | 300 | updated3 +(3 rows) + +-- Dump the HOT chain after prune +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) + +SET SESSION enable_seqscan = OFF; +SET SESSION enable_bitmapscan = OFF; +-- Verify indexes still work +EXPLAIN (COSTS OFF) SELECT id, indexed_col FROM hot_test WHERE indexed_col = 100; + QUERY PLAN +--------------------------------------------------- + Index Scan using hot_test_indexed_idx on hot_test + Index Cond: (indexed_col = 100) +(2 rows) + +SELECT id, indexed_col FROM hot_test WHERE indexed_col = 100; + id | indexed_col +----+------------- + 1 | 100 +(1 row) + +-- Vacuum the relation, expect the HOT chain to collapse +VACUUM hot_test; +-- Show that there is no chain after vacuum +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) + +-- Non-HOT update (update indexed column) +UPDATE hot_test SET indexed_col = 150 WHERE id = 1; +SELECT * FROM get_hot_count('hot_test'); + updates | hot +---------+----- + 4 | 3 +(1 row) + +-- Verify index was updated (new value findable) +EXPLAIN (COSTS OFF) SELECT id, indexed_col FROM hot_test WHERE indexed_col = 150; + QUERY PLAN +--------------------------------------------------- + Index Scan using hot_test_indexed_idx on hot_test + Index Cond: (indexed_col = 150) +(2 rows) + +SELECT id, indexed_col FROM hot_test WHERE indexed_col = 150; + id | indexed_col +----+------------- + 1 | 150 +(1 row) + +-- Verify old value no longer in index +EXPLAIN (COSTS OFF) SELECT id FROM hot_test WHERE indexed_col = 100; + QUERY PLAN +--------------------------------------------------- + Index Scan using hot_test_indexed_idx on hot_test + Index Cond: (indexed_col = 100) +(2 rows) + +SELECT id FROM hot_test WHERE indexed_col = 100; + id +---- +(0 rows) + +SET SESSION enable_seqscan = ON; +SET SESSION enable_bitmapscan = ON; +-- All-or-none property: updating one indexed column requires ALL index updates +DROP TABLE hot_test; +CREATE TABLE hot_test ( + id int PRIMARY KEY, + col_a int, + col_b int, + col_c int, + non_indexed text +) USING heap WITH (fillfactor = 50); +CREATE INDEX hot_test_a_idx ON hot_test(col_a); +CREATE INDEX hot_test_b_idx ON hot_test(col_b); +CREATE INDEX hot_test_c_idx ON hot_test(col_c); +INSERT INTO hot_test VALUES (1, 10, 20, 30, 'initial'); +-- Update only col_a - should NOT be HOT because an indexed column changed +-- This means ALL indexes must be updated (all-or-none property) +UPDATE hot_test SET col_a = 15 WHERE id = 1; +SELECT * FROM get_hot_count('hot_test'); + updates | hot +---------+----- + 1 | 0 +(1 row) + +-- Verify all three indexes still work correctly +SELECT id, col_a FROM hot_test WHERE col_a = 15; -- updated index + id | col_a +----+------- + 1 | 15 +(1 row) + +SELECT id, col_b FROM hot_test WHERE col_b = 20; -- unchanged index + id | col_b +----+------- + 1 | 20 +(1 row) + +SELECT id, col_c FROM hot_test WHERE col_c = 30; -- unchanged index + id | col_c +----+------- + 1 | 30 +(1 row) + +-- Now update only non-indexed column - should be HOT +UPDATE hot_test SET non_indexed = 'updated'; +SELECT * FROM get_hot_count('hot_test'); + updates | hot +---------+----- + 2 | 1 +(1 row) + +-- Verify all indexes still work +SELECT id FROM hot_test WHERE col_a = 15 AND col_b = 20 AND col_c = 30; + id +---- + 1 +(1 row) + +-- Partial index: both old and new outside predicate (conservative = non-HOT) +DROP TABLE hot_test; +CREATE TABLE hot_test ( + id int PRIMARY KEY, + status text, + data text +) WITH (fillfactor = 50); +-- Partial index only covers status = 'active' +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 non-indexed column on 'active' row (in predicate, status unchanged) +-- Should be HOT +UPDATE hot_test SET data = 'updated1' WHERE id = 1; +SELECT * FROM get_hot_count('hot_test'); + updates | hot +---------+----- + 1 | 1 +(1 row) + +-- Update non-indexed column on 'inactive' row (outside predicate) +-- Should be HOT +UPDATE hot_test SET data = 'updated2' WHERE id = 2; +SELECT * FROM get_hot_count('hot_test'); + updates | hot +---------+----- + 2 | 2 +(1 row) + +-- Update status from 'inactive' to 'deleted' (both outside predicate) +-- PostgreSQL is conservative: heap insert happens before predicate check +-- So this is NON-HOT even though both values are outside predicate +UPDATE hot_test SET status = 'deleted' WHERE id = 2; +SELECT * FROM get_hot_count('hot_test'); + updates | hot +---------+----- + 3 | 2 +(1 row) + +-- Verify index still works for 'active' rows +SELECT id, status FROM hot_test WHERE status = 'active'; + id | status +----+-------- + 1 | active +(1 row) + +-- Only BRIN (summarizing) indexes on non-PK columns +DROP TABLE hot_test; +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); +-- Update both BRIN columns - should still be HOT (only summarizing indexes) +UPDATE hot_test SET ts = '2024-01-02', brin_col = 2000 WHERE id = 1; +SELECT * FROM get_hot_count('hot_test'); + updates | hot +---------+----- + 1 | 1 +(1 row) + +-- Verify BRIN indexes work +SELECT id FROM hot_test WHERE ts >= '2024-01-02'; + id +---- + 1 +(1 row) + +SELECT id FROM hot_test WHERE brin_col >= 2000; + id +---- + 1 +(1 row) + +-- Update non-indexed column - should also be HOT +UPDATE hot_test SET value = 200 WHERE id = 1; +SELECT * FROM get_hot_count('hot_test'); + updates | hot +---------+----- + 2 | 2 +(1 row) + +-- Unique constraint (unique index) behaves like regular index +DROP TABLE hot_test; +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 data (non-indexed) - should be HOT +UPDATE hot_test SET data = 'updated'; +SELECT * FROM get_hot_count('hot_test'); + updates | hot +---------+----- + 2 | 2 +(1 row) + +-- Verify unique constraint still enforced +SELECT id, unique_col, data FROM hot_test ORDER BY id; + id | unique_col | data +----+------------+--------- + 1 | 100 | updated + 2 | 200 | updated +(2 rows) + +-- This should fail (unique violation) +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. +-- Multi-column index: any column change = non-HOT +DROP TABLE hot_test; +CREATE TABLE hot_test ( + id int PRIMARY KEY, + col_a int, + col_b int, + col_c int, + col_d int +) WITH (fillfactor = 50); +CREATE INDEX hot_test_ab_idx ON hot_test(col_a, col_b); +CREATE INDEX hot_test_ab_inc_c_idx ON hot_test(col_a, col_b) INCLUDE(col_c); +INSERT INTO hot_test VALUES (1, 10, 20, 30, 40); +-- Update col_a (part of multi-column index) - should NOT be HOT +UPDATE hot_test SET col_a = 15; +SELECT * FROM get_hot_count('hot_test'); + updates | hot +---------+----- + 1 | 0 +(1 row) + +-- Update col_b (part of multi-column index) - should NOT be HOT +UPDATE hot_test SET col_b = 25; +SELECT * FROM get_hot_count('hot_test'); + updates | hot +---------+----- + 2 | 0 +(1 row) + +-- Update col_c (not indexed, but included) - should NOT be HOT +UPDATE hot_test SET col_c = 35; +-- Verify multi-column index-only scan for included columns works +EXPLAIN (COSTS OFF) SELECT col_c FROM hot_test WHERE col_a = 15 AND col_b = 25; + QUERY PLAN +--------------------------------------------------------- + Index Only Scan using hot_test_ab_inc_c_idx on hot_test + Index Cond: ((col_a = 15) AND (col_b = 25)) +(2 rows) + +SELECT col_c FROM hot_test WHERE col_a = 15 AND col_b = 25; + col_c +------- + 35 +(1 row) + +-- ============================================================================ +-- Expression indexes with JSONB +-- ============================================================================ +DROP TABLE hot_test; +CREATE TABLE hot_test ( + id int PRIMARY KEY, + data jsonb +) USING heap WITH(fillfactor = 50); +-- Indexes on specific JSONB paths +CREATE INDEX hot_test_status_idx ON hot_test((data->'status')); +CREATE INDEX hot_test_user_id_idx ON hot_test((data->'user'->'id')); +INSERT INTO hot_test VALUES ( + 1, + '{"status": "active", "user": {"id": 123, "name": "Alice"}, "count": 0}'::jsonb +); +-- Baseline +SELECT 'Baseline' AS test, * FROM get_hot_count('hot_test'); + test | updates | hot +----------+---------+----- + Baseline | 0 | 0 +(1 row) + +-- Update non-indexed path {count} - should NOT be HOT +UPDATE hot_test SET data = jsonb_set(data, '{count}', '1') WHERE id = 1; +SELECT 'After updating count (non-indexed)' AS test, * FROM get_hot_count('hot_test'); + test | updates | hot +------------------------------------+---------+----- + After updating count (non-indexed) | 1 | 0 +(1 row) + +-- Update different non-indexed path {user,name} - should NOT be HOT +UPDATE hot_test SET data = jsonb_set(data, '{user,name}', '"Bob"') WHERE id = 1; +SELECT 'After updating user.name (non-indexed)' AS test, * FROM get_hot_count('hot_test'); + test | updates | hot +----------------------------------------+---------+----- + After updating user.name (non-indexed) | 2 | 0 +(1 row) + +-- Update indexed path {status} - should NOT be HOT +UPDATE hot_test SET data = jsonb_set(data, '{status}', '"inactive"') WHERE id = 1; +SELECT 'After updating status (indexed)' AS test, * FROM get_hot_count('hot_test'); + test | updates | hot +---------------------------------+---------+----- + After updating status (indexed) | 3 | 0 +(1 row) + +-- Update indexed path {user,id} - should NOT be HOT +UPDATE hot_test SET data = jsonb_set(data, '{user,id}', '456') WHERE id = 1; +SELECT 'After updating user.id (indexed)' AS test, * FROM get_hot_count('hot_test'); + test | updates | hot +----------------------------------+---------+----- + After updating user.id (indexed) | 4 | 0 +(1 row) + +-- Verify indexes still work correctly +SELECT id FROM hot_test WHERE data->'status' = '"inactive"'::jsonb; + id +---- + 1 +(1 row) + +SELECT id FROM hot_test WHERE data->'user'->'id' = '456'::jsonb; + id +---- + 1 +(1 row) + +-- ============================================================================ +-- Nested paths and path intersection +-- ============================================================================ +DROP TABLE hot_test; +CREATE TABLE hot_test ( + id int PRIMARY KEY, + data jsonb +) USING heap WITH(fillfactor = 50); +CREATE INDEX hot_test_deep_idx ON hot_test((data->'a'->'b'->'c')); +INSERT INTO hot_test VALUES ( + 1, + '{"a": {"b": {"c": "indexed", "d": "not-indexed"}}, "x": "other"}'::jsonb +); +SELECT 'Baseline' AS test, * FROM get_hot_count('hot_test'); + test | updates | hot +----------+---------+----- + Baseline | 0 | 0 +(1 row) + +-- Update sibling of indexed path {a,b,d} - should NOT be HOT +UPDATE hot_test SET data = jsonb_set(data, '{a,b,d}', '"updated"') WHERE id = 1; +SELECT 'After updating a.b.d (sibling, non-indexed)' AS test, * FROM get_hot_count('hot_test'); + test | updates | hot +---------------------------------------------+---------+----- + After updating a.b.d (sibling, non-indexed) | 1 | 0 +(1 row) + +-- Update unrelated path {x} - should NOT be HOT +UPDATE hot_test SET data = jsonb_set(data, '{x}', '"modified"') WHERE id = 1; +SELECT 'After updating x (unrelated path)' AS test, * FROM get_hot_count('hot_test'); + test | updates | hot +-----------------------------------+---------+----- + After updating x (unrelated path) | 2 | 0 +(1 row) + +-- Update parent of indexed path {a,b} - should NOT be HOT (affects child) +UPDATE hot_test SET data = jsonb_set(data, '{a,b}', '{"c": "new", "d": "data"}') WHERE id = 1; +SELECT 'After updating a.b (parent of indexed)' AS test, * FROM get_hot_count('hot_test'); + test | updates | hot +----------------------------------------+---------+----- + After updating a.b (parent of indexed) | 3 | 0 +(1 row) + +-- ============================================================================ +-- Multiple JSONB mutation functions +-- ============================================================================ +DROP TABLE hot_test; +CREATE TABLE hot_test ( + id int PRIMARY KEY, + data jsonb +) USING heap WITH(fillfactor = 50); +CREATE INDEX hot_test_keep_idx ON hot_test((data->'keep')); +INSERT INTO hot_test VALUES ( + 1, + '{"keep": "important", "remove": "unimportant", "extra": "data"}'::jsonb +); +SELECT 'Baseline' AS test, * FROM get_hot_count('hot_test'); + test | updates | hot +----------+---------+----- + Baseline | 0 | 0 +(1 row) + +-- jsonb_delete on non-indexed key - should NOT be HOT +UPDATE hot_test SET data = data - 'remove' WHERE id = 1; +SELECT 'After deleting non-indexed key' AS test, * FROM get_hot_count('hot_test'); + test | updates | hot +--------------------------------+---------+----- + After deleting non-indexed key | 1 | 0 +(1 row) + +-- jsonb_set on non-indexed key - should NOT be HOT +UPDATE hot_test SET data = jsonb_set(data, '{extra}', '"modified"') WHERE id = 1; +SELECT 'After modifying non-indexed key' AS test, * FROM get_hot_count('hot_test'); + test | updates | hot +---------------------------------+---------+----- + After modifying non-indexed key | 2 | 0 +(1 row) + +-- jsonb_delete on indexed key - should NOT be HOT +UPDATE hot_test SET data = data - 'keep' WHERE id = 1; +SELECT 'After deleting indexed key' AS test, * FROM get_hot_count('hot_test'); + test | updates | hot +----------------------------+---------+----- + After deleting indexed key | 3 | 0 +(1 row) + +-- ============================================================================ +-- Array operations +-- ============================================================================ +DROP TABLE hot_test; +CREATE TABLE hot_test ( + id int PRIMARY KEY, + data jsonb +) USING heap WITH(fillfactor = 50); +-- Index on array element +CREATE INDEX hot_test_tags_idx ON hot_test((data->'tags'->0)); +INSERT INTO hot_test VALUES ( + 1, + '{"tags": ["indexed", "second", "third"], "other": "data"}'::jsonb +); +SELECT 'Baseline' AS test, * FROM get_hot_count('hot_test'); + test | updates | hot +----------+---------+----- + Baseline | 0 | 0 +(1 row) + +-- Update non-indexed array element - should NOT be HOT +UPDATE hot_test SET data = jsonb_set(data, '{tags,1}', '"modified"') WHERE id = 1; +SELECT 'After updating tags[1]' AS test, * FROM get_hot_count('hot_test'); + test | updates | hot +------------------------+---------+----- + After updating tags[1] | 1 | 0 +(1 row) + +-- Update indexed array element - should NOT be HOT +UPDATE hot_test SET data = jsonb_set(data, '{tags,0}', '"changed"') WHERE id = 1; +SELECT 'After updating tags[0] (indexed)' AS test, * FROM get_hot_count('hot_test'); + test | updates | hot +----------------------------------+---------+----- + After updating tags[0] (indexed) | 2 | 0 +(1 row) + +-- ============================================================================ +-- Whole column index +-- ============================================================================ +DROP TABLE hot_test; +CREATE TABLE hot_test ( + id int PRIMARY KEY, + data jsonb +) USING heap WITH(fillfactor = 50); +-- Index on entire JSONB column, and a path extraction +CREATE INDEX hot_test_whole_idx ON hot_test(data); +CREATE INDEX hot_test_tags_idx ON hot_test((data->'a')); +INSERT INTO hot_test VALUES (1, '{"a": 1, "b": 1}'::jsonb); +SELECT 'Baseline' AS test, * FROM get_hot_count('hot_test'); + test | updates | hot +----------+---------+----- + Baseline | 0 | 0 +(1 row) + +-- Any modification to data - should NOT be HOT (whole column indexed) +UPDATE hot_test SET data = jsonb_set(data, '{b}', '2') WHERE id = 1; +SELECT 'After modifying any field (whole column indexed)' AS test, * FROM get_hot_count('hot_test'); + test | updates | hot +--------------------------------------------------+---------+----- + After modifying any field (whole column indexed) | 1 | 0 +(1 row) + +-- ============================================================================ +-- Performance at scale +-- ============================================================================ +DROP TABLE hot_test; +CREATE TABLE hot_test ( + id int PRIMARY KEY, + data jsonb +) USING heap WITH(fillfactor=50); +CREATE INDEX hot_test_status_idx ON hot_test((data->'status')); +CREATE INDEX hot_test_priority_idx ON hot_test((data->'priority')); +-- Insert 10000 rows +INSERT INTO hot_test +SELECT i, jsonb_build_object( + 'status', 'active', + 'priority', 1, + 'count', 0, + 'data', 'value_' || i +) +FROM generate_series(1, 10000) i; +SELECT 'Baseline (10000 rows)' AS test, * FROM get_hot_count('hot_test'); + test | updates | hot +-----------------------+---------+----- + Baseline (10000 rows) | 0 | 0 +(1 row) + +-- Update non-indexed fields on all rows - should NOT be HOT +UPDATE hot_test SET data = jsonb_set(data, '{count}', to_jsonb((data->>'count')::int + 1)); +SELECT 'After updating 10000 rows (non-indexed)' AS test, * FROM get_hot_count('hot_test'); + test | updates | hot +-----------------------------------------+---------+----- + After updating 10000 rows (non-indexed) | 10000 | 0 +(1 row) + +-- Verify correctness +SELECT COUNT(*) AS rows_with_count_1 FROM hot_test WHERE (data->>'count')::int = 1; + rows_with_count_1 +------------------- + 10000 +(1 row) + +-- Update indexed field on subset - should NOT be HOT for those rows +UPDATE hot_test SET data = jsonb_set(data, '{status}', '"inactive"') +WHERE id <= 10; +SELECT 'After updating 10 rows (indexed)' AS test, * FROM get_hot_count('hot_test'); + test | updates | hot +----------------------------------+---------+----- + After updating 10 rows (indexed) | 10010 | 0 +(1 row) + +-- Verify indexes work +SELECT COUNT(*) FROM hot_test WHERE data->>'status' = 'inactive'; + count +------- + 10 +(1 row) + +SELECT COUNT(*) FROM hot_test WHERE data->>'status' = 'active'; + count +------- + 9990 +(1 row) + +-- Only BRIN (summarizing) indexes on non-PK columns +DROP TABLE hot_test; +CREATE TABLE hot_test ( + id int PRIMARY KEY, + ts timestamp, + value int, + brin_col int +) USING heap 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); +-- Update both BRIN columns - should still be HOT (only summarizing indexes) +UPDATE hot_test SET ts = '2024-01-02', brin_col = 2000 WHERE id = 1; +SELECT 'After updating ts, brin_col (summarizing-only)' AS test, * FROM get_hot_count('hot_test'); + test | updates | hot +------------------------------------------------+---------+----- + After updating ts, brin_col (summarizing-only) | 1 | 1 +(1 row) + +-- Verify BRIN indexes work +SELECT id FROM hot_test WHERE ts >= '2024-01-02'; + id +---- + 1 +(1 row) + +SELECT id FROM hot_test WHERE brin_col >= 2000; + id +---- + 1 +(1 row) + +-- TOASTed columns can participate in HOT +DROP TABLE hot_test; +CREATE TABLE hot_test ( + id int PRIMARY KEY, + large_text text +) USING heap WITH(fillfactor = 50); +CREATE INDEX hot_test_idx ON hot_test(large_text); +-- Insert row with TOASTed column (> 2KB) +INSERT INTO hot_test VALUES (1, repeat('x', 3000)); +-- Update TOASTed column - should NOT be HOT +UPDATE hot_test SET large_text = repeat('y', 3000); +SELECT 'After updating large_text (TOASTed)' AS test, * FROM get_hot_count('hot_test'); + test | updates | hot +-------------------------------------+---------+----- + After updating large_text (TOASTed) | 1 | 0 +(1 row) + +-- Partitioned tables: HOT works within partitions +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); +CREATE TABLE hot_test_part2 PARTITION OF hot_test_partitioned + FOR VALUES FROM (100) TO (200); +CREATE INDEX hot_test_partitioned_idx ON hot_test_partitioned(indexed_col); +CREATE INDEX hot_test_part2_data ON hot_test_part2(data); +INSERT INTO hot_test_partitioned VALUES (1, 50, 100, 'initial1'); +INSERT INTO hot_test_partitioned VALUES (2, 150, 200, 'initial2'); +-- Update in partition 1 (non-indexed column) - should be HOT +UPDATE hot_test_partitioned SET data = 'UPDATED' WHERE id = 1; +SELECT 'After updating partition 1 data' AS test, * FROM get_hot_count('hot_test_part1'); + test | updates | hot +---------------------------------+---------+----- + After updating partition 1 data | 1 | 1 +(1 row) + +-- Update in partition 2 (indexed column) - should NOT be HOT +UPDATE hot_test_partitioned SET data = 'UPDATED' WHERE id = 2; +SELECT 'After updating large_text (TOASTed)' AS test, * FROM get_hot_count('hot_test_part2'); + test | updates | hot +-------------------------------------+---------+----- + After updating large_text (TOASTed) | 1 | 0 +(1 row) + +-- Verify indexes work on partitions +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) + +-- Update indexed column in partition - should NOT be HOT +-- Partition 1 previously had 1 update and 1 HOT update, this should +-- change that to 2 updates and 1 HOT update. +UPDATE hot_test_partitioned SET indexed_col = 150 WHERE id = 1; +SELECT 'After updating indexed_col' AS test, * FROM get_hot_count('hot_test_part1'); + test | updates | hot +----------------------------+---------+----- + After updating indexed_col | 2 | 1 +(1 row) + +-- ============================================================================ +-- Partial indexes with complex predicates on JSONB +-- ============================================================================ +-- Test partial indexes with WHERE clauses on JSONB expressions. +DROP TABLE hot_test; +CREATE TABLE hot_test ( + id int PRIMARY KEY, + data jsonb +) USING heap WITH(fillfactor = 50); +-- Partial index: only index status when priority > 5 +CREATE INDEX hot_test_partial_idx ON hot_test((data->'status')) + WHERE (data->>'priority')::int > 5; +INSERT INTO hot_test VALUES ( + 1, + '{"status": "active", "priority": 10, "count": 0}'::jsonb +); +INSERT INTO hot_test VALUES ( + 2, + '{"status": "active", "priority": 3, "count": 0}'::jsonb +); +SELECT 'Partial Index Test: Baseline' AS test, * FROM get_hot_count('hot_test'); + test | updates | hot +------------------------------+---------+----- + Partial Index Test: Baseline | 0 | 0 +(1 row) + +-- Update non-indexed path on row inside predicate (priority=10 > 5) +-- Should NOT be HOT despite {count} is not indexed +UPDATE hot_test SET data = jsonb_set(data, '{count}', '1') WHERE id = 1; +SELECT 'Partial Index Test: count update, inside predicate' AS test, * FROM get_hot_count('hot_test'); + test | updates | hot +----------------------------------------------------+---------+----- + Partial Index Test: count update, inside predicate | 1 | 0 +(1 row) + +-- Update non-indexed path on row outside predicate (priority=3 <= 5) +-- Should NOT be HOT dispite {count} is not indexed +UPDATE hot_test SET data = jsonb_set(data, '{count}', '1') WHERE id = 2; +SELECT 'Partial Index Test: count update, outside predicate' AS test, * FROM get_hot_count('hot_test'); + test | updates | hot +-----------------------------------------------------+---------+----- + Partial Index Test: count update, outside predicate | 2 | 0 +(1 row) + +-- Update indexed path on row inside predicate (priority=10 > 5) +-- Should NOT be HOT indexed portion is updated +UPDATE hot_test SET data = jsonb_set(data, '{status}', '"inactive"') WHERE id = 1; +SELECT 'Partial Index Test: status update, inside predicate' AS test, * FROM get_hot_count('hot_test'); + test | updates | hot +-----------------------------------------------------+---------+----- + Partial Index Test: status update, inside predicate | 3 | 0 +(1 row) + +-- Update indexed path on row outside predicate (priority=3 <= 5) +-- PostgreSQL makes a conservative choice and treats it as non-HOT because the +-- indexed column changed, even though the before/after rows are outside the predicate +UPDATE hot_test SET data = jsonb_set(data, '{status}', '"inactive"') WHERE id = 2; +SELECT 'Partial Index Test: status update, outside predicate' AS test, * FROM get_hot_count('hot_test'); + test | updates | hot +------------------------------------------------------+---------+----- + Partial Index Test: status update, outside predicate | 4 | 0 +(1 row) + +-- Verify index works +SELECT id FROM hot_test WHERE data->'status' = '"inactive"'::jsonb AND (data->>'priority')::int > 5; + id +---- + 1 +(1 row) + +-- ============================================================================ +DROP TABLE IF EXISTS hot_test; +DROP TABLE IF EXISTS hot_test_partitioned CASCADE; +DROP FUNCTION IF EXISTS has_hot_chain(text, tid); +DROP FUNCTION IF EXISTS print_hot_chain(text, tid); +DROP FUNCTION IF EXISTS get_hot_count(text); +DROP EXTENSION pageinspect; diff --git a/src/test/regress/parallel_schedule b/src/test/regress/parallel_schedule index 549e9b2d7be..e06247ef7ea 100644 --- a/src/test/regress/parallel_schedule +++ b/src/test/regress/parallel_schedule @@ -137,6 +137,11 @@ test: event_trigger_login # this test also uses event triggers, so likewise run it by itself test: fast_default +# ---------- +# HOT updates tests +# ---------- +test: hot_updates + # run tablespace test at the end because it drops the tablespace created during # setup that other tests may use. test: tablespace diff --git a/src/test/regress/sql/hot_updates.sql b/src/test/regress/sql/hot_updates.sql new file mode 100644 index 00000000000..34da4552d4f --- /dev/null +++ b/src/test/regress/sql/hot_updates.sql @@ -0,0 +1,692 @@ +-- Load required extensions +CREATE EXTENSION IF NOT EXISTS pageinspect; + +-- Function to get HOT update count +CREATE OR REPLACE 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; + + -- Read both committed and transaction-local stats + -- In autocommit mode (default for regression tests), this works correctly + -- Note: In explicit transactions (BEGIN/COMMIT), committed stats already + -- include flushed updates, so this would double-count. For explicit + -- transaction testing, call pg_stat_force_next_flush() before this function. + 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; + +-- Check if a tuple is part of a HOT chain (has a predecessor on same page) +CREATE OR REPLACE FUNCTION has_hot_chain(rel_name text, target_ctid tid) +RETURNS boolean AS $$ +DECLARE + block_num int; + page_item record; +BEGIN + block_num := (target_ctid::text::point)[0]::int; + + -- Look for a different tuple on the same page that points to our target tuple + FOR page_item IN + SELECT lp, lp_flags, t_ctid + FROM heap_page_items(get_raw_page(rel_name, block_num)) + WHERE lp_flags = 1 + AND t_ctid IS NOT NULL + AND t_ctid = target_ctid + AND ('(' || block_num::text || ',' || lp::text || ')')::tid != target_ctid + LOOP + RETURN true; + END LOOP; + + RETURN false; +END; +$$ LANGUAGE plpgsql; + +-- Print the HOT chain starting from a given tuple +CREATE OR REPLACE FUNCTION print_hot_chain(rel_name text, start_ctid tid) +RETURNS TABLE(chain_position int, ctid tid, lp_flags text, t_ctid tid, chain_end boolean) AS +$$ +#variable_conflict use_column +DECLARE + block_num int; + line_ptr int; + current_ctid tid := start_ctid; + next_ctid tid; + position int := 0; + max_iterations int := 100; + page_item record; + found_predecessor boolean := false; + flags_name text; +BEGIN + block_num := (start_ctid::text::point)[0]::int; + + -- Find the predecessor (old tuple pointing to our start_ctid) + FOR page_item IN + SELECT lp, lp_flags, t_ctid + FROM heap_page_items(get_raw_page(rel_name, block_num)) + WHERE lp_flags = 1 + AND t_ctid = start_ctid + LOOP + current_ctid := ('(' || block_num::text || ',' || page_item.lp::text || ')')::tid; + found_predecessor := true; + EXIT; + END LOOP; + + -- If no predecessor found, start with the given ctid + IF NOT found_predecessor THEN + current_ctid := start_ctid; + END IF; + + -- Follow the chain forward + WHILE position < max_iterations LOOP + line_ptr := (current_ctid::text::point)[1]::int; + + FOR page_item IN + SELECT lp, lp_flags, t_ctid + FROM heap_page_items(get_raw_page(rel_name, block_num)) + WHERE lp = line_ptr + LOOP + -- Map lp_flags to names + flags_name := CASE page_item.lp_flags + WHEN 0 THEN 'unused (0)' + WHEN 1 THEN 'normal (1)' + WHEN 2 THEN 'redirect (2)' + WHEN 3 THEN 'dead (3)' + ELSE 'unknown (' || page_item.lp_flags::text || ')' + END; + + RETURN QUERY SELECT + position, + current_ctid, + flags_name, + page_item.t_ctid, + (page_item.t_ctid IS NULL OR page_item.t_ctid = current_ctid)::boolean + ; + + IF page_item.t_ctid IS NULL OR page_item.t_ctid = current_ctid THEN + RETURN; + END IF; + + next_ctid := page_item.t_ctid; + + IF (next_ctid::text::point)[0]::int != block_num THEN + RETURN; + END IF; + + current_ctid := next_ctid; + position := position + 1; + END LOOP; + + IF position = 0 THEN + RETURN; + END IF; + END LOOP; +END; +$$ LANGUAGE plpgsql; + +-- Basic HOT update functionality +CREATE TABLE hot_test ( + id int PRIMARY KEY, + indexed_col int, + non_indexed_col text +) USING heap WITH (fillfactor = 50); + +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'); + +-- Get baseline +SELECT * FROM get_hot_count('hot_test'); + +-- Should be HOT updates (only non-indexed column modified) +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; + +-- Verify HOT updates occurred +SELECT * FROM get_hot_count('hot_test'); + +-- Dump the HOT chain for tuple with id == 1 +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); + +-- Trigger optimistic heap page pruning +SELECT ctid, * FROM hot_test; + +-- Dump the HOT chain after prune +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); + +SET SESSION enable_seqscan = OFF; +SET SESSION enable_bitmapscan = OFF; + +-- Verify indexes still work +EXPLAIN (COSTS OFF) SELECT id, indexed_col FROM hot_test WHERE indexed_col = 100; +SELECT id, indexed_col FROM hot_test WHERE indexed_col = 100; + +-- Vacuum the relation, expect the HOT chain to collapse +VACUUM hot_test; + +-- Show that there is no chain after vacuum +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); + +-- Non-HOT update (update indexed column) +UPDATE hot_test SET indexed_col = 150 WHERE id = 1; +SELECT * FROM get_hot_count('hot_test'); + +-- Verify index was updated (new value findable) +EXPLAIN (COSTS OFF) SELECT id, indexed_col FROM hot_test WHERE indexed_col = 150; +SELECT id, indexed_col FROM hot_test WHERE indexed_col = 150; + +-- Verify old value no longer in index +EXPLAIN (COSTS OFF) SELECT id FROM hot_test WHERE indexed_col = 100; +SELECT id FROM hot_test WHERE indexed_col = 100; + +SET SESSION enable_seqscan = ON; +SET SESSION enable_bitmapscan = ON; + +-- All-or-none property: updating one indexed column requires ALL index updates +DROP TABLE hot_test; +CREATE TABLE hot_test ( + id int PRIMARY KEY, + col_a int, + col_b int, + col_c int, + non_indexed text +) USING heap WITH (fillfactor = 50); + +CREATE INDEX hot_test_a_idx ON hot_test(col_a); +CREATE INDEX hot_test_b_idx ON hot_test(col_b); +CREATE INDEX hot_test_c_idx ON hot_test(col_c); + +INSERT INTO hot_test VALUES (1, 10, 20, 30, 'initial'); + +-- Update only col_a - should NOT be HOT because an indexed column changed +-- This means ALL indexes must be updated (all-or-none property) +UPDATE hot_test SET col_a = 15 WHERE id = 1; +SELECT * FROM get_hot_count('hot_test'); + +-- Verify all three indexes still work correctly +SELECT id, col_a FROM hot_test WHERE col_a = 15; -- updated index +SELECT id, col_b FROM hot_test WHERE col_b = 20; -- unchanged index +SELECT id, col_c FROM hot_test WHERE col_c = 30; -- unchanged index + +-- Now update only non-indexed column - should be HOT +UPDATE hot_test SET non_indexed = 'updated'; +SELECT * FROM get_hot_count('hot_test'); + +-- Verify all indexes still work +SELECT id FROM hot_test WHERE col_a = 15 AND col_b = 20 AND col_c = 30; + +-- Partial index: both old and new outside predicate (conservative = non-HOT) +DROP TABLE hot_test; +CREATE TABLE hot_test ( + id int PRIMARY KEY, + status text, + data text +) WITH (fillfactor = 50); + +-- Partial index only covers status = 'active' +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 non-indexed column on 'active' row (in predicate, status unchanged) +-- Should be HOT +UPDATE hot_test SET data = 'updated1' WHERE id = 1; +SELECT * FROM get_hot_count('hot_test'); + +-- Update non-indexed column on 'inactive' row (outside predicate) +-- Should be HOT +UPDATE hot_test SET data = 'updated2' WHERE id = 2; +SELECT * FROM get_hot_count('hot_test'); + +-- Update status from 'inactive' to 'deleted' (both outside predicate) +-- PostgreSQL is conservative: heap insert happens before predicate check +-- So this is NON-HOT even though both values are outside predicate +UPDATE hot_test SET status = 'deleted' WHERE id = 2; +SELECT * FROM get_hot_count('hot_test'); + +-- Verify index still works for 'active' rows +SELECT id, status FROM hot_test WHERE status = 'active'; + +-- Only BRIN (summarizing) indexes on non-PK columns +DROP TABLE hot_test; +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); + +-- Update both BRIN columns - should still be HOT (only summarizing indexes) +UPDATE hot_test SET ts = '2024-01-02', brin_col = 2000 WHERE id = 1; +SELECT * FROM get_hot_count('hot_test'); + +-- Verify BRIN indexes work +SELECT id FROM hot_test WHERE ts >= '2024-01-02'; +SELECT id FROM hot_test WHERE brin_col >= 2000; + +-- Update non-indexed column - should also be HOT +UPDATE hot_test SET value = 200 WHERE id = 1; +SELECT * FROM get_hot_count('hot_test'); + +-- Unique constraint (unique index) behaves like regular index +DROP TABLE hot_test; +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 data (non-indexed) - should be HOT +UPDATE hot_test SET data = 'updated'; +SELECT * FROM get_hot_count('hot_test'); + +-- Verify unique constraint still enforced +SELECT id, unique_col, data FROM hot_test ORDER BY id; + +-- This should fail (unique violation) +UPDATE hot_test SET unique_col = 100 WHERE id = 2; + +-- Multi-column index: any column change = non-HOT +DROP TABLE hot_test; +CREATE TABLE hot_test ( + id int PRIMARY KEY, + col_a int, + col_b int, + col_c int, + col_d int +) WITH (fillfactor = 50); + +CREATE INDEX hot_test_ab_idx ON hot_test(col_a, col_b); +CREATE INDEX hot_test_ab_inc_c_idx ON hot_test(col_a, col_b) INCLUDE(col_c); + +INSERT INTO hot_test VALUES (1, 10, 20, 30, 40); + +-- Update col_a (part of multi-column index) - should NOT be HOT +UPDATE hot_test SET col_a = 15; +SELECT * FROM get_hot_count('hot_test'); + +-- Update col_b (part of multi-column index) - should NOT be HOT +UPDATE hot_test SET col_b = 25; +SELECT * FROM get_hot_count('hot_test'); + +-- Update col_c (not indexed, but included) - should NOT be HOT +UPDATE hot_test SET col_c = 35; + +-- Verify multi-column index-only scan for included columns works +EXPLAIN (COSTS OFF) SELECT col_c FROM hot_test WHERE col_a = 15 AND col_b = 25; +SELECT col_c FROM hot_test WHERE col_a = 15 AND col_b = 25; + +-- ============================================================================ +-- Expression indexes with JSONB +-- ============================================================================ +DROP TABLE hot_test; +CREATE TABLE hot_test ( + id int PRIMARY KEY, + data jsonb +) USING heap WITH(fillfactor = 50); + +-- Indexes on specific JSONB paths +CREATE INDEX hot_test_status_idx ON hot_test((data->'status')); +CREATE INDEX hot_test_user_id_idx ON hot_test((data->'user'->'id')); + +INSERT INTO hot_test VALUES ( + 1, + '{"status": "active", "user": {"id": 123, "name": "Alice"}, "count": 0}'::jsonb +); + +-- Baseline +SELECT 'Baseline' AS test, * FROM get_hot_count('hot_test'); + +-- Update non-indexed path {count} - should NOT be HOT +UPDATE hot_test SET data = jsonb_set(data, '{count}', '1') WHERE id = 1; +SELECT 'After updating count (non-indexed)' AS test, * FROM get_hot_count('hot_test'); + +-- Update different non-indexed path {user,name} - should NOT be HOT +UPDATE hot_test SET data = jsonb_set(data, '{user,name}', '"Bob"') WHERE id = 1; +SELECT 'After updating user.name (non-indexed)' AS test, * FROM get_hot_count('hot_test'); + +-- Update indexed path {status} - should NOT be HOT +UPDATE hot_test SET data = jsonb_set(data, '{status}', '"inactive"') WHERE id = 1; +SELECT 'After updating status (indexed)' AS test, * FROM get_hot_count('hot_test'); + +-- Update indexed path {user,id} - should NOT be HOT +UPDATE hot_test SET data = jsonb_set(data, '{user,id}', '456') WHERE id = 1; +SELECT 'After updating user.id (indexed)' AS test, * FROM get_hot_count('hot_test'); + +-- Verify indexes still work correctly +SELECT id FROM hot_test WHERE data->'status' = '"inactive"'::jsonb; +SELECT id FROM hot_test WHERE data->'user'->'id' = '456'::jsonb; + +-- ============================================================================ +-- Nested paths and path intersection +-- ============================================================================ +DROP TABLE hot_test; +CREATE TABLE hot_test ( + id int PRIMARY KEY, + data jsonb +) USING heap WITH(fillfactor = 50); + +CREATE INDEX hot_test_deep_idx ON hot_test((data->'a'->'b'->'c')); + +INSERT INTO hot_test VALUES ( + 1, + '{"a": {"b": {"c": "indexed", "d": "not-indexed"}}, "x": "other"}'::jsonb +); + +SELECT 'Baseline' AS test, * FROM get_hot_count('hot_test'); + +-- Update sibling of indexed path {a,b,d} - should NOT be HOT +UPDATE hot_test SET data = jsonb_set(data, '{a,b,d}', '"updated"') WHERE id = 1; +SELECT 'After updating a.b.d (sibling, non-indexed)' AS test, * FROM get_hot_count('hot_test'); + +-- Update unrelated path {x} - should NOT be HOT +UPDATE hot_test SET data = jsonb_set(data, '{x}', '"modified"') WHERE id = 1; +SELECT 'After updating x (unrelated path)' AS test, * FROM get_hot_count('hot_test'); + +-- Update parent of indexed path {a,b} - should NOT be HOT (affects child) +UPDATE hot_test SET data = jsonb_set(data, '{a,b}', '{"c": "new", "d": "data"}') WHERE id = 1; +SELECT 'After updating a.b (parent of indexed)' AS test, * FROM get_hot_count('hot_test'); + +-- ============================================================================ +-- Multiple JSONB mutation functions +-- ============================================================================ +DROP TABLE hot_test; +CREATE TABLE hot_test ( + id int PRIMARY KEY, + data jsonb +) USING heap WITH(fillfactor = 50); + +CREATE INDEX hot_test_keep_idx ON hot_test((data->'keep')); + +INSERT INTO hot_test VALUES ( + 1, + '{"keep": "important", "remove": "unimportant", "extra": "data"}'::jsonb +); + +SELECT 'Baseline' AS test, * FROM get_hot_count('hot_test'); + +-- jsonb_delete on non-indexed key - should NOT be HOT +UPDATE hot_test SET data = data - 'remove' WHERE id = 1; +SELECT 'After deleting non-indexed key' AS test, * FROM get_hot_count('hot_test'); + +-- jsonb_set on non-indexed key - should NOT be HOT +UPDATE hot_test SET data = jsonb_set(data, '{extra}', '"modified"') WHERE id = 1; +SELECT 'After modifying non-indexed key' AS test, * FROM get_hot_count('hot_test'); + +-- jsonb_delete on indexed key - should NOT be HOT +UPDATE hot_test SET data = data - 'keep' WHERE id = 1; +SELECT 'After deleting indexed key' AS test, * FROM get_hot_count('hot_test'); + +-- ============================================================================ +-- Array operations +-- ============================================================================ +DROP TABLE hot_test; +CREATE TABLE hot_test ( + id int PRIMARY KEY, + data jsonb +) USING heap WITH(fillfactor = 50); + +-- Index on array element +CREATE INDEX hot_test_tags_idx ON hot_test((data->'tags'->0)); + +INSERT INTO hot_test VALUES ( + 1, + '{"tags": ["indexed", "second", "third"], "other": "data"}'::jsonb +); + +SELECT 'Baseline' AS test, * FROM get_hot_count('hot_test'); + +-- Update non-indexed array element - should NOT be HOT +UPDATE hot_test SET data = jsonb_set(data, '{tags,1}', '"modified"') WHERE id = 1; +SELECT 'After updating tags[1]' AS test, * FROM get_hot_count('hot_test'); + +-- Update indexed array element - should NOT be HOT +UPDATE hot_test SET data = jsonb_set(data, '{tags,0}', '"changed"') WHERE id = 1; +SELECT 'After updating tags[0] (indexed)' AS test, * FROM get_hot_count('hot_test'); + +-- ============================================================================ +-- Whole column index +-- ============================================================================ +DROP TABLE hot_test; +CREATE TABLE hot_test ( + id int PRIMARY KEY, + data jsonb +) USING heap WITH(fillfactor = 50); + +-- Index on entire JSONB column, and a path extraction +CREATE INDEX hot_test_whole_idx ON hot_test(data); +CREATE INDEX hot_test_tags_idx ON hot_test((data->'a')); + +INSERT INTO hot_test VALUES (1, '{"a": 1, "b": 1}'::jsonb); + +SELECT 'Baseline' AS test, * FROM get_hot_count('hot_test'); + +-- Any modification to data - should NOT be HOT (whole column indexed) +UPDATE hot_test SET data = jsonb_set(data, '{b}', '2') WHERE id = 1; +SELECT 'After modifying any field (whole column indexed)' AS test, * FROM get_hot_count('hot_test'); + +-- ============================================================================ +-- Performance at scale +-- ============================================================================ +DROP TABLE hot_test; +CREATE TABLE hot_test ( + id int PRIMARY KEY, + data jsonb +) USING heap WITH(fillfactor=50); + +CREATE INDEX hot_test_status_idx ON hot_test((data->'status')); +CREATE INDEX hot_test_priority_idx ON hot_test((data->'priority')); + +-- Insert 10000 rows +INSERT INTO hot_test +SELECT i, jsonb_build_object( + 'status', 'active', + 'priority', 1, + 'count', 0, + 'data', 'value_' || i +) +FROM generate_series(1, 10000) i; + +SELECT 'Baseline (10000 rows)' AS test, * FROM get_hot_count('hot_test'); + +-- Update non-indexed fields on all rows - should NOT be HOT +UPDATE hot_test SET data = jsonb_set(data, '{count}', to_jsonb((data->>'count')::int + 1)); + +SELECT 'After updating 10000 rows (non-indexed)' AS test, * FROM get_hot_count('hot_test'); + +-- Verify correctness +SELECT COUNT(*) AS rows_with_count_1 FROM hot_test WHERE (data->>'count')::int = 1; + +-- Update indexed field on subset - should NOT be HOT for those rows +UPDATE hot_test SET data = jsonb_set(data, '{status}', '"inactive"') +WHERE id <= 10; + +SELECT 'After updating 10 rows (indexed)' AS test, * FROM get_hot_count('hot_test'); + +-- Verify indexes work +SELECT COUNT(*) FROM hot_test WHERE data->>'status' = 'inactive'; +SELECT COUNT(*) FROM hot_test WHERE data->>'status' = 'active'; + +-- Only BRIN (summarizing) indexes on non-PK columns +DROP TABLE hot_test; +CREATE TABLE hot_test ( + id int PRIMARY KEY, + ts timestamp, + value int, + brin_col int +) USING heap 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); + +-- Update both BRIN columns - should still be HOT (only summarizing indexes) +UPDATE hot_test SET ts = '2024-01-02', brin_col = 2000 WHERE id = 1; +SELECT 'After updating ts, brin_col (summarizing-only)' AS test, * FROM get_hot_count('hot_test'); + +-- Verify BRIN indexes work +SELECT id FROM hot_test WHERE ts >= '2024-01-02'; +SELECT id FROM hot_test WHERE brin_col >= 2000; + +-- TOASTed columns can participate in HOT +DROP TABLE hot_test; +CREATE TABLE hot_test ( + id int PRIMARY KEY, + large_text text +) USING heap WITH(fillfactor = 50); + +CREATE INDEX hot_test_idx ON hot_test(large_text); + +-- Insert row with TOASTed column (> 2KB) +INSERT INTO hot_test VALUES (1, repeat('x', 3000)); + +-- Update TOASTed column - should NOT be HOT +UPDATE hot_test SET large_text = repeat('y', 3000); +SELECT 'After updating large_text (TOASTed)' AS test, * FROM get_hot_count('hot_test'); + +-- Partitioned tables: HOT works within partitions +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); +CREATE TABLE hot_test_part2 PARTITION OF hot_test_partitioned + FOR VALUES FROM (100) TO (200); + +CREATE INDEX hot_test_partitioned_idx ON hot_test_partitioned(indexed_col); +CREATE INDEX hot_test_part2_data ON hot_test_part2(data); + +INSERT INTO hot_test_partitioned VALUES (1, 50, 100, 'initial1'); +INSERT INTO hot_test_partitioned VALUES (2, 150, 200, 'initial2'); + +-- Update in partition 1 (non-indexed column) - should be HOT +UPDATE hot_test_partitioned SET data = 'UPDATED' WHERE id = 1; +SELECT 'After updating partition 1 data' AS test, * FROM get_hot_count('hot_test_part1'); + +-- Update in partition 2 (indexed column) - should NOT be HOT +UPDATE hot_test_partitioned SET data = 'UPDATED' WHERE id = 2; +SELECT 'After updating large_text (TOASTed)' AS test, * FROM get_hot_count('hot_test_part2'); + +-- Verify indexes work on partitions +SELECT id FROM hot_test_partitioned WHERE indexed_col = 100; +SELECT id FROM hot_test_partitioned WHERE indexed_col = 200; + +-- Update indexed column in partition - should NOT be HOT +-- Partition 1 previously had 1 update and 1 HOT update, this should +-- change that to 2 updates and 1 HOT update. +UPDATE hot_test_partitioned SET indexed_col = 150 WHERE id = 1; +SELECT 'After updating indexed_col' AS test, * FROM get_hot_count('hot_test_part1'); + +-- ============================================================================ +-- Partial indexes with complex predicates on JSONB +-- ============================================================================ +-- Test partial indexes with WHERE clauses on JSONB expressions. +DROP TABLE hot_test; +CREATE TABLE hot_test ( + id int PRIMARY KEY, + data jsonb +) USING heap WITH(fillfactor = 50); + +-- Partial index: only index status when priority > 5 +CREATE INDEX hot_test_partial_idx ON hot_test((data->'status')) + WHERE (data->>'priority')::int > 5; + +INSERT INTO hot_test VALUES ( + 1, + '{"status": "active", "priority": 10, "count": 0}'::jsonb +); +INSERT INTO hot_test VALUES ( + 2, + '{"status": "active", "priority": 3, "count": 0}'::jsonb +); + +SELECT 'Partial Index Test: Baseline' AS test, * FROM get_hot_count('hot_test'); + +-- Update non-indexed path on row inside predicate (priority=10 > 5) +-- Should NOT be HOT despite {count} is not indexed +UPDATE hot_test SET data = jsonb_set(data, '{count}', '1') WHERE id = 1; +SELECT 'Partial Index Test: count update, inside predicate' AS test, * FROM get_hot_count('hot_test'); + +-- Update non-indexed path on row outside predicate (priority=3 <= 5) +-- Should NOT be HOT dispite {count} is not indexed +UPDATE hot_test SET data = jsonb_set(data, '{count}', '1') WHERE id = 2; +SELECT 'Partial Index Test: count update, outside predicate' AS test, * FROM get_hot_count('hot_test'); + +-- Update indexed path on row inside predicate (priority=10 > 5) +-- Should NOT be HOT indexed portion is updated +UPDATE hot_test SET data = jsonb_set(data, '{status}', '"inactive"') WHERE id = 1; +SELECT 'Partial Index Test: status update, inside predicate' AS test, * FROM get_hot_count('hot_test'); + +-- Update indexed path on row outside predicate (priority=3 <= 5) +-- PostgreSQL makes a conservative choice and treats it as non-HOT because the +-- indexed column changed, even though the before/after rows are outside the predicate +UPDATE hot_test SET data = jsonb_set(data, '{status}', '"inactive"') WHERE id = 2; +SELECT 'Partial Index Test: status update, outside predicate' AS test, * FROM get_hot_count('hot_test'); + +-- Verify index works +SELECT id FROM hot_test WHERE data->'status' = '"inactive"'::jsonb AND (data->>'priority')::int > 5; +-- ============================================================================ +DROP TABLE IF EXISTS hot_test; +DROP TABLE IF EXISTS hot_test_partitioned CASCADE; +DROP FUNCTION IF EXISTS has_hot_chain(text, tid); +DROP FUNCTION IF EXISTS print_hot_chain(text, tid); +DROP FUNCTION IF EXISTS get_hot_count(text); +DROP EXTENSION pageinspect; -- 2.51.2