diff --git a/contrib/amcheck/expected/check_heap.out b/contrib/amcheck/expected/check_heap.out index 979e5e84e723d..b8dee2bb71b2e 100644 --- a/contrib/amcheck/expected/check_heap.out +++ b/contrib/amcheck/expected/check_heap.out @@ -231,6 +231,46 @@ SELECT * FROM verify_heapam('test_foreign_table', endblock := NULL); ERROR: cannot check relation "test_foreign_table" DETAIL: This operation is not supported for foreign tables. +-- HOT-indexed (HOT/SIU) on-page artifacts: +-- +-- A HOT-indexed UPDATE keeps the new tuple on the same page as a heap-only +-- tuple marked HEAP_INDEXED_UPDATED and plants index entries pointing at its +-- own TID. Pruning a chain of such updates collapses dead members to +-- LP_REDIRECT forwarders and preserves the LP of a live HOT-indexed member +-- whose index entries may not yet be swept. verify_heapam must treat all of +-- these as legitimate. This scenario exercises them and asserts that +-- verify_heapam reports zero corruption against legitimate HOT-indexed +-- activity. +CREATE TABLE hot_indexed_check (id int PRIMARY KEY, c1 int, c2 int, c3 int) + WITH (fillfactor = 70); +CREATE INDEX hot_indexed_check_c1 ON hot_indexed_check (c1); +CREATE INDEX hot_indexed_check_c2 ON hot_indexed_check (c2); +INSERT INTO hot_indexed_check + SELECT g, g, g, g FROM generate_series(1, 200) g; +-- Single-step UPDATEs: each row gets one HOT-indexed update. Each +-- successful HOT-indexed update keeps its new tuple on-page and inserts an +-- entry only into the index whose attribute changed. +UPDATE hot_indexed_check SET c1 = c1 + 1000; +-- Multi-step UPDATEs: drive several successive HOT-indexed updates against +-- the same rows so prune sees a chain of dead intermediates and collapses +-- them to LP_REDIRECT forwarders. An explicit VACUUM runs the prune path +-- and exercises chain collapse. +UPDATE hot_indexed_check SET c2 = c2 + 1 WHERE id <= 50; +UPDATE hot_indexed_check SET c2 = c2 + 1 WHERE id <= 50; +UPDATE hot_indexed_check SET c2 = c2 + 1 WHERE id <= 50; +VACUUM (INDEX_CLEANUP off) hot_indexed_check; +-- verify_heapam must not report any corruption against legitimate HOT- +-- indexed artifacts. Selecting the corrupting message makes any +-- regression unmistakable in the regress diff. +SELECT blkno, offnum, attnum, msg + FROM verify_heapam('hot_indexed_check', + startblock := NULL, + endblock := NULL); + blkno | offnum | attnum | msg +-------+--------+--------+----- +(0 rows) + +DROP TABLE hot_indexed_check; -- cleanup DROP TABLE heaptest; DROP TABLESPACE regress_test_stats_tblspc; diff --git a/contrib/amcheck/sql/check_heap.sql b/contrib/amcheck/sql/check_heap.sql index 1745bae634e56..c0ba2635180fe 100644 --- a/contrib/amcheck/sql/check_heap.sql +++ b/contrib/amcheck/sql/check_heap.sql @@ -138,6 +138,43 @@ SELECT * FROM verify_heapam('test_foreign_table', startblock := NULL, endblock := NULL); +-- HOT-indexed (HOT/SIU) on-page artifacts: +-- +-- A HOT-indexed UPDATE keeps the new tuple on the same page as a heap-only +-- tuple marked HEAP_INDEXED_UPDATED and plants index entries pointing at its +-- own TID. Pruning a chain of such updates collapses dead members to +-- LP_REDIRECT forwarders and preserves the LP of a live HOT-indexed member +-- whose index entries may not yet be swept. verify_heapam must treat all of +-- these as legitimate. This scenario exercises them and asserts that +-- verify_heapam reports zero corruption against legitimate HOT-indexed +-- activity. +CREATE TABLE hot_indexed_check (id int PRIMARY KEY, c1 int, c2 int, c3 int) + WITH (fillfactor = 70); +CREATE INDEX hot_indexed_check_c1 ON hot_indexed_check (c1); +CREATE INDEX hot_indexed_check_c2 ON hot_indexed_check (c2); +INSERT INTO hot_indexed_check + SELECT g, g, g, g FROM generate_series(1, 200) g; +-- Single-step UPDATEs: each row gets one HOT-indexed update. Each +-- successful HOT-indexed update keeps its new tuple on-page and inserts an +-- entry only into the index whose attribute changed. +UPDATE hot_indexed_check SET c1 = c1 + 1000; +-- Multi-step UPDATEs: drive several successive HOT-indexed updates against +-- the same rows so prune sees a chain of dead intermediates and collapses +-- them to LP_REDIRECT forwarders. An explicit VACUUM runs the prune path +-- and exercises chain collapse. +UPDATE hot_indexed_check SET c2 = c2 + 1 WHERE id <= 50; +UPDATE hot_indexed_check SET c2 = c2 + 1 WHERE id <= 50; +UPDATE hot_indexed_check SET c2 = c2 + 1 WHERE id <= 50; +VACUUM (INDEX_CLEANUP off) hot_indexed_check; +-- verify_heapam must not report any corruption against legitimate HOT- +-- indexed artifacts. Selecting the corrupting message makes any +-- regression unmistakable in the regress diff. +SELECT blkno, offnum, attnum, msg + FROM verify_heapam('hot_indexed_check', + startblock := NULL, + endblock := NULL); +DROP TABLE hot_indexed_check; + -- cleanup DROP TABLE heaptest; DROP TABLESPACE regress_test_stats_tblspc; diff --git a/contrib/amcheck/verify_heapam.c b/contrib/amcheck/verify_heapam.c index 20ff58aa78259..93a3bc318a2f8 100644 --- a/contrib/amcheck/verify_heapam.c +++ b/contrib/amcheck/verify_heapam.c @@ -13,6 +13,7 @@ #include "access/detoast.h" #include "access/genam.h" #include "access/heaptoast.h" +#include "access/hot_indexed.h" #include "access/multixact.h" #include "access/relation.h" #include "access/table.h" @@ -522,9 +523,12 @@ verify_heapam(PG_FUNCTION_ARGS) */ if (ItemIdIsRedirected(ctx.itemid)) { - OffsetNumber rdoffnum = ItemIdGetRedirect(ctx.itemid); + OffsetNumber rdoffnum; ItemId rditem; + /* Resolve the redirect's target offset. */ + rdoffnum = ItemIdGetRedirect(ctx.itemid); + if (rdoffnum < FirstOffsetNumber) { report_corruption(&ctx, @@ -615,18 +619,47 @@ verify_heapam(PG_FUNCTION_ARGS) ctx.tuphdr = (HeapTupleHeader) PageGetItem(ctx.page, ctx.itemid); ctx.natts = HeapTupleHeaderGetNatts(ctx.tuphdr); - /* Ok, ready to check this next tuple */ - check_tuple(&ctx, - &xmin_commit_status_ok[ctx.offnum], - &xmin_commit_status[ctx.offnum]); + /* + * A HOT-selectively-updated collapse-survivor stub is an + * LP_NORMAL item that is not a real tuple: HEAP_INDEXED_UPDATED + * with natts == 0, permanently invisible (HEAP_XMIN_INVALID), + * carrying a forward link and a modified-attrs bitmap. The + * per-tuple checks assume a real tuple and would misreport it, so + * skip them; the update-chain pass below still records its + * forward edge and treats it like a redirect (a forwarding node). + */ + if (!HotIndexedHeaderIsStub(ctx.tuphdr)) + check_tuple(&ctx, + &xmin_commit_status_ok[ctx.offnum], + &xmin_commit_status[ctx.offnum]); /* * If the CTID field of this tuple seems to point to another tuple * on the same page, record that tuple as the successor of this - * one. + * one. A collapse-survivor stub stores its forward link in the + * t_ctid offset only (the block half is repurposed to hold the + * stub's write-time natts), so resolve its successor via the stub + * accessor; the forward target is always on the same page. */ - nextblkno = ItemPointerGetBlockNumber(&(ctx.tuphdr)->t_ctid); - nextoffnum = ItemPointerGetOffsetNumber(&(ctx.tuphdr)->t_ctid); + if (HotIndexedHeaderIsStub(ctx.tuphdr)) + { + nextblkno = ctx.blkno; + nextoffnum = HotIndexedStubGetForward(ctx.tuphdr); + if (nextoffnum == ctx.offnum || + nextoffnum < FirstOffsetNumber || nextoffnum > maxoff) + { + report_corruption(&ctx, + psprintf("HOT-indexed stub forward link to item at offset %d is out of range or self-referential (valid range %d..%d)", + nextoffnum, + FirstOffsetNumber, maxoff)); + continue; + } + } + else + { + nextblkno = ItemPointerGetBlockNumber(&(ctx.tuphdr)->t_ctid); + nextoffnum = ItemPointerGetOffsetNumber(&(ctx.tuphdr)->t_ctid); + } if (nextblkno == ctx.blkno && nextoffnum != ctx.offnum && nextoffnum >= FirstOffsetNumber && nextoffnum <= maxoff) successor[ctx.offnum] = nextoffnum; @@ -675,7 +708,7 @@ verify_heapam(PG_FUNCTION_ARGS) */ Assert(ItemIdIsNormal(next_lp)); - /* Can only redirect to a HOT tuple. */ + /* A redirect targets the first surviving chain member. */ next_htup = (HeapTupleHeader) PageGetItem(ctx.page, next_lp); if (!HeapTupleHeaderIsHeapOnly(next_htup)) { @@ -687,6 +720,19 @@ verify_heapam(PG_FUNCTION_ARGS) /* HOT chains should not intersect. */ if (predecessor[nextoffnum] != InvalidOffsetNumber) { + /* + * In the HOT/SIU model several redirects legitimately + * forward to the same live tuple: when a chain collapses, + * the root and each entry-bearing dead member become a + * redirect to first_live so every stale btree entry still + * resolves there (the read path then rechecks the leaf + * key). Multiple predecessors are therefore expected + * when the target is HOT-selectively-updated; keep the + * first predecessor and do not report it as corruption. + */ + if ((next_htup->t_infomask2 & HEAP_INDEXED_UPDATED) != 0) + continue; + report_corruption(&ctx, psprintf("redirect line pointer points to offset %d, but offset %d also points there", nextoffnum, predecessor[nextoffnum])); @@ -701,6 +747,30 @@ verify_heapam(PG_FUNCTION_ARGS) continue; } + /* + * A collapse-survivor stub forwards like a redirect: it is not a + * real tuple, so don't apply the tuple-to-tuple update-chain + * checks, but do record the predecessor edge to its target so the + * live tuple it ultimately forwards to is not mistaken for a + * chain root. Its target must be heap-only (another stub or the + * live heap-only tuple). + */ + curr_htup = (HeapTupleHeader) PageGetItem(ctx.page, curr_lp); + if (HotIndexedHeaderIsStub(curr_htup)) + { + if (ItemIdIsNormal(next_lp)) + { + next_htup = (HeapTupleHeader) PageGetItem(ctx.page, next_lp); + if (!HeapTupleHeaderIsHeapOnly(next_htup)) + report_corruption(&ctx, + psprintf("HOT-indexed stub forwards to a non-heap-only tuple at offset %d", + nextoffnum)); + else if (predecessor[nextoffnum] == InvalidOffsetNumber) + predecessor[nextoffnum] = ctx.offnum; + } + continue; + } + /* * If the next line pointer is a redirect, or if it's a tuple but * the XMAX of this tuple doesn't match the XMIN of the next @@ -709,7 +779,6 @@ verify_heapam(PG_FUNCTION_ARGS) */ if (ItemIdIsRedirected(next_lp)) continue; - curr_htup = (HeapTupleHeader) PageGetItem(ctx.page, curr_lp); curr_xmax = HeapTupleHeaderGetUpdateXid(curr_htup); next_htup = (HeapTupleHeader) PageGetItem(ctx.page, next_lp); next_xmin = HeapTupleHeaderGetXmin(next_htup); diff --git a/contrib/amcheck/verify_nbtree.c b/contrib/amcheck/verify_nbtree.c index 3ef2d66f82675..c1bfdcc133cbc 100644 --- a/contrib/amcheck/verify_nbtree.c +++ b/contrib/amcheck/verify_nbtree.c @@ -1489,6 +1489,30 @@ bt_target_page_check(BtreeCheckState *state) if (state->heapallindexed && P_ISLEAF(topaque) && !ItemIdIsDead(itemid)) { IndexTuple norm; + ItemPointerData saved_tid; + bool siu_stripped = false; + + /* + * A HOT-indexed (SIU) fresh entry stores its heap TID with the + * ItemPointerSIUMaybeStaleFlag marker bit set in the offset field + * (see storage/itemptr.h). The marker is a bitmap-scan hint, not + * part of the tuple's heap address, and the heapallindexed callback + * (bt_tuple_present_callback) fingerprints the plain heap TID it + * gets from the heap scan. Strip the marker from this leaf tuple's + * TID for the duration of fingerprinting so the two normalized + * images match; restore it immediately afterward, since later + * checks in this loop still read itup from the (immutable) page + * image. Only plain leaf tuples can carry the marker. + */ + if (!BTreeTupleIsPosting(itup) && + ItemPointerIsSIUMaybeStale(&itup->t_tid)) + { + saved_tid = itup->t_tid; + /* Strip on the raw offset (no validity assert); restore below. */ + itup->t_tid.ip_posid = + ItemPointerOffsetNumberStrip(itup->t_tid.ip_posid); + siu_stripped = true; + } if (BTreeTupleIsPosting(itup)) { @@ -1516,6 +1540,10 @@ bt_target_page_check(BtreeCheckState *state) if (norm != itup) pfree(norm); } + + /* Restore the SIU marker bit stripped for fingerprinting, if any. */ + if (siu_stripped) + itup->t_tid = saved_tid; } /* diff --git a/contrib/bloom/expected/bloom.out b/contrib/bloom/expected/bloom.out index edc855121e12e..a5675da62b4ca 100644 --- a/contrib/bloom/expected/bloom.out +++ b/contrib/bloom/expected/bloom.out @@ -228,3 +228,35 @@ CREATE INDEX bloomidx2 ON tst USING bloom (i, t) WITH (length=0); ERROR: value 0 out of bounds for option "length" CREATE INDEX bloomidx2 ON tst USING bloom (i, t) WITH (col1=0); ERROR: value 0 out of bounds for option "col1" +-- +-- HOT-indexed (SIU) BitmapAnd correctness with a bloom index. +-- +-- A bloom index is bitmap-scan-only and non-summarizing, so it is eligible to +-- be the "unchanged" side of a HOT-indexed update: the update changes a +-- btree-indexed column while leaving the bloom-indexed column alone. The +-- btree fresh entry then points at the new heap-only tuple and the bloom entry +-- still points at the chain root; a BitmapAnd of the two must not drop the +-- matching row (the SIU fresh entry's TID carries an internal may-be-stale +-- marker that tbm_add_tuples degrades to a lossy page, so the intersection +-- keeps the row and the heap-side recheck resolves it). This is handled +-- centrally in tbm_add_tuples, so bloom -- like every other index AM -- needs +-- no bloom-specific code for it; this test guards that. +CREATE TABLE bloom_siu (id int PRIMARY KEY, bcol int, changed int) WITH (fillfactor = 50); +CREATE INDEX bloom_siu_b ON bloom_siu USING bloom (bcol); +CREATE INDEX bloom_siu_c ON bloom_siu (changed); +INSERT INTO bloom_siu VALUES (1, 11, 21); +UPDATE bloom_siu SET changed = 22 WHERE id = 1; -- HOT-indexed: only "changed" +SET enable_seqscan = off; +SET enable_indexscan = off; +SET enable_bitmapscan = on; +-- Must return the row (previously a false negative before the SIU bitmap fix). +SELECT count(*) AS bloom_bitmapand FROM bloom_siu WHERE bcol = 11 AND changed = 22; + bloom_bitmapand +----------------- + 1 +(1 row) + +RESET enable_seqscan; +RESET enable_indexscan; +RESET enable_bitmapscan; +DROP TABLE bloom_siu; diff --git a/contrib/bloom/sql/bloom.sql b/contrib/bloom/sql/bloom.sql index fa63b301c6e4c..28c9b3674ef43 100644 --- a/contrib/bloom/sql/bloom.sql +++ b/contrib/bloom/sql/bloom.sql @@ -93,3 +93,31 @@ SELECT reloptions FROM pg_class WHERE oid = 'bloomidx'::regclass; \set VERBOSITY terse CREATE INDEX bloomidx2 ON tst USING bloom (i, t) WITH (length=0); CREATE INDEX bloomidx2 ON tst USING bloom (i, t) WITH (col1=0); + +-- +-- HOT-indexed (SIU) BitmapAnd correctness with a bloom index. +-- +-- A bloom index is bitmap-scan-only and non-summarizing, so it is eligible to +-- be the "unchanged" side of a HOT-indexed update: the update changes a +-- btree-indexed column while leaving the bloom-indexed column alone. The +-- btree fresh entry then points at the new heap-only tuple and the bloom entry +-- still points at the chain root; a BitmapAnd of the two must not drop the +-- matching row (the SIU fresh entry's TID carries an internal may-be-stale +-- marker that tbm_add_tuples degrades to a lossy page, so the intersection +-- keeps the row and the heap-side recheck resolves it). This is handled +-- centrally in tbm_add_tuples, so bloom -- like every other index AM -- needs +-- no bloom-specific code for it; this test guards that. +CREATE TABLE bloom_siu (id int PRIMARY KEY, bcol int, changed int) WITH (fillfactor = 50); +CREATE INDEX bloom_siu_b ON bloom_siu USING bloom (bcol); +CREATE INDEX bloom_siu_c ON bloom_siu (changed); +INSERT INTO bloom_siu VALUES (1, 11, 21); +UPDATE bloom_siu SET changed = 22 WHERE id = 1; -- HOT-indexed: only "changed" +SET enable_seqscan = off; +SET enable_indexscan = off; +SET enable_bitmapscan = on; +-- Must return the row (previously a false negative before the SIU bitmap fix). +SELECT count(*) AS bloom_bitmapand FROM bloom_siu WHERE bcol = 11 AND changed = 22; +RESET enable_seqscan; +RESET enable_indexscan; +RESET enable_bitmapscan; +DROP TABLE bloom_siu; diff --git a/contrib/pageinspect/Makefile b/contrib/pageinspect/Makefile index eae989569d013..ee796f5f25f0c 100644 --- a/contrib/pageinspect/Makefile +++ b/contrib/pageinspect/Makefile @@ -13,7 +13,7 @@ OBJS = \ rawpage.o EXTENSION = pageinspect -DATA = pageinspect--1.12--1.13.sql \ +DATA = pageinspect--1.13--1.14.sql pageinspect--1.12--1.13.sql \ pageinspect--1.11--1.12.sql pageinspect--1.10--1.11.sql \ pageinspect--1.9--1.10.sql pageinspect--1.8--1.9.sql \ pageinspect--1.7--1.8.sql pageinspect--1.6--1.7.sql \ @@ -23,6 +23,11 @@ DATA = pageinspect--1.12--1.13.sql \ pageinspect--1.0--1.1.sql PGFILEDESC = "pageinspect - functions to inspect contents of database pages" +# hot_updates / hot_indexed_updates verify HOT and HOT-indexed (SIU) heap +# behaviour using pageinspect, amcheck and btree_gist together, so they live +# here (where pageinspect is built) and pull in the other two extensions. +EXTRA_INSTALL = contrib/amcheck contrib/btree_gist + # "page" is first because it creates the extension. REGRESS = \ page \ @@ -32,7 +37,9 @@ REGRESS = \ gin \ gist \ hash \ - oldextversions + oldextversions \ + hot_updates \ + hot_indexed_updates ifdef USE_PGXS PG_CONFIG = pg_config diff --git a/contrib/pageinspect/btreefuncs.c b/contrib/pageinspect/btreefuncs.c index 3381e7cc2a745..2eeee3e0e190c 100644 --- a/contrib/pageinspect/btreefuncs.c +++ b/contrib/pageinspect/btreefuncs.c @@ -485,8 +485,8 @@ bt_page_print_tuples(ua_page_items *uargs) bool leafpage = uargs->leafpage; bool rightmost = uargs->rightmost; bool ispivottuple; - Datum values[9]; - bool nulls[9]; + Datum values[10]; + bool nulls[10]; HeapTuple tuple; ItemId id; IndexTuple itup; @@ -497,6 +497,9 @@ bt_page_print_tuples(ua_page_items *uargs) *datacstring; char *ptr; ItemPointer htid; + bool hot_indexed; + ItemPointerData ctid_show; + ItemPointerData htid_show; id = PageGetItemId(page, offset); @@ -508,7 +511,35 @@ bt_page_print_tuples(ua_page_items *uargs) j = 0; memset(nulls, 0, sizeof(nulls)); values[j++] = Int16GetDatum(offset); - values[j++] = ItemPointerGetDatum(&itup->t_tid); + + /* + * A HOT-indexed (SIU) fresh entry stores its heap TID with the + * ItemPointerSIUMaybeStaleFlag marker bit set in the offset field (see + * storage/itemptr.h). Report the real offset in the ctid column -- the + * marker is a bitmap-scan hint, not part of the tuple's address -- and + * expose the marker itself in the separate hot_indexed column below. + * Only a plain leaf tuple's t_tid is a genuine heap TID that can carry + * the flag; a pivot/posting tuple repurposes t_tid for other data and + * must be shown verbatim. + */ + if (!BTreeTupleIsPivot(itup) && !BTreeTupleIsPosting(itup)) + { + ctid_show = itup->t_tid; + hot_indexed = ItemPointerIsSIUMaybeStale(&ctid_show); + /* + * Strip the SIU marker directly on the raw offset. Do not route + * through ItemPointerGetOffsetNumber(), which asserts a valid (nonzero) + * offset: this function also inspects arbitrary raw page images, where + * a corrupt ip_posid == 0 must be shown, not trip an assertion. + */ + ctid_show.ip_posid = ItemPointerOffsetNumberStrip(ctid_show.ip_posid); + values[j++] = ItemPointerGetDatum(&ctid_show); + } + else + { + hot_indexed = false; + values[j++] = ItemPointerGetDatum(&itup->t_tid); + } values[j++] = Int16GetDatum(IndexTupleSize(itup)); values[j++] = BoolGetDatum(IndexTupleHasNulls(itup)); values[j++] = BoolGetDatum(IndexTupleHasVarwidths(itup)); @@ -584,7 +615,13 @@ bt_page_print_tuples(ua_page_items *uargs) } if (htid) - values[j++] = ItemPointerGetDatum(htid); + { + /* Report the real offset; the SIU marker is shown via hot_indexed. */ + htid_show = *htid; + /* Strip on the raw offset; see the ctid path for why not the getter. */ + htid_show.ip_posid = ItemPointerOffsetNumberStrip(htid_show.ip_posid); + values[j++] = ItemPointerGetDatum(&htid_show); + } else nulls[j++] = true; @@ -606,6 +643,14 @@ bt_page_print_tuples(ua_page_items *uargs) else nulls[j++] = true; + /* + * hot_indexed: true iff this leaf entry is a HOT-indexed (SIU) fresh + * entry. Only present from pageinspect 1.14 on; older extension versions + * declare one fewer output column, so gate on the actual tuple descriptor. + */ + if (uargs->tupd->natts > j) + values[j++] = BoolGetDatum(hot_indexed); + /* Build and return the result tuple */ tuple = heap_form_tuple(uargs->tupd, values, nulls); diff --git a/contrib/pageinspect/expected/btree.out b/contrib/pageinspect/expected/btree.out index 0aa5d73322f82..173734c21533b 100644 --- a/contrib/pageinspect/expected/btree.out +++ b/contrib/pageinspect/expected/btree.out @@ -152,16 +152,17 @@ ERROR: invalid block number -1 SELECT * FROM bt_page_items('test1_a_idx', 0); ERROR: block 0 is a meta page SELECT * FROM bt_page_items('test1_a_idx', 1); --[ RECORD 1 ]----------------------- -itemoffset | 1 -ctid | (0,1) -itemlen | 16 -nulls | f -vars | f -data | 01 00 00 00 00 00 00 01 -dead | f -htid | (0,1) -tids | +-[ RECORD 1 ]------------------------ +itemoffset | 1 +ctid | (0,1) +itemlen | 16 +nulls | f +vars | f +data | 01 00 00 00 00 00 00 01 +dead | f +htid | (0,1) +tids | +hot_indexed | f SELECT * FROM bt_page_items('test1_a_idx', 2); ERROR: block number 2 is out of range @@ -170,16 +171,17 @@ ERROR: invalid block number SELECT * FROM bt_page_items(get_raw_page('test1_a_idx', 0)); ERROR: block is a meta page SELECT * FROM bt_page_items(get_raw_page('test1_a_idx', 1)); --[ RECORD 1 ]----------------------- -itemoffset | 1 -ctid | (0,1) -itemlen | 16 -nulls | f -vars | f -data | 01 00 00 00 00 00 00 01 -dead | f -htid | (0,1) -tids | +-[ RECORD 1 ]------------------------ +itemoffset | 1 +ctid | (0,1) +itemlen | 16 +nulls | f +vars | f +data | 01 00 00 00 00 00 00 01 +dead | f +htid | (0,1) +tids | +hot_indexed | f SELECT * FROM bt_page_items(get_raw_page('test1_a_idx', 2)); ERROR: block number 2 is out of range for relation "test1_a_idx" diff --git a/contrib/pageinspect/expected/hot_indexed_updates.out b/contrib/pageinspect/expected/hot_indexed_updates.out new file mode 100644 index 0000000000000..2bd5156917326 --- /dev/null +++ b/contrib/pageinspect/expected/hot_indexed_updates.out @@ -0,0 +1,2206 @@ +-- +-- HOT_INDEXED_UPDATES +-- Test HOT-indexed update (hot-indexed), aka HOT-indexed, behaviour +-- +-- Every UPDATE in this file modifies at least one non-summarizing +-- indexed attribute. On a pre-hot-indexed server all of these would be +-- non-HOT; on the hot-indexed branch each eligible update stays on-page and +-- inserts into only the indexes whose attributes actually changed. +-- +-- We verify four things: +-- (A) pg_stat counters: HOT and hot-indexed counts increment as expected +-- (B) index lookups return the new value and not the stale value +-- for EQUALITY queries (the read-side staleness test drops a +-- leaf whose covered attribute changed on the way to the live tuple) +-- (C) pg_relation_hot_indexed_stats reports the HOT-indexed versions we expect +-- (D) **RANGE/INEQUALITY** queries return the correct number of +-- tuples -- this is the class of bugs where a stale btree +-- entry's key is still reachable via a looser scan key; the +-- crossed-attribute bitmap drops the stale arrival because the index's +-- attribute changed between that leaf's target and the live tuple +-- +CREATE EXTENSION IF NOT EXISTS pageinspect; +NOTICE: extension "pageinspect" already exists, skipping +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; + 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; +CREATE OR REPLACE FUNCTION get_hi_count(rel_name text) +RETURNS TABLE (updates BIGINT, hot BIGINT, hot_idx 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); + hot_idx := COALESCE(pg_stat_get_tuples_hot_indexed_updated(rel_oid), 0) + + COALESCE(pg_stat_get_xact_tuples_hot_indexed_updated(rel_oid), 0); + RETURN NEXT; +END; +$$ LANGUAGE plpgsql; +-- --------------------------------------------------------------------------- +-- 1. Basic hot-indexed: modifying an indexed column stays HOT and counts as hot-indexed +-- --------------------------------------------------------------------------- +CREATE TABLE hi_basic ( + id int PRIMARY KEY, + indexed_col int, + non_indexed_col text +) WITH (fillfactor = 50); +CREATE INDEX hi_basic_idx ON hi_basic(indexed_col); +INSERT INTO hi_basic VALUES (1, 100, 'initial'); +-- Pre-hot-indexed this would be non-HOT. Under hot-indexed it's HOT-indexed; both the +-- HOT counter and the hot-indexed counter advance. +UPDATE hi_basic SET indexed_col = 150 WHERE id = 1; +SELECT pg_stat_force_next_flush(); + pg_stat_force_next_flush +-------------------------- + +(1 row) + +SELECT * FROM get_hi_count('hi_basic'); + updates | hot | hot_idx +---------+-----+--------- + 1 | 1 | 1 +(1 row) + +-- The new value is reachable via the index. +SET enable_seqscan = off; +EXPLAIN (COSTS OFF) SELECT id, indexed_col FROM hi_basic WHERE indexed_col = 150; + QUERY PLAN +----------------------------------------- + Bitmap Heap Scan on hi_basic + Recheck Cond: (indexed_col = 150) + -> Bitmap Index Scan on hi_basic_idx + Index Cond: (indexed_col = 150) +(4 rows) + +SELECT id, indexed_col FROM hi_basic WHERE indexed_col = 150; + id | indexed_col +----+------------- + 1 | 150 +(1 row) + +-- The old value is not reachable through this index: the stale btree +-- entry (indexed_col=100) walks to the current tuple via the hot-indexed hop, +-- nodeIndexscan re-evaluates `indexed_col = 100` against the current +-- tuple (indexed_col=150), and the row is correctly dropped. This is +-- the equality-lookup case the crossed-attribute bitmap handles. +EXPLAIN (COSTS OFF) SELECT id FROM hi_basic WHERE indexed_col = 100; + QUERY PLAN +----------------------------------------- + Bitmap Heap Scan on hi_basic + Recheck Cond: (indexed_col = 100) + -> Bitmap Index Scan on hi_basic_idx + Index Cond: (indexed_col = 100) +(4 rows) + +SELECT id FROM hi_basic WHERE indexed_col = 100; + id +---- +(0 rows) + +RESET enable_seqscan; +-- pg_relation_hot_indexed_stats sees one HOT-indexed version, zero HOT redirects (the +-- chain has not yet been pruned so no LP_REDIRECT exists). +SELECT n_hot_indexed, n_chains, avg_chain_len, max_chain_len +FROM pg_relation_hot_indexed_stats('hi_basic'); + n_hot_indexed | n_chains | avg_chain_len | max_chain_len +---------------+----------+---------------+--------------- + 1 | 0 | 0 | 0 +(1 row) + +DROP TABLE hi_basic; +-- --------------------------------------------------------------------------- +-- 2. RANGE/INEQUALITY correctness after hot-indexed on an indexed column +-- +-- This is the test class that catches the hot-indexed false-dup bug: a stale +-- btree entry whose key value still satisfies the range predicate, +-- reachable via the hot-indexed chain hop. +-- +-- To exercise the bug we must force an IndexScan plan (the +-- IndexOnlyScan path permissively drops every hot-indexed-reachable index-only +-- hit; the BitmapHeapScan path dedups by TID). We include a payload +-- column not present in the PK so the planner must heap-fetch. +-- +-- The read-side crossed-attribute bitmap makes the IndexScan return the correct +-- count of 1: the stale entry ('1','5') chain-walks to the live tuple across +-- the b-changing hop, and because the PK covers b the overlap is non-empty, so +-- the stale leaf is dropped. The fresh entry ('1','15') points directly at the +-- live tuple (no hop after it) and is kept. The ORDER BY likewise returns the +-- single live row. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_range ( + a int, + b int, + payload text, + PRIMARY KEY (a, b) +) WITH (fillfactor = 50); +INSERT INTO hi_range VALUES (1, 5, 'hi'); +-- hot-indexed update on the second PK column: stale btree entry ('1','5') +-- remains, new entry ('1','15') inserted. The stale entry points at +-- the chain root; the fresh entry points directly at the new +-- heap-only tuple. +UPDATE hi_range SET b = 15 WHERE a = 1 AND b = 5; +SET enable_seqscan = off; +SET enable_bitmapscan = off; +-- IndexScan: payload IS NOT NULL forces heap fetch, no IndexOnlyScan. +-- The stale ('1','5') leaf is dropped by the crossed-attribute bitmap, so this +-- returns 1. +EXPLAIN (COSTS OFF) +SELECT count(*) FROM hi_range WHERE a = 1 AND b < 100 AND payload IS NOT NULL; + QUERY PLAN +-------------------------------------------------- + Aggregate + -> Index Scan using hi_range_pkey on hi_range + Index Cond: ((a = 1) AND (b < 100)) + Filter: (payload IS NOT NULL) +(4 rows) + +SELECT count(*) FROM hi_range WHERE a = 1 AND b < 100 AND payload IS NOT NULL; + count +------- + 1 +(1 row) + +SELECT a, b FROM hi_range WHERE a = 1 AND payload IS NOT NULL ORDER BY b; + a | b +---+---- + 1 | 15 +(1 row) + +-- IndexOnlyScan: the page holds a preserved HOT-indexed member so it is never all-visible; IOS +-- performs the heap fetch and the crossed-attribute bitmap drops the stale ('1','5') +-- leaf, so count = 1. +EXPLAIN (COSTS OFF) SELECT count(*) FROM hi_range WHERE a = 1 AND b < 100; + QUERY PLAN +------------------------------------------------------- + Aggregate + -> Index Only Scan using hi_range_pkey on hi_range + Index Cond: ((a = 1) AND (b < 100)) +(3 rows) + +SELECT count(*) FROM hi_range WHERE a = 1 AND b < 100; + count +------- + 1 +(1 row) + +-- BitmapHeapScan: TID dedup collapses the stale and fresh hits. +SET enable_indexscan = off; +SET enable_indexonlyscan = off; +RESET enable_bitmapscan; +EXPLAIN (COSTS OFF) SELECT count(*) FROM hi_range WHERE a = 1 AND b < 100; + QUERY PLAN +--------------------------------------------------- + Aggregate + -> Bitmap Heap Scan on hi_range + Recheck Cond: ((a = 1) AND (b < 100)) + -> Bitmap Index Scan on hi_range_pkey + Index Cond: ((a = 1) AND (b < 100)) +(5 rows) + +SELECT count(*) FROM hi_range WHERE a = 1 AND b < 100; + count +------- + 1 +(1 row) + +RESET enable_indexscan; +RESET enable_indexonlyscan; +-- SeqScan: reads the heap directly, sees exactly one live tuple. +RESET enable_seqscan; +SET enable_indexscan = off; +SET enable_indexonlyscan = off; +SET enable_bitmapscan = off; +EXPLAIN (COSTS OFF) SELECT count(*) FROM hi_range WHERE a = 1 AND b < 100; + QUERY PLAN +----------------------------------------- + Aggregate + -> Seq Scan on hi_range + Filter: ((b < 100) AND (a = 1)) +(3 rows) + +SELECT count(*) FROM hi_range WHERE a = 1 AND b < 100; + count +------- + 1 +(1 row) + +RESET enable_indexscan; +RESET enable_indexonlyscan; +RESET enable_bitmapscan; +-- Same shape on a secondary (non-PK) btree: another hot-indexed update on b. +CREATE INDEX hi_range_b_idx ON hi_range(b); +UPDATE hi_range SET b = 25 WHERE a = 1 AND b = 15; +SET enable_seqscan = off; +SET enable_bitmapscan = off; +-- IndexScan path on the secondary index; same fix applies. +SELECT count(*) FROM hi_range WHERE b BETWEEN 0 AND 100 AND payload IS NOT NULL; + count +------- + 1 +(1 row) + +RESET enable_seqscan; +RESET enable_bitmapscan; +DROP TABLE hi_range; +-- --------------------------------------------------------------------------- +-- 3. All-or-none on a multi-indexed table: hot-indexed only touches indexes +-- whose attributes changed +-- --------------------------------------------------------------------------- +CREATE TABLE hi_multi ( + id int PRIMARY KEY, + col_a int, + col_b int, + col_c int, + non_indexed text +) WITH (fillfactor = 50); +CREATE INDEX hi_multi_a_idx ON hi_multi(col_a); +CREATE INDEX hi_multi_b_idx ON hi_multi(col_b); +CREATE INDEX hi_multi_c_idx ON hi_multi(col_c); +INSERT INTO hi_multi VALUES (1, 10, 20, 30, 'initial'); +-- col_a only: under hot-indexed this is HOT-indexed, and only hi_multi_a_idx +-- gets a new entry. hi_multi_b_idx / hi_multi_c_idx keep pointing +-- at the chain root. +UPDATE hi_multi SET col_a = 15 WHERE id = 1; +SELECT pg_stat_force_next_flush(); + pg_stat_force_next_flush +-------------------------- + +(1 row) + +SELECT * FROM get_hi_count('hi_multi'); + updates | hot | hot_idx +---------+-----+--------- + 1 | 1 | 1 +(1 row) + +-- Lookups on all three indexes return the row. +SET enable_seqscan = off; +SELECT id FROM hi_multi WHERE col_a = 15; + id +---- + 1 +(1 row) + +SELECT id FROM hi_multi WHERE col_b = 20; + id +---- + 1 +(1 row) + +SELECT id FROM hi_multi WHERE col_c = 30; + id +---- + 1 +(1 row) + +-- Old col_a value is unreachable by equality (stale entry dropped by the +-- read-side crossed-attribute bitmap). +SELECT id FROM hi_multi WHERE col_a = 10; + id +---- +(0 rows) + +RESET enable_seqscan; +DROP TABLE hi_multi; +-- --------------------------------------------------------------------------- +-- 4. Multi-column btree: hot-indexed on part of a composite key +-- --------------------------------------------------------------------------- +CREATE TABLE hi_composite ( + id int PRIMARY KEY, + col_a int, + col_b int, + data text +) WITH (fillfactor = 50); +CREATE INDEX hi_composite_ab_idx ON hi_composite(col_a, col_b); +INSERT INTO hi_composite VALUES (1, 10, 20, 'data'); +-- col_a is part of the composite key: hot-indexed. +UPDATE hi_composite SET col_a = 15; +SELECT pg_stat_force_next_flush(); + pg_stat_force_next_flush +-------------------------- + +(1 row) + +SELECT * FROM get_hi_count('hi_composite'); + updates | hot | hot_idx +---------+-----+--------- + 1 | 1 | 1 +(1 row) + +-- Reset and then update col_b (also part of the key). +UPDATE hi_composite SET col_a = 10; +UPDATE hi_composite SET col_b = 25; +SELECT pg_stat_force_next_flush(); + pg_stat_force_next_flush +-------------------------- + +(1 row) + +SELECT * FROM get_hi_count('hi_composite'); + updates | hot | hot_idx +---------+-----+--------- + 3 | 3 | 3 +(1 row) + +DROP TABLE hi_composite; +-- --------------------------------------------------------------------------- +-- 5. Partial index: status transition out-of-predicate +-- +-- 'status' is a partial-index predicate column. A change to a predicate +-- column can flip a row in or out of the index, which the read-side key +-- recheck cannot detect, so HeapUpdateHotAllowable conservatively disqualifies +-- HOT-indexed for any predicate-column change (even this out-of-predicate -> +-- out-of-predicate case). The update is therefore non-HOT, and the partial +-- index correctly stays empty for these non-'active' rows. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_partial ( + id int PRIMARY KEY, + status text, + data text +) WITH (fillfactor = 50); +CREATE INDEX hi_partial_active_idx ON hi_partial(status) WHERE status = 'active'; +INSERT INTO hi_partial VALUES (1, 'active', 'data1'); +INSERT INTO hi_partial VALUES (2, 'inactive', 'data2'); +INSERT INTO hi_partial VALUES (3, 'deleted', 'data3'); +-- out -> out transition on the predicate column: HOT-indexed keeps it on-page, +-- and the partial index gets no entry (the row satisfies the predicate neither +-- before nor after the update). +UPDATE hi_partial SET status = 'deleted' WHERE id = 2; +SELECT pg_stat_force_next_flush(); + pg_stat_force_next_flush +-------------------------- + +(1 row) + +SELECT * FROM get_hi_count('hi_partial'); + updates | hot | hot_idx +---------+-----+--------- + 1 | 1 | 1 +(1 row) + +-- The partial index still correctly answers "active" queries. +SELECT id, status FROM hi_partial WHERE status = 'active'; + id | status +----+-------- + 1 | active +(1 row) + +DROP TABLE hi_partial; +-- --------------------------------------------------------------------------- +-- 6. Partition: hot-indexed inside one partition +-- --------------------------------------------------------------------------- +CREATE TABLE hi_part ( + id int, + partition_key int, + indexed_col int, + data text, + PRIMARY KEY (id, partition_key) +) PARTITION BY RANGE (partition_key); +CREATE TABLE hi_part_1 PARTITION OF hi_part + FOR VALUES FROM (1) TO (100) WITH (fillfactor = 50); +CREATE INDEX hi_part_idx ON hi_part(indexed_col); +INSERT INTO hi_part VALUES (1, 50, 100, 'data'); +UPDATE hi_part SET indexed_col = 150 WHERE id = 1; +SELECT pg_stat_force_next_flush(); + pg_stat_force_next_flush +-------------------------- + +(1 row) + +SELECT * FROM get_hi_count('hi_part_1'); + updates | hot | hot_idx +---------+-----+--------- + 1 | 1 | 1 +(1 row) + +SET enable_seqscan = off; +SELECT id FROM hi_part WHERE indexed_col = 150; + id +---- + 1 +(1 row) + +SELECT id FROM hi_part WHERE indexed_col = 100; + id +---- +(0 rows) + +RESET enable_seqscan; +DROP TABLE hi_part CASCADE; +-- --------------------------------------------------------------------------- +-- 7. Trigger modifies indexed column: hot-indexed, not non-HOT +-- --------------------------------------------------------------------------- +CREATE TABLE hi_trigger ( + id int PRIMARY KEY, + triggered_col int, + data text +) WITH (fillfactor = 50); +CREATE INDEX hi_trigger_idx ON hi_trigger(triggered_col); +CREATE OR REPLACE FUNCTION hi_trigger_bump() +RETURNS TRIGGER AS $$ +BEGIN + NEW.triggered_col = NEW.triggered_col + 1; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER before_update_bump + BEFORE UPDATE ON hi_trigger + FOR EACH ROW + EXECUTE FUNCTION hi_trigger_bump(); +INSERT INTO hi_trigger VALUES (1, 100, 'initial'); +-- UPDATE's SET clause doesn't touch the indexed column, but the +-- trigger modifies it via heap_modify_tuple. hot-indexed must detect this +-- and keep the tuple on-page (HEAP_INDEXED_UPDATED) plus a new btree entry. +UPDATE hi_trigger SET data = 'updated' WHERE id = 1; +SELECT pg_stat_force_next_flush(); + pg_stat_force_next_flush +-------------------------- + +(1 row) + +SELECT * FROM get_hi_count('hi_trigger'); + updates | hot | hot_idx +---------+-----+--------- + 1 | 1 | 1 +(1 row) + +SELECT triggered_col FROM hi_trigger WHERE id = 1; + triggered_col +--------------- + 101 +(1 row) + +-- New value reachable. +SET enable_seqscan = off; +SELECT id FROM hi_trigger WHERE triggered_col = 101; + id +---- + 1 +(1 row) + +SELECT id FROM hi_trigger WHERE triggered_col = 100; + id +---- +(0 rows) + +RESET enable_seqscan; +DROP TABLE hi_trigger CASCADE; +DROP FUNCTION hi_trigger_bump(); +-- --------------------------------------------------------------------------- +-- 8. JSONB expression index: HOT-indexed is not yet supported on expression +-- indexes, so the update falls back to a non-HOT update (hot_idx = 0). +-- Reads stay correct. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_jsonb ( + id int PRIMARY KEY, + data jsonb +) WITH (fillfactor = 50); +CREATE INDEX hi_jsonb_name_idx ON hi_jsonb ((data->>'name')); +INSERT INTO hi_jsonb VALUES (1, '{"name":"Alice","age":30}'); +-- Changing the indexed expression's value (name): expression indexes are not +-- yet supported, so this is a non-HOT update. +UPDATE hi_jsonb SET data = jsonb_set(data, '{name}', '"Alice2"') WHERE id = 1; +SELECT pg_stat_force_next_flush(); + pg_stat_force_next_flush +-------------------------- + +(1 row) + +SELECT * FROM get_hi_count('hi_jsonb'); + updates | hot | hot_idx +---------+-----+--------- + 1 | 0 | 0 +(1 row) + +SET enable_seqscan = off; +SELECT id FROM hi_jsonb WHERE data->>'name' = 'Alice2'; + id +---- + 1 +(1 row) + +SELECT id FROM hi_jsonb WHERE data->>'name' = 'Alice'; + id +---- +(0 rows) + +RESET enable_seqscan; +DROP TABLE hi_jsonb; +-- --------------------------------------------------------------------------- +-- 9. GIN index with changed extracted keys: hot-indexed +-- --------------------------------------------------------------------------- +CREATE TABLE hi_gin ( + id int PRIMARY KEY, + tags text[] +) WITH (fillfactor = 50); +CREATE INDEX hi_gin_tags_idx ON hi_gin USING gin (tags); +INSERT INTO hi_gin VALUES (1, ARRAY['tag1', 'tag2']); +-- Adding a tag yields a different extracted-key set: hot-indexed. +UPDATE hi_gin SET tags = ARRAY['tag1', 'tag2', 'tag5'] WHERE id = 1; +SELECT pg_stat_force_next_flush(); + pg_stat_force_next_flush +-------------------------- + +(1 row) + +SELECT * FROM get_hi_count('hi_gin'); + updates | hot | hot_idx +---------+-----+--------- + 1 | 1 | 1 +(1 row) + +SET enable_seqscan = off; +SELECT id FROM hi_gin WHERE tags @> ARRAY['tag5']; + id +---- + 1 +(1 row) + +RESET enable_seqscan; +DROP TABLE hi_gin; +-- --------------------------------------------------------------------------- +-- 10. Per-index HOT-indexed counters: skipped vs matched +-- +-- A table with two independent secondary indexes. An UPDATE touches a +-- column covered by only one of them; the HOT-indexed path must insert +-- into that one index and skip the other. pg_stat_all_indexes reports +-- matched>0 on the updated index and skipped>0 on the untouched index. +-- --------------------------------------------------------------------------- +CREATE TABLE hotidx_perindex ( + id int PRIMARY KEY, + a int, + b int +) WITH (fillfactor = 50); +CREATE INDEX hotidx_perindex_a ON hotidx_perindex(a); +CREATE INDEX hotidx_perindex_b ON hotidx_perindex(b); +INSERT INTO hotidx_perindex VALUES (1, 100, 200); +-- Modify only column a. HOT-indexed inserts into hotidx_perindex_a and +-- skips hotidx_perindex_b (primary key indrelid is the table itself and +-- also unchanged, so it counts as skipped too). +UPDATE hotidx_perindex SET a = 101 WHERE id = 1; +-- Force flush of pending stats to the shared entry. +SELECT pg_stat_force_next_flush(); + pg_stat_force_next_flush +-------------------------- + +(1 row) + +SELECT indexrelname, + n_tup_hot_indexed_upd_matched AS matched, + n_tup_hot_indexed_upd_skipped AS skipped + FROM pg_stat_all_indexes + WHERE relname = 'hotidx_perindex' + ORDER BY indexrelname; + indexrelname | matched | skipped +----------------------+---------+--------- + hotidx_perindex_a | 1 | 0 + hotidx_perindex_b | 0 | 1 + hotidx_perindex_pkey | 0 | 1 +(3 rows) + +-- A second UPDATE touching only b inverts the assignment. +UPDATE hotidx_perindex SET b = 201 WHERE id = 1; +SELECT pg_stat_force_next_flush(); + pg_stat_force_next_flush +-------------------------- + +(1 row) + +SELECT indexrelname, + n_tup_hot_indexed_upd_matched AS matched, + n_tup_hot_indexed_upd_skipped AS skipped + FROM pg_stat_all_indexes + WHERE relname = 'hotidx_perindex' + ORDER BY indexrelname; + indexrelname | matched | skipped +----------------------+---------+--------- + hotidx_perindex_a | 1 | 1 + hotidx_perindex_b | 1 | 1 + hotidx_perindex_pkey | 0 | 2 +(3 rows) + +-- Invariant: matched + skipped == owning table's n_tup_hot_indexed_upd. +SELECT indexrelname, + n_tup_hot_indexed_upd_matched + n_tup_hot_indexed_upd_skipped AS total, + (SELECT n_tup_hot_indexed_upd FROM pg_stat_all_tables + WHERE relname = 'hotidx_perindex') AS table_hot_idx_upd + FROM pg_stat_all_indexes + WHERE relname = 'hotidx_perindex' + ORDER BY indexrelname; + indexrelname | total | table_hot_idx_upd +----------------------+-------+------------------- + hotidx_perindex_a | 2 | 2 + hotidx_perindex_b | 2 | 2 + hotidx_perindex_pkey | 2 | 2 +(3 rows) + +-- Boolean assertion of the same invariant. This is the canonical form +-- reviewers asked for: every index entry is either matched (the index +-- got a fresh insert this UPDATE) or skipped (HOT-indexed correctly +-- avoided an insert because the index's attrs did not change). If the +-- two counters drift apart from the table-level n_tup_hot_indexed_upd we +-- have either lost a per-index increment or double-counted one. +SELECT bool_and((n_tup_hot_indexed_upd_matched + n_tup_hot_indexed_upd_skipped) = + (SELECT n_tup_hot_indexed_upd FROM pg_stat_all_tables + WHERE relname = 'hotidx_perindex')) + AS perindex_invariant_holds + FROM pg_stat_all_indexes + WHERE relname = 'hotidx_perindex'; + perindex_invariant_holds +-------------------------- + t +(1 row) + +DROP TABLE hotidx_perindex; +-- --------------------------------------------------------------------------- +-- 11. Long hot-loop UPDATE stays compact and HOT-indexed +-- +-- A long run of HOT-indexed UPDATEs to a single row stays compact: prune +-- collapses each dead version to a redirect to the live tuple and reuses its +-- slot, so the heap stays bounded and the chain does not grow unbounded. +-- Every UPDATE that changes the indexed column (and leaves another index, +-- here the PK, unchanged) takes the HOT-indexed path. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_chaincap ( + id int PRIMARY KEY, + a int +) WITH (fillfactor = 10); +CREATE INDEX hi_chaincap_a_idx ON hi_chaincap(a); +INSERT INTO hi_chaincap VALUES (1, 0); +DO $$ +DECLARE + i int; +BEGIN + FOR i IN 1 .. 200 LOOP + UPDATE hi_chaincap SET a = i WHERE id = 1; + END LOOP; +END $$; +-- After 200 UPDATEs the row's value is 200. +SELECT a FROM hi_chaincap WHERE id = 1; + a +----- + 200 +(1 row) + +-- Every UPDATE took the HOT-indexed path (the PK index is unchanged, so it is +-- skipped), so n_tup_hot_indexed_upd advanced. +SELECT pg_stat_force_next_flush(); + pg_stat_force_next_flush +-------------------------- + +(1 row) + +SELECT hot_idx > 0 AS hot_indexed_fired + FROM get_hi_count('hi_chaincap'); + hot_indexed_fired +------------------- + t +(1 row) + +-- The heap stayed compact: prune+collapse reclaimed the dead versions, so the +-- single live row stays within a couple of pages. pg_relation_size reflects +-- the table's actual current size regardless of vacuum/analyze stats, unlike +-- pg_class.relpages (which is only updated by VACUUM/ANALYZE and would be +-- trivially <= 1 on this never-vacuumed table even if pruning had failed and +-- the heap had ballooned). +SELECT pg_relation_size('hi_chaincap') <= 8192 * 2 AS heap_stayed_compact; + heap_stayed_compact +--------------------- + t +(1 row) + +DROP TABLE hi_chaincap; +-- --------------------------------------------------------------------------- +-- 12. A HOT-indexed chain forms and reads through it stay correct +-- +-- Several HOT-indexed updates of the same live row build a multi-hop chain of +-- preserved HOT-indexed members, each carrying its own crossed-attribute +-- bitmap. We assert only horizon-independent facts: at least one HOT-indexed +-- member is present (the live version always is, and cannot be pruned), a scan +-- through the secondary index returns the one live row by its current key, and +-- none of the superseded keys surface (the crossed-attribute bitmap filters +-- their stale leaves). +-- +-- We deliberately do NOT assert an exact member count or any post-VACUUM +-- collapse/reclaim state: opportunistic HOT pruning and VACUUM collapse are +-- both gated on the superseded versions falling below the global xmin horizon, +-- which a snapshot held elsewhere in the running regression cluster can pin +-- back indefinitely -- so the physical layout (n_hot_indexed's exact value, +-- whether the chain has collapsed to an LP_REDIRECT) is not deterministic +-- here. Prune/collapse and snapshot-gated stale-leaf reclaim are covered +-- deterministically by the hot_indexed_adversarial isolation spec, where +-- transaction ordering -- hence the horizon -- is controlled and the collapse +-- is validated by reader consistency across it (permutation 7). +-- --------------------------------------------------------------------------- +CREATE TABLE hi_reclaim ( + id int PRIMARY KEY, + a int +) WITH (fillfactor = 50, autovacuum_enabled = false); +CREATE INDEX hi_reclaim_a_idx ON hi_reclaim(a); +INSERT INTO hi_reclaim VALUES (1, 100); +-- Build a multi-hop chain via several HOT-indexed updates; the row stays live. +UPDATE hi_reclaim SET a = 200 WHERE id = 1; +UPDATE hi_reclaim SET a = 300 WHERE id = 1; +UPDATE hi_reclaim SET a = 400 WHERE id = 1; +-- The live version is a HOT-indexed member and cannot be pruned, so this holds +-- regardless of how much opportunistic pruning has happened. +SELECT n_hot_indexed >= 1 AS hot_indexed_member_present + FROM pg_relation_hot_indexed_stats('hi_reclaim'); + hot_indexed_member_present +---------------------------- + t +(1 row) + +-- The live row resolves through the secondary index by its current key, and +-- none of the superseded keys surface through their stale leaves. +SELECT id, a FROM hi_reclaim WHERE a = 400; + id | a +----+----- + 1 | 400 +(1 row) + +SELECT count(*) = 0 AS no_stale_key_surfaces + FROM hi_reclaim WHERE a IN (100, 200, 300); + no_stale_key_surfaces +----------------------- + t +(1 row) + +DROP TABLE hi_reclaim; +-- --------------------------------------------------------------------------- +-- 13. Page with a preserved HOT-indexed member is never marked all-visible +-- +-- pruneheap deliberately leaves PD_ALL_VISIBLE clear on any page that still +-- carries a preserved HOT-indexed member: an index-only scan must heap-fetch +-- through the chain so the read-side crossed-attribute bitmap can filter stale btree +-- entries. +-- +-- We force the freeze path with VACUUM (FREEZE, DISABLE_PAGE_SKIPPING) and +-- then read pd_flags via pageinspect.page_header. The page must still carry +-- a HOT-indexed member (n_hot_indexed > 0) AND must not have PD_ALL_VISIBLE +-- (0x0004). +-- --------------------------------------------------------------------------- +CREATE TABLE hi_vm ( + id int PRIMARY KEY, + a int +) WITH (fillfactor = 50, autovacuum_enabled = false); +CREATE INDEX hi_vm_a_idx ON hi_vm(a); +INSERT INTO hi_vm VALUES (1, 1); +-- Two HOT-indexed updates leave a multi-hop chain, so a preserved HOT-indexed +-- member remains on the page after prune, which is what this test needs. +UPDATE hi_vm SET a = 2 WHERE id = 1; +UPDATE hi_vm SET a = 3 WHERE id = 1; +-- Force the all-visible bit decision: VACUUM with DISABLE_PAGE_SKIPPING +-- considers every page; FREEZE pushes hint bits hard. After this, any +-- page bearing a preserved HOT-indexed member must still report all_visible = 0. +VACUUM (FREEZE, DISABLE_PAGE_SKIPPING) hi_vm; +SELECT n_hot_indexed >= 1 AS hot_indexed_present + FROM pg_relation_hot_indexed_stats('hi_vm'); + hot_indexed_present +--------------------- + t +(1 row) + +-- PD_ALL_VISIBLE = 0x0004. Must be 0 on a page with a preserved member. +SELECT (flags & 4) = 0 AS not_marked_all_visible + FROM page_header(get_raw_page('hi_vm', 0)); + not_marked_all_visible +------------------------ + t +(1 row) + +DROP TABLE hi_vm; +-- --------------------------------------------------------------------------- +-- 14. Cycle-key dedup: column rename a -> b -> a stays correct +-- +-- A rename does not rewrite heap or index entries; it only updates the +-- catalog. The relcache invalidation must trigger a fresh attribute +-- bitmap and the HOT-indexed predicate must compare attribute *numbers*, +-- not attribute *names*. After two renames that net to identity, every +-- subsequent UPDATE must continue to drive the HOT-indexed path. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_cycle ( + id int PRIMARY KEY, + a int +) WITH (fillfactor = 50); +CREATE INDEX hi_cycle_a_idx ON hi_cycle(a); +INSERT INTO hi_cycle VALUES (1, 100); +-- Cycle the column name and confirm both intermediate forms drive HOT-indexed. +ALTER TABLE hi_cycle RENAME COLUMN a TO b; +UPDATE hi_cycle SET b = 200 WHERE id = 1; +SELECT pg_stat_force_next_flush(); + pg_stat_force_next_flush +-------------------------- + +(1 row) + +SELECT hot_idx > 0 AS hot_indexed_after_first_rename + FROM get_hi_count('hi_cycle'); + hot_indexed_after_first_rename +-------------------------------- + t +(1 row) + +ALTER TABLE hi_cycle RENAME COLUMN b TO a; +UPDATE hi_cycle SET a = 300 WHERE id = 1; +-- Lookup via the index returns the current value, not any of the +-- pre-rename values. +SET enable_seqscan = off; +SELECT id, a FROM hi_cycle WHERE a = 300; + id | a +----+----- + 1 | 300 +(1 row) + +SELECT id FROM hi_cycle WHERE a = 100; + id +---- +(0 rows) + +SELECT id FROM hi_cycle WHERE a = 200; + id +---- +(0 rows) + +RESET enable_seqscan; +DROP TABLE hi_cycle; +-- --------------------------------------------------------------------------- +-- 15. Summarizing-only column UPDATE produces CLASSIC, not INDEXED +-- +-- HeapUpdateHotAllowable returns HEAP_HEAP_ONLY_UPDATE when every +-- modified indexed attribute is covered only by summarizing indexes. +-- A BRIN-only column is the canonical case: the BRIN index gets a +-- new summary entry via aminsert, but no per-update btree entry is +-- needed and HOT-indexed does not fire. The signal is +-- n_tup_hot_upd > 0 with n_tup_hot_indexed_upd unchanged. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_brin ( + id int PRIMARY KEY, + bcol int +) WITH (fillfactor = 50); +CREATE INDEX hi_brin_idx ON hi_brin USING brin(bcol); +INSERT INTO hi_brin VALUES (1, 100); +-- Capture the HOT-indexed counter before, drive a BRIN-only update, +-- and assert that classic HOT advanced while HOT-indexed did not. +SELECT pg_stat_force_next_flush(); + pg_stat_force_next_flush +-------------------------- + +(1 row) + +SELECT hot_idx AS hot_idx_before FROM get_hi_count('hi_brin') \gset +UPDATE hi_brin SET bcol = 200 WHERE id = 1; +SELECT pg_stat_force_next_flush(); + pg_stat_force_next_flush +-------------------------- + +(1 row) + +SELECT (hot - 0) > 0 AS classic_hot_fired, + hot_idx = :hot_idx_before AS hot_indexed_did_not_fire + FROM get_hi_count('hi_brin'); + classic_hot_fired | hot_indexed_did_not_fire +-------------------+-------------------------- + t | t +(1 row) + +-- The BRIN index sees the new value via aminsert. +SELECT bcol FROM hi_brin WHERE id = 1; + bcol +------ + 200 +(1 row) + +DROP TABLE hi_brin; +-- --------------------------------------------------------------------------- +-- 16. UNIQUE index on a type where image equality != operator equality +-- +-- numeric 1.0 and 1.00 are equal under the btree opclass but have +-- different on-disk images. A HOT-indexed update 1.0 -> 1.00 inserts a +-- fresh leaf carrying the live image and leaves a stale leaf for 1.0 +-- (the hop's modified-attrs bitmap marks k changed, since modified-column +-- detection is image-based). A later INSERT of a value equal under the +-- opclass must still be detected as a duplicate: the unique check reaches +-- the live tuple through the fresh leaf, which points directly at it (no hop +-- after it, so the overlap is empty and the leaf is a genuine conflict); the +-- stale 1.0 leaf is skipped because the k-changing hop overlaps the unique +-- index's attribute. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_unum (k numeric UNIQUE, j int) WITH (fillfactor = 50); +CREATE INDEX hi_unum_j ON hi_unum(j); -- 2nd indexed attr, kept fixed +INSERT INTO hi_unum VALUES (1.0, 100); +UPDATE hi_unum SET k = 1.00 WHERE j = 100; -- HOT-indexed: 1.0 -> 1.00 +SELECT n_hot_indexed > 0 AS made_hot_indexed + FROM pg_relation_hot_indexed_stats('hi_unum'); + made_hot_indexed +------------------ + t +(1 row) + +-- A numerically-equal insert must conflict (the fresh leaf catches it): +INSERT INTO hi_unum VALUES (1.0, 1); -- expect duplicate key error +ERROR: duplicate key value violates unique constraint "hi_unum_k_key" +DETAIL: Key (k)=(1.0) already exists. +-- A genuinely different value is accepted: +INSERT INTO hi_unum VALUES (2.0, 2); +SELECT k, j FROM hi_unum ORDER BY j; + k | j +------+----- + 2.0 | 2 + 1.00 | 100 +(2 rows) + +DROP TABLE hi_unum; +-- --------------------------------------------------------------------------- +-- 17. CREATE INDEX and REINDEX over live HOT-indexed chains +-- +-- A freshly built or rebuilt index must reflect current values, never a +-- stale chain member: the build scans live tuples only and points each +-- HOT-indexed live tuple's entry at its own TID, so the new entries have no +-- hop after them and the crossed-attribute bitmap keeps them. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_reindex (id int PRIMARY KEY, a int, b int) WITH (fillfactor = 50); +CREATE INDEX hi_reindex_a ON hi_reindex(a); +INSERT INTO hi_reindex SELECT g, g, g FROM generate_series(1, 6) g; +UPDATE hi_reindex SET a = a + 100; -- HOT-indexed on a +UPDATE hi_reindex SET a = a + 100; -- again -> longer chains +SELECT n_hot_indexed > 0 AS made_hot_indexed + FROM pg_relation_hot_indexed_stats('hi_reindex'); + made_hot_indexed +------------------ + t +(1 row) + +-- Build a NEW index and REINDEX the existing one over the live chains. +CREATE INDEX hi_reindex_b ON hi_reindex(b); +REINDEX INDEX hi_reindex_a; +SET enable_seqscan = off; +SELECT id, a FROM hi_reindex WHERE a = 204; -- current value -> id 4 + id | a +----+----- + 4 | 204 +(1 row) + +SELECT count(*) FROM hi_reindex WHERE a = 4; -- obsolete value -> 0 + count +------- + 0 +(1 row) + +SELECT id FROM hi_reindex WHERE b = 2; -- via freshly built index -> 2 + id +---- + 2 +(1 row) + +RESET enable_seqscan; +DROP TABLE hi_reindex; +-- --------------------------------------------------------------------------- +-- 18. DROP every index over live HOT-indexed chains, then VACUUM +-- +-- After all indexes are dropped, heap pages may still carry preserved +-- HOT-indexed members left by earlier updates. VACUUM of such a no-index +-- relation must complete without error, and reads must stay correct via the +-- redirect forwarders. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_dropidx (id int PRIMARY KEY, a int) WITH (fillfactor = 50, autovacuum_enabled = false); +CREATE INDEX hi_dropidx_a ON hi_dropidx(a); +INSERT INTO hi_dropidx SELECT g, g FROM generate_series(1, 6) g; +UPDATE hi_dropidx SET a = a + 100; -- HOT-indexed on a +UPDATE hi_dropidx SET a = a + 100; -- again -> longer chains +SELECT n_hot_indexed > 0 AS made_hot_indexed + FROM pg_relation_hot_indexed_stats('hi_dropidx'); + made_hot_indexed +------------------ + t +(1 row) + +-- Drop every index, leaving preserved HOT-indexed members with no index to sweep. +DROP INDEX hi_dropidx_a; +ALTER TABLE hi_dropidx DROP CONSTRAINT hi_dropidx_pkey; +-- Must not crash on the no-index path; two passes exercise the second-pass +-- reclaim guard as well. +VACUUM hi_dropidx; +VACUUM hi_dropidx; +-- Reads remain correct after the indexes are gone. +SELECT id, a FROM hi_dropidx ORDER BY id; + id | a +----+----- + 1 | 201 + 2 | 202 + 3 | 203 + 4 | 204 + 5 | 205 + 6 | 206 +(6 rows) + +DROP TABLE hi_dropidx; +-- --------------------------------------------------------------------------- +-- 19. Re-collapse of a data-redirect chain across partial VACUUMs +-- +-- A chain that collapses to a HOT-indexed data redirect, is vacuumed with +-- INDEX_CLEANUP off (so the stale leaves and the redirect survive), then +-- receives further HOT-indexed updates that re-collapse the chain and +-- re-point the redirect at a new live tuple, must not leave the redirect +-- dangling. A subsequent full VACUUM must complete without error, leave the +-- heap consistent (verify_heapam reports nothing), and reads must stay +-- correct. (Regression: an earlier revision crashed reclaiming a mid-chain +-- member while a data redirect still pointed past it.) +-- --------------------------------------------------------------------------- +CREATE EXTENSION IF NOT EXISTS amcheck; +CREATE TABLE hi_recollapse (id int PRIMARY KEY, a int) WITH (fillfactor = 50, autovacuum_enabled = false); +CREATE INDEX hi_recollapse_a ON hi_recollapse(a); +INSERT INTO hi_recollapse VALUES (1, 1); +-- First chain: two HOT-indexed updates, then prune to a data redirect while +-- leaving the stale btree leaves in place (INDEX_CLEANUP off). +UPDATE hi_recollapse SET a = 2 WHERE id = 1; +UPDATE hi_recollapse SET a = 3 WHERE id = 1; +VACUUM (INDEX_CLEANUP off) hi_recollapse; +-- Re-collapse: more HOT-indexed updates extend the chain past the redirect +-- target; the next prune re-points the data redirect at the new first live +-- tuple and extends its union. +UPDATE hi_recollapse SET a = 4 WHERE id = 1; +UPDATE hi_recollapse SET a = 5 WHERE id = 1; +VACUUM (INDEX_CLEANUP off) hi_recollapse; +-- Full vacuum now reclaims the dead chain; the re-pointed redirect must not +-- dangle. Two passes also exercise the redirect re-point second pass. +VACUUM hi_recollapse; +VACUUM hi_recollapse; +-- Heap must be structurally consistent (no rows == no corruption). +SELECT * FROM verify_heapam('hi_recollapse'); + blkno | offnum | attnum | msg +-------+--------+--------+----- +(0 rows) + +SET enable_seqscan = off; +SELECT id, a FROM hi_recollapse WHERE a = 5; -- current value -> id 1 + id | a +----+--- + 1 | 5 +(1 row) + +SELECT count(*) FROM hi_recollapse WHERE a = 3; -- obsolete value -> 0 + count +------- + 0 +(1 row) + +RESET enable_seqscan; +SELECT id, a FROM hi_recollapse ORDER BY id; + id | a +----+--- + 1 | 5 +(1 row) + +DROP TABLE hi_recollapse; +-- --------------------------------------------------------------------------- +-- 20. Index deletion over an entry that points at a data-redirect root +-- +-- A data redirect is an LP_REDIRECT that carries a bitmap, so it reports +-- lp_len > 0 (ItemIdHasStorage true) even though it is not a normal tuple. +-- index_delete_check_htid must treat it as a redirect, not read its blob as a +-- HeapTupleHeader. Reproduce: collapse a chain root to a data redirect while +-- keeping the stale leaf that points at it (INDEX_CLEANUP off), then insert +-- many duplicates of the stale key so btree bottom-up deletion runs +-- heap_index_delete_tuples over that stale entry. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_iddel (id int, a int) WITH (fillfactor = 50, autovacuum_enabled = false); +CREATE INDEX hi_iddel_a ON hi_iddel(a); +INSERT INTO hi_iddel VALUES (1, 1); +UPDATE hi_iddel SET a = a + 1 WHERE id = 1; -- HOT-indexed +UPDATE hi_iddel SET a = a + 1 WHERE id = 1; -- multi-hop chain +VACUUM (INDEX_CLEANUP off) hi_iddel; -- root -> data redirect, keep stale a=1 leaf +-- Many duplicates of the stale key fill the leaf and trigger bottom-up +-- deletion, which feeds the stale a=1 entry (htid -> the data-redirect root) +-- to heap_index_delete_tuples. Must not crash or misread the blob. +INSERT INTO hi_iddel SELECT g, 1 FROM generate_series(2, 3000) g; +VACUUM hi_iddel; +SELECT * FROM verify_heapam('hi_iddel'); + blkno | offnum | attnum | msg +-------+--------+--------+----- +(0 rows) + +SET enable_seqscan = off; +SELECT id, a FROM hi_iddel WHERE id = 1; -- current value -> a = 3 + id | a +----+--- + 1 | 3 +(1 row) + +RESET enable_seqscan; +DROP TABLE hi_iddel; +-- --------------------------------------------------------------------------- +-- 21. A change to a column covered by a non-btree index AM is HOT-indexed +-- +-- A HOT-indexed update leaves a stale pre-update leaf that the read side +-- filters via the crossed-attribute bitmap, which is access-method agnostic. +-- A column covered by a non-btree index (here a GiST index on a point column) +-- is therefore HOT-indexed like any other, and the GiST index still returns +-- correct results across the chain. A change to a btree-only column on the +-- same table is likewise HOT-indexed. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_nonbtree (id int PRIMARY KEY, tag int, p point) + WITH (fillfactor = 10); +CREATE INDEX hi_nonbtree_tag ON hi_nonbtree (tag); -- btree index +CREATE INDEX hi_nonbtree_p ON hi_nonbtree USING gist (p); -- GiST, non-btree +INSERT INTO hi_nonbtree SELECT g, g, point(g, g) + FROM generate_series(1, 200) g; +-- Change the GiST-covered column first: HOT-indexed (hot_idx = 200). +UPDATE hi_nonbtree SET p = point(p[0] + 1000, p[1] + 1000); +SELECT hot_idx AS gist_col_hot_indexed FROM get_hi_count('hi_nonbtree'); + gist_col_hot_indexed +---------------------- + 200 +(1 row) + +-- The GiST index must return correct results: the old positions are gone and +-- every row is found at its new position (no stale leaf surfaces an old key). +SET enable_seqscan = off; +SELECT count(*) AS at_old_positions + FROM hi_nonbtree WHERE p <@ box(point(0, 0), point(300, 300)); + at_old_positions +------------------ + 0 +(1 row) + +SELECT count(*) AS at_new_positions + FROM hi_nonbtree WHERE p <@ box(point(1000, 1000), point(1300, 1300)); + at_new_positions +------------------ + 200 +(1 row) + +RESET enable_seqscan; +-- Changing the btree-only column (p unchanged) stays HOT-indexed. +UPDATE hi_nonbtree SET tag = tag + 1000; +SELECT hot_idx > 0 AS btree_col_is_hot_indexed FROM get_hi_count('hi_nonbtree'); + btree_col_is_hot_indexed +-------------------------- + t +(1 row) + +-- A distance-ordered (KNN) GiST scan uses the reorder scan path +-- (IndexNextWithReorder), a different code path from the range scan above. +-- It must also drop stale HOT-indexed leaves: after the p-update the only +-- live positions are the new ones, so the nearest neighbours of the new +-- origin must all be at the new positions and none at the old ones. +SET enable_seqscan = off; +SELECT count(*) AS knn_all_at_new_positions FROM ( + SELECT p FROM hi_nonbtree ORDER BY p <-> point(1000, 1000) LIMIT 200 +) s WHERE p <@ box(point(1000, 1000), point(1300, 1300)); + knn_all_at_new_positions +-------------------------- + 200 +(1 row) + +SELECT count(*) AS knn_none_at_old_positions FROM ( + SELECT p FROM hi_nonbtree ORDER BY p <-> point(0, 0) LIMIT 200 +) s WHERE p <@ box(point(0, 0), point(300, 300)); + knn_none_at_old_positions +--------------------------- + 0 +(1 row) + +RESET enable_seqscan; +DROP TABLE hi_nonbtree; +-- --------------------------------------------------------------------------- +-- 22. ABA on a unique key across two distinct live rows: a key cycled away +-- and back must still collide with another row that holds it. The stale +-- leaves left by the cycle must not let a genuine duplicate slip past the +-- uniqueness check -- the read-side recheck compares the live key, not just +-- a changed-attribute bitmap. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_aba (k int, v int) WITH (fillfactor = 50); +CREATE UNIQUE INDEX hi_aba_k ON hi_aba (k); +CREATE INDEX hi_aba_v ON hi_aba (v); +INSERT INTO hi_aba VALUES (1, 10), (2, 20); +-- Cycle row1's unique key 1 -> 3 -> 1 (v unchanged, so each step is +-- HOT-indexed and leaves stale entries in hi_aba_k). +UPDATE hi_aba SET k = 3 WHERE v = 10; +UPDATE hi_aba SET k = 1 WHERE v = 10; +SELECT hot_idx > 0 AS cycled_hot_indexed FROM get_hi_count('hi_aba'); + cycled_hot_indexed +-------------------- + t +(1 row) + +-- row1 is live at k = 1 again. Moving row2 onto k = 1 must raise a unique +-- violation despite the stale '1' leaves from the cycle. +UPDATE hi_aba SET k = 1 WHERE v = 20; +ERROR: duplicate key value violates unique constraint "hi_aba_k" +DETAIL: Key (k)=(1) already exists. +DROP TABLE hi_aba; +-- --------------------------------------------------------------------------- +-- 23. Partial index whose predicate references a non-key column. Flipping the +-- row out of the predicate while leaving the indexed key unchanged is +-- HOT-indexed: the predicate column is part of the index's attribute set, so +-- the crossed-attribute bitmap drops the now-stale partial-index entry on read +-- (no value recheck is involved). +-- --------------------------------------------------------------------------- +CREATE TABLE hi_partpred (id int PRIMARY KEY, k int, active boolean) + WITH (fillfactor = 50); +CREATE INDEX hi_partpred_k ON hi_partpred (k) WHERE active; +INSERT INTO hi_partpred VALUES (1, 100, true); +-- Flip the predicate column 'active' true -> false; the index key k is +-- unchanged. The row no longer satisfies the predicate, so its partial-index +-- entry must be removed, not left pointing into the chain. +UPDATE hi_partpred SET active = false WHERE id = 1; +SELECT pg_stat_force_next_flush(); + pg_stat_force_next_flush +-------------------------- + +(1 row) + +SELECT hot, hot_idx FROM get_hi_count('hi_partpred'); + hot | hot_idx +-----+--------- + 1 | 1 +(1 row) + +-- The partial index must not surface the row now that active = false. +-- A query whose qual exactly matches the partial predicate uses the index +-- without re-filtering 'active' on the heap, so a stale entry would surface. +SET enable_seqscan = off; +SET enable_bitmapscan = off; +EXPLAIN (COSTS OFF) SELECT id FROM hi_partpred WHERE active; + QUERY PLAN +----------------------------------------------- + Index Scan using hi_partpred_k on hi_partpred +(1 row) + +SELECT id FROM hi_partpred WHERE k = 100 AND active; + id +---- +(0 rows) + +SELECT id FROM hi_partpred WHERE active; + id +---- +(0 rows) + +RESET enable_bitmapscan; +RESET enable_seqscan; +DROP TABLE hi_partpred; +-- --------------------------------------------------------------------------- +-- 24. Reclaim + stub mix. Repeated updates of column a followed by an update +-- of column b build a chain whose prune reclaims the members whose change was +-- superseded (a changed again) and keeps stubs for those that were not, so a +-- root redirect ends up pointing at a stub and a later walk crosses mid-chain +-- stubs. Reads through each index and amcheck must stay correct across the +-- collapse, and a second round must walk the existing stubs without severing +-- the chain. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_stubmix (id int PRIMARY KEY, a int, b int) WITH (fillfactor = 50, autovacuum_enabled = false); +CREATE INDEX hi_stubmix_a ON hi_stubmix (a); +CREATE INDEX hi_stubmix_b ON hi_stubmix (b); +INSERT INTO hi_stubmix VALUES (1, 10, 100); +UPDATE hi_stubmix SET a = 11 WHERE id = 1; -- changes a +UPDATE hi_stubmix SET a = 12 WHERE id = 1; -- changes a again (supersedes) +UPDATE hi_stubmix SET b = 101 WHERE id = 1; -- changes b +VACUUM hi_stubmix; +SET enable_seqscan = off; +SET enable_bitmapscan = off; +SELECT id, a, b FROM hi_stubmix WHERE a = 12; -- current a + id | a | b +----+----+----- + 1 | 12 | 101 +(1 row) + +SELECT id, a, b FROM hi_stubmix WHERE b = 101; -- current b + id | a | b +----+----+----- + 1 | 12 | 101 +(1 row) + +SELECT id FROM hi_stubmix WHERE a = 10; -- stale a: 0 rows + id +---- +(0 rows) + +RESET enable_bitmapscan; +RESET enable_seqscan; +SELECT * FROM verify_heapam('hi_stubmix'); -- no corruption across stubs + blkno | offnum | attnum | msg +-------+--------+--------+----- +(0 rows) + +-- A second round must walk the existing stubs (no priorXmax sever). +UPDATE hi_stubmix SET a = 13 WHERE id = 1; +VACUUM hi_stubmix; +SELECT id, a, b FROM hi_stubmix WHERE a = 13; + id | a | b +----+----+----- + 1 | 13 | 101 +(1 row) + +SELECT * FROM verify_heapam('hi_stubmix'); + blkno | offnum | attnum | msg +-------+--------+--------+----- +(0 rows) + +DROP TABLE hi_stubmix; +-- --------------------------------------------------------------------------- +-- 25. Exclusion-constraint tables are HOT-indexed-eligible. +-- +-- An exclusion constraint is enforced by check_exclusion_or_unique_constraint, +-- which rechecks each candidate against the live tuple's current index-form +-- with the constraint's own operators, so a stale entry left by a HOT-indexed +-- update is skipped while the live key always has its own entry. Updating a +-- non-constrained indexed column is HOT-indexed (the GiST exclusion index is +-- skipped), and the constraint stays correct. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_excl ( + id int PRIMARY KEY, + tag int, + during int4range, + EXCLUDE USING gist (during WITH &&) +) WITH (fillfactor = 10); +CREATE INDEX hi_excl_tag ON hi_excl(tag); +INSERT INTO hi_excl VALUES (1, 100, int4range(1, 10)), (2, 200, int4range(20, 30)); +-- Update a non-constrained indexed column: HOT-indexed (GiST exclusion index +-- and PK skipped), and the exclusion constraint is still enforced. +UPDATE hi_excl SET tag = tag + 1 WHERE id = 1; +SELECT hot_idx > 0 AS tag_update_hot_indexed FROM get_hi_count('hi_excl'); + tag_update_hot_indexed +------------------------ + t +(1 row) + +INSERT INTO hi_excl VALUES (3, 300, int4range(5, 15)); -- overlaps id=1's (1,10) +ERROR: conflicting key value violates exclusion constraint "hi_excl_during_excl" +DETAIL: Key (during)=([5,15)) conflicts with existing key (during)=([1,10)). +-- Move id=1's range away (this updates the GiST index, leaving a stale entry +-- for the old (1,10) range). A range overlapping only the OLD range now +-- inserts cleanly (the stale entry is skipped); one overlapping the NEW range +-- still conflicts. +UPDATE hi_excl SET during = int4range(100, 110) WHERE id = 1; +INSERT INTO hi_excl VALUES (4, 400, int4range(5, 15)); -- only overlapped old range: OK +INSERT INTO hi_excl VALUES (5, 500, int4range(105, 115));-- overlaps new (100,110): conflict +ERROR: conflicting key value violates exclusion constraint "hi_excl_during_excl" +DETAIL: Key (during)=([105,115)) conflicts with existing key (during)=([100,110)). +DROP TABLE hi_excl; +-- --------------------------------------------------------------------------- +-- 26. TOAST interaction. An indexed column stored out-of-line must behave +-- correctly across HOT-indexed updates: an entry kept across an update of a +-- different column still resolves to the (unchanged) toasted value, and after +-- the toasted column itself is changed the stale entry is dropped by the +-- crossed-attribute bitmap (no value comparison or detoasting is needed). +-- --------------------------------------------------------------------------- +CREATE TABLE hi_toast (id int PRIMARY KEY, big text, tag int) WITH (fillfactor = 50); +ALTER TABLE hi_toast ALTER COLUMN big SET STORAGE EXTERNAL; -- no compression +CREATE INDEX hi_toast_big ON hi_toast (big); +CREATE INDEX hi_toast_tag ON hi_toast (tag); +INSERT INTO hi_toast VALUES (1, repeat('A', 2000), 10); +-- The big value is stored out-of-line. +SELECT pg_column_size(big) > 1500 AS big_is_external FROM hi_toast WHERE id = 1; + big_is_external +----------------- + t +(1 row) + +-- HOT-indexed update of tag leaves big (and its index entry) unchanged. +UPDATE hi_toast SET tag = 11 WHERE id = 1; +SELECT hot_idx > 0 AS tag_update_hot_indexed FROM get_hi_count('hi_toast'); + tag_update_hot_indexed +------------------------ + t +(1 row) + +SET enable_seqscan = off; +SET enable_bitmapscan = off; +SELECT id, tag, length(big) FROM hi_toast WHERE big = repeat('A', 2000); + id | tag | length +----+-----+-------- + 1 | 11 | 2000 +(1 row) + +-- HOT-indexed update of the toasted indexed column itself: the old entry is +-- now stale because the crossed-attribute bitmap shows big changed. +UPDATE hi_toast SET big = repeat('B', 2000) WHERE id = 1; +SELECT id FROM hi_toast WHERE big = repeat('A', 2000); -- stale: 0 rows + id +---- +(0 rows) + +SELECT id, length(big) FROM hi_toast WHERE big = repeat('B', 2000); -- current + id | length +----+-------- + 1 | 2000 +(1 row) + +RESET enable_bitmapscan; +RESET enable_seqscan; +SELECT * FROM verify_heapam('hi_toast'); + blkno | offnum | attnum | msg +-------+--------+--------+----- +(0 rows) + +DROP TABLE hi_toast; +-- --------------------------------------------------------------------------- +-- 27. ABA on an indexed column. A HOT-indexed update that sets an indexed +-- column to a value an earlier chain member already held leaves two leaves +-- with that same key, both chain-resolving to the live tuple. A value-based +-- recheck cannot tell them apart and would return the row twice; the +-- crossed-attribute bitmap drops the stale ancestor leaf (its walk crosses the +-- key-changing hops) and keeps only the fresh entry, so a forced index scan +-- returns the row exactly once. REINDEX must not change that. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_aba (id int PRIMARY KEY, k int, v int) WITH (fillfactor = 50); +CREATE INDEX hi_aba_k ON hi_aba (k); +CREATE INDEX hi_aba_v ON hi_aba (v); +INSERT INTO hi_aba VALUES (1, 1, 100); +UPDATE hi_aba SET k = 3 WHERE id = 1; -- HOT-indexed: k changed, v kept +UPDATE hi_aba SET k = 1 WHERE id = 1; -- HOT-indexed: k cycled back (ABA) +SET enable_seqscan = off; +SET enable_bitmapscan = off; +SELECT count(*) AS k1_once FROM hi_aba WHERE k = 1; -- exactly 1 + k1_once +--------- + 1 +(1 row) + +SELECT count(*) AS k3_gone FROM hi_aba WHERE k = 3; -- 0 (stale dropped) + k3_gone +--------- + 0 +(1 row) + +REINDEX INDEX hi_aba_k; +SELECT count(*) AS k1_after_reindex FROM hi_aba WHERE k = 1; -- still 1 + k1_after_reindex +------------------ + 1 +(1 row) + +RESET enable_bitmapscan; +RESET enable_seqscan; +SELECT * FROM verify_heapam('hi_aba'); + blkno | offnum | attnum | msg +-------+--------+--------+----- +(0 rows) + +DROP TABLE hi_aba; +-- --------------------------------------------------------------------------- +-- 28. Partial index, predicate column changed but the row STAYS in the index +-- (predicate still true, key unchanged). The update is HOT-indexed; selective +-- maintenance re-inserts a fresh entry (the predicate column changed and still +-- holds), so the row is still returned -- the bitmap drops the older entry and +-- the fresh one re-supplies it. Guards against a "lost row" from over-eager +-- dropping. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_partstay (id int PRIMARY KEY, k int, n int) WITH (fillfactor = 50); +CREATE INDEX hi_partstay_k ON hi_partstay (k) WHERE n > 0; +CREATE INDEX hi_partstay_id2 ON hi_partstay (id); +INSERT INTO hi_partstay VALUES (1, 5, 3); +UPDATE hi_partstay SET n = 7 WHERE id = 1; -- n 3->7, still > 0, k unchanged +SELECT pg_stat_force_next_flush(); + pg_stat_force_next_flush +-------------------------- + +(1 row) + +SELECT hot_idx > 0 AS stay_is_hot_indexed FROM get_hi_count('hi_partstay'); + stay_is_hot_indexed +--------------------- + t +(1 row) + +SET enable_seqscan = off; +SET enable_bitmapscan = off; +SELECT count(*) AS stay_rows FROM hi_partstay WHERE k = 5 AND n > 0; -- want 1 + stay_rows +----------- + 1 +(1 row) + +RESET enable_bitmapscan; +RESET enable_seqscan; +DROP TABLE hi_partstay; +-- --------------------------------------------------------------------------- +-- 29. Partitioned table. A within-partition UPDATE of one indexed column is +-- HOT-indexed on the leaf partition's heap exactly as for a non-partitioned +-- table; a cross-partition update is a delete+insert and never HOT. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_part (id int, a int, b int) PARTITION BY RANGE (id); +CREATE TABLE hi_part1 PARTITION OF hi_part FOR VALUES FROM (0) TO (100) + WITH (fillfactor = 50); +CREATE INDEX hi_part_a ON hi_part (a); +CREATE INDEX hi_part_b ON hi_part (b); +INSERT INTO hi_part VALUES (1, 10, 20); +UPDATE hi_part SET a = 11 WHERE id = 1; -- one indexed col, within partition +SELECT pg_stat_force_next_flush(); + pg_stat_force_next_flush +-------------------------- + +(1 row) + +SELECT hot_idx > 0 AS part_is_hot_indexed FROM get_hi_count('hi_part1'); + part_is_hot_indexed +--------------------- + t +(1 row) + +SET enable_seqscan = off; +SET enable_bitmapscan = off; +SELECT count(*) AS a11 FROM hi_part WHERE a = 11; -- want 1 + a11 +----- + 1 +(1 row) + +SELECT count(*) AS a10 FROM hi_part WHERE a = 10; -- want 0 (stale dropped) + a10 +----- + 0 +(1 row) + +RESET enable_bitmapscan; +RESET enable_seqscan; +SELECT * FROM verify_heapam('hi_part1'); + blkno | offnum | attnum | msg +-------+--------+--------+----- +(0 rows) + +DROP TABLE hi_part; +-- --------------------------------------------------------------------------- +-- 30. Non-btree access method (hash). Read-side staleness is access-method +-- agnostic (the crossed-attribute bitmap), so any index AM's column is +-- HOT-indexed. Hash is the sharpest case: its scans recheck the heap value, +-- which alone cannot disambiguate a value cycled away and back (ABA) -- the +-- bitmap drops the stale ancestor so the row is returned exactly once. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_hash (id int PRIMARY KEY, v int, w int) WITH (fillfactor = 50); +CREATE INDEX hi_hash_v ON hi_hash USING hash (v); +CREATE INDEX hi_hash_w ON hi_hash (w); +INSERT INTO hi_hash VALUES (1, 10, 100); +UPDATE hi_hash SET v = 99 WHERE id = 1; +UPDATE hi_hash SET v = 10 WHERE id = 1; -- ABA: 10 -> 99 -> 10, w unchanged +SELECT pg_stat_force_next_flush(); + pg_stat_force_next_flush +-------------------------- + +(1 row) + +SELECT hot_idx > 0 AS hash_is_hot_indexed FROM get_hi_count('hi_hash'); + hash_is_hot_indexed +--------------------- + t +(1 row) + +SET enable_seqscan = off; +SET enable_bitmapscan = off; +SELECT count(*) AS hash_v10 FROM hi_hash WHERE v = 10; -- want 1 (no duplicate) + hash_v10 +---------- + 1 +(1 row) + +SELECT count(*) AS hash_v99 FROM hi_hash WHERE v = 99; -- want 0 (stale dropped) + hash_v99 +---------- + 0 +(1 row) + +RESET enable_bitmapscan; +RESET enable_seqscan; +DROP TABLE hi_hash; +-- --------------------------------------------------------------------------- +-- 31. DDL after a HOT-indexed chain exists. The per-hop modified-attrs +-- bitmap on the page is keyed by physical attribute number and sized by the +-- relation's natts AT WRITE TIME. Indexes added/dropped after the chain +-- forms, and ADD/DROP COLUMN, must not corrupt the read-side staleness test. +-- The sharp case is ADD COLUMN crossing an 8-attribute boundary, which grows +-- ceil(natts/8): readers must locate each hop's bitmap from that hop's own +-- write-time natts (HeapTupleHeaderGetNatts / the stub's stashed natts), not +-- the relation's current natts. +-- --------------------------------------------------------------------------- +-- Exactly 8 attributes (c1..c7 + payload) so adding the 9th flips the bitmap +-- from 1 byte to 2. c7 is the column we churn; c2 is an unchanged indexed +-- column whose leaf must stay current. +CREATE TABLE hi_ddl ( + c1 int PRIMARY KEY, c2 int, c3 int, c4 int, + c5 int, c6 int, c7 int, payload text +) WITH (fillfactor = 50, autovacuum_enabled = false); +CREATE INDEX hi_ddl_c2 ON hi_ddl(c2); +CREATE INDEX hi_ddl_c7 ON hi_ddl(c7); +INSERT INTO hi_ddl VALUES (1, 10, 20, 30, 40, 50, 70, 'p'); +-- Form a HOT-indexed chain on c7 BEFORE any further DDL. +UPDATE hi_ddl SET c7 = 71 WHERE c1 = 1; +UPDATE hi_ddl SET c7 = 72 WHERE c1 = 1; +-- (a) CREATE INDEX after the chain exists: the new index is built against the +-- live tuple under its own TID, so its entry is never stale. +CREATE INDEX hi_ddl_c3 ON hi_ddl(c3); +-- (b) ADD COLUMN crossing the 8-attribute boundary (natts 8 -> 9). Existing +-- hops keep their 1-byte bitmaps; the relation now wants 2. Reads through the +-- old chain must still be correct. +ALTER TABLE hi_ddl ADD COLUMN c9 int; +CREATE INDEX hi_ddl_c9 ON hi_ddl(c9); +SET enable_seqscan = off; +SET enable_bitmapscan = off; +SET enable_indexonlyscan = off; +-- Live c7 is 72. The c7 index must return the live row for 72 and drop the +-- stale leaves for 70 and 71 (offsets misread would corrupt this). +SELECT count(*) AS c7_eq_72 FROM hi_ddl WHERE c7 = 72 AND payload IS NOT NULL; + c7_eq_72 +---------- + 1 +(1 row) + +SELECT count(*) AS c7_eq_70_stale FROM hi_ddl WHERE c7 = 70 AND payload IS NOT NULL; + c7_eq_70_stale +---------------- + 0 +(1 row) + +SELECT count(*) AS c7_eq_71_stale FROM hi_ddl WHERE c7 = 71 AND payload IS NOT NULL; + c7_eq_71_stale +---------------- + 0 +(1 row) + +-- c2 never changed across the chain: its leaf must NOT be judged stale even +-- though a crossed hop changed c7. A misread bitmap could spuriously flag it. +SELECT count(*) AS c2_eq_10_current FROM hi_ddl WHERE c2 = 10 AND payload IS NOT NULL; + c2_eq_10_current +------------------ + 1 +(1 row) + +-- (c) Continue churning c7 AFTER the ADD COLUMN: the new hop's bitmap is sized +-- for natts 9 (2 bytes); the old hops are 1 byte. A chain with mixed-size +-- bitmaps must still resolve correctly. +UPDATE hi_ddl SET c7 = 73 WHERE c1 = 1; +SELECT count(*) AS c7_eq_73 FROM hi_ddl WHERE c7 = 73 AND payload IS NOT NULL; + c7_eq_73 +---------- + 1 +(1 row) + +SELECT count(*) AS c7_eq_72_now_stale FROM hi_ddl WHERE c7 = 72 AND payload IS NOT NULL; + c7_eq_72_now_stale +-------------------- + 0 +(1 row) + +-- (d) Collapse the chain to stubs via VACUUM, then read again: the stub must +-- preserve its write-time natts so its bitmap stays locatable post-ADD COLUMN. +UPDATE hi_ddl SET c7 = 74 WHERE c1 = 1; +VACUUM (INDEX_CLEANUP off) hi_ddl; +SELECT count(*) AS c7_eq_74_after_vacuum FROM hi_ddl WHERE c7 = 74 AND payload IS NOT NULL; + c7_eq_74_after_vacuum +----------------------- + 1 +(1 row) + +SELECT count(*) AS c2_eq_10_after_vacuum FROM hi_ddl WHERE c2 = 10 AND payload IS NOT NULL; + c2_eq_10_after_vacuum +----------------------- + 1 +(1 row) + +-- (e) DROP COLUMN keeps the attnum slot (no renumber), so bitmaps stay aligned. +ALTER TABLE hi_ddl DROP COLUMN c4; +SELECT count(*) AS c7_after_drop FROM hi_ddl WHERE c7 = 74 AND payload IS NOT NULL; + c7_after_drop +--------------- + 1 +(1 row) + +SELECT count(*) AS c2_after_drop FROM hi_ddl WHERE c2 = 10 AND payload IS NOT NULL; + c2_after_drop +--------------- + 1 +(1 row) + +-- (f) DROP INDEX on the churned column: remaining indexes still resolve. +DROP INDEX hi_ddl_c7; +SELECT count(*) AS c2_after_dropidx FROM hi_ddl WHERE c2 = 10 AND payload IS NOT NULL; + c2_after_dropidx +------------------ + 1 +(1 row) + +RESET enable_seqscan; +RESET enable_bitmapscan; +RESET enable_indexonlyscan; +-- The seqscan truth confirms the live row; the count assertions above (read +-- through the post-DDL indexes) match it, which is what would break if a +-- mis-sized bitmap corrupted the staleness verdict. +SELECT c1, c2, c7 FROM hi_ddl WHERE c1 = 1; + c1 | c2 | c7 +----+----+---- + 1 | 10 | 74 +(1 row) + +DROP TABLE hi_ddl; +-- --------------------------------------------------------------------------- +-- 32. BitmapAnd/BitmapOr across a changed and an unchanged index +-- +-- Reported by Alexander Korotkov, 2026-07-09. A HOT-indexed update points +-- the CHANGED index's fresh entry at the new heap-only tuple, while the +-- UNCHANGED index's entry still points at the chain root. BitmapAnd/BitmapOr +-- intersect/union two indexes' raw TID sets in the TID-bitmap layer BEFORE +-- either side touches the heap: an exact-mode intersection sees {root} on one +-- side and {new-tuple} on the other and drops the row, even though both +-- resolve, through the chain, to the one live tuple. Bitmap scans tolerate +-- false positives but not false negatives, so this was a correctness bug. +-- +-- Fixed by a one-bit marker in the stored TID's otherwise-unused offset bit +-- 14 (ItemPointerSIUMaybeStaleFlag, storage/itemptr.h), set only on a +-- HOT-indexed fresh entry's own TID (never on tts_tid itself, never on a +-- classic-HOT or plain-insert entry). tbm_add_tuples() -- the single choke +-- point every amgetbitmap funnels exact heap TIDs through -- checks the flag +-- and, when set, adds the whole page as lossy (tbm_add_page) instead of the +-- single exact offset. A lossy page survives any AND/OR against an exact-mode +-- page (per tbm_intersect_page's own case analysis) and forces the existing +-- heap-side crossed-attribute recheck to make the final call. Handling it +-- there means NO index access method needs to know about HOT-indexed chains: +-- btree, hash, GIN, GiST, SP-GiST, contrib/bloom, and any out-of-tree AM are +-- all correct with no AM-specific code. One table per non-btree access method +-- below, each paired with a btree index on the changed column, exercises the +-- changed+unchanged BitmapAnd/BitmapOr shape for every AM SIU uses elsewhere +-- in this file; bloom's equivalent case lives in contrib/bloom's own test +-- (where the extension is guaranteed present). +-- --------------------------------------------------------------------------- +SET enable_seqscan = off; +SET enable_indexscan = off; +SET enable_bitmapscan = on; +-- (a) btree + btree +CREATE TABLE hi_bmand_bt ( + id int PRIMARY KEY, + c1 int, + c2 int +) WITH (fillfactor = 50); +CREATE INDEX hi_bmand_bt_c1 ON hi_bmand_bt(c1); -- unchanged +CREATE INDEX hi_bmand_bt_c2 ON hi_bmand_bt(c2); -- changed +INSERT INTO hi_bmand_bt VALUES (1, 11, 21); +UPDATE hi_bmand_bt SET c2 = 22 WHERE id = 1; +SELECT count(*) AS bt_bt_bitmapand FROM hi_bmand_bt WHERE c1 = 11 AND c2 = 22; + bt_bt_bitmapand +----------------- + 1 +(1 row) + +-- The fresh entry in the changed index (hi_bmand_bt_c2) is flagged: pageinspect +-- reports its real heap offset (the marker is stripped from ctid/htid) and +-- surfaces the marker in the hot_indexed column. The unchanged index's entry +-- is not flagged. (bt_page_items block 1 is the sole leaf for a single row.) +SELECT hot_indexed, count(*) + FROM bt_page_items('hi_bmand_bt_c2', 1) GROUP BY hot_indexed ORDER BY hot_indexed; + hot_indexed | count +-------------+------- + f | 1 + t | 1 +(2 rows) + +SELECT bool_and(NOT hot_indexed) AS unchanged_index_never_flagged + FROM bt_page_items('hi_bmand_bt_c1', 1); + unchanged_index_never_flagged +------------------------------- + t +(1 row) + +-- amcheck's heapallindexed verification re-derives each live heap tuple's +-- expected index entry and checks the index contains it. For the CHANGED +-- index, a HOT-indexed fresh entry stores that TID with the SIU may-be-stale +-- marker set in the offset; amcheck must strip it when fingerprinting or it +-- would spuriously report "lacks matching index tuple". No VACUUM has run, so +-- the fresh entry points directly at the live heap-only tuple (no stub +-- forwarding), isolating the marker-strip path this commit adds. +-- +-- (Only the changed index is checked here. heapallindexed on an index NOT +-- maintained by a HOT-indexed update -- which by design has no entry for the +-- new heap-only tuple -- is a separate, pre-existing SIU/amcheck-integration +-- question, unrelated to the bit-14 marker, and out of scope for this commit.) +SELECT bt_index_check('hi_bmand_bt_c2', heapallindexed => true); + bt_index_check +---------------- + +(1 row) + +DROP TABLE hi_bmand_bt; +-- (b) hash (unchanged) + btree (changed) +CREATE TABLE hi_bmand_hash ( + id int PRIMARY KEY, + c1 int, + c2 int +) WITH (fillfactor = 50); +CREATE INDEX hi_bmand_hash_c1 ON hi_bmand_hash USING hash (c1); +CREATE INDEX hi_bmand_hash_c2 ON hi_bmand_hash(c2); +INSERT INTO hi_bmand_hash VALUES (1, 11, 21); +UPDATE hi_bmand_hash SET c2 = 22 WHERE id = 1; +SELECT count(*) AS hash_bt_bitmapand FROM hi_bmand_hash WHERE c1 = 11 AND c2 = 22; + hash_bt_bitmapand +------------------- + 1 +(1 row) + +DROP TABLE hi_bmand_hash; +-- (c) GIN (unchanged) + btree (changed) +CREATE TABLE hi_bmand_gin ( + id int PRIMARY KEY, + tags int[], + c2 int +) WITH (fillfactor = 50); +CREATE INDEX hi_bmand_gin_tags ON hi_bmand_gin USING gin (tags); +CREATE INDEX hi_bmand_gin_c2 ON hi_bmand_gin(c2); +INSERT INTO hi_bmand_gin VALUES (1, ARRAY[1,2,3], 21); +UPDATE hi_bmand_gin SET c2 = 22 WHERE id = 1; +SELECT count(*) AS gin_bt_bitmapand + FROM hi_bmand_gin WHERE tags @> ARRAY[2] AND c2 = 22; + gin_bt_bitmapand +------------------ + 1 +(1 row) + +DROP TABLE hi_bmand_gin; +-- (d) GiST (unchanged) + btree (changed) +CREATE TABLE hi_bmand_gist ( + id int PRIMARY KEY, + p point, + c2 int +) WITH (fillfactor = 50); +CREATE INDEX hi_bmand_gist_p ON hi_bmand_gist USING gist (p); +CREATE INDEX hi_bmand_gist_c2 ON hi_bmand_gist(c2); +INSERT INTO hi_bmand_gist VALUES (1, point(5, 5), 21); +UPDATE hi_bmand_gist SET c2 = 22 WHERE id = 1; +SELECT count(*) AS gist_bt_bitmapand + FROM hi_bmand_gist WHERE p <@ box(point(0, 0), point(10, 10)) AND c2 = 22; + gist_bt_bitmapand +------------------- + 1 +(1 row) + +DROP TABLE hi_bmand_gist; +-- (e) SP-GiST (unchanged) + btree (changed) +CREATE TABLE hi_bmand_spgist ( + id int PRIMARY KEY, + t text, + c2 int +) WITH (fillfactor = 50); +CREATE INDEX hi_bmand_spgist_t ON hi_bmand_spgist USING spgist (t); +CREATE INDEX hi_bmand_spgist_c2 ON hi_bmand_spgist(c2); +INSERT INTO hi_bmand_spgist VALUES (1, 'hello', 21); +UPDATE hi_bmand_spgist SET c2 = 22 WHERE id = 1; +SELECT count(*) AS spgist_bt_bitmapand + FROM hi_bmand_spgist WHERE t = 'hello' AND c2 = 22; + spgist_bt_bitmapand +--------------------- + 1 +(1 row) + +DROP TABLE hi_bmand_spgist; +-- (f) BitmapOr: a HOT-indexed fresh entry (btree, changed) unioned with an +-- unrelated unchanged index's entry for a DIFFERENT row must not affect that +-- other row's count, and the SIU row itself must still be found through +-- either arm of the OR. +CREATE TABLE hi_bmor_bt ( + id int PRIMARY KEY, + c1 int, + c2 int +) WITH (fillfactor = 50); +CREATE INDEX hi_bmor_bt_c1 ON hi_bmor_bt(c1); +CREATE INDEX hi_bmor_bt_c2 ON hi_bmor_bt(c2); +INSERT INTO hi_bmor_bt VALUES (1, 11, 21), (2, 12, 32); +UPDATE hi_bmor_bt SET c2 = 22 WHERE id = 1; -- HOT-indexed on row 1 only +SELECT count(*) AS bt_bt_bitmapor + FROM hi_bmor_bt WHERE c1 = 11 OR c2 = 32; -- row 1 via c1, row 2 via c2 + bt_bt_bitmapor +---------------- + 2 +(1 row) + +DROP TABLE hi_bmor_bt; +RESET enable_seqscan; +RESET enable_indexscan; +RESET enable_bitmapscan; +-- --------------------------------------------------------------------------- +-- 33. Heap rewrite (CLUSTER / VACUUM FULL) over live HOT-indexed chains +-- +-- CLUSTER and VACUUM FULL rewrite the heap and rebuild every index, flattening +-- each HOT chain: each live tuple becomes a standalone tuple in the new heap +-- with a fresh, direct index entry, so no rewritten tuple is a HOT-indexed +-- (SIU) chain member. The rewrite (reform_tuple) must therefore clear +-- HEAP_INDEXED_UPDATED on the copied tuple; leaving it set would leave a +-- standalone tuple carrying the marker (and a now-meaningless inline +-- modified-attrs bitmap), so a reader reaching it through the rebuilt index +-- would run the read-side staleness test against garbage and wrongly drop the +-- row. Regression guard: without the clear, a=203 (and, for CLUSTER over the +-- SIU secondary index, live rows) went missing via index after the rewrite +-- while the heap still held them. +-- --------------------------------------------------------------------------- +-- (a) VACUUM FULL, including the single-secondary-index case that made any +-- SIU-updated table return wrong index results after a routine VACUUM FULL. +CREATE TABLE hi_rewrite (id int PRIMARY KEY, a int, payload text) + WITH (fillfactor = 50); +CREATE INDEX hi_rewrite_a ON hi_rewrite(a); +INSERT INTO hi_rewrite SELECT g, g, repeat('x', 20) FROM generate_series(1, 20) g; +UPDATE hi_rewrite SET a = a + 100 WHERE id <= 10; -- HOT-indexed on a +UPDATE hi_rewrite SET a = a + 100 WHERE id <= 5; -- again -> longer chains +-- Row id=3 has a = 203 after two HOT-indexed updates. +SELECT count(*) AS seqcount_before FROM hi_rewrite; + seqcount_before +----------------- + 20 +(1 row) + +VACUUM FULL hi_rewrite; +SELECT count(*) AS seqcount_after_vf FROM hi_rewrite; -- no rows lost + seqcount_after_vf +------------------- + 20 +(1 row) + +SET enable_seqscan = off; +SET enable_indexscan = on; +SET enable_bitmapscan = off; +-- Every current value is findable through the rebuilt index... +SELECT a AS a203_via_index_after_vf FROM hi_rewrite WHERE a = 203; + a203_via_index_after_vf +------------------------- + 203 +(1 row) + +SELECT count(*) AS all_findable_via_index_after_vf + FROM hi_rewrite WHERE a BETWEEN 1 AND 100000; + all_findable_via_index_after_vf +--------------------------------- + 20 +(1 row) + +-- ...and no superseded (stale) value surfaces. +SELECT count(*) AS stale_a_after_vf FROM hi_rewrite WHERE a IN (3, 103); + stale_a_after_vf +------------------ + 0 +(1 row) + +RESET enable_seqscan; +RESET enable_indexscan; +RESET enable_bitmapscan; +SELECT * FROM verify_heapam('hi_rewrite'); + blkno | offnum | attnum | msg +-------+--------+--------+----- +(0 rows) + +SELECT bt_index_check('hi_rewrite_a', heapallindexed => true); + bt_index_check +---------------- + +(1 row) + +DROP TABLE hi_rewrite; +-- (b) CLUSTER using the SIU-updated secondary index (the ordering scan walks +-- the chains) and a second CLUSTER by heap order; row and value counts must be +-- preserved and every value findable through the index afterward. +CREATE TABLE hi_cluster (id int PRIMARY KEY, a int, b int, payload text) + WITH (fillfactor = 40); +CREATE INDEX hi_cluster_a ON hi_cluster(a); +CREATE INDEX hi_cluster_b ON hi_cluster(b); +INSERT INTO hi_cluster + SELECT g, g, g * 10, repeat('x', 20) FROM generate_series(1, 30) g; +UPDATE hi_cluster SET a = a + 100 WHERE id % 2 = 0; -- HOT-indexed on a +UPDATE hi_cluster SET b = b + 500 WHERE id % 3 = 0; -- HOT-indexed on b +UPDATE hi_cluster SET a = a - 100 WHERE id = 6; -- cycle a back (ABA) +-- seqscan truth (sums are invariant across any lossless rewrite). +SELECT count(*) AS n_before, sum(a) AS suma_before, sum(b) AS sumb_before + FROM hi_cluster; + n_before | suma_before | sumb_before +----------+-------------+------------- + 30 | 1865 | 9650 +(1 row) + +CLUSTER hi_cluster USING hi_cluster_a; +SELECT count(*) AS n_after_cluster, sum(a) AS suma_after, sum(b) AS sumb_after + FROM hi_cluster; + n_after_cluster | suma_after | sumb_after +-----------------+------------+------------ + 30 | 1865 | 9650 +(1 row) + +SET enable_seqscan = off; +SET enable_indexscan = on; +SET enable_bitmapscan = off; +SELECT count(*) AS a_all_findable + FROM hi_cluster WHERE a BETWEEN 1 AND 100000; + a_all_findable +---------------- + 30 +(1 row) + +SELECT count(*) AS b_all_findable + FROM hi_cluster WHERE b BETWEEN 1 AND 100000; + b_all_findable +---------------- + 30 +(1 row) + +RESET enable_seqscan; +RESET enable_indexscan; +RESET enable_bitmapscan; +SELECT bt_index_check('hi_cluster_a', heapallindexed => true); + bt_index_check +---------------- + +(1 row) + +SELECT bt_index_check('hi_cluster_b', heapallindexed => true); + bt_index_check +---------------- + +(1 row) + +SELECT * FROM verify_heapam('hi_cluster'); + blkno | offnum | attnum | msg +-------+--------+--------+----- +(0 rows) + +DROP TABLE hi_cluster; +-- (c) CLUSTER USING a secondary index when a row's clustering-column value was +-- SIU-changed (or ABA-cycled) in an EARLIER hop and a DIFFERENT indexed column +-- changed in the LAST hop, with the chain left UNCOLLAPSED (no VACUUM before +-- the CLUSTER). This is the shape that silently lost rows: the clustering +-- index has no entry pointing directly at the live tuple (its entries address +-- earlier chain members, and no fresh clustering-index entry was planted +-- because the last hop changed a different column), so CLUSTER's index-scan +-- copy path -- running under SnapshotAny, before any prune/collapse inserts a +-- redirect/stub that would let it chain-walk to the live tuple -- never +-- reached the row and dropped it from the rewritten heap. A lossless rewrite +-- must preserve every live row regardless of which index it clusters by, so +-- the fix (repack.c) forces the seqscan+sort copy path for any heap with more +-- than one index (SIU-capable), the same path VACUUM FULL already uses. +-- +-- NB: do NOT VACUUM before the CLUSTER here, and keep a non-roomy fillfactor: +-- a chain collapse or a roomier page both mask the bug (they change what the +-- index scan can reach), so this test deliberately reproduces the losing +-- conditions. +CREATE TABLE hi_cluster_lasthop (id int PRIMARY KEY, a int, b int) + WITH (fillfactor = 40, autovacuum_enabled = false); +CREATE INDEX hi_cl_lh_a ON hi_cluster_lasthop(a); +CREATE INDEX hi_cl_lh_b ON hi_cluster_lasthop(b); +INSERT INTO hi_cluster_lasthop + SELECT g, g, g FROM generate_series(1, 20) g; +-- ids 1..10: change the clustering column a and change it back (ABA on a) +UPDATE hi_cluster_lasthop SET a = a + 1000 WHERE id <= 10; +UPDATE hi_cluster_lasthop SET a = a - 1000 WHERE id <= 10; +-- ids 5..15: LAST hop changes b (a different indexed column), a unchanged here +UPDATE hi_cluster_lasthop SET b = b + 100 WHERE id BETWEEN 5 AND 15; +SELECT n_hot_indexed >= 1 AS siu_chains_formed + FROM pg_relation_hot_indexed_stats('hi_cluster_lasthop'); + siu_chains_formed +------------------- + t +(1 row) + +-- ground truth via seqscan: 20 live rows (chains NOT collapsed). +SET enable_indexscan = off; +SET enable_bitmapscan = off; +SELECT count(*) AS n_before FROM hi_cluster_lasthop; + n_before +---------- + 20 +(1 row) + +RESET enable_indexscan; +RESET enable_bitmapscan; +CLUSTER hi_cluster_lasthop USING hi_cl_lh_a; +-- All rows must survive (seqscan, reads the heap directly). +SET enable_indexscan = off; +SET enable_bitmapscan = off; +SELECT count(*) AS n_after_cluster_a FROM hi_cluster_lasthop; + n_after_cluster_a +------------------- + 20 +(1 row) + +SELECT string_agg(id::text, ',' ORDER BY id) AS missing_ids + FROM generate_series(1, 20) id + WHERE id NOT IN (SELECT id FROM hi_cluster_lasthop); + missing_ids +------------- + +(1 row) + +RESET enable_indexscan; +RESET enable_bitmapscan; +-- ...and every row is findable through the clustering index afterward. +SET enable_seqscan = off; +SET enable_indexscan = on; +SET enable_bitmapscan = off; +SELECT count(*) AS all_findable_via_a + FROM hi_cluster_lasthop WHERE a BETWEEN 1 AND 100000; + all_findable_via_a +-------------------- + 20 +(1 row) + +RESET enable_seqscan; +RESET enable_indexscan; +RESET enable_bitmapscan; +SELECT bt_index_check('hi_cl_lh_a', heapallindexed => true); + bt_index_check +---------------- + +(1 row) + +-- Same losing shape, but CLUSTER by a NON-btree clusterable index (GiST). The +-- fix lives in the heap AM's copy-for-cluster path and is agnostic to the +-- clustering index's access method, so this must preserve every row too -- a +-- guard that a btree-only fix (or one that touched index AMs) would miss. A +-- GiST clustering index has no sort path, so the heap AM falls back to a plain +-- seqscan copy; order is not preserved, but no live row is lost. +CREATE EXTENSION IF NOT EXISTS btree_gist; +CREATE TABLE hi_cluster_gist (id int PRIMARY KEY, a int, b int) + WITH (fillfactor = 40, autovacuum_enabled = false); +CREATE INDEX hi_cl_g_a ON hi_cluster_gist USING gist (a); +CREATE INDEX hi_cl_g_b ON hi_cluster_gist (b); +INSERT INTO hi_cluster_gist + SELECT g, g, g FROM generate_series(1, 20) g; +UPDATE hi_cluster_gist SET a = a + 1000 WHERE id <= 10; +UPDATE hi_cluster_gist SET a = a - 1000 WHERE id <= 10; +UPDATE hi_cluster_gist SET b = b + 100 WHERE id BETWEEN 5 AND 15; +CLUSTER hi_cluster_gist USING hi_cl_g_a; +SET enable_indexscan = off; +SET enable_bitmapscan = off; +SELECT count(*) AS n_after_cluster_gist FROM hi_cluster_gist; + n_after_cluster_gist +---------------------- + 20 +(1 row) + +SELECT string_agg(id::text, ',' ORDER BY id) AS missing_ids_gist + FROM generate_series(1, 20) id + WHERE id NOT IN (SELECT id FROM hi_cluster_gist); + missing_ids_gist +------------------ + +(1 row) + +RESET enable_indexscan; +RESET enable_bitmapscan; +DROP TABLE hi_cluster_gist; +SELECT * FROM verify_heapam('hi_cluster_lasthop'); + blkno | offnum | attnum | msg +-------+--------+--------+----- +(0 rows) + +DROP TABLE hi_cluster_lasthop; +-- --------------------------------------------------------------------------- +-- Cleanup +-- --------------------------------------------------------------------------- +DROP FUNCTION get_hi_count(text); +DROP FUNCTION get_hot_count(text); +-- pageinspect and amcheck were both created above with IF NOT EXISTS and may +-- have pre-existed this test; leave them, matching amcheck's treatment, +-- rather than risk dropping an extension this test did not create. diff --git a/contrib/pageinspect/expected/hot_updates.out b/contrib/pageinspect/expected/hot_updates.out new file mode 100644 index 0000000000000..91dea419b6448 --- /dev/null +++ b/contrib/pageinspect/expected/hot_updates.out @@ -0,0 +1,535 @@ +-- +-- HOT_UPDATES +-- Test classic Heap-Only Tuple (HOT) update decisions +-- +-- This file covers HOT decisions that apply identically on a pre-hot-indexed +-- server: every UPDATE here either leaves all indexed attributes +-- unchanged or touches only summarizing-index (BRIN) attributes, so the +-- HOT vs non-HOT choice does not depend on whether Selective Index +-- Update (hot-indexed) is enabled. hot-indexed-specific behaviour (UPDATEs that modify +-- a non-summarizing indexed attribute) is covered in +-- hot_indexed_updates.sql. +-- +-- Validation methods: +-- 1. Statistics (pg_stat_get_tuples_hot_updated) +-- 2. pageinspect for HOT chain structure +-- 3. EXPLAIN to confirm the planner still picks the index +-- +-- Load required extensions +CREATE EXTENSION IF NOT EXISTS pageinspect; +-- Sum of committed and in-progress (non-HOT, HOT) update counters. +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; + 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 target_ctid is the TAIL of a HOT chain on the 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; + 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; +-- Emit the HOT chain rooted at start_ctid. +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; + + 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 NOT found_predecessor THEN + current_ctid := start_ctid; + END IF; + + 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 + 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; +-- --------------------------------------------------------------------------- +-- 1. Basic HOT: update of a non-indexed column +-- --------------------------------------------------------------------------- +CREATE TABLE hot_test ( + id int PRIMARY KEY, + indexed_col int, + non_indexed_col text +) 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'); +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; +-- --------------------------------------------------------------------------- +-- 2. 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; +-- --------------------------------------------------------------------------- +-- 3. 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); +INSERT INTO hot_test VALUES (1, 100, repeat('x', 3000), 'small'); +-- 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', 3000); +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; +-- --------------------------------------------------------------------------- +-- 4. 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; +-- --------------------------------------------------------------------------- +-- 5. 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; +-- --------------------------------------------------------------------------- +-- 6. 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; +-- --------------------------------------------------------------------------- +-- 7. 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 pg_stat_force_next_flush(); + pg_stat_force_next_flush +-------------------------- + +(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; +-- --------------------------------------------------------------------------- +-- 8. JSONB expression index: non-indexed path change is HOT +-- --------------------------------------------------------------------------- +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"}'); +-- The jsonb column is the expression index's input, so HOT-indexed is +-- disqualified (expression indexes are not yet supported) and the jsonb +-- change blocks classic HOT: non-HOT update. +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) + +-- Likewise non-HOT: expression index disqualifies HOT-indexed. +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) + +-- Likewise non-HOT: expression index disqualifies HOT-indexed. +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; +-- --------------------------------------------------------------------------- +-- 9. A change to a GIN-indexed column is HOT-indexed +-- +-- The read side filters a stale leaf via the crossed-attribute bitmap, which +-- is access-method agnostic, so a GIN-covered column is HOT-indexed like any +-- other: only the GIN index is maintained, and a GIN scan (which rechecks on +-- the heap) returns correct results across the chain. +-- --------------------------------------------------------------------------- +CREATE TABLE hot_gin_test ( + id int PRIMARY KEY, + tags text[], + properties jsonb +) WITH (fillfactor = 50); +CREATE INDEX hot_gin_tags_idx ON hot_gin_test USING gin (tags); +CREATE INDEX hot_gin_props_idx ON hot_gin_test USING gin (properties); +INSERT INTO hot_gin_test VALUES + (1, ARRAY['tag1', 'tag2'], '{"key1":"val1","key2":"val2"}'), + (2, ARRAY['tag3', 'tag4'], '{"key3":"val3","key4":"val4"}'); +-- Reorder tags: a GIN-covered column changes, so this is HOT-indexed. +UPDATE hot_gin_test SET tags = ARRAY['tag2', 'tag1'] WHERE id = 1; +SELECT pg_stat_force_next_flush(); + pg_stat_force_next_flush +-------------------------- + +(1 row) + +SELECT * FROM get_hot_count('hot_gin_test'); + updates | hot +---------+----- + 1 | 1 +(1 row) + +DROP TABLE hot_gin_test; +-- --------------------------------------------------------------------------- +-- Cleanup +-- --------------------------------------------------------------------------- +DROP FUNCTION has_hot_chain(text, tid); +DROP FUNCTION print_hot_chain(text, tid); +DROP FUNCTION get_hot_count(text); +-- Leave pageinspect installed: this runs inside the pageinspect regress suite +-- (page.sql created it), and other tests in the schedule still need it. diff --git a/contrib/pageinspect/meson.build b/contrib/pageinspect/meson.build index c43ea400a4d7b..0464f73a901c5 100644 --- a/contrib/pageinspect/meson.build +++ b/contrib/pageinspect/meson.build @@ -38,6 +38,7 @@ install_data( 'pageinspect--1.10--1.11.sql', 'pageinspect--1.11--1.12.sql', 'pageinspect--1.12--1.13.sql', + 'pageinspect--1.13--1.14.sql', 'pageinspect.control', kwargs: contrib_data_args, ) @@ -56,6 +57,8 @@ tests += { 'hash', 'checksum', 'oldextversions', + 'hot_updates', + 'hot_indexed_updates', ], }, } diff --git a/contrib/pageinspect/pageinspect--1.13--1.14.sql b/contrib/pageinspect/pageinspect--1.13--1.14.sql new file mode 100644 index 0000000000000..6c5c824d5e2c1 --- /dev/null +++ b/contrib/pageinspect/pageinspect--1.13--1.14.sql @@ -0,0 +1,49 @@ +/* contrib/pageinspect/pageinspect--1.13--1.14.sql */ + +-- complain if script is sourced in psql, rather than via CREATE EXTENSION +\echo Use "ALTER EXTENSION pageinspect UPDATE TO '1.14'" to load this file. \quit + +-- +-- bt_page_items(relname, blkno) -- add hot_indexed column +-- +-- A HOT-indexed (SIU) fresh index entry stores its heap TID with an internal +-- marker bit (ItemPointerSIUMaybeStaleFlag) set in the offset field, which +-- tells a bitmap scan the entry points mid-chain and must be resolved +-- lossily. Earlier pageinspect versions surfaced that marker as an inflated +-- ctid offset; from 1.14 the ctid/htid columns report the real offset and the +-- new hot_indexed column exposes the marker explicitly. +-- +DROP FUNCTION bt_page_items(text, int8); +CREATE FUNCTION bt_page_items(IN relname text, IN blkno int8, + OUT itemoffset smallint, + OUT ctid tid, + OUT itemlen smallint, + OUT nulls bool, + OUT vars bool, + OUT data text, + OUT dead boolean, + OUT htid tid, + OUT tids tid[], + OUT hot_indexed bool) +RETURNS SETOF record +AS 'MODULE_PATHNAME', 'bt_page_items_1_9' +LANGUAGE C STRICT PARALLEL SAFE; + +-- +-- bt_page_items(page) -- add hot_indexed column +-- +DROP FUNCTION bt_page_items(bytea); +CREATE FUNCTION bt_page_items(IN page bytea, + OUT itemoffset smallint, + OUT ctid tid, + OUT itemlen smallint, + OUT nulls bool, + OUT vars bool, + OUT data text, + OUT dead boolean, + OUT htid tid, + OUT tids tid[], + OUT hot_indexed bool) +RETURNS SETOF record +AS 'MODULE_PATHNAME', 'bt_page_items_bytea' +LANGUAGE C STRICT PARALLEL SAFE; diff --git a/contrib/pageinspect/pageinspect.control b/contrib/pageinspect/pageinspect.control index cfc87feac034a..aee3f598a9e19 100644 --- a/contrib/pageinspect/pageinspect.control +++ b/contrib/pageinspect/pageinspect.control @@ -1,5 +1,5 @@ # pageinspect extension comment = 'inspect the contents of database pages at a low level' -default_version = '1.13' +default_version = '1.14' module_pathname = '$libdir/pageinspect' relocatable = true diff --git a/contrib/pageinspect/sql/hot_indexed_updates.sql b/contrib/pageinspect/sql/hot_indexed_updates.sql new file mode 100644 index 0000000000000..a270eead08d60 --- /dev/null +++ b/contrib/pageinspect/sql/hot_indexed_updates.sql @@ -0,0 +1,1501 @@ +-- +-- HOT_INDEXED_UPDATES +-- Test HOT-indexed update (hot-indexed), aka HOT-indexed, behaviour +-- +-- Every UPDATE in this file modifies at least one non-summarizing +-- indexed attribute. On a pre-hot-indexed server all of these would be +-- non-HOT; on the hot-indexed branch each eligible update stays on-page and +-- inserts into only the indexes whose attributes actually changed. +-- +-- We verify four things: +-- (A) pg_stat counters: HOT and hot-indexed counts increment as expected +-- (B) index lookups return the new value and not the stale value +-- for EQUALITY queries (the read-side staleness test drops a +-- leaf whose covered attribute changed on the way to the live tuple) +-- (C) pg_relation_hot_indexed_stats reports the HOT-indexed versions we expect +-- (D) **RANGE/INEQUALITY** queries return the correct number of +-- tuples -- this is the class of bugs where a stale btree +-- entry's key is still reachable via a looser scan key; the +-- crossed-attribute bitmap drops the stale arrival because the index's +-- attribute changed between that leaf's target and the live tuple +-- + +CREATE EXTENSION IF NOT EXISTS pageinspect; + +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; + 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; + +CREATE OR REPLACE FUNCTION get_hi_count(rel_name text) +RETURNS TABLE (updates BIGINT, hot BIGINT, hot_idx 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); + hot_idx := COALESCE(pg_stat_get_tuples_hot_indexed_updated(rel_oid), 0) + + COALESCE(pg_stat_get_xact_tuples_hot_indexed_updated(rel_oid), 0); + RETURN NEXT; +END; +$$ LANGUAGE plpgsql; + + +-- --------------------------------------------------------------------------- +-- 1. Basic hot-indexed: modifying an indexed column stays HOT and counts as hot-indexed +-- --------------------------------------------------------------------------- +CREATE TABLE hi_basic ( + id int PRIMARY KEY, + indexed_col int, + non_indexed_col text +) WITH (fillfactor = 50); +CREATE INDEX hi_basic_idx ON hi_basic(indexed_col); + +INSERT INTO hi_basic VALUES (1, 100, 'initial'); + +-- Pre-hot-indexed this would be non-HOT. Under hot-indexed it's HOT-indexed; both the +-- HOT counter and the hot-indexed counter advance. +UPDATE hi_basic SET indexed_col = 150 WHERE id = 1; +SELECT pg_stat_force_next_flush(); +SELECT * FROM get_hi_count('hi_basic'); + +-- The new value is reachable via the index. +SET enable_seqscan = off; +EXPLAIN (COSTS OFF) SELECT id, indexed_col FROM hi_basic WHERE indexed_col = 150; +SELECT id, indexed_col FROM hi_basic WHERE indexed_col = 150; + +-- The old value is not reachable through this index: the stale btree +-- entry (indexed_col=100) walks to the current tuple via the hot-indexed hop, +-- nodeIndexscan re-evaluates `indexed_col = 100` against the current +-- tuple (indexed_col=150), and the row is correctly dropped. This is +-- the equality-lookup case the crossed-attribute bitmap handles. +EXPLAIN (COSTS OFF) SELECT id FROM hi_basic WHERE indexed_col = 100; +SELECT id FROM hi_basic WHERE indexed_col = 100; +RESET enable_seqscan; + +-- pg_relation_hot_indexed_stats sees one HOT-indexed version, zero HOT redirects (the +-- chain has not yet been pruned so no LP_REDIRECT exists). +SELECT n_hot_indexed, n_chains, avg_chain_len, max_chain_len +FROM pg_relation_hot_indexed_stats('hi_basic'); + +DROP TABLE hi_basic; + +-- --------------------------------------------------------------------------- +-- 2. RANGE/INEQUALITY correctness after hot-indexed on an indexed column +-- +-- This is the test class that catches the hot-indexed false-dup bug: a stale +-- btree entry whose key value still satisfies the range predicate, +-- reachable via the hot-indexed chain hop. +-- +-- To exercise the bug we must force an IndexScan plan (the +-- IndexOnlyScan path permissively drops every hot-indexed-reachable index-only +-- hit; the BitmapHeapScan path dedups by TID). We include a payload +-- column not present in the PK so the planner must heap-fetch. +-- +-- The read-side crossed-attribute bitmap makes the IndexScan return the correct +-- count of 1: the stale entry ('1','5') chain-walks to the live tuple across +-- the b-changing hop, and because the PK covers b the overlap is non-empty, so +-- the stale leaf is dropped. The fresh entry ('1','15') points directly at the +-- live tuple (no hop after it) and is kept. The ORDER BY likewise returns the +-- single live row. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_range ( + a int, + b int, + payload text, + PRIMARY KEY (a, b) +) WITH (fillfactor = 50); + +INSERT INTO hi_range VALUES (1, 5, 'hi'); + +-- hot-indexed update on the second PK column: stale btree entry ('1','5') +-- remains, new entry ('1','15') inserted. The stale entry points at +-- the chain root; the fresh entry points directly at the new +-- heap-only tuple. +UPDATE hi_range SET b = 15 WHERE a = 1 AND b = 5; + +SET enable_seqscan = off; +SET enable_bitmapscan = off; + +-- IndexScan: payload IS NOT NULL forces heap fetch, no IndexOnlyScan. +-- The stale ('1','5') leaf is dropped by the crossed-attribute bitmap, so this +-- returns 1. +EXPLAIN (COSTS OFF) +SELECT count(*) FROM hi_range WHERE a = 1 AND b < 100 AND payload IS NOT NULL; +SELECT count(*) FROM hi_range WHERE a = 1 AND b < 100 AND payload IS NOT NULL; +SELECT a, b FROM hi_range WHERE a = 1 AND payload IS NOT NULL ORDER BY b; + +-- IndexOnlyScan: the page holds a preserved HOT-indexed member so it is never all-visible; IOS +-- performs the heap fetch and the crossed-attribute bitmap drops the stale ('1','5') +-- leaf, so count = 1. +EXPLAIN (COSTS OFF) SELECT count(*) FROM hi_range WHERE a = 1 AND b < 100; +SELECT count(*) FROM hi_range WHERE a = 1 AND b < 100; + +-- BitmapHeapScan: TID dedup collapses the stale and fresh hits. +SET enable_indexscan = off; +SET enable_indexonlyscan = off; +RESET enable_bitmapscan; +EXPLAIN (COSTS OFF) SELECT count(*) FROM hi_range WHERE a = 1 AND b < 100; +SELECT count(*) FROM hi_range WHERE a = 1 AND b < 100; +RESET enable_indexscan; +RESET enable_indexonlyscan; + +-- SeqScan: reads the heap directly, sees exactly one live tuple. +RESET enable_seqscan; +SET enable_indexscan = off; +SET enable_indexonlyscan = off; +SET enable_bitmapscan = off; +EXPLAIN (COSTS OFF) SELECT count(*) FROM hi_range WHERE a = 1 AND b < 100; +SELECT count(*) FROM hi_range WHERE a = 1 AND b < 100; +RESET enable_indexscan; +RESET enable_indexonlyscan; +RESET enable_bitmapscan; + +-- Same shape on a secondary (non-PK) btree: another hot-indexed update on b. +CREATE INDEX hi_range_b_idx ON hi_range(b); +UPDATE hi_range SET b = 25 WHERE a = 1 AND b = 15; + +SET enable_seqscan = off; +SET enable_bitmapscan = off; +-- IndexScan path on the secondary index; same fix applies. +SELECT count(*) FROM hi_range WHERE b BETWEEN 0 AND 100 AND payload IS NOT NULL; +RESET enable_seqscan; +RESET enable_bitmapscan; + +DROP TABLE hi_range; + +-- --------------------------------------------------------------------------- +-- 3. All-or-none on a multi-indexed table: hot-indexed only touches indexes +-- whose attributes changed +-- --------------------------------------------------------------------------- +CREATE TABLE hi_multi ( + id int PRIMARY KEY, + col_a int, + col_b int, + col_c int, + non_indexed text +) WITH (fillfactor = 50); +CREATE INDEX hi_multi_a_idx ON hi_multi(col_a); +CREATE INDEX hi_multi_b_idx ON hi_multi(col_b); +CREATE INDEX hi_multi_c_idx ON hi_multi(col_c); + +INSERT INTO hi_multi VALUES (1, 10, 20, 30, 'initial'); + +-- col_a only: under hot-indexed this is HOT-indexed, and only hi_multi_a_idx +-- gets a new entry. hi_multi_b_idx / hi_multi_c_idx keep pointing +-- at the chain root. +UPDATE hi_multi SET col_a = 15 WHERE id = 1; +SELECT pg_stat_force_next_flush(); +SELECT * FROM get_hi_count('hi_multi'); + +-- Lookups on all three indexes return the row. +SET enable_seqscan = off; +SELECT id FROM hi_multi WHERE col_a = 15; +SELECT id FROM hi_multi WHERE col_b = 20; +SELECT id FROM hi_multi WHERE col_c = 30; + +-- Old col_a value is unreachable by equality (stale entry dropped by the +-- read-side crossed-attribute bitmap). +SELECT id FROM hi_multi WHERE col_a = 10; +RESET enable_seqscan; + +DROP TABLE hi_multi; + +-- --------------------------------------------------------------------------- +-- 4. Multi-column btree: hot-indexed on part of a composite key +-- --------------------------------------------------------------------------- +CREATE TABLE hi_composite ( + id int PRIMARY KEY, + col_a int, + col_b int, + data text +) WITH (fillfactor = 50); +CREATE INDEX hi_composite_ab_idx ON hi_composite(col_a, col_b); + +INSERT INTO hi_composite VALUES (1, 10, 20, 'data'); + +-- col_a is part of the composite key: hot-indexed. +UPDATE hi_composite SET col_a = 15; +SELECT pg_stat_force_next_flush(); +SELECT * FROM get_hi_count('hi_composite'); + +-- Reset and then update col_b (also part of the key). +UPDATE hi_composite SET col_a = 10; +UPDATE hi_composite SET col_b = 25; +SELECT pg_stat_force_next_flush(); +SELECT * FROM get_hi_count('hi_composite'); + +DROP TABLE hi_composite; + +-- --------------------------------------------------------------------------- +-- 5. Partial index: status transition out-of-predicate +-- +-- 'status' is a partial-index predicate column. A change to a predicate +-- column can flip a row in or out of the index, which the read-side key +-- recheck cannot detect, so HeapUpdateHotAllowable conservatively disqualifies +-- HOT-indexed for any predicate-column change (even this out-of-predicate -> +-- out-of-predicate case). The update is therefore non-HOT, and the partial +-- index correctly stays empty for these non-'active' rows. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_partial ( + id int PRIMARY KEY, + status text, + data text +) WITH (fillfactor = 50); +CREATE INDEX hi_partial_active_idx ON hi_partial(status) WHERE status = 'active'; + +INSERT INTO hi_partial VALUES (1, 'active', 'data1'); +INSERT INTO hi_partial VALUES (2, 'inactive', 'data2'); +INSERT INTO hi_partial VALUES (3, 'deleted', 'data3'); + +-- out -> out transition on the predicate column: HOT-indexed keeps it on-page, +-- and the partial index gets no entry (the row satisfies the predicate neither +-- before nor after the update). +UPDATE hi_partial SET status = 'deleted' WHERE id = 2; +SELECT pg_stat_force_next_flush(); +SELECT * FROM get_hi_count('hi_partial'); + +-- The partial index still correctly answers "active" queries. +SELECT id, status FROM hi_partial WHERE status = 'active'; + +DROP TABLE hi_partial; + +-- --------------------------------------------------------------------------- +-- 6. Partition: hot-indexed inside one partition +-- --------------------------------------------------------------------------- +CREATE TABLE hi_part ( + id int, + partition_key int, + indexed_col int, + data text, + PRIMARY KEY (id, partition_key) +) PARTITION BY RANGE (partition_key); +CREATE TABLE hi_part_1 PARTITION OF hi_part + FOR VALUES FROM (1) TO (100) WITH (fillfactor = 50); +CREATE INDEX hi_part_idx ON hi_part(indexed_col); + +INSERT INTO hi_part VALUES (1, 50, 100, 'data'); + +UPDATE hi_part SET indexed_col = 150 WHERE id = 1; +SELECT pg_stat_force_next_flush(); +SELECT * FROM get_hi_count('hi_part_1'); + +SET enable_seqscan = off; +SELECT id FROM hi_part WHERE indexed_col = 150; +SELECT id FROM hi_part WHERE indexed_col = 100; +RESET enable_seqscan; + +DROP TABLE hi_part CASCADE; + +-- --------------------------------------------------------------------------- +-- 7. Trigger modifies indexed column: hot-indexed, not non-HOT +-- --------------------------------------------------------------------------- +CREATE TABLE hi_trigger ( + id int PRIMARY KEY, + triggered_col int, + data text +) WITH (fillfactor = 50); +CREATE INDEX hi_trigger_idx ON hi_trigger(triggered_col); + +CREATE OR REPLACE FUNCTION hi_trigger_bump() +RETURNS TRIGGER AS $$ +BEGIN + NEW.triggered_col = NEW.triggered_col + 1; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER before_update_bump + BEFORE UPDATE ON hi_trigger + FOR EACH ROW + EXECUTE FUNCTION hi_trigger_bump(); + +INSERT INTO hi_trigger VALUES (1, 100, 'initial'); + +-- UPDATE's SET clause doesn't touch the indexed column, but the +-- trigger modifies it via heap_modify_tuple. hot-indexed must detect this +-- and keep the tuple on-page (HEAP_INDEXED_UPDATED) plus a new btree entry. +UPDATE hi_trigger SET data = 'updated' WHERE id = 1; +SELECT pg_stat_force_next_flush(); +SELECT * FROM get_hi_count('hi_trigger'); +SELECT triggered_col FROM hi_trigger WHERE id = 1; + +-- New value reachable. +SET enable_seqscan = off; +SELECT id FROM hi_trigger WHERE triggered_col = 101; +SELECT id FROM hi_trigger WHERE triggered_col = 100; +RESET enable_seqscan; + +DROP TABLE hi_trigger CASCADE; +DROP FUNCTION hi_trigger_bump(); + +-- --------------------------------------------------------------------------- +-- 8. JSONB expression index: HOT-indexed is not yet supported on expression +-- indexes, so the update falls back to a non-HOT update (hot_idx = 0). +-- Reads stay correct. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_jsonb ( + id int PRIMARY KEY, + data jsonb +) WITH (fillfactor = 50); +CREATE INDEX hi_jsonb_name_idx ON hi_jsonb ((data->>'name')); + +INSERT INTO hi_jsonb VALUES (1, '{"name":"Alice","age":30}'); + +-- Changing the indexed expression's value (name): expression indexes are not +-- yet supported, so this is a non-HOT update. +UPDATE hi_jsonb SET data = jsonb_set(data, '{name}', '"Alice2"') WHERE id = 1; +SELECT pg_stat_force_next_flush(); +SELECT * FROM get_hi_count('hi_jsonb'); + +SET enable_seqscan = off; +SELECT id FROM hi_jsonb WHERE data->>'name' = 'Alice2'; +SELECT id FROM hi_jsonb WHERE data->>'name' = 'Alice'; +RESET enable_seqscan; + +DROP TABLE hi_jsonb; + +-- --------------------------------------------------------------------------- +-- 9. GIN index with changed extracted keys: hot-indexed +-- --------------------------------------------------------------------------- +CREATE TABLE hi_gin ( + id int PRIMARY KEY, + tags text[] +) WITH (fillfactor = 50); +CREATE INDEX hi_gin_tags_idx ON hi_gin USING gin (tags); + +INSERT INTO hi_gin VALUES (1, ARRAY['tag1', 'tag2']); + +-- Adding a tag yields a different extracted-key set: hot-indexed. +UPDATE hi_gin SET tags = ARRAY['tag1', 'tag2', 'tag5'] WHERE id = 1; +SELECT pg_stat_force_next_flush(); +SELECT * FROM get_hi_count('hi_gin'); + +SET enable_seqscan = off; +SELECT id FROM hi_gin WHERE tags @> ARRAY['tag5']; +RESET enable_seqscan; + +DROP TABLE hi_gin; + +-- --------------------------------------------------------------------------- +-- 10. Per-index HOT-indexed counters: skipped vs matched +-- +-- A table with two independent secondary indexes. An UPDATE touches a +-- column covered by only one of them; the HOT-indexed path must insert +-- into that one index and skip the other. pg_stat_all_indexes reports +-- matched>0 on the updated index and skipped>0 on the untouched index. +-- --------------------------------------------------------------------------- +CREATE TABLE hotidx_perindex ( + id int PRIMARY KEY, + a int, + b int +) WITH (fillfactor = 50); +CREATE INDEX hotidx_perindex_a ON hotidx_perindex(a); +CREATE INDEX hotidx_perindex_b ON hotidx_perindex(b); + +INSERT INTO hotidx_perindex VALUES (1, 100, 200); + +-- Modify only column a. HOT-indexed inserts into hotidx_perindex_a and +-- skips hotidx_perindex_b (primary key indrelid is the table itself and +-- also unchanged, so it counts as skipped too). +UPDATE hotidx_perindex SET a = 101 WHERE id = 1; + +-- Force flush of pending stats to the shared entry. +SELECT pg_stat_force_next_flush(); + +SELECT indexrelname, + n_tup_hot_indexed_upd_matched AS matched, + n_tup_hot_indexed_upd_skipped AS skipped + FROM pg_stat_all_indexes + WHERE relname = 'hotidx_perindex' + ORDER BY indexrelname; + +-- A second UPDATE touching only b inverts the assignment. +UPDATE hotidx_perindex SET b = 201 WHERE id = 1; +SELECT pg_stat_force_next_flush(); + +SELECT indexrelname, + n_tup_hot_indexed_upd_matched AS matched, + n_tup_hot_indexed_upd_skipped AS skipped + FROM pg_stat_all_indexes + WHERE relname = 'hotidx_perindex' + ORDER BY indexrelname; + +-- Invariant: matched + skipped == owning table's n_tup_hot_indexed_upd. +SELECT indexrelname, + n_tup_hot_indexed_upd_matched + n_tup_hot_indexed_upd_skipped AS total, + (SELECT n_tup_hot_indexed_upd FROM pg_stat_all_tables + WHERE relname = 'hotidx_perindex') AS table_hot_idx_upd + FROM pg_stat_all_indexes + WHERE relname = 'hotidx_perindex' + ORDER BY indexrelname; + +-- Boolean assertion of the same invariant. This is the canonical form +-- reviewers asked for: every index entry is either matched (the index +-- got a fresh insert this UPDATE) or skipped (HOT-indexed correctly +-- avoided an insert because the index's attrs did not change). If the +-- two counters drift apart from the table-level n_tup_hot_indexed_upd we +-- have either lost a per-index increment or double-counted one. +SELECT bool_and((n_tup_hot_indexed_upd_matched + n_tup_hot_indexed_upd_skipped) = + (SELECT n_tup_hot_indexed_upd FROM pg_stat_all_tables + WHERE relname = 'hotidx_perindex')) + AS perindex_invariant_holds + FROM pg_stat_all_indexes + WHERE relname = 'hotidx_perindex'; + +DROP TABLE hotidx_perindex; + +-- --------------------------------------------------------------------------- +-- 11. Long hot-loop UPDATE stays compact and HOT-indexed +-- +-- A long run of HOT-indexed UPDATEs to a single row stays compact: prune +-- collapses each dead version to a redirect to the live tuple and reuses its +-- slot, so the heap stays bounded and the chain does not grow unbounded. +-- Every UPDATE that changes the indexed column (and leaves another index, +-- here the PK, unchanged) takes the HOT-indexed path. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_chaincap ( + id int PRIMARY KEY, + a int +) WITH (fillfactor = 10); +CREATE INDEX hi_chaincap_a_idx ON hi_chaincap(a); + +INSERT INTO hi_chaincap VALUES (1, 0); + +DO $$ +DECLARE + i int; +BEGIN + FOR i IN 1 .. 200 LOOP + UPDATE hi_chaincap SET a = i WHERE id = 1; + END LOOP; +END $$; + +-- After 200 UPDATEs the row's value is 200. +SELECT a FROM hi_chaincap WHERE id = 1; + +-- Every UPDATE took the HOT-indexed path (the PK index is unchanged, so it is +-- skipped), so n_tup_hot_indexed_upd advanced. +SELECT pg_stat_force_next_flush(); +SELECT hot_idx > 0 AS hot_indexed_fired + FROM get_hi_count('hi_chaincap'); + +-- The heap stayed compact: prune+collapse reclaimed the dead versions, so the +-- single live row stays within a couple of pages. pg_relation_size reflects +-- the table's actual current size regardless of vacuum/analyze stats, unlike +-- pg_class.relpages (which is only updated by VACUUM/ANALYZE and would be +-- trivially <= 1 on this never-vacuumed table even if pruning had failed and +-- the heap had ballooned). +SELECT pg_relation_size('hi_chaincap') <= 8192 * 2 AS heap_stayed_compact; + +DROP TABLE hi_chaincap; + +-- --------------------------------------------------------------------------- +-- 12. A HOT-indexed chain forms and reads through it stay correct +-- +-- Several HOT-indexed updates of the same live row build a multi-hop chain of +-- preserved HOT-indexed members, each carrying its own crossed-attribute +-- bitmap. We assert only horizon-independent facts: at least one HOT-indexed +-- member is present (the live version always is, and cannot be pruned), a scan +-- through the secondary index returns the one live row by its current key, and +-- none of the superseded keys surface (the crossed-attribute bitmap filters +-- their stale leaves). +-- +-- We deliberately do NOT assert an exact member count or any post-VACUUM +-- collapse/reclaim state: opportunistic HOT pruning and VACUUM collapse are +-- both gated on the superseded versions falling below the global xmin horizon, +-- which a snapshot held elsewhere in the running regression cluster can pin +-- back indefinitely -- so the physical layout (n_hot_indexed's exact value, +-- whether the chain has collapsed to an LP_REDIRECT) is not deterministic +-- here. Prune/collapse and snapshot-gated stale-leaf reclaim are covered +-- deterministically by the hot_indexed_adversarial isolation spec, where +-- transaction ordering -- hence the horizon -- is controlled and the collapse +-- is validated by reader consistency across it (permutation 7). +-- --------------------------------------------------------------------------- +CREATE TABLE hi_reclaim ( + id int PRIMARY KEY, + a int +) WITH (fillfactor = 50, autovacuum_enabled = false); +CREATE INDEX hi_reclaim_a_idx ON hi_reclaim(a); + +INSERT INTO hi_reclaim VALUES (1, 100); +-- Build a multi-hop chain via several HOT-indexed updates; the row stays live. +UPDATE hi_reclaim SET a = 200 WHERE id = 1; +UPDATE hi_reclaim SET a = 300 WHERE id = 1; +UPDATE hi_reclaim SET a = 400 WHERE id = 1; + +-- The live version is a HOT-indexed member and cannot be pruned, so this holds +-- regardless of how much opportunistic pruning has happened. +SELECT n_hot_indexed >= 1 AS hot_indexed_member_present + FROM pg_relation_hot_indexed_stats('hi_reclaim'); + +-- The live row resolves through the secondary index by its current key, and +-- none of the superseded keys surface through their stale leaves. +SELECT id, a FROM hi_reclaim WHERE a = 400; +SELECT count(*) = 0 AS no_stale_key_surfaces + FROM hi_reclaim WHERE a IN (100, 200, 300); + +DROP TABLE hi_reclaim; + +-- --------------------------------------------------------------------------- +-- 13. Page with a preserved HOT-indexed member is never marked all-visible +-- +-- pruneheap deliberately leaves PD_ALL_VISIBLE clear on any page that still +-- carries a preserved HOT-indexed member: an index-only scan must heap-fetch +-- through the chain so the read-side crossed-attribute bitmap can filter stale btree +-- entries. +-- +-- We force the freeze path with VACUUM (FREEZE, DISABLE_PAGE_SKIPPING) and +-- then read pd_flags via pageinspect.page_header. The page must still carry +-- a HOT-indexed member (n_hot_indexed > 0) AND must not have PD_ALL_VISIBLE +-- (0x0004). +-- --------------------------------------------------------------------------- +CREATE TABLE hi_vm ( + id int PRIMARY KEY, + a int +) WITH (fillfactor = 50, autovacuum_enabled = false); +CREATE INDEX hi_vm_a_idx ON hi_vm(a); + +INSERT INTO hi_vm VALUES (1, 1); +-- Two HOT-indexed updates leave a multi-hop chain, so a preserved HOT-indexed +-- member remains on the page after prune, which is what this test needs. +UPDATE hi_vm SET a = 2 WHERE id = 1; +UPDATE hi_vm SET a = 3 WHERE id = 1; + +-- Force the all-visible bit decision: VACUUM with DISABLE_PAGE_SKIPPING +-- considers every page; FREEZE pushes hint bits hard. After this, any +-- page bearing a preserved HOT-indexed member must still report all_visible = 0. +VACUUM (FREEZE, DISABLE_PAGE_SKIPPING) hi_vm; + +SELECT n_hot_indexed >= 1 AS hot_indexed_present + FROM pg_relation_hot_indexed_stats('hi_vm'); + +-- PD_ALL_VISIBLE = 0x0004. Must be 0 on a page with a preserved member. +SELECT (flags & 4) = 0 AS not_marked_all_visible + FROM page_header(get_raw_page('hi_vm', 0)); + +DROP TABLE hi_vm; + +-- --------------------------------------------------------------------------- +-- 14. Cycle-key dedup: column rename a -> b -> a stays correct +-- +-- A rename does not rewrite heap or index entries; it only updates the +-- catalog. The relcache invalidation must trigger a fresh attribute +-- bitmap and the HOT-indexed predicate must compare attribute *numbers*, +-- not attribute *names*. After two renames that net to identity, every +-- subsequent UPDATE must continue to drive the HOT-indexed path. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_cycle ( + id int PRIMARY KEY, + a int +) WITH (fillfactor = 50); +CREATE INDEX hi_cycle_a_idx ON hi_cycle(a); + +INSERT INTO hi_cycle VALUES (1, 100); + +-- Cycle the column name and confirm both intermediate forms drive HOT-indexed. +ALTER TABLE hi_cycle RENAME COLUMN a TO b; +UPDATE hi_cycle SET b = 200 WHERE id = 1; +SELECT pg_stat_force_next_flush(); +SELECT hot_idx > 0 AS hot_indexed_after_first_rename + FROM get_hi_count('hi_cycle'); + +ALTER TABLE hi_cycle RENAME COLUMN b TO a; +UPDATE hi_cycle SET a = 300 WHERE id = 1; +-- Lookup via the index returns the current value, not any of the +-- pre-rename values. +SET enable_seqscan = off; +SELECT id, a FROM hi_cycle WHERE a = 300; +SELECT id FROM hi_cycle WHERE a = 100; +SELECT id FROM hi_cycle WHERE a = 200; +RESET enable_seqscan; + +DROP TABLE hi_cycle; + +-- --------------------------------------------------------------------------- +-- 15. Summarizing-only column UPDATE produces CLASSIC, not INDEXED +-- +-- HeapUpdateHotAllowable returns HEAP_HEAP_ONLY_UPDATE when every +-- modified indexed attribute is covered only by summarizing indexes. +-- A BRIN-only column is the canonical case: the BRIN index gets a +-- new summary entry via aminsert, but no per-update btree entry is +-- needed and HOT-indexed does not fire. The signal is +-- n_tup_hot_upd > 0 with n_tup_hot_indexed_upd unchanged. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_brin ( + id int PRIMARY KEY, + bcol int +) WITH (fillfactor = 50); +CREATE INDEX hi_brin_idx ON hi_brin USING brin(bcol); + +INSERT INTO hi_brin VALUES (1, 100); + +-- Capture the HOT-indexed counter before, drive a BRIN-only update, +-- and assert that classic HOT advanced while HOT-indexed did not. +SELECT pg_stat_force_next_flush(); +SELECT hot_idx AS hot_idx_before FROM get_hi_count('hi_brin') \gset +UPDATE hi_brin SET bcol = 200 WHERE id = 1; +SELECT pg_stat_force_next_flush(); +SELECT (hot - 0) > 0 AS classic_hot_fired, + hot_idx = :hot_idx_before AS hot_indexed_did_not_fire + FROM get_hi_count('hi_brin'); + +-- The BRIN index sees the new value via aminsert. +SELECT bcol FROM hi_brin WHERE id = 1; + +DROP TABLE hi_brin; + +-- --------------------------------------------------------------------------- +-- 16. UNIQUE index on a type where image equality != operator equality +-- +-- numeric 1.0 and 1.00 are equal under the btree opclass but have +-- different on-disk images. A HOT-indexed update 1.0 -> 1.00 inserts a +-- fresh leaf carrying the live image and leaves a stale leaf for 1.0 +-- (the hop's modified-attrs bitmap marks k changed, since modified-column +-- detection is image-based). A later INSERT of a value equal under the +-- opclass must still be detected as a duplicate: the unique check reaches +-- the live tuple through the fresh leaf, which points directly at it (no hop +-- after it, so the overlap is empty and the leaf is a genuine conflict); the +-- stale 1.0 leaf is skipped because the k-changing hop overlaps the unique +-- index's attribute. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_unum (k numeric UNIQUE, j int) WITH (fillfactor = 50); +CREATE INDEX hi_unum_j ON hi_unum(j); -- 2nd indexed attr, kept fixed +INSERT INTO hi_unum VALUES (1.0, 100); +UPDATE hi_unum SET k = 1.00 WHERE j = 100; -- HOT-indexed: 1.0 -> 1.00 +SELECT n_hot_indexed > 0 AS made_hot_indexed + FROM pg_relation_hot_indexed_stats('hi_unum'); +-- A numerically-equal insert must conflict (the fresh leaf catches it): +INSERT INTO hi_unum VALUES (1.0, 1); -- expect duplicate key error +-- A genuinely different value is accepted: +INSERT INTO hi_unum VALUES (2.0, 2); +SELECT k, j FROM hi_unum ORDER BY j; +DROP TABLE hi_unum; + +-- --------------------------------------------------------------------------- +-- 17. CREATE INDEX and REINDEX over live HOT-indexed chains +-- +-- A freshly built or rebuilt index must reflect current values, never a +-- stale chain member: the build scans live tuples only and points each +-- HOT-indexed live tuple's entry at its own TID, so the new entries have no +-- hop after them and the crossed-attribute bitmap keeps them. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_reindex (id int PRIMARY KEY, a int, b int) WITH (fillfactor = 50); +CREATE INDEX hi_reindex_a ON hi_reindex(a); +INSERT INTO hi_reindex SELECT g, g, g FROM generate_series(1, 6) g; +UPDATE hi_reindex SET a = a + 100; -- HOT-indexed on a +UPDATE hi_reindex SET a = a + 100; -- again -> longer chains +SELECT n_hot_indexed > 0 AS made_hot_indexed + FROM pg_relation_hot_indexed_stats('hi_reindex'); +-- Build a NEW index and REINDEX the existing one over the live chains. +CREATE INDEX hi_reindex_b ON hi_reindex(b); +REINDEX INDEX hi_reindex_a; +SET enable_seqscan = off; +SELECT id, a FROM hi_reindex WHERE a = 204; -- current value -> id 4 +SELECT count(*) FROM hi_reindex WHERE a = 4; -- obsolete value -> 0 +SELECT id FROM hi_reindex WHERE b = 2; -- via freshly built index -> 2 +RESET enable_seqscan; +DROP TABLE hi_reindex; + +-- --------------------------------------------------------------------------- +-- 18. DROP every index over live HOT-indexed chains, then VACUUM +-- +-- After all indexes are dropped, heap pages may still carry preserved +-- HOT-indexed members left by earlier updates. VACUUM of such a no-index +-- relation must complete without error, and reads must stay correct via the +-- redirect forwarders. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_dropidx (id int PRIMARY KEY, a int) WITH (fillfactor = 50, autovacuum_enabled = false); +CREATE INDEX hi_dropidx_a ON hi_dropidx(a); +INSERT INTO hi_dropidx SELECT g, g FROM generate_series(1, 6) g; +UPDATE hi_dropidx SET a = a + 100; -- HOT-indexed on a +UPDATE hi_dropidx SET a = a + 100; -- again -> longer chains +SELECT n_hot_indexed > 0 AS made_hot_indexed + FROM pg_relation_hot_indexed_stats('hi_dropidx'); +-- Drop every index, leaving preserved HOT-indexed members with no index to sweep. +DROP INDEX hi_dropidx_a; +ALTER TABLE hi_dropidx DROP CONSTRAINT hi_dropidx_pkey; +-- Must not crash on the no-index path; two passes exercise the second-pass +-- reclaim guard as well. +VACUUM hi_dropidx; +VACUUM hi_dropidx; +-- Reads remain correct after the indexes are gone. +SELECT id, a FROM hi_dropidx ORDER BY id; +DROP TABLE hi_dropidx; + +-- --------------------------------------------------------------------------- +-- 19. Re-collapse of a data-redirect chain across partial VACUUMs +-- +-- A chain that collapses to a HOT-indexed data redirect, is vacuumed with +-- INDEX_CLEANUP off (so the stale leaves and the redirect survive), then +-- receives further HOT-indexed updates that re-collapse the chain and +-- re-point the redirect at a new live tuple, must not leave the redirect +-- dangling. A subsequent full VACUUM must complete without error, leave the +-- heap consistent (verify_heapam reports nothing), and reads must stay +-- correct. (Regression: an earlier revision crashed reclaiming a mid-chain +-- member while a data redirect still pointed past it.) +-- --------------------------------------------------------------------------- +CREATE EXTENSION IF NOT EXISTS amcheck; +CREATE TABLE hi_recollapse (id int PRIMARY KEY, a int) WITH (fillfactor = 50, autovacuum_enabled = false); +CREATE INDEX hi_recollapse_a ON hi_recollapse(a); +INSERT INTO hi_recollapse VALUES (1, 1); +-- First chain: two HOT-indexed updates, then prune to a data redirect while +-- leaving the stale btree leaves in place (INDEX_CLEANUP off). +UPDATE hi_recollapse SET a = 2 WHERE id = 1; +UPDATE hi_recollapse SET a = 3 WHERE id = 1; +VACUUM (INDEX_CLEANUP off) hi_recollapse; +-- Re-collapse: more HOT-indexed updates extend the chain past the redirect +-- target; the next prune re-points the data redirect at the new first live +-- tuple and extends its union. +UPDATE hi_recollapse SET a = 4 WHERE id = 1; +UPDATE hi_recollapse SET a = 5 WHERE id = 1; +VACUUM (INDEX_CLEANUP off) hi_recollapse; +-- Full vacuum now reclaims the dead chain; the re-pointed redirect must not +-- dangle. Two passes also exercise the redirect re-point second pass. +VACUUM hi_recollapse; +VACUUM hi_recollapse; +-- Heap must be structurally consistent (no rows == no corruption). +SELECT * FROM verify_heapam('hi_recollapse'); +SET enable_seqscan = off; +SELECT id, a FROM hi_recollapse WHERE a = 5; -- current value -> id 1 +SELECT count(*) FROM hi_recollapse WHERE a = 3; -- obsolete value -> 0 +RESET enable_seqscan; +SELECT id, a FROM hi_recollapse ORDER BY id; +DROP TABLE hi_recollapse; + +-- --------------------------------------------------------------------------- +-- 20. Index deletion over an entry that points at a data-redirect root +-- +-- A data redirect is an LP_REDIRECT that carries a bitmap, so it reports +-- lp_len > 0 (ItemIdHasStorage true) even though it is not a normal tuple. +-- index_delete_check_htid must treat it as a redirect, not read its blob as a +-- HeapTupleHeader. Reproduce: collapse a chain root to a data redirect while +-- keeping the stale leaf that points at it (INDEX_CLEANUP off), then insert +-- many duplicates of the stale key so btree bottom-up deletion runs +-- heap_index_delete_tuples over that stale entry. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_iddel (id int, a int) WITH (fillfactor = 50, autovacuum_enabled = false); +CREATE INDEX hi_iddel_a ON hi_iddel(a); +INSERT INTO hi_iddel VALUES (1, 1); +UPDATE hi_iddel SET a = a + 1 WHERE id = 1; -- HOT-indexed +UPDATE hi_iddel SET a = a + 1 WHERE id = 1; -- multi-hop chain +VACUUM (INDEX_CLEANUP off) hi_iddel; -- root -> data redirect, keep stale a=1 leaf +-- Many duplicates of the stale key fill the leaf and trigger bottom-up +-- deletion, which feeds the stale a=1 entry (htid -> the data-redirect root) +-- to heap_index_delete_tuples. Must not crash or misread the blob. +INSERT INTO hi_iddel SELECT g, 1 FROM generate_series(2, 3000) g; +VACUUM hi_iddel; +SELECT * FROM verify_heapam('hi_iddel'); +SET enable_seqscan = off; +SELECT id, a FROM hi_iddel WHERE id = 1; -- current value -> a = 3 +RESET enable_seqscan; +DROP TABLE hi_iddel; + +-- --------------------------------------------------------------------------- +-- 21. A change to a column covered by a non-btree index AM is HOT-indexed +-- +-- A HOT-indexed update leaves a stale pre-update leaf that the read side +-- filters via the crossed-attribute bitmap, which is access-method agnostic. +-- A column covered by a non-btree index (here a GiST index on a point column) +-- is therefore HOT-indexed like any other, and the GiST index still returns +-- correct results across the chain. A change to a btree-only column on the +-- same table is likewise HOT-indexed. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_nonbtree (id int PRIMARY KEY, tag int, p point) + WITH (fillfactor = 10); +CREATE INDEX hi_nonbtree_tag ON hi_nonbtree (tag); -- btree index +CREATE INDEX hi_nonbtree_p ON hi_nonbtree USING gist (p); -- GiST, non-btree +INSERT INTO hi_nonbtree SELECT g, g, point(g, g) + FROM generate_series(1, 200) g; + +-- Change the GiST-covered column first: HOT-indexed (hot_idx = 200). +UPDATE hi_nonbtree SET p = point(p[0] + 1000, p[1] + 1000); +SELECT hot_idx AS gist_col_hot_indexed FROM get_hi_count('hi_nonbtree'); + +-- The GiST index must return correct results: the old positions are gone and +-- every row is found at its new position (no stale leaf surfaces an old key). +SET enable_seqscan = off; +SELECT count(*) AS at_old_positions + FROM hi_nonbtree WHERE p <@ box(point(0, 0), point(300, 300)); +SELECT count(*) AS at_new_positions + FROM hi_nonbtree WHERE p <@ box(point(1000, 1000), point(1300, 1300)); +RESET enable_seqscan; + +-- Changing the btree-only column (p unchanged) stays HOT-indexed. +UPDATE hi_nonbtree SET tag = tag + 1000; +SELECT hot_idx > 0 AS btree_col_is_hot_indexed FROM get_hi_count('hi_nonbtree'); + +-- A distance-ordered (KNN) GiST scan uses the reorder scan path +-- (IndexNextWithReorder), a different code path from the range scan above. +-- It must also drop stale HOT-indexed leaves: after the p-update the only +-- live positions are the new ones, so the nearest neighbours of the new +-- origin must all be at the new positions and none at the old ones. +SET enable_seqscan = off; +SELECT count(*) AS knn_all_at_new_positions FROM ( + SELECT p FROM hi_nonbtree ORDER BY p <-> point(1000, 1000) LIMIT 200 +) s WHERE p <@ box(point(1000, 1000), point(1300, 1300)); +SELECT count(*) AS knn_none_at_old_positions FROM ( + SELECT p FROM hi_nonbtree ORDER BY p <-> point(0, 0) LIMIT 200 +) s WHERE p <@ box(point(0, 0), point(300, 300)); +RESET enable_seqscan; +DROP TABLE hi_nonbtree; + +-- --------------------------------------------------------------------------- +-- 22. ABA on a unique key across two distinct live rows: a key cycled away +-- and back must still collide with another row that holds it. The stale +-- leaves left by the cycle must not let a genuine duplicate slip past the +-- uniqueness check -- the read-side recheck compares the live key, not just +-- a changed-attribute bitmap. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_aba (k int, v int) WITH (fillfactor = 50); +CREATE UNIQUE INDEX hi_aba_k ON hi_aba (k); +CREATE INDEX hi_aba_v ON hi_aba (v); +INSERT INTO hi_aba VALUES (1, 10), (2, 20); + +-- Cycle row1's unique key 1 -> 3 -> 1 (v unchanged, so each step is +-- HOT-indexed and leaves stale entries in hi_aba_k). +UPDATE hi_aba SET k = 3 WHERE v = 10; +UPDATE hi_aba SET k = 1 WHERE v = 10; +SELECT hot_idx > 0 AS cycled_hot_indexed FROM get_hi_count('hi_aba'); + +-- row1 is live at k = 1 again. Moving row2 onto k = 1 must raise a unique +-- violation despite the stale '1' leaves from the cycle. +UPDATE hi_aba SET k = 1 WHERE v = 20; +DROP TABLE hi_aba; + +-- --------------------------------------------------------------------------- +-- 23. Partial index whose predicate references a non-key column. Flipping the +-- row out of the predicate while leaving the indexed key unchanged is +-- HOT-indexed: the predicate column is part of the index's attribute set, so +-- the crossed-attribute bitmap drops the now-stale partial-index entry on read +-- (no value recheck is involved). +-- --------------------------------------------------------------------------- +CREATE TABLE hi_partpred (id int PRIMARY KEY, k int, active boolean) + WITH (fillfactor = 50); +CREATE INDEX hi_partpred_k ON hi_partpred (k) WHERE active; +INSERT INTO hi_partpred VALUES (1, 100, true); + +-- Flip the predicate column 'active' true -> false; the index key k is +-- unchanged. The row no longer satisfies the predicate, so its partial-index +-- entry must be removed, not left pointing into the chain. +UPDATE hi_partpred SET active = false WHERE id = 1; +SELECT pg_stat_force_next_flush(); +SELECT hot, hot_idx FROM get_hi_count('hi_partpred'); + +-- The partial index must not surface the row now that active = false. +-- A query whose qual exactly matches the partial predicate uses the index +-- without re-filtering 'active' on the heap, so a stale entry would surface. +SET enable_seqscan = off; +SET enable_bitmapscan = off; +EXPLAIN (COSTS OFF) SELECT id FROM hi_partpred WHERE active; +SELECT id FROM hi_partpred WHERE k = 100 AND active; +SELECT id FROM hi_partpred WHERE active; +RESET enable_bitmapscan; +RESET enable_seqscan; +DROP TABLE hi_partpred; + +-- --------------------------------------------------------------------------- +-- 24. Reclaim + stub mix. Repeated updates of column a followed by an update +-- of column b build a chain whose prune reclaims the members whose change was +-- superseded (a changed again) and keeps stubs for those that were not, so a +-- root redirect ends up pointing at a stub and a later walk crosses mid-chain +-- stubs. Reads through each index and amcheck must stay correct across the +-- collapse, and a second round must walk the existing stubs without severing +-- the chain. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_stubmix (id int PRIMARY KEY, a int, b int) WITH (fillfactor = 50, autovacuum_enabled = false); +CREATE INDEX hi_stubmix_a ON hi_stubmix (a); +CREATE INDEX hi_stubmix_b ON hi_stubmix (b); +INSERT INTO hi_stubmix VALUES (1, 10, 100); +UPDATE hi_stubmix SET a = 11 WHERE id = 1; -- changes a +UPDATE hi_stubmix SET a = 12 WHERE id = 1; -- changes a again (supersedes) +UPDATE hi_stubmix SET b = 101 WHERE id = 1; -- changes b +VACUUM hi_stubmix; +SET enable_seqscan = off; +SET enable_bitmapscan = off; +SELECT id, a, b FROM hi_stubmix WHERE a = 12; -- current a +SELECT id, a, b FROM hi_stubmix WHERE b = 101; -- current b +SELECT id FROM hi_stubmix WHERE a = 10; -- stale a: 0 rows +RESET enable_bitmapscan; +RESET enable_seqscan; +SELECT * FROM verify_heapam('hi_stubmix'); -- no corruption across stubs +-- A second round must walk the existing stubs (no priorXmax sever). +UPDATE hi_stubmix SET a = 13 WHERE id = 1; +VACUUM hi_stubmix; +SELECT id, a, b FROM hi_stubmix WHERE a = 13; +SELECT * FROM verify_heapam('hi_stubmix'); +DROP TABLE hi_stubmix; + +-- --------------------------------------------------------------------------- +-- 25. Exclusion-constraint tables are HOT-indexed-eligible. +-- +-- An exclusion constraint is enforced by check_exclusion_or_unique_constraint, +-- which rechecks each candidate against the live tuple's current index-form +-- with the constraint's own operators, so a stale entry left by a HOT-indexed +-- update is skipped while the live key always has its own entry. Updating a +-- non-constrained indexed column is HOT-indexed (the GiST exclusion index is +-- skipped), and the constraint stays correct. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_excl ( + id int PRIMARY KEY, + tag int, + during int4range, + EXCLUDE USING gist (during WITH &&) +) WITH (fillfactor = 10); +CREATE INDEX hi_excl_tag ON hi_excl(tag); +INSERT INTO hi_excl VALUES (1, 100, int4range(1, 10)), (2, 200, int4range(20, 30)); + +-- Update a non-constrained indexed column: HOT-indexed (GiST exclusion index +-- and PK skipped), and the exclusion constraint is still enforced. +UPDATE hi_excl SET tag = tag + 1 WHERE id = 1; +SELECT hot_idx > 0 AS tag_update_hot_indexed FROM get_hi_count('hi_excl'); +INSERT INTO hi_excl VALUES (3, 300, int4range(5, 15)); -- overlaps id=1's (1,10) + +-- Move id=1's range away (this updates the GiST index, leaving a stale entry +-- for the old (1,10) range). A range overlapping only the OLD range now +-- inserts cleanly (the stale entry is skipped); one overlapping the NEW range +-- still conflicts. +UPDATE hi_excl SET during = int4range(100, 110) WHERE id = 1; +INSERT INTO hi_excl VALUES (4, 400, int4range(5, 15)); -- only overlapped old range: OK +INSERT INTO hi_excl VALUES (5, 500, int4range(105, 115));-- overlaps new (100,110): conflict +DROP TABLE hi_excl; + +-- --------------------------------------------------------------------------- +-- 26. TOAST interaction. An indexed column stored out-of-line must behave +-- correctly across HOT-indexed updates: an entry kept across an update of a +-- different column still resolves to the (unchanged) toasted value, and after +-- the toasted column itself is changed the stale entry is dropped by the +-- crossed-attribute bitmap (no value comparison or detoasting is needed). +-- --------------------------------------------------------------------------- +CREATE TABLE hi_toast (id int PRIMARY KEY, big text, tag int) WITH (fillfactor = 50); +ALTER TABLE hi_toast ALTER COLUMN big SET STORAGE EXTERNAL; -- no compression +CREATE INDEX hi_toast_big ON hi_toast (big); +CREATE INDEX hi_toast_tag ON hi_toast (tag); +INSERT INTO hi_toast VALUES (1, repeat('A', 2000), 10); +-- The big value is stored out-of-line. +SELECT pg_column_size(big) > 1500 AS big_is_external FROM hi_toast WHERE id = 1; +-- HOT-indexed update of tag leaves big (and its index entry) unchanged. +UPDATE hi_toast SET tag = 11 WHERE id = 1; +SELECT hot_idx > 0 AS tag_update_hot_indexed FROM get_hi_count('hi_toast'); +SET enable_seqscan = off; +SET enable_bitmapscan = off; +SELECT id, tag, length(big) FROM hi_toast WHERE big = repeat('A', 2000); +-- HOT-indexed update of the toasted indexed column itself: the old entry is +-- now stale because the crossed-attribute bitmap shows big changed. +UPDATE hi_toast SET big = repeat('B', 2000) WHERE id = 1; +SELECT id FROM hi_toast WHERE big = repeat('A', 2000); -- stale: 0 rows +SELECT id, length(big) FROM hi_toast WHERE big = repeat('B', 2000); -- current +RESET enable_bitmapscan; +RESET enable_seqscan; +SELECT * FROM verify_heapam('hi_toast'); +DROP TABLE hi_toast; + +-- --------------------------------------------------------------------------- +-- 27. ABA on an indexed column. A HOT-indexed update that sets an indexed +-- column to a value an earlier chain member already held leaves two leaves +-- with that same key, both chain-resolving to the live tuple. A value-based +-- recheck cannot tell them apart and would return the row twice; the +-- crossed-attribute bitmap drops the stale ancestor leaf (its walk crosses the +-- key-changing hops) and keeps only the fresh entry, so a forced index scan +-- returns the row exactly once. REINDEX must not change that. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_aba (id int PRIMARY KEY, k int, v int) WITH (fillfactor = 50); +CREATE INDEX hi_aba_k ON hi_aba (k); +CREATE INDEX hi_aba_v ON hi_aba (v); +INSERT INTO hi_aba VALUES (1, 1, 100); +UPDATE hi_aba SET k = 3 WHERE id = 1; -- HOT-indexed: k changed, v kept +UPDATE hi_aba SET k = 1 WHERE id = 1; -- HOT-indexed: k cycled back (ABA) +SET enable_seqscan = off; +SET enable_bitmapscan = off; +SELECT count(*) AS k1_once FROM hi_aba WHERE k = 1; -- exactly 1 +SELECT count(*) AS k3_gone FROM hi_aba WHERE k = 3; -- 0 (stale dropped) +REINDEX INDEX hi_aba_k; +SELECT count(*) AS k1_after_reindex FROM hi_aba WHERE k = 1; -- still 1 +RESET enable_bitmapscan; +RESET enable_seqscan; +SELECT * FROM verify_heapam('hi_aba'); +DROP TABLE hi_aba; + +-- --------------------------------------------------------------------------- +-- 28. Partial index, predicate column changed but the row STAYS in the index +-- (predicate still true, key unchanged). The update is HOT-indexed; selective +-- maintenance re-inserts a fresh entry (the predicate column changed and still +-- holds), so the row is still returned -- the bitmap drops the older entry and +-- the fresh one re-supplies it. Guards against a "lost row" from over-eager +-- dropping. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_partstay (id int PRIMARY KEY, k int, n int) WITH (fillfactor = 50); +CREATE INDEX hi_partstay_k ON hi_partstay (k) WHERE n > 0; +CREATE INDEX hi_partstay_id2 ON hi_partstay (id); +INSERT INTO hi_partstay VALUES (1, 5, 3); +UPDATE hi_partstay SET n = 7 WHERE id = 1; -- n 3->7, still > 0, k unchanged +SELECT pg_stat_force_next_flush(); +SELECT hot_idx > 0 AS stay_is_hot_indexed FROM get_hi_count('hi_partstay'); +SET enable_seqscan = off; +SET enable_bitmapscan = off; +SELECT count(*) AS stay_rows FROM hi_partstay WHERE k = 5 AND n > 0; -- want 1 +RESET enable_bitmapscan; +RESET enable_seqscan; +DROP TABLE hi_partstay; + +-- --------------------------------------------------------------------------- +-- 29. Partitioned table. A within-partition UPDATE of one indexed column is +-- HOT-indexed on the leaf partition's heap exactly as for a non-partitioned +-- table; a cross-partition update is a delete+insert and never HOT. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_part (id int, a int, b int) PARTITION BY RANGE (id); +CREATE TABLE hi_part1 PARTITION OF hi_part FOR VALUES FROM (0) TO (100) + WITH (fillfactor = 50); +CREATE INDEX hi_part_a ON hi_part (a); +CREATE INDEX hi_part_b ON hi_part (b); +INSERT INTO hi_part VALUES (1, 10, 20); +UPDATE hi_part SET a = 11 WHERE id = 1; -- one indexed col, within partition +SELECT pg_stat_force_next_flush(); +SELECT hot_idx > 0 AS part_is_hot_indexed FROM get_hi_count('hi_part1'); +SET enable_seqscan = off; +SET enable_bitmapscan = off; +SELECT count(*) AS a11 FROM hi_part WHERE a = 11; -- want 1 +SELECT count(*) AS a10 FROM hi_part WHERE a = 10; -- want 0 (stale dropped) +RESET enable_bitmapscan; +RESET enable_seqscan; +SELECT * FROM verify_heapam('hi_part1'); +DROP TABLE hi_part; + +-- --------------------------------------------------------------------------- +-- 30. Non-btree access method (hash). Read-side staleness is access-method +-- agnostic (the crossed-attribute bitmap), so any index AM's column is +-- HOT-indexed. Hash is the sharpest case: its scans recheck the heap value, +-- which alone cannot disambiguate a value cycled away and back (ABA) -- the +-- bitmap drops the stale ancestor so the row is returned exactly once. +-- --------------------------------------------------------------------------- +CREATE TABLE hi_hash (id int PRIMARY KEY, v int, w int) WITH (fillfactor = 50); +CREATE INDEX hi_hash_v ON hi_hash USING hash (v); +CREATE INDEX hi_hash_w ON hi_hash (w); +INSERT INTO hi_hash VALUES (1, 10, 100); +UPDATE hi_hash SET v = 99 WHERE id = 1; +UPDATE hi_hash SET v = 10 WHERE id = 1; -- ABA: 10 -> 99 -> 10, w unchanged +SELECT pg_stat_force_next_flush(); +SELECT hot_idx > 0 AS hash_is_hot_indexed FROM get_hi_count('hi_hash'); +SET enable_seqscan = off; +SET enable_bitmapscan = off; +SELECT count(*) AS hash_v10 FROM hi_hash WHERE v = 10; -- want 1 (no duplicate) +SELECT count(*) AS hash_v99 FROM hi_hash WHERE v = 99; -- want 0 (stale dropped) +RESET enable_bitmapscan; +RESET enable_seqscan; +DROP TABLE hi_hash; + +-- --------------------------------------------------------------------------- +-- 31. DDL after a HOT-indexed chain exists. The per-hop modified-attrs +-- bitmap on the page is keyed by physical attribute number and sized by the +-- relation's natts AT WRITE TIME. Indexes added/dropped after the chain +-- forms, and ADD/DROP COLUMN, must not corrupt the read-side staleness test. +-- The sharp case is ADD COLUMN crossing an 8-attribute boundary, which grows +-- ceil(natts/8): readers must locate each hop's bitmap from that hop's own +-- write-time natts (HeapTupleHeaderGetNatts / the stub's stashed natts), not +-- the relation's current natts. +-- --------------------------------------------------------------------------- +-- Exactly 8 attributes (c1..c7 + payload) so adding the 9th flips the bitmap +-- from 1 byte to 2. c7 is the column we churn; c2 is an unchanged indexed +-- column whose leaf must stay current. +CREATE TABLE hi_ddl ( + c1 int PRIMARY KEY, c2 int, c3 int, c4 int, + c5 int, c6 int, c7 int, payload text +) WITH (fillfactor = 50, autovacuum_enabled = false); +CREATE INDEX hi_ddl_c2 ON hi_ddl(c2); +CREATE INDEX hi_ddl_c7 ON hi_ddl(c7); +INSERT INTO hi_ddl VALUES (1, 10, 20, 30, 40, 50, 70, 'p'); + +-- Form a HOT-indexed chain on c7 BEFORE any further DDL. +UPDATE hi_ddl SET c7 = 71 WHERE c1 = 1; +UPDATE hi_ddl SET c7 = 72 WHERE c1 = 1; + +-- (a) CREATE INDEX after the chain exists: the new index is built against the +-- live tuple under its own TID, so its entry is never stale. +CREATE INDEX hi_ddl_c3 ON hi_ddl(c3); + +-- (b) ADD COLUMN crossing the 8-attribute boundary (natts 8 -> 9). Existing +-- hops keep their 1-byte bitmaps; the relation now wants 2. Reads through the +-- old chain must still be correct. +ALTER TABLE hi_ddl ADD COLUMN c9 int; +CREATE INDEX hi_ddl_c9 ON hi_ddl(c9); + +SET enable_seqscan = off; +SET enable_bitmapscan = off; +SET enable_indexonlyscan = off; + +-- Live c7 is 72. The c7 index must return the live row for 72 and drop the +-- stale leaves for 70 and 71 (offsets misread would corrupt this). +SELECT count(*) AS c7_eq_72 FROM hi_ddl WHERE c7 = 72 AND payload IS NOT NULL; +SELECT count(*) AS c7_eq_70_stale FROM hi_ddl WHERE c7 = 70 AND payload IS NOT NULL; +SELECT count(*) AS c7_eq_71_stale FROM hi_ddl WHERE c7 = 71 AND payload IS NOT NULL; + +-- c2 never changed across the chain: its leaf must NOT be judged stale even +-- though a crossed hop changed c7. A misread bitmap could spuriously flag it. +SELECT count(*) AS c2_eq_10_current FROM hi_ddl WHERE c2 = 10 AND payload IS NOT NULL; + +-- (c) Continue churning c7 AFTER the ADD COLUMN: the new hop's bitmap is sized +-- for natts 9 (2 bytes); the old hops are 1 byte. A chain with mixed-size +-- bitmaps must still resolve correctly. +UPDATE hi_ddl SET c7 = 73 WHERE c1 = 1; +SELECT count(*) AS c7_eq_73 FROM hi_ddl WHERE c7 = 73 AND payload IS NOT NULL; +SELECT count(*) AS c7_eq_72_now_stale FROM hi_ddl WHERE c7 = 72 AND payload IS NOT NULL; + +-- (d) Collapse the chain to stubs via VACUUM, then read again: the stub must +-- preserve its write-time natts so its bitmap stays locatable post-ADD COLUMN. +UPDATE hi_ddl SET c7 = 74 WHERE c1 = 1; +VACUUM (INDEX_CLEANUP off) hi_ddl; +SELECT count(*) AS c7_eq_74_after_vacuum FROM hi_ddl WHERE c7 = 74 AND payload IS NOT NULL; +SELECT count(*) AS c2_eq_10_after_vacuum FROM hi_ddl WHERE c2 = 10 AND payload IS NOT NULL; + +-- (e) DROP COLUMN keeps the attnum slot (no renumber), so bitmaps stay aligned. +ALTER TABLE hi_ddl DROP COLUMN c4; +SELECT count(*) AS c7_after_drop FROM hi_ddl WHERE c7 = 74 AND payload IS NOT NULL; +SELECT count(*) AS c2_after_drop FROM hi_ddl WHERE c2 = 10 AND payload IS NOT NULL; + +-- (f) DROP INDEX on the churned column: remaining indexes still resolve. +DROP INDEX hi_ddl_c7; +SELECT count(*) AS c2_after_dropidx FROM hi_ddl WHERE c2 = 10 AND payload IS NOT NULL; + +RESET enable_seqscan; +RESET enable_bitmapscan; +RESET enable_indexonlyscan; + +-- The seqscan truth confirms the live row; the count assertions above (read +-- through the post-DDL indexes) match it, which is what would break if a +-- mis-sized bitmap corrupted the staleness verdict. +SELECT c1, c2, c7 FROM hi_ddl WHERE c1 = 1; + +DROP TABLE hi_ddl; + +-- --------------------------------------------------------------------------- +-- 32. BitmapAnd/BitmapOr across a changed and an unchanged index +-- +-- Reported by Alexander Korotkov, 2026-07-09. A HOT-indexed update points +-- the CHANGED index's fresh entry at the new heap-only tuple, while the +-- UNCHANGED index's entry still points at the chain root. BitmapAnd/BitmapOr +-- intersect/union two indexes' raw TID sets in the TID-bitmap layer BEFORE +-- either side touches the heap: an exact-mode intersection sees {root} on one +-- side and {new-tuple} on the other and drops the row, even though both +-- resolve, through the chain, to the one live tuple. Bitmap scans tolerate +-- false positives but not false negatives, so this was a correctness bug. +-- +-- Fixed by a one-bit marker in the stored TID's otherwise-unused offset bit +-- 14 (ItemPointerSIUMaybeStaleFlag, storage/itemptr.h), set only on a +-- HOT-indexed fresh entry's own TID (never on tts_tid itself, never on a +-- classic-HOT or plain-insert entry). tbm_add_tuples() -- the single choke +-- point every amgetbitmap funnels exact heap TIDs through -- checks the flag +-- and, when set, adds the whole page as lossy (tbm_add_page) instead of the +-- single exact offset. A lossy page survives any AND/OR against an exact-mode +-- page (per tbm_intersect_page's own case analysis) and forces the existing +-- heap-side crossed-attribute recheck to make the final call. Handling it +-- there means NO index access method needs to know about HOT-indexed chains: +-- btree, hash, GIN, GiST, SP-GiST, contrib/bloom, and any out-of-tree AM are +-- all correct with no AM-specific code. One table per non-btree access method +-- below, each paired with a btree index on the changed column, exercises the +-- changed+unchanged BitmapAnd/BitmapOr shape for every AM SIU uses elsewhere +-- in this file; bloom's equivalent case lives in contrib/bloom's own test +-- (where the extension is guaranteed present). +-- --------------------------------------------------------------------------- +SET enable_seqscan = off; +SET enable_indexscan = off; +SET enable_bitmapscan = on; + +-- (a) btree + btree +CREATE TABLE hi_bmand_bt ( + id int PRIMARY KEY, + c1 int, + c2 int +) WITH (fillfactor = 50); +CREATE INDEX hi_bmand_bt_c1 ON hi_bmand_bt(c1); -- unchanged +CREATE INDEX hi_bmand_bt_c2 ON hi_bmand_bt(c2); -- changed +INSERT INTO hi_bmand_bt VALUES (1, 11, 21); +UPDATE hi_bmand_bt SET c2 = 22 WHERE id = 1; +SELECT count(*) AS bt_bt_bitmapand FROM hi_bmand_bt WHERE c1 = 11 AND c2 = 22; +-- The fresh entry in the changed index (hi_bmand_bt_c2) is flagged: pageinspect +-- reports its real heap offset (the marker is stripped from ctid/htid) and +-- surfaces the marker in the hot_indexed column. The unchanged index's entry +-- is not flagged. (bt_page_items block 1 is the sole leaf for a single row.) +SELECT hot_indexed, count(*) + FROM bt_page_items('hi_bmand_bt_c2', 1) GROUP BY hot_indexed ORDER BY hot_indexed; +SELECT bool_and(NOT hot_indexed) AS unchanged_index_never_flagged + FROM bt_page_items('hi_bmand_bt_c1', 1); +-- amcheck's heapallindexed verification re-derives each live heap tuple's +-- expected index entry and checks the index contains it. For the CHANGED +-- index, a HOT-indexed fresh entry stores that TID with the SIU may-be-stale +-- marker set in the offset; amcheck must strip it when fingerprinting or it +-- would spuriously report "lacks matching index tuple". No VACUUM has run, so +-- the fresh entry points directly at the live heap-only tuple (no stub +-- forwarding), isolating the marker-strip path this commit adds. +-- +-- (Only the changed index is checked here. heapallindexed on an index NOT +-- maintained by a HOT-indexed update -- which by design has no entry for the +-- new heap-only tuple -- is a separate, pre-existing SIU/amcheck-integration +-- question, unrelated to the bit-14 marker, and out of scope for this commit.) +SELECT bt_index_check('hi_bmand_bt_c2', heapallindexed => true); +DROP TABLE hi_bmand_bt; + +-- (b) hash (unchanged) + btree (changed) +CREATE TABLE hi_bmand_hash ( + id int PRIMARY KEY, + c1 int, + c2 int +) WITH (fillfactor = 50); +CREATE INDEX hi_bmand_hash_c1 ON hi_bmand_hash USING hash (c1); +CREATE INDEX hi_bmand_hash_c2 ON hi_bmand_hash(c2); +INSERT INTO hi_bmand_hash VALUES (1, 11, 21); +UPDATE hi_bmand_hash SET c2 = 22 WHERE id = 1; +SELECT count(*) AS hash_bt_bitmapand FROM hi_bmand_hash WHERE c1 = 11 AND c2 = 22; +DROP TABLE hi_bmand_hash; + +-- (c) GIN (unchanged) + btree (changed) +CREATE TABLE hi_bmand_gin ( + id int PRIMARY KEY, + tags int[], + c2 int +) WITH (fillfactor = 50); +CREATE INDEX hi_bmand_gin_tags ON hi_bmand_gin USING gin (tags); +CREATE INDEX hi_bmand_gin_c2 ON hi_bmand_gin(c2); +INSERT INTO hi_bmand_gin VALUES (1, ARRAY[1,2,3], 21); +UPDATE hi_bmand_gin SET c2 = 22 WHERE id = 1; +SELECT count(*) AS gin_bt_bitmapand + FROM hi_bmand_gin WHERE tags @> ARRAY[2] AND c2 = 22; +DROP TABLE hi_bmand_gin; + +-- (d) GiST (unchanged) + btree (changed) +CREATE TABLE hi_bmand_gist ( + id int PRIMARY KEY, + p point, + c2 int +) WITH (fillfactor = 50); +CREATE INDEX hi_bmand_gist_p ON hi_bmand_gist USING gist (p); +CREATE INDEX hi_bmand_gist_c2 ON hi_bmand_gist(c2); +INSERT INTO hi_bmand_gist VALUES (1, point(5, 5), 21); +UPDATE hi_bmand_gist SET c2 = 22 WHERE id = 1; +SELECT count(*) AS gist_bt_bitmapand + FROM hi_bmand_gist WHERE p <@ box(point(0, 0), point(10, 10)) AND c2 = 22; +DROP TABLE hi_bmand_gist; + +-- (e) SP-GiST (unchanged) + btree (changed) +CREATE TABLE hi_bmand_spgist ( + id int PRIMARY KEY, + t text, + c2 int +) WITH (fillfactor = 50); +CREATE INDEX hi_bmand_spgist_t ON hi_bmand_spgist USING spgist (t); +CREATE INDEX hi_bmand_spgist_c2 ON hi_bmand_spgist(c2); +INSERT INTO hi_bmand_spgist VALUES (1, 'hello', 21); +UPDATE hi_bmand_spgist SET c2 = 22 WHERE id = 1; +SELECT count(*) AS spgist_bt_bitmapand + FROM hi_bmand_spgist WHERE t = 'hello' AND c2 = 22; +DROP TABLE hi_bmand_spgist; + +-- (f) BitmapOr: a HOT-indexed fresh entry (btree, changed) unioned with an +-- unrelated unchanged index's entry for a DIFFERENT row must not affect that +-- other row's count, and the SIU row itself must still be found through +-- either arm of the OR. +CREATE TABLE hi_bmor_bt ( + id int PRIMARY KEY, + c1 int, + c2 int +) WITH (fillfactor = 50); +CREATE INDEX hi_bmor_bt_c1 ON hi_bmor_bt(c1); +CREATE INDEX hi_bmor_bt_c2 ON hi_bmor_bt(c2); +INSERT INTO hi_bmor_bt VALUES (1, 11, 21), (2, 12, 32); +UPDATE hi_bmor_bt SET c2 = 22 WHERE id = 1; -- HOT-indexed on row 1 only +SELECT count(*) AS bt_bt_bitmapor + FROM hi_bmor_bt WHERE c1 = 11 OR c2 = 32; -- row 1 via c1, row 2 via c2 +DROP TABLE hi_bmor_bt; + +RESET enable_seqscan; +RESET enable_indexscan; +RESET enable_bitmapscan; + +-- --------------------------------------------------------------------------- +-- 33. Heap rewrite (CLUSTER / VACUUM FULL) over live HOT-indexed chains +-- +-- CLUSTER and VACUUM FULL rewrite the heap and rebuild every index, flattening +-- each HOT chain: each live tuple becomes a standalone tuple in the new heap +-- with a fresh, direct index entry, so no rewritten tuple is a HOT-indexed +-- (SIU) chain member. The rewrite (reform_tuple) must therefore clear +-- HEAP_INDEXED_UPDATED on the copied tuple; leaving it set would leave a +-- standalone tuple carrying the marker (and a now-meaningless inline +-- modified-attrs bitmap), so a reader reaching it through the rebuilt index +-- would run the read-side staleness test against garbage and wrongly drop the +-- row. Regression guard: without the clear, a=203 (and, for CLUSTER over the +-- SIU secondary index, live rows) went missing via index after the rewrite +-- while the heap still held them. +-- --------------------------------------------------------------------------- +-- (a) VACUUM FULL, including the single-secondary-index case that made any +-- SIU-updated table return wrong index results after a routine VACUUM FULL. +CREATE TABLE hi_rewrite (id int PRIMARY KEY, a int, payload text) + WITH (fillfactor = 50); +CREATE INDEX hi_rewrite_a ON hi_rewrite(a); +INSERT INTO hi_rewrite SELECT g, g, repeat('x', 20) FROM generate_series(1, 20) g; +UPDATE hi_rewrite SET a = a + 100 WHERE id <= 10; -- HOT-indexed on a +UPDATE hi_rewrite SET a = a + 100 WHERE id <= 5; -- again -> longer chains +-- Row id=3 has a = 203 after two HOT-indexed updates. +SELECT count(*) AS seqcount_before FROM hi_rewrite; + +VACUUM FULL hi_rewrite; + +SELECT count(*) AS seqcount_after_vf FROM hi_rewrite; -- no rows lost +SET enable_seqscan = off; +SET enable_indexscan = on; +SET enable_bitmapscan = off; +-- Every current value is findable through the rebuilt index... +SELECT a AS a203_via_index_after_vf FROM hi_rewrite WHERE a = 203; +SELECT count(*) AS all_findable_via_index_after_vf + FROM hi_rewrite WHERE a BETWEEN 1 AND 100000; +-- ...and no superseded (stale) value surfaces. +SELECT count(*) AS stale_a_after_vf FROM hi_rewrite WHERE a IN (3, 103); +RESET enable_seqscan; +RESET enable_indexscan; +RESET enable_bitmapscan; +SELECT * FROM verify_heapam('hi_rewrite'); +SELECT bt_index_check('hi_rewrite_a', heapallindexed => true); +DROP TABLE hi_rewrite; + +-- (b) CLUSTER using the SIU-updated secondary index (the ordering scan walks +-- the chains) and a second CLUSTER by heap order; row and value counts must be +-- preserved and every value findable through the index afterward. +CREATE TABLE hi_cluster (id int PRIMARY KEY, a int, b int, payload text) + WITH (fillfactor = 40); +CREATE INDEX hi_cluster_a ON hi_cluster(a); +CREATE INDEX hi_cluster_b ON hi_cluster(b); +INSERT INTO hi_cluster + SELECT g, g, g * 10, repeat('x', 20) FROM generate_series(1, 30) g; +UPDATE hi_cluster SET a = a + 100 WHERE id % 2 = 0; -- HOT-indexed on a +UPDATE hi_cluster SET b = b + 500 WHERE id % 3 = 0; -- HOT-indexed on b +UPDATE hi_cluster SET a = a - 100 WHERE id = 6; -- cycle a back (ABA) +-- seqscan truth (sums are invariant across any lossless rewrite). +SELECT count(*) AS n_before, sum(a) AS suma_before, sum(b) AS sumb_before + FROM hi_cluster; + +CLUSTER hi_cluster USING hi_cluster_a; + +SELECT count(*) AS n_after_cluster, sum(a) AS suma_after, sum(b) AS sumb_after + FROM hi_cluster; +SET enable_seqscan = off; +SET enable_indexscan = on; +SET enable_bitmapscan = off; +SELECT count(*) AS a_all_findable + FROM hi_cluster WHERE a BETWEEN 1 AND 100000; +SELECT count(*) AS b_all_findable + FROM hi_cluster WHERE b BETWEEN 1 AND 100000; +RESET enable_seqscan; +RESET enable_indexscan; +RESET enable_bitmapscan; +SELECT bt_index_check('hi_cluster_a', heapallindexed => true); +SELECT bt_index_check('hi_cluster_b', heapallindexed => true); +SELECT * FROM verify_heapam('hi_cluster'); +DROP TABLE hi_cluster; + +-- (c) CLUSTER USING a secondary index when a row's clustering-column value was +-- SIU-changed (or ABA-cycled) in an EARLIER hop and a DIFFERENT indexed column +-- changed in the LAST hop, with the chain left UNCOLLAPSED (no VACUUM before +-- the CLUSTER). This is the shape that silently lost rows: the clustering +-- index has no entry pointing directly at the live tuple (its entries address +-- earlier chain members, and no fresh clustering-index entry was planted +-- because the last hop changed a different column), so CLUSTER's index-scan +-- copy path -- running under SnapshotAny, before any prune/collapse inserts a +-- redirect/stub that would let it chain-walk to the live tuple -- never +-- reached the row and dropped it from the rewritten heap. A lossless rewrite +-- must preserve every live row regardless of which index it clusters by, so +-- the fix (repack.c) forces the seqscan+sort copy path for any heap with more +-- than one index (SIU-capable), the same path VACUUM FULL already uses. +-- +-- NB: do NOT VACUUM before the CLUSTER here, and keep a non-roomy fillfactor: +-- a chain collapse or a roomier page both mask the bug (they change what the +-- index scan can reach), so this test deliberately reproduces the losing +-- conditions. +CREATE TABLE hi_cluster_lasthop (id int PRIMARY KEY, a int, b int) + WITH (fillfactor = 40, autovacuum_enabled = false); +CREATE INDEX hi_cl_lh_a ON hi_cluster_lasthop(a); +CREATE INDEX hi_cl_lh_b ON hi_cluster_lasthop(b); +INSERT INTO hi_cluster_lasthop + SELECT g, g, g FROM generate_series(1, 20) g; +-- ids 1..10: change the clustering column a and change it back (ABA on a) +UPDATE hi_cluster_lasthop SET a = a + 1000 WHERE id <= 10; +UPDATE hi_cluster_lasthop SET a = a - 1000 WHERE id <= 10; +-- ids 5..15: LAST hop changes b (a different indexed column), a unchanged here +UPDATE hi_cluster_lasthop SET b = b + 100 WHERE id BETWEEN 5 AND 15; +SELECT n_hot_indexed >= 1 AS siu_chains_formed + FROM pg_relation_hot_indexed_stats('hi_cluster_lasthop'); +-- ground truth via seqscan: 20 live rows (chains NOT collapsed). +SET enable_indexscan = off; +SET enable_bitmapscan = off; +SELECT count(*) AS n_before FROM hi_cluster_lasthop; +RESET enable_indexscan; +RESET enable_bitmapscan; + +CLUSTER hi_cluster_lasthop USING hi_cl_lh_a; + +-- All rows must survive (seqscan, reads the heap directly). +SET enable_indexscan = off; +SET enable_bitmapscan = off; +SELECT count(*) AS n_after_cluster_a FROM hi_cluster_lasthop; +SELECT string_agg(id::text, ',' ORDER BY id) AS missing_ids + FROM generate_series(1, 20) id + WHERE id NOT IN (SELECT id FROM hi_cluster_lasthop); +RESET enable_indexscan; +RESET enable_bitmapscan; +-- ...and every row is findable through the clustering index afterward. +SET enable_seqscan = off; +SET enable_indexscan = on; +SET enable_bitmapscan = off; +SELECT count(*) AS all_findable_via_a + FROM hi_cluster_lasthop WHERE a BETWEEN 1 AND 100000; +RESET enable_seqscan; +RESET enable_indexscan; +RESET enable_bitmapscan; +SELECT bt_index_check('hi_cl_lh_a', heapallindexed => true); + +-- Same losing shape, but CLUSTER by a NON-btree clusterable index (GiST). The +-- fix lives in the heap AM's copy-for-cluster path and is agnostic to the +-- clustering index's access method, so this must preserve every row too -- a +-- guard that a btree-only fix (or one that touched index AMs) would miss. A +-- GiST clustering index has no sort path, so the heap AM falls back to a plain +-- seqscan copy; order is not preserved, but no live row is lost. +CREATE EXTENSION IF NOT EXISTS btree_gist; +CREATE TABLE hi_cluster_gist (id int PRIMARY KEY, a int, b int) + WITH (fillfactor = 40, autovacuum_enabled = false); +CREATE INDEX hi_cl_g_a ON hi_cluster_gist USING gist (a); +CREATE INDEX hi_cl_g_b ON hi_cluster_gist (b); +INSERT INTO hi_cluster_gist + SELECT g, g, g FROM generate_series(1, 20) g; +UPDATE hi_cluster_gist SET a = a + 1000 WHERE id <= 10; +UPDATE hi_cluster_gist SET a = a - 1000 WHERE id <= 10; +UPDATE hi_cluster_gist SET b = b + 100 WHERE id BETWEEN 5 AND 15; + +CLUSTER hi_cluster_gist USING hi_cl_g_a; + +SET enable_indexscan = off; +SET enable_bitmapscan = off; +SELECT count(*) AS n_after_cluster_gist FROM hi_cluster_gist; +SELECT string_agg(id::text, ',' ORDER BY id) AS missing_ids_gist + FROM generate_series(1, 20) id + WHERE id NOT IN (SELECT id FROM hi_cluster_gist); +RESET enable_indexscan; +RESET enable_bitmapscan; +DROP TABLE hi_cluster_gist; +SELECT * FROM verify_heapam('hi_cluster_lasthop'); +DROP TABLE hi_cluster_lasthop; + +-- --------------------------------------------------------------------------- +-- Cleanup +-- --------------------------------------------------------------------------- +DROP FUNCTION get_hi_count(text); +DROP FUNCTION get_hot_count(text); +-- pageinspect and amcheck were both created above with IF NOT EXISTS and may +-- have pre-existed this test; leave them, matching amcheck's treatment, +-- rather than risk dropping an extension this test did not create. diff --git a/contrib/pageinspect/sql/hot_updates.sql b/contrib/pageinspect/sql/hot_updates.sql new file mode 100644 index 0000000000000..919e0939d7cde --- /dev/null +++ b/contrib/pageinspect/sql/hot_updates.sql @@ -0,0 +1,399 @@ +-- +-- HOT_UPDATES +-- Test classic Heap-Only Tuple (HOT) update decisions +-- +-- This file covers HOT decisions that apply identically on a pre-hot-indexed +-- server: every UPDATE here either leaves all indexed attributes +-- unchanged or touches only summarizing-index (BRIN) attributes, so the +-- HOT vs non-HOT choice does not depend on whether Selective Index +-- Update (hot-indexed) is enabled. hot-indexed-specific behaviour (UPDATEs that modify +-- a non-summarizing indexed attribute) is covered in +-- hot_indexed_updates.sql. +-- +-- Validation methods: +-- 1. Statistics (pg_stat_get_tuples_hot_updated) +-- 2. pageinspect for HOT chain structure +-- 3. EXPLAIN to confirm the planner still picks the index +-- + +-- Load required extensions +CREATE EXTENSION IF NOT EXISTS pageinspect; + +-- Sum of committed and in-progress (non-HOT, HOT) update counters. +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; + 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 target_ctid is the TAIL of a HOT chain on the 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; + 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; + +-- Emit the HOT chain rooted at start_ctid. +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; + + 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 NOT found_predecessor THEN + current_ctid := start_ctid; + END IF; + + 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 + 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; + + +-- --------------------------------------------------------------------------- +-- 1. Basic HOT: update of a non-indexed column +-- --------------------------------------------------------------------------- +CREATE TABLE hot_test ( + id int PRIMARY KEY, + indexed_col int, + non_indexed_col text +) 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'); + +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; + +-- --------------------------------------------------------------------------- +-- 2. 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; + +-- --------------------------------------------------------------------------- +-- 3. 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); + +INSERT INTO hot_test VALUES (1, 100, repeat('x', 3000), 'small'); + +-- 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', 3000); +SELECT pg_stat_force_next_flush(); +SELECT * FROM get_hot_count('hot_test'); + +DROP TABLE hot_test; + +-- --------------------------------------------------------------------------- +-- 4. 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; + +-- --------------------------------------------------------------------------- +-- 5. 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; + +-- --------------------------------------------------------------------------- +-- 6. 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; + +-- --------------------------------------------------------------------------- +-- 7. 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 pg_stat_force_next_flush(); +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; + +-- --------------------------------------------------------------------------- +-- 8. JSONB expression index: non-indexed path change is HOT +-- --------------------------------------------------------------------------- +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"}'); + +-- The jsonb column is the expression index's input, so HOT-indexed is +-- disqualified (expression indexes are not yet supported) and the jsonb +-- change blocks classic HOT: non-HOT update. +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'); + +-- Likewise non-HOT: expression index disqualifies HOT-indexed. +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'); + +-- Likewise non-HOT: expression index disqualifies HOT-indexed. +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; + +-- --------------------------------------------------------------------------- +-- 9. A change to a GIN-indexed column is HOT-indexed +-- +-- The read side filters a stale leaf via the crossed-attribute bitmap, which +-- is access-method agnostic, so a GIN-covered column is HOT-indexed like any +-- other: only the GIN index is maintained, and a GIN scan (which rechecks on +-- the heap) returns correct results across the chain. +-- --------------------------------------------------------------------------- +CREATE TABLE hot_gin_test ( + id int PRIMARY KEY, + tags text[], + properties jsonb +) WITH (fillfactor = 50); +CREATE INDEX hot_gin_tags_idx ON hot_gin_test USING gin (tags); +CREATE INDEX hot_gin_props_idx ON hot_gin_test USING gin (properties); + +INSERT INTO hot_gin_test VALUES + (1, ARRAY['tag1', 'tag2'], '{"key1":"val1","key2":"val2"}'), + (2, ARRAY['tag3', 'tag4'], '{"key3":"val3","key4":"val4"}'); + +-- Reorder tags: a GIN-covered column changes, so this is HOT-indexed. +UPDATE hot_gin_test SET tags = ARRAY['tag2', 'tag1'] WHERE id = 1; +SELECT pg_stat_force_next_flush(); +SELECT * FROM get_hot_count('hot_gin_test'); + +DROP TABLE hot_gin_test; + +-- --------------------------------------------------------------------------- +-- Cleanup +-- --------------------------------------------------------------------------- +DROP FUNCTION has_hot_chain(text, tid); +DROP FUNCTION print_hot_chain(text, tid); +DROP FUNCTION get_hot_count(text); +-- Leave pageinspect installed: this runs inside the pageinspect regress suite +-- (page.sql created it), and other tests in the schedule still need it. diff --git a/contrib/pg_surgery/Makefile b/contrib/pg_surgery/Makefile index a66776c4c4131..da752a811478b 100644 --- a/contrib/pg_surgery/Makefile +++ b/contrib/pg_surgery/Makefile @@ -10,6 +10,7 @@ DATA = pg_surgery--1.0.sql PGFILEDESC = "pg_surgery - perform surgery on a damaged relation" REGRESS = heap_surgery +EXTRA_INSTALL = contrib/pageinspect ifdef USE_PGXS PG_CONFIG = pg_config diff --git a/contrib/pg_surgery/expected/heap_surgery.out b/contrib/pg_surgery/expected/heap_surgery.out index df7d13b09086f..1a5fef5b2bb95 100644 --- a/contrib/pg_surgery/expected/heap_surgery.out +++ b/contrib/pg_surgery/expected/heap_surgery.out @@ -175,6 +175,89 @@ DETAIL: This operation is not supported for views. select heap_force_freeze('vw'::regclass, ARRAY['(0, 1)']::tid[]); ERROR: cannot operate on relation "vw" DETAIL: This operation is not supported for views. +-- A HOT/SIU chain collapse turns the chain root and each dead entry-bearing +-- member into an LP_REDIRECT to the live tuple. pg_surgery operates on real +-- tuples and must leave the live row reachable after such a collapse. +create extension pageinspect; +create table htomb (id int primary key, a int, b int) with (fillfactor = 50); +create index htomb_a on htomb(a); +insert into htomb values (1, 10, 20); +-- Two HOT-indexed updates on an indexed attr, then prune: the dead mid-chain +-- versions collapse to LP_REDIRECTs to the live tuple. INDEX_CLEANUP off keeps +-- the stale btree leaves (and hence the redirects) in place. +update htomb set a = 11 where id = 1; +update htomb set a = 12 where id = 1; +vacuum (index_cleanup off) htomb; +select n_hot_indexed > 0 as made_hot_indexed + from pg_relation_hot_indexed_stats('htomb'); + made_hot_indexed +------------------ + t +(1 row) + +-- the live row is intact and reachable after the collapse +select id, a, b from htomb; + id | a | b +----+----+---- + 1 | 12 | 20 +(1 row) + +drop table htomb; +-- A collapse that keeps a *stub* (an xid-free forwarding LP_NORMAL item with +-- natts == 0), not just redirects: update two different indexed columns so the +-- first dead member's changed-attr bitmap is not subsumed by later hops and is +-- preserved as a stub. pg_surgery must skip such a stub -- forcing a +-- freeze/kill would overwrite its t_ctid forward link and corrupt the chain. +create table hstub (id int primary key, a int, b int) with (fillfactor = 50); +create index hstub_a on hstub(a); +create index hstub_b on hstub(b); +insert into hstub values (1, 10, 100); +update hstub set a = 11 where id = 1; -- changes a +update hstub set a = 12 where id = 1; -- changes a again (supersedes) +update hstub set b = 101 where id = 1; -- changes b -> first hop's {a} kept as stub +vacuum (index_cleanup off) hstub; +-- Locate the stub: an LP_NORMAL item carrying HEAP_INDEXED_UPDATED (0x0800 in +-- t_infomask2) with zero live attributes (t_infomask2 natts bits == 0). +select lp as stub_off + from heap_page_items(get_raw_page('hstub', 0)) + where lp_flags = 1 -- LP_NORMAL + and (t_infomask2 & 2048) <> 0 -- HEAP_INDEXED_UPDATED + and (t_infomask2 & 2047) = 0 -- natts == 0 (stub sentinel) + \gset +-- Force kill/freeze on the stub's tid: both must be refused with a NOTICE. +select heap_force_kill('hstub'::regclass, ARRAY[('(0,' || :'stub_off' || ')')]::tid[]); +NOTICE: skipping tid (0, 3) for relation "hstub" because it is a HOT-indexed collapse stub + heap_force_kill +----------------- + +(1 row) + +select heap_force_freeze('hstub'::regclass, ARRAY[('(0,' || :'stub_off' || ')')]::tid[]); +NOTICE: skipping tid (0, 3) for relation "hstub" because it is a HOT-indexed collapse stub + heap_force_freeze +------------------- + +(1 row) + +-- The chain is untouched: the live row is still reachable through each index. +set enable_seqscan = off; +set enable_bitmapscan = off; +select id, a, b from hstub where a = 12; + id | a | b +----+----+----- + 1 | 12 | 101 +(1 row) + +select id, a, b from hstub where b = 101; + id | a | b +----+----+----- + 1 | 12 | 101 +(1 row) + +reset enable_bitmapscan; +reset enable_seqscan; +drop table hstub; +drop extension pageinspect; -- cleanup. drop view vw; drop extension pg_surgery; diff --git a/contrib/pg_surgery/heap_surgery.c b/contrib/pg_surgery/heap_surgery.c index b8ce10957827d..7fc653cc334d9 100644 --- a/contrib/pg_surgery/heap_surgery.c +++ b/contrib/pg_surgery/heap_surgery.c @@ -13,6 +13,7 @@ #include "postgres.h" #include "access/htup_details.h" +#include "access/hot_indexed.h" #include "access/relation.h" #include "access/visibilitymap.h" #include "access/xloginsert.h" @@ -226,6 +227,21 @@ heap_force_common(FunctionCallInfo fcinfo, HeapTupleForceOption heap_force_opt) blkno, offno, RelationGetRelationName(rel)))); continue; } + else if (HotIndexedHeaderIsStub((HeapTupleHeader) PageGetItem(page, itemid))) + { + /* + * A HOT-indexed collapse-survivor stub is an xid-free + * forwarding node, not a real tuple: its t_ctid carries the + * chain forward link and the write-time natts, and it has no + * attribute data. Forcing a kill or freeze would overwrite + * t_ctid and clear its xact bits, breaking the chain walk and + * corrupting the heap. Skip it, as we do for redirects. + */ + ereport(NOTICE, + (errmsg("skipping tid (%u, %u) for relation \"%s\" because it is a HOT-indexed collapse stub", + blkno, offno, RelationGetRelationName(rel)))); + continue; + } /* Mark it for processing. */ Assert(offno <= MaxHeapTuplesPerPage); diff --git a/contrib/pg_surgery/sql/heap_surgery.sql b/contrib/pg_surgery/sql/heap_surgery.sql index 6526b27535de4..be4fed2af651c 100644 --- a/contrib/pg_surgery/sql/heap_surgery.sql +++ b/contrib/pg_surgery/sql/heap_surgery.sql @@ -83,6 +83,59 @@ create view vw as select 1; select heap_force_kill('vw'::regclass, ARRAY['(0, 1)']::tid[]); select heap_force_freeze('vw'::regclass, ARRAY['(0, 1)']::tid[]); +-- A HOT/SIU chain collapse turns the chain root and each dead entry-bearing +-- member into an LP_REDIRECT to the live tuple. pg_surgery operates on real +-- tuples and must leave the live row reachable after such a collapse. +create extension pageinspect; +create table htomb (id int primary key, a int, b int) with (fillfactor = 50); +create index htomb_a on htomb(a); +insert into htomb values (1, 10, 20); +-- Two HOT-indexed updates on an indexed attr, then prune: the dead mid-chain +-- versions collapse to LP_REDIRECTs to the live tuple. INDEX_CLEANUP off keeps +-- the stale btree leaves (and hence the redirects) in place. +update htomb set a = 11 where id = 1; +update htomb set a = 12 where id = 1; +vacuum (index_cleanup off) htomb; +select n_hot_indexed > 0 as made_hot_indexed + from pg_relation_hot_indexed_stats('htomb'); +-- the live row is intact and reachable after the collapse +select id, a, b from htomb; +drop table htomb; + +-- A collapse that keeps a *stub* (an xid-free forwarding LP_NORMAL item with +-- natts == 0), not just redirects: update two different indexed columns so the +-- first dead member's changed-attr bitmap is not subsumed by later hops and is +-- preserved as a stub. pg_surgery must skip such a stub -- forcing a +-- freeze/kill would overwrite its t_ctid forward link and corrupt the chain. +create table hstub (id int primary key, a int, b int) with (fillfactor = 50); +create index hstub_a on hstub(a); +create index hstub_b on hstub(b); +insert into hstub values (1, 10, 100); +update hstub set a = 11 where id = 1; -- changes a +update hstub set a = 12 where id = 1; -- changes a again (supersedes) +update hstub set b = 101 where id = 1; -- changes b -> first hop's {a} kept as stub +vacuum (index_cleanup off) hstub; +-- Locate the stub: an LP_NORMAL item carrying HEAP_INDEXED_UPDATED (0x0800 in +-- t_infomask2) with zero live attributes (t_infomask2 natts bits == 0). +select lp as stub_off + from heap_page_items(get_raw_page('hstub', 0)) + where lp_flags = 1 -- LP_NORMAL + and (t_infomask2 & 2048) <> 0 -- HEAP_INDEXED_UPDATED + and (t_infomask2 & 2047) = 0 -- natts == 0 (stub sentinel) + \gset +-- Force kill/freeze on the stub's tid: both must be refused with a NOTICE. +select heap_force_kill('hstub'::regclass, ARRAY[('(0,' || :'stub_off' || ')')]::tid[]); +select heap_force_freeze('hstub'::regclass, ARRAY[('(0,' || :'stub_off' || ')')]::tid[]); +-- The chain is untouched: the live row is still reachable through each index. +set enable_seqscan = off; +set enable_bitmapscan = off; +select id, a, b from hstub where a = 12; +select id, a, b from hstub where b = 101; +reset enable_bitmapscan; +reset enable_seqscan; +drop table hstub; +drop extension pageinspect; + -- cleanup. drop view vw; drop extension pg_surgery; diff --git a/contrib/test_decoding/Makefile b/contrib/test_decoding/Makefile index 0111124399a8e..800216b2ae45f 100644 --- a/contrib/test_decoding/Makefile +++ b/contrib/test_decoding/Makefile @@ -5,7 +5,7 @@ PGFILEDESC = "test_decoding - example of a logical decoding output plugin" REGRESS = ddl xact rewrite toast permissions decoding_in_xact \ decoding_into_rel binary prepared replorigin time messages \ - repack spill slot truncate stream stats twophase twophase_stream + repack spill slot truncate stream stats twophase twophase_stream hot_indexed ISOLATION = mxact delayed_startup ondisk_startup concurrent_ddl_dml \ oldest_xmin snapshot_transfer subxact_without_top concurrent_stream \ twophase_snapshot slot_creation_error catalog_change_snapshot \ diff --git a/contrib/test_decoding/expected/hot_indexed.out b/contrib/test_decoding/expected/hot_indexed.out new file mode 100644 index 0000000000000..1e2186fda5608 --- /dev/null +++ b/contrib/test_decoding/expected/hot_indexed.out @@ -0,0 +1,59 @@ +-- Logical decoding of HOT-indexed UPDATEs. A HOT-indexed update is an +-- ordinary heap update at the WAL level (the new version is logged in full), +-- so it must decode exactly like any other update. Exercise a chain of +-- HOT-indexed updates under REPLICA IDENTITY FULL so the decoded old tuple and +-- new tuple can both be checked. +SET synchronous_commit = on; +SELECT 'init' FROM pg_create_logical_replication_slot('regression_slot', 'test_decoding'); + ?column? +---------- + init +(1 row) + +CREATE TABLE hi_decode (id int PRIMARY KEY, a int, b int) WITH (fillfactor = 50); +CREATE INDEX hi_decode_a ON hi_decode (a); +CREATE INDEX hi_decode_b ON hi_decode (b); +ALTER TABLE hi_decode REPLICA IDENTITY FULL; +INSERT INTO hi_decode VALUES (1, 10, 100); +-- each update changes one indexed column, so each stays HOT-indexed +UPDATE hi_decode SET a = 11 WHERE id = 1; +UPDATE hi_decode SET b = 101 WHERE id = 1; +UPDATE hi_decode SET a = 12 WHERE id = 1; +-- cycle a away and back (ABA) and then delete +UPDATE hi_decode SET a = 99 WHERE id = 1; +UPDATE hi_decode SET a = 12 WHERE id = 1; +DELETE FROM hi_decode WHERE id = 1; +SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, + 'include-xids', '0', 'skip-empty-xacts', '1'); + data +------------------------------------------------------------------------------------------------------------------------------------------- + BEGIN + table public.hi_decode: INSERT: id[integer]:1 a[integer]:10 b[integer]:100 + COMMIT + BEGIN + table public.hi_decode: UPDATE: old-key: id[integer]:1 a[integer]:10 b[integer]:100 new-tuple: id[integer]:1 a[integer]:11 b[integer]:100 + COMMIT + BEGIN + table public.hi_decode: UPDATE: old-key: id[integer]:1 a[integer]:11 b[integer]:100 new-tuple: id[integer]:1 a[integer]:11 b[integer]:101 + COMMIT + BEGIN + table public.hi_decode: UPDATE: old-key: id[integer]:1 a[integer]:11 b[integer]:101 new-tuple: id[integer]:1 a[integer]:12 b[integer]:101 + COMMIT + BEGIN + table public.hi_decode: UPDATE: old-key: id[integer]:1 a[integer]:12 b[integer]:101 new-tuple: id[integer]:1 a[integer]:99 b[integer]:101 + COMMIT + BEGIN + table public.hi_decode: UPDATE: old-key: id[integer]:1 a[integer]:99 b[integer]:101 new-tuple: id[integer]:1 a[integer]:12 b[integer]:101 + COMMIT + BEGIN + table public.hi_decode: DELETE: id[integer]:1 a[integer]:12 b[integer]:101 + COMMIT +(21 rows) + +SELECT pg_drop_replication_slot('regression_slot'); + pg_drop_replication_slot +-------------------------- + +(1 row) + +DROP TABLE hi_decode; diff --git a/contrib/test_decoding/meson.build b/contrib/test_decoding/meson.build index ac655853d269c..91765ca0e7289 100644 --- a/contrib/test_decoding/meson.build +++ b/contrib/test_decoding/meson.build @@ -42,6 +42,7 @@ tests += { 'stats', 'twophase', 'twophase_stream', + 'hot_indexed', ], 'regress_args': [ '--temp-config', files('logical.conf'), diff --git a/contrib/test_decoding/sql/hot_indexed.sql b/contrib/test_decoding/sql/hot_indexed.sql new file mode 100644 index 0000000000000..05d7d091b627a --- /dev/null +++ b/contrib/test_decoding/sql/hot_indexed.sql @@ -0,0 +1,29 @@ +-- Logical decoding of HOT-indexed UPDATEs. A HOT-indexed update is an +-- ordinary heap update at the WAL level (the new version is logged in full), +-- so it must decode exactly like any other update. Exercise a chain of +-- HOT-indexed updates under REPLICA IDENTITY FULL so the decoded old tuple and +-- new tuple can both be checked. +SET synchronous_commit = on; + +SELECT 'init' FROM pg_create_logical_replication_slot('regression_slot', 'test_decoding'); + +CREATE TABLE hi_decode (id int PRIMARY KEY, a int, b int) WITH (fillfactor = 50); +CREATE INDEX hi_decode_a ON hi_decode (a); +CREATE INDEX hi_decode_b ON hi_decode (b); +ALTER TABLE hi_decode REPLICA IDENTITY FULL; + +INSERT INTO hi_decode VALUES (1, 10, 100); +-- each update changes one indexed column, so each stays HOT-indexed +UPDATE hi_decode SET a = 11 WHERE id = 1; +UPDATE hi_decode SET b = 101 WHERE id = 1; +UPDATE hi_decode SET a = 12 WHERE id = 1; +-- cycle a away and back (ABA) and then delete +UPDATE hi_decode SET a = 99 WHERE id = 1; +UPDATE hi_decode SET a = 12 WHERE id = 1; +DELETE FROM hi_decode WHERE id = 1; + +SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, + 'include-xids', '0', 'skip-empty-xacts', '1'); + +SELECT pg_drop_replication_slot('regression_slot'); +DROP TABLE hi_decode; diff --git a/doc/src/sgml/catalogs.sgml b/doc/src/sgml/catalogs.sgml index 4b474c139174d..4c9aba72ba751 100644 --- a/doc/src/sgml/catalogs.sgml +++ b/doc/src/sgml/catalogs.sgml @@ -8727,6 +8727,22 @@ SCRAM-SHA-256$<iteration count>:&l + + + subhotindexedonapply char + + + Gating mode for the HOT-indexed apply path. Corresponds to the + hot_indexed_on_apply + subscription option: + + o = off + s = subset_only (default) + a = always + + + + subserver oid diff --git a/doc/src/sgml/monitoring.sgml b/doc/src/sgml/monitoring.sgml index d1a20d001e9c8..242f6218e969a 100644 --- a/doc/src/sgml/monitoring.sgml +++ b/doc/src/sgml/monitoring.sgml @@ -4467,6 +4467,19 @@ description | Waiting for a newly initialized WAL file to reach durable storage + + + n_tup_hot_indexed_upd bigint + + + Number of rows updated using the HOT-indexed path: the successor + version stays on the same page as a heap-only tuple even though it + changed one or more indexed columns, and only the affected indexes + receive a new entry. Every such update is also counted in + n_tup_hot_upd. + + + n_tup_newpage_upd bigint @@ -4937,6 +4950,27 @@ description | Waiting for a newly initialized WAL file to reach durable storage + + + n_tup_hot_indexed_upd_matched bigint + + + Number of HOT-indexed updates that inserted a new entry into this + index (the update changed one of this index's attributes) + + + + + + n_tup_hot_indexed_upd_skipped bigint + + + Number of HOT-indexed updates that skipped this index (the update + changed no attribute of this index, so its existing entry continues + to resolve the HOT chain) + + + stats_reset timestamp with time zone @@ -5618,6 +5652,29 @@ description | Waiting for a newly initialized WAL file to reach durable storage + + + + pg_relation_hot_indexed_stats + + pg_relation_hot_indexed_stats ( relation regclass ) + record + ( n_hot_indexed bigint, + n_chains bigint, + avg_chain_len double precision, + max_chain_len bigint ) + + + Reports HOT-indexed structural statistics for a table by scanning + every page under AccessShareLock: + n_hot_indexed is the number of live + HOT-indexed tuple versions present, and + n_chains, avg_chain_len + and max_chain_len describe the HOT-indexed + chains. Intended for diagnostics. + + + diff --git a/doc/src/sgml/pageinspect.sgml b/doc/src/sgml/pageinspect.sgml index 3a113439e1dc4..3e9576419c38d 100644 --- a/doc/src/sgml/pageinspect.sgml +++ b/doc/src/sgml/pageinspect.sgml @@ -458,6 +458,18 @@ test=# SELECT itemoffset, ctid, itemlen, nulls, vars, data, dead, htid, tids[0:2 away, which is represented as a NULL htid value. + + hot_indexed is true when the leaf tuple is a + fresh index entry planted by a HOT-indexed (selective index update) + UPDATE, which points at a mid-chain heap-only tuple + rather than at the chain root. Such an entry's stored heap TID carries + an internal marker bit in its offset that a bitmap scan uses to fall + back to page-level (lossy) matching; ctid and + htid report the real heap offset with that + marker removed, and hot_indexed exposes the + marker itself. It is false for ordinary entries and for the pivot and + posting-list tuples whose ctid is repurposed. + Note that the first item on any non-rightmost page (any page with a non-zero value in the btpo_next field) is the diff --git a/doc/src/sgml/ref/alter_subscription.sgml b/doc/src/sgml/ref/alter_subscription.sgml index 8d64744375a50..ccce5c73a44fd 100644 --- a/doc/src/sgml/ref/alter_subscription.sgml +++ b/doc/src/sgml/ref/alter_subscription.sgml @@ -296,8 +296,11 @@ ALTER SUBSCRIPTION name RENAME TO < two_phase, retain_dead_tuples, max_retention_duration, - wal_receiver_timeout, and - conflict_log_destination. + wal_receiver_timeout, + conflict_log_destination, and + hot_indexed_on_apply. + For hot_indexed_on_apply, the new value takes effect + at the apply worker's next catalog reload. Only a superuser can set password_required = false. diff --git a/doc/src/sgml/ref/create_subscription.sgml b/doc/src/sgml/ref/create_subscription.sgml index 81fbf3487a418..0d59f857bb341 100644 --- a/doc/src/sgml/ref/create_subscription.sgml +++ b/doc/src/sgml/ref/create_subscription.sgml @@ -651,6 +651,61 @@ CREATE SUBSCRIPTION subscription_name + + + hot_indexed_on_apply (text) + + + Controls whether the subscription's apply worker may take the + HOT-indexed update path when an UPDATE replicated + from the publisher touches an indexed attribute. Because the + subscriber's index set may differ from the publisher's, an + unconstrained HOT-indexed decision on the apply path can produce a + heap chain whose index state disagrees with the upstream row. The + option restricts when the apply worker is allowed to take that path. + + + Accepted values are: + + + off + + + Force non-HOT on apply whenever the subscriber has any indexed + attribute beyond the primary key. This matches the conservative + pre-existing behaviour. + + + + + subset_only + + + Allow the HOT-indexed apply path when the subscriber's + indexed-attr set is a subset of its primary-key attrs (which + includes the no-secondary-index case). This is the default and + captures the common replication-ready schema shape while staying + safe when the subscriber adds indexes the publisher does not + have. + + + + + always + + + Unconditional HOT-indexed eligibility on apply. The operator + takes responsibility for keeping the subscriber's indexed-attr + set compatible with the publisher's; divergent schemas can + produce spurious duplicate-key conflicts for subsequent + inserts on the subscriber. + + + + + + + diff --git a/src/backend/access/heap/Makefile b/src/backend/access/heap/Makefile index 1d27ccb916e09..dfaf335073607 100644 --- a/src/backend/access/heap/Makefile +++ b/src/backend/access/heap/Makefile @@ -20,6 +20,7 @@ OBJS = \ heapam_xlog.o \ heaptoast.o \ hio.o \ + hot_indexed_stats.o \ pruneheap.o \ rewriteheap.o \ vacuumlazy.o \ diff --git a/src/backend/access/heap/README.HOT b/src/backend/access/heap/README.HOT index 74e407f375aad..7123656173c67 100644 --- a/src/backend/access/heap/README.HOT +++ b/src/backend/access/heap/README.HOT @@ -156,6 +156,40 @@ all summarizing indexes. (Realistically, we only need to propagate the update to the indexes that contain the updated values, but that is yet to be implemented.) + +Per-Index Update Tracking +------------------------- + +After the table AM performs the update, the executor determines which +indexes need new entries using per-index tracking. + +The table AM communicates whether a HOT update occurred via the +update_all_indexes boolean output of table_tuple_update(), together with the +modified-attrs Bitmapset the caller passed in (attribute numbers encoded with +FirstLowInvalidHeapAttributeNumber). When update_all_indexes is true the +update was non-HOT and every index requires a new entry (the tuple has a new +TID). When false the update was HOT: the caller consults modified_attrs with +each index's own attributes to insert entries only into the indexes whose key +attributes changed (a HOT-indexed update) or only the summarizing indexes (a +classic HOT update that changed a summarized column), and skips the rest. + +The executor then calls ExecSetIndexUnchanged() to populate the per-index +ii_IndexUnchanged flag on each IndexInfo. This flag indicates whether each +index's key values are unchanged by the update. For non-HOT updates +the flag is cleared on every index, so each gets a fresh entry at the +new TID; the flag is never a skip on its own, just a hint to the +index AM's aminsert for optimizations such as bottom-up deletion of +logically equivalent duplicate entries. + +ExecInsertIndexTuples consults ii_IndexUnchanged to decide whether to +skip a non-summarizing index during an UPDATE: if the index is marked +unchanged, the HOT chain root's existing entry still points at the +tuple, so no new entry is needed. For non-HOT updates the TID +changed and ExecSetIndexUnchanged marks every index as changed, +forcing each to receive a new entry. Summarizing indexes always get +the opportunity to update their block-level summaries. + + Abort Cases ----------- diff --git a/src/backend/access/heap/README.HOT-INDEXED b/src/backend/access/heap/README.HOT-INDEXED new file mode 100644 index 0000000000000..b562831039de1 --- /dev/null +++ b/src/backend/access/heap/README.HOT-INDEXED @@ -0,0 +1,492 @@ +HOT-indexed updates (Selective Index Update, "HOT/SIU") +======================================================= + +Classic HOT (see README.HOT) keeps an UPDATE off the indexes only when no +indexed column changed: the new tuple is a heap-only tuple appended to the +chain, and every index entry continues to resolve, via the same-page t_ctid +chain, to a tuple whose indexed values still equal the entry's key. + +HOT-indexed (Selective Index Update, SIU) relaxes that to: an UPDATE may +change indexed columns and still be a heap-only tuple on the same page, +provided new index entries are inserted only into the indexes whose key +attributes actually changed. The indexes whose attributes did not change are +left untouched, which is where the write-amplification saving comes from. + +The price is that a chain may now contain tuples with different index keys, so +an index entry for an old key can chain-resolve to a live tuple whose current +key differs. Such an entry is STALE. The read side detects and drops stale +entries by testing the heap's crossed-attribute bitmap against the index's +key columns. + +This file documents the eligibility rules, the write path, the on-chain +representation, the read-side staleness test, prune/collapse, vacuum reclamation, +recovery, the logical-replication apply gating, and the statistics. + + +The classic-HOT invariant we are relaxing +------------------------------------------ + +Classic HOT relies on two properties: + + (a) every live index entry resolves, via a same-page t_ctid chain, to the + chain's root line pointer; and + + (b) the indexed values of every tuple on the chain equal the key of every + index entry that reaches it. + +HOT-indexed keeps (a) but breaks (b): after a HOT-indexed update the chain +holds tuples with different keys, and a new index entry is planted that points +at the *specific* heap-only tuple whose key it matches -- not necessarily the +root. + + +The HOT-indexed invariant (the new contract) +-------------------------------------------- + + An index entry points at the heap-only tuple version whose indexed key it + matched at insertion time. A chain walk that reaches a live tuple by + crossing a HOT-indexed hop *after* the entry's own target may be stale: if + the union of those crossed hops' modified attributes overlaps the index's + key columns, the entry is stale and the tuple is dropped from that scan. + The row is + re-supplied by the fresh entry that the same update inserted for the new + key. + +This is what makes dropping a stale entry safe: the live row is always +reachable through exactly one non-stale entry per index. + + +Eligibility: HeapUpdateHotAllowable +----------------------------------- + +The executor computes modified_idx_attrs (the indexed attributes this UPDATE +changed, attribute numbers offset by FirstLowInvalidHeapAttributeNumber) and +passes it to heap_update via table_tuple_update. HeapUpdateHotAllowable +classifies the update: + + HEAP_UPDATE_ALL_INDEXES + HOT is not permitted; the new tuple goes on a fresh TID and every + index gets a new entry. + HEAP_HEAP_ONLY_UPDATE + no non-summarizing indexed attribute changed, so no index needs a + new entry (classic HOT). + HEAP_SELECTIVE_INDEX_UPDATE + at least one non-summarizing index's attribute changed, but the + update may stay on the HOT chain and maintain only the changed + indexes selectively. + +A non-summarizing indexed attribute changing yields HEAP_SELECTIVE_INDEX_UPDATE +unless one of these forces HEAP_UPDATE_ALL_INDEXES: + + 1. The logical-replication apply path, gated per subscription (see "Logical + replication" below). + 2. An UPDATE touching an attribute referenced by an expression index + (selective maintenance of expression indexes is not implemented yet). + 3. An UPDATE that changes *every* indexed attribute: there is no index to + skip, so a plain non-HOT update is cheaper. + +System catalogs stay classic-HOT only: a catalog UPDATE that changes a +non-summarizing indexed attribute falls back to HEAP_UPDATE_ALL_INDEXES, because +catalog reads go through many paths not all proven safe against stale chain +entries. This is the pre-HOT-indexed behaviour for such updates. + +INDEX_ATTR_BITMAP_INDEXED (cached in rd_indexedattr) is the set of columns +referenced by non-summarizing indexes plus, folded in, the columns referenced +only by summarizing indexes, so that a change to a summarizing-only column is +seen by the modified-attribute comparison (its index is maintained via the +classic-HOT summarizing path). Read-side staleness is filtered by the +crossed-attribute bitmap, which is access-method agnostic, so a change to a +column covered by any index is HOT-indexed regardless of the index's access +method. Summarizing indexes (e.g. BRIN) keep no per-row leaf that can go +stale and are maintained via the summarizing path. + + +The write path +-------------- + +For HEAP_SELECTIVE_INDEX_UPDATE, heap_update: + + - stores the new tuple as a heap-only tuple on the same page, linked into + the chain via t_ctid, exactly like classic HOT; and + - sets HEAP_INDEXED_UPDATED (t_infomask2 bit 0x0800) on the new tuple to + mark that the chain now carries differing keys. + +There is no separate on-page meta-item: the bit on the heap-only tuple is the +entire on-disk footprint. As for classic HOT, if the new tuple does not fit +on the page the update falls back to a non-HOT (new-page) update. + +The inline modified-attrs bitmap is ceil(natts/8) bytes, sized by the tuple's +OWN attribute count at write time (HeapTupleHeaderGetNatts), not the relation's +current natts. ADD COLUMN raises the relation's natts without rewriting +existing tuples, so one chain can hold hops whose bitmaps were sized for +different (smaller) natts; every consumer locates and sizes a hop's bitmap +from that hop's own write-time natts (HotIndexedTupleBitmapNatts in +access/hot_indexed.h). A collapse-survivor stub overwrites natts with its 0 +sentinel, so it preserves its write-time natts in the unused block-number half +of t_ctid (the offset half is the forward link). Bit positions are attribute +based and identical across sizes, so a smaller bitmap simply ORs into the low +bytes of a larger crossed-attribute accumulator. DROP COLUMN keeps the attnum +slot (it never renumbers), so existing bitmaps stay aligned. + +After the update, table_tuple_update reports update_all_indexes = false (the +tuple is heap-only). The executor then maintains indexes selectively: +ExecSetIndexUnchanged marks each index whose key attributes did not change as +unchanged, and ExecInsertIndexTuples inserts a fresh entry only into the +indexes that did change. Each such entry points at the new tuple's own TID. + + +The chain and the two kinds of leaf entry +------------------------------------------ + +After a HOT-indexed update there are, for a changed index, two kinds of leaf +entry reaching the chain: + + - the pre-update entry for the OLD key, still pointing at an older chain + member (now stale once the walk crosses the HOT-indexed hop); and + - the fresh entry for the NEW key, pointing at the new heap-only tuple. + +Index build and REINDEX index a live HOT-indexed tuple under its OWN TID (not +the chain root), so the freshly built entry has no hop after it and is never +treated as stale. + + +Read-side correctness: the crossed-attribute bitmap +--------------------------------------------------- + +heap_hot_search_buffer walks the chain from the entry's target to the live +visible tuple. Each hop it crosses after the entry's own target -- a live +HOT-indexed member, a collapse-survivor stub, or a collapsed (redirected) +prefix -- contributes that hop's inline modified-attrs bitmap to a running +union, IndexFetchTableData.xs_hot_indexed_crossed, and sets +*hot_indexed_recheck to flag that the walk crossed at least one such hop. + +The index-access layer (index_fetch_heap) tests that union against the +arriving index's key columns. Any overlap means a crossed hop changed one of +this index's inputs, so the entry's stored key no longer matches the live +tuple: IndexScanDesc.xs_hot_indexed_stale is set, and IndexScan, +IndexOnlyScan, CLUSTER, and the logical-replication replica-identity lookups +drop the tuple. If the union is disjoint from the index's key columns, none +of the index's inputs changed across the chain, so the entry is current and +the row is returned. + +The union is complete: every crossed live hop and stub contributes its +bitmap, and chain collapse only ever reclaims a member whose attributes are a +subset of the surviving later hops (see "Prune and chain collapse"), so a +reader crossing the survivors still sees every collapsed hop's attributes. +Disjointness therefore reliably means the entry is current. + +This needs no value comparison and no leaf key, so it serves equality, range, +and inequality scans uniformly, works for any access method whose columns are +eligible for HOT-indexed updates, and is correct even when a key is cycled +away and back (X -> Y -> X): the update that restored the value planted a +fresh entry pointing at its own live tuple, whose walk crosses no later +key-changing hop, so that entry uniquely returns the row while the stale +ancestor entry -- whose walk does cross the changing hops -- is dropped. + +The read mechanism never reconstructs or compares an index key, so it needs no +per-access-method support. (nbtree keeps an internal leaf-key comparison, +_bt_heap_keys_equal_leaf, used only by _bt_check_unique to tell a stale chain +entry from a live duplicate during a unique insert; it is not part of the read +path.) + +Unique checks. _bt_check_unique fetches the conflicting tuple under +SnapshotDirty and, when the chain walk crossed a HOT-indexed hop, compares the +live tuple's current key against the arriving leaf with the index's own +ordering procedure (_bt_heap_keys_equal_leaf, using BTORDER_PROC under each +column's collation). This recheck is reached only for an index receiving a +fresh entry during a HOT-indexed update; HeapUpdateHotAllowable disqualifies +any UPDATE that touches an expression-index attribute, so the index here never +has an expression key column (every key column is a plain attribute), and the +comparison reads attribute values straight from the heap slot -- no expression +evaluation or executor state is needed. Using the opclass comparator -- not a +bitwise image comparison -- means a key +that was cycled away and back (X -> Y -> X) does not raise a spurious +duplicate against its own stale leaf, while a genuinely live duplicate (equal +under the opclass even if not bitwise-identical, e.g. numeric 1.0 vs 1.00) is +still detected. (Appendix A motivates this recheck in detail.) + + +Bitmap scans: the mid-chain TID and BitmapAnd/BitmapOr +----------------------------------------------------- + +The crossed-attribute test above runs on the way to the heap. A bitmap scan +has an earlier hazard it cannot reach: BitmapAnd/BitmapOr combine two indexes' +TID sets in tidbitmap.c at raw block+offset granularity, before either side +touches the heap. A HOT-indexed fresh entry points at the mid-chain new tuple +while an unchanged index's entry for the same row points at the chain root, so +for "WHERE changed_col = x AND unchanged_col = y" the two bitmap index scans +contribute different TIDs for the one live row, the exact-mode intersection +finds nothing in common, and the row is dropped -- a false negative (bitmap +scans tolerate false positives, not false negatives). + +A fresh entry's stored heap TID therefore carries ItemPointerSIUMaybeStaleFlag, +bit 14 of the offset field (see storage/itemptr.h). MaxOffsetNumber needs at +most 14 bits even at the largest configurable BLCKSZ, so the bit is free for +any real offset; the two reserved offset sentinels (SpecTokenOffsetNumber, +MovedPartitionsOffsetNumber) already set it as part of their own encoding, so +ItemPointerGetOffsetNumber's strip is range-gated to leave them intact. The +flag is set only on the local TID copy handed to a fresh entry's index_insert +(ExecInsertIndexTuples), never on the heap tuple's own t_ctid and never on a +classic-HOT or plain entry. ItemPointerGetOffsetNumber and ItemPointerCompare +strip it by default, so the plain index-scan heap lookup, the unique check, +tuplesort, and the tid opclass all see the real offset; only +ItemPointerGetOffsetNumberNoCheck exposes the raw value. + +tbm_add_tuples -- the single choke point every amgetbitmap funnels exact heap +TIDs through -- checks the flag and, when set, adds the whole page as lossy +(tbm_add_page) instead of the single exact offset. A lossy page survives any +AND/OR against an exact-mode page (tbm_intersect_page's own case analysis) and +forces a recheck, so BitmapHeapScan resolves the chain and the crossed- +attribute test above makes the final call. Because this lives in +tbm_add_tuples, no index access method needs any HOT-indexed-specific code: +btree, hash, GIN, GiST, SP-GiST, contrib/bloom, and out-of-tree AMs are all +correct, and a TID that never carries the flag takes the identical old path. +GIN's unrelated page-level lossy sentinel (ItemPointerIsLossyPage) is not +affected. + +The cost is precision, not correctness: a heap page carrying a live fresh +entry contributes lossy (a whole-page recheck on that bitmap scan) until the +chain collapses; it is bounded and self-healing via prune/VACUUM. pageinspect +1.14's bt_page_items reports the real offset (earlier versions showed the +marker as an inflated offset) and adds a hot_indexed boolean column. + + +Prune and chain collapse +------------------------- + +Because a HOT-indexed update plants an index entry pointing at a mid-chain +heap-only tuple's own TID, classic HOT's assumption that mid-chain line +pointers have no external references no longer holds. Pruning therefore must +not reclaim such a line pointer while a not-yet-swept index entry can still +arrive at it. + +heap_prune_chain collapses a run of dead chain members to a single +LP_REDIRECT that forwards to the first live tuple, and preserves the line +pointer of a live HOT-indexed member (heap_prune_item_preserves_hot_indexed) +so a reader arriving via a stale entry still finds a walkable hop. More than +one LP_REDIRECT may forward to the same live tuple. The redirect lifecycle +reuses the existing prune WAL records; there is no new on-disk format. + + +Vacuum reclamation +------------------ + +VACUUM's index cleanup sweeps the stale index entries. The collapse back to +classic HOT is driven by prune, not by VACUUM's second pass: once a chain is +fully dead, a later prune (heap_prune_chain / heap_prune_chain_find_live) +reclaims its members and re-points the root redirect straight at first_live. +Re-pointing a redirect preserves reachability (every walker still reaches +first_live), so it is safe under the exclusive lock prune already holds. + +VACUUM's second pass (lazy_vacuum_heap_page) does not itself re-point +redirects or reclaim stubs; it performs the usual LP_DEAD -> LP_UNUSED +conversion and leaves the HOT-indexed collapse to prune. + +A page that still carries a preserved HOT-indexed member or a collapse-survivor +stub is deliberately left non-all-visible, so that an index-only scan +heap-fetches through the chain and the crossed-attribute bitmap can filter +stale entries (enforced in heap_prune_record_redirect, the stub recorders, and +heap_page_would_be_all_visible). + + +amcheck and statistics +---------------------- + +verify_heapam treats the HOT-indexed artifacts as legitimate: a live +HEAP_INDEXED_UPDATED heap-only tuple whose line pointer is preserved, and +multiple LP_REDIRECTs forwarding to one live tuple. + +Statistics: pg_stat_all_tables.n_tup_hot_indexed_upd counts HOT-indexed +updates; pg_stat_all_indexes.n_tup_hot_indexed_upd_matched / _skipped count +per-index recheck outcomes; and pg_relation_hot_indexed_stats() reports +per-relation HOT-indexed chain counts. + + +Logical replication +------------------- + +A HOT-indexed update of a replica-identity attribute on a subscriber leaves a +stale index leaf; the apply worker's replica-identity lookups tolerate that +only when the indexed attributes are covered by the replica identity. The +per-subscription hot_indexed_on_apply option (pg_subscription.subhotindexedonapply, +off / subset_only / always; subset_only is the default) controls this: +HeapUpdateHotAllowable consults GetHotIndexedApplyMode on the apply path and +falls back to non-HOT when the indexed attributes are not exactly the primary +key (off) or not a subset of it (subset_only). + + +Recovery +-------- + +HOT-indexed updates and the prune/collapse use the existing heap UPDATE and +prune/freeze WAL records, so crash recovery replays them with no new record +types. src/test/recovery/t/054_hot_indexed_recovery.pl builds a chain, +crashes without a checkpoint (forcing WAL redo), and verifies the chain walk, +verify_heapam, and vacuum reclamation after restart, with +wal_consistency_checking = 'all' comparing each replayed page to its FPI. + + +Adversarial tests +----------------- + +src/test/isolation/specs/hot_indexed_adversarial.spec exercises the cases the +invariant must satisfy under concurrency: key cycling (X->Y->X), aborted +HOT-indexed updates, concurrent unique inserts against a freed/taken key, +snapshot-safe reclaim of stale leaves, and reader consistency across a +concurrent prune/collapse. + + +Appendices +---------- + +Appendix A: Why the unique-check path needs a value comparison at all +--------------------------------------------------------------------- + +This is the one place HOT-indexed does compare a key value, even though the +read path deliberately avoids one. The rest of this appendix explains why the +comparison is needed, why it must use the opclass comparator rather than a +bitwise one, and why the ABA case is what forces the issue. + +1. The setup: why a unique insert can even reach a stale leaf + +Under classic HOT, an index has exactly one leaf entry per logical row, and +every leaf entry's key matches the live tuple it chain-resolves to. So +_bt_check_unique can trust: "if I find a leaf whose key equals my new key, and +it resolves to a live tuple, that's a genuine duplicate." + +HOT/SIU breaks that one-to-one correspondence. A HOT-indexed UPDATE that +changes column a from X to Y: + - inserts a fresh leaf entry (Y -> new tuple) into idx_a, and + - leaves the old leaf entry (X -> old chain root) in place. + +That old (X -> root) entry is now stale: it still chain-resolves to a live +tuple, but that live tuple's current a is Y, not X. The read path handles this +with the crossed-attribute bitmap (no value comparison needed): if the walk +from the entry's target to the live tuple crosses a hop that changed a, the +entry is stale and dropped. + +When you now INSERT a row with a = X, _bt_check_unique scans idx_a for key X +and finds that stale (X -> root) leaf. It must decide: is this a real +conflict? + +2. Why the read-path bitmap is not sufficient here + +The read path's logic is: "this entry crossed a hop that changed a => stale => +drop it, the fresh entry will supply the row." For scans that's correct and +complete, because every live row has exactly one non-stale entry that +re-supplies it. + +But a unique check is asking a different question. It is not "should I return +this row?" -- it is "does the live tuple this entry resolves to conflict with +the key I'm inserting?" The bitmap can only tell you "an indexed attribute +changed somewhere on the chain." It cannot tell you what the live value is +now, and that is exactly what you need to know to detect a duplicate. + +This is the crux of the ABA problem. Consider: + + INSERT (a=10) LP[1] a=10 (root) + UPDATE a=11 (HOT-indexed) LP[2] a=11 bitmap {a}, leaf (11)->LP[2] + UPDATE a=10 (HOT-indexed) LP[3] a=10 bitmap {a}, leaf (10)->LP[3], live + +idx_a now has leaves (10)->LP[1] [stale ancestor], (11)->LP[2] [stale], and +(10)->LP[3] [fresh, live]. + +Now INSERT (a=10), a genuine duplicate of the live row. _bt_check_unique scans +for key 10 and finds the (10)->LP[1] stale ancestor entry. The chain walk from +LP[1] to the live tuple LP[3] crosses hops that changed a (10->11, then +11->10), so the bitmap says "stale." If the unique check trusted the bitmap +alone it would skip (10)->LP[1] as stale and miss the real duplicate. The +bitmap is fooled because a changed (so the bit is set) even though it changed +back to the same value: "an attribute changed on the chain" is not "the live +value differs from this leaf's key." Under ABA they diverge. + +The sharper case is concurrency. While the restoring UPDATE (a: 11 -> 10) is +in flight, it has written its new heap tuple but not yet inserted the fresh +(10)->LP[3] leaf. A concurrent INSERT (a=10) running its _bt_check_unique scan +in that window sees only the stale (10)->LP[1] ancestor. The value recheck +below makes that hit resolve to xwait on the in-flight updater (via +_bt_doinsert's wait-and-recheck), so the inserter re-checks after the updater +commits and finds the conflict. A bitmap-only verdict would skip the ancestor +before reaching the xwait logic and admit a duplicate -- which is why the +recheck is a correctness requirement, not merely an optimization. + +3. Why a value comparison fixes it, and why it must be the opclass comparator + +So the unique path needs to look at the actual live value, not just "did +something change." _bt_check_unique fetches the conflicting tuple under +SnapshotDirty and, when hi_recheck says a HOT-indexed hop was crossed, calls +_bt_heap_keys_equal_leaf to compare the live tuple's current key against the +arriving leaf's stored key: + + - live key equals the leaf key -> genuine duplicate (or an in-flight conflict + reached as xwait) -- correct: ABA back to X is a real conflict with a new X. + - live key differs -> the leaf is truly stale -> skip it (the fresh entry + handles the real row). + +Which equality? Two candidates: + +Bitwise/image comparison (datum_image_eq) compares raw bytes. That is wrong +for unique checking in the dangerous direction. Uniqueness in PostgreSQL is +defined by the index opclass's equality operator, not byte identity, and many +types have values equal under the opclass but byte-distinct: + - numeric: 1.0 and 1.00 are opclass-equal, different on-disk bytes. + - float8: -0.0 and +0.0 are equal, different bit patterns. + - text/citext under a nondeterministic collation: canonically-equivalent + strings that are not byte-identical. + +A bitwise comparison would conclude "not equal => stale => skip" for a live +1.00 versus an inserted 1.0 and miss a genuine violation -- a correctness hole +as bad as the ABA one. + +So _bt_heap_keys_equal_leaf uses the index's own BTORDER_PROC (btree support +function 1) under each key column's collation, the same machinery _bt_compare +and _bt_mkscankey use to define equality for the index. A zero result means +"equal as the index defines equality," which is precisely the unique-violation +condition, and the verdict agrees with the index's own notion of uniqueness in +both directions. + +4. Why no expression evaluation is needed + +_bt_heap_keys_equal_leaf reads each key column straight from the heap slot +(slot_getattr) and compares it to the leaf datum; it does not evaluate indexed +expressions and needs no executor state. That is sufficient because the +recheck is only ever reached for an index receiving a fresh entry during a +HOT-indexed update, and HeapUpdateHotAllowable disqualifies any UPDATE that +touches an attribute referenced by an expression index +(INDEX_ATTR_BITMAP_EXPRESSION captures every such attribute). So a HOT-indexed +chain never has a crossed hop affecting an expression index, the index reaching +the recheck never has an expression key column (every indkey is a real +attribute number), and there is nothing to evaluate. If selective maintenance +of expression indexes is implemented in the future, this is where an +expression-evaluating comparison (e.g. FormIndexDatum) would be reintroduced. + +5. Why the asymmetry (bitmap on read, value recheck on unique) is intentional + +It looks like two different answers to the same question, but the questions +differ: + + - Read/scan path: "should this row be returned?" A stale entry is redundant + (the fresh entry supplies the row), so the conservative bitmap verdict is + sufficient -- worst case under ABA you drop a redundant entry and the fresh + one still returns the row. No value comparison, so reads stay + access-method-agnostic and cheap. + - Unique-check path: "is this a conflict?" A wrong "stale" verdict here does + not just drop a redundant entry; it silently admits a duplicate, corrupting + the constraint. It cannot tolerate the bitmap's false "stale" under ABA and + must consult the live value (or wait on an in-flight updater) via the + opclass comparator. + +The bitmap is a filter (a necessary condition: "could be stale"); the opclass +recheck is the authority (the sufficient condition: "is the live key actually +different, or is a conflicting update in flight"). The unique path layers the +authority on top of the filter precisely because its error mode is +unforgiving. + +In one sentence: the unique check compares the live tuple's current key to the +arriving leaf with the index's own equality (not bytes) because the +crossed-attribute bitmap can only say "something changed" -- true under an +X->Y->X cycle even though the value is back to X -- and only an opclass-correct +value comparison (which also routes an in-flight restoring update to xwait) can +both recognize the cycled-back value as a genuine duplicate and catch +duplicates that are opclass-equal but not byte-identical, either of which a +bitmap or a bitwise comparison would get wrong. diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c index 9cdc221675b2d..7c38f55d025f7 100644 --- a/src/backend/access/heap/heapam.c +++ b/src/backend/access/heap/heapam.c @@ -34,18 +34,25 @@ #include "access/heapam.h" #include "access/heaptoast.h" #include "access/hio.h" +#include "access/hot_indexed.h" #include "access/multixact.h" #include "access/subtrans.h" #include "access/syncscan.h" +#include "access/sysattr.h" +#include "access/tableam.h" #include "access/valid.h" #include "access/visibilitymap.h" #include "access/xloginsert.h" #include "catalog/pg_database.h" #include "catalog/pg_database_d.h" +#include "catalog/pg_subscription.h" #include "commands/vacuum.h" #include "executor/instrument_node.h" +#include "executor/tuptable.h" +#include "nodes/lockoptions.h" #include "pgstat.h" #include "port/pg_bitutils.h" +#include "replication/logicalworker.h" #include "storage/lmgr.h" #include "storage/predicate.h" #include "storage/proc.h" @@ -53,6 +60,7 @@ #include "utils/datum.h" #include "utils/injection_point.h" #include "utils/inval.h" +#include "utils/relcache.h" #include "utils/spccache.h" #include "utils/syscache.h" @@ -70,11 +78,10 @@ static void check_lock_if_inplace_updateable_rel(Relation relation, HeapTuple newtup); static void check_inplace_rel_lock(HeapTuple oldtup); #endif -static Bitmapset *HeapDetermineColumnsInfo(Relation relation, - Bitmapset *interesting_cols, - Bitmapset *external_cols, - HeapTuple oldtup, HeapTuple newtup, - bool *has_external); +static Bitmapset *HeapUpdateModifiedIdxAttrs(Relation relation, + HeapTuple oldtup, HeapTuple newtup); +static HeapTuple heap_form_hot_indexed_tuple(HeapTuple tup, int relnatts, + const Bitmapset *modified_idx_attrs); static bool heap_acquire_tuplock(Relation relation, const ItemPointerData *tid, LockTupleMode mode, LockWaitPolicy wait_policy, bool *have_tuple_lock); @@ -2106,9 +2113,28 @@ heap_insert(Relation relation, HeapTuple tup, CommandId cid, * If this is the single and first tuple on page, we can reinit the * page instead of restoring the whole thing. Set flag, and hide * buffer references from XLogInsert. + * + * Also require that the page's tuple area contains nothing other than + * this tuple. Vacuum's lp_truncate_only second pass + * (PRUNE_VACUUM_CLEANUP) does not call PageRepairFragmentation, so a + * page can legitimately end up with one LP_UNUSED slot at offset 1 + * plus orphan tuple bytes left over from the previous lifetime. If + * heap_insert reuses that LP_UNUSED slot, primary's page keeps the + * orphan bytes while a standby replaying INSERT+INIT zeroes them. + * Emitting INSERT+INIT in that case trips wal_consistency_checking. + * Falling back to a regular INSERT (with the FPI on first touch after + * a checkpoint) keeps replay byte-identical without sacrificing crash + * safety. + * + * NOTE: heap_multi_insert() does not need this extra check: it uses the + * simpler starting_with_empty_page (PageGetMaxOffsetNumber(page) == 0) + * gate for its own INIT_PAGE decision, because by construction it is + * never handed a page with leftover orphan bytes from a prior lifetime. */ if (ItemPointerGetOffsetNumber(&(heaptup->t_self)) == FirstOffsetNumber && - PageGetMaxOffsetNumber(page) == FirstOffsetNumber) + PageGetMaxOffsetNumber(page) == FirstOffsetNumber && + ((PageHeader) page)->pd_upper == + ((PageHeader) page)->pd_special - MAXALIGN(heaptup->t_len)) { info |= XLOG_HEAP_INIT_PAGE; bufflags |= REGBUF_WILL_INIT; @@ -3190,7 +3216,7 @@ simple_heap_delete(Relation relation, const ItemPointerData *tid) * heap_update - replace a tuple * * See table_tuple_update() for an explanation of the parameters, except that - * this routine directly takes a tuple rather than a slot. + * this routine directly takes a heap tuple rather than a slot. * * In the failure cases, the routine fills *tmfd with the tuple's t_ctid, * t_xmax (resolving a possible MultiXact, if necessary), and t_cmax (the last @@ -3199,18 +3225,16 @@ simple_heap_delete(Relation relation, const ItemPointerData *tid) */ TM_Result heap_update(Relation relation, const ItemPointerData *otid, HeapTuple newtup, - CommandId cid, uint32 options pg_attribute_unused(), Snapshot crosscheck, bool wait, - TM_FailureData *tmfd, LockTupleMode *lockmode, - TU_UpdateIndexes *update_indexes) + CommandId cid, uint32 options pg_attribute_unused(), + Snapshot crosscheck, bool wait, + TM_FailureData *tmfd, LockTupleMode lockmode, + const Bitmapset *modified_idx_attrs, + HeapUpdateIndexMode hot_mode) { TM_Result result; TransactionId xid = GetCurrentTransactionId(); - Bitmapset *hot_attrs; - Bitmapset *sum_attrs; - Bitmapset *key_attrs; - Bitmapset *id_attrs; - Bitmapset *interesting_attrs; - Bitmapset *modified_attrs; + Bitmapset *idx_attrs, + *id_attrs; ItemId lp; HeapTupleData oldtup; HeapTuple heaptup; @@ -3231,13 +3255,15 @@ heap_update(Relation relation, const ItemPointerData *otid, HeapTuple newtup, bool have_tuple_lock = false; bool iscombo; bool use_hot_update = false; - bool summarized_update = false; + bool hot_indexed = false; /* HOT-indexed update (modified an + * indexed attr but stayed HOT) */ + Size hi_bmbytes = 0; /* trailing modified-attrs bitmap size, if any */ bool key_intact; bool all_visible_cleared = false; bool all_visible_cleared_new = false; bool checked_lockers; bool locker_remains; - bool id_has_external = false; + bool rep_id_key_required = false; TransactionId xmax_new_tuple, xmax_old_tuple; uint16 infomask_old_tuple, @@ -3268,33 +3294,18 @@ heap_update(Relation relation, const ItemPointerData *otid, HeapTuple newtup, #endif /* - * Fetch the list of attributes to be checked for various operations. - * - * For HOT considerations, this is wasted effort if we fail to update or - * have to put the new tuple on a different page. But we must compute the - * list before obtaining buffer lock --- in the worst case, if we are - * doing an update on one of the relevant system catalogs, we could - * deadlock if we try to fetch the list later. In any case, the relcache - * caches the data so this is usually pretty cheap. + * Fetch the attributes used across all indexes on this relation as well + * as the replica identity and columns. * - * We also need columns used by the replica identity and columns that are - * considered the "key" of rows in the table. - * - * Note that we get copies of each bitmap, so we need not worry about - * relcache flush happening midway through. - */ - hot_attrs = RelationGetIndexAttrBitmap(relation, - INDEX_ATTR_BITMAP_HOT_BLOCKING); - sum_attrs = RelationGetIndexAttrBitmap(relation, - INDEX_ATTR_BITMAP_SUMMARIZED); - key_attrs = RelationGetIndexAttrBitmap(relation, INDEX_ATTR_BITMAP_KEY); - id_attrs = RelationGetIndexAttrBitmap(relation, - INDEX_ATTR_BITMAP_IDENTITY_KEY); - interesting_attrs = NULL; - interesting_attrs = bms_add_members(interesting_attrs, hot_attrs); - interesting_attrs = bms_add_members(interesting_attrs, sum_attrs); - interesting_attrs = bms_add_members(interesting_attrs, key_attrs); - interesting_attrs = bms_add_members(interesting_attrs, id_attrs); + * Note: We must compute the list before obtaining buffer lock. In the + * worst case, if we are doing an update on one of the relevant system + * catalogs, we could deadlock if we try to fetch the list later. Keep in + * mind that relcache returns copies of each bitmap, so we need not worry + * about relcache flush happening midway through, but we do need to free + * them. + */ + idx_attrs = RelationGetIndexAttrBitmap(relation, INDEX_ATTR_BITMAP_INDEXED); + id_attrs = RelationGetIndexAttrBitmap(relation, INDEX_ATTR_BITMAP_IDENTITY_KEY); block = ItemPointerGetBlockNumber(otid); INJECTION_POINT("heap_update-before-pin", NULL); @@ -3348,20 +3359,17 @@ heap_update(Relation relation, const ItemPointerData *otid, HeapTuple newtup, tmfd->ctid = *otid; tmfd->xmax = InvalidTransactionId; tmfd->cmax = InvalidCommandId; - *update_indexes = TU_None; - bms_free(hot_attrs); - bms_free(sum_attrs); - bms_free(key_attrs); bms_free(id_attrs); - /* modified_attrs not yet initialized */ - bms_free(interesting_attrs); + bms_free(idx_attrs); + /* modified_idx_attrs is owned by the caller, don't free it */ + return TM_Deleted; } /* - * Fill in enough data in oldtup for HeapDetermineColumnsInfo to work - * properly. + * Fill in enough data in oldtup to determine replica identity attribute + * requirements. */ oldtup.t_tableOid = RelationGetRelid(relation); oldtup.t_data = (HeapTupleHeader) PageGetItem(page, lp); @@ -3372,16 +3380,59 @@ heap_update(Relation relation, const ItemPointerData *otid, HeapTuple newtup, newtup->t_tableOid = RelationGetRelid(relation); /* - * Determine columns modified by the update. Additionally, identify - * whether any of the unmodified replica identity key attributes in the - * old tuple is externally stored or not. This is required because for - * such attributes the flattened value won't be WAL logged as part of the - * new tuple so we must include it as part of the old_key_tuple. See - * ExtractReplicaIdentity. + * ExtractReplicaIdentity() needs to know if a modified indexed attribute + * is used as a replica identity or if any of the replica identity + * attributes are referenced in an index, unmodified, and are stored + * externally in the old tuple being replaced. In those cases it may be + * necessary to WAL log them so they are available to replicas. */ - modified_attrs = HeapDetermineColumnsInfo(relation, interesting_attrs, - id_attrs, &oldtup, - newtup, &id_has_external); + rep_id_key_required = bms_overlap(modified_idx_attrs, id_attrs); + if (!rep_id_key_required) + { + Bitmapset *attrs; + TupleDesc tupdesc = RelationGetDescr(relation); + int attidx = -1; + + /* + * Reduce the set under review to only the unmodified indexed replica + * identity key attributes. idx_attrs is copied (by bms_difference()) + * not modified here. + */ + attrs = bms_difference(idx_attrs, modified_idx_attrs); + attrs = bms_int_members(attrs, id_attrs); + + while ((attidx = bms_next_member(attrs, attidx)) >= 0) + { + /* + * attidx is zero-based, attrnum is the normal attribute number + */ + AttrNumber attrnum = attidx + FirstLowInvalidHeapAttributeNumber; + Datum value; + bool isnull; + + /* + * System attributes are not added into INDEX_ATTR_BITMAP_INDEXED + * bitmap by relcache. + */ + Assert(attrnum > 0); + + value = heap_getattr(&oldtup, attrnum, tupdesc, &isnull); + + /* No need to check attributes that can't be stored externally */ + if (isnull || + TupleDescCompactAttr(tupdesc, attrnum - 1)->attlen != -1) + continue; + + /* Check if the old tuple's attribute is stored externally */ + if (VARATT_IS_EXTERNAL((struct varlena *) DatumGetPointer(value))) + { + rep_id_key_required = true; + break; + } + } + + bms_free(attrs); + } /* * If we're not updating any "key" column, we can grab a weaker lock type. @@ -3394,9 +3445,8 @@ heap_update(Relation relation, const ItemPointerData *otid, HeapTuple newtup, * is updates that don't manipulate key columns, not those that * serendipitously arrive at the same key values. */ - if (!bms_overlap(modified_attrs, key_attrs)) + if (lockmode == LockTupleNoKeyExclusive) { - *lockmode = LockTupleNoKeyExclusive; mxact_status = MultiXactStatusNoKeyUpdate; key_intact = true; @@ -3413,7 +3463,7 @@ heap_update(Relation relation, const ItemPointerData *otid, HeapTuple newtup, } else { - *lockmode = LockTupleExclusive; + Assert(lockmode == LockTupleExclusive); mxact_status = MultiXactStatusUpdate; key_intact = false; } @@ -3492,7 +3542,7 @@ heap_update(Relation relation, const ItemPointerData *otid, HeapTuple newtup, bool current_is_member = false; if (DoesMultiXactIdConflict((MultiXactId) xwait, infomask, - *lockmode, ¤t_is_member)) + lockmode, ¤t_is_member)) { LockBuffer(buffer, BUFFER_LOCK_UNLOCK); @@ -3501,7 +3551,7 @@ heap_update(Relation relation, const ItemPointerData *otid, HeapTuple newtup, * requesting a lock and already have one; avoids deadlock). */ if (!current_is_member) - heap_acquire_tuplock(relation, &(oldtup.t_self), *lockmode, + heap_acquire_tuplock(relation, &(oldtup.t_self), lockmode, LockWaitBlock, &have_tuple_lock); /* wait for multixact */ @@ -3586,7 +3636,7 @@ heap_update(Relation relation, const ItemPointerData *otid, HeapTuple newtup, * lock. */ LockBuffer(buffer, BUFFER_LOCK_UNLOCK); - heap_acquire_tuplock(relation, &(oldtup.t_self), *lockmode, + heap_acquire_tuplock(relation, &(oldtup.t_self), lockmode, LockWaitBlock, &have_tuple_lock); XactLockTableWait(xwait, relation, &oldtup.t_self, XLTW_Update); @@ -3646,17 +3696,14 @@ heap_update(Relation relation, const ItemPointerData *otid, HeapTuple newtup, tmfd->cmax = InvalidCommandId; UnlockReleaseBuffer(buffer); if (have_tuple_lock) - UnlockTupleTuplock(relation, &(oldtup.t_self), *lockmode); + UnlockTupleTuplock(relation, &(oldtup.t_self), lockmode); if (vmbuffer != InvalidBuffer) ReleaseBuffer(vmbuffer); - *update_indexes = TU_None; - bms_free(hot_attrs); - bms_free(sum_attrs); - bms_free(key_attrs); bms_free(id_attrs); - bms_free(modified_attrs); - bms_free(interesting_attrs); + bms_free(idx_attrs); + /* modified_idx_attrs is owned by the caller, don't free it */ + return result; } @@ -3686,7 +3733,7 @@ heap_update(Relation relation, const ItemPointerData *otid, HeapTuple newtup, compute_new_xmax_infomask(HeapTupleHeaderGetRawXmax(oldtup.t_data), oldtup.t_data->t_infomask, oldtup.t_data->t_infomask2, - xid, *lockmode, true, + xid, lockmode, true, &xmax_old_tuple, &infomask_old_tuple, &infomask2_old_tuple); @@ -3775,6 +3822,38 @@ heap_update(Relation relation, const ItemPointerData *otid, HeapTuple newtup, newtupsize = MAXALIGN(newtup->t_len); + /* + * Keep HOT-indexed (SIU) chains uniform. HeapUpdateHotAllowable returns + * HEAP_HEAP_ONLY_UPDATE whenever this update modifies no indexed + * attribute. But if the tuple being updated is already a HOT-indexed + * chain member (it carries HEAP_INDEXED_UPDATED), emitting a classic-HOT + * version would splice a non-HEAP_INDEXED_UPDATED tuple into the chain. + * The prune/collapse machinery forwards only HEAP_INDEXED_UPDATED members + * through bridges, so such a classic-HOT version, once it dies mid + * collapsed-chain, has no handler and trips the "not linked to from any + * HOT chain" error. Promote to HEAP_SELECTIVE_INDEX_UPDATE instead: with an + * empty modified-attrs set the new version carries HEAP_INDEXED_UPDATED + * and an empty inline-trailing bitmap, inserts into no index (nothing + * changed), and keeps every chain member uniform. Catalog relations are + * classic-HOT only and never carry HEAP_INDEXED_UPDATED, so this never + * fires for them. + */ + if (hot_mode == HEAP_HEAP_ONLY_UPDATE && + (oldtup.t_data->t_infomask2 & HEAP_INDEXED_UPDATED) != 0) + hot_mode = HEAP_SELECTIVE_INDEX_UPDATE; + + /* + * A HOT-indexed update appends a fixed-size inline-trailing + * modified-attrs bitmap to the new tuple (see access/hot_indexed.h). + * Reserve room for it in the page-fit calculation now, while we still + * might take the same-page HOT path; if the update later drops to non-HOT + * (the tuple does not fit on the page) it is stored without the bitmap and + * the reservation is simply conservative. + */ + if (hot_mode == HEAP_SELECTIVE_INDEX_UPDATE) + hi_bmbytes = HotIndexedBitmapBytes(RelationGetNumberOfAttributes(relation)); + newtupsize = MAXALIGN(newtup->t_len + hi_bmbytes); + if (need_toast || newtupsize > pagefree) { TransactionId xmax_lock_old_tuple; @@ -3803,7 +3882,7 @@ heap_update(Relation relation, const ItemPointerData *otid, HeapTuple newtup, compute_new_xmax_infomask(HeapTupleHeaderGetRawXmax(oldtup.t_data), oldtup.t_data->t_infomask, oldtup.t_data->t_infomask2, - xid, *lockmode, false, + xid, lockmode, false, &xmax_lock_old_tuple, &infomask_lock_old_tuple, &infomask2_lock_old_tuple); @@ -3872,7 +3951,7 @@ heap_update(Relation relation, const ItemPointerData *otid, HeapTuple newtup, { /* Note we always use WAL and FSM during updates */ heaptup = heap_toast_insert_or_update(relation, newtup, &oldtup, 0); - newtupsize = MAXALIGN(heaptup->t_len); + newtupsize = MAXALIGN(heaptup->t_len + hi_bmbytes); } else heaptup = newtup; @@ -3973,23 +4052,11 @@ heap_update(Relation relation, const ItemPointerData *otid, HeapTuple newtup, { /* * Since the new tuple is going into the same page, we might be able - * to do a HOT update. Check if any of the index columns have been - * changed. + * to do a HOT update. Check if HeapUpdateHotAllowable() has + * sanctioned it (HEAP_HEAP_ONLY_UPDATE or HEAP_SELECTIVE_INDEX_UPDATE). */ - if (!bms_overlap(modified_attrs, hot_attrs)) - { + if (hot_mode != HEAP_UPDATE_ALL_INDEXES) use_hot_update = true; - - /* - * If none of the columns that are used in hot-blocking indexes - * were updated, we can apply HOT, but we do still need to check - * if we need to update the summarizing indexes, and update those - * indexes if the columns were updated, or we may fail to detect - * e.g. value bound changes in BRIN minmax indexes. - */ - if (bms_overlap(modified_attrs, sum_attrs)) - summarized_update = true; - } } else { @@ -3997,6 +4064,27 @@ heap_update(Relation relation, const ItemPointerData *otid, HeapTuple newtup, PageSetFull(page); } + /* + * For a same-page HOT-indexed update, replace heaptup with a copy that + * carries the inline-trailing modified-attrs bitmap (and + * HEAP_INDEXED_UPDATED). Done here, outside the critical section, + * because it allocates; the bitmap's size was reserved in newtupsize + * above. Only the stored tuple (heaptup) gets the bitmap and the flag; + * the caller's newtup must NOT be marked HEAP_INDEXED_UPDATED, because it + * has no trailing bitmap -- see the flag handling below. + */ + if (use_hot_update && hot_mode == HEAP_SELECTIVE_INDEX_UPDATE) + { + HeapTuple ext; + + ext = heap_form_hot_indexed_tuple(heaptup, + RelationGetNumberOfAttributes(relation), + modified_idx_attrs); + if (heaptup != newtup) + heap_freetuple(heaptup); + heaptup = ext; + } + /* * Compute replica identity tuple before entering the critical section so * we don't PANIC upon a memory allocation failure. @@ -4005,8 +4093,7 @@ heap_update(Relation relation, const ItemPointerData *otid, HeapTuple newtup, * columns are modified or it has external data. */ old_key_tuple = ExtractReplicaIdentity(relation, &oldtup, - bms_overlap(modified_attrs, id_attrs) || - id_has_external, + rep_id_key_required, &old_key_copied); /* NO EREPORT(ERROR) from here till changes are logged */ @@ -4034,6 +4121,28 @@ heap_update(Relation relation, const ItemPointerData *otid, HeapTuple newtup, HeapTupleSetHeapOnly(heaptup); /* Mark the caller's copy too, in case different from heaptup */ HeapTupleSetHeapOnly(newtup); + + /* + * For a HOT-indexed update, the new live tuple carries + * HEAP_INDEXED_UPDATED so index scans walking the chain know it is a + * HOT-indexed hop carrying an inline-trailing modified-attrs bitmap. + * heap_form_hot_indexed_tuple() (which produced heaptup above) is the + * single authoritative site that sets the flag, on the version actually + * stored on the page -- heaptup carries the trailing bitmap, so the + * flag's promise (a bitmap occupies the final HotIndexedBitmapBytes(natts) + * bytes of the item) holds. The caller's newtup is a separate in-memory + * tuple whose t_len does not include the bitmap; it is deliberately left + * unflagged (marking it HEAP_INDEXED_UPDATED would assert a trailing + * bitmap that is not there). Nothing reads the modified-attrs bitmap off + * an in-memory tuple; every consumer reads it from the page via the line + * pointer's length. Here we only need to record that this was a + * HOT-indexed hop for the post-update bookkeeping below. + */ + if (hot_mode == HEAP_SELECTIVE_INDEX_UPDATE) + { + Assert((heaptup->t_data->t_infomask2 & HEAP_INDEXED_UPDATED) != 0); + hot_indexed = true; + } } else { @@ -4136,9 +4245,10 @@ heap_update(Relation relation, const ItemPointerData *otid, HeapTuple newtup, * Release the lmgr tuple lock, if we had it. */ if (have_tuple_lock) - UnlockTupleTuplock(relation, &(oldtup.t_self), *lockmode); + UnlockTupleTuplock(relation, &(oldtup.t_self), lockmode); - pgstat_count_heap_update(relation, use_hot_update, newbuf != buffer); + pgstat_count_heap_update(relation, use_hot_update, hot_indexed, + newbuf != buffer); /* * If heaptup is a private copy, release it. Don't forget to copy t_self @@ -4150,31 +4260,12 @@ heap_update(Relation relation, const ItemPointerData *otid, HeapTuple newtup, heap_freetuple(heaptup); } - /* - * If it is a HOT update, the update may still need to update summarized - * indexes, lest we fail to update those summaries and get incorrect - * results (for example, minmax bounds of the block may change with this - * update). - */ - if (use_hot_update) - { - if (summarized_update) - *update_indexes = TU_Summarizing; - else - *update_indexes = TU_None; - } - else - *update_indexes = TU_All; - if (old_key_tuple != NULL && old_key_copied) heap_freetuple(old_key_tuple); - bms_free(hot_attrs); - bms_free(sum_attrs); - bms_free(key_attrs); bms_free(id_attrs); - bms_free(modified_attrs); - bms_free(interesting_attrs); + bms_free(idx_attrs); + /* modified_idx_attrs is owned by the caller, don't free it */ return TM_Ok; } @@ -4303,7 +4394,7 @@ check_inplace_rel_lock(HeapTuple oldtup) /* * Check if the specified attribute's values are the same. Subroutine for - * HeapDetermineColumnsInfo. + * HeapUpdateModifiedIdxAttrs. */ static bool heap_attr_equals(TupleDesc tupdesc, int attrnum, Datum value1, Datum value2, @@ -4347,28 +4438,203 @@ heap_attr_equals(TupleDesc tupdesc, int attrnum, Datum value1, Datum value2, } /* - * Check which columns are being updated. - * - * Given an updated tuple, determine (and return into the output bitmapset), - * from those listed as interesting, the set of columns that changed. - * - * has_external indicates if any of the unmodified attributes (from those - * listed as interesting) of the old tuple is a member of external_cols and is - * stored externally. + * HeapUpdateHotAllowable -- + * + * Classify an UPDATE for HOT eligibility from the set of indexed attributes + * it changed (modified_idx_attrs, computed by the executor): + * + * HEAP_UPDATE_ALL_INDEXES HOT is not permitted; the new tuple goes on a + * fresh TID and every index gets a new entry. + * HEAP_HEAP_ONLY_UPDATE Classic HOT: no non-summarizing indexed + * attribute changed, so no index needs a new + * entry and the new tuple joins the chain via a + * t_ctid forward link. + * HEAP_SELECTIVE_INDEX_UPDATE HOT with selective index update: at least one + * non-summarizing index's attribute changed, but + * the new tuple can still join the HOT chain on + * the same page; only the indexes whose + * attributes changed receive a new entry. + * + * This routine only classifies the update; heap_update() performs it and may + * still fall back to a non-HOT update when the new tuple does not fit on the + * page, exactly as for classic HOT. + */ +HeapUpdateIndexMode +HeapUpdateHotAllowable(Relation relation, const Bitmapset *modified_idx_attrs) +{ + const Bitmapset *all_idx_attrs; + + /* + * Case (a): no indexed attribute was modified -> classic HOT. + */ + if (bms_is_empty(modified_idx_attrs)) + return HEAP_HEAP_ONLY_UPDATE; + + /* + * Case (b): at least one indexed attribute changed. If all of them are + * used only by summarizing indexes, we can still take the classic HOT + * path -- the summarizing index AM gets a new entry via aminsert and no + * non-summarizing index needs to change. + */ + if (bms_is_subset(modified_idx_attrs, RelationGetIndexAttrBitmapNoCopy(relation, + INDEX_ATTR_BITMAP_SUMMARIZED))) + return HEAP_HEAP_ONLY_UPDATE; + + /* + * A non-summarizing indexed attribute changed. HOT-indexed is supported + * whenever the relation can tolerate extra index entries in a chain whose + * per-chain-member keys may differ. The logical-replication apply path + * is gated above by hot_indexed_on_apply. The remaining + * HEAP_UPDATE_ALL_INDEXES fallbacks are: + * + * - An UPDATE that modifies an attribute referenced by an expression + * index. Selective maintenance of an expression index requires + * evaluating the indexed expression to decide whether its value (hence + * its entry) changed; that expression-aware path is not implemented yet, + * so such an update falls back to non-HOT. Updates that do not touch any + * expression-index attribute stay eligible. + * + * - An UPDATE that modifies every indexed attribute of the relation. + * HOT-indexed only pays off when it can skip maintaining at least one + * index whose key did not change; if all indexed attributes changed there + * is nothing to skip, so a plain non-HOT update is cheaper (it avoids the + * chain-walk and bitmap-overlap overhead). + */ + all_idx_attrs = RelationGetIndexAttrBitmapNoCopy(relation, + INDEX_ATTR_BITMAP_INDEXED); + + /* + * The logical-replication apply path gates HOT-indexed updates on the + * per-subscription hot_indexed_on_apply option. A HOT-indexed update of + * a replica-identity attribute leaves a stale index leaf; the apply + * worker's replica-identity lookups cope with that (see + * RelationFindReplTupleByIndex), but only when the indexed attributes are + * a subset of the replica identity. "off" disqualifies whenever the + * subscriber has any indexed attribute beyond its PK; "subset_only" (the + * default) requires the indexed attributes to be a subset of the PK; + * "always" applies no apply-path gating. + */ + if (IsLogicalWorker()) + { + char mode = GetHotIndexedApplyMode(); + const Bitmapset *pk_attrs = RelationGetIndexAttrBitmapNoCopy(relation, + INDEX_ATTR_BITMAP_PRIMARY_KEY); + + if (mode == LOGICALREP_HOT_INDEXED_OFF) + { + if (!bms_equal(all_idx_attrs, pk_attrs)) + return HEAP_UPDATE_ALL_INDEXES; + } + else if (mode == LOGICALREP_HOT_INDEXED_SUBSET_ONLY) + { + if (!bms_is_subset(all_idx_attrs, pk_attrs)) + return HEAP_UPDATE_ALL_INDEXES; + } + /* LOGICALREP_HOT_INDEXED_ALWAYS: no apply-path gating. */ + } + + /* + * System catalogs keep classic HOT (an UPDATE touching no non-summarizing + * indexed attribute already returned HEAP_HEAP_ONLY_UPDATE above), but do + * NOT take the HOT-indexed path: catalog reads go through many code paths + * (systable index scans, SnapshotDirty unique checks, seqscans in + * orderings the chain-walk dedup does not cover) that are not all proven + * safe against stale chain entries. Falling back to a non-HOT update + * here is exactly the pre-HOT-indexed behaviour for such catalog updates. + */ + if (IsCatalogRelation(relation)) + return HEAP_UPDATE_ALL_INDEXES; + + /* + * Disqualify when the update touches an attribute referenced by an + * expression index (see case 1 above). Updates that leave every + * expression-index attribute unchanged remain eligible. + */ + if (bms_overlap(modified_idx_attrs, + RelationGetIndexAttrBitmapNoCopy(relation, + INDEX_ATTR_BITMAP_EXPRESSION))) + return HEAP_UPDATE_ALL_INDEXES; + + /* + * If every indexed attribute changed, a HOT-selective update could not + * skip any index -- each index needs a fresh entry anyway -- so it would + * pay the HOT/SIU chain-walk and bitmap-overlap overhead for no saved + * index maintenance. Fall back to a plain non-HOT update in that case. + */ + if (bms_is_subset(all_idx_attrs, modified_idx_attrs)) + return HEAP_UPDATE_ALL_INDEXES; + + return HEAP_SELECTIVE_INDEX_UPDATE; +} + +/* + * If we're not updating any attributes used when forming the index keys we can + * grab a weaker lock type. This allows for more concurrency when we are + * running simultaneously with foreign key checks. + */ +LockTupleMode +HeapUpdateDetermineLockmode(Relation relation, const Bitmapset *modified_idx_attrs) +{ + LockTupleMode lockmode = LockTupleExclusive; + const Bitmapset *key_attrs; + + /* + * Common fast path: when no indexed attribute changed (e.g. pgbench-style + * "UPDATE t SET non_idx_col = ..." or the wide_0 "UPDATE t SET id = id" + * workload after the executor's fast path in ExecUpdateModifiedIdxAttrs), + * modified_idx_attrs is empty and a key column cannot have changed. Skip + * the relcache lookup and return the weaker lock immediately. At high + * TPS this avoids a per-UPDATE RelationGetIndexAttrBitmap call (and its + * bms_copy) on the KEY bitmap. + */ + if (bms_is_empty(modified_idx_attrs)) + return LockTupleNoKeyExclusive; + + /* + * Borrow the cached bitmap rather than copying it; we only test overlap + * and never mutate or free key_attrs. HeapUpdateDetermineLockmode runs + * without buffer locks but the relcache entry is pinned by the caller's + * lock on the relation, and we touch nothing between fetch and the + * bms_overlap that could trigger a relcache invalidation. + */ + key_attrs = RelationGetIndexAttrBitmapNoCopy(relation, + INDEX_ATTR_BITMAP_KEY); + + if (!bms_overlap(modified_idx_attrs, key_attrs)) + lockmode = LockTupleNoKeyExclusive; + + return lockmode; +} + +/* + * Return a Bitmapset that contains the set of modified (changed) indexed + * attributes between oldtup and newtup. */ static Bitmapset * -HeapDetermineColumnsInfo(Relation relation, - Bitmapset *interesting_cols, - Bitmapset *external_cols, - HeapTuple oldtup, HeapTuple newtup, - bool *has_external) +HeapUpdateModifiedIdxAttrs(Relation relation, HeapTuple oldtup, HeapTuple newtup) { int attidx; - Bitmapset *modified = NULL; + Bitmapset *attrs, + *modified_idx_attrs = NULL; TupleDesc tupdesc = RelationGetDescr(relation); + /* Get the set of all attributes across all indexes for this relation */ + attrs = RelationGetIndexAttrBitmap(relation, INDEX_ATTR_BITMAP_INDEXED); + + /* No indexed attributes, we're done */ + if (bms_is_empty(attrs)) + return NULL; + + /* + * This heap update function is used outside the executor and so unlike + * heapam_tuple_update() where there is ResultRelInfo and EState to + * provide the concise set of attributes that might have been modified + * (via ExecGetAllUpdatedCols()) we simply check all indexed attributes to + * find the subset that changed value. That's the "modified indexed + * attributes" or "modified_idx_attrs". + */ attidx = -1; - while ((attidx = bms_next_member(interesting_cols, attidx)) >= 0) + while ((attidx = bms_next_member(attrs, attidx)) >= 0) { /* attidx is zero-based, attrnum is the normal attribute number */ AttrNumber attrnum = attidx + FirstLowInvalidHeapAttributeNumber; @@ -4384,7 +4650,7 @@ HeapDetermineColumnsInfo(Relation relation, */ if (attrnum == 0) { - modified = bms_add_member(modified, attidx); + modified_idx_attrs = bms_add_member(modified_idx_attrs, attidx); continue; } @@ -4397,7 +4663,7 @@ HeapDetermineColumnsInfo(Relation relation, { if (attrnum != TableOidAttributeNumber) { - modified = bms_add_member(modified, attidx); + modified_idx_attrs = bms_add_member(modified_idx_attrs, attidx); continue; } } @@ -4413,29 +4679,77 @@ HeapDetermineColumnsInfo(Relation relation, if (!heap_attr_equals(tupdesc, attrnum, value1, value2, isnull1, isnull2)) - { - modified = bms_add_member(modified, attidx); - continue; - } + modified_idx_attrs = bms_add_member(modified_idx_attrs, attidx); + } - /* - * No need to check attributes that can't be stored externally. Note - * that system attributes can't be stored externally. - */ - if (attrnum < 0 || isnull1 || - TupleDescCompactAttr(tupdesc, attrnum - 1)->attlen != -1) - continue; + bms_free(attrs); - /* - * Check if the old tuple's attribute is stored externally and is a - * member of external_cols. - */ - if (VARATT_IS_EXTERNAL((varlena *) DatumGetPointer(value1)) && - bms_is_member(attidx, external_cols)) - *has_external = true; + return modified_idx_attrs; +} + +/* + * heap_form_hot_indexed_tuple + * + * Return a newly palloc'd copy of tup that carries the fixed-size + * inline-trailing modified-attributes bitmap (see access/hot_indexed.h), + * with HEAP_INDEXED_UPDATED set. The bitmap records the user attributes in + * modified_idx_attrs (the indexed attributes this UPDATE changed, using the + * FirstLowInvalidHeapAttributeNumber offset convention); an empty set yields + * an all-zero bitmap, which is correct for the chain-uniformity promotion of + * a classic-HOT update on an already-HOT-indexed chain. + * + * The bitmap occupies the final HotIndexedBitmapBytes(natts) bytes of the + * tuple, where natts is the tuple's own attribute count + * (HeapTupleHeaderGetNatts) -- which a reader recovers from the stored tuple, + * so the bitmap stays locatable even after the relation's natts later grows + * via ADD COLUMN. For a freshly formed UPDATE tuple this equals the + * relation's current natts; we assert that to catch any future divergence. + * The bitmap sits past the attribute data, so heap_deform_tuple never sees + * it. The caller must have reserved room for the extra bytes in the page-fit + * calculation, and must free the returned tuple. + */ +static HeapTuple +heap_form_hot_indexed_tuple(HeapTuple tup, int relnatts, + const Bitmapset *modified_idx_attrs) +{ + int natts = HeapTupleHeaderGetNatts(tup->t_data); + Size bmbytes; + Size newlen; + HeapTuple newtuple; + uint8 *bitmap; + int x = -1; + + /* + * The bitmap is sized and located by the tuple's own natts; a freshly + * formed UPDATE tuple carries the full relation natts. If these ever + * diverge the page-fit reservation (made with relnatts) and the actual + * bitmap size would disagree. + */ + Assert(natts == relnatts); + bmbytes = HotIndexedBitmapBytes(natts); + newlen = tup->t_len + bmbytes; + + newtuple = (HeapTuple) palloc0(HEAPTUPLESIZE + newlen); + newtuple->t_len = newlen; + newtuple->t_self = tup->t_self; + newtuple->t_tableOid = tup->t_tableOid; + newtuple->t_data = (HeapTupleHeader) ((char *) newtuple + HEAPTUPLESIZE); + + /* copy the original tuple; the trailing bitmap bytes stay zero */ + memcpy(newtuple->t_data, tup->t_data, tup->t_len); + newtuple->t_data->t_infomask2 |= HEAP_INDEXED_UPDATED; + + bitmap = HotIndexedGetModifiedBitmapRW(newtuple->t_data, newlen, natts); + while ((x = bms_next_member(modified_idx_attrs, x)) >= 0) + { + AttrNumber attnum = x + FirstLowInvalidHeapAttributeNumber; + + /* only user attributes can be modified-and-indexed */ + if (attnum >= 1) + HotIndexedSetAttrModified(bitmap, attnum); } - return modified; + return newtuple; } /* @@ -4448,17 +4762,91 @@ HeapDetermineColumnsInfo(Relation relation, */ void simple_heap_update(Relation relation, const ItemPointerData *otid, HeapTuple tup, - TU_UpdateIndexes *update_indexes) + bool *update_all_indexes) { TM_Result result; TM_FailureData tmfd; LockTupleMode lockmode; + TupleTableSlot *slot; + BufferHeapTupleTableSlot *bslot; + HeapTuple oldtup; + bool shouldFree = true; + Bitmapset *local_modified_idx_attrs; + HeapUpdateIndexMode hot_mode; + Buffer buffer; + + Assert(ItemPointerIsValid(otid)); + + *update_all_indexes = false; + + /* + * To update a heap tuple we need to find the set of modified indexed + * attributes ("modified_idx_attrs") and use that to determine if a HOT + * update is allowable or not. When updating heap tuples via execution of + * UPDATE statements this set is constructed before calling into the table + * AM's update function by ExecUpdateModifiedIdxAttrs() which compares the + * old/new TupleTableSlots. + * + * Here things are a bit different, we have the old TID and the new tuple, + * not two TupleTableSlots, but we still need to construct a similar + * bitmap so as to be able to know if HOT updates are allowed or not. + * + * To do that we first have to fetch the old tuple itself, but because + * heapam_fetch_row_version() is static, we replicate in part that code + * here. + * + * This is a bit repetitive because heap_update() will again find and form + * the old HeapTuple from the old TID and in most cases the callers + * (ignoring extensions, are always catalog tuple updates) already had the + * set of changed attributes (the "replaces" array), but for now this + * minor repetition of work is necessary. + */ + INJECTION_POINT("simple_heap_update-before-pin", NULL); + + slot = MakeTupleTableSlot(RelationGetDescr(relation), &TTSOpsBufferHeapTuple, 0); + bslot = (BufferHeapTupleTableSlot *) slot; + + /* + * Set the TID in the slot and then fetch the old tuple so we can examine + * it + */ + bslot->base.tupdata.t_self = *otid; + if (!heap_fetch(relation, SnapshotAny, &bslot->base.tupdata, &buffer, false)) + { + /* + * heap_update() checks for !ItemIdIsNormal(lp) and will return false + * in those cases. + */ + Assert(RelationSupportsSysCache(RelationGetRelid(relation))); + + /* modified_idx_attrs not yet initialized */ + ExecDropSingleTupleTableSlot(slot); + + elog(ERROR, "tuple concurrently deleted"); + + return; + } + + Assert(buffer != InvalidBuffer); + + /* Store in slot, transferring existing pin */ + ExecStorePinnedBufferHeapTuple(&bslot->base.tupdata, slot, buffer); + oldtup = ExecFetchSlotHeapTuple(slot, false, &shouldFree); + + local_modified_idx_attrs = HeapUpdateModifiedIdxAttrs(relation, oldtup, tup); + lockmode = HeapUpdateDetermineLockmode(relation, local_modified_idx_attrs); + hot_mode = HeapUpdateHotAllowable(relation, local_modified_idx_attrs); + + result = heap_update(relation, otid, tup, GetCurrentCommandId(true), + 0 /* options */ , + InvalidSnapshot, true /* wait for commit */ , + &tmfd, lockmode, local_modified_idx_attrs, hot_mode); + + if (shouldFree) + heap_freetuple(oldtup); + + ExecDropSingleTupleTableSlot(slot); - result = heap_update(relation, otid, tup, - GetCurrentCommandId(true), 0, - InvalidSnapshot, - true /* wait for commit */ , - &tmfd, &lockmode, update_indexes); switch (result) { case TM_SelfModified: @@ -4467,7 +4855,14 @@ simple_heap_update(Relation relation, const ItemPointerData *otid, HeapTuple tup break; case TM_Ok: - /* done successfully */ + + /* + * If the tuple stored by heap_update is heap-only this was a HOT + * update and (subject to per-index checks) not every index needs + * a new entry; otherwise every index must get one pointing at the + * new tuple's TID. + */ + *update_all_indexes = !HeapTupleIsHeapOnly(tup); break; case TM_Updated: @@ -4482,6 +4877,8 @@ simple_heap_update(Relation relation, const ItemPointerData *otid, HeapTuple tup elog(ERROR, "unrecognized heap_update status: %u", result); break; } + + bms_free(local_modified_idx_attrs); } @@ -8046,40 +8443,42 @@ index_delete_check_htid(TM_IndexDeleteOp *delstate, Assert(OffsetNumberIsValid(istatus->idxoffnum)); if (unlikely(indexpagehoffnum > maxoff)) - ereport(ERROR, - (errcode(ERRCODE_INDEX_CORRUPTED), - errmsg_internal("heap tid from index tuple (%u,%u) points past end of heap page line pointer array at offset %u of block %u in index \"%s\"", - ItemPointerGetBlockNumber(htid), - indexpagehoffnum, - istatus->idxoffnum, delstate->iblknum, - RelationGetRelationName(delstate->irel)))); + { + /* + * Under HOT-indexed updates, a stale btree entry can outlive heap + * pruning/vacuum of the page it targets; if the target offset is past + * the current max, treat as vacuumable instead of raising an + * index-corruption error. + */ + return; + } iid = PageGetItemId(page, indexpagehoffnum); if (unlikely(!ItemIdIsUsed(iid))) - ereport(ERROR, - (errcode(ERRCODE_INDEX_CORRUPTED), - errmsg_internal("heap tid from index tuple (%u,%u) points to unused heap page item at offset %u of block %u in index \"%s\"", - ItemPointerGetBlockNumber(htid), - indexpagehoffnum, - istatus->idxoffnum, delstate->iblknum, - RelationGetRelationName(delstate->irel)))); - - if (ItemIdHasStorage(iid)) { - HeapTupleHeader htup; - - Assert(ItemIdIsNormal(iid)); - htup = (HeapTupleHeader) PageGetItem(page, iid); - - if (unlikely(HeapTupleHeaderIsHeapOnly(htup))) - ereport(ERROR, - (errcode(ERRCODE_INDEX_CORRUPTED), - errmsg_internal("heap tid from index tuple (%u,%u) points to heap-only tuple at offset %u of block %u in index \"%s\"", - ItemPointerGetBlockNumber(htid), - indexpagehoffnum, - istatus->idxoffnum, delstate->iblknum, - RelationGetRelationName(delstate->irel)))); + /* + * Under HOT-indexed updates, a stale btree entry can legitimately + * point at an LP that has since been reclaimed to LP_UNUSED by + * pruning before VACUUM processed the index. Treat that as "the + * chain is vacuumable" (caller's downstream chain walk will reach the + * same conclusion) rather than an index-corruption error. + */ + return; } + + /* + * A redirect target (LP_REDIRECT) is a valid chain root: an index entry + * pointing at it is legitimate and the caller's chain walk decides + * deletability. Only genuinely normal tuples are inspected below. + * + * A normal tuple that is heap-only (HeapTupleHeaderIsHeapOnly) is also + * tolerated without further checks: a HOT-indexed update plants a fresh + * index entry that points directly at such a tuple (it carries + * HEAP_INDEXED_UPDATED), and a stale btree entry can likewise arrive at + * a heap-only tuple when its chain root was pruned out. Both are legal + * under HOT-indexed; the caller's chain walk decides whether the entry + * is deletable, so there is nothing to check here for that case. + */ } /* @@ -8293,7 +8692,7 @@ heap_index_delete_tuples(Relation rel, TM_IndexDeleteOp *delstate) /* Are any tuples from this HOT chain non-vacuumable? */ if (heap_hot_search_buffer(&tmp, rel, buf, &SnapshotNonVacuumable, - &heapTuple, NULL, true)) + &heapTuple, NULL, true, NULL, NULL, NULL)) continue; /* can't delete entry */ /* Caller will delete, since whole HOT chain is vacuumable */ @@ -8875,9 +9274,20 @@ log_heap_update(Relation reln, Buffer oldbuf, } } - /* If new tuple is the single and first tuple on page... */ + /* + * If new tuple is the single and first tuple on page, replay can reinit + * the page from scratch. + * + * Also require that the page's tuple area contains nothing other than this + * tuple. See heap_insert for why this matters when vacuum has left orphan + * tuple bytes behind an LP_UNUSED slot. + * + * NOTE: this must mirror the same logic in heap_insert() + */ if (ItemPointerGetOffsetNumber(&(newtup->t_self)) == FirstOffsetNumber && - PageGetMaxOffsetNumber(page) == FirstOffsetNumber) + PageGetMaxOffsetNumber(page) == FirstOffsetNumber && + ((PageHeader) page)->pd_upper == + ((PageHeader) page)->pd_special - MAXALIGN(newtup->t_len)) { info |= XLOG_HEAP_INIT_PAGE; init = true; @@ -8939,6 +9349,7 @@ log_heap_update(Relation reln, Buffer oldbuf, * The 'data' doesn't include the common prefix or suffix. */ XLogRegisterBufData(0, &xlhdr, SizeOfHeapHeader); + if (prefixlen == 0) { XLogRegisterBufData(0, diff --git a/src/backend/access/heap/heapam_handler.c b/src/backend/access/heap/heapam_handler.c index bf87430cf0176..7dd6613b988bb 100644 --- a/src/backend/access/heap/heapam_handler.c +++ b/src/backend/access/heap/heapam_handler.c @@ -27,10 +27,10 @@ #include "access/syncscan.h" #include "access/tableam.h" #include "access/tsmapi.h" -#include "access/visibilitymap.h" #include "access/xact.h" #include "catalog/catalog.h" #include "catalog/index.h" +#include "catalog/pg_am.h" #include "catalog/storage.h" #include "catalog/storage_xlog.h" #include "commands/progress.h" @@ -218,42 +218,38 @@ static TM_Result heapam_tuple_update(Relation relation, ItemPointer otid, TupleTableSlot *slot, CommandId cid, uint32 options, Snapshot snapshot, Snapshot crosscheck, - bool wait, TM_FailureData *tmfd, - LockTupleMode *lockmode, TU_UpdateIndexes *update_indexes) + bool wait, TM_FailureData *tmfd, LockTupleMode *lockmode, + Bitmapset **modified_attrs) { bool shouldFree = true; HeapTuple tuple = ExecFetchSlotHeapTuple(slot, true, &shouldFree); + HeapUpdateIndexMode hot_mode; TM_Result result; + Assert(ItemPointerIsValid(otid)); + + hot_mode = HeapUpdateHotAllowable(relation, *modified_attrs); + *lockmode = HeapUpdateDetermineLockmode(relation, *modified_attrs); + /* Update the tuple with table oid */ slot->tts_tableOid = RelationGetRelid(relation); tuple->t_tableOid = slot->tts_tableOid; result = heap_update(relation, otid, tuple, cid, options, crosscheck, wait, - tmfd, lockmode, update_indexes); + tmfd, *lockmode, *modified_attrs, hot_mode); ItemPointerCopy(&tuple->t_self, &slot->tts_tid); /* - * Decide whether new index entries are needed for the tuple - * - * Note: heap_update returns the tid (location) of the new tuple in the - * t_self field. - * - * If the update is not HOT, we must update all indexes. If the update is - * HOT, it could be that we updated summarized columns, so we either - * update only summarized indexes, or none at all. + * Tell the caller whether every index needs a new entry. If the new + * tuple is not heap-only the update was not HOT: it is an independent + * version requiring a fresh entry in every index, which we signal by + * adding the whole-row attribute to *modified_attrs. Otherwise (classic + * HOT or HOT-indexed) the caller consults the per-index attributes. */ - if (result != TM_Ok) - { - Assert(*update_indexes == TU_None); - *update_indexes = TU_None; - } - else if (!HeapTupleIsHeapOnly(tuple)) - Assert(*update_indexes == TU_All); - else - Assert((*update_indexes == TU_Summarizing) || - (*update_indexes == TU_None)); + if (result == TM_Ok && !HeapTupleIsHeapOnly(tuple)) + *modified_attrs = bms_add_member(*modified_attrs, + TableTupleUpdateAllIndexes); if (shouldFree) pfree(tuple); @@ -642,6 +638,45 @@ heapam_relation_copy_for_cluster(Relation OldHeap, Relation NewHeap, else bistate = NULL; + /* + * The index-scan copy path below trusts the clustering index to reach + * every live heap tuple directly. A HOT-indexed (SIU) chain breaks that: + * an UPDATE that changes some OTHER indexed column produces a live tuple + * that the clustering index has no entry pointing at (its entries still + * address earlier chain members), and until a prune/VACUUM collapses the + * chain the index scan cannot reach it, so it would be silently dropped + * from the rewritten heap. Any heap with more than one index is + * SIU-capable (selective maintenance needs a changed and an unchanged + * index). + * + * This is the heap AM's own invariant, so the heap AM -- not the CLUSTER + * command and not any index AM -- forces a direct-scan copy here, + * independently of which access method the clustering index uses. No + * index AM (core or out-of-tree) needs to know anything about SIU chains. + * A btree clustering index can still preserve cluster order via the + * seqscan+sort path (tuplesort_begin_cluster requires btree anyway); any + * other clusterable AM has no sort path, so it falls back to a plain + * seqscan copy and loses ordering -- an acceptable trade, since a lossless + * rewrite must never drop a live row and cluster order is not a + * correctness property. Both direct-scan paths visit every heap tuple, + * the same way VACUUM FULL already does, and are therefore immune. + */ + if (OldIndex != NULL && !use_sort) + { + List *indexoidlist = RelationGetIndexList(OldHeap); + bool siu_capable = (list_length(indexoidlist) > 1); + + list_free(indexoidlist); + + if (siu_capable) + { + if (OldIndex->rd_rel->relam == BTREE_AM_OID) + use_sort = true; /* seqscan+sort, keeps cluster order */ + else + OldIndex = NULL; /* plain seqscan copy */ + } + } + /* Set up sorting if wanted */ if (use_sort) tuplesort = tuplesort_begin_cluster(oldTupDesc, OldIndex, @@ -719,9 +754,33 @@ heapam_relation_copy_for_cluster(Relation OldHeap, Relation NewHeap, if (!index_getnext_slot(indexScan, ForwardScanDirection, slot)) break; - /* Since we used no scan keys, should never need to recheck */ + /* + * CLUSTER uses a no-key full-index scan; it cannot do any + * tuple-level filtering itself. The HOT-indexed reader path + * routinely sets xs_recheck when walking chain entries whose + * index key may be stale relative to the visible heap tuple. + * Those entries cause the same live tuple to be visited via the + * fresh hot-indexed-inserted entry too; including them would + * duplicate rows in the rewritten heap. Skip them here -- the + * tuple is reachable through its canonical index entry. + * + * If xs_recheck is set with actual scan keys, that's a real lossy + * index scenario CLUSTER can't handle (historical restriction). + */ if (indexScan->xs_recheck) - elog(ERROR, "CLUSTER does not support lossy index conditions"); + { + if (indexScan->numberOfKeys > 0) + elog(ERROR, "CLUSTER does not support lossy index conditions"); + continue; + } + + /* + * Same reasoning as for xs_recheck: a HOT-indexed stale entry + * would re-emit an already-visited tuple via its canonical fresh + * entry. Skip. + */ + if (indexScan->xs_hot_indexed_stale) + continue; } else { @@ -1635,30 +1694,48 @@ heapam_index_build_range_scan(Relation heapRelation, offnum = ItemPointerGetOffsetNumber(&heapTuple->t_self); - /* - * If a HOT tuple points to a root that we don't know about, - * obtain root items afresh. If that still fails, report it as - * corruption. - */ - if (root_offsets[offnum - 1] == InvalidOffsetNumber) + if ((heapTuple->t_data->t_infomask2 & HEAP_INDEXED_UPDATED) != 0) { - Page page = BufferGetPage(hscan->rs_cbuf); - - LockBuffer(hscan->rs_cbuf, BUFFER_LOCK_SHARE); - heap_get_root_tuples(page, root_offsets); - LockBuffer(hscan->rs_cbuf, BUFFER_LOCK_UNLOCK); + /* + * HOT-indexed (Selective Index Update) live tuple: index it + * under its OWN TID, not the chain root. Its indexed values + * differ from earlier chain members', and the bitmap-overlap + * read path keeps an entry only when no hop after the entry's + * target changed the index's attributes. That holds for an + * entry pointing directly at the live tuple (no later hop); + * an entry pointed at the root would be dropped as stale, + * losing the row. + */ + ItemPointerSet(&tid, ItemPointerGetBlockNumber(&heapTuple->t_self), + offnum); } + else + { + /* + * If a HOT tuple points to a root that we don't know about, + * obtain root items afresh. If that still fails, report it + * as corruption. + */ + if (root_offsets[offnum - 1] == InvalidOffsetNumber) + { + Page page = BufferGetPage(hscan->rs_cbuf); - if (!OffsetNumberIsValid(root_offsets[offnum - 1])) - ereport(ERROR, - (errcode(ERRCODE_DATA_CORRUPTED), - errmsg_internal("failed to find parent tuple for heap-only tuple at (%u,%u) in table \"%s\"", - ItemPointerGetBlockNumber(&heapTuple->t_self), - offnum, - RelationGetRelationName(heapRelation)))); + LockBuffer(hscan->rs_cbuf, BUFFER_LOCK_SHARE); + heap_get_root_tuples(page, root_offsets); + LockBuffer(hscan->rs_cbuf, BUFFER_LOCK_UNLOCK); + } - ItemPointerSet(&tid, ItemPointerGetBlockNumber(&heapTuple->t_self), - root_offsets[offnum - 1]); + if (!OffsetNumberIsValid(root_offsets[offnum - 1])) + ereport(ERROR, + (errcode(ERRCODE_DATA_CORRUPTED), + errmsg_internal("failed to find parent tuple for heap-only tuple at (%u,%u) in table \"%s\"", + ItemPointerGetBlockNumber(&heapTuple->t_self), + offnum, + RelationGetRelationName(heapRelation)))); + + ItemPointerSet(&tid, ItemPointerGetBlockNumber(&heapTuple->t_self), + root_offsets[offnum - 1]); + } /* Call the AM's callback routine to process the tuple */ callback(indexRelation, &tid, values, isnull, tupleIsAlive, @@ -1823,7 +1900,8 @@ heapam_index_validate_scan(Relation heapRelation, rootTuple = *heapcursor; root_offnum = ItemPointerGetOffsetNumber(heapcursor); - if (HeapTupleIsHeapOnly(heapTuple)) + if (HeapTupleIsHeapOnly(heapTuple) && + (heapTuple->t_data->t_infomask2 & HEAP_INDEXED_UPDATED) == 0) { root_offnum = root_offsets[root_offnum - 1]; if (!OffsetNumberIsValid(root_offnum)) @@ -2433,7 +2511,23 @@ reform_tuple(HeapTuple tuple, Relation OldHeap, Relation NewHeap, /* Skip work if no changes are needed */ if (!needs_reform) - return heap_copytuple(tuple); + { + HeapTuple copy = heap_copytuple(tuple); + + /* + * A CLUSTER / VACUUM FULL rewrite flattens every HOT chain: each live + * tuple becomes a standalone tuple in the new heap with a fresh index + * entry, so no rewritten tuple is a HOT-indexed (SIU) chain member. + * heap_copytuple preserves the source header verbatim, though, which + * would leave HEAP_INDEXED_UPDATED (and its now-meaningless trailing + * inline modified-attrs bitmap) set on the copy. A reader reaching + * such a tuple through the rebuilt index would then run the read-side + * staleness test against garbage and wrongly drop the row. Clear the + * marker here. (The fresh-form path below never sets it.) + */ + copy->t_data->t_infomask2 &= ~HEAP_INDEXED_UPDATED; + return copy; + } heap_deform_tuple(tuple, oldTupDesc, values, isnull); @@ -2580,6 +2674,7 @@ BitmapHeapScanNextBlock(TableScanDesc scan, * offset. */ int curslot; + bool page_had_hot_indexed = false; /* We must have extracted the tuple offsets by now */ Assert(noffsets > -1); @@ -2589,11 +2684,63 @@ BitmapHeapScanNextBlock(TableScanDesc scan, OffsetNumber offnum = offsets[curslot]; ItemPointerData tid; HeapTupleData heapTuple; + bool hot_indexed_stale = false; ItemPointerSet(&tid, block, offnum); if (heap_hot_search_buffer(&tid, scan->rs_rd, buffer, snapshot, - &heapTuple, NULL, true)) - hscan->rs_vistuples[ntup++] = ItemPointerGetOffsetNumber(&tid); + &heapTuple, NULL, true, + &hot_indexed_stale, NULL, NULL)) + { + OffsetNumber resolved = ItemPointerGetOffsetNumber(&tid); + bool already_have = false; + + /* + * A bitmap heap scan cannot attribute a TID to one index, so + * any crossed in-chain HOT/SIU hop means the arriving entry + * may be stale; recheck/dedup conservatively. + */ + if (hot_indexed_stale) + page_had_hot_indexed = true; + + /* + * With HOT-indexed updates, more than one bitmap entry on the + * same block can chain-resolve to the same live tuple (a + * stale old-key entry plus the fresh new-key entry, or + * multiple stale entries from successive hot-indexed + * updates). Once we've seen any hot-indexed hop on this + * block dedup inline so upper nodes (e.g., MERGE) don't see + * the same row twice. Preserve original insertion order: + * MERGE's RETURNING ordering and test harness stability both + * depend on it. In the absence of hot-indexed on the page we + * skip the linear scan entirely -- the TBM's TIDs are already + * distinct by construction. + */ + if (page_had_hot_indexed) + { + for (int j = 0; j < ntup; j++) + { + if (hscan->rs_vistuples[j] == resolved) + { + already_have = true; + break; + } + } + } + + if (!already_have) + hscan->rs_vistuples[ntup++] = resolved; + + /* + * If we reached the visible tuple through a HOT-indexed + * (hot-indexed) hop, the bitmap index entry that pointed us + * at the chain root may describe key values the visible tuple + * no longer has. Force BitmapHeapScan to run its recheck + * qual against these tuples even if the bitmap page was + * otherwise exact. + */ + if (hot_indexed_stale) + *recheck = true; + } } } else diff --git a/src/backend/access/heap/heapam_indexscan.c b/src/backend/access/heap/heapam_indexscan.c index 33d14f1de7d52..2da89ef780da3 100644 --- a/src/backend/access/heap/heapam_indexscan.c +++ b/src/backend/access/heap/heapam_indexscan.c @@ -15,7 +15,9 @@ #include "postgres.h" #include "access/heapam.h" +#include "access/hot_indexed.h" #include "access/relscan.h" +#include "miscadmin.h" #include "storage/predicate.h" @@ -35,6 +37,14 @@ heapam_index_fetch_begin(Relation rel, uint32 flags) hscan->xs_blk = InvalidBlockNumber; hscan->xs_vmbuffer = InvalidBuffer; + /* + * Scratch space for the union of modified-attrs bitmaps that a HOT/SIU + * chain walk crosses, sized for this relation's column count. Threaded + * back out through xs_hot_indexed_crossed for the index-access layer. + */ + hscan->xs_base.xs_hot_indexed_crossed = + palloc0(HotIndexedBitmapBytes(RelationGetNumberOfAttributes(rel))); + return &hscan->xs_base; } @@ -63,6 +73,9 @@ heapam_index_fetch_end(IndexFetchTableData *scan) if (BufferIsValid(hscan->xs_vmbuffer)) ReleaseBuffer(hscan->xs_vmbuffer); + if (hscan->xs_base.xs_hot_indexed_crossed != NULL) + pfree(hscan->xs_base.xs_hot_indexed_crossed); + pfree(hscan); } @@ -83,13 +96,24 @@ heapam_index_fetch_end(IndexFetchTableData *scan) * globally dead; *all_dead is set true if all members of the HOT chain * are vacuumable, false if not. * + * If hot_indexed_recheck is not NULL, *hot_indexed_recheck is set true iff the + * walk crossed a HOT-selectively-updated (HOT/SIU) hop after the entry tuple + * on the way to the returned tuple -- i.e. the arriving index entry's stored + * key may no longer match the live tuple, so the caller must recheck it (via + * a leaf-key comparison or a qual recheck). The entry tuple's own producing + * hop is excluded, so a fresh entry pointing directly at its tuple is not + * flagged. When no such hop was crossed, *hot_indexed_recheck is left false. + * * Unlike heap_fetch, the caller must already have pin and (at least) share * lock on the buffer; it is still pinned/locked at exit. */ bool heap_hot_search_buffer(ItemPointer tid, Relation relation, Buffer buffer, Snapshot snapshot, HeapTuple heapTuple, - bool *all_dead, bool first_call) + bool *all_dead, bool first_call, + bool *hot_indexed_recheck, + uint8 *crossed_bitmap, + bool *prefix_all_dead) { Page page = BufferGetPage(buffer); TransactionId prev_xmax = InvalidTransactionId; @@ -98,12 +122,32 @@ heap_hot_search_buffer(ItemPointer tid, Relation relation, Buffer buffer, bool at_chain_start; bool valid; bool skip; + bool prefix_dead; GlobalVisState *vistest = NULL; + int relnatts = RelationGetNumberOfAttributes(relation); + int chain_hops; + OffsetNumber maxoff_guard; + + /* Only track prefix_dead when the caller actually wants it. */ + prefix_dead = (prefix_all_dead != NULL); /* If this is not the first call, previous call returned a (live!) tuple */ if (all_dead) *all_dead = first_call; + /* + * On the first call, clear the recheck flag and the crossed-attrs union. + * On subsequent calls (same chain continuing) keep whatever an earlier + * hop already accumulated. + */ + if (first_call) + { + if (hot_indexed_recheck) + *hot_indexed_recheck = false; + if (crossed_bitmap) + memset(crossed_bitmap, 0, HotIndexedBitmapBytes(relnatts)); + } + blkno = ItemPointerGetBlockNumber(tid); offnum = ItemPointerGetOffsetNumber(tid); at_chain_start = first_call; @@ -113,11 +157,25 @@ heap_hot_search_buffer(ItemPointer tid, Relation relation, Buffer buffer, Assert(TransactionIdIsValid(RecentXmin)); Assert(BufferGetBlockNumber(buffer) == blkno); + /* + * Bound the HOT/SIU chain walk. A corrupt page whose forward links form + * a cycle among valid in-range offsets (e.g. stub A -> stub B -> stub A) + * would otherwise spin this loop forever under a buffer share-lock, with + * no CHECK_FOR_INTERRUPTS to break out. No legitimate chain visits more + * offsets than the page holds. + */ + chain_hops = 0; + maxoff_guard = PageGetMaxOffsetNumber(page); + /* Scan through possible multiple members of HOT-chain */ for (;;) { ItemId lp; + CHECK_FOR_INTERRUPTS(); + if (chain_hops++ > maxoff_guard) + break; /* defend against a corrupt forward-link cycle */ + /* check for bogus TID */ if (offnum < FirstOffsetNumber || offnum > PageGetMaxOffsetNumber(page)) break; @@ -130,7 +188,17 @@ heap_hot_search_buffer(ItemPointer tid, Relation relation, Buffer buffer, /* We should only see a redirect at start of chain */ if (ItemIdIsRedirected(lp) && at_chain_start) { - /* Follow the redirect */ + /* + * Follow the redirect. A collapsed dead prefix is preserved + * as a run of forwarding stubs, each carrying its segment's + * modified-attrs bitmap, ending at the first live tuple; + * chain collapse reclaims a dead member only when its + * attributes are a subset of the surviving later hops (see + * pruneheap.c). So the stubs and live hops this walk crosses + * below contribute the complete union of every collapsed + * hop's modified attributes, and that union drives the + * overlap staleness test for the index-access layer. + */ offnum = ItemIdGetRedirect(lp); at_chain_start = false; continue; @@ -151,10 +219,111 @@ heap_hot_search_buffer(ItemPointer tid, Relation relation, Buffer buffer, ItemPointerSet(&heapTuple->t_self, blkno, offnum); /* - * Shouldn't see a HEAP_ONLY tuple at chain start. + * A collapse-survivor stub is an LP_NORMAL item but not a real tuple: + * it is a freeze-safe forwarding node carrying the modified-attrs + * bitmap for the chain segment it represents. Treat it like a + * crossed HOT/SIU hop -- arm the recheck and OR its bitmap into the + * crossed union (unless we arrived directly at it, in which case the + * arriving entry already reflects this segment's value) -- then + * follow its forward link. A stub is never visible and never + * returned, and its forward link is a logical, not xid-continuous, + * edge, so reset prev_xmax to skip the chain-integrity check on the + * next member. + */ + if (HotIndexedHeaderIsStub(heapTuple->t_data)) + { + if (!at_chain_start) + { + if (hot_indexed_recheck) + *hot_indexed_recheck = true; + if (crossed_bitmap) + { + int bmnatts = + HotIndexedTupleBitmapNatts(heapTuple->t_data); + + /* + * A hop's write-time natts can never legitimately exceed + * the relation's current natts. On a corrupt page a + * stub's unbounded stashed natts could otherwise overflow + * crossed_bitmap, which is allocated for relnatts; clamp + * defensively. + */ + Assert(bmnatts >= 0 && bmnatts <= relnatts); + if (bmnatts < 0 || bmnatts > relnatts) + bmnatts = relnatts; + + HotIndexedBitmapUnion(crossed_bitmap, + HotIndexedGetModifiedBitmap(heapTuple->t_data, + heapTuple->t_len, + bmnatts), + bmnatts); + } + } + offnum = HotIndexedStubGetForward(heapTuple->t_data); + at_chain_start = false; + prev_xmax = InvalidTransactionId; + continue; + } + + /* + * Shouldn't see a HEAP_ONLY tuple at chain start, unless that tuple + * is the target of a freshly-inserted hot-indexed index entry: then + * arriving directly at a heap-only HOT-indexed tuple is legal and the + * tuple is the canonical visible version, so we fall through and + * apply normal visibility checks to it. Otherwise, treat it as a + * broken chain. */ if (at_chain_start && HeapTupleIsHeapOnly(heapTuple)) - break; + { + if ((heapTuple->t_data->t_infomask2 & HEAP_INDEXED_UPDATED) == 0) + break; + + /* + * We were pointed directly at this hot-indexed tuple. The index + * entry we arrived through was inserted *for* this update, so it + * reflects this tuple's current attribute values; its own + * producing hop is not a crossed hop, so it is not flagged for + * recheck (a fresh entry is never stale for its own index). + */ + } + else if (hot_indexed_recheck != NULL && + (heapTuple->t_data->t_infomask2 & HEAP_INDEXED_UPDATED) != 0) + { + /* + * A HOT/SIU hop reached by following the chain (or a redirect) + * from an earlier entry: this hop is crossed, so the arriving + * entry's stored key may no longer match the live tuple. Set the + * recheck flag to tell the index-access layer to consult the + * crossed-attrs union; that union (accumulated below) is what + * decides staleness. + */ + *hot_indexed_recheck = true; + + /* + * Accumulate this hop's modified-attrs bitmap into the crossed + * union. A tuple's inline bitmap records the indexed attributes + * that changed at the hop INTO it, which is exactly the hop we + * just crossed by advancing to it; ORing each crossed hop yields + * the indexed attributes that changed after the entry's own + * tuple. + */ + if (crossed_bitmap) + { + int bmnatts = + HotIndexedTupleBitmapNatts(heapTuple->t_data); + + /* See the comment on the stub case's crossed_bitmap use. */ + Assert(bmnatts >= 0 && bmnatts <= relnatts); + if (bmnatts < 0 || bmnatts > relnatts) + bmnatts = relnatts; + + HotIndexedBitmapUnion(crossed_bitmap, + HotIndexedGetModifiedBitmap(heapTuple->t_data, + heapTuple->t_len, + bmnatts), + bmnatts); + } + } /* * The xmin should match the previous xmax value, else chain is @@ -186,6 +355,15 @@ heap_hot_search_buffer(ItemPointer tid, Relation relation, Buffer buffer, HeapTupleHeaderGetXmin(heapTuple->t_data)); if (all_dead) *all_dead = false; + + /* + * Report whether every chain member skipped before this + * visible tuple is dead to all transactions. With a stale + * verdict this lets the caller kill the arriving leaf safely. + */ + if (prefix_all_dead) + *prefix_all_dead = prefix_dead; + return true; } } @@ -194,18 +372,25 @@ heap_hot_search_buffer(ItemPointer tid, Relation relation, Buffer buffer, /* * If we can't see it, maybe no one else can either. At caller * request, check whether all chain members are dead to all - * transactions. + * transactions. The same surely-dead test feeds prefix_dead, which + * (unlike all_dead) is not reset when a visible tuple is found, so it + * records whether the members skipped ahead of the returned tuple are + * all dead to all -- the safe-to-kill-this-leaf condition. * * Note: if you change the criterion here for what is "dead", fix the * planner's get_actual_variable_range() function to match. */ - if (all_dead && *all_dead) + if ((all_dead && *all_dead) || (prefix_all_dead && prefix_dead)) { if (!vistest) vistest = GlobalVisTestFor(relation); if (!HeapTupleIsSurelyDead(heapTuple, vistest)) - *all_dead = false; + { + if (all_dead) + *all_dead = false; + prefix_dead = false; + } } /* @@ -273,7 +458,15 @@ heapam_index_fetch_tuple(struct IndexFetchTableData *scan, snapshot, &bslot->base.tupdata, all_dead, - !*heap_continue); + !*heap_continue, + &scan->xs_hot_indexed_recheck, + scan->xs_hot_indexed_crossed, + &scan->xs_prefix_all_dead); + if (!got_heap_tuple) + { + scan->xs_hot_indexed_recheck = false; + scan->xs_prefix_all_dead = false; + } bslot->base.tupdata.t_self = *tid; LockBuffer(hscan->xs_cbuf, BUFFER_LOCK_UNLOCK); diff --git a/src/backend/access/heap/heapam_xlog.c b/src/backend/access/heap/heapam_xlog.c index 9ed7024e81474..571ea5a4db673 100644 --- a/src/backend/access/heap/heapam_xlog.c +++ b/src/backend/access/heap/heapam_xlog.c @@ -103,6 +103,8 @@ heap_xlog_prune_freeze(XLogReaderState *record) Size datalen; xlhp_freeze_plan *plans; OffsetNumber *frz_offsets; + OffsetNumber *stubs; + int nstubs; char *dataptr = XLogRecGetBlockData(record, 0, &datalen); bool do_prune; @@ -110,9 +112,10 @@ heap_xlog_prune_freeze(XLogReaderState *record) &nplans, &plans, &frz_offsets, &nredirected, &redirected, &ndead, &nowdead, - &nunused, &nowunused); + &nunused, &nowunused, + &nstubs, &stubs); - do_prune = nredirected > 0 || ndead > 0 || nunused > 0; + do_prune = nredirected > 0 || ndead > 0 || nunused > 0 || nstubs > 0; /* Ensure the record does something */ Assert(do_prune || nplans > 0 || vmflags & VISIBILITYMAP_VALID_BITS); @@ -126,7 +129,8 @@ heap_xlog_prune_freeze(XLogReaderState *record) (xlrec.flags & XLHP_CLEANUP_LOCK) == 0, redirected, nredirected, nowdead, ndead, - nowunused, nunused); + nowunused, nunused, + stubs, nstubs); /* Freeze tuples */ for (int p = 0; p < nplans; p++) diff --git a/src/backend/access/heap/hot_indexed_stats.c b/src/backend/access/heap/hot_indexed_stats.c new file mode 100644 index 0000000000000..895a29654b522 --- /dev/null +++ b/src/backend/access/heap/hot_indexed_stats.c @@ -0,0 +1,188 @@ +/*------------------------------------------------------------------------- + * + * hot_indexed_stats.c + * SQL-callable diagnostic that walks every page of a heap relation and + * reports hot-indexed-related structural statistics. + * + * These numbers complement the running pgstat counters + * (n_tup_hot_indexed_upd in pg_stat_all_tables): they answer "what is on disk + * right now?" rather than "how often did hot-indexed fire during the stats + * window?". + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * + * IDENTIFICATION + * src/backend/access/heap/hot_indexed_stats.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/heapam.h" +#include "access/hot_indexed.h" +#include "access/htup_details.h" +#include "catalog/objectaddress.h" +#include "catalog/pg_type.h" +#include "fmgr.h" +#include "funcapi.h" +#include "miscadmin.h" +#include "storage/bufmgr.h" +#include "storage/bufpage.h" +#include "storage/itemptr.h" +#include "utils/acl.h" +#include "utils/builtins.h" +#include "utils/rel.h" + +/* + * pg_relation_hot_indexed_stats(regclass) -> record + * + * Walks every block of the relation's main fork and counts: + * n_hot_indexed -- live HOT-indexed versions (HEAP_INDEXED_UPDATED, natts>0, + * carrying an inline-trailing modified-attrs bitmap) + * n_chains -- LP_REDIRECT items, i.e. HOT chain roots. Matches + * the number of distinct HOT chains that have survived + * the most recent prune. Root-not-redirect chains + * (length 1) are not counted here because they are + * indistinguishable from a non-chain tuple. + * avg_chain_len -- mean length across chains rooted at an LP_REDIRECT, + * derived by walking each redirect target to the end + * of its HEAP_HOT_UPDATED chain. + * max_chain_len -- longest chain observed. + * + * The caller must hold SELECT privilege on the relation, like other + * relation-inspection functions; it takes only AccessShareLock and short-term + * buffer share locks while scanning. + */ +Datum +pg_relation_hot_indexed_stats(PG_FUNCTION_ARGS) +{ + Oid relid = PG_GETARG_OID(0); + Relation rel; + AclResult aclresult; + BlockNumber nblocks; + BlockNumber blk; + int64 n_hot_indexed = 0; + int64 n_chains = 0; + int64 sum_chain_len = 0; + int64 max_chain_len = 0; + TupleDesc tupdesc; + Datum values[4]; + bool nulls[4] = {0}; + HeapTuple resulttup; + + rel = relation_open(relid, AccessShareLock); + if (rel->rd_rel->relkind != RELKIND_RELATION && + rel->rd_rel->relkind != RELKIND_MATVIEW && + rel->rd_rel->relkind != RELKIND_TOASTVALUE) + ereport(ERROR, + (errcode(ERRCODE_WRONG_OBJECT_TYPE), + errmsg("\"%s\" is not a table, materialized view, or TOAST table", + RelationGetRelationName(rel)))); + + /* Caller must be able to read the relation. */ + aclresult = pg_class_aclcheck(relid, GetUserId(), ACL_SELECT); + if (aclresult != ACLCHECK_OK) + aclcheck_error(aclresult, + get_relkind_objtype(rel->rd_rel->relkind), + RelationGetRelationName(rel)); + + nblocks = RelationGetNumberOfBlocks(rel); + + for (blk = 0; blk < nblocks; blk++) + { + Buffer buf; + Page page; + OffsetNumber off; + OffsetNumber maxoff; + + CHECK_FOR_INTERRUPTS(); + + buf = ReadBufferExtended(rel, MAIN_FORKNUM, blk, RBM_NORMAL, NULL); + LockBuffer(buf, BUFFER_LOCK_SHARE); + page = BufferGetPage(buf); + + if (PageIsNew(page) || PageIsEmpty(page)) + { + UnlockReleaseBuffer(buf); + continue; + } + + maxoff = PageGetMaxOffsetNumber(page); + for (off = FirstOffsetNumber; off <= maxoff; off = OffsetNumberNext(off)) + { + ItemId lp = PageGetItemId(page, off); + + if (!ItemIdIsUsed(lp)) + continue; + + if (ItemIdIsRedirected(lp)) + { + /* Walk the chain starting at the redirect target. */ + OffsetNumber cur = ItemIdGetRedirect(lp); + int64 len = 0; + + /* + * Walk the same-page HOT chain. Bound the loop by the page's + * item count so a corrupt cyclic t_ctid cannot spin forever + * under the buffer lock, and check for interrupts each step. + */ + while (cur >= FirstOffsetNumber && cur <= maxoff && len < maxoff) + { + ItemId chain_lp = PageGetItemId(page, cur); + HeapTupleHeader thdr; + + CHECK_FOR_INTERRUPTS(); + + if (!ItemIdIsNormal(chain_lp)) + break; + thdr = (HeapTupleHeader) PageGetItem(page, chain_lp); + len++; + if (!(thdr->t_infomask2 & HEAP_HOT_UPDATED)) + break; + /* HOT chains stay on one page; stop if the link leaves it. */ + if (ItemPointerGetBlockNumber(&thdr->t_ctid) != blk) + break; + cur = ItemPointerGetOffsetNumber(&thdr->t_ctid); + } + if (len > 0) + { + n_chains++; + sum_chain_len += len; + if (len > max_chain_len) + max_chain_len = len; + } + } + else if (ItemIdIsNormal(lp)) + { + HeapTupleHeader thdr = (HeapTupleHeader) PageGetItem(page, lp); + + if (!HotIndexedHeaderIsStub(thdr) && + (thdr->t_infomask2 & HEAP_INDEXED_UPDATED) != 0) + n_hot_indexed++; + } + } + + UnlockReleaseBuffer(buf); + } + + relation_close(rel, AccessShareLock); + + tupdesc = CreateTemplateTupleDesc(4); + TupleDescInitEntry(tupdesc, (AttrNumber) 1, "n_hot_indexed", INT8OID, -1, 0); + TupleDescInitEntry(tupdesc, (AttrNumber) 2, "n_chains", INT8OID, -1, 0); + TupleDescInitEntry(tupdesc, (AttrNumber) 3, "avg_chain_len", FLOAT8OID, -1, 0); + TupleDescInitEntry(tupdesc, (AttrNumber) 4, "max_chain_len", INT8OID, -1, 0); + TupleDescFinalize(tupdesc); + tupdesc = BlessTupleDesc(tupdesc); + + values[0] = Int64GetDatum(n_hot_indexed); + values[1] = Int64GetDatum(n_chains); + if (n_chains > 0) + values[2] = Float8GetDatum(((double) sum_chain_len) / (double) n_chains); + else + values[2] = Float8GetDatum(0.0); + values[3] = Int64GetDatum(max_chain_len); + + resulttup = heap_form_tuple(tupdesc, values, nulls); + PG_RETURN_DATUM(HeapTupleGetDatum(resulttup)); +} diff --git a/src/backend/access/heap/meson.build b/src/backend/access/heap/meson.build index 00ec07d7f30d1..b5c2a8d5cb6ea 100644 --- a/src/backend/access/heap/meson.build +++ b/src/backend/access/heap/meson.build @@ -8,6 +8,7 @@ backend_sources += files( 'heapam_xlog.c', 'heaptoast.c', 'hio.c', + 'hot_indexed_stats.c', 'pruneheap.c', 'rewriteheap.c', 'vacuumlazy.c', diff --git a/src/backend/access/heap/pruneheap.c b/src/backend/access/heap/pruneheap.c index 6f3ba9113b522..d4b373e6ef9b6 100644 --- a/src/backend/access/heap/pruneheap.c +++ b/src/backend/access/heap/pruneheap.c @@ -16,6 +16,7 @@ #include "access/heapam.h" #include "access/heapam_xlog.h" +#include "access/hot_indexed.h" #include "access/htup_details.h" #include "access/multixact.h" #include "access/transam.h" @@ -68,11 +69,20 @@ typedef struct int nredirected; /* numbers of entries in arrays below */ int ndead; int nunused; + int nstubs; int nfrozen; /* arrays that accumulate indexes of items to be changed */ OffsetNumber redirected[MaxHeapTuplesPerPage * 2]; OffsetNumber nowdead[MaxHeapTuplesPerPage]; OffsetNumber nowunused[MaxHeapTuplesPerPage]; + + /* + * HOT-selectively-updated collapse-survivor stubs: (offset, forward) + * pairs of line pointers rewritten in place into xid-free forwarding + * stubs that preserve their segment's modified-attrs bitmap. + */ + OffsetNumber stubs[MaxHeapTuplesPerPage * 2]; + HeapTupleFreeze frozen[MaxHeapTuplesPerPage]; /* @@ -221,6 +231,10 @@ static void heap_prune_record_prunable(PruneState *prstate, TransactionId xid, static void heap_prune_record_redirect(PruneState *prstate, OffsetNumber offnum, OffsetNumber rdoffnum, bool was_normal); +static void heap_prune_record_stub(PruneState *prstate, + OffsetNumber offnum, OffsetNumber forward); +static void heap_prune_record_unchanged_lp_stub(PruneState *prstate, + OffsetNumber offnum); static void heap_prune_record_dead(PruneState *prstate, OffsetNumber offnum, bool was_normal); static void heap_prune_record_dead_or_unused(PruneState *prstate, OffsetNumber offnum, @@ -231,6 +245,7 @@ static void heap_prune_record_unchanged_lp_unused(PruneState *prstate, OffsetNum static void heap_prune_record_unchanged_lp_normal(PruneState *prstate, OffsetNumber offnum); static void heap_prune_record_unchanged_lp_dead(PruneState *prstate, OffsetNumber offnum); static void heap_prune_record_unchanged_lp_redirect(PruneState *prstate, OffsetNumber offnum); +static bool heap_prune_item_preserves_hot_indexed(Page page, OffsetNumber offnum); static void page_verify_redirects(Page page); @@ -459,6 +474,7 @@ prune_freeze_setup(PruneFreezeParams *params, prstate->new_prune_xid = InvalidTransactionId; prstate->latest_xid_removed = InvalidTransactionId; prstate->nredirected = prstate->ndead = prstate->nunused = 0; + prstate->nstubs = 0; prstate->nfrozen = 0; prstate->nroot_items = 0; prstate->nheaponly_items = 0; @@ -627,6 +643,23 @@ prune_freeze_plan(PruneState *prstate, OffsetNumber *off_loc) * Get the tuple's visibility status and queue it up for processing. */ htup = (HeapTupleHeader) PageGetItem(page, itemid); + + /* + * A collapse-survivor stub is an LP_NORMAL item that is not a real + * tuple: HEAP_INDEXED_UPDATED with natts == 0, permanently invisible + * (HEAP_XMIN_INVALID), carrying a forward link and the modified-attrs + * bitmap for the chain segment it stands in for. + * heap_prune_satisfies_vacuum() would classify it HEAPTUPLE_DEAD and + * pruning would reclaim it, destroying the bitmap a read needs. + * Record it as an unchanged item so it is preserved; the HOT chain + * walk steps through it transparently to reach the live tuple. + */ + if (HotIndexedHeaderIsStub(htup)) + { + heap_prune_record_unchanged_lp_stub(prstate, offnum); + continue; + } + tup.t_data = htup; tup.t_len = ItemIdGetLength(itemid); ItemPointerSet(&tup.t_self, blockno, offnum); @@ -697,18 +730,38 @@ prune_freeze_plan(PruneState *prstate, OffsetNumber *off_loc) ItemId itemid = PageGetItemId(page, offnum); HeapTupleHeader htup = (HeapTupleHeader) PageGetItem(page, itemid); - if (likely(!HeapTupleHeaderIsHotUpdated(htup))) - { - HeapTupleHeaderAdvanceConflictHorizon(htup, - &prstate->latest_xid_removed); + /* + * A dead heap-only tuple that carries a stale btree leaf of its + * own (HEAP_INDEXED_UPDATED, natts > 0) is a HOT-indexed chain + * member not reached by any chain walk (an aborted + * HOT-selectively-updated sub-chain, or a member whose live root + * stopped the walk). Mark it LP_DEAD instead of reclaiming it + * outright: that pins the slot against reuse and adds it to the + * dead-items array so ambulkdelete sweeps the stale leaf and a + * later vacuum reclaims the LP. + * + * Otherwise, this is the classic-HOT case upstream has always + * handled here: a dead heap-only tuple with no leaf of its own + * should have been reached and removed as part of pruning its + * HOT chain. If it is not HotUpdated, it is a legitimate + * standalone dead heap-only tuple (e.g. an aborted update) and + * can be reclaimed; if it *is* HotUpdated, something is wrong -- + * preserve it and error out rather than silently destroy the + * evidence. + */ + HeapTupleHeaderAdvanceConflictHorizon(htup, + &prstate->latest_xid_removed); + if ((htup->t_infomask2 & HEAP_INDEXED_UPDATED) != 0 && + HeapTupleHeaderGetNatts(htup) > 0) + heap_prune_record_dead_or_unused(prstate, offnum, true); + else if (likely(!HeapTupleHeaderIsHotUpdated(htup))) heap_prune_record_unused(prstate, offnum, true); - } else { /* - * This tuple should've been processed and removed as part of - * a HOT chain, so something's wrong. To preserve evidence, - * we don't dare to remove it. We cannot leave behind a DEAD + * This tuple should've been processed and removed as part of a + * HOT chain, so something's wrong. To preserve evidence, we + * don't dare to remove it. We cannot leave behind a DEAD * tuple either, because that will cause VACUUM to error out. * Throwing an error with a distinct error message seems like * the least bad option. @@ -1183,7 +1236,8 @@ heap_page_prune_and_freeze(PruneFreezeParams *params, do_prune = prstate.nredirected > 0 || prstate.ndead > 0 || - prstate.nunused > 0; + prstate.nunused > 0 || + prstate.nstubs > 0; /* * Even if we don't prune anything, if we found a new value for the @@ -1284,7 +1338,8 @@ heap_page_prune_and_freeze(PruneFreezeParams *params, heap_page_prune_execute(prstate.buffer, false, prstate.redirected, prstate.nredirected, prstate.nowdead, prstate.ndead, - prstate.nowunused, prstate.nunused); + prstate.nowunused, prstate.nunused, + prstate.stubs, prstate.nstubs); } if (do_freeze) @@ -1327,7 +1382,8 @@ heap_page_prune_and_freeze(PruneFreezeParams *params, prstate.frozen, prstate.nfrozen, prstate.redirected, prstate.nredirected, prstate.nowdead, prstate.ndead, - prstate.nowunused, prstate.nunused); + prstate.nowunused, prstate.nunused, + prstate.stubs, prstate.nstubs); } } @@ -1468,6 +1524,101 @@ htsv_get_valid_status(int status) return (HTSV_Result) status; } +/* + * heap_prune_chain_find_live + * Follow a HOT chain from 'start' to its first surviving member. + * + * Used when re-pruning a HOT/SIU chain that was collapsed by an earlier prune: + * the root and any entry-bearing dead members were turned into LP_REDIRECTs to + * what was then the first live tuple. If that tuple has since been HOT-updated + * again and died, the redirects must be re-pointed to the current first live + * tuple, or several redirects forwarding to one live tuple must agree on it. + * Both cases need the chain's current first surviving member. + * + * Walks t_ctid on this page starting at 'start', skipping DEAD members, and + * returns the offset of the first non-DEAD (surviving) member. Returns + * InvalidOffsetNumber if the chain dead-ends with no survivor or runs off the + * page. Reads only the page's pre-execute state, so it is correct regardless + * of the order in which sibling redirects are processed. + */ +static OffsetNumber +heap_prune_chain_find_live(PruneState *prstate, OffsetNumber start) +{ + Page page = prstate->page; + OffsetNumber maxoff = PageGetMaxOffsetNumber(page); + OffsetNumber offnum = start; + OffsetNumber survivor = start; /* successor of the last DEAD member */ + int loops = 0; + + while (offnum >= FirstOffsetNumber && offnum <= maxoff) + { + ItemId lp = PageGetItemId(page, offnum); + HTSV_Result status; + HeapTupleHeader htup; + + /* A redirect/dead/unused item cannot be a surviving chain member. */ + if (!ItemIdIsNormal(lp)) + return InvalidOffsetNumber; + + htup = (HeapTupleHeader) PageGetItem(page, lp); + + /* + * A collapse-survivor stub is an xid-free forwarding node, not a + * chain member; the page scan records it unchanged without computing + * visibility, so its htsv slot is unset. Step through it to its + * forward link rather than reading htsv, which would trip the + * validity assert. + */ + if (HotIndexedHeaderIsStub(htup)) + { + offnum = HotIndexedStubGetForward(htup); + if (++loops > maxoff) + return InvalidOffsetNumber; + continue; + } + + status = htsv_get_valid_status(prstate->htsv[offnum]); + htup = (HeapTupleHeader) PageGetItem(page, lp); + + if (status == HEAPTUPLE_DEAD) + { + /* + * A DEAD member is reclaimed/redirected, so the surviving tail + * starts at its successor. A DEAD member with no live successor + * means the whole chain is dead. + */ + if (!HeapTupleHeaderIsHotUpdated(htup) || + ItemPointerGetBlockNumber(&htup->t_ctid) != prstate->block) + return InvalidOffsetNumber; + offnum = ItemPointerGetOffsetNumber(&htup->t_ctid); + survivor = offnum; + } + else if (status == HEAPTUPLE_RECENTLY_DEAD) + { + /* + * RECENTLY_DEAD members belong to the surviving tail unless a + * DEAD member follows them (which would make them part of the + * dead prefix). Keep walking to find out, but do not advance the + * survivor; it stays at the successor of the last DEAD member. + */ + if (!HeapTupleHeaderIsHotUpdated(htup) || + ItemPointerGetBlockNumber(&htup->t_ctid) != prstate->block) + return survivor; + offnum = ItemPointerGetOffsetNumber(&htup->t_ctid); + } + else + { + /* LIVE (or in-progress): the surviving tail is settled. */ + return survivor; + } + + if (++loops > maxoff) + return InvalidOffsetNumber; /* defend against a corrupt cycle */ + } + + return InvalidOffsetNumber; +} + /* * Prune specified line pointer or a HOT chain originating at line pointer. * @@ -1515,6 +1666,7 @@ heap_prune_chain(OffsetNumber maxoff, OffsetNumber rootoffnum, */ int ndeadchain = 0, nchain = 0; + int nstubhops = 0; rootlp = PageGetItemId(page, rootoffnum); @@ -1538,6 +1690,38 @@ heap_prune_chain(OffsetNumber maxoff, OffsetNumber rootoffnum, if (offnum > maxoff) break; + /* + * Step transparently through a collapse-survivor stub. A redirect or + * an earlier stub may forward into a stub that replaced a dead + * mid-chain member; the stub was already recorded (as unchanged) by + * the page scan and is not itself a chain member, so follow its + * forward link rather than stopping at the processed check below. + */ + { + ItemId slp = PageGetItemId(page, offnum); + + if (ItemIdIsNormal(slp)) + { + HeapTupleHeader shtup = (HeapTupleHeader) PageGetItem(page, slp); + + if (HotIndexedHeaderIsStub(shtup)) + { + /* + * A stub is xid-free, so the xmin/xmax linkage cannot be + * verified across it. Trust the stub's forward link and + * skip the prior-xmax check for the first member past it + * (otherwise the chain would be severed there, dropping + * its tail). + */ + offnum = HotIndexedStubGetForward(shtup); + priorXmax = InvalidTransactionId; + if (++nstubhops > maxoff) + break; /* defend against a corrupt stub-forward cycle */ + continue; + } + } + } + /* If item is already processed, stop --- it must not be same chain */ if (prstate->processed[offnum]) break; @@ -1644,13 +1828,27 @@ heap_prune_chain(OffsetNumber maxoff, OffsetNumber rootoffnum, if (ItemIdIsRedirected(rootlp) && nchain < 2) { /* - * We found a redirect item that doesn't point to a valid follow-on - * item. This can happen if the loop in heap_page_prune_and_freeze() - * caused us to visit the dead successor of a redirect item before - * visiting the redirect item. We can clean up by setting the - * redirect item to LP_DEAD state or LP_UNUSED if the caller - * indicated. + * The walk could not get past the redirect: its target was either + * already processed by a sibling redirect's walk (several redirects + * of a collapsed HOT/SIU chain forward to the same live tuple) or has + * since died and been collapsed further. Re-point this redirect at + * the chain's current first surviving member so every entry that + * resolves through it still reaches the live tuple. If no survivor + * remains, the redirect is dangling and is reclaimed (LP_DEAD, or + * LP_UNUSED if the caller allows it). */ + OffsetNumber target = ItemIdGetRedirect(rootlp); + OffsetNumber live = heap_prune_chain_find_live(prstate, target); + + if (OffsetNumberIsValid(live)) + { + if (live == target) + heap_prune_record_unchanged_lp_redirect(prstate, rootoffnum); + else + heap_prune_record_redirect(prstate, rootoffnum, live, false); + return; + } + heap_prune_record_dead_or_unused(prstate, rootoffnum, false); return; } @@ -1676,24 +1874,143 @@ heap_prune_chain(OffsetNumber maxoff, OffsetNumber rootoffnum, else if (ndeadchain == nchain) { /* - * The entire chain is dead. Mark the root line pointer LP_DEAD, and - * fully remove the other tuples in the chain. + * The entire chain is dead. No live tuple remains to forward to, so + * mark the root LP_DEAD (or LP_UNUSED if the caller allows it) and + * reclaim each member. A dead HOT-selectively-updated member may + * still have a stale btree leaf pointing at it: mark it LP_DEAD so + * the slot is pinned against reuse and added to the dead-items array, + * letting ambulkdelete sweep the leaf and a later vacuum reclaim the + * line pointer. Classic-HOT members carry no leaf of their own and + * go straight to LP_UNUSED. */ heap_prune_record_dead_or_unused(prstate, rootoffnum, ItemIdIsNormal(rootlp)); for (int i = 1; i < nchain; i++) - heap_prune_record_unused(prstate, chainitems[i], true); + { + if (heap_prune_item_preserves_hot_indexed(page, chainitems[i])) + heap_prune_record_dead_or_unused(prstate, chainitems[i], true); + else + heap_prune_record_unused(prstate, chainitems[i], true); + } } else { /* - * We found a DEAD tuple in the chain. Redirect the root line pointer - * to the first non-DEAD tuple, and mark as unused each intermediate - * item that we are able to remove from the chain. + * The chain has a dead prefix followed by a live remainder. Collapse + * it with PHOT-style key tuples so that the per-hop modified-attrs + * bitmaps survive for the bitmap-overlap read path. + * + * Walk the dead members from the live end backwards, accumulating in + * laterattrs the union of the modified-attrs bitmaps of the members + * that follow (the "later hops"). A dead key tuple -- one that + * carried its own index entries because it changed an indexed + * attribute at its hop (heap_prune_item_preserves_hot_indexed) -- is + * disposed of as follows: + * + * - If every attribute it changed was changed again by a later hop + * (its bitmap is a subset of laterattrs), every index entry pointing + * at it is superseded, so no live entry references it: reclaim it + * (LP_DEAD), which lets this vacuum's index pass sweep its now-stale + * leaves and a later pass free the line pointer. This loses no + * per-hop information for readers -- its attributes are already + * carried by the surviving later members the reader still crosses. + * + * - Otherwise it introduced an attribute not changed again, so a live + * entry still points at it: keep it as an xid-free stub forwarding to + * the next survivor, preserving its inline bitmap for the read path. + * + * Classic-HOT members carry no entry of their own and are reclaimed + * to LP_UNUSED; survivors forward past them. The root is redirected + * to the first survivor. Stubs carry no XIDs, so the page stays + * freeze-safe; because each forwards to the next survivor (not the + * live tuple), a reader crossing the collapsed prefix sees every + * surviving hop's bitmap, and stub->stub forwarding lets a later + * collapse extend the chain without re-pointing existing stubs. */ - heap_prune_record_redirect(prstate, rootoffnum, chainitems[ndeadchain], - ItemIdIsNormal(rootlp)); - for (int i = 1; i < ndeadchain; i++) - heap_prune_record_unused(prstate, chainitems[i], true); + OffsetNumber first_live = chainitems[ndeadchain]; + OffsetNumber next_survivor = first_live; + OffsetNumber root_target; + int relnatts = RelationGetNumberOfAttributes(prstate->relation); + uint8 laterattrs[(MaxHeapAttributeNumber + 7) / 8]; + + /* + * laterattrs accumulates every surviving hop's modified attributes. + * Size it for the relation's current natts (the maximum); each + * contributing tuple's bitmap is located and OR-ed using that tuple's + * write-time natts (HotIndexedTupleBitmapNatts), since ADD COLUMN may + * have grown the relation since some hops were written. + */ + memset(laterattrs, 0, HotIndexedBitmapBytes(relnatts)); + for (int i = ndeadchain; i < nchain; i++) + { + ItemId lp = PageGetItemId(page, chainitems[i]); + HeapTupleHeader htup = (HeapTupleHeader) PageGetItem(page, lp); + + if ((htup->t_infomask2 & HEAP_INDEXED_UPDATED) != 0) + { + int bmnatts = HotIndexedTupleBitmapNatts(htup); + + /* + * A hop's write-time natts can never legitimately exceed the + * relation's current natts (natts only grows via ADD COLUMN). + * On a corrupt page a stub's unbounded stashed natts (or a + * corrupt live tuple's natts field) could otherwise overflow + * laterattrs, which is sized for relnatts; clamp defensively. + */ + Assert(bmnatts <= relnatts); + if (bmnatts > relnatts) + bmnatts = relnatts; + + HotIndexedBitmapUnion(laterattrs, + HotIndexedGetModifiedBitmap(htup, + ItemIdGetLength(lp), + bmnatts), + bmnatts); + } + } + + /* dead prefix: reclaim superseded members, stub the rest */ + for (int i = ndeadchain - 1; i >= 1; i--) + { + if (heap_prune_item_preserves_hot_indexed(page, chainitems[i])) + { + ItemId lp = PageGetItemId(page, chainitems[i]); + HeapTupleHeader htup = (HeapTupleHeader) PageGetItem(page, lp); + int bmnatts = HotIndexedTupleBitmapNatts(htup); + const uint8 *attrs; + + /* See the comment above laterattrs' first use. */ + Assert(bmnatts <= relnatts); + if (bmnatts > relnatts) + bmnatts = relnatts; + attrs = HotIndexedGetModifiedBitmap(htup, + ItemIdGetLength(lp), + bmnatts); + + if (HotIndexedBitmapIsSubset(attrs, laterattrs, bmnatts)) + heap_prune_record_dead_or_unused(prstate, chainitems[i], true); + else + { + heap_prune_record_stub(prstate, chainitems[i], next_survivor); + next_survivor = chainitems[i]; + } + HotIndexedBitmapUnion(laterattrs, attrs, bmnatts); + } + else + heap_prune_record_unused(prstate, chainitems[i], true); + } + + root_target = next_survivor; + + /* + * root -> first survivor (skip a redundant no-op redirect on + * re-prune) + */ + if (ItemIdIsRedirected(rootlp) && + ItemIdGetRedirect(rootlp) == root_target) + heap_prune_record_unchanged_lp_redirect(prstate, rootoffnum); + else + heap_prune_record_redirect(prstate, rootoffnum, root_target, + ItemIdIsNormal(rootlp)); /* the rest of tuples in the chain are normal, unchanged tuples */ for (int i = ndeadchain; i < nchain; i++) @@ -1738,6 +2055,34 @@ heap_prune_record_redirect(PruneState *prstate, * separately as an unchanged tuple. */ + /* + * If the redirect points at a HOT-selectively-updated live tuple, the + * page may still carry stale btree entries that resolve through this + * redirect to a tuple with a different key. Such entries are filtered by + * the read path's crossed-attribute bitmap, which requires fetching the + * heap tuple -- but an index-only scan trusts the visibility map and skips + * that fetch. So + * the page must not be reported all-visible/all-frozen while such a + * redirect exists; it becomes eligible again only once vacuum has swept + * the stale leaves and reclaimed the redirect. + */ + if (rdoffnum >= FirstOffsetNumber && + rdoffnum <= PageGetMaxOffsetNumber(prstate->page)) + { + ItemId tlp = PageGetItemId(prstate->page, rdoffnum); + + if (ItemIdIsNormal(tlp)) + { + HeapTupleHeader thtup = (HeapTupleHeader) PageGetItem(prstate->page, tlp); + + if ((thtup->t_infomask2 & HEAP_INDEXED_UPDATED) != 0) + { + prstate->set_all_visible = false; + prstate->set_all_frozen = false; + } + } + } + Assert(prstate->nredirected < MaxHeapTuplesPerPage); prstate->redirected[prstate->nredirected * 2] = offnum; prstate->redirected[prstate->nredirected * 2 + 1] = rdoffnum; @@ -1755,6 +2100,65 @@ heap_prune_record_redirect(PruneState *prstate, prstate->hastup = true; } +/* + * Record a line pointer to be rewritten in place as a HOT-selectively-updated + * collapse-survivor stub forwarding to 'forward'. + * + * The source must be a dead heap-only tuple that carried its own btree + * entries (a key tuple) and so cannot be reclaimed outright: a stale entry may + * still resolve through it. Rewriting it into an xid-free stub keeps the + * forward link and the tuple's inline modified-attrs bitmap (so the read path + * can judge staleness) while dropping its XIDs, which keeps the page + * freeze-safe. + */ +static void +heap_prune_record_stub(PruneState *prstate, + OffsetNumber offnum, OffsetNumber forward) +{ + Assert(!prstate->processed[offnum]); + prstate->processed[offnum] = true; + + /* + * As with a redirect to a HOT-selectively-updated tuple, the page must + * not be reported all-visible/all-frozen while a stub exists: an + * index-only scan would otherwise trust the VM and skip the recheck that + * filters a now-stale entry resolving through the stub. A stub's + * HEAP_XMIN_INVALID also makes it invisible to every snapshot, which an + * all-visible page must never contain. Eligibility returns once vacuum + * reclaims the stub. + */ + prstate->set_all_visible = false; + prstate->set_all_frozen = false; + + Assert(prstate->nstubs < MaxHeapTuplesPerPage); + prstate->stubs[prstate->nstubs * 2] = offnum; + prstate->stubs[prstate->nstubs * 2 + 1] = forward; + prstate->nstubs++; + + /* The dead key tuple's storage is being discarded; count it removed. */ + prstate->ndeleted++; + + prstate->hastup = true; +} + +/* + * Record an existing collapse-survivor stub that is to be left unchanged. + * + * Encountered when re-pruning a page that already holds stubs from an earlier + * collapse. The stub is preserved (its bitmap is still needed) and counts as + * a reason the page cannot be reported all-visible/all-frozen. + */ +static void +heap_prune_record_unchanged_lp_stub(PruneState *prstate, OffsetNumber offnum) +{ + Assert(!prstate->processed[offnum]); + prstate->processed[offnum] = true; + prstate->hastup = true; + + prstate->set_all_visible = false; + prstate->set_all_frozen = false; +} + /* Record line pointer to be marked dead */ static void heap_prune_record_dead(PruneState *prstate, OffsetNumber offnum, @@ -1836,6 +2240,52 @@ heap_prune_record_unused(PruneState *prstate, OffsetNumber offnum, bool was_norm prstate->ndeleted++; } + +/* + * heap_prune_item_preserves_hot_indexed + * True iff the LP at `offnum` on `page` is a live HOT-indexed (HOT/SIU) + * heap-only tuple whose LP must be preserved rather than reclaimed to + * LP_UNUSED, because a not-yet-swept index entry may still point at it. + * + * A HOT-indexed update plants a new index entry pointing at the heap-only + * tuple's own TID. Classic HOT's invariant that mid-chain LPs have no + * external references therefore does not hold for such tuples: until + * ambulkdelete sweeps any stale index entry, a reader arriving via it must + * still find a walkable hop at the LP. Chain collapse converts dead members + * to LP_REDIRECT forwarders for exactly this reason; a live member like this + * one must simply not be reclaimed out from under such a reader. + * + * Excluded from preservation: + * - items that are not LP_NORMAL (REDIRECT, DEAD, UNUSED); + * - tuples without HEAP_INDEXED_UPDATED (classic HOT chain members never + * had a per-tuple index entry planted); + * - tuples with no attributes (defensive: not a real chain member); + * - aborted heap-only tuples (HEAP_XMIN_INVALID): never visible through any + * index entry, so reclaiming them is safe. + */ +static bool +heap_prune_item_preserves_hot_indexed(Page page, OffsetNumber offnum) +{ + ItemId lp = PageGetItemId(page, offnum); + HeapTupleHeader htup; + + if (!ItemIdIsNormal(lp)) + return false; + + htup = (HeapTupleHeader) PageGetItem(page, lp); + + if ((htup->t_infomask2 & HEAP_INDEXED_UPDATED) == 0) + return false; + if (HeapTupleHeaderGetNatts(htup) == 0) + return false; + if ((htup->t_infomask & HEAP_XMIN_INVALID) != 0) + return false; + + return true; +} + + + /* * Record an unused line pointer that is left unchanged. */ @@ -2069,8 +2519,44 @@ heap_prune_record_unchanged_lp_redirect(PruneState *prstate, OffsetNumber offnum */ Assert(!prstate->processed[offnum]); prstate->processed[offnum] = true; + + /* + * As in heap_prune_record_redirect: if this redirect forwards to a + * HOT-selectively-updated live tuple, the page may carry stale btree + * entries that resolve through it, so it must not be reported + * all-visible/all-frozen (an index-only scan would otherwise skip the + * crossed-attribute bitmap check). This must happen here too, not only when the + * redirect is first created, because a re-prune records an existing SIU + * redirect as unchanged. + */ + { + ItemId lp = PageGetItemId(prstate->page, offnum); + + if (ItemIdIsRedirected(lp)) + { + OffsetNumber rdoffnum = ItemIdGetRedirect(lp); + + if (rdoffnum >= FirstOffsetNumber && + rdoffnum <= PageGetMaxOffsetNumber(prstate->page)) + { + ItemId tlp = PageGetItemId(prstate->page, rdoffnum); + + if (ItemIdIsNormal(tlp)) + { + HeapTupleHeader thtup = (HeapTupleHeader) PageGetItem(prstate->page, tlp); + + if ((thtup->t_infomask2 & HEAP_INDEXED_UPDATED) != 0) + { + prstate->set_all_visible = false; + prstate->set_all_frozen = false; + } + } + } + } + } } + /* * Perform the actual page changes needed by heap_page_prune_and_freeze(). * @@ -2085,16 +2571,27 @@ void heap_page_prune_execute(Buffer buffer, bool lp_truncate_only, OffsetNumber *redirected, int nredirected, OffsetNumber *nowdead, int ndead, - OffsetNumber *nowunused, int nunused) + OffsetNumber *nowunused, int nunused, + OffsetNumber *stubs, int nstubs) { Page page = BufferGetPage(buffer); OffsetNumber *offnum; HeapTupleHeader htup PG_USED_FOR_ASSERTS_ONLY; /* Shouldn't be called unless there's something to do */ - Assert(nredirected > 0 || ndead > 0 || nunused > 0); + Assert(nredirected > 0 || ndead > 0 || nunused > 0 || nstubs > 0); - /* If 'lp_truncate_only', we can only remove already-dead line pointers */ + /* + * If 'lp_truncate_only', we can only remove already-dead line pointers + * and cannot re-point redirects: repointing moves the tuple an index-only + * scan or a concurrent chain walk expects to find, which needs a cleanup + * lock (see the file header comment). No producer in this series calls + * with lp_truncate_only set and a nonzero nredirected -- vacuum's second + * pass (the lp_truncate_only caller) never re-points a redirect itself, + * leaving that to a later prune that holds a cleanup lock -- so keep + * upstream's stricter assertion rather than the weaker one this would + * otherwise need to justify. + */ Assert(!lp_truncate_only || (nredirected == 0 && ndead == 0)); /* Update all redirected line pointers */ @@ -2106,6 +2603,16 @@ heap_page_prune_execute(Buffer buffer, bool lp_truncate_only, ItemId fromlp = PageGetItemId(page, fromoff); ItemId tolp PG_USED_FOR_ASSERTS_ONLY; + /* + * A redundant redirect (the LP already redirects to tooff) is a + * harmless no-op. This arises when a HOT-indexed chain that was + * already collapsed is re-pruned and the root still resolves to the + * same target; skip it so the apply stays idempotent on both primary + * and replay. + */ + if (ItemIdIsRedirected(fromlp) && ItemIdGetRedirect(fromlp) == tooff) + continue; + #ifdef USE_ASSERT_CHECKING /* @@ -2120,7 +2627,16 @@ heap_page_prune_execute(Buffer buffer, bool lp_truncate_only, Assert(ItemIdHasStorage(fromlp) && ItemIdIsNormal(fromlp)); htup = (HeapTupleHeader) PageGetItem(page, fromlp); - Assert(!HeapTupleHeaderIsHeapOnly(htup)); + + /* + * The redirect source is normally the non-heap-only chain root. A + * HOT/SIU chain collapse additionally redirects dead heap-only + * members that carried their own btree entry to the live tuple, + * so a heap-only redirect source is allowed when it is + * HOT-selectively-updated (HEAP_INDEXED_UPDATED). + */ + Assert(!HeapTupleHeaderIsHeapOnly(htup) || + (htup->t_infomask2 & HEAP_INDEXED_UPDATED) != 0); } else { @@ -2148,12 +2664,55 @@ heap_page_prune_execute(Buffer buffer, bool lp_truncate_only, tolp = PageGetItemId(page, tooff); Assert(ItemIdHasStorage(tolp) && ItemIdIsNormal(tolp)); htup = (HeapTupleHeader) PageGetItem(page, tolp); + /* A redirect targets the first surviving member: a heap-only tuple. */ Assert(HeapTupleHeaderIsHeapOnly(htup)); #endif ItemIdSetRedirect(fromlp, tooff); } + /* + * Rewrite collapse-survivor stubs in place. Each (offset, forward) pair + * names a dead key tuple to be turned into an xid-free forwarding stub: + * permanently invisible (HEAP_XMIN_INVALID|HEAP_XMAX_INVALID), flagged + * HEAP_INDEXED_UPDATED with natts == 0 so consumers recognise it as a + * stub rather than a tuple, heap-only preserved so it remains a valid + * redirect/forward target, and t_ctid.offnum set to the forward offset. + * The item's storage (including its inline modified-attrs bitmap in the + * final bytes) is left undisturbed, so the bitmap survives and need not + * be carried in WAL. + */ + offnum = stubs; + for (int i = 0; i < nstubs; i++) + { + OffsetNumber off = *offnum++; + OffsetNumber forward = *offnum++; + ItemId lp = PageGetItemId(page, off); + HeapTupleHeader tup; + int bitmap_natts; + + Assert(ItemIdIsNormal(lp)); + tup = (HeapTupleHeader) PageGetItem(page, lp); + + /* + * Preserve the tuple's write-time natts before we overwrite the natts + * field with the stub sentinel (0): the trailing modified-attrs bitmap + * was sized with it, and readers need it to locate the bitmap when the + * relation's current natts has since grown (ADD COLUMN). The stub's + * t_ctid offset half holds the forward link; the block half is unused + * for a stub, so stash the natts there. This runs identically on the + * primary and in redo (the pre-stub tuple is on both pages), so no WAL + * change is needed. + */ + bitmap_natts = HeapTupleHeaderGetNatts(tup); + + tup->t_infomask = HEAP_XMIN_INVALID | HEAP_XMAX_INVALID; + tup->t_infomask2 = HEAP_ONLY_TUPLE | HEAP_INDEXED_UPDATED; + HeapTupleHeaderSetNatts(tup, 0); + ItemPointerSetOffsetNumber(&tup->t_ctid, forward); + HotIndexedStubSetBitmapNatts(tup, bitmap_natts); + } + /* Update all now-dead line pointers */ offnum = nowdead; for (int i = 0; i < ndead; i++) @@ -2169,12 +2728,23 @@ heap_page_prune_execute(Buffer buffer, bool lp_truncate_only, * an index. This should never be necessary with any individual * heap-only tuple item, though. (It's not clear how much of a problem * that would be, but there is no reason to allow it.) + * + * Exception: a HOT-indexed aborted orphan whose chain root is + * unreachable on this page is intentionally marked LP_DEAD by the + * heap-only-tuples loop in heap_page_prune_and_freeze (see the + * heap_prune_record_dead call there). The tuple is heap-only (it was + * created by an UPDATE) and carries HEAP_INDEXED_UPDATED; the + * adjacent btree leaf is still live, so we keep the slot pinned via + * LP_DEAD until ambulkdelete sweeps it. A subsequent vacuum reclaims + * the LP to LP_UNUSED. */ if (ItemIdHasStorage(lp)) { Assert(ItemIdIsNormal(lp)); htup = (HeapTupleHeader) PageGetItem(page, lp); - Assert(!HeapTupleHeaderIsHeapOnly(htup)); + Assert(!HeapTupleHeaderIsHeapOnly(htup) || + ((htup->t_infomask2 & HEAP_INDEXED_UPDATED) != 0 && + HeapTupleHeaderGetNatts(htup) > 0)); } else { @@ -2197,7 +2767,7 @@ heap_page_prune_execute(Buffer buffer, bool lp_truncate_only, if (lp_truncate_only) { - /* Setting LP_DEAD to LP_UNUSED in vacuum's second pass */ + /* Setting LP_DEAD to LP_UNUSED in vacuum's second pass. */ Assert(ItemIdIsDead(lp) && !ItemIdHasStorage(lp)); } else @@ -2208,7 +2778,8 @@ heap_page_prune_execute(Buffer buffer, bool lp_truncate_only, * items to be made LP_UNUSED instead. This is only possible if * the relation has no indexes. If there are any dead items, then * mark_unused_now was not true and every item being marked - * LP_UNUSED must refer to a heap-only tuple. + * LP_UNUSED must refer to a heap-only tuple whose chain has been + * pruned. */ if (ndead > 0) { @@ -2284,6 +2855,8 @@ page_verify_redirects(Page page) Assert(ItemIdIsNormal(targitem)); Assert(ItemIdHasStorage(targitem)); htup = (HeapTupleHeader) PageGetItem(page, targitem); + + /* A redirect targets the first surviving chain member: heap-only. */ Assert(HeapTupleHeaderIsHeapOnly(htup)); } #endif @@ -2586,7 +3159,8 @@ log_heap_prune_and_freeze(Relation relation, Buffer buffer, HeapTupleFreeze *frozen, int nfrozen, OffsetNumber *redirected, int nredirected, OffsetNumber *dead, int ndead, - OffsetNumber *unused, int nunused) + OffsetNumber *unused, int nunused, + OffsetNumber *stubs, int nstubs) { xl_heap_prune xlrec; XLogRecPtr recptr; @@ -2601,8 +3175,10 @@ log_heap_prune_and_freeze(Relation relation, Buffer buffer, xlhp_prune_items redirect_items; xlhp_prune_items dead_items; xlhp_prune_items unused_items; + xlhp_prune_items stub_items; OffsetNumber frz_offsets[MaxHeapTuplesPerPage]; - bool do_prune = nredirected > 0 || ndead > 0 || nunused > 0; + bool do_prune = nredirected > 0 || ndead > 0 || nunused > 0 || + nstubs > 0; bool do_set_vm = vmflags & VISIBILITYMAP_VALID_BITS; bool heap_fpi_allowed = true; @@ -2690,6 +3266,16 @@ log_heap_prune_and_freeze(Relation relation, Buffer buffer, XLogRegisterBufData(0, unused, sizeof(OffsetNumber) * nunused); } + if (nstubs > 0) + { + xlrec.flags |= XLHP_HAS_HOT_INDEXED_STUBS; + + stub_items.ntargets = nstubs; + XLogRegisterBufData(0, &stub_items, + offsetof(xlhp_prune_items, data)); + XLogRegisterBufData(0, stubs, + sizeof(OffsetNumber[2]) * nstubs); + } if (nfrozen > 0) XLogRegisterBufData(0, frz_offsets, sizeof(OffsetNumber) * nfrozen); @@ -2712,8 +3298,17 @@ log_heap_prune_and_freeze(Relation relation, Buffer buffer, xlrec.flags |= XLHP_CLEANUP_LOCK; else { - Assert(nredirected == 0 && ndead == 0); - /* also, any items in 'unused' must've been LP_DEAD previously */ + /* + * Without a cleanup lock we can only remove already-dead line + * pointers and re-point redirects. The latter happens when vacuum's + * second pass reclaims a collapsed HOT-indexed chain and re-points + * the root redirect at first_live: that change is made under an + * exclusive lock and preserves the chain's reachability (every walker + * still reaches first_live), so no cleanup lock is needed -- the same + * basis on which this pass already reclaims dead line pointers to + * LP_UNUSED. + */ + Assert(ndead == 0); } XLogRegisterData(&xlrec, SizeOfHeapPrune); if (TransactionIdIsValid(conflict_xid)) diff --git a/src/backend/access/heap/vacuumlazy.c b/src/backend/access/heap/vacuumlazy.c index 39395aed0d592..6a8a5bbd790e9 100644 --- a/src/backend/access/heap/vacuumlazy.c +++ b/src/backend/access/heap/vacuumlazy.c @@ -131,6 +131,7 @@ #include "access/genam.h" #include "access/heapam.h" +#include "access/hot_indexed.h" #include "access/htup_details.h" #include "access/multixact.h" #include "access/tidstore.h" @@ -1972,6 +1973,7 @@ lazy_scan_new_or_empty(LVRelState *vacrel, Buffer buf, BlockNumber blkno, NULL, 0, NULL, 0, NULL, 0, + NULL, 0, NULL, 0); END_CRIT_SECTION(); @@ -2686,6 +2688,7 @@ lazy_vacuum_heap_rel(LVRelState *vacrel) Size freespace; OffsetNumber offsets[MaxOffsetNumber]; int num_offsets; + bool got_cleanup_lock; vacuum_delay_point(false); @@ -2708,8 +2711,18 @@ lazy_vacuum_heap_rel(LVRelState *vacrel) */ visibilitymap_pin(vacrel->rel, blkno, &vmbuffer); - /* We need a non-cleanup exclusive lock to mark dead_items unused */ - LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + /* + * Setting dead items unused needs only an exclusive lock. We still + * prefer a cleanup lock here, as the first pass does, and fall back to + * an exclusive lock if one is not immediately available. This pass + * only turns LP_DEAD items into LP_UNUSED; it does NOT reclaim a + * collapsed HOT-indexed chain's stubs or re-point its redirects -- + * that chain-structure rewrite (which moves TIDs concurrent scans + * follow, and so needs a cleanup lock) is left to a later prune. + */ + got_cleanup_lock = ConditionalLockBufferForCleanup(buf); + if (!got_cleanup_lock) + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); lazy_vacuum_heap_page(vacrel, blkno, buf, offsets, num_offsets, vmbuffer); @@ -2783,7 +2796,10 @@ lazy_vacuum_heap_page(LVRelState *vacrel, BlockNumber blkno, Buffer buffer, * and mark the page all-visible within the same critical section, * enabling both changes to be emitted in a single WAL record. Since the * visibility checks may perform I/O and allocate memory, they must be - * done outside the critical section. + * done outside the critical section. heap_page_would_be_all_visible() + * carries its own SIU-redirect and stub guards, so it correctly refuses + * to mark the page all-visible while a not-yet-reclaimed HOT-indexed + * member is still present. */ if (heap_page_would_be_all_visible(vacrel->rel, buffer, vacrel->vistest, true, @@ -2815,13 +2831,12 @@ lazy_vacuum_heap_page(LVRelState *vacrel, BlockNumber blkno, Buffer buffer, itemid = PageGetItemId(page, toff); + /* A reclaimable item is a classic LP_DEAD line pointer. */ Assert(ItemIdIsDead(itemid) && !ItemIdHasStorage(itemid)); ItemIdSetUnused(itemid); unused[nunused++] = toff; } - Assert(nunused > 0); - /* Attempt to truncate line pointer array now */ PageTruncateLinePointerArray(page); @@ -2851,12 +2866,13 @@ lazy_vacuum_heap_page(LVRelState *vacrel, BlockNumber blkno, Buffer buffer, vmflags != 0 ? vmbuffer : InvalidBuffer, vmflags, conflict_xid, - false, /* no cleanup lock required */ + false, /* no cleanup lock required: see below */ PRUNE_VACUUM_CLEANUP, NULL, 0, /* frozen */ NULL, 0, /* redirected */ NULL, 0, /* dead */ - unused, nunused); + unused, nunused, + NULL, 0); /* stubs */ } END_CRIT_SECTION(); @@ -3647,10 +3663,44 @@ heap_page_would_be_all_visible(Relation rel, Buffer buf, *logging_offnum = offnum; itemid = PageGetItemId(page, offnum); - /* Unused or redirect line pointers are of no interest */ - if (!ItemIdIsUsed(itemid) || ItemIdIsRedirected(itemid)) + /* Unused line pointers are of no interest. */ + if (!ItemIdIsUsed(itemid)) continue; + /* + * Plain redirects are of no interest (the chain member they point at + * is inspected separately) -- except a redirect that forwards to a + * HOT-selectively-updated live tuple. Such a redirect may still be + * reached by a stale index entry whose key the live tuple no longer + * holds; if the page were marked all-visible an index-only scan would + * trust the VM, skip the heap fetch, and surface that stale key. + * Keep the page not-all-visible until the stale leaves are swept and + * the redirect reclaimed. This mirrors the guard in + * heap_prune_record_redirect, applied here because VACUUM's second + * pass can set all-visible after reclaiming other items on the page. + */ + if (ItemIdIsRedirected(itemid)) + { + OffsetNumber rdoff = ItemIdGetRedirect(itemid); + + if (rdoff >= FirstOffsetNumber && rdoff <= maxoff) + { + ItemId tlp = PageGetItemId(page, rdoff); + + if (ItemIdIsNormal(tlp)) + { + HeapTupleHeader thtup = (HeapTupleHeader) PageGetItem(page, tlp); + + if ((thtup->t_infomask2 & HEAP_INDEXED_UPDATED) != 0) + { + *all_frozen = all_visible = false; + break; + } + } + } + continue; + } + ItemPointerSet(&(tuple.t_self), blockno, offnum); /* @@ -3676,6 +3726,24 @@ heap_page_would_be_all_visible(Relation rel, Buffer buf, tuple.t_len = ItemIdGetLength(itemid); tuple.t_tableOid = RelationGetRelid(rel); + /* + * A HOT-indexed collapse-survivor stub is an LP_NORMAL item that is + * not a real tuple: it forwards through the chain and carries a + * preserved modified-attrs bitmap that a reader arriving via a stale + * leaf must still cross. A page holding one must stay not-all-visible + * so index-only scans heap-fetch through the chain, exactly like the + * redirect-to-SIU case above. A stub's header is frozen-invalid + * (HEAP_XMIN_INVALID), so the visibility check below would also class + * it not-all-visible -- but recognize it explicitly here rather than + * relying on that side effect, so the guard cannot silently lapse if + * the stub encoding ever changes. + */ + if (HotIndexedHeaderIsStub(tuple.t_data)) + { + *all_frozen = all_visible = false; + break; + } + /* Visibility checks may do IO or allocate memory */ Assert(CritSectionCount == 0); switch (HeapTupleSatisfiesVacuumHorizon(&tuple, buf, &dead_after)) diff --git a/src/backend/access/index/genam.c b/src/backend/access/index/genam.c index 1408989c56873..6628f9bf85dc0 100644 --- a/src/backend/access/index/genam.c +++ b/src/backend/access/index/genam.c @@ -103,6 +103,9 @@ RelationGetIndexScan(Relation indexRelation, int nkeys, int norderbys) scan->orderByData = NULL; scan->xs_want_itup = false; /* may be set later */ + scan->xs_index_only = false; /* may be set later */ + + scan->xs_hot_indexed_stale = false; /* * During recovery we ignore killed tuples and don't bother to kill them diff --git a/src/backend/access/index/indexam.c b/src/backend/access/index/indexam.c index 7967e93984786..7237777e61c3c 100644 --- a/src/backend/access/index/indexam.c +++ b/src/backend/access/index/indexam.c @@ -44,6 +44,7 @@ #include "postgres.h" #include "access/amapi.h" +#include "access/hot_indexed.h" #include "access/relation.h" #include "access/reloptions.h" #include "access/relscan.h" @@ -606,6 +607,15 @@ index_getnext_tid(IndexScanDesc scan, ScanDirection direction) /* XXX: we should assert that a snapshot is pushed or registered */ Assert(TransactionIdIsValid(RecentXmin)); + /* + * Reset the HOT-indexed recheck flag: it is set by the heap AM during + * index_fetch_heap and is per-fetched-tuple, not per-index-entry. For + * IndexOnlyScan, which may skip index_fetch_heap when the VM says the + * entry is visible-to-all, this ensures we don't carry a stale value from + * a previous entry. + */ + scan->xs_hot_indexed_stale = false; + /* * The AM's amgettuple proc finds the next index entry matching the scan * keys, and puts the TID into scan->xs_heaptid. It should also set @@ -666,15 +676,97 @@ index_fetch_heap(IndexScanDesc scan, TupleTableSlot *slot) if (found) pgstat_count_heap_fetch(scan->indexRelation); + /* + * The table AM reported, via xs_hot_indexed_recheck, whether the walk to + * the live tuple crossed a HOT-indexed hop after the arriving index + * entry's own tuple. When it did, the entry's stored key may no longer + * agree with the live tuple, and we must decide whether to drop it. + * + * The crossed-attribute bitmap (xs_hot_indexed_crossed) is the staleness + * authority. It is the union of the per-hop modified-attribute bitmaps + * of every hop the walk crossed, and it is complete: each crossed live + * hop, collapse-survivor stub, and redirected (collapsed) prefix + * contributes its segment's bitmap, and chain collapse only ever reclaims + * a member whose attributes are a subset of the surviving later hops (see + * pruneheap.c). Therefore: + * + * - if the union is disjoint from the heap columns this index references, + * none of the index's inputs changed across the chain, so the entry's key + * still matches the live tuple: keep it; and + * + * - if the union overlaps them, one of this index's key columns changed + * after the entry's own tuple, so the entry is stale: drop it. + * + * Dropping on overlap is correct even when the key was cycled away and + * back to its original value (an ABA update): the update that set the + * value back created a fresh entry pointing at its own (live) tuple, + * whose walk crosses no later key-changing hop, so that entry uniquely + * supplies the row while this stale ancestor entry is dropped. No + * value-recheck is needed, so this works for any access method; the + * staleness decision is purely attribute-based. + */ + scan->xs_hot_indexed_stale = false; + if (found && + scan->xs_heapfetch->xs_hot_indexed_recheck && + scan->xs_heapfetch->xs_hot_indexed_crossed != NULL) + { + Bitmapset *idxattrs = RelationGetIndexedAttrs(scan->indexRelation); + int x = -1; + + while ((x = bms_next_member(idxattrs, x)) >= 0) + { + AttrNumber attnum = x + FirstLowInvalidHeapAttributeNumber; + + /* the crossed bitmap records only user attributes */ + if (attnum >= 1 && + HotIndexedAttrIsModified(scan->xs_heapfetch->xs_hot_indexed_crossed, + attnum)) + { + scan->xs_hot_indexed_stale = true; + break; + } + } + bms_free(idxattrs); + } + /* * If we scanned a whole HOT chain and found only dead tuples, tell index * AM to kill its entry for that TID (this will take effect in the next * amgettuple call, in index_getnext_tid). We do not do this when in * recovery because it may violate MVCC to do so. See comments in * RelationGetIndexScan(). + * + * Additionally kill a stale HOT-indexed leaf (one whose key the live + * tuple no longer holds) when every chain member skipped before the + * returned tuple is dead to all transactions (xs_prefix_all_dead): no + * snapshot can reach a matching version through this leaf, so it is + * redundant and reclaiming it bounds the index bloat HOT-indexed updates + * create. + * + * Two independent conditions make this safe: + * + * - The surely-dead prefix gate (xs_prefix_all_dead) means no snapshot, + * including older ones still running, can reach a version through this + * leaf whose key matches: every member ahead of the live tuple is dead + * to all. This is what makes it MVCC-safe, exactly as for the + * all_dead case. + * + * - The leaf is genuinely redundant, not the row's only entry. A stale + * verdict means the crossed-hop union overlaps this index's columns, + * i.e. one of this index's attributes changed on a hop after this + * leaf's target. The update that made that change maintained this + * index (its attribute changed), so it planted a fresh entry pointing + * at its own live tuple; that fresh entry crosses no later + * key-changing hop and uniquely supplies the row. Dropping the stale + * ancestor therefore never removes the row's last reachable entry. + * This holds even under ABA key cycling (X -> Y -> X): the X-restoring + * update changed this index's column (Y -> X) and so planted the fresh + * entry. */ if (!scan->xactStartedInRecovery) - scan->kill_prior_tuple = all_dead; + scan->kill_prior_tuple = + all_dead || + (scan->xs_hot_indexed_stale && scan->xs_heapfetch->xs_prefix_all_dead); return found; } diff --git a/src/backend/access/nbtree/nbtinsert.c b/src/backend/access/nbtree/nbtinsert.c index c8af97dd23dfb..f0ffe8c6d2e8b 100644 --- a/src/backend/access/nbtree/nbtinsert.c +++ b/src/backend/access/nbtree/nbtinsert.c @@ -15,6 +15,8 @@ #include "postgres.h" +#include "access/genam.h" +#include "access/htup_details.h" #include "access/nbtree.h" #include "access/nbtxlog.h" #include "access/tableam.h" @@ -22,6 +24,7 @@ #include "access/xloginsert.h" #include "common/int.h" #include "common/pg_prng.h" +#include "executor/tuptable.h" #include "lib/qunique.h" #include "miscadmin.h" #include "storage/lmgr.h" @@ -34,6 +37,11 @@ static BTStack _bt_search_insert(Relation rel, Relation heaprel, BTInsertState insertstate); + +/* Internal helper: HOT-indexed leaf-key staleness check for _bt_check_unique. */ +static bool _bt_heap_keys_equal_leaf(Relation rel, IndexTuple leaftup, + TupleTableSlot *heapSlot); + static TransactionId _bt_check_unique(Relation rel, BTInsertState insertstate, Relation heapRel, IndexUniqueCheck checkUnique, bool *is_unique, @@ -426,6 +434,8 @@ _bt_check_unique(Relation rel, BTInsertState insertstate, Relation heapRel, bool inposting = false; bool prevalldead = true; int curposti = 0; + TupleTableSlot *chain_walk_slot = NULL; + bool hi_recheck = false; /* Assume unique until we find a duplicate */ *is_unique = true; @@ -509,6 +519,7 @@ _bt_check_unique(Relation rel, BTInsertState insertstate, Relation heapRel, { ItemPointerData htid; bool all_dead = false; + bool hot_indexed_stale = false; if (!inposting) { @@ -559,13 +570,82 @@ _bt_check_unique(Relation rel, BTInsertState insertstate, Relation heapRel, * satisfying SnapshotDirty. This is necessary because for AMs * with optimizations like heap's HOT, we have just a single * index entry for the entire chain. + * + * The fetch reports (hi_recheck) whether the chain walk to + * the live tuple crossed a HOT-selectively-updated (HOT/SIU) + * hop. In classic HOT the chain preserves the index key, so a + * live tuple anywhere in the chain is a definite conflict; + * with HOT/SIU that invariant no longer holds -- an old index + * entry for key K may chain-lead to a heap tuple whose actual + * index key is K'. When a hop was crossed we recheck the + * leaf key against the live tuple below; a stale entry is + * filtered out, not treated as a conflict. chain_walk_slot + * holds the live tuple for that recheck and is freed at every + * exit. */ - else if (table_index_fetch_tuple_check(heapRel, &htid, + else if ((chain_walk_slot != NULL || + (chain_walk_slot = table_slot_create(heapRel, NULL))) && + table_index_fetch_tuple_check(heapRel, &htid, &SnapshotDirty, - &all_dead)) + &all_dead, + &hi_recheck, + chain_walk_slot)) { TransactionId xwait; + /* + * The chain walk reported (hi_recheck) that it crossed at + * least one HOT/SIU hop on the way to the live tuple, so + * the classic "live tuple in the chain implies the same + * index key" invariant may not hold: an old index entry + * for key K may chain-lead to a tuple whose current key + * is K'. Recheck the leaf's stored key against the live + * tuple's current index form. A mismatch means the leaf + * is stale (not a conflict): skip it; the fresh entry + * inserted for the current value is the canonical one. + * Because the leaf still resolves to a live tuple, clear + * prevalldead so the caller never marks it LP_DEAD + * (killable). + */ + hot_indexed_stale = + (hi_recheck && + !_bt_heap_keys_equal_leaf(rel, curitup, chain_walk_slot)); + + if (hot_indexed_stale) + { + prevalldead = false; + /* + * Do NOT release nbuf here: page/opaque/curitup may + * point into it (a right-sibling page reached while + * scanning equal tuples), and the loop continues to + * dereference them after this jump. nbuf is released + * when the scan finishes (or advances to another page). + */ + ExecClearTuple(chain_walk_slot); + goto bt_chain_walk_skip; + } + + /* + * The leaf's key still matches the live tuple. If the + * chain walk crossed a HOT-indexed hop and resolved to + * the very tuple the caller is inserting an entry for, + * this is not a duplicate -- it is the same logical row + * being re-indexed (e.g. a HOT-indexed UPDATE that left + * this index's key unchanged, or a key cycled away and + * back). Skip it rather than raising a spurious unique + * violation. + */ + if (hi_recheck && + ItemPointerCompare(&htid, &itup->t_tid) == 0) + { + prevalldead = false; + /* keep nbuf pinned; see the hot_indexed_stale path */ + ExecClearTuple(chain_walk_slot); + goto bt_chain_walk_skip; + } + if (chain_walk_slot != NULL) + ExecClearTuple(chain_walk_slot); + /* * It is a duplicate. If we are only doing a partial * check, then don't bother checking if the tuple is being @@ -578,6 +658,8 @@ _bt_check_unique(Relation rel, BTInsertState insertstate, Relation heapRel, { if (nbuf != InvalidBuffer) _bt_relbuf(rel, nbuf); + if (chain_walk_slot) + ExecDropSingleTupleTableSlot(chain_walk_slot); *is_unique = false; return InvalidTransactionId; } @@ -593,6 +675,8 @@ _bt_check_unique(Relation rel, BTInsertState insertstate, Relation heapRel, { if (nbuf != InvalidBuffer) _bt_relbuf(rel, nbuf); + if (chain_walk_slot) + ExecDropSingleTupleTableSlot(chain_walk_slot); /* Tell _bt_doinsert to wait... */ *speculativeToken = SnapshotDirty.speculativeToken; /* Caller releases lock on buf immediately */ @@ -619,7 +703,8 @@ _bt_check_unique(Relation rel, BTInsertState insertstate, Relation heapRel, */ htid = itup->t_tid; if (table_index_fetch_tuple_check(heapRel, &htid, - SnapshotSelf, NULL)) + SnapshotSelf, NULL, + NULL, NULL)) { /* Normal case --- it's still live */ } @@ -654,6 +739,8 @@ _bt_check_unique(Relation rel, BTInsertState insertstate, Relation heapRel, _bt_relbuf(rel, insertstate->buf); insertstate->buf = InvalidBuffer; insertstate->bounds_valid = false; + if (chain_walk_slot) + ExecDropSingleTupleTableSlot(chain_walk_slot); { Datum values[INDEX_MAX_KEYS]; @@ -715,6 +802,9 @@ _bt_check_unique(Relation rel, BTInsertState insertstate, Relation heapRel, */ if (!all_dead && inposting) prevalldead = false; + + bt_chain_walk_skip: + ; } } @@ -782,9 +872,84 @@ _bt_check_unique(Relation rel, BTInsertState insertstate, Relation heapRel, if (nbuf != InvalidBuffer) _bt_relbuf(rel, nbuf); + if (chain_walk_slot) + ExecDropSingleTupleTableSlot(chain_walk_slot); + return InvalidTransactionId; } +/* + * _bt_heap_keys_equal_leaf() -- Compare a heap tuple's current btree key + * against the key stored in a leaf IndexTuple. + * + * The HOT-indexed unique-check path uses this to distinguish a live tuple + * whose current key still matches the arriving leaf (a genuine conflict) + * from a stale chain hit: with a HOT-indexed (Selective Index Update) chain + * the leaf entry for an old key still resolves to the live tuple, whose + * current index form may differ. + * + * Equality must agree with the index's own notion of equality, because the + * caller uses the verdict to decide whether to raise a unique violation. + * We compare each key column with its btree ordering procedure (BTORDER_PROC, + * the same support function _bt_mkscankey uses) under the column's collation + * -- not a bitwise image comparison. Bitwise equality would wrongly treat + * opclass-equal but image-distinct values (numeric 1.0 vs 1.00, float -0.0 + * vs 0.0, text under a nondeterministic collation) as "not equal" and skip a + * genuine duplicate. + * + * This is called from _bt_check_unique while the leaf buffer is locked, so it + * deliberately avoids executor machinery: it fetches each key attribute + * straight from the slot. It is only ever reached for an index receiving a + * fresh entry during a HOT-indexed update, and HeapUpdateHotAllowable + * disqualifies any UPDATE that touches an expression-index attribute, so the + * index here has no expression key column (every indkey is a real attribute + * number). We assert that rather than handle a keycol == 0 case that cannot + * occur; if expression-index selective maintenance is implemented in the + * future, this is where an expression-evaluating comparison would be added. + * + * heapSlot must already be populated by the caller (via + * table_index_fetch_tuple / table_index_fetch_tuple_check). + */ +static bool +_bt_heap_keys_equal_leaf(Relation rel, IndexTuple leaftup, + TupleTableSlot *heapSlot) +{ + TupleDesc indexDesc = RelationGetDescr(rel); + int nkey = IndexRelationGetNumberOfKeyAttributes(rel); + Form_pg_index indexStruct = rel->rd_index; + + Assert(leaftup != NULL); + Assert(heapSlot != NULL && !TTS_EMPTY(heapSlot)); + + for (int i = 0; i < nkey; i++) + { + AttrNumber keycol = indexStruct->indkey.values[i]; + Datum heap_datum; + bool heap_isnull; + Datum leaf_datum; + bool leaf_isnull; + FmgrInfo *cmpproc; + + /* Expression key columns cannot reach here (see header). */ + Assert(keycol != 0); + + heap_datum = slot_getattr(heapSlot, keycol, &heap_isnull); + leaf_datum = index_getattr(leaftup, i + 1, indexDesc, &leaf_isnull); + + if (heap_isnull != leaf_isnull) + return false; + if (heap_isnull) + continue; + + /* opclass 3-way compare under the column's collation; 0 == equal */ + cmpproc = index_getprocinfo(rel, i + 1, BTORDER_PROC); + if (DatumGetInt32(FunctionCall2Coll(cmpproc, rel->rd_indcollation[i], + heap_datum, leaf_datum)) != 0) + return false; + } + + return true; +} /* * _bt_findinsertloc() -- Finds an insert location for a tuple diff --git a/src/backend/access/nbtree/nbtree.c b/src/backend/access/nbtree/nbtree.c index 3df2c752eadef..5b269eaaa937a 100644 --- a/src/backend/access/nbtree/nbtree.c +++ b/src/backend/access/nbtree/nbtree.c @@ -408,6 +408,16 @@ btrescan(IndexScanDesc scan, ScanKey scankey, int nscankeys, * race condition involving VACUUM setting pages all-visible in the VM. * It's also unsafe for plain index scans that use a non-MVCC snapshot. * + * Note that wanting the index tuple (xs_want_itup) is not by itself a + * reason to retain the pin: btree copies each returned IndexTuple into + * so->currTuples (scan-local memory) and points xs_itup there, so the + * tuple stays valid after the pin is dropped. Only genuine index-only + * scans (xs_index_only), which may return a tuple without fetching the + * heap and therefore rely on the VM, must keep the pin. A plain index + * scan that sets xs_want_itup merely to inspect or recheck the index + * tuple still fetches and visibility-checks the heap, so it has no VM + * race and may drop pins like any other plain scan. + * * Also opt out of dropping leaf page pins eagerly during bitmap scans. * Pins cannot be held for more than an instant during bitmap scans either * way, so we might as well avoid wasting cycles on acquiring page LSNs. @@ -416,7 +426,7 @@ btrescan(IndexScanDesc scan, ScanKey scankey, int nscankeys, * * Note: so->dropPin should never change across rescans. */ - so->dropPin = (!scan->xs_want_itup && + so->dropPin = (!scan->xs_index_only && IsMVCCLikeSnapshot(scan->xs_snapshot) && scan->heapRelation != NULL); diff --git a/src/backend/access/rmgrdesc/heapdesc.c b/src/backend/access/rmgrdesc/heapdesc.c index 75ae6f9d375cd..97f925df16136 100644 --- a/src/backend/access/rmgrdesc/heapdesc.c +++ b/src/backend/access/rmgrdesc/heapdesc.c @@ -108,7 +108,8 @@ heap_xlog_deserialize_prune_and_freeze(char *cursor, uint16 flags, OffsetNumber **frz_offsets, int *nredirected, OffsetNumber **redirected, int *ndead, OffsetNumber **nowdead, - int *nunused, OffsetNumber **nowunused) + int *nunused, OffsetNumber **nowunused, + int *nstubs, OffsetNumber **stubs) { if (flags & XLHP_HAS_FREEZE_PLANS) { @@ -178,6 +179,23 @@ heap_xlog_deserialize_prune_and_freeze(char *cursor, uint16 flags, *nowunused = NULL; } + if (flags & XLHP_HAS_HOT_INDEXED_STUBS) + { + xlhp_prune_items *subrecord = (xlhp_prune_items *) cursor; + + *nstubs = subrecord->ntargets; + Assert(*nstubs > 0); + *stubs = &subrecord->data[0]; + + cursor += offsetof(xlhp_prune_items, data); + cursor += sizeof(OffsetNumber[2]) * *nstubs; + } + else + { + *nstubs = 0; + *stubs = NULL; + } + *frz_offsets = (OffsetNumber *) cursor; } @@ -305,6 +323,8 @@ heap2_desc(StringInfo buf, XLogReaderState *record) int nredirected; int nunused; int ndead; + int nstubs; + OffsetNumber *stubs; int nplans; xlhp_freeze_plan *plans; OffsetNumber *frz_offsets; @@ -315,10 +335,11 @@ heap2_desc(StringInfo buf, XLogReaderState *record) &nplans, &plans, &frz_offsets, &nredirected, &redirected, &ndead, &nowdead, - &nunused, &nowunused); + &nunused, &nowunused, + &nstubs, &stubs); - appendStringInfo(buf, ", nplans: %u, nredirected: %u, ndead: %u, nunused: %u", - nplans, nredirected, ndead, nunused); + appendStringInfo(buf, ", nplans: %u, nredirected: %u, ndead: %u, nunused: %u, nstubs: %u", + nplans, nredirected, ndead, nunused, nstubs); if (nplans > 0) { @@ -347,6 +368,13 @@ heap2_desc(StringInfo buf, XLogReaderState *record) array_desc(buf, nowunused, sizeof(OffsetNumber), nunused, &offset_elem_desc, NULL); } + + if (nstubs > 0) + { + appendStringInfoString(buf, ", stubs:"); + array_desc(buf, stubs, sizeof(OffsetNumber) * 2, + nstubs, &redirect_elem_desc, NULL); + } } } else if (info == XLOG_HEAP2_MULTI_INSERT) diff --git a/src/backend/access/table/tableam.c b/src/backend/access/table/tableam.c index 68ff0966f1c57..f05cabc8c6584 100644 --- a/src/backend/access/table/tableam.c +++ b/src/backend/access/table/tableam.c @@ -242,19 +242,31 @@ bool table_index_fetch_tuple_check(Relation rel, ItemPointer tid, Snapshot snapshot, - bool *all_dead) + bool *all_dead, + bool *hot_indexed_recheck_out, + TupleTableSlot *keep_slot) { IndexFetchTableData *scan; TupleTableSlot *slot; bool call_again = false; bool found; - slot = table_slot_create(rel, NULL); + slot = keep_slot ? keep_slot : table_slot_create(rel, NULL); scan = table_index_fetch_begin(rel, SO_NONE); found = table_index_fetch_tuple(scan, tid, snapshot, slot, &call_again, all_dead); + + /* + * Surface the table AM's HOT/SIU recheck signal to the caller (the index + * AM, which rechecks the arriving leaf key against the live tuple); the + * scan is freed below, so copy it out. + */ + if (hot_indexed_recheck_out != NULL) + *hot_indexed_recheck_out = found && scan->xs_hot_indexed_recheck; + table_index_fetch_end(scan); - ExecDropSingleTupleTableSlot(slot); + if (keep_slot == NULL) + ExecDropSingleTupleTableSlot(slot); return found; } @@ -361,7 +373,7 @@ void simple_table_tuple_update(Relation rel, ItemPointer otid, TupleTableSlot *slot, Snapshot snapshot, - TU_UpdateIndexes *update_indexes) + Bitmapset **modified_attrs) { TM_Result result; TM_FailureData tmfd; @@ -371,7 +383,8 @@ simple_table_tuple_update(Relation rel, ItemPointer otid, GetCurrentCommandId(true), 0, snapshot, InvalidSnapshot, true /* wait for commit */ , - &tmfd, &lockmode, update_indexes); + &tmfd, &lockmode, + modified_attrs); switch (result) { diff --git a/src/backend/catalog/indexing.c b/src/backend/catalog/indexing.c index fd7d2ec0e3aba..efd8a19c224c7 100644 --- a/src/backend/catalog/indexing.c +++ b/src/backend/catalog/indexing.c @@ -18,11 +18,14 @@ #include "access/genam.h" #include "access/heapam.h" #include "access/htup_details.h" +#include "access/tableam.h" #include "access/xact.h" #include "catalog/index.h" #include "catalog/indexing.h" #include "executor/executor.h" +#include "nodes/bitmapset.h" #include "utils/rel.h" +#include "utils/relcache.h" /* @@ -69,11 +72,17 @@ CatalogCloseIndexes(CatalogIndexState indstate) * * This should be called for each inserted or updated catalog tuple. * - * This is effectively a cut-down version of ExecInsertIndexTuples. + * This is effectively a cut-down version of ExecInsertIndexTuples. For + * UPDATE paths the caller supplies update_all_indexes (from + * table_tuple_update / simple_heap_update) so we can tell which indexes + * actually need a new entry: update_all_indexes is true for a fresh insert or + * a non-HOT update (every index gets an entry), false for a classic-HOT + * catalog update (non-summarizing indexes are skipped, since their existing + * entries still resolve the chain). */ static void CatalogIndexInsert(CatalogIndexState indstate, HeapTuple heapTuple, - TU_UpdateIndexes updateIndexes) + bool update_all_indexes) { int i; int numIndexes; @@ -83,20 +92,6 @@ CatalogIndexInsert(CatalogIndexState indstate, HeapTuple heapTuple, IndexInfo **indexInfoArray; Datum values[INDEX_MAX_KEYS]; bool isnull[INDEX_MAX_KEYS]; - bool onlySummarized = (updateIndexes == TU_Summarizing); - - /* - * HOT update does not require index inserts. But with asserts enabled we - * want to check that it'd be legal to currently insert into the - * table/index. - */ -#ifndef USE_ASSERT_CHECKING - if (HeapTupleIsHeapOnly(heapTuple) && !onlySummarized) - return; -#endif - - /* When only updating summarized indexes, the tuple has to be HOT. */ - Assert((!onlySummarized) || HeapTupleIsHeapOnly(heapTuple)); /* * Get information from the state structure. Fall out if nothing to do. @@ -120,6 +115,7 @@ CatalogIndexInsert(CatalogIndexState indstate, HeapTuple heapTuple, { IndexInfo *indexInfo; Relation index; + bool index_unchanged; indexInfo = indexInfoArray[i]; index = relationDescs[i]; @@ -138,20 +134,16 @@ CatalogIndexInsert(CatalogIndexState indstate, HeapTuple heapTuple, Assert(index->rd_index->indimmediate); Assert(indexInfo->ii_NumIndexKeyAttrs != 0); - /* see earlier check above */ -#ifdef USE_ASSERT_CHECKING - if (HeapTupleIsHeapOnly(heapTuple) && !onlySummarized) - { - Assert(!ReindexIsProcessingIndex(RelationGetRelid(index))); - continue; - } -#endif /* USE_ASSERT_CHECKING */ - /* - * Skip insertions into non-summarizing indexes if we only need to - * update summarizing indexes. + * Decide whether this index needs a new entry. On INSERT or a + * non-HOT update (update_all_indexes) every index gets one. On a + * classic-HOT catalog update no indexed attribute changed, so the + * non-summarizing indexes are skipped (summarizing indexes always get + * a chance to update their block-level summaries below). */ - if (onlySummarized && !indexInfo->ii_Summarizing) + index_unchanged = !update_all_indexes; + + if (index_unchanged && !indexInfo->ii_Summarizing) continue; /* @@ -240,7 +232,7 @@ CatalogTupleInsert(Relation heapRel, HeapTuple tup) simple_heap_insert(heapRel, tup); - CatalogIndexInsert(indstate, tup, TU_All); + CatalogIndexInsert(indstate, tup, true); CatalogCloseIndexes(indstate); } @@ -260,7 +252,7 @@ CatalogTupleInsertWithInfo(Relation heapRel, HeapTuple tup, simple_heap_insert(heapRel, tup); - CatalogIndexInsert(indstate, tup, TU_All); + CatalogIndexInsert(indstate, tup, true); } /* @@ -291,7 +283,7 @@ CatalogTuplesMultiInsertWithInfo(Relation heapRel, TupleTableSlot **slot, tuple = ExecFetchSlotHeapTuple(slot[i], true, &should_free); tuple->t_tableOid = slot[i]->tts_tableOid; - CatalogIndexInsert(indstate, tuple, TU_All); + CatalogIndexInsert(indstate, tuple, true); if (should_free) heap_freetuple(tuple); @@ -313,15 +305,15 @@ void CatalogTupleUpdate(Relation heapRel, const ItemPointerData *otid, HeapTuple tup) { CatalogIndexState indstate; - TU_UpdateIndexes updateIndexes = TU_All; + bool update_all_indexes; CatalogTupleCheckConstraints(heapRel, tup); indstate = CatalogOpenIndexes(heapRel); - simple_heap_update(heapRel, otid, tup, &updateIndexes); + simple_heap_update(heapRel, otid, tup, &update_all_indexes); - CatalogIndexInsert(indstate, tup, updateIndexes); + CatalogIndexInsert(indstate, tup, update_all_indexes); CatalogCloseIndexes(indstate); } @@ -337,13 +329,13 @@ void CatalogTupleUpdateWithInfo(Relation heapRel, const ItemPointerData *otid, HeapTuple tup, CatalogIndexState indstate) { - TU_UpdateIndexes updateIndexes = TU_All; + bool update_all_indexes; CatalogTupleCheckConstraints(heapRel, tup); - simple_heap_update(heapRel, otid, tup, &updateIndexes); + simple_heap_update(heapRel, otid, tup, &update_all_indexes); - CatalogIndexInsert(indstate, tup, updateIndexes); + CatalogIndexInsert(indstate, tup, update_all_indexes); } /* diff --git a/src/backend/catalog/pg_subscription.c b/src/backend/catalog/pg_subscription.c index 2068e03c571db..b065a7be249b5 100644 --- a/src/backend/catalog/pg_subscription.c +++ b/src/backend/catalog/pg_subscription.c @@ -133,6 +133,7 @@ GetSubscription(Oid subid, bool missing_ok, bool conninfo_needed, sub->maxretention = subform->submaxretention; sub->retentionactive = subform->subretentionactive; sub->conflictlogrelid = subform->subconflictlogrelid; + sub->hotindexedonapply = subform->subhotindexedonapply; if (conninfo_needed) { diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql index 090281a03ddff..91439d7b1dc25 100644 --- a/src/backend/catalog/system_views.sql +++ b/src/backend/catalog/system_views.sql @@ -730,6 +730,7 @@ CREATE VIEW pg_stat_all_tables AS pg_stat_get_tuples_updated(C.oid) AS n_tup_upd, pg_stat_get_tuples_deleted(C.oid) AS n_tup_del, pg_stat_get_tuples_hot_updated(C.oid) AS n_tup_hot_upd, + pg_stat_get_tuples_hot_indexed_updated(C.oid) AS n_tup_hot_indexed_upd, pg_stat_get_tuples_newpage_updated(C.oid) AS n_tup_newpage_upd, pg_stat_get_live_tuples(C.oid) AS n_live_tup, pg_stat_get_dead_tuples(C.oid) AS n_dead_tup, @@ -768,6 +769,7 @@ CREATE VIEW pg_stat_xact_all_tables AS pg_stat_get_xact_tuples_updated(C.oid) AS n_tup_upd, pg_stat_get_xact_tuples_deleted(C.oid) AS n_tup_del, pg_stat_get_xact_tuples_hot_updated(C.oid) AS n_tup_hot_upd, + pg_stat_get_xact_tuples_hot_indexed_updated(C.oid) AS n_tup_hot_indexed_upd, pg_stat_get_xact_tuples_newpage_updated(C.oid) AS n_tup_newpage_upd FROM pg_class C LEFT JOIN pg_index I ON C.oid = I.indrelid @@ -869,6 +871,8 @@ CREATE VIEW pg_stat_all_indexes AS pg_stat_get_lastscan(I.oid) AS last_idx_scan, pg_stat_get_tuples_returned(I.oid) AS idx_tup_read, pg_stat_get_tuples_fetched(I.oid) AS idx_tup_fetch, + pg_stat_get_tuples_hot_indexed_updated_skipped(I.oid) AS n_tup_hot_indexed_upd_skipped, + pg_stat_get_tuples_hot_indexed_updated_matched(I.oid) AS n_tup_hot_indexed_upd_matched, pg_stat_get_stat_reset_time(I.oid) AS stats_reset FROM pg_class C JOIN pg_index X ON C.oid = X.indrelid JOIN @@ -1538,6 +1542,7 @@ GRANT SELECT (oid, subdbid, subskiplsn, subname, subowner, subenabled, subbinary, substream, subtwophasestate, subdisableonerr, subpasswordrequired, subrunasowner, subfailover, subretaindeadtuples, submaxretention, subretentionactive, + subhotindexedonapply, subserver, subconflictlogrelid, subconflictlogdest, subslotname, subsynccommit, subwalrcvtimeout, subpublications, suborigin) ON pg_subscription TO public; diff --git a/src/backend/catalog/toasting.c b/src/backend/catalog/toasting.c index 4aa52a4bd2531..e0bc01f63d3a8 100644 --- a/src/backend/catalog/toasting.c +++ b/src/backend/catalog/toasting.c @@ -307,8 +307,6 @@ create_toast_table(Relation rel, Oid toastOid, Oid toastIndexOid, indexInfo->ii_Unique = true; indexInfo->ii_NullsNotDistinct = false; indexInfo->ii_ReadyForInserts = true; - indexInfo->ii_CheckedUnchanged = false; - indexInfo->ii_IndexUnchanged = false; indexInfo->ii_Concurrent = false; indexInfo->ii_BrokenHotChain = false; indexInfo->ii_ParallelWorkers = 0; diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c index 02883fe34a485..29aae64e1f3a2 100644 --- a/src/backend/commands/repack.c +++ b/src/backend/commands/repack.c @@ -2679,9 +2679,19 @@ apply_concurrent_update(Relation rel, TupleTableSlot *spilled_tuple, { LockTupleMode lockmode; TM_FailureData tmfd; - TU_UpdateIndexes update_indexes; + Bitmapset *modified_idx_attrs; TM_Result res; + /* + * Compute the set of modified indexed attributes by comparing the old + * (ondisk) and new (spilled) tuples. heap_update needs this to make a + * correct HOT decision; without it modified_idx_attrs would be NULL and + * heap_update would always treat the update as HOT-eligible. + */ + modified_idx_attrs = ExecUpdateModifiedIdxAttrs(chgcxt->cc_rri, + ondisk_tuple, + spilled_tuple); + /* * Carry out the update, skipping logical decoding for it. */ @@ -2691,26 +2701,32 @@ apply_concurrent_update(Relation rel, TupleTableSlot *spilled_tuple, InvalidSnapshot, InvalidSnapshot, false, - &tmfd, &lockmode, &update_indexes); + &tmfd, &lockmode, + &modified_idx_attrs); if (res != TM_Ok) ereport(ERROR, errcode(ERRCODE_T_R_SERIALIZATION_FAILURE), errmsg("could not apply concurrent %s on relation \"%s\"", "UPDATE", RelationGetRelationName(rel))); - if (update_indexes != TU_None) + if (chgcxt->cc_rri->ri_NumIndices > 0 && + !bms_is_empty(modified_idx_attrs)) { - uint32 flags = EIIT_IS_UPDATE; + bool all_indexes = + bms_is_member(TableTupleUpdateAllIndexes, modified_idx_attrs); - if (update_indexes == TU_Summarizing) - flags |= EIIT_ONLY_SUMMARIZING; + ExecSetIndexUnchanged(chgcxt->cc_rri, modified_idx_attrs); ExecInsertIndexTuples(chgcxt->cc_rri, chgcxt->cc_estate, - flags, + EIIT_IS_UPDATE | + (all_indexes ? + 0 : EIIT_IS_HOT_INDEXED), spilled_tuple, NIL, NULL); } + bms_free(modified_idx_attrs); + pgstat_progress_incr_param(PROGRESS_REPACK_HEAP_TUPLES_UPDATED, 1); } diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c index 4292e7fb8f464..bf9dba3f4e4d9 100644 --- a/src/backend/commands/subscriptioncmds.c +++ b/src/backend/commands/subscriptioncmds.c @@ -82,6 +82,7 @@ #define SUBOPT_LSN 0x00020000 #define SUBOPT_ORIGIN 0x00040000 #define SUBOPT_CONFLICT_LOG_DEST 0x00080000 +#define SUBOPT_HOT_INDEXED_ON_APPLY 0x00100000 /* check if the 'val' has 'bits' set */ #define IsSet(val, bits) (((val) & (bits)) == (bits)) @@ -113,6 +114,7 @@ typedef struct SubOpts ConflictLogDest conflictlogdest; XLogRecPtr lsn; char *wal_receiver_timeout; + char hotindexedonapply; } SubOpts; /* @@ -207,6 +209,8 @@ parse_subscription_options(ParseState *pstate, List *stmt_options, opts->origin = pstrdup(LOGICALREP_ORIGIN_ANY); if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DEST)) opts->conflictlogdest = CONFLICT_LOG_DEST_LOG; + if (IsSet(supported_opts, SUBOPT_HOT_INDEXED_ON_APPLY)) + opts->hotindexedonapply = LOGICALREP_HOT_INDEXED_SUBSET_ONLY; /* Parse options */ foreach(lc, stmt_options) @@ -459,6 +463,30 @@ parse_subscription_options(ParseState *pstate, List *stmt_options, opts->conflictlogdest = GetConflictLogDest(val); opts->specified_opts |= SUBOPT_CONFLICT_LOG_DEST; } + else if (IsSet(supported_opts, SUBOPT_HOT_INDEXED_ON_APPLY) && + strcmp(defel->defname, "hot_indexed_on_apply") == 0) + { + char *val; + + if (IsSet(opts->specified_opts, SUBOPT_HOT_INDEXED_ON_APPLY)) + errorConflictingDefElem(defel, pstate); + + opts->specified_opts |= SUBOPT_HOT_INDEXED_ON_APPLY; + val = defGetString(defel); + + if (pg_strcasecmp(val, "off") == 0) + opts->hotindexedonapply = LOGICALREP_HOT_INDEXED_OFF; + else if (pg_strcasecmp(val, "subset_only") == 0) + opts->hotindexedonapply = LOGICALREP_HOT_INDEXED_SUBSET_ONLY; + else if (pg_strcasecmp(val, "always") == 0) + opts->hotindexedonapply = LOGICALREP_HOT_INDEXED_ALWAYS; + else + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("unrecognized value for subscription parameter \"%s\": \"%s\"", + "hot_indexed_on_apply", val), + errhint("Valid values are \"off\", \"subset_only\", and \"always\"."))); + } else ereport(ERROR, (errcode(ERRCODE_SYNTAX_ERROR), @@ -699,7 +727,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt, SUBOPT_RETAIN_DEAD_TUPLES | SUBOPT_MAX_RETENTION_DURATION | SUBOPT_WAL_RECEIVER_TIMEOUT | SUBOPT_ORIGIN | - SUBOPT_CONFLICT_LOG_DEST); + SUBOPT_CONFLICT_LOG_DEST | + SUBOPT_HOT_INDEXED_ON_APPLY); parse_subscription_options(pstate, stmt->options, supported_opts, &opts); /* @@ -853,6 +882,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt, Int32GetDatum(opts.maxretention); values[Anum_pg_subscription_subretentionactive - 1] = BoolGetDatum(opts.retaindeadtuples); + values[Anum_pg_subscription_subhotindexedonapply - 1] = + CharGetDatum(opts.hotindexedonapply); values[Anum_pg_subscription_subserver - 1] = ObjectIdGetDatum(serverid); if (!OidIsValid(serverid)) values[Anum_pg_subscription_subconninfo - 1] = @@ -1624,7 +1655,8 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt, SUBOPT_MAX_RETENTION_DURATION | SUBOPT_WAL_RECEIVER_TIMEOUT | SUBOPT_ORIGIN | - SUBOPT_CONFLICT_LOG_DEST); + SUBOPT_CONFLICT_LOG_DEST | + SUBOPT_HOT_INDEXED_ON_APPLY); break; case ALTER_SUBSCRIPTION_ENABLED: @@ -2011,6 +2043,13 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt, } } + if (IsSet(opts.specified_opts, SUBOPT_HOT_INDEXED_ON_APPLY)) + { + values[Anum_pg_subscription_subhotindexedonapply - 1] = + CharGetDatum(opts.hotindexedonapply); + replaces[Anum_pg_subscription_subhotindexedonapply - 1] = true; + } + update_tuple = true; break; } diff --git a/src/backend/executor/execIndexing.c b/src/backend/executor/execIndexing.c index eb383812901aa..467b4903ad214 100644 --- a/src/backend/executor/execIndexing.c +++ b/src/backend/executor/execIndexing.c @@ -113,11 +113,13 @@ #include "catalog/index.h" #include "executor/executor.h" #include "nodes/nodeFuncs.h" +#include "pgstat.h" #include "storage/lmgr.h" #include "utils/injection_point.h" #include "utils/lsyscache.h" #include "utils/multirangetypes.h" #include "utils/rangetypes.h" +#include "utils/rel.h" #include "utils/snapmgr.h" /* waitMode argument to check_exclusion_or_unique_constraint() */ @@ -140,11 +142,6 @@ static bool check_exclusion_or_unique_constraint(Relation heap, Relation index, static bool index_recheck_constraint(Relation index, const Oid *constr_procs, const Datum *existing_values, const bool *existing_isnull, const Datum *new_values); -static bool index_unchanged_by_update(ResultRelInfo *resultRelInfo, - EState *estate, IndexInfo *indexInfo, - Relation indexRelation); -static bool index_expression_changed_walker(Node *node, - Bitmapset *allUpdatedCols); static void ExecWithoutOverlapsNotEmpty(Relation rel, NameData attname, Datum attval, char typtype, Oid atttypid); @@ -277,24 +274,12 @@ ExecCloseIndices(ResultRelInfo *resultRelInfo) * into all the relations indexing the result relation * when a heap tuple is inserted into the result relation. * - * When EIIT_IS_UPDATE is set and EIIT_ONLY_SUMMARIZING isn't, - * executor is performing an UPDATE that could not use an - * optimization like heapam's HOT (in more general terms a - * call to table_tuple_update() took place and set - * 'update_indexes' to TU_All). Receiving this hint makes - * us consider if we should pass down the 'indexUnchanged' - * hint in turn. That's something that we figure out for - * each index_insert() call iff EIIT_IS_UPDATE is set. - * (When that flag is not set we already know not to pass the - * hint to any index.) - * - * If EIIT_ONLY_SUMMARIZING is set, an equivalent optimization to - * HOT has been applied and any updated columns are indexed - * only by summarizing indexes (or in more general terms a - * call to table_tuple_update() took place and set - * 'update_indexes' to TU_Summarizing). We can (and must) - * therefore only update the indexes that have - * 'amsummarizing' = true. + * When EIIT_IS_UPDATE is set, the executor is performing an + * UPDATE. The per-index ii_IndexUnchanged flag (populated by + * ExecSetIndexUnchanged()) indicates whether each index's key + * values are unchanged by this update. When ii_IndexUnchanged + * is true, we pass indexUnchanged=true to index_insert() as a + * hint for bottom-up deletion optimization. * * Unique and exclusion constraints are enforced at the same * time. This returns a list of index OIDs for any unique or @@ -370,11 +355,32 @@ ExecInsertIndexTuples(ResultRelInfo *resultRelInfo, continue; /* - * Skip processing of non-summarizing indexes if we only update - * summarizing indexes + * UPDATE skip rule. ExecSetIndexUnchanged populated + * ii_IndexNeedsUpdate for every index: true when the table AM stored + * an independent new version, or when any attribute the index + * references (key, INCLUDE, expression, or partial-predicate column) + * overlaps the modified-attrs bitmap. When it is false on a + * non-summarizing index we skip the insert entirely; the HOT chain + * keeps existing entries pointing at the chain root. Summarizing + * indexes always get a chance to update their block-level summaries. */ - if ((flags & EIIT_ONLY_SUMMARIZING) && !indexInfo->ii_Summarizing) + if ((flags & EIIT_IS_UPDATE) && + !indexInfo->ii_IndexNeedsUpdate && + !indexInfo->ii_Summarizing) + { + /* + * This index was skipped because its key attributes did not + * change. When the overall update is a HOT-indexed update (some + * other non-summarizing index did change), record the skip on + * this index's pgstat entry. A classic-HOT update (no indexed + * attribute changed) does not reach this path -- + * ExecInsertIndexTuples is only invoked when at least one index + * needs a fresh entry. + */ + if (flags & EIIT_IS_HOT_INDEXED) + pgstat_count_hot_indexed_upd_skipped(indexRelation); continue; + } /* Check for partial index */ if (indexInfo->ii_Predicate != NIL) @@ -397,6 +403,17 @@ ExecInsertIndexTuples(ResultRelInfo *resultRelInfo, continue; } + /* + * Non-skipped index under a HOT-indexed update: this index is + * receiving a fresh entry because one of its key attributes changed. + * Summarizing indexes always insert regardless of the HOT-indexed + * decision (same as classic HOT), so they are not counted here. Count + * only now that the partial-index predicate (if any) has also passed, + * so a predicate-excluded partial index is not counted as matched. + */ + if ((flags & EIIT_IS_HOT_INDEXED) && !indexInfo->ii_Summarizing) + pgstat_count_hot_indexed_upd_matched(indexRelation); + /* * FormIndexDatum fills in its values and isnull parameters with the * appropriate values for the column(s) of the index. @@ -436,25 +453,57 @@ ExecInsertIndexTuples(ResultRelInfo *resultRelInfo, checkUnique = UNIQUE_CHECK_PARTIAL; /* - * There's definitely going to be an index_insert() call for this - * index. If we're being called as part of an UPDATE statement, - * consider if the 'indexUnchanged' = true hint should be passed. + * For UPDATE operations, use the per-index ii_IndexUnchanged flag + * (populated by ExecSetIndexUnchanged) to hint whether the index + * values are unchanged. This helps the index AM optimize for + * bottom-up deletion of duplicate index entries. */ - indexUnchanged = ((flags & EIIT_IS_UPDATE) && - index_unchanged_by_update(resultRelInfo, - estate, - indexInfo, - indexRelation)); + indexUnchanged = (flags & EIIT_IS_UPDATE) ? + indexInfo->ii_IndexUnchanged : false; - satisfiesConstraint = - index_insert(indexRelation, /* index relation */ - values, /* array of index Datums */ - isnull, /* null flags */ - tupleid, /* tid of heap tuple */ - heapRelation, /* heap relation */ - checkUnique, /* type of uniqueness check to do */ - indexUnchanged, /* UPDATE without logical change? */ - indexInfo); /* index AM may need this */ + /* + * A fresh entry planted here under a HOT-indexed update points at the + * new heap-only tuple itself (tupleid), not at the chain's root the + * way every other index entry does -- that positional distinction is + * what lets the read side judge staleness from the crossed-attribute + * bitmap without a value recheck (see hot_indexed.h). A bitmap scan + * combines two indexes' TID sets at raw block+offset granularity + * before either side touches the heap, so an unrelated, unchanged + * index's root-pointing entry for this same row will not agree with + * this entry's offset, and BitmapAnd/BitmapOr can silently drop a + * matching row. Flag the copy of tupleid handed to this index's + * insert (never the slot's own tts_tid, which other indexes in this + * same loop -- and the caller -- still need unflagged) so every + * amgetbitmap implementation can recognize the hazard from the TID + * alone and fall back to a page-level bitmap contribution instead of + * an exact one; see ItemPointerSIUMaybeStaleFlag in itemptr.h. + */ + if ((flags & EIIT_IS_HOT_INDEXED) && !indexInfo->ii_Summarizing) + { + ItemPointerData siu_tid = *tupleid; + + ItemPointerSetSIUMaybeStale(&siu_tid); + + satisfiesConstraint = + index_insert(indexRelation, /* index relation */ + values, /* array of index Datums */ + isnull, /* null flags */ + &siu_tid, /* tid of heap tuple, SIU-flagged */ + heapRelation, /* heap relation */ + checkUnique, /* type of uniqueness check to do */ + indexUnchanged, /* UPDATE without logical change? */ + indexInfo); /* index AM may need this */ + } + else + satisfiesConstraint = + index_insert(indexRelation, /* index relation */ + values, /* array of index Datums */ + isnull, /* null flags */ + tupleid, /* tid of heap tuple */ + heapRelation, /* heap relation */ + checkUnique, /* type of uniqueness check to do */ + indexUnchanged, /* UPDATE without logical change? */ + indexInfo); /* index AM may need this */ /* * If the index has an associated exclusion constraint, check that. @@ -721,6 +770,7 @@ check_exclusion_or_unique_constraint(Relation heap, Relation index, int i; bool conflict; bool found_self; + bool found_self_siu_hit; ExprContext *econtext; TupleTableSlot *existing_slot; TupleTableSlot *save_scantuple; @@ -823,6 +873,7 @@ check_exclusion_or_unique_constraint(Relation heap, Relation index, retry: conflict = false; found_self = false; + found_self_siu_hit = false; index_scan = index_beginscan(heap, index, &DirtySnapshot, NULL, indnkeyatts, 0, SO_NONE); @@ -838,14 +889,42 @@ check_exclusion_or_unique_constraint(Relation heap, Relation index, char *error_existing; /* - * Ignore the entry for the tuple we're trying to check. + * Ignore the entry for the tuple we're trying to check. With HOT- + * indexed (hot-indexed) updates, several index entries may chain-lead + * to the same heap tuple (a stale entry for the old key and a fresh + * entry for the new key). They all resolve to the same TID here and + * must all be treated as "self", not as a duplicate error. We + * tolerate the duplicate self arrival whenever *either* this + * iteration or an earlier one saw xs_hot_indexed_stale -- the + * canonical direct entry and the stale chain-walk entries can arrive + * in either order. */ if (ItemPointerIsValid(tupleid) && ItemPointerEquals(tupleid, &existing_slot->tts_tid)) { - if (found_self) /* should not happen */ + if (found_self) + { + /* + * A repeat self-arrival is legitimate only in the HOT-indexed + * case: the canonical direct entry plus one or more stale + * chain-walk entries all resolve to this TID, and they may + * arrive in either order. Tolerate the repeat when either the + * current arrival is stale, or an earlier arrival for this TID + * was (covering the direct-entry-after-stale-entry order). A + * repeat that is non-stale with no stale arrival seen is a + * genuine duplicate-TID corruption; keep raising on it. + */ + if (index_scan->xs_hot_indexed_stale || found_self_siu_hit) + { + if (index_scan->xs_hot_indexed_stale) + found_self_siu_hit = true; + continue; + } elog(ERROR, "found self tuple multiple times in index \"%s\"", RelationGetRelationName(index)); + } + if (index_scan->xs_hot_indexed_stale) + found_self_siu_hit = true; found_self = true; continue; } @@ -869,6 +948,31 @@ check_exclusion_or_unique_constraint(Relation heap, Relation index, * conflict */ } + /* + * HOT-indexed chains can reach this loop via a stale btree leaf entry + * whose key is different from the heap tuple's current index-form. + * existing_values holds the current heap tuple's index-form + * (FormIndexDatum above). Compare it against our new tuple's values + * using the same constraint operators; if they don't agree, the + * chain-walked tuple is not actually in conflict with our insertion + * -- it just shared a TID with a stale leaf entry we happened to scan + * through. Skip it. + * + * This mirrors _bt_check_unique's HOT-indexed recheck path; for + * exclusion constraints the user-supplied operator in constr_procs + * replaces the btree equality comparator, and + * index_recheck_constraint does the right thing for either. + */ + if (index_scan->xs_hot_indexed_stale) + { + if (!index_recheck_constraint(index, + constr_procs, + existing_values, + existing_isnull, + values)) + continue; /* stale chain hit, not a real conflict */ + } + /* * At this point we have either a conflict or a potential conflict. * @@ -1009,149 +1113,94 @@ index_recheck_constraint(Relation index, const Oid *constr_procs, } /* - * Check if ExecInsertIndexTuples() should pass indexUnchanged hint. + * ExecSetIndexUnchanged + * + * Populate two per-index flags ahead of ExecInsertIndexTuples: * - * When the executor performs an UPDATE that requires a new round of index - * tuples, determine if we should pass 'indexUnchanged' = true hint for one - * single index. + * - ii_IndexNeedsUpdate (wide) drives the skip decision. It is true when + * the table AM stored an independent new version (whole-row attribute + * present in modified_idx_attrs) or when any attribute the index + * references -- key, INCLUDE, expression, or partial-predicate column, + * per RelationGetIndexedAttrs() -- changed. A non-summarizing index for + * which this is false is skipped: its existing entry keeps resolving the + * HOT chain. + * + * - ii_IndexUnchanged (narrow) is the indexUnchanged hint to aminsert, + * consumed by nbtree deduplication / bottom-up deletion. Per the + * historical rule it counts only key columns; INCLUDE and predicate + * columns are deliberately ignored, and an expression key is treated + * conservatively as possibly changed. */ -static bool -index_unchanged_by_update(ResultRelInfo *resultRelInfo, EState *estate, - IndexInfo *indexInfo, Relation indexRelation) +void +ExecSetIndexUnchanged(ResultRelInfo *resultRelInfo, + const Bitmapset *modified_idx_attrs) { - Bitmapset *updatedCols; - Bitmapset *extraUpdatedCols; - Bitmapset *allUpdatedCols; - bool hasexpression = false; - List *idxExprs; - - /* - * Check cache first - */ - if (indexInfo->ii_CheckedUnchanged) - return indexInfo->ii_IndexUnchanged; - indexInfo->ii_CheckedUnchanged = true; + int numIndices = resultRelInfo->ri_NumIndices; + IndexInfo **indexInfoArray = resultRelInfo->ri_IndexRelationInfo; + RelationPtr indexDescs = resultRelInfo->ri_IndexRelationDescs; + bool all_indexes; - /* - * Check for indexed attribute overlap with updated columns. - * - * Only do this for key columns. A change to a non-key column within an - * INCLUDE index should not be counted here. Non-key column values are - * opaque payload state to the index AM, a little like an extra table TID. - * - * Note that row-level BEFORE triggers won't affect our behavior, since - * they don't affect the updatedCols bitmaps generally. It doesn't seem - * worth the trouble of checking which attributes were changed directly. - */ - updatedCols = ExecGetUpdatedCols(resultRelInfo, estate); - extraUpdatedCols = ExecGetExtraUpdatedCols(resultRelInfo, estate); - for (int attr = 0; attr < indexInfo->ii_NumIndexKeyAttrs; attr++) - { - int keycol = indexInfo->ii_IndexAttrNumbers[attr]; - - if (keycol <= 0) - { - /* - * Skip expressions for now, but remember to deal with them later - * on - */ - hasexpression = true; - continue; - } - - if (bms_is_member(keycol - FirstLowInvalidHeapAttributeNumber, - updatedCols) || - bms_is_member(keycol - FirstLowInvalidHeapAttributeNumber, - extraUpdatedCols)) - { - /* Changed key column -- don't hint for this index */ - indexInfo->ii_IndexUnchanged = false; - return false; - } - } - - /* - * When we get this far and index has no expressions, return true so that - * index_insert() call will go on to pass 'indexUnchanged' = true hint. - * - * The _absence_ of an indexed key attribute that overlaps with updated - * attributes (in addition to the total absence of indexed expressions) - * shows that the index as a whole is logically unchanged by UPDATE. - */ - if (!hasexpression) - { - indexInfo->ii_IndexUnchanged = true; - return true; - } + if (numIndices == 0) + return; /* - * Need to pass only one bms to expression_tree_walker helper function. - * Avoid allocating memory in common case where there are no extra cols. + * A whole-row entry in modified_idx_attrs means the table AM stored an + * independent new version (e.g. at a new TID), so every index needs a + * fresh entry regardless of which attributes changed. */ - if (!extraUpdatedCols) - allUpdatedCols = updatedCols; - else - allUpdatedCols = bms_union(updatedCols, extraUpdatedCols); + all_indexes = bms_is_member(TableTupleUpdateAllIndexes, modified_idx_attrs); - /* - * We have to work slightly harder in the event of indexed expressions, - * but the principle is the same as before: try to find columns (Vars, - * actually) that overlap with known-updated columns. - * - * If we find any matching Vars, don't pass hint for index. Otherwise - * pass hint. - */ - idxExprs = RelationGetIndexExpressions(indexRelation); - hasexpression = index_expression_changed_walker((Node *) idxExprs, - allUpdatedCols); - list_free(idxExprs); - if (extraUpdatedCols) - bms_free(allUpdatedCols); - - if (hasexpression) + for (int i = 0; i < numIndices; i++) { - indexInfo->ii_IndexUnchanged = false; - return false; - } - - /* - * Deliberately don't consider index predicates. We should even give the - * hint when result rel's "updated tuple" has no corresponding index - * tuple, which is possible with a partial index (provided the usual - * conditions are met). - */ - indexInfo->ii_IndexUnchanged = true; - return true; -} + IndexInfo *indexInfo = indexInfoArray[i]; + Relation indexDesc = indexDescs[i]; + Bitmapset *indexedattrs; + bool keychanged; -/* - * Indexed expression helper for index_unchanged_by_update(). - * - * Returns true when Var that appears within allUpdatedCols located. - */ -static bool -index_expression_changed_walker(Node *node, Bitmapset *allUpdatedCols) -{ - if (node == NULL) - return false; + if (indexDesc == NULL) + continue; - if (IsA(node, Var)) - { - Var *var = (Var *) node; + /* + * Skip decision (wide). The index needs a new entry if the AM stored + * an independent version, or if any attribute it references -- key, + * INCLUDE, expression, or partial-predicate column -- changed. + * RelationGetIndexedAttrs() covers all of those. (An UPDATE that + * touches an expression-index attribute never reaches the HOT-indexed + * path: HeapUpdateHotAllowable disqualifies it, pending + * expression-aware maintenance.) + */ + indexedattrs = RelationGetIndexedAttrs(indexDesc); + indexInfo->ii_IndexNeedsUpdate = + all_indexes || bms_overlap(indexedattrs, modified_idx_attrs); + bms_free(indexedattrs); - if (bms_is_member(var->varattno - FirstLowInvalidHeapAttributeNumber, - allUpdatedCols)) + /* + * aminsert hint (narrow). ii_IndexUnchanged feeds nbtree + * deduplication / bottom-up deletion heuristics and, per the + * historical rule, counts only key columns: a change to an INCLUDE + * column or to a partial-index predicate column does not disqualify + * the hint. An expression key column is treated conservatively as + * possibly changed. + */ + keychanged = false; + for (int k = 0; k < indexInfo->ii_NumIndexKeyAttrs; k++) { - /* Var was updated -- indicates that we should not hint */ - return true; - } + AttrNumber keycol = indexInfo->ii_IndexAttrNumbers[k]; - /* Still haven't found a reason to not pass the hint */ - return false; + if (keycol == 0) /* expression key: assume it may have changed */ + { + keychanged = true; + break; + } + if (bms_is_member(keycol - FirstLowInvalidHeapAttributeNumber, + modified_idx_attrs)) + { + keychanged = true; + break; + } + } + indexInfo->ii_IndexUnchanged = !keychanged; } - - return expression_tree_walker(node, index_expression_changed_walker, - allUpdatedCols); } /* diff --git a/src/backend/executor/execReplication.c b/src/backend/executor/execReplication.c index b2ca5cbf11761..7b5bbdbfd7a59 100644 --- a/src/backend/executor/execReplication.c +++ b/src/backend/executor/execReplication.c @@ -33,6 +33,7 @@ #include "utils/builtins.h" #include "utils/lsyscache.h" #include "utils/rel.h" +#include "utils/relcache.h" #include "utils/snapmgr.h" #include "utils/syscache.h" #include "utils/typcache.h" @@ -216,6 +217,18 @@ RelationFindReplTupleByIndex(Relation rel, Oid idxoid, /* Try to find the tuple */ while (index_getnext_slot(scan, ForwardScanDirection, outslot)) { + /* + * A HOT-indexed update can leave a stale index leaf: an entry whose + * key is a pre-update value but whose TID chain-resolves to a live + * tuple now carrying a different key. Such a tuple is not the + * replica-identity match we are looking for (and the PK/RI fast path + * below skips the equality recheck that would otherwise catch it), so + * drop it -- exactly as IndexScan/IndexOnlyScan do. The fresh leaf + * for the current key, if any, is returned by a later iteration. + */ + if (scan->xs_hot_indexed_stale) + continue; + /* * Avoid expensive equality check if the index is primary key or * replica identity index. @@ -677,6 +690,10 @@ RelationFindDeletedTupleInfoByIndex(Relation rel, Oid idxoid, /* Try to find the tuple */ while (index_getnext_slot(scan, ForwardScanDirection, scanslot)) { + /* Skip stale HOT-indexed leaves (see RelationFindReplTupleByIndex). */ + if (scan->xs_hot_indexed_stale) + continue; + /* * Avoid expensive equality check if the index is primary key or * replica identity index. @@ -910,6 +927,7 @@ ExecSimpleRelationUpdate(ResultRelInfo *resultRelInfo, bool skip_tuple = false; Relation rel = resultRelInfo->ri_RelationDesc; ItemPointer tid = &(searchslot->tts_tid); + Bitmapset *modified_idx_attrs = NULL; /* * We support only non-system tables, with @@ -932,7 +950,6 @@ ExecSimpleRelationUpdate(ResultRelInfo *resultRelInfo, if (!skip_tuple) { List *recheckIndexes = NIL; - TU_UpdateIndexes update_indexes; List *conflictindexes; bool conflict = false; @@ -948,25 +965,37 @@ ExecSimpleRelationUpdate(ResultRelInfo *resultRelInfo, if (rel->rd_rel->relispartition) ExecPartitionCheck(resultRelInfo, slot, estate, true); + modified_idx_attrs = ExecUpdateModifiedIdxAttrs(resultRelInfo, + searchslot, slot); + + Assert(!bms_is_member(TableTupleUpdateAllIndexes, modified_idx_attrs)); simple_table_tuple_update(rel, tid, slot, estate->es_snapshot, - &update_indexes); + &modified_idx_attrs); conflictindexes = resultRelInfo->ri_onConflictArbiterIndexes; - if (resultRelInfo->ri_NumIndices > 0 && (update_indexes != TU_None)) + if (resultRelInfo->ri_NumIndices > 0 && + !bms_is_empty(modified_idx_attrs)) { + bool all_indexes = + bms_is_member(TableTupleUpdateAllIndexes, modified_idx_attrs); uint32 flags = EIIT_IS_UPDATE; if (conflictindexes != NIL) flags |= EIIT_NO_DUPE_ERROR; - if (update_indexes == TU_Summarizing) - flags |= EIIT_ONLY_SUMMARIZING; + if (!all_indexes) + flags |= EIIT_IS_HOT_INDEXED; + + ExecSetIndexUnchanged(resultRelInfo, modified_idx_attrs); + recheckIndexes = ExecInsertIndexTuples(resultRelInfo, estate, flags, slot, conflictindexes, &conflict); } + bms_free(modified_idx_attrs); + /* * Refer to the comments above the call to CheckAndReportConflict() in * ExecSimpleRelationInsert to understand why this check is done at diff --git a/src/backend/executor/execTuples.c b/src/backend/executor/execTuples.c index 97ae019d10a7a..5a94b3a124523 100644 --- a/src/backend/executor/execTuples.c +++ b/src/backend/executor/execTuples.c @@ -66,6 +66,7 @@ #include "nodes/nodeFuncs.h" #include "storage/bufmgr.h" #include "utils/builtins.h" +#include "utils/datum.h" #include "utils/expandeddatum.h" #include "utils/lsyscache.h" #include "utils/typcache.h" @@ -2012,6 +2013,87 @@ ExecFetchSlotHeapTupleDatum(TupleTableSlot *slot) return ret; } +/* + * ExecCompareSlotAttrs + * + * Compare the subset of attributes in attrs between TupleTableSlots to detect + * which attributes have changed. + * + * The input Bitmapset attrs is modified in place (recycled when possible via + * bms_del_member, which may pfree it and return NULL) and may be freed; + * callers must use only the returned pointer, not their original attrs + * value. Returns the Bitmapset of attribute indices (using the + * FirstLowInvalidHeapAttributeNumber convention) that differ between the two + * slots. + */ +Bitmapset * +ExecCompareSlotAttrs(Bitmapset *attrs, TupleDesc tupdesc, + TupleTableSlot *s1, TupleTableSlot *s2) +{ + int attidx = -1; + + while ((attidx = bms_next_member(attrs, attidx)) >= 0) + { + /* attidx is zero-based, attrnum is the normal attribute number */ + AttrNumber attrnum = attidx + FirstLowInvalidHeapAttributeNumber; + Datum value1, + value2; + bool null1, + null2; + CompactAttribute *att; + + /* + * If it's a whole-tuple reference, say "not equal". It's not really + * worth supporting this case, since it could only succeed after a + * no-op update, which is hardly a case worth optimizing for. + */ + if (attrnum == 0) + continue; + + /* + * Likewise, automatically say "not equal" for any system attribute + * other than tableOID; we cannot expect these to be consistent in a + * HOT chain, or even to be set correctly yet in the new tuple. + */ + if (attrnum < 0) + { + if (attrnum == TableOidAttributeNumber) + attrs = bms_del_member(attrs, attidx); + continue; + } + + att = TupleDescCompactAttr(tupdesc, attrnum - 1); + value1 = slot_getattr(s1, attrnum, &null1); + value2 = slot_getattr(s2, attrnum, &null2); + + /* A change to/from NULL, so not equal */ + if (null1 != null2) + continue; + + /* Both NULL, no change/unmodified */ + if (null2) + { + attrs = bms_del_member(attrs, attidx); + continue; + } + + /* + * Use datumIsEqual, matching heap_attr_equals() (the non-executor + * counterpart used by simple_heap_update -> HeapUpdateModifiedIdxAttrs). + * The two must agree on whether an indexed attribute changed, or the + * executor and non-executor UPDATE paths could make different + * HOT-indexed decisions for the same tuple pair (datum_image_eq + * detoasts and compares content, so it can call two physically + * different varlena representations of the same value "equal" where + * datumIsEqual would not), risking heap/index inconsistency. + */ + if (datumIsEqual(value1, value2, att->attbyval, att->attlen)) + attrs = bms_del_member(attrs, attidx); + } + + return attrs; +} + /* ---------------------------------------------------------------- * convenience initialization routines * ---------------------------------------------------------------- diff --git a/src/backend/executor/nodeIndexonlyscan.c b/src/backend/executor/nodeIndexonlyscan.c index d52012e8a6987..cd0caef136693 100644 --- a/src/backend/executor/nodeIndexonlyscan.c +++ b/src/backend/executor/nodeIndexonlyscan.c @@ -104,6 +104,7 @@ IndexOnlyNext(IndexOnlyScanState *node) /* Set it up for index-only scan */ node->ioss_ScanDesc->xs_want_itup = true; + node->ioss_ScanDesc->xs_index_only = true; node->ioss_VMBuffer = InvalidBuffer; /* @@ -172,6 +173,25 @@ IndexOnlyNext(IndexOnlyScanState *node) if (!index_fetch_heap(scandesc, node->ioss_TableSlot)) continue; /* no visible tuple, try next index entry */ + /* + * HOT-indexed stale entry: if the chain walk to reach this tuple + * crossed a hot-indexed hop that changed an attribute this index + * covers, the leaf we arrived through is stale. For IOS we serve + * values out of xs_itup, so a stale leaf would surface the wrong + * values; drop it. The fresh entry for the new value returns the + * row with correct values via its own path. Prune keeps any page + * that can carry such a stale leaf -- one with a redirect to a + * live HEAP_INDEXED_UPDATED tuple -- out of the visibility map + * (see heap_prune_record_redirect), so an index-only scan always + * reaches this heap fetch when staleness could apply. + */ + if (scandesc->xs_hot_indexed_stale) + { + InstrCountFiltered2(node, 1); + ExecClearTuple(node->ioss_TableSlot); + continue; + } + ExecClearTuple(node->ioss_TableSlot); /* @@ -229,6 +249,16 @@ IndexOnlyNext(IndexOnlyScanState *node) } } + /* + * No HOT-indexed staleness check is needed on the VM-all-visible path + * (where we skipped the heap fetch). Prune keeps any page that could + * carry a stale leaf -- one with a redirect to a live + * HEAP_INDEXED_UPDATED tuple -- out of the visibility map, so an + * all-visible entry never crossed a HOT/SIU hop. (index_getnext_tid + * also resets xs_hot_indexed_stale per entry, and only the heap fetch + * in index_fetch_heap ever sets it, so it cannot be set here anyway.) + */ + /* * We don't currently support rechecking ORDER BY distances. (In * principle, if the index can support retrieval of the originally @@ -775,6 +805,7 @@ ExecIndexOnlyScanInitializeDSM(IndexOnlyScanState *node, ScanRelIsReadOnly(&node->ss) ? SO_HINT_REL_READ_ONLY : SO_NONE); node->ioss_ScanDesc->xs_want_itup = true; + node->ioss_ScanDesc->xs_index_only = true; node->ioss_VMBuffer = InvalidBuffer; /* @@ -825,6 +856,7 @@ ExecIndexOnlyScanInitializeWorker(IndexOnlyScanState *node, ScanRelIsReadOnly(&node->ss) ? SO_HINT_REL_READ_ONLY : SO_NONE); node->ioss_ScanDesc->xs_want_itup = true; + node->ioss_ScanDesc->xs_index_only = true; /* * If no run-time keys to calculate or they are ready, go ahead and pass diff --git a/src/backend/executor/nodeIndexscan.c b/src/backend/executor/nodeIndexscan.c index 39f6691ee35ed..f3176acd14f95 100644 --- a/src/backend/executor/nodeIndexscan.c +++ b/src/backend/executor/nodeIndexscan.c @@ -151,6 +151,20 @@ IndexNext(IndexScanState *node) } } + /* + * HOT-indexed stale entry: the chain we walked to reach this tuple + * crossed a hot-indexed hop that changed an attribute this index + * covers, so the leaf entry we arrived through is stale. Drop it; + * the fresh entry inserted for the new value returns the row through + * its own path. Staleness was decided by the heap AM via per-hop + * modified-attrs bitmaps (see heap_hot_search_buffer). + */ + if (scandesc->xs_hot_indexed_stale) + { + InstrCountFiltered2(node, 1); + continue; + } + return slot; } @@ -276,6 +290,19 @@ IndexNextWithReorder(IndexScanState *node) continue; } + /* + * Drop a HOT-indexed stale leaf entry, exactly as IndexNext does. The + * staleness signal is access-method agnostic (indexam.c sets it from + * per-hop modified-attrs overlap, with no btree gating), so a + * distance-ordered GiST/SP-GiST scan that reaches this path can see it + * too; the fresh entry inserted for the new value returns the row. + */ + if (scandesc->xs_hot_indexed_stale) + { + InstrCountFiltered2(node, 1); + goto next_indextuple; + } + /* * If the index was lossy, we have to recheck the index quals and * ORDER BY expressions using the fetched tuple. diff --git a/src/backend/executor/nodeModifyTable.c b/src/backend/executor/nodeModifyTable.c index b9781eb3b95ba..cbf15216f8615 100644 --- a/src/backend/executor/nodeModifyTable.c +++ b/src/backend/executor/nodeModifyTable.c @@ -18,6 +18,7 @@ * ExecModifyTable - retrieve the next tuple from the node * ExecEndModifyTable - shut down the ModifyTable node * ExecReScanModifyTable - rescan the ModifyTable node + * ExecUpdateModifiedIdxAttrs - find set of updated indexed columns * * NOTES * The ModifyTable node receives input from its outerPlan, which is @@ -56,6 +57,7 @@ #include "access/htup_details.h" #include "access/tableam.h" #include "access/tupconvert.h" +#include "access/tupdesc.h" #include "access/xact.h" #include "commands/trigger.h" #include "executor/execPartition.h" @@ -128,7 +130,14 @@ typedef struct ModifyTableContext typedef struct UpdateContext { bool crossPartUpdate; /* was it a cross-partition update? */ - TU_UpdateIndexes updateIndexes; /* Which index updates are required? */ + + /* + * Set of indexed attributes the UPDATE changed (in/out for the table AM's + * update callback). Populated by ExecUpdateAct and consumed by + * ExecUpdateEpilogue; the AM adds the whole-row attribute + * (TableTupleUpdateAllIndexes) when every index needs a fresh entry. + */ + Bitmapset *modified_attrs; /* * Lock mode to acquire on the latest tuple version before performing @@ -202,6 +211,61 @@ static void fireASTriggers(ModifyTableState *node); static void ExecInitForPortionOf(ModifyTableState *mtstate, EState *estate, ResultRelInfo *resultRelInfo); +/* + * ExecUpdateModifiedIdxAttrs + * + * Find the set of attributes referenced by this relation and used in this + * UPDATE that now differ in value. This is done by reviewing slot datum that + * are in the UPDATE statment and are known to be referenced by at least one + * index in some way. This set is called the "modified indexed attributes" or + * "modified_idx_attrs". An overlap of a single index's attributes and this + * modified_idx_attrs set signals that the attributes in the new_tts used to + * form the index datum have changed. + * + * Return a Bitmapset that contains the set of modified (changed) indexed + * attributes between oldtup and newtup. + * + * Note: There is a similar function called HeapUpdateModifiedIdxAttrs() that operates + * on the old TID and new HeapTuple rather than the old/new TupleTableSlots as + * this function does. These two functions should mirror one another until + * someday when catalog tuple updates track their changes avoiding the need to + * re-discover them in simple_heap_update(). + */ +Bitmapset * +ExecUpdateModifiedIdxAttrs(ResultRelInfo *resultRelInfo, + TupleTableSlot *old_tts, + TupleTableSlot *new_tts) +{ + Relation relation = resultRelInfo->ri_RelationDesc; + TupleDesc tupdesc = RelationGetDescr(relation); + Bitmapset *attrs; + + /* If no indexes, we're done */ + if (resultRelInfo->ri_NumIndices == 0) + return NULL; + + /* + * Determine which indexed attributes actually changed value by comparing + * the old and new tuples attribute-by-attribute over the relation's full + * indexed-attribute set. We deliberately do NOT try to narrow the work + * using the SQL UPDATE's target list (ExecGetAllUpdatedCols): that list + * does not capture indexed columns mutated outside the SET clause, such + * as a column rewritten by a BEFORE/INSTEAD-OF trigger via + * heap_modify_tuple (see tsvector_update_trigger() in tsearch.sql), the + * implicit temporal range column of a FOR PORTION OF update, or the + * pre-built tuples applied by REPACK (CONCURRENTLY) and logical + * replication through a synthetic ResultRelInfo. Comparing the actual + * tuple values is always correct. + * + * RelationGetIndexAttrBitmap returns a copy we are free to mutate; + * ExecCompareSlotAttrs deletes the attributes that did not change and + * returns the surviving "modified indexed attributes" set. + */ + attrs = RelationGetIndexAttrBitmap(relation, INDEX_ATTR_BITMAP_INDEXED); + attrs = ExecCompareSlotAttrs(attrs, tupdesc, old_tts, new_tts); + + return attrs; +} /* * Verify that the tuples to be produced by INSERT match the @@ -2446,14 +2510,17 @@ ExecUpdatePrepareSlot(ResultRelInfo *resultRelInfo, */ static TM_Result ExecUpdateAct(ModifyTableContext *context, ResultRelInfo *resultRelInfo, - ItemPointer tupleid, HeapTuple oldtuple, TupleTableSlot *slot, - bool canSetTag, UpdateContext *updateCxt) + ItemPointer tupleid, HeapTuple oldtuple, TupleTableSlot *oldSlot, + TupleTableSlot *slot, bool canSetTag, UpdateContext *updateCxt) { EState *estate = context->estate; Relation resultRelationDesc = resultRelInfo->ri_RelationDesc; bool partition_constraint_failed; TM_Result result; + /* Reset any state left over from a previous call */ + updateCxt->modified_attrs = NULL; + updateCxt->crossPartUpdate = false; /* @@ -2570,7 +2637,17 @@ ExecUpdateAct(ModifyTableContext *context, ResultRelInfo *resultRelInfo, ExecConstraints(resultRelInfo, slot, estate); /* - * replace the heap tuple + * Next up we need to find out the set of indexed attributes that have + * changed in value and should trigger a new index tuple. We could start + * with the set of updated columns via ExecGetUpdatedCols(), but if we do + * we will overlook attributes directly modified by heap_modify_tuple() + * which are not known to ExecGetUpdatedCols(). + */ + updateCxt->modified_attrs = + ExecUpdateModifiedIdxAttrs(resultRelInfo, oldSlot, slot); + + /* + * Call into the table AM to update the heap tuple. * * Note: if es_crosscheck_snapshot isn't InvalidSnapshot, we check that * the row to be updated is visible to that snapshot, and throw a @@ -2578,6 +2655,15 @@ ExecUpdateAct(ModifyTableContext *context, ResultRelInfo *resultRelInfo, * for referential integrity updates in transaction-snapshot mode * transactions. */ + /* + * modified_attrs may legitimately already contain TableTupleUpdateAllIndexes + * on input: an index whose expression references a whole-row Var records + * attribute 0 in INDEX_ATTR_BITMAP_INDEXED, and ExecCompareSlotAttrs keeps + * attribute 0 (whose bitmap index equals the sentinel) to force an + * all-index update. The AM also sets the sentinel as an output signal; + * either way the epilogue treats the sentinel as "update all indexes", so + * its presence is harmless here. + */ result = table_tuple_update(resultRelationDesc, tupleid, slot, estate->es_output_cid, 0, @@ -2585,7 +2671,7 @@ ExecUpdateAct(ModifyTableContext *context, ResultRelInfo *resultRelInfo, estate->es_crosscheck_snapshot, true /* wait for commit */ , &context->tmfd, &updateCxt->lockmode, - &updateCxt->updateIndexes); + &updateCxt->modified_attrs); return result; } @@ -2606,17 +2692,38 @@ ExecUpdateEpilogue(ModifyTableContext *context, UpdateContext *updateCxt, List *recheckIndexes = NIL; /* insert index entries for tuple if necessary */ - if (resultRelInfo->ri_NumIndices > 0 && (updateCxt->updateIndexes != TU_None)) + if (resultRelInfo->ri_NumIndices > 0 && + !bms_is_empty(updateCxt->modified_attrs)) { - uint32 flags = EIIT_IS_UPDATE; + bool all_indexes = + bms_is_member(TableTupleUpdateAllIndexes, + updateCxt->modified_attrs); + + /* + * Populate per-index ii_IndexUnchanged before inserting. When the AM + * stored an independent new version (whole-row attribute present) + * every index needs a fresh entry; for a HOT update only those whose + * attributes overlap the modified set do. + */ + ExecSetIndexUnchanged(resultRelInfo, updateCxt->modified_attrs); - if (updateCxt->updateIndexes == TU_Summarizing) - flags |= EIIT_ONLY_SUMMARIZING; recheckIndexes = ExecInsertIndexTuples(resultRelInfo, context->estate, - flags, slot, NIL, + EIIT_IS_UPDATE | + (all_indexes ? + 0 : EIIT_IS_HOT_INDEXED), + slot, NIL, NULL); } + /* + * Free the modified-attrs bitmap now that the index inserts have consumed + * it. It is palloc'd in the per-query context (via RelationGetIndexAttrBitmap) + * once per updated row, so without this a bulk UPDATE would accumulate one + * Bitmapset per row for the lifetime of the statement. + */ + bms_free(updateCxt->modified_attrs); + updateCxt->modified_attrs = NULL; + /* Compute temporal leftovers in FOR PORTION OF */ if (((ModifyTable *) context->mtstate->ps.plan)->forPortionOf) ExecForPortionOfLeftovers(context, context->estate, resultRelInfo, tupleid); @@ -2831,8 +2938,8 @@ ExecUpdate(ModifyTableContext *context, ResultRelInfo *resultRelInfo, */ redo_act: lockedtid = *tupleid; - result = ExecUpdateAct(context, resultRelInfo, tupleid, oldtuple, slot, - canSetTag, &updateCxt); + result = ExecUpdateAct(context, resultRelInfo, tupleid, oldtuple, oldSlot, + slot, canSetTag, &updateCxt); /* * If ExecUpdateAct reports that a cross-partition update was done, @@ -3682,8 +3789,8 @@ ExecMergeMatched(ModifyTableContext *context, ResultRelInfo *resultRelInfo, Assert(oldtuple == NULL); result = ExecUpdateAct(context, resultRelInfo, tupleid, - NULL, newslot, canSetTag, - &updateCxt); + NULL, resultRelInfo->ri_oldTupleSlot, + newslot, canSetTag, &updateCxt); /* * As in ExecUpdate(), if ExecUpdateAct() reports that a diff --git a/src/backend/nodes/makefuncs.c b/src/backend/nodes/makefuncs.c index cdc02b274ff5d..80d1fd17cfa4b 100644 --- a/src/backend/nodes/makefuncs.c +++ b/src/backend/nodes/makefuncs.c @@ -845,8 +845,6 @@ makeIndexInfo(int numattrs, int numkeyattrs, Oid amoid, List *expressions, n->ii_Unique = unique; n->ii_NullsNotDistinct = nulls_not_distinct; n->ii_ReadyForInserts = isready; - n->ii_CheckedUnchanged = false; - n->ii_IndexUnchanged = false; n->ii_Concurrent = concurrent; n->ii_Summarizing = summarizing; n->ii_WithoutOverlaps = withoutoverlaps; diff --git a/src/backend/nodes/tidbitmap.c b/src/backend/nodes/tidbitmap.c index f1f925cb13b99..f3faf51e4b956 100644 --- a/src/backend/nodes/tidbitmap.c +++ b/src/backend/nodes/tidbitmap.c @@ -362,6 +362,25 @@ tbm_free_shared_area(dsa_area *dsa, dsa_pointer dp) * * If recheck is true, then the recheck flag will be set in the * TBMIterateResult when any of these tuples are reported out. + * + * A TID may carry the SIU "may-be-stale" marker (ItemPointerSIUMaybeStaleFlag, + * see storage/itemptr.h): a HOT-indexed (selective index update) fresh entry + * points at a mid-chain heap-only tuple rather than at the chain root the way + * every other index entry for that row does. Because BitmapAnd/BitmapOr + * intersect/union TID sets here at block+offset granularity before the heap + * is ever consulted, an unrelated unchanged index's root-pointing entry for + * the same row would not agree with such an entry's offset, and an exact-mode + * intersection could silently drop a matching row. Whenever we see the flag + * we therefore add the whole page as lossy instead of the exact offset: a + * lossy page survives any AND/OR against an exact-mode page (see + * tbm_intersect_page) and forces a recheck, so BitmapHeapScan resolves the + * chain and the table AM's heap-side staleness test makes the final call. + * + * Handling this here -- at the single choke point every amgetbitmap funnels + * exact heap TIDs through -- means no index access method needs to know about + * HOT-indexed chains: core AMs, contrib AMs (bloom), and out-of-tree AMs are + * all correct automatically, and a TID that never carries the flag takes the + * identical path it always did. */ void tbm_add_tuples(TIDBitmap *tbm, const ItemPointerData *tids, int ntids, @@ -374,10 +393,26 @@ tbm_add_tuples(TIDBitmap *tbm, const ItemPointerData *tids, int ntids, for (int i = 0; i < ntids; i++) { BlockNumber blk = ItemPointerGetBlockNumber(tids + i); - OffsetNumber off = ItemPointerGetOffsetNumber(tids + i); + OffsetNumber off; int wordnum, bitnum; + /* + * A HOT-indexed fresh entry's TID must not be trusted at exact + * offset granularity here (see the function header). Test the raw + * TID -- before ItemPointerGetOffsetNumber strips the marker -- and + * if set, degrade the whole page to lossy and move on. + */ + if (unlikely(ItemPointerIsSIUMaybeStale(tids + i))) + { + tbm_add_page(tbm, blk); + /* tbm_add_page may have lossified pages; force a fresh lookup */ + currblk = InvalidBlockNumber; + continue; + } + + off = ItemPointerGetOffsetNumber(tids + i); + /* safety check to ensure we don't overrun bit array bounds */ if (off < 1 || off > TBM_MAX_TUPLES_PER_PAGE) elog(ERROR, "tuple offset out of range: %u", off); diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c index 7799266c61409..16336ced104f6 100644 --- a/src/backend/replication/logical/worker.c +++ b/src/backend/replication/logical/worker.c @@ -6083,6 +6083,29 @@ IsLogicalWorker(void) return MyLogicalRepWorker != NULL; } +/* + * Return the cached HOT-indexed apply mode of the current logical replication + * worker's subscription. + * + * Callers outside worker.c (notably heapam.c's HeapUpdateHotAllowable) use + * this accessor to avoid pulling in worker_internal.h or the Subscription + * struct. Non-apply processes get LOGICALREP_HOT_INDEXED_OFF, which is the + * conservative value; callers are expected to guard with IsLogicalWorker() + * first for clarity, but the accessor is safe either way. + */ +char +GetHotIndexedApplyMode(void) +{ + /* + * Derive directly from MySubscription rather than caching, so there is no + * second copy to keep in sync with MySubscription reloads. Readers + * outside worker.c go through this accessor so they don't need visibility + * into the Subscription struct. + */ + return MySubscription ? MySubscription->hotindexedonapply + : LOGICALREP_HOT_INDEXED_OFF; +} + /* * Is current process a logical replication parallel apply worker? */ diff --git a/src/backend/storage/page/itemptr.c b/src/backend/storage/page/itemptr.c index 546874ebc5f18..3c57502b919db 100644 --- a/src/backend/storage/page/itemptr.c +++ b/src/backend/storage/page/itemptr.c @@ -52,20 +52,28 @@ ItemPointerCompare(const ItemPointerData *arg1, const ItemPointerData *arg2) { /* * Use ItemPointerGet{Offset,Block}NumberNoCheck to avoid asserting - * ip_posid != 0, which may not be true for a user-supplied TID. + * ip_posid != 0, which may not be true for a user-supplied TID. Strip + * the SIU may-be-stale bit (see ItemPointerSIUMaybeStaleFlag in + * itemptr.h) so two TIDs naming the same (block, offset) always compare + * equal regardless of which one, if either, happens to carry it -- the + * bit is a hint to a handful of bitmap-scan call sites, not part of a + * TID's identity for ordering/equality purposes. ItemPointerOffsetNumberStrip + * leaves the reserved sentinel values (SpecTokenOffsetNumber, + * MovedPartitionsOffsetNumber) untouched, since those already have the + * same bit set as part of their own encoding. */ BlockNumber b1 = ItemPointerGetBlockNumberNoCheck(arg1); BlockNumber b2 = ItemPointerGetBlockNumberNoCheck(arg2); + OffsetNumber o1 = ItemPointerOffsetNumberStrip(ItemPointerGetOffsetNumberNoCheck(arg1)); + OffsetNumber o2 = ItemPointerOffsetNumberStrip(ItemPointerGetOffsetNumberNoCheck(arg2)); if (b1 < b2) return -1; else if (b1 > b2) return 1; - else if (ItemPointerGetOffsetNumberNoCheck(arg1) < - ItemPointerGetOffsetNumberNoCheck(arg2)) + else if (o1 < o2) return -1; - else if (ItemPointerGetOffsetNumberNoCheck(arg1) > - ItemPointerGetOffsetNumberNoCheck(arg2)) + else if (o1 > o2) return 1; else return 0; diff --git a/src/backend/utils/activity/pgstat_relation.c b/src/backend/utils/activity/pgstat_relation.c index 04f2eb21d0bb5..dd1140b29fb81 100644 --- a/src/backend/utils/activity/pgstat_relation.c +++ b/src/backend/utils/activity/pgstat_relation.c @@ -384,11 +384,17 @@ pgstat_count_heap_insert(Relation rel, PgStat_Counter n) /* * count a tuple update + * + * hot -- the update was a heap-only tuple (classic HOT or HOT-indexed) + * hot_indexed -- the update was a HOT-indexed update, a subcase of + * hot=true; hot_indexed implies hot + * newpage -- the new tuple went to a different buffer than the old one */ void -pgstat_count_heap_update(Relation rel, bool hot, bool newpage) +pgstat_count_heap_update(Relation rel, bool hot, bool hot_indexed, bool newpage) { Assert(!(hot && newpage)); + Assert(!(hot_indexed && !hot)); if (pgstat_should_count_relation(rel)) { @@ -398,11 +404,17 @@ pgstat_count_heap_update(Relation rel, bool hot, bool newpage) pgstat_info->trans->tuples_updated++; /* - * tuples_hot_updated and tuples_newpage_updated counters are - * nontransactional, so just advance them + * tuples_hot_updated, tuples_hot_indexed_updated, and + * tuples_newpage_updated counters are nontransactional, so just + * advance them. tuples_hot_indexed_updated is counted in *addition* to + * tuples_hot: every hot-indexed update is also a HOT update. */ if (hot) + { pgstat_info->counts.tuples_hot_updated++; + if (hot_indexed) + pgstat_info->counts.tuples_hot_indexed_updated++; + } else if (newpage) pgstat_info->counts.tuples_newpage_updated++; } @@ -854,7 +866,10 @@ pgstat_relation_flush_cb(PgStat_EntryRef *entry_ref, bool nowait) tabentry->tuples_updated += lstats->counts.tuples_updated; tabentry->tuples_deleted += lstats->counts.tuples_deleted; tabentry->tuples_hot_updated += lstats->counts.tuples_hot_updated; + tabentry->tuples_hot_indexed_updated += lstats->counts.tuples_hot_indexed_updated; tabentry->tuples_newpage_updated += lstats->counts.tuples_newpage_updated; + tabentry->tuples_hot_indexed_upd_skipped += lstats->counts.tuples_hot_indexed_upd_skipped; + tabentry->tuples_hot_indexed_upd_matched += lstats->counts.tuples_hot_indexed_upd_matched; /* * If table was truncated/dropped, first reset the live/dead counters. diff --git a/src/backend/utils/adt/pgstatfuncs.c b/src/backend/utils/adt/pgstatfuncs.c index 565d0e70768bb..552f3540cde83 100644 --- a/src/backend/utils/adt/pgstatfuncs.c +++ b/src/backend/utils/adt/pgstatfuncs.c @@ -93,6 +93,15 @@ PG_STAT_GET_RELENTRY_INT64(tuples_fetched) /* pg_stat_get_tuples_hot_updated */ PG_STAT_GET_RELENTRY_INT64(tuples_hot_updated) +/* pg_stat_get_tuples_hot_indexed_updated */ +PG_STAT_GET_RELENTRY_INT64(tuples_hot_indexed_updated) + +/* pg_stat_get_tuples_hot_indexed_updated_skipped */ +PG_STAT_GET_RELENTRY_INT64(tuples_hot_indexed_upd_skipped) + +/* pg_stat_get_tuples_hot_indexed_updated_matched */ +PG_STAT_GET_RELENTRY_INT64(tuples_hot_indexed_upd_matched) + /* pg_stat_get_tuples_newpage_updated */ PG_STAT_GET_RELENTRY_INT64(tuples_newpage_updated) @@ -1888,6 +1897,9 @@ PG_STAT_GET_XACT_RELENTRY_INT64(tuples_fetched) /* pg_stat_get_xact_tuples_hot_updated */ PG_STAT_GET_XACT_RELENTRY_INT64(tuples_hot_updated) +/* pg_stat_get_xact_tuples_hot_indexed_updated */ +PG_STAT_GET_XACT_RELENTRY_INT64(tuples_hot_indexed_updated) + /* pg_stat_get_xact_tuples_newpage_updated */ PG_STAT_GET_XACT_RELENTRY_INT64(tuples_newpage_updated) diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c index fb4e042be8adf..ca605265703d1 100644 --- a/src/backend/utils/cache/relcache.c +++ b/src/backend/utils/cache/relcache.c @@ -1586,6 +1586,7 @@ RelationInitIndexAccessInfo(Relation relation) */ relation->rd_indexprs = NIL; relation->rd_indpred = NIL; + relation->rd_indattr = NULL; relation->rd_exclops = NULL; relation->rd_exclprocs = NULL; relation->rd_exclstrats = NULL; @@ -2482,8 +2483,9 @@ RelationDestroyRelation(Relation relation, bool remember_tupdesc) bms_free(relation->rd_keyattr); bms_free(relation->rd_pkattr); bms_free(relation->rd_idattr); - bms_free(relation->rd_hotblockingattr); + bms_free(relation->rd_indexedattr); bms_free(relation->rd_summarizedattr); + bms_free(relation->rd_exprindexattr); if (relation->rd_pubdesc) pfree(relation->rd_pubdesc); if (relation->rd_options) @@ -5275,6 +5277,130 @@ RelationGetIndexPredicate(Relation relation) return result; } +/* + * RelationGetIndexedAttrs -- palloc'd Bitmapset of heap attrs this index + * references. + * + * Includes attributes used as simple key columns, INCLUDE columns, inside + * expression columns, and inside the partial-index predicate. Attribute + * numbers use the FirstLowInvalidHeapAttributeNumber offset convention so + * that system attributes are representable alongside user attributes. + * + * The function builds up the bitmap from: + * - rd_index->indkey (keys + INCLUDE) + * - RelationGetIndexExpressions (parsed expression trees, already cached) + * - RelationGetIndexPredicate (parsed predicate tree, already cached) + * and caches a copy in rd_indexedattr, which lives in rd_indexcxt. + * + * The returned Bitmapset is allocated in the caller's current memory + * context; the caller owns it and must bms_free when done. We never hand + * out a borrowed pointer to the cached copy because relcache invalidation + * can rebuild rd_indexcxt in place even while a refcount is held. + * + * Caller must hold an open lock on the index relation. + */ +Bitmapset * +RelationGetIndexedAttrs(Relation indexRel) +{ + Bitmapset *attrs = NULL; + Form_pg_index indexStruct; + List *indexprs; + List *indpred; + MemoryContext oldcxt; + + Assert(indexRel->rd_rel->relkind == RELKIND_INDEX || + indexRel->rd_rel->relkind == RELKIND_PARTITIONED_INDEX); + + /* Fast path: return a copy of the cached bitmap. */ + if (indexRel->rd_indattr != NULL) + return bms_copy(indexRel->rd_indattr); + + indexStruct = indexRel->rd_index; + + /* + * During very early bootstrap rd_indextuple may not be populated yet. In + * that case we fall back to just the key columns without caching. + */ + if (indexRel->rd_indextuple == NULL) + { + for (int i = 0; i < indexStruct->indnatts; i++) + { + AttrNumber attrnum = indexStruct->indkey.values[i]; + + if (attrnum != 0) + attrs = bms_add_member(attrs, + attrnum - FirstLowInvalidHeapAttributeNumber); + } + return attrs; + } + + /* + * Key columns and INCLUDE (covering) columns. INCLUDE columns must be + * counted: their values are stored in the index leaf and served by + * index-only scans, so an update that changes an INCLUDE column must + * insert a fresh index entry (or be disqualified from staying + * HOT-indexed) exactly as for a key column. This matches the heap-level + * RelationGetIndexAttrBitmap(..., INDEX_ATTR_BITMAP_INDEXED), which also + * unions all indnatts. Expression and partial-predicate columns are + * added below. + */ + for (int i = 0; i < indexStruct->indnatts; i++) + { + AttrNumber attrnum = indexStruct->indkey.values[i]; + + /* attnum 0 means "expression"; those attrs are picked up below. */ + if (attrnum != 0) + attrs = bms_add_member(attrs, + attrnum - FirstLowInvalidHeapAttributeNumber); + } + + /* + * Expression columns and partial-index predicate columns. Deliberately + * do NOT use RelationGetIndexExpressions()/RelationGetIndexPredicate() + * here, for the same reason RelationGetIndexAttrBitmap avoids them: those + * functions run eval_const_expressions(), which needs a snapshot we may + * not have, and can const-fold away a Var reference (e.g. a CASE with a + * statically-decidable branch), causing this bitmap to under-report an + * attribute that rd_indexedattr/rd_exprindexattr (built from the same raw + * catalog text) still record. Parse the raw stored trees instead, so + * this function's result stays a superset-consistent match with those. + */ + { + Datum datum; + bool isnull; + + datum = heap_getattr(indexRel->rd_indextuple, Anum_pg_index_indexprs, + GetPgIndexDescriptor(), &isnull); + if (!isnull) + { + indexprs = (List *) stringToNode(TextDatumGetCString(datum)); + pull_varattnos((Node *) indexprs, 1, &attrs); + } + + datum = heap_getattr(indexRel->rd_indextuple, Anum_pg_index_indpred, + GetPgIndexDescriptor(), &isnull); + if (!isnull) + { + indpred = (List *) stringToNode(TextDatumGetCString(datum)); + pull_varattnos((Node *) indpred, 1, &attrs); + } + } + + /* + * Cache a copy inside rd_indexcxt so subsequent calls are cheap. The + * cached bitmap is freed along with rd_indexcxt on relcache rebuild, so + * it's safe to stash here. + */ + if (indexRel->rd_indexcxt != NULL) + { + oldcxt = MemoryContextSwitchTo(indexRel->rd_indexcxt); + indexRel->rd_indattr = bms_copy(attrs); + MemoryContextSwitchTo(oldcxt); + } + + return attrs; +} + /* * RelationGetIndexAttrBitmap -- get a bitmap of index attribute numbers * @@ -5291,8 +5417,8 @@ RelationGetIndexPredicate(Relation relation) * (beware: even if PK is deferrable!) * INDEX_ATTR_BITMAP_IDENTITY_KEY Columns in the table's replica identity * index (empty if FULL) - * INDEX_ATTR_BITMAP_HOT_BLOCKING Columns that block updates from being HOT - * INDEX_ATTR_BITMAP_SUMMARIZED Columns included in summarizing indexes + * INDEX_ATTR_BITMAP_INDEXED Columns referenced by indexes + * INDEX_ATTR_BITMAP_SUMMARIZED Columns only included in summarizing indexes * * Attribute numbers are offset by FirstLowInvalidHeapAttributeNumber so that * we can include system attributes (e.g., OID) in the bitmap representation. @@ -5313,10 +5439,11 @@ Bitmapset * RelationGetIndexAttrBitmap(Relation relation, IndexAttrBitmapKind attrKind) { Bitmapset *uindexattrs; /* columns in unique indexes */ + Bitmapset *exprindexattrs; /* columns referenced by expression indexes */ Bitmapset *pkindexattrs; /* columns in the primary index */ Bitmapset *idindexattrs; /* columns in the replica identity */ - Bitmapset *hotblockingattrs; /* columns with HOT blocking indexes */ - Bitmapset *summarizedattrs; /* columns with summarizing indexes */ + Bitmapset *indexedattrs; /* columns referenced by indexes */ + Bitmapset *summarizedattrs; /* columns only in summarizing indexes */ List *indexoidlist; List *newindexoidlist; Oid relpkindex; @@ -5335,10 +5462,12 @@ RelationGetIndexAttrBitmap(Relation relation, IndexAttrBitmapKind attrKind) return bms_copy(relation->rd_pkattr); case INDEX_ATTR_BITMAP_IDENTITY_KEY: return bms_copy(relation->rd_idattr); - case INDEX_ATTR_BITMAP_HOT_BLOCKING: - return bms_copy(relation->rd_hotblockingattr); + case INDEX_ATTR_BITMAP_INDEXED: + return bms_copy(relation->rd_indexedattr); case INDEX_ATTR_BITMAP_SUMMARIZED: return bms_copy(relation->rd_summarizedattr); + case INDEX_ATTR_BITMAP_EXPRESSION: + return bms_copy(relation->rd_exprindexattr); default: elog(ERROR, "unknown attrKind %u", attrKind); } @@ -5381,8 +5510,9 @@ RelationGetIndexAttrBitmap(Relation relation, IndexAttrBitmapKind attrKind) uindexattrs = NULL; pkindexattrs = NULL; idindexattrs = NULL; - hotblockingattrs = NULL; + indexedattrs = NULL; summarizedattrs = NULL; + exprindexattrs = NULL; foreach(l, indexoidlist) { Oid indexOid = lfirst_oid(l); @@ -5441,7 +5571,7 @@ RelationGetIndexAttrBitmap(Relation relation, IndexAttrBitmapKind attrKind) if (indexDesc->rd_indam->amsummarizing) attrs = &summarizedattrs; else - attrs = &hotblockingattrs; + attrs = &indexedattrs; /* Collect simple attribute references */ for (i = 0; i < indexDesc->rd_index->indnatts; i++) @@ -5450,9 +5580,9 @@ RelationGetIndexAttrBitmap(Relation relation, IndexAttrBitmapKind attrKind) /* * Since we have covering indexes with non-key columns, we must - * handle them accurately here. non-key columns must be added into - * hotblockingattrs or summarizedattrs, since they are in index, - * and update shouldn't miss them. + * handle them accurately here. Non-key columns must be added into + * indexedattrs or summarizedattrs, since they are in index, and + * update shouldn't miss them. * * Summarizing indexes do not block HOT, but do need to be updated * when the column value changes, thus require a separate @@ -5487,6 +5617,28 @@ RelationGetIndexAttrBitmap(Relation relation, IndexAttrBitmapKind attrKind) /* Collect all attributes in the index predicate, too */ pull_varattnos(indexPredicate, 1, attrs); + /* + * If this index evaluates an expression, record every heap attribute + * it references (key columns, expression vars, predicate vars) in + * exprindexattrs. HeapUpdateHotAllowable() disqualifies the + * HOT-indexed path for an UPDATE that touches one of these, because + * expression-aware selective index maintenance is not implemented + * yet. + */ + if (indexExpressions != NULL) + { + for (i = 0; i < indexDesc->rd_index->indnatts; i++) + { + int attrnum = indexDesc->rd_index->indkey.values[i]; + + if (attrnum != 0) + exprindexattrs = bms_add_member(exprindexattrs, + attrnum - FirstLowInvalidHeapAttributeNumber); + } + pull_varattnos(indexExpressions, 1, &exprindexattrs); + pull_varattnos(indexPredicate, 1, &exprindexattrs); + } + index_close(indexDesc, AccessShareLock); } @@ -5513,12 +5665,33 @@ RelationGetIndexAttrBitmap(Relation relation, IndexAttrBitmapKind attrKind) bms_free(uindexattrs); bms_free(pkindexattrs); bms_free(idindexattrs); - bms_free(hotblockingattrs); + bms_free(indexedattrs); bms_free(summarizedattrs); + bms_free(exprindexattrs); goto restart; } + /* + * Record which attributes are referenced only by summarizing indexes, so + * INDEX_ATTR_BITMAP_SUMMARIZED reports columns whose sole indexes are + * summarizing ones, then fold those columns into indexedattrs as well. + * + * INDEX_ATTR_BITMAP_INDEXED must include summarizing-index columns for + * the HOT-indexed write path: it compares the old and new tuples over + * this bitmap to build the set of modified indexed attributes, and only + * maintains indexes when that set is non-empty (or the update is + * non-HOT). A change to a column indexed only by a summarizing index + * must therefore appear in the bitmap so the summarizing index gets its + * block summary refreshed. HeapUpdateHotAllowable's all_summarizing + * check still keeps such an update on the classic-HOT path (it stays + * classic HOT, since INDEX_ATTR_BITMAP_SUMMARIZED -- summarizing-only -- + * is a superset of the modified attributes), and the summarizing index + * inserts unconditionally via its ii_Summarizing flag. + */ + summarizedattrs = bms_del_members(summarizedattrs, indexedattrs); + indexedattrs = bms_add_members(indexedattrs, summarizedattrs); + /* Don't leak the old values of these bitmaps, if any */ relation->rd_attrsvalid = false; bms_free(relation->rd_keyattr); @@ -5527,10 +5700,12 @@ RelationGetIndexAttrBitmap(Relation relation, IndexAttrBitmapKind attrKind) relation->rd_pkattr = NULL; bms_free(relation->rd_idattr); relation->rd_idattr = NULL; - bms_free(relation->rd_hotblockingattr); - relation->rd_hotblockingattr = NULL; + bms_free(relation->rd_indexedattr); + relation->rd_indexedattr = NULL; bms_free(relation->rd_summarizedattr); relation->rd_summarizedattr = NULL; + bms_free(relation->rd_exprindexattr); + relation->rd_exprindexattr = NULL; /* * Now save copies of the bitmaps in the relcache entry. We intentionally @@ -5543,8 +5718,9 @@ RelationGetIndexAttrBitmap(Relation relation, IndexAttrBitmapKind attrKind) relation->rd_keyattr = bms_copy(uindexattrs); relation->rd_pkattr = bms_copy(pkindexattrs); relation->rd_idattr = bms_copy(idindexattrs); - relation->rd_hotblockingattr = bms_copy(hotblockingattrs); + relation->rd_indexedattr = bms_copy(indexedattrs); relation->rd_summarizedattr = bms_copy(summarizedattrs); + relation->rd_exprindexattr = bms_copy(exprindexattrs); relation->rd_attrsvalid = true; MemoryContextSwitchTo(oldcxt); @@ -5557,10 +5733,76 @@ RelationGetIndexAttrBitmap(Relation relation, IndexAttrBitmapKind attrKind) return pkindexattrs; case INDEX_ATTR_BITMAP_IDENTITY_KEY: return idindexattrs; - case INDEX_ATTR_BITMAP_HOT_BLOCKING: - return hotblockingattrs; + case INDEX_ATTR_BITMAP_INDEXED: + return indexedattrs; case INDEX_ATTR_BITMAP_SUMMARIZED: return summarizedattrs; + case INDEX_ATTR_BITMAP_EXPRESSION: + return exprindexattrs; + default: + elog(ERROR, "unknown attrKind %u", attrKind); + return NULL; + } +} + +/* + * RelationGetIndexAttrBitmapNoCopy -- borrowing variant of + * RelationGetIndexAttrBitmap + * + * Returns a pointer to the relcache-owned bitmap for the given attrKind + * without making a defensive copy. This is a hot-path optimization for + * read-only callers that perform set operations like bms_overlap, + * bms_is_subset, bms_equal, or bms_num_members and never mutate the + * returned bitmap. The result is conceptually `const Bitmapset *`; callers + * must not pass it to anything that could free or modify the underlying + * memory (e.g., bms_add_member, bms_int_members, bms_free). + * + * Lifetime: the pointer is valid only until the next event that could + * trigger a relcache invalidation on `relation`. Callers must not invoke + * any code that opens a relation, runs catalog lookups, or otherwise + * accepts invalidation messages between the fetch and the last use. + * + * For the common case the relcache entry's attribute bitmaps are already + * computed (rd_attrsvalid is true). When they aren't, we go through + * RelationGetIndexAttrBitmap to populate the cache (which costs one + * throwaway bms_copy on first use) and then return the cached pointer on + * the second pass. The first-use path is rare and never on the bench hot + * path, so the simplicity is preferred over open-coding the populate-only + * variant. + */ +const Bitmapset * +RelationGetIndexAttrBitmapNoCopy(Relation relation, IndexAttrBitmapKind attrKind) +{ + if (!relation->rd_attrsvalid) + { + Bitmapset *populated; + + /* Populate rd_*attr fields; discard the returned copy. */ + populated = RelationGetIndexAttrBitmap(relation, attrKind); + bms_free(populated); + + /* + * If the relation has no indexes, RelationGetIndexAttrBitmap returns + * NULL without setting rd_attrsvalid. Mirror that here. + */ + if (!relation->rd_attrsvalid) + return NULL; + } + + switch (attrKind) + { + case INDEX_ATTR_BITMAP_KEY: + return relation->rd_keyattr; + case INDEX_ATTR_BITMAP_PRIMARY_KEY: + return relation->rd_pkattr; + case INDEX_ATTR_BITMAP_IDENTITY_KEY: + return relation->rd_idattr; + case INDEX_ATTR_BITMAP_INDEXED: + return relation->rd_indexedattr; + case INDEX_ATTR_BITMAP_SUMMARIZED: + return relation->rd_summarizedattr; + case INDEX_ATTR_BITMAP_EXPRESSION: + return relation->rd_exprindexattr; default: elog(ERROR, "unknown attrKind %u", attrKind); return NULL; @@ -6500,6 +6742,7 @@ load_relcache_init_file(bool shared) rel->rd_partcheckcxt = NULL; rel->rd_indexprs = NIL; rel->rd_indpred = NIL; + rel->rd_indattr = NULL; rel->rd_exclops = NULL; rel->rd_exclprocs = NULL; rel->rd_exclstrats = NULL; diff --git a/src/bin/pg_dump/pg_dump.c b/src/bin/pg_dump/pg_dump.c index 4948e6d80c7ee..9cec0e0bb2b49 100644 --- a/src/bin/pg_dump/pg_dump.c +++ b/src/bin/pg_dump/pg_dump.c @@ -5138,6 +5138,7 @@ getSubscriptions(Archive *fout) int i_subfailover; int i_subretaindeadtuples; int i_submaxretention; + int i_subhotindexedonapply; int i, ntups; @@ -5235,6 +5236,14 @@ getSubscriptions(Archive *fout) appendPQExpBufferStr(query, " '-1' AS subwalrcvtimeout,\n"); + if (fout->remoteVersion >= 190000) + appendPQExpBufferStr(query, + " s.subhotindexedonapply,\n"); + else + appendPQExpBuffer(query, + " '%c' AS subhotindexedonapply,\n", + LOGICALREP_HOT_INDEXED_SUBSET_ONLY); + if (fout->remoteVersion >= 190000) appendPQExpBufferStr(query, " fs.srvname AS subservername\n"); else @@ -5279,6 +5288,7 @@ getSubscriptions(Archive *fout) i_subfailover = PQfnumber(res, "subfailover"); i_subretaindeadtuples = PQfnumber(res, "subretaindeadtuples"); i_submaxretention = PQfnumber(res, "submaxretention"); + i_subhotindexedonapply = PQfnumber(res, "subhotindexedonapply"); i_subservername = PQfnumber(res, "subservername"); i_subconninfo = PQfnumber(res, "subconninfo"); i_subslotname = PQfnumber(res, "subslotname"); @@ -5322,6 +5332,8 @@ getSubscriptions(Archive *fout) (strcmp(PQgetvalue(res, i, i_subretaindeadtuples), "t") == 0); subinfo[i].submaxretention = atoi(PQgetvalue(res, i, i_submaxretention)); + subinfo[i].subhotindexedonapply = + *(PQgetvalue(res, i, i_subhotindexedonapply)); if (PQgetisnull(res, i, i_subconninfo)) subinfo[i].subconninfo = NULL; else @@ -5599,6 +5611,11 @@ dumpSubscription(Archive *fout, const SubscriptionInfo *subinfo) if (subinfo->submaxretention) appendPQExpBuffer(query, ", max_retention_duration = %d", subinfo->submaxretention); + if (subinfo->subhotindexedonapply == LOGICALREP_HOT_INDEXED_OFF) + appendPQExpBufferStr(query, ", hot_indexed_on_apply = off"); + else if (subinfo->subhotindexedonapply == LOGICALREP_HOT_INDEXED_ALWAYS) + appendPQExpBufferStr(query, ", hot_indexed_on_apply = always"); + if (strcmp(subinfo->subsynccommit, "off") != 0) appendPQExpBuffer(query, ", synchronous_commit = %s", fmtId(subinfo->subsynccommit)); diff --git a/src/bin/pg_dump/pg_dump.h b/src/bin/pg_dump/pg_dump.h index 5a6726d8b12e2..e3e7401df0801 100644 --- a/src/bin/pg_dump/pg_dump.h +++ b/src/bin/pg_dump/pg_dump.h @@ -722,6 +722,7 @@ typedef struct _SubscriptionInfo bool subfailover; bool subretaindeadtuples; int submaxretention; + char subhotindexedonapply; char *subservername; char *subconninfo; char *subslotname; diff --git a/src/bin/pg_upgrade/meson.build b/src/bin/pg_upgrade/meson.build index ffbf6ae8d759b..0a6fa0dcff2ff 100644 --- a/src/bin/pg_upgrade/meson.build +++ b/src/bin/pg_upgrade/meson.build @@ -69,6 +69,7 @@ tests += { 't/006_transfer_modes.pl', 't/007_multixact_conversion.pl', 't/008_extension_control_path.pl', + 't/009_hot_indexed.pl', ], 'deps': [test_ext], 'test_kwargs': {'priority': 40}, # pg_upgrade tests are slow diff --git a/src/bin/pg_upgrade/t/009_hot_indexed.pl b/src/bin/pg_upgrade/t/009_hot_indexed.pl new file mode 100644 index 0000000000000..b71129ae03d50 --- /dev/null +++ b/src/bin/pg_upgrade/t/009_hot_indexed.pl @@ -0,0 +1,118 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# pg_upgrade must preserve HOT-indexed on-disk state. A relation that has +# accumulated HOT-indexed chains -- including a value cycled away and back +# (ABA), an out-of-line (TOAST) indexed column, and chains collapsed to +# xid-free forwarding stubs by VACUUM -- must come through an upgrade with its +# data intact, its indexes structurally sound, and its chains still scanning +# correctly. pg_upgrade transfers heap and index files verbatim, so this is +# really a check that the new-state bits (HEAP_INDEXED_UPDATED, collapse stubs) +# are not rejected by pg_upgrade's checks and stay correct on the new cluster. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $mode = $ENV{PG_TEST_PG_UPGRADE_MODE} || '--copy'; + +my $oldnode = PostgreSQL::Test::Cluster->new('old_node'); +$oldnode->init; +$oldnode->start; + +# Build a relation with several secondary indexes so single-column updates +# stay HOT-indexed, then exercise the cases that produce interesting on-disk +# state. +$oldnode->safe_psql('postgres', q{ + CREATE EXTENSION amcheck; + CREATE TABLE hi (id int PRIMARY KEY, k int, v int, big text) + WITH (fillfactor = 50); + ALTER TABLE hi ALTER COLUMN big SET STORAGE EXTERNAL; + CREATE INDEX hi_k ON hi (k); + CREATE INDEX hi_v ON hi (v); + CREATE INDEX hi_big ON hi (big); + INSERT INTO hi SELECT g, g, g * 10, repeat(chr(64 + g), 2000) + FROM generate_series(1, 20) g; +}); + +# Interleave updates of different indexed columns on the same rows. A member +# that changed a column not changed again by a later hop survives VACUUM as a +# collapse stub; the rest are reclaimed. Row 1 additionally cycles k away and +# back (ABA), and row 2 rewrites its toasted indexed column. +$oldnode->safe_psql('postgres', q{ + UPDATE hi SET k = k + 100 WHERE id <= 10; -- changes k + UPDATE hi SET v = v + 1 WHERE id <= 10; -- changes v (survives as stub) + UPDATE hi SET k = k - 100 WHERE id <= 10; -- k back to original (ABA) + UPDATE hi SET big = repeat('Z', 2000) WHERE id = 2; +}); +# Collapse dead chain members to stubs. +$oldnode->safe_psql('postgres', 'VACUUM (INDEX_CLEANUP off) hi'); + +# The pre-upgrade state must already be self-consistent. +is( $oldnode->safe_psql('postgres', + q{SELECT count(*) FROM verify_heapam('hi')}), + '0', 'pre-upgrade heap is consistent'); + +# Snapshot the data we will compare after the upgrade. +my $expect = $oldnode->safe_psql('postgres', + q{SELECT id, k, v, length(big) FROM hi ORDER BY id}); + +$oldnode->stop; + +# New cluster, same version. +my $newnode = PostgreSQL::Test::Cluster->new('new_node'); +$newnode->init; + +my $oldbindir = $oldnode->config_data('--bindir'); +my $newbindir = $newnode->config_data('--bindir'); + +# Run pg_upgrade from a writable directory (matches 002_pg_upgrade). +chdir ${PostgreSQL::Test::Utils::tmp_check}; + +command_ok( + [ + 'pg_upgrade', '--no-sync', + '--old-datadir' => $oldnode->data_dir, + '--new-datadir' => $newnode->data_dir, + '--old-bindir' => $oldbindir, + '--new-bindir' => $newbindir, + '--socketdir' => $newnode->host, + '--old-port' => $oldnode->port, + '--new-port' => $newnode->port, + $mode, + ], + 'run of pg_upgrade for HOT-indexed relation'); + +$newnode->start; + +# Data survived intact. +my $got = $newnode->safe_psql('postgres', + q{SELECT id, k, v, length(big) FROM hi ORDER BY id}); +is($got, $expect, 'HOT-indexed table data preserved across pg_upgrade'); + +# Heap and indexes are structurally sound on the new cluster. +is( $newnode->safe_psql('postgres', + q{SELECT count(*) FROM verify_heapam('hi')}), + '0', 'post-upgrade heap is consistent (collapse stubs recognised)'); +is( $newnode->safe_psql('postgres', q{ + SELECT count(*) FROM ( + SELECT bt_index_check(c.oid) + FROM pg_class c JOIN pg_index i ON i.indexrelid = c.oid + WHERE i.indrelid = 'hi'::regclass) s}), + '4', 'post-upgrade indexes pass bt_index_check'); + +# The ABA chain on row 1 still scans correctly through a forced index scan: +# k=1 returns exactly the one live row, and its superseded value is gone. +is( $newnode->safe_psql('postgres', q{ + SET enable_seqscan = off; SET enable_bitmapscan = off; + SELECT count(*) FROM hi WHERE k = 1}), + '1', 'post-upgrade index scan returns the ABA row once'); +is( $newnode->safe_psql('postgres', q{ + SET enable_seqscan = off; SET enable_bitmapscan = off; + SELECT count(*) FROM hi WHERE k = 101}), + '0', 'post-upgrade index scan drops the superseded value'); + +$newnode->stop; + +done_testing(); diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c index fef86b4cca368..ab3da64cbcbb9 100644 --- a/src/bin/psql/describe.c +++ b/src/bin/psql/describe.c @@ -6870,7 +6870,7 @@ describeSubscriptions(const char *pattern, bool verbose) printQueryOpt myopt = pset.popt; static const bool translate_columns[] = {false, false, false, false, false, false, false, false, false, false, false, false, false, false, - false, false, false, false, false, false, false}; + false, false, false, false, false, false, false, false}; initPQExpBuffer(&buf); @@ -6943,6 +6943,14 @@ describeSubscriptions(const char *pattern, bool verbose) ", submaxretention AS \"%s\"\n", gettext_noop("Max retention duration")); + appendPQExpBuffer(&buf, + ", (CASE subhotindexedonapply\n" + " WHEN " CppAsString2(LOGICALREP_HOT_INDEXED_OFF) " THEN 'off'\n" + " WHEN " CppAsString2(LOGICALREP_HOT_INDEXED_SUBSET_ONLY) " THEN 'subset_only'\n" + " WHEN " CppAsString2(LOGICALREP_HOT_INDEXED_ALWAYS) " THEN 'always'\n" + " END) AS \"%s\"\n", + gettext_noop("HOT-indexed on apply")); + appendPQExpBuffer(&buf, ", subretentionactive AS \"%s\"\n", gettext_noop("Retention active")); diff --git a/src/include/access/amapi.h b/src/include/access/amapi.h index 7924033353031..17c76e37cdeea 100644 --- a/src/include/access/amapi.h +++ b/src/include/access/amapi.h @@ -29,7 +29,6 @@ typedef struct IndexPath IndexPath; /* Likewise, this file shouldn't depend on execnodes.h. */ typedef struct IndexInfo IndexInfo; - /* * Properties for amproperty API. This list covers properties known to the * core code, but an index AM can define its own properties, by matching the diff --git a/src/include/access/heapam.h b/src/include/access/heapam.h index 5176478c29583..31d3220c5ff95 100644 --- a/src/include/access/heapam.h +++ b/src/include/access/heapam.h @@ -384,12 +384,47 @@ extern TM_Result heap_delete(Relation relation, const ItemPointerData *tid, bool wait, TM_FailureData *tmfd); extern void heap_finish_speculative(Relation relation, const ItemPointerData *tid); extern void heap_abort_speculative(Relation relation, const ItemPointerData *tid); + +/* + * HeapUpdateIndexMode -- + * Three-valued classification returned by HeapUpdateHotAllowable() that + * tells heap_update() whether a HOT update is permitted for this tuple and, + * if so, whether the indexes may be maintained selectively. + * + * HEAP_UPDATE_ALL_INDEXES + * HOT is not allowed; the new tuple must go on its own TID and every + * index receives a fresh entry. This is the classic pre-HOT-indexed + * behavior for updates that modify a non-summarizing indexed attribute. + * + * HEAP_HEAP_ONLY_UPDATE + * Classic HOT update: no non-summarizing indexed attribute changed (only + * summarizing ones, if any), so no index needs a new entry. + * + * HEAP_SELECTIVE_INDEX_UPDATE + * HOT with selective index update: at least one non-summarizing index's + * attribute changed, but the new tuple can still join the HOT chain on + * the same page; only the indexes whose attributes changed receive a new + * entry. As for classic HOT, heap_update() still falls back to a + * non-HOT update if the new tuple does not fit on the page. + * + * Callers should spell the exact mode they care about. HEAP_UPDATE_ALL_INDEXES + * is the zero/false value and doubles as a distinguished "no HOT" sentinel + * (e.g. testing hot_mode != HEAP_UPDATE_ALL_INDEXES), but the values are not + * meaningful as a numeric ordering. + */ +typedef enum HeapUpdateIndexMode +{ + HEAP_UPDATE_ALL_INDEXES = 0, + HEAP_HEAP_ONLY_UPDATE = 1, + HEAP_SELECTIVE_INDEX_UPDATE = 2, +} HeapUpdateIndexMode; + extern TM_Result heap_update(Relation relation, const ItemPointerData *otid, - HeapTuple newtup, - CommandId cid, uint32 options, + HeapTuple newtup, CommandId cid, uint32 options, Snapshot crosscheck, bool wait, - TM_FailureData *tmfd, LockTupleMode *lockmode, - TU_UpdateIndexes *update_indexes); + TM_FailureData *tmfd, LockTupleMode lockmode, + const Bitmapset *modified_idx_attrs, + HeapUpdateIndexMode hot_mode); extern TM_Result heap_lock_tuple(Relation relation, HeapTuple tuple, CommandId cid, LockTupleMode mode, LockWaitPolicy wait_policy, bool follow_updates, @@ -424,7 +459,7 @@ extern bool heap_tuple_needs_eventual_freeze(HeapTupleHeader tuple); extern void simple_heap_insert(Relation relation, HeapTuple tup); extern void simple_heap_delete(Relation relation, const ItemPointerData *tid); extern void simple_heap_update(Relation relation, const ItemPointerData *otid, - HeapTuple tup, TU_UpdateIndexes *update_indexes); + HeapTuple tup, bool *update_all_indexes); extern TransactionId heap_index_delete_tuples(Relation rel, TM_IndexDeleteOp *delstate); @@ -435,7 +470,10 @@ extern void heapam_index_fetch_reset(IndexFetchTableData *scan); extern void heapam_index_fetch_end(IndexFetchTableData *scan); extern bool heap_hot_search_buffer(ItemPointer tid, Relation relation, Buffer buffer, Snapshot snapshot, HeapTuple heapTuple, - bool *all_dead, bool first_call); + bool *all_dead, bool first_call, + bool *hot_indexed_recheck, + uint8 *crossed_bitmap, + bool *prefix_all_dead); extern bool heapam_index_fetch_tuple(struct IndexFetchTableData *scan, ItemPointer tid, Snapshot snapshot, TupleTableSlot *slot, bool *heap_continue, @@ -452,7 +490,8 @@ extern void heap_page_prune_and_freeze(PruneFreezeParams *params, extern void heap_page_prune_execute(Buffer buffer, bool lp_truncate_only, OffsetNumber *redirected, int nredirected, OffsetNumber *nowdead, int ndead, - OffsetNumber *nowunused, int nunused); + OffsetNumber *nowunused, int nunused, + OffsetNumber *stubs, int nstubs); extern void heap_get_root_tuples(Page page, OffsetNumber *root_offsets); extern void log_heap_prune_and_freeze(Relation relation, Buffer buffer, Buffer vmbuffer, uint8 vmflags, @@ -462,7 +501,15 @@ extern void log_heap_prune_and_freeze(Relation relation, Buffer buffer, HeapTupleFreeze *frozen, int nfrozen, OffsetNumber *redirected, int nredirected, OffsetNumber *dead, int ndead, - OffsetNumber *unused, int nunused); + OffsetNumber *unused, int nunused, + OffsetNumber *stubs, int nstubs); + +/* in heap/heapam.c */ + +extern HeapUpdateIndexMode HeapUpdateHotAllowable(Relation relation, + const Bitmapset *modified_idx_attrs); +extern LockTupleMode HeapUpdateDetermineLockmode(Relation relation, + const Bitmapset *modified_idx_attrs); /* in heap/vacuumlazy.c */ extern void heap_vacuum_rel(Relation rel, diff --git a/src/include/access/heapam_xlog.h b/src/include/access/heapam_xlog.h index fdca7d821c87c..8997a505006c4 100644 --- a/src/include/access/heapam_xlog.h +++ b/src/include/access/heapam_xlog.h @@ -273,6 +273,10 @@ typedef struct xl_heap_update * uint16 nunused * OffsetNumber nowunused[nunused] * + * xlhp_prune_items + * uint16 nstubs + * OffsetNumber stubs[2 * nstubs] + * * OffsetNumber frz_offsets[sum([plan.ntuples for plan in plans])] *----------------------------------------------------------------------------- * @@ -341,6 +345,18 @@ typedef struct xl_heap_prune #define XLHP_VM_ALL_VISIBLE (1 << 8) #define XLHP_VM_ALL_FROZEN (1 << 9) +/* + * Indicates that an xlhp_prune_items sub-record with HOT-selectively-updated + * collapse-survivor stubs is present. Each pair (offset, forward) names a + * line pointer to be rewritten in place into an xid-free forwarding stub + * (HEAP_XMIN_INVALID|HEAP_XMAX_INVALID, HEAP_ONLY_TUPLE|HEAP_INDEXED_UPDATED, + * natts==0) whose t_ctid.offnum is set to the forward offset. The stub's + * modified-attrs bitmap is already present in the item on the page (it is the + * pre-prune tuple's inline bitmap, left undisturbed), so it is not carried in + * the WAL. + */ +#define XLHP_HAS_HOT_INDEXED_STUBS (1 << 10) + /* * xlhp_freeze_plan describes how to freeze a group of one or more heap tuples * (appears in xl_heap_prune's xlhp_freeze_plans sub-record) @@ -494,6 +510,7 @@ extern void heap_xlog_deserialize_prune_and_freeze(char *cursor, uint16 flags, OffsetNumber **frz_offsets, int *nredirected, OffsetNumber **redirected, int *ndead, OffsetNumber **nowdead, - int *nunused, OffsetNumber **nowunused); + int *nunused, OffsetNumber **nowunused, + int *nstubs, OffsetNumber **stubs); #endif /* HEAPAM_XLOG_H */ diff --git a/src/include/access/hot_indexed.h b/src/include/access/hot_indexed.h new file mode 100644 index 0000000000000..a5378531e9104 --- /dev/null +++ b/src/include/access/hot_indexed.h @@ -0,0 +1,202 @@ +/*------------------------------------------------------------------------- + * + * hot_indexed.h + * Inline-trailing modified-attributes bitmap for HOT-indexed (HOT/SIU) + * tuples. + * + * A heap tuple produced by a HOT-indexed UPDATE has HEAP_INDEXED_UPDATED set + * in t_infomask2 and carries, appended after its normal attribute data, a + * fixed-size bitmap recording which heap attributes changed at this chain hop + * (relative to the prior chain member). Bit (attnum - 1) corresponds to user + * attribute attnum. + * + * The bitmap is ceil(natts / 8) bytes, where natts is the tuple's own + * attribute count at the time it was written (HeapTupleHeaderGetNatts for a + * live tuple; preserved separately for a stub, see below). Its length is not + * stored as such; it occupies the final HotIndexedBitmapBytes(natts) bytes of + * the line pointer's item and is located via ItemIdGetLength(), past the + * attribute data: routines that deform the tuple stop at natts and never see + * it. + * + * Because ADD COLUMN raises the relation's natts without rewriting existing + * tuples, a chain can hold tuples whose bitmaps were sized for different + * (smaller) natts than the relation has now. Consumers therefore size and + * locate a tuple's bitmap from that tuple's OWN write-time natts + * (HotIndexedTupleBitmapNatts), never from the relation's current natts. + * Bit positions are attribute based and identical across sizes, so a smaller + * bitmap simply ORs into the low bytes of a larger accumulator. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/hot_indexed.h + * + *------------------------------------------------------------------------- + */ +#ifndef HOT_INDEXED_H +#define HOT_INDEXED_H + +#include "access/htup_details.h" + +/* + * Number of bytes in the trailing modified-attrs bitmap for a relation with + * natts user attributes (one bit per attribute, attnum - 1). + */ +static inline Size +HotIndexedBitmapBytes(int natts) +{ + Assert(natts >= 0); + return (Size) ((natts + 7) / 8); +} + +/* + * Read-only pointer to the trailing modified-attrs bitmap of a HOT-indexed + * tuple. item_len is ItemIdGetLength() of the tuple's line pointer; natts is + * the tuple's OWN write-time attribute count (HotIndexedTupleBitmapNatts()), + * NOT the relation's current natts -- see the invariant at the top of this + * file: after ADD COLUMN a chain can hold tuples whose bitmaps were sized for + * a smaller natts, and locating the bitmap from the relation's current natts + * would read past the tuple's data. The caller must have verified that the + * tuple has HEAP_INDEXED_UPDATED set. + */ +static inline const uint8 * +HotIndexedGetModifiedBitmap(const HeapTupleHeaderData *htup, + Size item_len, int natts) +{ + return (const uint8 *) ((const char *) htup + + item_len - HotIndexedBitmapBytes(natts)); +} + +/* Writable variant, for OR-ing the union onto a surviving redirect target. */ +static inline uint8 * +HotIndexedGetModifiedBitmapRW(HeapTupleHeaderData *htup, + Size item_len, int natts) +{ + return (uint8 *) ((char *) htup + + item_len - HotIndexedBitmapBytes(natts)); +} + +/* True if user attribute attnum (1-based) is set in the bitmap. */ +static inline bool +HotIndexedAttrIsModified(const uint8 *bitmap, AttrNumber attnum) +{ + int bit = attnum - 1; + + Assert(attnum >= 1); + return (bitmap[bit / 8] & (1 << (bit % 8))) != 0; +} + +/* Set user attribute attnum (1-based) in the bitmap. */ +static inline void +HotIndexedSetAttrModified(uint8 *bitmap, AttrNumber attnum) +{ + int bit = attnum - 1; + + Assert(attnum >= 1); + bitmap[bit / 8] |= (uint8) (1 << (bit % 8)); +} + +/* OR the first nbytes of src into dst. dst must be at least nbytes long; it + * may be longer (sized for a larger natts) -- bit positions are attribute + * based and identical across sizes, so OR-ing only src's bytes is correct. */ +static inline void +HotIndexedBitmapUnion(uint8 *dst, const uint8 *src, int src_natts) +{ + Size nbytes = HotIndexedBitmapBytes(src_natts); + + for (Size i = 0; i < nbytes; i++) + dst[i] |= src[i]; +} + +/* + * Is every bit set in sub also set in super? sub is sized for sub_natts; + * super must be at least that long. Used by prune to decide a collapse + * survivor is reclaimable: when the attributes a dead member changed are all + * changed again by later hops, every index entry pointing at that member is + * superseded (stale), so it carries no live entry. + */ +static inline bool +HotIndexedBitmapIsSubset(const uint8 *sub, const uint8 *super, int sub_natts) +{ + Size nbytes = HotIndexedBitmapBytes(sub_natts); + + for (Size i = 0; i < nbytes; i++) + if ((sub[i] & ~super[i]) != 0) + return false; + return true; +} + +/* + * Stub line pointers (collapse survivors). + * + * When prune collapses a dead HOT-indexed chain it cannot keep the dead key + * tuples as live data-bearing tuples (their XIDs would hold back + * relfrozenxid). Each preserved dead key tuple is instead converted to an + * xid-free "stub": an LP_NORMAL item whose HeapTupleHeader is frozen and + * marked not-a-real-tuple (HEAP_INDEXED_UPDATED set, HeapTupleHeaderGetNatts + * == 0), whose t_ctid.offnum forwards to the next key tuple on the page, and + * whose payload is the modified-attrs bitmap for the segment it represents -- + * located, like a live tuple's inline bitmap, in the final + * HotIndexedBitmapBytes(natts) bytes of the item, so the same accessors work + * for both. Because the natts field is overwritten with the 0 sentinel, the + * stub's write-time natts (needed to size/locate that bitmap) is preserved in + * the otherwise-unused block-number half of t_ctid; HotIndexedStubGetForward + * reads only the offset half. + * + * A stub is signature-skipped (not visibility-skipped) by every consumer that + * walks LP_NORMAL items. + */ +static inline bool +HotIndexedHeaderIsStub(const HeapTupleHeaderData *tup) +{ + return (tup->t_infomask2 & HEAP_INDEXED_UPDATED) != 0 && + HeapTupleHeaderGetNatts(tup) == 0; +} + +/* Offset of the next key tuple this stub forwards to (same page). */ +static inline OffsetNumber +HotIndexedStubGetForward(const HeapTupleHeaderData *tup) +{ + return ItemPointerGetOffsetNumberNoCheck(&tup->t_ctid); +} + +/* + * Bitmap sizing across the relation's lifetime. + * + * The trailing bitmap is ceil(natts / 8) bytes, where natts is the relation's + * attribute count *at the time the tuple was written*. ADD COLUMN raises the + * relation's natts without rewriting existing tuples, so a chain can hold + * tuples whose bitmaps were sized for different (smaller) natts than the + * relation has now. Every consumer must therefore size and locate a tuple's + * bitmap from that tuple's own write-time natts, never from the relation's + * current natts. + * + * For a live HOT-indexed tuple the write-time natts is HeapTupleHeaderGetNatts + * (a freshly formed UPDATE tuple stores the full relation natts, which is what + * heap_form_hot_indexed_tuple sized the bitmap with). A stub overwrites natts + * with 0 as its sentinel, so it preserves its write-time natts in the unused + * block-number half of t_ctid instead (the offset half holds the forward + * link). + */ +static inline void +HotIndexedStubSetBitmapNatts(HeapTupleHeaderData *tup, int natts) +{ + BlockIdSet(&tup->t_ctid.ip_blkid, (BlockNumber) natts); +} + +static inline int +HotIndexedStubGetBitmapNatts(const HeapTupleHeaderData *tup) +{ + return (int) BlockIdGetBlockNumber(&tup->t_ctid.ip_blkid); +} + +/* Write-time natts of any HOT-indexed item (live tuple or stub). */ +static inline int +HotIndexedTupleBitmapNatts(const HeapTupleHeaderData *tup) +{ + if (HotIndexedHeaderIsStub(tup)) + return HotIndexedStubGetBitmapNatts(tup); + return HeapTupleHeaderGetNatts(tup); +} + +#endif /* HOT_INDEXED_H */ diff --git a/src/include/access/htup_details.h b/src/include/access/htup_details.h index 77a6c48fd711a..7eb7f86f5ed1b 100644 --- a/src/include/access/htup_details.h +++ b/src/include/access/htup_details.h @@ -289,7 +289,13 @@ HEAP_XMAX_IS_KEYSHR_LOCKED(uint16 infomask) * information stored in t_infomask2: */ #define HEAP_NATTS_MASK 0x07FF /* 11 bits for number of attributes */ -/* bits 0x1800 are available */ +#define HEAP_INDEXED_UPDATED 0x0800 /* HOT tuple produced by an UPDATE + * that also changed an indexed + * attribute (HOT/SIU); index scans + * that reach it via a chain recheck + * the arriving leaf key against the + * live tuple. */ +/* bit 0x1000 is available */ #define HEAP_KEYS_UPDATED 0x2000 /* tuple was updated and key cols * modified, or tuple deleted */ #define HEAP_HOT_UPDATED 0x4000 /* tuple was HOT-updated */ diff --git a/src/include/access/relscan.h b/src/include/access/relscan.h index 2ea06a67a6346..fe4469178aa24 100644 --- a/src/include/access/relscan.h +++ b/src/include/access/relscan.h @@ -134,6 +134,45 @@ typedef struct IndexFetchTableData * permitted. */ uint32 flags; + + /* + * Side channel for table AMs whose update chains can reach a different + * set of index-key values than the arriving index entry recorded (heap's + * HOT-selectively-updated chains). Set true by the table AM when the + * walk to the live tuple crossed a HOT/SIU hop after the entry's own + * tuple, meaning the arriving entry's stored key may no longer match the + * live tuple and the index-access layer must recheck it. Left false when + * no such hop was crossed (the entry is definitely current), and always + * false for AMs without such chains. + */ + bool xs_hot_indexed_recheck; + + /* + * Companion to xs_hot_indexed_recheck. xs_hot_indexed_crossed is the + * union of the per-hop modified-attrs bitmaps the walk crossed after the + * entry's own tuple, over heap attribute numbers (bit attnum-1 for a + * 1-based attnum). The index-access layer tests it against the arriving + * index's key columns to judge staleness without a key comparison: any + * overlap means a crossed hop changed one of the index's inputs, so the + * entry is stale. The union is complete (every crossed live hop and + * collapse-survivor stub contributes its bitmap, and collapse only + * reclaims members subsumed by surviving hops), so disjointness reliably + * means fresh. It is NULL for AMs without such chains and is sized by + * the table AM for the heap relation's column count. + */ + uint8 *xs_hot_indexed_crossed; + + /* + * Set by the table AM when it returns a tuple: true iff every chain + * member the walk skipped before reaching the returned (visible) tuple is + * dead to all transactions (below the global xmin horizon). Combined + * with a stale verdict (the crossed-attribute bitmap overlapped the + * index's key columns), this lets the index-access layer + * kill the arriving leaf: no snapshot can reach a matching version + * through it, so it is redundant. AMs without such chains leave it + * false. + */ + bool xs_prefix_all_dead; } IndexFetchTableData; struct IndexScanInstrumentation; @@ -154,6 +193,13 @@ typedef struct IndexScanDescData struct ScanKeyData *keyData; /* array of index qualifier descriptors */ struct ScanKeyData *orderByData; /* array of ordering op descriptors */ bool xs_want_itup; /* caller requests index tuples */ + bool xs_index_only; /* caller is an index-only scan that may + * return tuples without fetching the heap; + * AMs must retain leaf-page pins for such + * scans (VM all-visible / TID-recycle race), + * whereas a plain scan that sets xs_want_itup + * only to inspect the index tuple still + * fetches the heap and may drop pins */ bool xs_temp_snap; /* unregister snapshot at scan end? */ /* signaling to index AM about killing index tuples */ @@ -189,6 +235,20 @@ typedef struct IndexScanDescData bool xs_recheck; /* T means scan keys must be rechecked */ + /* + * T means the index entry that reached xs_heaptid is stale: the HOT chain + * walked to reach the tuple crossed a HOT-selectively-updated (HOT/SIU) + * hop that changed an attribute this index covers, so the arriving + * entry's stored key no longer matches the live tuple. The executor + * drops such a tuple; the row is re-supplied by the fresh entry inserted + * for the new value. Unlike xs_recheck (set by lossy AMs such as GiST + * and GIN), this is computed by the index-access layer by testing the + * heap AM's crossed-attribute bitmap (xs_hot_indexed_crossed) against + * this index's key columns: any overlap means a crossed hop changed one + * of the index's inputs, so the entry is stale. + */ + bool xs_hot_indexed_stale; + /* * When fetching with an ordering operator, the values of the ORDER BY * expressions of the last returned tuple, according to the index. If diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h index f2c36696bcad0..7aa944369dbc9 100644 --- a/src/include/access/tableam.h +++ b/src/include/access/tableam.h @@ -19,6 +19,7 @@ #include "access/relscan.h" #include "access/sdir.h" +#include "access/sysattr.h" #include "access/xact.h" #include "executor/tuptable.h" #include "storage/read_stream.h" @@ -28,6 +29,18 @@ #define DEFAULT_TABLE_ACCESS_METHOD "heap" +/* + * Whole-row sentinel for the in/out modified-attributes set of + * table_tuple_update(). On input the caller supplies the indexed attributes + * whose values changed. A table AM that stored the new tuple as an + * independent version not reachable through the existing index entries (for + * heap, a non-HOT update) adds this whole-row attribute (attribute number 0, + * FirstLowInvalidHeapAttributeNumber convention) on output, signalling that + * every index needs a new entry. Diffing real columns never yields attribute + * 0, so it is unambiguous as this sentinel. + */ +#define TableTupleUpdateAllIndexes (0 - FirstLowInvalidHeapAttributeNumber) + /* GUCs */ extern PGDLLIMPORT char *default_table_access_method; extern PGDLLIMPORT bool synchronize_seqscans; @@ -125,22 +138,6 @@ typedef enum TM_Result TM_WouldBlock, } TM_Result; -/* - * Result codes for table_update(..., update_indexes*..). - * Used to determine which indexes to update. - */ -typedef enum TU_UpdateIndexes -{ - /* No indexed columns were updated (incl. TID addressing of tuple) */ - TU_None, - - /* A non-summarizing indexed column was updated, or the TID has changed */ - TU_All, - - /* Only summarized columns were updated, TID is unchanged */ - TU_Summarizing, -} TU_UpdateIndexes; - /* * When table_tuple_update, table_tuple_delete, or table_tuple_lock fail * because the target tuple is already outdated, they fill in this struct to @@ -488,6 +485,13 @@ typedef struct TableAmRoutine * index_fetch_tuple iff it is guaranteed that no backend needs to see * that tuple. Index AMs can use that to avoid returning that tid in * future searches. + * + * If a tuple is returned and the table AM reached it by walking a HOT + * chain that crossed a HOT-selectively-updated (HOT/SIU) hop after the + * arriving entry's own tuple, it sets scan->xs_hot_indexed_recheck (see + * struct IndexFetchTableData) to tell the index-access layer to recheck + * the arriving leaf key against the live tuple. AMs without such update + * chains leave it false. */ bool (*index_fetch_tuple) (struct IndexFetchTableData *scan, ItemPointer tid, @@ -586,7 +590,7 @@ typedef struct TableAmRoutine bool wait, TM_FailureData *tmfd, LockTupleMode *lockmode, - TU_UpdateIndexes *update_indexes); + Bitmapset **modified_attrs); /* see table_tuple_lock() for reference about parameters */ TM_Result (*tuple_lock) (Relation rel, @@ -1318,11 +1322,20 @@ table_index_fetch_tuple(struct IndexFetchTableData *scan, * returns whether there are table tuple items corresponding to an index * entry. This likely is only useful to verify if there's a conflict in a * unique index. + * + * If keep_slot is non-NULL, on a positive result the function stores the + * fetched tuple into *keep_slot (which must be a valid slot of the + * relation's type) and returns with the slot populated; the caller is + * responsible for clearing the slot. When keep_slot is NULL a temporary + * slot is created internally and dropped before return, matching the + * pre-existing behaviour. */ extern bool table_index_fetch_tuple_check(Relation rel, ItemPointer tid, Snapshot snapshot, - bool *all_dead); + bool *all_dead, + bool *hot_indexed_recheck_out, + TupleTableSlot *keep_slot); /* ------------------------------------------------------------------------ @@ -1573,12 +1586,20 @@ table_tuple_delete(Relation rel, ItemPointer tid, CommandId cid, * TABLE_UPDATE_NO_LOGICAL -- force-disables the emitting of logical * decoding information for the tuple. * + * In parameters: + * modified_attrs - in/out; on input, the set of indexed attributes whose + * values changed (FirstLowInvalidHeapAttributeNumber convention). A + * table AM may use this to choose between HOT and non-HOT storage of the + * new tuple. On output the AM adds the whole-row attribute + * (TableTupleUpdateAllIndexes) iff it stored the new tuple as an + * independent version requiring a fresh entry in every index; otherwise + * the caller consults each index's own attributes against this set to + * decide per index (the standard HOT / selective-index-update cases). + * * Output parameters: * slot - newly constructed tuple data to store * tmfd - filled in failure cases (see below) * lockmode - filled with lock mode acquired on tuple - * update_indexes - in success cases this is set if new index entries - * are required for this tuple; see TU_UpdateIndexes * * Normal, successful return value is TM_Ok, which means we did actually * update it. Failure return codes are TM_SelfModified, TM_Updated, and @@ -1599,12 +1620,20 @@ table_tuple_update(Relation rel, ItemPointer otid, TupleTableSlot *slot, CommandId cid, uint32 options, Snapshot snapshot, Snapshot crosscheck, bool wait, TM_FailureData *tmfd, LockTupleMode *lockmode, - TU_UpdateIndexes *update_indexes) + Bitmapset **modified_attrs) { + /* + * *modified_attrs may already contain TableTupleUpdateAllIndexes on input + * when an index expression references a whole-row Var (see the note in + * ExecUpdateAct); the AM also uses the sentinel as an output signal. Both + * mean "update all indexes", so only require modified_attrs itself to be + * non-NULL for the in-tree heap AM, which dereferences it unconditionally. + */ + Assert(modified_attrs != NULL); return rel->rd_tableam->tuple_update(rel, otid, slot, cid, options, snapshot, crosscheck, - wait, tmfd, - lockmode, update_indexes); + wait, tmfd, lockmode, + modified_attrs); } /* @@ -2089,7 +2118,7 @@ extern void simple_table_tuple_delete(Relation rel, ItemPointer tid, Snapshot snapshot); extern void simple_table_tuple_update(Relation rel, ItemPointer otid, TupleTableSlot *slot, Snapshot snapshot, - TU_UpdateIndexes *update_indexes); + Bitmapset **modified_attrs); /* ---------------------------------------------------------------------------- diff --git a/src/include/catalog/catversion.h b/src/include/catalog/catversion.h index ba4e2b4d908f9..d5885a2edc06f 100644 --- a/src/include/catalog/catversion.h +++ b/src/include/catalog/catversion.h @@ -57,6 +57,9 @@ */ /* yyyymmddN */ +/* XXX bump catversion -- this commit adds a catalog column + * (subhotindexedonapply) and touches pg_subscription; the committer + * sets the real value at commit time to avoid needless rebase churn. */ #define CATALOG_VERSION_NO 202607091 #endif diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat index 3cb84359adf06..175fa88eb57de 100644 --- a/src/include/catalog/pg_proc.dat +++ b/src/include/catalog/pg_proc.dat @@ -5594,6 +5594,29 @@ proname => 'pg_stat_get_tuples_hot_updated', provolatile => 's', proparallel => 'r', prorettype => 'int8', proargtypes => 'oid', prosrc => 'pg_stat_get_tuples_hot_updated' }, +{ oid => '9953', + descr => 'statistics: number of tuples updated via HOT-indexed (Selective Index Update)', + proname => 'pg_stat_get_tuples_hot_indexed_updated', provolatile => 's', + proparallel => 'r', prorettype => 'int8', proargtypes => 'oid', + prosrc => 'pg_stat_get_tuples_hot_indexed_updated' }, +{ oid => '9956', + descr => 'statistics: number of HOT-indexed updates that skipped this index', + proname => 'pg_stat_get_tuples_hot_indexed_updated_skipped', provolatile => 's', + proparallel => 'r', prorettype => 'int8', proargtypes => 'oid', + prosrc => 'pg_stat_get_tuples_hot_indexed_upd_skipped' }, +{ oid => '9957', + descr => 'statistics: number of HOT-indexed updates that inserted into this index', + proname => 'pg_stat_get_tuples_hot_indexed_updated_matched', provolatile => 's', + proparallel => 'r', prorettype => 'int8', proargtypes => 'oid', + prosrc => 'pg_stat_get_tuples_hot_indexed_upd_matched' }, +{ oid => '9955', + descr => 'HOT-indexed structural stats: HOT-indexed versions and chain lengths', + proname => 'pg_relation_hot_indexed_stats', provolatile => 'v', + proparallel => 'r', prorettype => 'record', proargtypes => 'regclass', + proallargtypes => '{regclass,int8,int8,float8,int8}', + proargmodes => '{i,o,o,o,o}', + proargnames => '{relation,n_hot_indexed,n_chains,avg_chain_len,max_chain_len}', + prosrc => 'pg_relation_hot_indexed_stats' }, { oid => '6217', descr => 'statistics: number of tuples updated onto a new page', proname => 'pg_stat_get_tuples_newpage_updated', provolatile => 's', @@ -6179,6 +6202,11 @@ proname => 'pg_stat_get_xact_tuples_hot_updated', provolatile => 'v', proparallel => 'r', prorettype => 'int8', proargtypes => 'oid', prosrc => 'pg_stat_get_xact_tuples_hot_updated' }, +{ oid => '9954', + descr => 'statistics: number of HOT-indexed tuple updates in current transaction', + proname => 'pg_stat_get_xact_tuples_hot_indexed_updated', provolatile => 'v', + proparallel => 'r', prorettype => 'int8', proargtypes => 'oid', + prosrc => 'pg_stat_get_xact_tuples_hot_indexed_updated' }, { oid => '6218', descr => 'statistics: number of tuples updated onto a new page in current transaction', proname => 'pg_stat_get_xact_tuples_newpage_updated', provolatile => 'v', diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h index 65ce8e145fb04..9be556b884fea 100644 --- a/src/include/catalog/pg_subscription.h +++ b/src/include/catalog/pg_subscription.h @@ -92,6 +92,10 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW * exceeded max_retention_duration, when * defined */ + char subhotindexedonapply; /* Per-subscription gating of the HOT- + * indexed apply path. See + * LOGICALREP_HOT_INDEXED_* constants. */ + Oid subserver BKI_LOOKUP_OPT(pg_foreign_server); /* If connection uses * server */ @@ -173,6 +177,9 @@ typedef struct Subscription * exceeded max_retention_duration, when * defined */ Oid conflictlogrelid; /* conflict log table Oid */ + char hotindexedonapply; /* Per-subscription gating of the + * HOT-indexed apply path. See + * LOGICALREP_HOT_INDEXED_* constants. */ char *conninfo; /* Connection string to the publisher */ char *slotname; /* Name of the replication slot */ char *synccommit; /* Synchronous commit setting for worker */ @@ -220,6 +227,26 @@ typedef struct Subscription */ #define LOGICALREP_STREAM_PARALLEL 'p' +/* + * Per-subscription gating of the HOT-indexed apply path. Recorded as a + * single-character code in pg_subscription.subhotindexedonapply. + * + * 'o' -- OFF: force non-HOT on apply whenever the subscriber carries any + * indexed attribute beyond the primary key. Matches the conservative + * behaviour before this option was introduced. + * 's' -- SUBSET_ONLY (default for freshly created subscriptions): allow the + * HOT-indexed apply path when the subscriber's full indexed-attr set is + * a subset of its primary-key attrs (which covers the no-secondary- + * index case as well). Safe on matching schemas; falls back to non-HOT + * when the subscriber adds indexes beyond the primary key. + * 'a' -- ALWAYS: unconditional HOT-indexed eligibility on apply. The + * operator accepts responsibility for keeping subscriber and publisher + * indexed-attr sets compatible. + */ +#define LOGICALREP_HOT_INDEXED_OFF 'o' +#define LOGICALREP_HOT_INDEXED_SUBSET_ONLY 's' +#define LOGICALREP_HOT_INDEXED_ALWAYS 'a' + #endif /* EXPOSE_TO_CLIENT_CODE */ extern Subscription *GetSubscription(Oid subid, bool missing_ok, diff --git a/src/include/executor/executor.h b/src/include/executor/executor.h index 1798e6027d4c2..f7211ececffa9 100644 --- a/src/include/executor/executor.h +++ b/src/include/executor/executor.h @@ -615,6 +615,10 @@ extern TupleDesc ExecCleanTypeFromTL(List *targetList); extern TupleDesc ExecTypeFromExprList(List *exprList); extern void ExecTypeSetColNames(TupleDesc typeInfo, List *namesList); extern void UpdateChangedParamSet(PlanState *node, Bitmapset *newchg); +extern Bitmapset *ExecCompareSlotAttrs(Bitmapset *attrs, + TupleDesc tupdesc, + TupleTableSlot *old_tts, + TupleTableSlot *new_tts); typedef struct TupOutputState { @@ -750,11 +754,13 @@ extern Bitmapset *ExecGetAllUpdatedCols(ResultRelInfo *relinfo, EState *estate); */ extern void ExecOpenIndices(ResultRelInfo *resultRelInfo, bool speculative); extern void ExecCloseIndices(ResultRelInfo *resultRelInfo); +extern void ExecSetIndexUnchanged(ResultRelInfo *resultRelInfo, + const Bitmapset *modified_idx_attrs); /* flags for ExecInsertIndexTuples */ #define EIIT_IS_UPDATE (1<<0) #define EIIT_NO_DUPE_ERROR (1<<1) -#define EIIT_ONLY_SUMMARIZING (1<<2) +#define EIIT_IS_HOT_INDEXED (1<<2) extern List *ExecInsertIndexTuples(ResultRelInfo *resultRelInfo, EState *estate, uint32 flags, TupleTableSlot *slot, List *arbiterIndexes, @@ -814,5 +820,8 @@ extern ResultRelInfo *ExecLookupResultRelByOid(ModifyTableState *node, Oid resultoid, bool missing_ok, bool update_cache); +extern Bitmapset *ExecUpdateModifiedIdxAttrs(ResultRelInfo *relinfo, + TupleTableSlot *old_tts, + TupleTableSlot *new_tts); #endif /* EXECUTOR_H */ diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h index e64fd8c7ea300..2e3712205a409 100644 --- a/src/include/nodes/execnodes.h +++ b/src/include/nodes/execnodes.h @@ -216,10 +216,18 @@ typedef struct IndexInfo bool ii_NullsNotDistinct; /* is it valid for inserts? */ bool ii_ReadyForInserts; - /* IndexUnchanged status determined yet? */ - bool ii_CheckedUnchanged; - /* aminsert hint, cached for retail inserts */ + /* + * aminsert hint: index logically unchanged by UPDATE? Narrow rule: key + * columns only; INCLUDE columns and the partial-index predicate are not + * considered (expression indexes are treated conservatively). + */ bool ii_IndexUnchanged; + /* + * selective UPDATE: does this index need a new entry? Wide rule: true if + * any key, INCLUDE, expression, or predicate column it references changed + * (or the AM stored an independent new version). + */ + bool ii_IndexNeedsUpdate; /* are we doing a concurrent index build? */ bool ii_Concurrent; /* did we detect any broken HOT chains? */ diff --git a/src/include/pgstat.h b/src/include/pgstat.h index 58a44857f1311..28b79370f0d7d 100644 --- a/src/include/pgstat.h +++ b/src/include/pgstat.h @@ -151,7 +151,19 @@ typedef struct PgStat_TableCounts PgStat_Counter tuples_updated; PgStat_Counter tuples_deleted; PgStat_Counter tuples_hot_updated; + PgStat_Counter tuples_hot_indexed_updated; PgStat_Counter tuples_newpage_updated; + + /* + * Per-index HOT-indexed update counters. Maintained on pgstat entries + * keyed on an index oid, not on the owning table's entry. They count how + * many HOT-indexed updates skipped this index (key unchanged) vs. + * inserted a fresh entry (key changed). Summarizing indexes do not + * contribute to either counter. + */ + PgStat_Counter tuples_hot_indexed_upd_skipped; + PgStat_Counter tuples_hot_indexed_upd_matched; + bool truncdropped; PgStat_Counter delta_live_tuples; @@ -218,7 +230,7 @@ typedef struct PgStat_TableXactStatus * ------------------------------------------------------------ */ -#define PGSTAT_FILE_FORMAT_ID 0x01A5BCBC +#define PGSTAT_FILE_FORMAT_ID 0x01A5BCBD typedef struct PgStat_ArchiverStats { @@ -460,8 +472,13 @@ typedef struct PgStat_StatTabEntry PgStat_Counter tuples_updated; PgStat_Counter tuples_deleted; PgStat_Counter tuples_hot_updated; + PgStat_Counter tuples_hot_indexed_updated; PgStat_Counter tuples_newpage_updated; + /* Per-index HOT-indexed update counters (see PgStat_TableCounts). */ + PgStat_Counter tuples_hot_indexed_upd_skipped; + PgStat_Counter tuples_hot_indexed_upd_matched; + PgStat_Counter live_tuples; PgStat_Counter dead_tuples; PgStat_Counter mod_since_analyze; @@ -752,6 +769,16 @@ extern void pgstat_report_analyze(Relation rel, if (pgstat_should_count_relation(rel)) \ (rel)->pgstat_info->counts.tuples_returned += (n); \ } while (0) +#define pgstat_count_hot_indexed_upd_skipped(rel) \ + do { \ + if (pgstat_should_count_relation(rel)) \ + (rel)->pgstat_info->counts.tuples_hot_indexed_upd_skipped++;\ + } while (0) +#define pgstat_count_hot_indexed_upd_matched(rel) \ + do { \ + if (pgstat_should_count_relation(rel)) \ + (rel)->pgstat_info->counts.tuples_hot_indexed_upd_matched++;\ + } while (0) #define pgstat_count_buffer_read(rel) \ do { \ if (pgstat_should_count_relation(rel)) \ @@ -764,7 +791,7 @@ extern void pgstat_report_analyze(Relation rel, } while (0) extern void pgstat_count_heap_insert(Relation rel, PgStat_Counter n); -extern void pgstat_count_heap_update(Relation rel, bool hot, bool newpage); +extern void pgstat_count_heap_update(Relation rel, bool hot, bool hot_indexed, bool newpage); extern void pgstat_count_heap_delete(Relation rel); extern void pgstat_count_truncate(Relation rel); extern void pgstat_update_heap_dead_tuples(Relation rel, int delta); diff --git a/src/include/replication/logicalworker.h b/src/include/replication/logicalworker.h index 7d748a28da82b..c9df7d32f2d73 100644 --- a/src/include/replication/logicalworker.h +++ b/src/include/replication/logicalworker.h @@ -24,6 +24,14 @@ extern void SequenceSyncWorkerMain(Datum main_arg); extern bool IsLogicalWorker(void); extern bool IsLogicalParallelApplyWorker(void); +/* + * Accessor for the cached hot_indexed_on_apply mode of the current apply + * worker's subscription. Returns a LOGICALREP_HOT_INDEXED_* code (see + * catalog/pg_subscription.h). Non-apply processes always see + * LOGICALREP_HOT_INDEXED_OFF. + */ +extern char GetHotIndexedApplyMode(void); + extern void HandleParallelApplyMessageInterrupt(void); extern void ProcessParallelApplyMessages(void); diff --git a/src/include/storage/itemptr.h b/src/include/storage/itemptr.h index 8b3392dbefef1..cf6948125e3f7 100644 --- a/src/include/storage/itemptr.h +++ b/src/include/storage/itemptr.h @@ -69,6 +69,65 @@ typedef ItemPointerData *ItemPointer; #define MovedPartitionsOffsetNumber 0xfffd #define MovedPartitionsBlockNumber InvalidBlockNumber +/* + * SIU (selective index update / HOT-indexed) "may-be-stale" marker bit. + * + * Set ONLY on a TID stored inside an INDEX TUPLE by a HOT-indexed update's + * fresh entry -- never on a heap tuple's own t_ctid, and never on a plain + * ItemPointer value passed around the executor/table AM (those still mean + * exactly what they always have). Such an entry points at a non-root, + * heap-only chain member rather than at the chain's root the way every + * other index entry does, so a bitmap scan must not trust a raw offset- + * level match against it: BitmapAnd/BitmapOr intersect two indexes' TID + * sets at block+offset granularity, and this entry's offset will not agree + * with an unrelated, unchanged index's entry for the same logical row (see + * the HOT-indexed design notes for the full false-negative scenario). A + * caller that finds this bit set on a heap-item TID must fall back to a + * page-level (lossy) bitmap contribution instead of an exact one, forcing + * the existing heap-side crossed-attribute recheck to make the final call. + * + * Bit 14 is free at every supported BLCKSZ: MaxOffsetNumber needs at most + * 14 bits (BLCKSZ=32KB, the largest configurable block size) to represent + * every legal offset, leaving bits 14-15 unused by the real offset value; + * bit 15 is left alone because it is set in both of the sentinels above + * (SpecTokenOffsetNumber, MovedPartitionsOffsetNumber), and staying clear + * of that neighborhood avoids ever having to reason about the interaction. + * nbtree separately overloads the top 4 bits of a *pivot or posting* tuple's + * own t_tid (BT_STATUS_OFFSET_MASK, gated by INDEX_ALT_TID_MASK in t_info) + * to mean something else entirely -- this bit is never read or set on that + * field; it only ever applies to a genuine heap-row TID (including each + * individual entry of a posting list's heap-TID array, which are ordinary + * TIDs despite living inside a posting tuple). + * + * IMPORTANT: never mask this bit off unconditionally. Both sentinels above + * (SpecTokenOffsetNumber = 0xfffe, MovedPartitionsOffsetNumber = 0xfffd) + * already have bit 14 set as part of their own value; blindly clearing it + * would corrupt them into an unrecognized offset (this was caught by the + * isolation suite: partition-key-update / MERGE tests silently stopped + * detecting a concurrently-moved tuple). A flagged real offset is always + * well below the sentinel range even at the largest configurable BLCKSZ + * (worst case 0x6000 at BLCKSZ=32KB, vs. sentinels at 0xfffd/0xfffe), so + * ItemPointerOffsetNumberStrip only clears the bit when the raw value is + * below that range; a sentinel passes through unchanged. + */ +#define ItemPointerSIUMaybeStaleFlag ((OffsetNumber) (1 << 14)) + +#define ItemPointerOffsetNumberMask ((OffsetNumber) ~ItemPointerSIUMaybeStaleFlag) + +/* + * ItemPointerOffsetNumberStrip + * Strip the SIU may-be-stale bit from a raw ip_posid value, UNLESS that + * value is one of the reserved sentinels (SpecTokenOffsetNumber, + * MovedPartitionsOffsetNumber), which must pass through untouched. + */ +static inline OffsetNumber +ItemPointerOffsetNumberStrip(OffsetNumber raw) +{ + if (raw >= MovedPartitionsOffsetNumber) /* covers both sentinels */ + return raw; + return (OffsetNumber) (raw & ItemPointerOffsetNumberMask); +} + /* ---------------- * support functions @@ -108,7 +167,11 @@ ItemPointerGetBlockNumber(const ItemPointerData *pointer) /* * ItemPointerGetOffsetNumberNoCheck - * Returns the offset number of a disk item pointer. + * Returns the offset number of a disk item pointer, INCLUDING the SIU + * may-be-stale bit if set. Only appropriate for code that explicitly + * wants the raw stored value (e.g. bitmap-scan code deciding whether to + * trust an exact match); anything that will use the result to locate a + * line pointer on a page must use ItemPointerGetOffsetNumber instead. */ static inline OffsetNumber ItemPointerGetOffsetNumberNoCheck(const ItemPointerData *pointer) @@ -118,13 +181,53 @@ ItemPointerGetOffsetNumberNoCheck(const ItemPointerData *pointer) /* * ItemPointerGetOffsetNumber - * As above, but verifies that the item pointer looks valid. + * As above, but verifies that the item pointer looks valid, AND strips + * the SIU may-be-stale bit (see ItemPointerSIUMaybeStaleFlag) so every + * ordinary consumer -- anything that turns this into a page lookup, a + * comparison against another TID, or a value handed back to a caller + * that predates this bit's existence -- keeps seeing exactly the real + * offset it always has. Only the handful of call sites that need to + * notice the flag (currently: each amgetbitmap implementation) use + * ItemPointerGetOffsetNumberNoCheck / ItemPointerIsSIUMaybeStale instead. */ static inline OffsetNumber ItemPointerGetOffsetNumber(const ItemPointerData *pointer) { Assert(ItemPointerIsValid(pointer)); - return ItemPointerGetOffsetNumberNoCheck(pointer); + return ItemPointerOffsetNumberStrip(ItemPointerGetOffsetNumberNoCheck(pointer)); +} + +/* + * ItemPointerIsSIUMaybeStale + * True iff this heap-row TID (as stored in an index tuple) was + * flagged by a HOT-indexed fresh-entry insert as pointing at a + * non-root chain member; see ItemPointerSIUMaybeStaleFlag. + */ +static inline bool +ItemPointerIsSIUMaybeStale(const ItemPointerData *pointer) +{ + return (pointer->ip_posid & ItemPointerSIUMaybeStaleFlag) != 0; +} + +/* + * ItemPointerSetSIUMaybeStale + * Set the SIU may-be-stale bit on a heap-row TID that will be stored + * in an index tuple. Must only be called on a genuine heap TID, not + * on a repurposed nbtree pivot/posting t_tid. + */ +static inline void +ItemPointerSetSIUMaybeStale(ItemPointerData *pointer) +{ + Assert(pointer); + /* + * The marker must only ever land on a genuine, in-range heap offset: a + * value in the sentinel range (SpecTokenOffsetNumber / MovedPartitions- + * OffsetNumber) already has bit 14 set and would be corrupted, and + * ItemPointerOffsetNumberStrip deliberately leaves such values untouched, + * so a later strip would not clear the flag. Catch misuse in debug builds. + */ + Assert(ItemPointerGetOffsetNumberNoCheck(pointer) < MovedPartitionsOffsetNumber); + pointer->ip_posid |= ItemPointerSIUMaybeStaleFlag; } /* diff --git a/src/include/utils/rel.h b/src/include/utils/rel.h index 89c159b133fad..304783f0bbc93 100644 --- a/src/include/utils/rel.h +++ b/src/include/utils/rel.h @@ -162,8 +162,9 @@ typedef struct RelationData Bitmapset *rd_keyattr; /* cols that can be ref'd by foreign keys */ Bitmapset *rd_pkattr; /* cols included in primary key */ Bitmapset *rd_idattr; /* included in replica identity index */ - Bitmapset *rd_hotblockingattr; /* cols blocking HOT update */ + Bitmapset *rd_indexedattr; /* all cols referenced by indexes */ Bitmapset *rd_summarizedattr; /* cols indexed by summarizing indexes */ + Bitmapset *rd_exprindexattr; /* cols referenced by expression indexes */ PublicationDesc *rd_pubdesc; /* publication descriptor, or NULL */ @@ -217,6 +218,16 @@ typedef struct RelationData Oid *rd_indcollation; /* OIDs of index collations */ bytea **rd_opcoptions; /* parsed opclass-specific options */ + /* + * Bitmap of heap attribute numbers referenced by this index (simple keys, + * INCLUDE columns, expression columns, and partial-index predicate + * columns), offset by FirstLowInvalidHeapAttributeNumber. Lazily built by + * RelationGetIndexedAttrs() and cached in rd_indexcxt. Consumers must + * bms_copy before relying on the pointer beyond any potential + * AcceptInvalidationMessages() call. + */ + Bitmapset *rd_indattr; + /* * rd_amcache is available for index and table AMs to cache private data * about the relation. This must be just a cache since it may get reset diff --git a/src/include/utils/relcache.h b/src/include/utils/relcache.h index 89c27aa1529f9..a381aa3e095ae 100644 --- a/src/include/utils/relcache.h +++ b/src/include/utils/relcache.h @@ -62,6 +62,19 @@ extern List *RelationGetDummyIndexExpressions(Relation relation); extern List *RelationGetIndexPredicate(Relation relation); extern bytea **RelationGetIndexAttOptions(Relation relation, bool copy); +/* + * RelationGetIndexedAttrs -- return a freshly-palloc'd Bitmapset of every + * heap attribute this index references, via keys, INCLUDE columns, + * expressions, or partial-index predicates. + * + * The argument must be an index Relation (not its owning heap). Attribute + * numbers are offset by FirstLowInvalidHeapAttributeNumber. The result is + * palloc'd in the caller's context; bms_free when done. The relcache + * caches its own copy in rd_indexcxt so subsequent calls only pay for the + * final bms_copy. + */ +extern Bitmapset *RelationGetIndexedAttrs(Relation indexRel); + /* * Which set of columns to return by RelationGetIndexAttrBitmap. */ @@ -70,13 +83,17 @@ typedef enum IndexAttrBitmapKind INDEX_ATTR_BITMAP_KEY, INDEX_ATTR_BITMAP_PRIMARY_KEY, INDEX_ATTR_BITMAP_IDENTITY_KEY, - INDEX_ATTR_BITMAP_HOT_BLOCKING, + INDEX_ATTR_BITMAP_INDEXED, INDEX_ATTR_BITMAP_SUMMARIZED, + INDEX_ATTR_BITMAP_EXPRESSION, } IndexAttrBitmapKind; extern Bitmapset *RelationGetIndexAttrBitmap(Relation relation, IndexAttrBitmapKind attrKind); +extern const Bitmapset *RelationGetIndexAttrBitmapNoCopy(Relation relation, + IndexAttrBitmapKind attrKind); + extern Bitmapset *RelationGetIdentityKeyBitmap(Relation relation); extern void RelationGetExclusionInfo(Relation indexRelation, diff --git a/src/test/benchmarks/siu/README.md b/src/test/benchmarks/siu/README.md new file mode 100644 index 0000000000000..9a62d268ad7de --- /dev/null +++ b/src/test/benchmarks/siu/README.md @@ -0,0 +1,82 @@ +# hot-indexed (HOT-indexed) A/B benchmark harness + +Two postgres variants, identical pgdata layouts, pgbench workloads +exercising classic HOT, non-HOT, and HOT-indexed paths. + +## Contents + +- `scripts/build.sh` -- builds two postgres variants (`master` = tepid's + merge-base with origin/master; `tepid` = the branch under test). Requires + a writable benchmark root via `BENCH` (default `/scratch/tepid-bench`). +- `scripts/run.sh` -- A/B driver. Runs `simple_update` (pgbench -N), + `hot_indexed_update`, `hot_indexed_mixed`, `read_indexscan`, and `wide_N` + for N in `$WIDE_STEPS`. + Collects TPS, latency, WAL bytes, HOT update count, pre/post heap and + index size, peak CPU% and RSS. Writes a CSV per run to `$BENCH/results/`. +- `scripts/soak.sh` -- long-running single-workload driver that samples + TPS/HOT%/WAL/bloat every `$SAMPLE` seconds under `$DURATION` seconds + of constant pressure, per variant. +- `scripts/bloat.sh` -- single-variant bloat probe. Runs update+vacuum cycles + on a table whose changed column and an unchanged column are both indexed, and + reports (via pgstattuple) that the changed index stays bounded with periodic + VACUUM but grows unbounded without it, plus the skip count showing + HOT-indexed updates avoiding the unchanged index. Spins its own throwaway + cluster, so it does not touch the A/B pgdata. +- `scripts/hot_indexed_update.sql` -- `UPDATE siu_table SET b = rand WHERE a = rand`. +- `scripts/hot_indexed_mixed.sql` -- 80 % SELECT by PK + 20 % indexed-col UPDATE. +- `scripts/read_indexscan.sql` -- read-only btree index scans on a freshly + reset `siu_table` (no stale entries); confirms the HOT-indexed read path + adds no per-scan overhead, since the crossed-attribute bitmap decides + staleness without a key comparison and the scan requests no index tuple. +- `scripts/wide_update.sql` -- driver script for the wide-table workload; + the `SET` clause is built at run time from `$WIDE_STEPS`. + +## Running + +``` +# Build both variants (run once per benchmark host) +REPO=$HOME/ws/postgres/tepid BENCH=/scratch/tepid-bench \ + ./scripts/build.sh + +# Standard A/B +SCALE=20 CLIENTS=16 THREADS=8 DURATION=120 \ + WIDE_COLS=16 WIDE_STEPS=0,1,2,4,8,16 \ + ./scripts/run.sh + +# Soak +SCALE=50 CLIENTS=16 THREADS=8 DURATION=900 SAMPLE=60 \ + ./scripts/soak.sh + +# Bloat probe (single variant; defaults to the tepid build) +BENCH=/scratch/tepid-bench ROWS=5000 CYCLES=8 UPDATES=20 \ + ./scripts/bloat.sh +``` + +## Env vars + +``` +REPO path to postgres source (has .git) +BENCH bench root (install prefixes, build trees, results) +SCALE pgbench -s (also drives siu_table row count = SCALE*100k) +CLIENTS pgbench -c +THREADS pgbench -j +DURATION seconds per workload +WIDE_COLS number of indexed int columns in wide_table (default 16) +WIDE_STEPS comma-separated list of columns-modified values to exercise + (default 0,1,4,8,16) +PORT postgres port for the bench servers +SHARED_BUFFERS postgresql.conf setting (default 512MB) +MASTER_REV revision for the master variant (default: tepid's merge-base + with origin/master) +TEPID_REV revision for the tepid variant (default: tepid) + +bloat.sh only: +BINDIR postgres bin dir to test (default: tepid variant under $BENCH) +ROWS seeded rows (default 5000) +CYCLES update+vacuum cycles (default 8) +UPDATES updates per row per cycle (default 20) +``` + +The scripts are portable between Linux and FreeBSD; the CPU/RSS sampler +uses `ps -o pcpu=,rss= --ppid LEADER -p LEADER` (Linux) or `pgrep -P` + +per-pid `ps` (FreeBSD) -- peak values are approximate. diff --git a/src/test/benchmarks/siu/scripts/bit14_ab.sh b/src/test/benchmarks/siu/scripts/bit14_ab.sh new file mode 100644 index 0000000000000..bf97f93c5df7b --- /dev/null +++ b/src/test/benchmarks/siu/scripts/bit14_ab.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# bit14_ab.sh -- A/B pgbench harness for the ItemPointerSIUMaybeStaleFlag +# ("bit14") fix specifically. Uses the same two variants build.sh already +# produced under $BENCH/{master,tepid}: "master" = bit14 REVERTED (tepid- +# bit14-off), "tepid" = bit14 present (tepid-bit14-on). Both otherwise +# identical -- this isolates exactly the fix's cost/benefit, not the SIU +# feature's own win (that's what run.sh's other workloads already cover). +# +# Runs bitmap_and_mixed.sql (80% BitmapAnd reads across two untouched +# indexes, 20% HOT-indexed updates on a third index on the same table/pages) +# A/B alternating, N iterations each, writing raw per-iteration TPS + latency +# rows to a CSV (compute the median downstream from the collected rows). +set -euo pipefail + +BENCH=${BENCH:-/scratch/siu-bench} +SCALE=${SCALE:-10} +CLIENTS=${CLIENTS:-16} +THREADS=${THREADS:-8} +DURATION=${DURATION:-60} +ITERATIONS=${ITERATIONS:-5} +PORT=${PORT:-57481} +SHARED_BUFFERS=${SHARED_BUFFERS:-4GB} + +TS=$(date -u +%Y%m%dT%H%M%SZ) +OUT=$BENCH/results/bit14_$TS.csv +LOGDIR=$BENCH/logs/bit14_$TS +mkdir -p "$LOGDIR" "$BENCH/results" +echo "iteration,variant,tps,latency_avg_ms" > "$OUT" +echo "=== bit14 A/B run $TS -> $OUT (scale=$SCALE clients=$CLIENTS threads=$THREADS duration=${DURATION}s iterations=$ITERATIONS)" + +bin_of() { echo "$BENCH/$1/usr/local/pgsql/bin"; } +LD_of() { + local base=$BENCH/$1/usr/local/pgsql + if [ -d "$base/lib64" ]; then echo "$base/lib64"; else echo "$base/lib"; fi +} +psql_as() { + local v=$1; shift + LD_LIBRARY_PATH="$(LD_of "$v")" "$(bin_of "$v")/psql" -h /tmp -p "$PORT" -U postgres -X "$@" +} +pgbench_as() { + local v=$1; shift + LD_LIBRARY_PATH="$(LD_of "$v")" "$(bin_of "$v")/pgbench" -h /tmp -p "$PORT" -U postgres "$@" +} + +start_pg() { + local v=$1 + local datadir=$BENCH/_data_bit14_$v + rm -rf "$datadir" + mkdir -p "$datadir" + LD_LIBRARY_PATH="$(LD_of "$v")" "$(bin_of "$v")/initdb" -D "$datadir" -U postgres >"$LOGDIR/initdb_$v.log" 2>&1 + cat >> "$datadir/postgresql.conf" </dev/null + sleep 2 +} + +stop_pg() { + local v=$1 + local datadir=$BENCH/_data_bit14_$v + LD_LIBRARY_PATH="$(LD_of "$v")" "$(bin_of "$v")/pg_ctl" -D "$datadir" stop -m fast >/dev/null 2>&1 || true +} + +seed() { + local v=$1 + local rows=$((SCALE * 100000)) + psql_as "$v" <"$log" 2>&1 || echo " iter $iter $v: pgbench exited nonzero, see $log" >&2 + local tps lat + tps=$(grep -oP 'tps = \K[0-9.]+' "$log" | tail -1) + lat=$(grep -oP 'latency average = \K[0-9.]+' "$log" | tail -1) + if [ -z "$tps" ] || [ -z "$lat" ]; then + # Do not write a partial/failed iteration into the result set: an NA row + # would silently pollute the A/B comparison. Count and report it instead. + echo " iter $iter $v FAILED (no tps/lat in $log); skipping CSV row" >&2 + stop_pg "$v" + return + fi + echo "$iter,$v,$tps,$lat" >> "$OUT" + echo " iter $iter $v tps=$tps lat=${lat}ms" + stop_pg "$v" +} + +# A/B alternate, not batched, to cancel drift. +for i in $(seq 1 "$ITERATIONS"); do + run_iter "$i" master # bit14-off + run_iter "$i" tepid # bit14-on +done + +echo "=== done -> $OUT" diff --git a/src/test/benchmarks/siu/scripts/bitmap_and_mixed.sql b/src/test/benchmarks/siu/scripts/bitmap_and_mixed.sql new file mode 100644 index 0000000000000..93a0e9277250f --- /dev/null +++ b/src/test/benchmarks/siu/scripts/bitmap_and_mixed.sql @@ -0,0 +1,25 @@ +-- bitmap_and_mixed: quantifies the cost of the bit14 SIU may-be-stale fix +-- (see storage/itemptr.h) specifically -- not the SIU write-path win the +-- other workloads measure. +-- +-- 80% of transactions force a BitmapAnd across siu_b and siu_d (both never +-- updated by this workload) with a wide-enough predicate range to make the +-- planner pick a bitmap plan over a plain index scan. 20% of transactions +-- HOT-indexed-update column c (a third, separately-indexed column), which is +-- what plants a may-be-stale-flagged fresh entry in siu_c. siu_c is not +-- part of the BitmapAnd predicate here on purpose: the bit14 fix's cost +-- shows up in whether flagged pages elsewhere on the SAME heap page as an +-- update also lose exact-mode precision on OTHER indexes' bitmap +-- contributions for unrelated rows sharing that page, which is exactly what +-- reset_state's freshly-clustered layout (a and b both increasing with row +-- order) puts multiple rows per heap page under. +\set aid random(1, :scale * 100000) +\set cid random(1, :scale * 100000) +\set lo random(1, :scale * 100000 - 1000) +\set hi_off random(1, 1000) +\set which random(1, 100) +\if :which > 80 + UPDATE siu_table SET c = :cid WHERE a = :aid; +\else + SELECT count(*) FROM siu_table WHERE b BETWEEN :lo::int AND :lo::int + :hi_off::int AND d BETWEEN :lo::int AND :lo::int + :hi_off::int; +\endif diff --git a/src/test/benchmarks/siu/scripts/bloat.sh b/src/test/benchmarks/siu/scripts/bloat.sh new file mode 100755 index 0000000000000..34171c68f5b2d --- /dev/null +++ b/src/test/benchmarks/siu/scripts/bloat.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Single-variant bloat benchmark for HOT-indexed (SIU) updates. +# +# The A/B run.sh measures TPS/WAL/aggregate size; it does not isolate two +# bloat properties of the HOT-indexed path, which this script demonstrates: +# +# 1. Stale index entries on the CHANGED index accumulate between vacuums, +# but VACUUM reclaims them, so size stays bounded across update+vacuum +# cycles. Skipping vacuum lets them grow unbounded (the inherent +# inter-vacuum bloat; read-filtered by the crossed-attribute bitmap meanwhile). +# 2. An index on an UNCHANGED column is skipped by HOT-indexed updates (the +# selective-update benefit) -- visible as a skip count. Updates that fall +# back to non-HOT (e.g. when the page has no room for the chain) still +# insert into it, so its size reflects only the non-HOT remainder. +# +# Uses the tepid variant built by build.sh (override BINDIR for any build) and +# a throwaway cluster under $BENCH, so it never touches the A/B pgdata. +# +# Env: BENCH (default /scratch/siu-bench), BINDIR (default tepid variant bin), +# PORT (default 57481), ROWS (default 5000), CYCLES (default 8), +# UPDATES (updates per row per cycle, default 20). +set -euo pipefail + +BENCH=${BENCH:-/scratch/siu-bench} +BINDIR=${BINDIR:-$BENCH/tepid/usr/local/pgsql/bin} +PORT=${PORT:-57481} +ROWS=${ROWS:-5000} +CYCLES=${CYCLES:-8} +UPDATES=${UPDATES:-20} +DATADIR=$BENCH/_data_bloat + +base=$(dirname "$BINDIR") +if [ -d "$base/lib64" ]; then + export LD_LIBRARY_PATH="$base/lib64${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" +else + export LD_LIBRARY_PATH="$base/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" +fi + +PSQL=("$BINDIR/psql" -h /tmp -p "$PORT" -U postgres -X -q) +P() { "${PSQL[@]}" -At "$@"; } + +"$BINDIR/pg_ctl" -D "$DATADIR" stop -m fast >/dev/null 2>&1 || true +rm -rf "$DATADIR" +"$BINDIR/initdb" -D "$DATADIR" -U postgres --no-sync >/dev/null +cat >> "$DATADIR/postgresql.conf" </dev/null +trap '"$BINDIR/pg_ctl" -D "$DATADIR" stop -m fast >/dev/null 2>&1 || true' EXIT +"${PSQL[@]}" -c "CREATE EXTENSION IF NOT EXISTS pgstattuple;" >/dev/null + +# $1 = table name, $2 = vacuum_each (1/0). Returns final idx_a composition. +run_arm() { + local tbl=$1 vac=$2 + "${PSQL[@]}" </dev/null +DROP TABLE IF EXISTS $tbl; +CREATE TABLE $tbl (id int PRIMARY KEY, a int, b int, pad text) WITH (fillfactor = 50); +CREATE INDEX ${tbl}_a ON $tbl(a); -- changed column +CREATE INDEX ${tbl}_b ON $tbl(b); -- never changed +INSERT INTO $tbl SELECT g, g, g, repeat('x', 40) FROM generate_series(1, $ROWS) g; +VACUUM (FREEZE, ANALYZE) $tbl; +SQL + local cyc + for ((cyc = 1; cyc <= CYCLES; cyc++)); do + "${PSQL[@]}" -c "DO \$\$ BEGIN FOR u IN 1..$UPDATES LOOP UPDATE $tbl SET a = a + 1; END LOOP; END \$\$;" >/dev/null + [ "$vac" = 1 ] && "${PSQL[@]}" -c "VACUUM $tbl;" >/dev/null + done + P -c "SELECT '$tbl(vacuum_each=$vac):' + || ' idx_a_kb=' || pg_relation_size('${tbl}_a')/1024 + || ' idx_a_live=' || (SELECT tuple_count FROM pgstattuple('${tbl}_a')) + || ' idx_a_free%=' || round((SELECT free_percent FROM pgstattuple('${tbl}_a'))::numeric,1) + || ' idx_b_kb=' || pg_relation_size('${tbl}_b')/1024 + || ' idx_b_skips=' || coalesce((SELECT n_tup_hot_indexed_upd_skipped + FROM pg_stat_all_indexes WHERE indexrelname='${tbl}_b'), 0);" +} + +echo "=== HOT-indexed bloat: $CYCLES cycles x ($UPDATES updates/row x $ROWS rows) ===" +run_arm t_vac 1 +run_arm t_novac 0 +echo "idx_a: bounded with vacuum_each=1, unbounded with =0 (stale entries accumulate" +echo "until reclaimed). idx_b_skips counts entries the HOT-indexed path avoided on" +echo "the unchanged index; idx_b_kb is only the non-HOT-fallback remainder." diff --git a/src/test/benchmarks/siu/scripts/build.sh b/src/test/benchmarks/siu/scripts/build.sh new file mode 100755 index 0000000000000..2bd0e9eb63dff --- /dev/null +++ b/src/test/benchmarks/siu/scripts/build.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Build two postgres variants for tepid (HOT-indexed) A/B benchmarks. +# +# Env vars (all optional): +# REPO -- path to postgres source repo (default: $HOME/ws/postgres/tepid, or /scratch/siu-bench/repo) +# BENCH -- bench root (default: /scratch/siu-bench) +# MASTER_REV -- revision for the "master" variant (default: tepid's merge-base with origin/master) +# TEPID_REV -- revision for the "tepid" variant (default: tepid) +# JOBS -- parallel compile jobs (default: nproc or 8) +set -euo pipefail + +BENCH=${BENCH:-/scratch/siu-bench} +JOBS=${JOBS:-$( (command -v nproc >/dev/null && nproc) || sysctl -n hw.ncpu 2>/dev/null || echo 8 )} +if [ -z "${REPO:-}" ]; then + for candidate in "$HOME/ws/postgres/tepid" "$BENCH/repo" /scratch/pg; do + if [ -d "$candidate/.git" ]; then REPO=$candidate; break; fi + done +fi +: "${REPO:?REPO not set and no default found}" +cd "$REPO" + +TEPID_REV=${TEPID_REV:-tepid} +MASTER_REV=${MASTER_REV:-$(git merge-base "$TEPID_REV" origin/master 2>/dev/null || git merge-base "$TEPID_REV" master)} + +echo "REPO=$REPO MASTER=$MASTER_REV TEPID=$TEPID_REV JOBS=$JOBS BENCH=$BENCH" + +die() { printf 'build: %s\n' "$*" >&2; exit 1; } +[ -n "$MASTER_REV" ] || die "could not resolve MASTER_REV; set it explicitly or ensure origin/master exists" +if git status --porcelain | grep -v '^??' | grep -q .; then + die "repo has unstaged/uncommitted changes; stash or commit first" +fi + +build_variant() { + local name=$1 + local rev=$2 + local prefix=$BENCH/$name + echo "=== building $name ($rev) into $prefix" + rm -rf "$prefix" + mkdir -p "$prefix" + git checkout --quiet --detach "$rev" + local bld=$BENCH/_build_$name + rm -rf "$bld" + meson setup "$bld" --prefix="$prefix/usr/local/pgsql" \ + -Dbuildtype=release -Dcassert=false \ + "-Dextra_version=-siubench-$name" >/dev/null + meson compile -C "$bld" -j "$JOBS" + meson install -C "$bld" --destdir=/ >/dev/null + "$prefix/usr/local/pgsql/bin/postgres" --version +} + +ORIG=$(git symbolic-ref --quiet --short HEAD || git rev-parse HEAD) +trap 'git checkout --quiet "$ORIG"' EXIT + +build_variant master "$MASTER_REV" +build_variant tepid "$TEPID_REV" diff --git a/src/test/benchmarks/siu/scripts/hot_indexed_mixed.sql b/src/test/benchmarks/siu/scripts/hot_indexed_mixed.sql new file mode 100644 index 0000000000000..3ab3289df27ad --- /dev/null +++ b/src/test/benchmarks/siu/scripts/hot_indexed_mixed.sql @@ -0,0 +1,11 @@ +-- Mixed workload: 80% selects, 20% indexed-column updates. +-- Exercises both the hot-indexed writer and the crossed-attribute-bitmap reader. +\set aid random(1, :scale * 100000) +\set bid random(1, 1000000) +\set which random(1, 100) +BEGIN; +SELECT * FROM siu_table WHERE a = :aid; +\if :which > 80 + UPDATE siu_table SET b = :bid WHERE a = :aid; +\endif +COMMIT; diff --git a/src/test/benchmarks/siu/scripts/hot_indexed_update.sql b/src/test/benchmarks/siu/scripts/hot_indexed_update.sql new file mode 100644 index 0000000000000..f1bcf959c67f5 --- /dev/null +++ b/src/test/benchmarks/siu/scripts/hot_indexed_update.sql @@ -0,0 +1,6 @@ +-- hot-indexed-friendly workload: narrow table with a few non-PK indexes. +-- Each UPDATE changes a non-summarizing indexed column on a random row. +-- With hot-indexed this is HOT-indexed; without hot-indexed it is non-HOT. +\set aid random(1, :scale * 100000) +\set new_b random(1, 1000000) +UPDATE siu_table SET b = :new_b WHERE a = :aid; diff --git a/src/test/benchmarks/siu/scripts/read_indexscan.sql b/src/test/benchmarks/siu/scripts/read_indexscan.sql new file mode 100644 index 0000000000000..465689763ea24 --- /dev/null +++ b/src/test/benchmarks/siu/scripts/read_indexscan.sql @@ -0,0 +1,11 @@ +-- read_indexscan: read-only btree index-scan workload confirming the +-- HOT-indexed read path adds no per-scan overhead on tables with no stale +-- entries. +-- The crossed-attribute bitmap decides staleness without a key comparison and +-- the scan does not request the index tuple, so on a freshly reset siu_table +-- (no stale HOT-indexed entries) there should be no master-vs-tepid +-- difference on this cell. The predicate is an equality on the +-- indexed column b and the target list includes the non-indexed column e, +-- forcing a plain (heap-fetching) index scan rather than an index-only scan. +\set id random(1, :rows) +SELECT a, b, c, d, e FROM siu_table WHERE b = :id; diff --git a/src/test/benchmarks/siu/scripts/run.sh b/src/test/benchmarks/siu/scripts/run.sh new file mode 100755 index 0000000000000..e1321926f740e --- /dev/null +++ b/src/test/benchmarks/siu/scripts/run.sh @@ -0,0 +1,377 @@ +#!/usr/bin/env bash +# A/B pgbench harness for tepid: master (upstream) vs tepid (HOT-indexed). +# +# Env vars: +# SCALE -- pgbench -s (also multiplier for siu_table row count = SCALE*100k) +# CLIENTS -- pgbench -c +# THREADS -- pgbench -j +# DURATION -- pgbench -T (seconds per workload) +# WIDE_COLS -- # of indexed columns in the wide_table (default 16) +# WIDE_STEPS -- comma-separated list of "updated columns" counts for +# the wide workload (default "0,1,4,8,WIDE_COLS") +# PORT -- postgres port (default 57480) +# +# For each variant in {master, tepid}: +# initdb fresh pgdata, start postgres, create test objects, +# run workloads (pgbench -N simple_update, hot_indexed_update, hot_indexed_mixed, +# read_indexscan, and wide_N for each value in WIDE_STEPS), collect TPS + HOT +# counts + WAL delta + peak CPU/RSS sampled via pidstat. +# Emits CSV + Markdown summary under /scratch/siu-bench/results/. +set -euo pipefail + +BENCH=${BENCH:-/scratch/siu-bench} +SCALE=${SCALE:-20} +CLIENTS=${CLIENTS:-16} +THREADS=${THREADS:-8} +DURATION=${DURATION:-120} +WIDE_COLS=${WIDE_COLS:-16} +WIDE_STEPS=${WIDE_STEPS:-0,1,4,8,16} +PORT=${PORT:-57480} + +TS=$(date -u +%Y%m%dT%H%M%SZ) +OUT=$BENCH/results/$TS.csv +LOGDIR=$BENCH/logs/$TS +mkdir -p "$LOGDIR" "$BENCH/results" +echo "variant,workload,tps,latency_avg_ms,classic_hot_updates,hot_indexed_updates,non_hot_updates,total_updates,wal_bytes,bloat_pages_before,bloat_pages_after,index_size_before,index_size_after,cpu_pct_peak,rss_mib_peak,per_index_before,per_index_after" > "$OUT" +echo "=== siu-bench A/B run $TS -> $OUT (scale=$SCALE clients=$CLIENTS threads=$THREADS duration=${DURATION}s)" + +bin_of() { + echo "$BENCH/$1/usr/local/pgsql/bin" +} + +LD_of() { + local base=$BENCH/$1/usr/local/pgsql + # Linux distros that split 64-bit libs use lib64; most others use lib. + if [ -d "$base/lib64" ]; then + echo "$base/lib64" + else + echo "$base/lib" + fi +} + +psql_as() { + local v=$1; shift + LD_LIBRARY_PATH="$(LD_of "$v")" "$(bin_of "$v")/psql" -h /tmp -p "$PORT" -U postgres -X "$@" +} + +pgbench_as() { + local v=$1; shift + LD_LIBRARY_PATH="$(LD_of "$v")" "$(bin_of "$v")/pgbench" -h /tmp -p "$PORT" -U postgres "$@" +} + +start_pg() { + local v=$1 + local datadir=$BENCH/_data_$v + [ -d "$datadir" ] && find "$datadir" -mindepth 1 -delete && rmdir "$datadir" + mkdir -p "$datadir" + + LD_LIBRARY_PATH="$(LD_of "$v")" "$(bin_of "$v")/initdb" -D "$datadir" -U postgres >"$LOGDIR/initdb_$v.log" 2>&1 + local sb=${SHARED_BUFFERS:-512MB} + cat >> "$datadir/postgresql.conf" </dev/null + sleep 2 +} + +stop_pg() { + local v=$1 + local datadir=$BENCH/_data_$v + LD_LIBRARY_PATH="$(LD_of "$v")" "$(bin_of "$v")/pg_ctl" -D "$datadir" stop -m fast >/dev/null 2>&1 || true +} + +postmaster_pid() { + local v=$1 + head -1 "$BENCH/_data_$v/postmaster.pid" 2>/dev/null +} + +setup_schemas() { + local v=$1 + seed_siu_table "$v" + seed_wide_table "$v" + # pgbench schema for built-in simple_update. + LD_LIBRARY_PATH="$(LD_of "$v")" "$(bin_of "$v")/pgbench" -h /tmp -p "$PORT" -U postgres \ + -i -s "$SCALE" -q postgres >"$LOGDIR/pgbench_init_$v.log" 2>&1 +} + +# seed_siu_table: (re)create the narrow table used by the siu_* workloads. +seed_siu_table() { + local v=$1 + local rows=$((SCALE * 100000)) + psql_as "$v" <>"$LOGDIR/pgbench_init_$v.log" 2>&1 + psql_as "$v" -c "CHECKPOINT" >/dev/null + ;; + siu_table) + seed_siu_table "$v" + ;; + wide_table) + seed_wide_table "$v" + ;; + *) + echo "reset_state: unknown table $table" >&2 + return 1 + ;; + esac + psql_as "$v" -c "SELECT pg_stat_reset_single_table_counters('$table'::regclass::oid)" >/dev/null +} + +bloat_stats() { + local v=$1 table=$2 + psql_as "$v" -Atc "SELECT pg_table_size('$table')/8192 || ',' || pg_indexes_size('$table')" +} + +# siu_count: number of HOT-indexed updates observed on $table since its +# pgstat counters were last reset. Returns "0" on master (where the +# counter column does not exist) so the CSV column stays numeric. +siu_count() { + local v=$1 table=$2 + local val + val=$(psql_as "$v" -Atc \ + "SELECT coalesce(n_tup_hot_indexed_upd, 0) FROM pg_stat_user_tables WHERE relname='$table'" 2>/dev/null) + [[ "$val" =~ ^[0-9]+$ ]] || val=0 + echo "$val" +} + +# per_index_sizes: emit "idx1=bytes;idx2=bytes;..." for the indexes on +# $table, sorted by indexrelid. Used by the wide_* workloads so we can +# see per-column index growth rather than just the aggregate. Returns +# the literal "none" when $table has no indexes. +per_index_sizes() { + local v=$1 table=$2 + local out + out=$(psql_as "$v" -Atc "SELECT string_agg( + i.relname || '=' || pg_relation_size(i.oid)::text, + ';' ORDER BY i.oid) + FROM pg_class t + JOIN pg_index ix ON ix.indrelid = t.oid + JOIN pg_class i ON i.oid = ix.indexrelid + WHERE t.relname = '$table'") + [ -n "$out" ] || out="none" + echo "$out" +} + +sample_peak() { + # Sample CPU / RSS of the postmaster tree for $DURATION+5 seconds. + # Writes "peak_cpu_pct,peak_rss_mib" to the given outfile. Portable across + # Linux / FreeBSD (falls back to pgrep + per-pid ps where --ppid isn't + # available). Returns 'NA,NA' if the sampler can't collect useful data. + local outfile=$1 v=$2 + local leader + leader=$(postmaster_pid "$v") + [ -z "$leader" ] && { echo "NA,NA" > "$outfile"; return; } + local dur=$(( DURATION + 5 )) + ( + local max_cpu=0 + local max_rss=0 + local t0=$(date +%s) + while :; do + # Children of the leader + the leader itself. + local pids + pids=$( (pgrep -P "$leader" 2>/dev/null; echo "$leader") | tr '\n' ' ') + local sample + sample=$(ps -o pcpu=,rss= -p $pids 2>/dev/null | \ + awk '{cpu+=$1; rss+=$2} END{printf "%.1f %d\n", cpu+0, rss+0}') + local c r + read -r c r <<<"$sample" + if [ -n "${c:-}" ] && [ -n "${r:-}" ]; then + awk -v m="$max_cpu" -v c="$c" 'BEGIN{exit !(c>m)}' && max_cpu=$c + [ "$r" -gt "$max_rss" ] 2>/dev/null && max_rss=$r + fi + local now=$(date +%s) + [ $((now - t0)) -ge "$dur" ] && break + sleep 1 + done + local rss_mib=$(( max_rss / 1024 )) + echo "$max_cpu,$rss_mib" > "$outfile" + ) & + echo $! +} + +run_one() { + local v=$1 workload=$2 script=$3 table=${4:-siu_table} extra_set=${5:-} + + local wal_start wal_end hot_start hot_end total_start total_end tps lat + local siu_start siu_end + local bloat_before bloat_after idx_before idx_after + local per_idx_before per_idx_after + read -r bloat_before idx_before <<<"$(bloat_stats "$v" "$table" | tr , ' ')" + per_idx_before=$(per_index_sizes "$v" "$table") + + wal_start=$(psql_as "$v" -Atc "SELECT pg_current_wal_lsn()::text") + hot_start=$(psql_as "$v" -Atc "SELECT coalesce(n_tup_hot_upd,0) FROM pg_stat_user_tables WHERE relname='$table'") + siu_start=$(siu_count "$v" "$table") + total_start=$(psql_as "$v" -Atc "SELECT coalesce(n_tup_upd,0) FROM pg_stat_user_tables WHERE relname='$table'") + + local out="$LOGDIR/${v}_${workload}.log" + local cpu_rss_file=$LOGDIR/${v}_${workload}.cpu + local sampler_pid + sampler_pid=$(sample_peak "$cpu_rss_file" "$v") + + set +e + case "$workload" in + simple_update) + pgbench_as "$v" -N -c "$CLIENTS" -j "$THREADS" -T "$DURATION" \ + -n postgres >"$out" 2>&1 + ;; + wide_*) + # build the SET clause from extra_set which is "c1=:v,c2=:v,..." + pgbench_as "$v" -f <(sed "s/:wide_set_clause/$extra_set/" "$script") \ + -c "$CLIENTS" -j "$THREADS" -T "$DURATION" \ + -D "scale=$SCALE" -n postgres >"$out" 2>&1 + ;; + read_indexscan) + # read-only; pass the row count so the script can pick random keys + pgbench_as "$v" -f "$script" -c "$CLIENTS" -j "$THREADS" -T "$DURATION" \ + -D "rows=$((SCALE * 100000))" -n postgres >"$out" 2>&1 + ;; + *) + pgbench_as "$v" -f "$script" -c "$CLIENTS" -j "$THREADS" -T "$DURATION" \ + -n postgres >"$out" 2>&1 + ;; + esac + set -e + + wait "$sampler_pid" 2>/dev/null || true + local cpu_rss + cpu_rss=$(cat "$cpu_rss_file" 2>/dev/null || echo "NA,NA") + + tps=$(awk '/tps = /{print $3; exit}' "$out") + lat=$(awk '/latency average = /{print $4; exit}' "$out") + tps=${tps:-NA} + lat=${lat:-NA} + + wal_end=$(psql_as "$v" -Atc "SELECT pg_current_wal_lsn()::text") + hot_end=$(psql_as "$v" -Atc "SELECT coalesce(n_tup_hot_upd,0) FROM pg_stat_user_tables WHERE relname='$table'") + siu_end=$(siu_count "$v" "$table") + total_end=$(psql_as "$v" -Atc "SELECT coalesce(n_tup_upd,0) FROM pg_stat_user_tables WHERE relname='$table'") + + local wal_bytes + wal_bytes=$(psql_as "$v" -Atc "SELECT pg_wal_lsn_diff('$wal_end'::pg_lsn, '$wal_start'::pg_lsn)::bigint") + + # Capture a WAL record-type histogram for this workload. pg_waldump's + # --stats=record output is rich (~60 lines) so stash it in LOGDIR + # rather than trying to fold into the CSV. Tolerate failures: if the + # segment containing wal_start has been recycled (rare with + # max_wal_size=4GB but possible under long chained runs), we emit a + # note and move on instead of aborting the whole run. + local wal_stats_file=$LOGDIR/${v}_${workload}.walstats + LD_LIBRARY_PATH="$(LD_of "$v")" "$(bin_of "$v")/pg_waldump" \ + --stats=record -p "$BENCH/_data_$v/pg_wal" \ + --start="$wal_start" --end="$wal_end" \ + > "$wal_stats_file" 2> "${wal_stats_file}.err" \ + || echo "pg_waldump unavailable for this range; see ${wal_stats_file}.err" > "$wal_stats_file" + + read -r bloat_after idx_after <<<"$(bloat_stats "$v" "$table" | tr , ' ')" + per_idx_after=$(per_index_sizes "$v" "$table") + + local hot=$((hot_end - hot_start)) + local siu=$((siu_end - siu_start)) + local tot=$((total_end - total_start)) + local classic_hot=$((hot - siu)) + local non_hot=$((tot - hot)) + + printf '%s,%s,%s,%s,%d,%d,%d,%d,%s,%s,%s,%s,%s,%s,%s,%s\n' \ + "$v" "$workload" "$tps" "$lat" "$classic_hot" "$siu" "$non_hot" "$tot" \ + "$wal_bytes" \ + "$bloat_before" "$bloat_after" \ + "$idx_before" "$idx_after" \ + "$cpu_rss" "$per_idx_before" "$per_idx_after" >> "$OUT" + printf ' %-8s %-14s tps=%10s lat=%6s classic_hot=%7d hi=%7d non_hot=%7d tot=%-7d wal=%12s bloat=%s->%s idx=%s->%s cpu_rss=%s\n' \ + "$v" "$workload" "$tps" "$lat" "$classic_hot" "$siu" "$non_hot" "$tot" "$wal_bytes" \ + "$bloat_before" "$bloat_after" "$idx_before" "$idx_after" "$cpu_rss" +} + +build_wide_set_clause() { + # emit e.g. "c1=:v,c2=:v,...,cN=:v" for first N cols. + local n=$1 + if [ "$n" -eq 0 ]; then + # No indexed-col update; touch a non-indexed column (id % 1 so it's a no-op) + echo "id=id" + return + fi + local clauses="" + for i in $(seq 1 "$n"); do + [ -n "$clauses" ] && clauses+="," + clauses+="c$i=:v" + done + echo "$clauses" +} + +for v in master tepid; do + echo "--- variant: $v" + stop_pg "$v" || true + start_pg "$v" + setup_schemas "$v" + + run_one "$v" simple_update '' pgbench_accounts + reset_state "$v" siu_table + run_one "$v" hot_indexed_update "$BENCH/scripts/hot_indexed_update.sql" siu_table + reset_state "$v" siu_table + run_one "$v" hot_indexed_mixed "$BENCH/scripts/hot_indexed_mixed.sql" siu_table + reset_state "$v" siu_table + run_one "$v" read_indexscan "$BENCH/scripts/read_indexscan.sql" siu_table + + for n in ${WIDE_STEPS//,/ }; do + reset_state "$v" wide_table + run_one "$v" "wide_${n}" "$BENCH/scripts/wide_update.sql" wide_table \ + "$(build_wide_set_clause "$n")" + done + + stop_pg "$v" +done + +echo "=== results: $OUT" +column -t -s, "$OUT" | head -50 diff --git a/src/test/benchmarks/siu/scripts/soak.sh b/src/test/benchmarks/siu/scripts/soak.sh new file mode 100755 index 0000000000000..930f159a22c0c --- /dev/null +++ b/src/test/benchmarks/siu/scripts/soak.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# tepid soak: run hot_indexed_update for $DURATION seconds on each variant, sampling +# TPS / HOT-rate / WAL volume / table+index bloat every $SAMPLE seconds. +# Emits a CSV with one sample row per tick per variant. +set -euo pipefail + +BENCH=${BENCH:-/scratch/siu-bench} +SCALE=${SCALE:-50} +CLIENTS=${CLIENTS:-16} +THREADS=${THREADS:-8} +DURATION=${DURATION:-900} # 15 minutes +SAMPLE=${SAMPLE:-60} # every 60 s +PORT=${PORT:-57503} +SHARED_BUFFERS=${SHARED_BUFFERS:-2GB} + +TS=$(date -u +%Y%m%dT%H%M%SZ) +OUT=$BENCH/results/soak_$TS.csv +LOGDIR=$BENCH/logs/soak_$TS +mkdir -p "$LOGDIR" "$BENCH/results" +echo "variant,t_secs,tps_instant,hot_pct_instant,heap_pages,index_bytes,wal_bytes_since_start,n_dead_tup" > "$OUT" +echo "=== soak $TS -> $OUT" + +bin_of() { echo "$BENCH/$1/usr/local/pgsql/bin"; } +LD_of() { local b=$BENCH/$1/usr/local/pgsql; [ -d "$b/lib64" ] && echo "$b/lib64" || echo "$b/lib"; } + +psql_as() { local v=$1; shift; LD_LIBRARY_PATH="$(LD_of "$v")" "$(bin_of "$v")/psql" -h /tmp -p "$PORT" -U postgres -X "$@"; } +pgbench_as() { local v=$1; shift; LD_LIBRARY_PATH="$(LD_of "$v")" "$(bin_of "$v")/pgbench" -h /tmp -p "$PORT" -U postgres "$@"; } + +start_pg() { + local v=$1 datadir=$BENCH/_data_$v + [ -d "$datadir" ] && find "$datadir" -mindepth 1 -delete && rmdir "$datadir" + mkdir -p "$datadir" + LD_LIBRARY_PATH="$(LD_of "$v")" "$(bin_of "$v")/initdb" -D "$datadir" -U postgres >"$LOGDIR/initdb_$v.log" 2>&1 + cat >> "$datadir/postgresql.conf" </dev/null + sleep 2 +} + +stop_pg() { + local v=$1 + LD_LIBRARY_PATH="$(LD_of "$v")" "$(bin_of "$v")/pg_ctl" -D "$BENCH/_data_$v" stop -m fast >/dev/null 2>&1 || true +} + +setup() { + local v=$1 rows=$((SCALE * 100000)) + psql_as "$v" <"$LOGDIR/pgbench_$v.log" 2>&1 & + local pgb=$! + + local t=0 + while [ "$t" -lt "$DURATION" ]; do + sleep "$SAMPLE" + t=$((t + SAMPLE)) + local now_hot now_tot wal_now wal_bytes heap_pages idx_bytes n_dead + now_hot=$(psql_as "$v" -Atc "SELECT coalesce(n_tup_hot_upd,0) FROM pg_stat_user_tables WHERE relname='siu_table'") + now_tot=$(psql_as "$v" -Atc "SELECT coalesce(n_tup_upd,0) FROM pg_stat_user_tables WHERE relname='siu_table'") + wal_now=$(psql_as "$v" -Atc "SELECT pg_current_wal_lsn()::text") + wal_bytes=$(psql_as "$v" -Atc "SELECT pg_wal_lsn_diff('$wal_now'::pg_lsn, '$wal0'::pg_lsn)::bigint") + heap_pages=$(psql_as "$v" -Atc "SELECT pg_table_size('siu_table')/8192") + idx_bytes=$(psql_as "$v" -Atc "SELECT pg_indexes_size('siu_table')") + n_dead=$(psql_as "$v" -Atc "SELECT coalesce(n_dead_tup,0) FROM pg_stat_user_tables WHERE relname='siu_table'") + + local d_hot=$((now_hot - prev_hot)) + local d_tot=$((now_tot - prev_tot)) + local tps_i hot_pct + if [ "$d_tot" -gt 0 ]; then + tps_i=$(awk -v d="$d_tot" -v s="$SAMPLE" 'BEGIN{printf "%.1f", d/s}') + hot_pct=$(awk -v h="$d_hot" -v t="$d_tot" 'BEGIN{printf "%.1f", 100*h/t}') + else + tps_i=0; hot_pct=0 + fi + printf '%s,%d,%s,%s,%s,%s,%s,%s\n' "$v" "$t" "$tps_i" "$hot_pct" "$heap_pages" "$idx_bytes" "$wal_bytes" "$n_dead" >> "$OUT" + printf ' %-6s t=%-5d tps=%8s hot=%-5s%% heap_pgs=%-7s idx=%-12s wal=%-12s dead=%s\n' \ + "$v" "$t" "$tps_i" "$hot_pct" "$heap_pages" "$idx_bytes" "$wal_bytes" "$n_dead" + prev_hot=$now_hot + prev_tot=$now_tot + done + + wait "$pgb" 2>/dev/null || true + stop_pg "$v" +} + +for v in master tepid; do + run_soak "$v" +done + +echo "=== soak results: $OUT" +column -t -s, "$OUT" | head -80 diff --git a/src/test/benchmarks/siu/scripts/wide_update.sql b/src/test/benchmarks/siu/scripts/wide_update.sql new file mode 100644 index 0000000000000..c253d5493db56 --- /dev/null +++ b/src/test/benchmarks/siu/scripts/wide_update.sql @@ -0,0 +1,7 @@ +-- Wide-table workload. The setup script creates a table with WIDE_COLS integer +-- columns, each separately btree-indexed. The workload UPDATEs a +-- configurable number of those indexed columns per transaction +-- (driven by WIDE_STEPS via run.sh) on a random row. +\set rid random(1, :scale * 1000) +\set v random(1, 1000000000) +UPDATE wide_table SET :wide_set_clause WHERE id = :rid; diff --git a/src/test/isolation/expected/hot_indexed_adversarial.out b/src/test/isolation/expected/hot_indexed_adversarial.out new file mode 100644 index 0000000000000..22f628015139b --- /dev/null +++ b/src/test/isolation/expected/hot_indexed_adversarial.out @@ -0,0 +1,139 @@ +Parsed test spec with 6 sessions + +starting permutation: s2_noseq s1_cycle s2_eq10 s2_eq20 +step s2_noseq: SET enable_seqscan = off; +step s1_cycle: UPDATE hia SET k = 20 WHERE id = 1; UPDATE hia SET k = 10 WHERE id = 1; +step s2_eq10: SELECT id, k FROM hia WHERE k = 10; +id| k +--+-- + 1|10 +(1 row) + +step s2_eq20: SELECT id, k FROM hia WHERE k = 20; +id|k +--+- +(0 rows) + + +starting permutation: s2_noseq s1_cycle s2_range +step s2_noseq: SET enable_seqscan = off; +step s1_cycle: UPDATE hia SET k = 20 WHERE id = 1; UPDATE hia SET k = 10 WHERE id = 1; +step s2_range: SELECT id, k FROM hia WHERE k >= 5 ORDER BY k; +id| k +--+-- + 1|10 +(1 row) + + +starting permutation: s2_noseq s1_begin s1_upd20 s1_abort s2_eq20 s2_eq10 +step s2_noseq: SET enable_seqscan = off; +step s1_begin: BEGIN; +step s1_upd20: UPDATE hia SET k = 20 WHERE id = 1; +step s1_abort: ROLLBACK; +step s2_eq20: SELECT id, k FROM hia WHERE k = 20; +id|k +--+- +(0 rows) + +step s2_eq10: SELECT id, k FROM hia WHERE k = 10; +id| k +--+-- + 1|10 +(1 row) + + +starting permutation: s1_begin s1_uupd20 s2_ins10 s1_commit +step s1_begin: BEGIN; +step s1_uupd20: UPDATE hiu SET k = 20 WHERE id = 1; +step s2_ins10: INSERT INTO hiu VALUES (2, 10, repeat('y', 40)); +step s1_commit: COMMIT; +step s2_ins10: <... completed> + +starting permutation: s1_begin s1_uupd20 s1_commit s2_ins10 +step s1_begin: BEGIN; +step s1_uupd20: UPDATE hiu SET k = 20 WHERE id = 1; +step s1_commit: COMMIT; +step s2_ins10: INSERT INTO hiu VALUES (2, 10, repeat('y', 40)); + +starting permutation: s1_begin s1_uupd20 s1_commit s2_ins20 +step s1_begin: BEGIN; +step s1_uupd20: UPDATE hiu SET k = 20 WHERE id = 1; +step s1_commit: COMMIT; +step s2_ins20: INSERT INTO hiu VALUES (3, 20, repeat('z', 40)); +ERROR: duplicate key value violates unique constraint "hiu_k" + +starting permutation: s3_begin s3_eq10 s2_to30 s2_scan30 s3_eq10 s3_commit +step s3_begin: BEGIN ISOLATION LEVEL REPEATABLE READ; SET enable_seqscan = off; +step s3_eq10: SELECT id, k FROM hia WHERE k = 10; +id| k +--+-- + 1|10 +(1 row) + +step s2_to30: UPDATE hia SET k = 20 WHERE id = 1; UPDATE hia SET k = 30 WHERE id = 1; +step s2_scan30: SET enable_seqscan = off; SELECT id, k FROM hia WHERE k = 30; +id| k +--+-- + 1|30 +(1 row) + +step s3_eq10: SELECT id, k FROM hia WHERE k = 10; +id| k +--+-- + 1|10 +(1 row) + +step s3_commit: COMMIT; + +starting permutation: b1_begin b1_snap b2_update b2_vacuum b1_snap b1_commit b3_seq +step b1_begin: BEGIN; +step b1_snap: SELECT id, v FROM hib WHERE v = 400; +id| v +--+--- + 1|400 +(1 row) + +step b2_update: UPDATE hib SET v = 500 WHERE id = 1; +step b2_vacuum: VACUUM (INDEX_CLEANUP off) hib; +step b1_snap: SELECT id, v FROM hib WHERE v = 400; +id|v +--+- +(0 rows) + +step b1_commit: COMMIT; +step b3_seq: SELECT id, v FROM hib ORDER BY id; +id| v +--+--- + 1|500 + 2| 20 + 3| 30 + 4| 40 + 5| 50 +(5 rows) + + +starting permutation: b1_begin b2_update b1_snap b2_vacuum b1_snap b1_commit b3_seq +step b1_begin: BEGIN; +step b2_update: UPDATE hib SET v = 500 WHERE id = 1; +step b1_snap: SELECT id, v FROM hib WHERE v = 400; +id|v +--+- +(0 rows) + +step b2_vacuum: VACUUM (INDEX_CLEANUP off) hib; +step b1_snap: SELECT id, v FROM hib WHERE v = 400; +id|v +--+- +(0 rows) + +step b1_commit: COMMIT; +step b3_seq: SELECT id, v FROM hib ORDER BY id; +id| v +--+--- + 1|500 + 2| 20 + 3| 30 + 4| 40 + 5| 50 +(5 rows) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c54..e9afb48199e01 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: hot_indexed_adversarial diff --git a/src/test/isolation/specs/hot_indexed_adversarial.spec b/src/test/isolation/specs/hot_indexed_adversarial.spec new file mode 100644 index 0000000000000..64705bf37e06b --- /dev/null +++ b/src/test/isolation/specs/hot_indexed_adversarial.spec @@ -0,0 +1,123 @@ +# Adversarial correctness tests for HOT-indexed (SIU) updates. +# +# Each permutation pins a case that the mid-chain-pointing invariant must +# satisfy: an index entry points at the heap-only version whose key it +# matched, and a chain walk that crosses a HOT-indexed hop drops the arriving +# entry when the crossed-attribute bitmap overlaps the index's key columns +# (no key comparison). These are +# exactly the cases that historically broke write-amplification-reduction +# designs. + +setup +{ + CREATE TABLE hia (id int PRIMARY KEY, k int, pad text) WITH (fillfactor = 40); + CREATE INDEX hia_k ON hia(k); + CREATE TABLE hiu (id int PRIMARY KEY, k int, pad text) WITH (fillfactor = 40); + CREATE UNIQUE INDEX hiu_k ON hiu(k); + INSERT INTO hia VALUES (1, 10, repeat('x', 40)); + INSERT INTO hiu VALUES (1, 10, repeat('x', 40)); + + -- Table for the concurrent-collapse reader-consistency case (7). + CREATE TABLE hib (id int PRIMARY KEY, v int, pad text) WITH (fillfactor = 50); + CREATE INDEX hib_v_idx ON hib(v); + INSERT INTO hib SELECT g, g * 10, repeat('x', 50) FROM generate_series(1, 5) g; + UPDATE hib SET v = 100 WHERE id = 1; + UPDATE hib SET v = 200 WHERE id = 1; + UPDATE hib SET v = 300 WHERE id = 1; + UPDATE hib SET v = 400 WHERE id = 1; +} + +teardown +{ + DROP TABLE hia; + DROP TABLE hiu; + DROP TABLE hib; +} + +session s1 +step s1_begin { BEGIN; } +# Cycle the indexed key away and back: 10 -> 20 -> 10. The original 10 leaf +# and the freshly-inserted 10 leaf both resolve to the live tuple; the chain +# walk must drop the stale one so a lookup returns the row exactly once. +step s1_cycle { UPDATE hia SET k = 20 WHERE id = 1; UPDATE hia SET k = 10 WHERE id = 1; } +# A single HOT-indexed update on hia, used by the abort and reader cases. +step s1_upd20 { UPDATE hia SET k = 20 WHERE id = 1; } +# A HOT-indexed update on the UNIQUE-indexed table, freeing key 10 for k=20. +step s1_uupd20 { UPDATE hiu SET k = 20 WHERE id = 1; } +step s1_commit { COMMIT; } +step s1_abort { ROLLBACK; } + +session s2 +# Index-only lookups (forced) that exercise the stale-leaf recheck. +step s2_noseq { SET enable_seqscan = off; } +step s2_eq10 { SELECT id, k FROM hia WHERE k = 10; } +step s2_eq20 { SELECT id, k FROM hia WHERE k = 20; } +step s2_range { SELECT id, k FROM hia WHERE k >= 5 ORDER BY k; } +# Concurrent unique insert of the key s1 is freeing (10) and of the key s1 is +# taking (20); _bt_check_unique must filter the stale 10 leaf but still +# conflict on the live key. +step s2_ins10 { INSERT INTO hiu VALUES (2, 10, repeat('y', 40)); } +step s2_ins20 { INSERT INTO hiu VALUES (3, 20, repeat('z', 40)); } +# Move the indexed key well away from 10 (two HOT-indexed hops) and force an +# index scan on the new key. That scan reaches the live tuple through the +# stale 10 leaf and may try to kill it for bloat reclaim. +step s2_to30 { UPDATE hia SET k = 20 WHERE id = 1; UPDATE hia SET k = 30 WHERE id = 1; } +step s2_scan30 { SET enable_seqscan = off; SELECT id, k FROM hia WHERE k = 30; } + +# Reader holding an older REPEATABLE READ snapshot that still sees k=10. +session s3 +step s3_begin { BEGIN ISOLATION LEVEL REPEATABLE READ; SET enable_seqscan = off; } +step s3_eq10 { SELECT id, k FROM hia WHERE k = 10; } +step s3_commit { COMMIT; } + +session b1 +step b1_begin { BEGIN; } +# Reader takes a snapshot and reads the chain via the secondary index. +step b1_snap { SELECT id, v FROM hib WHERE v = 400; } +step b1_commit { COMMIT; } + +session b2 +# Force prune/collapse: another HOT-indexed update plus a VACUUM that +# collapses the dead chain members to LP_REDIRECT forwarders. +step b2_update { UPDATE hib SET v = 500 WHERE id = 1; } +step b2_vacuum { VACUUM (INDEX_CLEANUP off) hib; } + +session b3 +step b3_seq { SELECT id, v FROM hib ORDER BY id; } + +# 1. a->b->a cycle: exactly one row for the cycled-back key, none for the +# transient key. +permutation s2_noseq s1_cycle s2_eq10 s2_eq20 + +# 2. Range scan returns the live row exactly once across the stale+fresh +# leaves left by the cycle. +permutation s2_noseq s1_cycle s2_range + +# 3. Abort of a HOT-indexed update: the new key must not surface and the old +# key must still resolve to the (restored) live tuple. +permutation s2_noseq s1_begin s1_upd20 s1_abort s2_eq20 s2_eq10 + +# 4. Concurrent unique insert while a HOT-indexed update is in flight. An +# insert of the key s1 is freeing (10) must wait on the uncommitted updater, +# then succeed once it commits (the stale 10 leaf is filtered). +permutation s1_begin s1_uupd20 s2_ins10 s1_commit + +# 5. After the update commits, the freed key (10) inserts cleanly and the +# taken key (20) conflicts -- the live leaf is honoured, the stale one is not. +permutation s1_begin s1_uupd20 s1_commit s2_ins10 +permutation s1_begin s1_uupd20 s1_commit s2_ins20 + +# 6. Snapshot safety of stale-leaf reclaim. An older REPEATABLE READ reader +# still sees k=10; a concurrent session then moves the key to 30 and runs an +# index scan that reaches the live tuple through the stale 10 leaf and may +# try to reclaim it. The reclaim is gated on the skipped versions being +# dead to all transactions, which s3's held snapshot prevents, so s3 must +# still find the row by k=10 after the scan. +permutation s3_begin s3_eq10 s2_to30 s2_scan30 s3_eq10 s3_commit + +# 7. Reader consistency across a concurrent prune/collapse. s1's index scan, +# crossing the collapsed chain after the VACUUM, must still return the row +# via the crossed-attribute bitmap; the query must not error and the row count +# must be consistent. +permutation b1_begin b1_snap b2_update b2_vacuum b1_snap b1_commit b3_seq +permutation b1_begin b2_update b1_snap b2_vacuum b1_snap b1_commit b3_seq diff --git a/src/test/modules/injection_points/expected/syscache-update-pruned.out b/src/test/modules/injection_points/expected/syscache-update-pruned.out index a6a4e8db996b1..07ef67a1eb4dd 100644 --- a/src/test/modules/injection_points/expected/syscache-update-pruned.out +++ b/src/test/modules/injection_points/expected/syscache-update-pruned.out @@ -16,8 +16,8 @@ step wakeinval4: step at2: <... completed> step wakeinval4: <... completed> step wakegrant4: - SELECT FROM injection_points_detach('heap_update-before-pin'); - SELECT FROM injection_points_wakeup('heap_update-before-pin'); + SELECT FROM injection_points_detach('simple_heap_update-before-pin'); + SELECT FROM injection_points_wakeup('simple_heap_update-before-pin'); step grant1: <... completed> ERROR: tuple concurrently deleted @@ -42,8 +42,8 @@ step mkrels4: SELECT FROM vactest.mkrels('intruder', 1, 100); -- repopulate LP_UNUSED step wakegrant4: - SELECT FROM injection_points_detach('heap_update-before-pin'); - SELECT FROM injection_points_wakeup('heap_update-before-pin'); + SELECT FROM injection_points_detach('simple_heap_update-before-pin'); + SELECT FROM injection_points_wakeup('simple_heap_update-before-pin'); step grant1: <... completed> ERROR: duplicate key value violates unique constraint "pg_class_oid_index" @@ -71,8 +71,8 @@ step at2: <... completed> step wakeinval4: <... completed> step at4: ALTER TABLE vactest.child50 INHERIT vactest.orig50; step wakegrant4: - SELECT FROM injection_points_detach('heap_update-before-pin'); - SELECT FROM injection_points_wakeup('heap_update-before-pin'); + SELECT FROM injection_points_detach('simple_heap_update-before-pin'); + SELECT FROM injection_points_wakeup('simple_heap_update-before-pin'); step grant1: <... completed> step wakegrant4: <... completed> diff --git a/src/test/modules/injection_points/specs/syscache-update-pruned.spec b/src/test/modules/injection_points/specs/syscache-update-pruned.spec index e3a4295bd12e8..fef9ac895a122 100644 --- a/src/test/modules/injection_points/specs/syscache-update-pruned.spec +++ b/src/test/modules/injection_points/specs/syscache-update-pruned.spec @@ -103,7 +103,7 @@ session s1 setup { SET debug_discard_caches = 0; SELECT FROM injection_points_set_local(); - SELECT FROM injection_points_attach('heap_update-before-pin', 'wait'); + SELECT FROM injection_points_attach('simple_heap_update-before-pin', 'wait'); } step cachefill1 { SELECT FROM vactest.reloid_catcache_set('vactest.orig50'); } step grant1 { GRANT SELECT ON vactest.orig50 TO PUBLIC; } @@ -140,8 +140,8 @@ step mkrels4 { SELECT FROM vactest.mkrels('intruder', 1, 100); -- repopulate LP_UNUSED } step wakegrant4 { - SELECT FROM injection_points_detach('heap_update-before-pin'); - SELECT FROM injection_points_wakeup('heap_update-before-pin'); + SELECT FROM injection_points_detach('simple_heap_update-before-pin'); + SELECT FROM injection_points_wakeup('simple_heap_update-before-pin'); } step at4 { ALTER TABLE vactest.child50 INHERIT vactest.orig50; } step wakeinval4 { diff --git a/src/test/recovery/Makefile b/src/test/recovery/Makefile index d41aaaf8ae13d..2736caa1a1be4 100644 --- a/src/test/recovery/Makefile +++ b/src/test/recovery/Makefile @@ -9,7 +9,8 @@ # #------------------------------------------------------------------------- -EXTRA_INSTALL=contrib/pg_prewarm \ +EXTRA_INSTALL=contrib/amcheck \ + contrib/pg_prewarm \ contrib/pg_stat_statements \ contrib/test_decoding \ src/test/modules/injection_points diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build index ad0d85f41897e..8dbcda35775d8 100644 --- a/src/test/recovery/meson.build +++ b/src/test/recovery/meson.build @@ -63,6 +63,7 @@ tests += { 't/052_checkpoint_segment_missing.pl', 't/053_standby_login_event_trigger.pl', 't/054_unlogged_sequence_promotion.pl', + 't/055_hot_indexed_recovery.pl', ], }, } diff --git a/src/test/recovery/t/055_hot_indexed_recovery.pl b/src/test/recovery/t/055_hot_indexed_recovery.pl new file mode 100644 index 0000000000000..b6f077b3159e8 --- /dev/null +++ b/src/test/recovery/t/055_hot_indexed_recovery.pl @@ -0,0 +1,149 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Crash-recovery coverage for HOT-indexed (HOT/SIU) chains. +# +# Build a HOT-indexed chain by repeatedly UPDATEing a single row, +# changing one indexed (non-PK) column each time. Force a prune so the +# dead chain members collapse to LP_REDIRECT forwarders (with the live +# HOT-indexed version visible via pg_relation_hot_indexed_stats as +# n_hot_indexed > 0). Crash-recover the primary with stop('immediate') +# so the collapsed chain comes back from WAL or from the FPI. After +# restart, verify: +# +# 1. an index lookup walking the chain returns the live tuple, +# 2. pg_amcheck (verify_heapam) reports no errors on the relation, +# 3. VACUUM reclaims the collapsed chain (n_hot_indexed drops to 0). +# +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('primary'); +$node->init; +# Disable autovacuum to keep the chain shape stable up to the explicit +# prune we trigger below. +$node->append_conf('postgresql.conf', q{autovacuum = off +wal_consistency_checking = 'all'}); +$node->start; + +# amcheck (verify_heapam) is shipped as a contrib extension; we use it +# from SQL after the crash-restart cycle. +$node->safe_psql('postgres', q{CREATE EXTENSION amcheck}); + +# Wide-ish table: PK + four indexed columns plus a non-indexed payload +# so HOT-indexed updates have width to amortise. fillfactor = 50 keeps +# free space on-page for HOT-indexed continuations. +$node->safe_psql('postgres', q{ + CREATE TABLE hi_recov ( + id int PRIMARY KEY, + c1 int, + c2 int, + c3 int, + c4 int, + payload text + ) WITH (fillfactor = 50); + CREATE INDEX hi_recov_c1 ON hi_recov(c1); + CREATE INDEX hi_recov_c2 ON hi_recov(c2); + CREATE INDEX hi_recov_c3 ON hi_recov(c3); + CREATE INDEX hi_recov_c4 ON hi_recov(c4); + INSERT INTO hi_recov VALUES (1, 100, 200, 300, 400, 'payload'); +}); + +# Build a HOT-indexed chain: five UPDATEs, each touching one indexed +# column. Every UPDATE keeps the new version on-page and plants a fresh +# index entry because c1 is indexed and changed. Use a SQL transaction- +# range loop so each UPDATE is its own xact (xmin/xmax distinct). +for my $i (1 .. 5) +{ + my $newval = 100 + $i; + $node->safe_psql('postgres', + "UPDATE hi_recov SET c1 = $newval WHERE id = 1"); +} + +my $pre_prune = $node->safe_psql('postgres', + q{SELECT n_hot_indexed FROM pg_relation_hot_indexed_stats('hi_recov')}); +cmp_ok($pre_prune, '>', 0, + 'HOT-indexed chain has at least one live HOT-indexed version before prune'); + +# Force a prune. The chain has dead heap-only members from the early +# UPDATEs (their xmins are now committed and below the snapshot horizon). +# A SELECT under default isolation visits the page; under +# default_statistics_target etc. that's not enough on its own to trigger +# prune. The reliable way to drive opportunistic prune is a query that +# exercises the heap_page_prune_opt path, which fires from an indexscan +# that finds the page non-all-visible. Use a sequential scan plus a +# subsequent UPDATE that itself looks for free space (heap_update calls +# heap_page_prune_opt). +$node->safe_psql('postgres', q{ + SET enable_indexscan = off; + SELECT count(*) FROM hi_recov; + UPDATE hi_recov SET payload = 'pruned' WHERE id = 1; +}); + +# Read the chain state after the prune: the live HOT-indexed version +# remains while dead members collapse to LP_REDIRECT forwarders. +my $post_prune = $node->safe_psql('postgres', + q{SELECT n_hot_indexed FROM pg_relation_hot_indexed_stats('hi_recov')}); +cmp_ok($post_prune, '>', 0, + 'live HOT-indexed version survives opportunistic prune'); + +# Crash-restart. stop('immediate') is the standard "kill -9" simulation +# used elsewhere in src/test/recovery/. We intentionally do NOT issue a +# CHECKPOINT first: that would advance the redo point past the HOT-indexed +# UPDATE/prune records and leave nothing for recovery to replay. Crashing +# without a checkpoint forces startup redo to reconstruct the collapsed +# chain from WAL, and wal_consistency_checking = 'all' (set above) compares +# each replayed page against its full-page image, catching any divergence +# between the write path and the redo path. +$node->stop('immediate'); +$node->start; + +# 1. Chain walk via the indexed column on the live row returns the +# correct (and only the correct) tuple. c1 = 105 was the last +# UPDATE, so the live tuple has c1 = 105 and c2..c4 unchanged. +my $live = $node->safe_psql('postgres', q{ + SET enable_seqscan = off; + SELECT id, c1, c2, c3, c4, payload FROM hi_recov WHERE c1 = 105; +}); +is($live, "1|105|200|300|400|pruned", + 'index lookup on chain returns the post-prune live tuple'); + +# Older c1 values are not reachable: every stale btree entry that +# chain-resolves across a HOT/SIU hop must be dropped by the read-side +# crossed-attribute bitmap. +my $stale_count = $node->safe_psql('postgres', + q{SELECT count(*) FROM hi_recov WHERE c1 = 100}); +is($stale_count, '0', + 'stale btree entries are filtered by the crossed-attribute bitmap'); + +# 2. verify_heapam reports no errors on the relation (skip_option = +# 'all-frozen' is the default; we want to scan everything). +my $heapcheck = $node->safe_psql('postgres', q{ + SELECT count(*) FROM verify_heapam('hi_recov', + skip := 'none', + check_toast := false); +}); +is($heapcheck, '0', + 'verify_heapam reports zero errors after crash recovery'); + +# 3. Reclamation: after the live row is deleted, two VACUUM (FREEZE) +# passes drive prune to revisit the page and reclaim the now-fully-dead +# collapsed chain (the first removes the dead row's index entries and +# reduces its LP; the second reclaims the unreferenced members and +# re-points the redirect). After that, n_hot_indexed must be zero. +$node->safe_psql('postgres', q{DELETE FROM hi_recov WHERE id = 1}); +$node->safe_psql('postgres', + q{VACUUM (FREEZE, DISABLE_PAGE_SKIPPING) hi_recov}); +$node->safe_psql('postgres', + q{VACUUM (FREEZE, DISABLE_PAGE_SKIPPING) hi_recov}); +my $final = $node->safe_psql('postgres', + q{SELECT n_hot_indexed FROM pg_relation_hot_indexed_stats('hi_recov')}); +is($final, '0', + 'two VACUUM (FREEZE) passes after DELETE reclaim the chain post-recovery'); + +$node->stop; + +done_testing(); diff --git a/src/test/regress/expected/generated_virtual.out b/src/test/regress/expected/generated_virtual.out index 6ee029796f178..bdc9e9727a4b1 100644 --- a/src/test/regress/expected/generated_virtual.out +++ b/src/test/regress/expected/generated_virtual.out @@ -287,7 +287,7 @@ DETAIL: Column "b" is a generated column. INSERT INTO gtest1v VALUES (8, DEFAULT), (9, DEFAULT); -- error ERROR: cannot insert a non-DEFAULT value into column "b" DETAIL: Column "b" is a generated column. -SELECT * FROM gtest1v; +SELECT * FROM gtest1v ORDER BY a; a | b ---+---- 3 | 6 diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out index 6a3341356da1f..a3b2cd98ff877 100644 --- a/src/test/regress/expected/rules.out +++ b/src/test/regress/expected/rules.out @@ -1810,6 +1810,8 @@ pg_stat_all_indexes| SELECT c.oid AS relid, pg_stat_get_lastscan(i.oid) AS last_idx_scan, pg_stat_get_tuples_returned(i.oid) AS idx_tup_read, pg_stat_get_tuples_fetched(i.oid) AS idx_tup_fetch, + pg_stat_get_tuples_hot_indexed_updated_skipped(i.oid) AS n_tup_hot_indexed_upd_skipped, + pg_stat_get_tuples_hot_indexed_updated_matched(i.oid) AS n_tup_hot_indexed_upd_matched, pg_stat_get_stat_reset_time(i.oid) AS stats_reset FROM (((pg_class c JOIN pg_index x ON ((c.oid = x.indrelid))) @@ -1829,6 +1831,7 @@ pg_stat_all_tables| SELECT c.oid AS relid, pg_stat_get_tuples_updated(c.oid) AS n_tup_upd, pg_stat_get_tuples_deleted(c.oid) AS n_tup_del, pg_stat_get_tuples_hot_updated(c.oid) AS n_tup_hot_upd, + pg_stat_get_tuples_hot_indexed_updated(c.oid) AS n_tup_hot_indexed_upd, pg_stat_get_tuples_newpage_updated(c.oid) AS n_tup_newpage_upd, pg_stat_get_live_tuples(c.oid) AS n_live_tup, pg_stat_get_dead_tuples(c.oid) AS n_dead_tup, @@ -2335,6 +2338,8 @@ pg_stat_sys_indexes| SELECT relid, last_idx_scan, idx_tup_read, idx_tup_fetch, + n_tup_hot_indexed_upd_skipped, + n_tup_hot_indexed_upd_matched, stats_reset FROM pg_stat_all_indexes WHERE ((schemaname = ANY (ARRAY['pg_catalog'::name, 'information_schema'::name])) OR (schemaname ~ '^pg_toast'::text)); @@ -2351,6 +2356,7 @@ pg_stat_sys_tables| SELECT relid, n_tup_upd, n_tup_del, n_tup_hot_upd, + n_tup_hot_indexed_upd, n_tup_newpage_upd, n_live_tup, n_dead_tup, @@ -2390,6 +2396,8 @@ pg_stat_user_indexes| SELECT relid, last_idx_scan, idx_tup_read, idx_tup_fetch, + n_tup_hot_indexed_upd_skipped, + n_tup_hot_indexed_upd_matched, stats_reset FROM pg_stat_all_indexes WHERE ((schemaname <> ALL (ARRAY['pg_catalog'::name, 'information_schema'::name])) AND (schemaname !~ '^pg_toast'::text)); @@ -2406,6 +2414,7 @@ pg_stat_user_tables| SELECT relid, n_tup_upd, n_tup_del, n_tup_hot_upd, + n_tup_hot_indexed_upd, n_tup_newpage_upd, n_live_tup, n_dead_tup, @@ -2461,6 +2470,7 @@ pg_stat_xact_all_tables| SELECT c.oid AS relid, pg_stat_get_xact_tuples_updated(c.oid) AS n_tup_upd, pg_stat_get_xact_tuples_deleted(c.oid) AS n_tup_del, pg_stat_get_xact_tuples_hot_updated(c.oid) AS n_tup_hot_upd, + pg_stat_get_xact_tuples_hot_indexed_updated(c.oid) AS n_tup_hot_indexed_upd, pg_stat_get_xact_tuples_newpage_updated(c.oid) AS n_tup_newpage_upd FROM ((pg_class c LEFT JOIN pg_index i ON ((c.oid = i.indrelid))) @@ -2478,6 +2488,7 @@ pg_stat_xact_sys_tables| SELECT relid, n_tup_upd, n_tup_del, n_tup_hot_upd, + n_tup_hot_indexed_upd, n_tup_newpage_upd FROM pg_stat_xact_all_tables WHERE ((schemaname = ANY (ARRAY['pg_catalog'::name, 'information_schema'::name])) OR (schemaname ~ '^pg_toast'::text)); @@ -2501,6 +2512,7 @@ pg_stat_xact_user_tables| SELECT relid, n_tup_upd, n_tup_del, n_tup_hot_upd, + n_tup_hot_indexed_upd, n_tup_newpage_upd FROM pg_stat_xact_all_tables WHERE ((schemaname <> ALL (ARRAY['pg_catalog'::name, 'information_schema'::name])) AND (schemaname !~ '^pg_toast'::text)); diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out index d201ad764f05a..cc1d9bbb146f3 100644 --- a/src/test/regress/expected/subscription.out +++ b/src/test/regress/expected/subscription.out @@ -139,18 +139,18 @@ CREATE SUBSCRIPTION regress_testsub4 CONNECTION 'dbname=regress_doesnotexist' PU WARNING: subscription was created, but is not connected HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications. \dRs+ regress_testsub4 - List of subscriptions - Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description -------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- - regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | | f | 0 | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | + List of subscriptions + Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | HOT-indexed on apply | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description +------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+----------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- + regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | | f | 0 | subset_only | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | (1 row) ALTER SUBSCRIPTION regress_testsub4 SET (origin = any); \dRs+ regress_testsub4 - List of subscriptions - Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description -------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- - regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | | f | 0 | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | + List of subscriptions + Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | HOT-indexed on apply | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description +------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+----------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- + regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | | f | 0 | subset_only | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | (1 row) DROP SUBSCRIPTION regress_testsub3; @@ -237,10 +237,10 @@ ALTER SUBSCRIPTION regress_testsub CONNECTION 'foobar'; ERROR: invalid connection string syntax: missing "=" after "foobar" in connection info string \dRs+ - List of subscriptions - Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description ------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------------+------------+------------------- - regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | | f | 0 | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | test subscription + List of subscriptions + Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | HOT-indexed on apply | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description +-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+----------------------+------------------+--------------------+-----------------------------+------------------+------------+------------------- + regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | | f | 0 | subset_only | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | test subscription (1 row) ALTER SUBSCRIPTION regress_testsub SET PUBLICATION testpub2, testpub3 WITH (refresh = false); @@ -249,10 +249,10 @@ ALTER SUBSCRIPTION regress_testsub SET (slot_name = 'newname'); ALTER SUBSCRIPTION regress_testsub SET (password_required = false); ALTER SUBSCRIPTION regress_testsub SET (run_as_owner = true); \dRs+ - List of subscriptions - Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description ------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+------------------+--------------------+------------------------------+------------------+------------+------------------- - regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | | f | 0 | f | off | dbname=regress_doesnotexist2 | -1 | 0/00000000 | test subscription + List of subscriptions + Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | HOT-indexed on apply | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description +-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+----------------------+------------------+--------------------+------------------------------+------------------+------------+------------------- + regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | | f | 0 | subset_only | f | off | dbname=regress_doesnotexist2 | -1 | 0/00000000 | test subscription (1 row) ALTER SUBSCRIPTION regress_testsub SET (password_required = true); @@ -268,10 +268,10 @@ ERROR: unrecognized subscription parameter: "create_slot" -- ok ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/12345'); \dRs+ - List of subscriptions - Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description ------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+------------------+--------------------+------------------------------+------------------+------------+------------------- - regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | | f | 0 | f | off | dbname=regress_doesnotexist2 | -1 | 0/00012345 | test subscription + List of subscriptions + Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | HOT-indexed on apply | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description +-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+----------------------+------------------+--------------------+------------------------------+------------------+------------+------------------- + regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | | f | 0 | subset_only | f | off | dbname=regress_doesnotexist2 | -1 | 0/00012345 | test subscription (1 row) -- ok - with lsn = NONE @@ -280,10 +280,10 @@ ALTER SUBSCRIPTION regress_testsub SKIP (lsn = NONE); ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/0'); ERROR: invalid WAL location (LSN): 0/0 \dRs+ - List of subscriptions - Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description ------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+------------------+--------------------+------------------------------+------------------+------------+------------------- - regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | | f | 0 | f | off | dbname=regress_doesnotexist2 | -1 | 0/00000000 | test subscription + List of subscriptions + Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | HOT-indexed on apply | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description +-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+----------------------+------------------+--------------------+------------------------------+------------------+------------+------------------- + regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | | f | 0 | subset_only | f | off | dbname=regress_doesnotexist2 | -1 | 0/00000000 | test subscription (1 row) BEGIN; @@ -319,10 +319,10 @@ ALTER SUBSCRIPTION regress_testsub_foo SET (wal_receiver_timeout = '80s'); ALTER SUBSCRIPTION regress_testsub_foo SET (wal_receiver_timeout = 'foobar'); ERROR: invalid value for parameter "wal_receiver_timeout": "foobar" \dRs+ - List of subscriptions - Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description ----------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+------------------+--------------------+------------------------------+------------------+------------+------------------- - regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | | f | 0 | f | local | dbname=regress_doesnotexist2 | 80s | 0/00000000 | test subscription + List of subscriptions + Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | HOT-indexed on apply | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description +---------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+----------------------+------------------+--------------------+------------------------------+------------------+------------+------------------- + regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | | f | 0 | subset_only | f | local | dbname=regress_doesnotexist2 | 80s | 0/00000000 | test subscription (1 row) -- rename back to keep the rest simple @@ -351,19 +351,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB WARNING: subscription was created, but is not connected HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications. \dRs+ - List of subscriptions - Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description ------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- - regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | | f | 0 | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | + List of subscriptions + Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | HOT-indexed on apply | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description +-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+----------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- + regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | | f | 0 | subset_only | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | (1 row) ALTER SUBSCRIPTION regress_testsub SET (binary = false); ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE); \dRs+ - List of subscriptions - Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description ------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- - regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | | f | 0 | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | + List of subscriptions + Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | HOT-indexed on apply | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description +-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+----------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- + regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | | f | 0 | subset_only | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | (1 row) DROP SUBSCRIPTION regress_testsub; @@ -375,27 +375,27 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB WARNING: subscription was created, but is not connected HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications. \dRs+ - List of subscriptions - Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description ------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- - regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | | f | 0 | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | + List of subscriptions + Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | HOT-indexed on apply | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description +-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+----------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- + regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | | f | 0 | subset_only | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | (1 row) ALTER SUBSCRIPTION regress_testsub SET (streaming = parallel); \dRs+ - List of subscriptions - Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description ------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- - regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | | f | 0 | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | + List of subscriptions + Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | HOT-indexed on apply | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description +-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+----------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- + regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | | f | 0 | subset_only | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | (1 row) ALTER SUBSCRIPTION regress_testsub SET (streaming = false); ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE); \dRs+ - List of subscriptions - Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description ------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- - regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | | f | 0 | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | + List of subscriptions + Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | HOT-indexed on apply | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description +-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+----------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- + regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | | f | 0 | subset_only | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | (1 row) -- fail - publication already exists @@ -410,10 +410,10 @@ ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refr ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refresh = false); ERROR: publication "testpub1" is already in subscription "regress_testsub" \dRs+ - List of subscriptions - Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description ------------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- - regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | | f | 0 | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | + List of subscriptions + Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | HOT-indexed on apply | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description +-----------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+----------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- + regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | | f | 0 | subset_only | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | (1 row) -- fail - publication used more than once @@ -428,10 +428,10 @@ ERROR: publication "testpub3" is not in subscription "regress_testsub" -- ok - delete publications ALTER SUBSCRIPTION regress_testsub DROP PUBLICATION testpub1, testpub2 WITH (refresh = false); \dRs+ - List of subscriptions - Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description ------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- - regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | | f | 0 | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | + List of subscriptions + Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | HOT-indexed on apply | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description +-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+----------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- + regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | | f | 0 | subset_only | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | (1 row) DROP SUBSCRIPTION regress_testsub; @@ -467,19 +467,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB WARNING: subscription was created, but is not connected HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications. \dRs+ - List of subscriptions - Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description ------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- - regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | | f | 0 | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | + List of subscriptions + Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | HOT-indexed on apply | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description +-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+----------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- + regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | | f | 0 | subset_only | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | (1 row) -- we can alter streaming when two_phase enabled ALTER SUBSCRIPTION regress_testsub SET (streaming = true); \dRs+ - List of subscriptions - Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description ------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- - regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | | f | 0 | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | + List of subscriptions + Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | HOT-indexed on apply | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description +-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+----------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- + regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | | f | 0 | subset_only | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | (1 row) ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE); @@ -489,10 +489,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB WARNING: subscription was created, but is not connected HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications. \dRs+ - List of subscriptions - Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description ------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- - regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | | f | 0 | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | + List of subscriptions + Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | HOT-indexed on apply | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description +-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+----------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- + regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | | f | 0 | subset_only | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | (1 row) ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE); @@ -505,18 +505,18 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB WARNING: subscription was created, but is not connected HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications. \dRs+ - List of subscriptions - Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description ------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- - regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | | f | 0 | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | + List of subscriptions + Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | HOT-indexed on apply | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description +-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+----------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- + regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | | f | 0 | subset_only | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | (1 row) ALTER SUBSCRIPTION regress_testsub SET (disable_on_error = true); \dRs+ - List of subscriptions - Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description ------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- - regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | | f | 0 | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | + List of subscriptions + Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | HOT-indexed on apply | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description +-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+----------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- + regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | | f | 0 | subset_only | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | (1 row) ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE); @@ -529,10 +529,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB WARNING: subscription was created, but is not connected HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications. \dRs+ - List of subscriptions - Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description ------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- - regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | | f | 0 | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | + List of subscriptions + Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | HOT-indexed on apply | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description +-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+----------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- + regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | | f | 0 | subset_only | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | (1 row) ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE); @@ -549,10 +549,10 @@ NOTICE: max_retention_duration is ineffective when retain_dead_tuples is disabl WARNING: subscription was created, but is not connected HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications. \dRs+ - List of subscriptions - Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description ------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- - regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | | f | 1000 | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | + List of subscriptions + Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | HOT-indexed on apply | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description +-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+----------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- + regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | | f | 1000 | subset_only | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | (1 row) -- fail - max_retention_duration must be non-negative @@ -561,10 +561,10 @@ ERROR: max_retention_duration cannot be negative -- ok ALTER SUBSCRIPTION regress_testsub SET (max_retention_duration = 0); \dRs+ - List of subscriptions - Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description ------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- - regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | | f | 0 | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | + List of subscriptions + Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Server | Retain dead tuples | Max retention duration | HOT-indexed on apply | Retention active | Synchronous commit | Conninfo | Receiver timeout | Skip LSN | Description +-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------+--------------------+------------------------+----------------------+------------------+--------------------+-----------------------------+------------------+------------+------------- + regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | | f | 0 | subset_only | f | off | dbname=regress_doesnotexist | -1 | 0/00000000 | (1 row) ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE); diff --git a/src/test/regress/expected/triggers.out b/src/test/regress/expected/triggers.out index 8fcb33ac81a62..00ebe3058757b 100644 --- a/src/test/regress/expected/triggers.out +++ b/src/test/regress/expected/triggers.out @@ -959,16 +959,24 @@ NOTICE: main_view BEFORE UPDATE STATEMENT (before_view_upd_stmt) NOTICE: main_view AFTER UPDATE STATEMENT (after_view_upd_stmt) UPDATE 0 -- Delete from view using trigger -DELETE FROM main_view WHERE a IN (20,21); +DELETE FROM main_view WHERE a = 20 AND b = 31; NOTICE: main_view BEFORE DELETE STATEMENT (before_view_del_stmt) NOTICE: main_view INSTEAD OF DELETE ROW (instead_of_del) -NOTICE: OLD: (21,10) -NOTICE: main_view INSTEAD OF DELETE ROW (instead_of_del) NOTICE: OLD: (20,31) +NOTICE: main_view AFTER DELETE STATEMENT (after_view_del_stmt) +DELETE 1 +DELETE FROM main_view WHERE a = 21 AND b = 10; +NOTICE: main_view BEFORE DELETE STATEMENT (before_view_del_stmt) +NOTICE: main_view INSTEAD OF DELETE ROW (instead_of_del) +NOTICE: OLD: (21,10) +NOTICE: main_view AFTER DELETE STATEMENT (after_view_del_stmt) +DELETE 1 +DELETE FROM main_view WHERE a = 21 AND b = 32; +NOTICE: main_view BEFORE DELETE STATEMENT (before_view_del_stmt) NOTICE: main_view INSTEAD OF DELETE ROW (instead_of_del) NOTICE: OLD: (21,32) NOTICE: main_view AFTER DELETE STATEMENT (after_view_del_stmt) -DELETE 3 +DELETE 1 DELETE FROM main_view WHERE a = 31 RETURNING a, b; NOTICE: main_view BEFORE DELETE STATEMENT (before_view_del_stmt) NOTICE: main_view INSTEAD OF DELETE ROW (instead_of_del) diff --git a/src/test/regress/expected/tsearch.out b/src/test/regress/expected/tsearch.out index 5b7c2123f373e..6dc193f02d66a 100644 --- a/src/test/regress/expected/tsearch.out +++ b/src/test/regress/expected/tsearch.out @@ -2493,7 +2493,8 @@ SELECT to_tsquery('SKIES & My | booKs'); 'sky' | 'book' (1 row) ---trigger +-- tsvector_update_trigger() uses heap_modify_tuple() to set column 'a' +-- without going through the executor's SET-clause tracking. CREATE TRIGGER tsvectorupdate BEFORE UPDATE OR INSERT ON test_tsvector FOR EACH ROW EXECUTE PROCEDURE tsvector_update_trigger(a, 'pg_catalog.english', t); diff --git a/src/test/regress/expected/updatable_views.out b/src/test/regress/expected/updatable_views.out index 9c6bb2219f922..18f2591c069e1 100644 --- a/src/test/regress/expected/updatable_views.out +++ b/src/test/regress/expected/updatable_views.out @@ -372,15 +372,15 @@ INSERT INTO rw_view16 (a, b) VALUES (3, 'Row 3'); -- should be OK UPDATE rw_view16 SET a=3, aa=-3 WHERE a=3; -- should fail ERROR: multiple assignments to same column "a" UPDATE rw_view16 SET aa=-3 WHERE a=3; -- should be OK -SELECT * FROM base_tbl; +SELECT * FROM base_tbl ORDER BY a; a | b ----+-------- + -3 | Row 3 -2 | Row -2 -1 | Row -1 0 | Row 0 1 | Row 1 2 | Row 2 - -3 | Row 3 (6 rows) DELETE FROM rw_view16 WHERE a=-3; -- should be OK diff --git a/src/test/regress/sql/generated_virtual.sql b/src/test/regress/sql/generated_virtual.sql index ed9d50fe784c0..fe63928f105f3 100644 --- a/src/test/regress/sql/generated_virtual.sql +++ b/src/test/regress/sql/generated_virtual.sql @@ -127,7 +127,7 @@ ALTER VIEW gtest1v ALTER COLUMN b SET DEFAULT 100; INSERT INTO gtest1v VALUES (8, DEFAULT); -- error INSERT INTO gtest1v VALUES (8, DEFAULT), (9, DEFAULT); -- error -SELECT * FROM gtest1v; +SELECT * FROM gtest1v ORDER BY a; DELETE FROM gtest1v WHERE a >= 5; DROP VIEW gtest1v; diff --git a/src/test/regress/sql/triggers.sql b/src/test/regress/sql/triggers.sql index 2285e90110ea6..19c2572201fa8 100644 --- a/src/test/regress/sql/triggers.sql +++ b/src/test/regress/sql/triggers.sql @@ -660,7 +660,9 @@ UPDATE main_view SET b = 32 WHERE a = 21 AND b = 31 RETURNING a, b; UPDATE main_view SET b = 0 WHERE false; -- Delete from view using trigger -DELETE FROM main_view WHERE a IN (20,21); +DELETE FROM main_view WHERE a = 20 AND b = 31; +DELETE FROM main_view WHERE a = 21 AND b = 10; +DELETE FROM main_view WHERE a = 21 AND b = 32; DELETE FROM main_view WHERE a = 31 RETURNING a, b; \set QUIET true diff --git a/src/test/regress/sql/tsearch.sql b/src/test/regress/sql/tsearch.sql index 8b3d700f57cdb..094181e776429 100644 --- a/src/test/regress/sql/tsearch.sql +++ b/src/test/regress/sql/tsearch.sql @@ -760,7 +760,8 @@ SELECT to_tsvector('SKIES My booKs'); SELECT plainto_tsquery('SKIES My booKs'); SELECT to_tsquery('SKIES & My | booKs'); ---trigger +-- tsvector_update_trigger() uses heap_modify_tuple() to set column 'a' +-- without going through the executor's SET-clause tracking. CREATE TRIGGER tsvectorupdate BEFORE UPDATE OR INSERT ON test_tsvector FOR EACH ROW EXECUTE PROCEDURE tsvector_update_trigger(a, 'pg_catalog.english', t); diff --git a/src/test/regress/sql/updatable_views.sql b/src/test/regress/sql/updatable_views.sql index 2ef9aa32f364e..6108cd10b9e14 100644 --- a/src/test/regress/sql/updatable_views.sql +++ b/src/test/regress/sql/updatable_views.sql @@ -125,7 +125,7 @@ INSERT INTO rw_view16 VALUES (3, 'Row 3', 3); -- should fail INSERT INTO rw_view16 (a, b) VALUES (3, 'Row 3'); -- should be OK UPDATE rw_view16 SET a=3, aa=-3 WHERE a=3; -- should fail UPDATE rw_view16 SET aa=-3 WHERE a=3; -- should be OK -SELECT * FROM base_tbl; +SELECT * FROM base_tbl ORDER BY a; DELETE FROM rw_view16 WHERE a=-3; -- should be OK -- Read-only views INSERT INTO ro_view17 VALUES (3, 'ROW 3'); diff --git a/src/test/subscription/meson.build b/src/test/subscription/meson.build index e71e95c6297eb..f7ee5b8449a44 100644 --- a/src/test/subscription/meson.build +++ b/src/test/subscription/meson.build @@ -48,6 +48,8 @@ tests += { 't/036_sequences.pl', 't/037_except.pl', 't/038_walsnd_shutdown_timeout.pl', + 't/039_hot_indexed_apply.pl', + 't/040_hot_indexed_replica_identity.pl', 't/100_bugs.pl', ], }, diff --git a/src/test/subscription/t/039_hot_indexed_apply.pl b/src/test/subscription/t/039_hot_indexed_apply.pl new file mode 100644 index 0000000000000..fe108e6f08156 --- /dev/null +++ b/src/test/subscription/t/039_hot_indexed_apply.pl @@ -0,0 +1,416 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Per-subscription hot_indexed_on_apply option: parser, catalog round-trip, +# ALTER behaviour, and apply-path gating under each of the three modes. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; +use Time::HiRes qw(usleep); + +my $publisher = PostgreSQL::Test::Cluster->new('publisher'); +$publisher->init(allows_streaming => 'logical'); +$publisher->start; + +my $subscriber = PostgreSQL::Test::Cluster->new('subscriber'); +$subscriber->init; +$subscriber->start; + +my $pub_conninfo = $publisher->connstr . ' dbname=postgres'; + +# --- Schema ---------------------------------------------------------------- +# tab_extra has an extra btree index beyond the primary key on the +# subscriber side; that is the schema shape that subset_only must demote +# to non-HOT on apply but always must let through. +$publisher->safe_psql('postgres', + q{CREATE TABLE tab_extra (id int PRIMARY KEY, payload int, tag text)}); + +# tab_pk has only the primary key; indexed-attr set is a subset of the PK +# attrs, so subset_only and always should both allow HOT-indexed on apply. +$publisher->safe_psql('postgres', + q{CREATE TABLE tab_pk (id int PRIMARY KEY, payload int)}); + +$publisher->safe_psql('postgres', + q{CREATE PUBLICATION pub FOR TABLE tab_extra, tab_pk}); + +# Subscriber mirrors both tables. tab_extra has the extra secondary index +# only on the subscriber, which is the schema-divergence case the option +# gates. +$subscriber->safe_psql('postgres', + q{CREATE TABLE tab_extra (id int PRIMARY KEY, payload int, tag text)}); +$subscriber->safe_psql('postgres', + q{CREATE INDEX tab_extra_payload_idx ON tab_extra(payload)}); +$subscriber->safe_psql('postgres', + q{CREATE TABLE tab_pk (id int PRIMARY KEY, payload int)}); + +# --- Parser / catalog checks ---------------------------------------------- +# Default on fresh subscription is 's' (subset_only). +$subscriber->safe_psql('postgres', qq{ + CREATE SUBSCRIPTION sub_default + CONNECTION '$pub_conninfo' + PUBLICATION pub + WITH (connect = false, slot_name = NONE, enabled = false, + create_slot = false); +}); +is( $subscriber->safe_psql('postgres', + q{SELECT subhotindexedonapply FROM pg_subscription + WHERE subname = 'sub_default'}), + 's', + 'fresh subscription defaults to subset_only'); + +# Explicit 'always' is stored as 'a'. +$subscriber->safe_psql('postgres', qq{ + CREATE SUBSCRIPTION sub_always_p + CONNECTION '$pub_conninfo' + PUBLICATION pub + WITH (connect = false, slot_name = NONE, enabled = false, + create_slot = false, hot_indexed_on_apply = 'always'); +}); +is( $subscriber->safe_psql('postgres', + q{SELECT subhotindexedonapply FROM pg_subscription + WHERE subname = 'sub_always_p'}), + 'a', + 'CREATE with hot_indexed_on_apply = always stores a'); + +# ALTER SUBSCRIPTION SET updates the column. +$subscriber->safe_psql('postgres', + q{ALTER SUBSCRIPTION sub_default SET (hot_indexed_on_apply = 'off')}); +is( $subscriber->safe_psql('postgres', + q{SELECT subhotindexedonapply FROM pg_subscription + WHERE subname = 'sub_default'}), + 'o', + 'ALTER SUBSCRIPTION SET hot_indexed_on_apply = off stores o'); + +# Unknown values are rejected. +my ($ret, $stdout, $stderr) = $subscriber->psql('postgres', qq{ + CREATE SUBSCRIPTION sub_bogus + CONNECTION '$pub_conninfo' + PUBLICATION pub + WITH (connect = false, slot_name = NONE, enabled = false, + create_slot = false, hot_indexed_on_apply = 'bogus'); +}); +isnt($ret, 0, 'bogus hot_indexed_on_apply value is rejected'); +like($stderr, + qr/unrecognized value for subscription parameter "hot_indexed_on_apply"/, + 'bogus hot_indexed_on_apply value reports the expected error'); + +# Drop the placeholder subscriptions so we can rebuild with real slots. +$subscriber->safe_psql('postgres', 'DROP SUBSCRIPTION sub_default'); +$subscriber->safe_psql('postgres', 'DROP SUBSCRIPTION sub_always_p'); + +# --- Apply-path behaviour ------------------------------------------------- +# Pre-populate both sides identically so we can use copy_data=false and +# avoid duplicate-key conflicts when we recreate subscriptions across the +# three test cases. We update non-overlapping id ranges per case so the +# pg_stat counters segment cleanly. +$publisher->safe_psql('postgres', + q{INSERT INTO tab_extra + SELECT g, 0, 't' FROM generate_series(1, 200) g}); +$publisher->safe_psql('postgres', + q{INSERT INTO tab_pk + SELECT g, 0 FROM generate_series(1, 200) g}); +$subscriber->safe_psql('postgres', + q{INSERT INTO tab_extra + SELECT g, 0, 't' FROM generate_series(1, 200) g}); +$subscriber->safe_psql('postgres', + q{INSERT INTO tab_pk + SELECT g, 0 FROM generate_series(1, 200) g}); + +# Helper: read counters and poll up to 10 s for n_tup_upd to reach a +# minimum target value (the apply worker flushes pgstat asynchronously). +sub poll_counters +{ + my ($node, $table, $upd_target) = @_; + + my $deadline = time() + 10; + my $row = ''; + while (1) + { + $row = $node->safe_psql('postgres', + qq{SELECT coalesce(n_tup_upd, 0), + coalesce(n_tup_hot_upd, 0), + coalesce(n_tup_hot_indexed_upd, 0) + FROM pg_stat_user_tables WHERE relname = '$table'}); + my ($upd) = split /\|/, $row; + last if ($upd + 0) >= $upd_target || time() >= $deadline; + usleep(100_000); + } + my ($upd, $hot, $hot_idx) = split /\|/, $row; + return ($upd + 0, $hot + 0, $hot_idx + 0); +} + +# Helper: fire UPDATEs that touch the indexed payload column on a given +# id range and return the deltas in (n_tup_upd, n_tup_hot_upd, +# n_tup_hot_indexed_upd) on the subscriber. +sub apply_updates_and_read +{ + my ($table, $sub_name, $id_lo, $id_hi) = @_; + + my ($upd0, $hot0, $hotidx0) = + poll_counters($subscriber, $table, 0); + + for my $i ($id_lo .. $id_hi) + { + $publisher->safe_psql('postgres', + "UPDATE $table SET payload = payload + 1 WHERE id = $i"); + } + $publisher->wait_for_catchup($sub_name); + + my $n = $id_hi - $id_lo + 1; + my ($upd1, $hot1, $hotidx1) = + poll_counters($subscriber, $table, $upd0 + $n); + note("$table $sub_name $id_lo..$id_hi: dn_upd=" + . ($upd1 - $upd0) . " dhot=" . ($hot1 - $hot0) + . " dhotidx=" . ($hotidx1 - $hotidx0)); + return ($upd1 - $upd0, $hot1 - $hot0, $hotidx1 - $hotidx0); +} + +# Case 1: off, subscriber-only secondary index. HOT-indexed must be +# suppressed on tab_extra. Plain HOT updates also stay zero because every +# UPDATE touches `payload` which is indexed on the subscriber. +$subscriber->safe_psql('postgres', qq{ + CREATE SUBSCRIPTION sub_off + CONNECTION '$pub_conninfo' + PUBLICATION pub + WITH (slot_name = 'sub_off_slot', create_slot = true, + hot_indexed_on_apply = 'off', copy_data = false); +}); +$publisher->wait_for_catchup('sub_off'); + +my (undef, undef, $off_extra_hotidx) = + apply_updates_and_read('tab_extra', 'sub_off', 1, 20); +is($off_extra_hotidx, 0, + 'hot_indexed_on_apply = off: no HOT-indexed updates on tab_extra'); + +$subscriber->safe_psql('postgres', 'DROP SUBSCRIPTION sub_off'); + +# Case 2: subset_only. On tab_pk (no secondary index, indexed-attr set is +# a subset of PK attrs), classic HOT must fire because `payload` is not +# indexed there. On tab_extra (subscriber's `payload` index is NOT covered +# by the PK), the apply worker must demote to non-HOT just like 'off'. +$subscriber->safe_psql('postgres', qq{ + CREATE SUBSCRIPTION sub_subset + CONNECTION '$pub_conninfo' + PUBLICATION pub + WITH (slot_name = 'sub_subset_slot', create_slot = true, + hot_indexed_on_apply = 'subset_only', copy_data = false); +}); +$publisher->wait_for_catchup('sub_subset'); + +my (undef, $ss_pk_hot, $ss_pk_hotidx) = + apply_updates_and_read('tab_pk', 'sub_subset', 1, 20); +cmp_ok($ss_pk_hot, '>', 0, + 'hot_indexed_on_apply = subset_only: classic HOT fires on tab_pk'); + +my (undef, undef, $ss_extra_hotidx) = + apply_updates_and_read('tab_extra', 'sub_subset', 21, 40); +is($ss_extra_hotidx, 0, + 'hot_indexed_on_apply = subset_only: no HOT-indexed on tab_extra'); + +$subscriber->safe_psql('postgres', 'DROP SUBSCRIPTION sub_subset'); + +# Case 3: always. Unconditional HOT-indexed eligibility. On tab_extra +# updates touching the indexed payload column should now run on the +# HOT-indexed path: n_tup_hot_indexed_upd must increase. +$subscriber->safe_psql('postgres', qq{ + CREATE SUBSCRIPTION sub_always + CONNECTION '$pub_conninfo' + PUBLICATION pub + WITH (slot_name = 'sub_always_slot', create_slot = true, + hot_indexed_on_apply = 'always', copy_data = false); +}); +$publisher->wait_for_catchup('sub_always'); + +my (undef, undef, $al_extra_hotidx) = + apply_updates_and_read('tab_extra', 'sub_always', 41, 80); +cmp_ok($al_extra_hotidx, '>', 0, + 'hot_indexed_on_apply = always: HOT-indexed fires on tab_extra'); + +# ALTER back to off and verify the apply worker picks up the new mode. +$subscriber->safe_psql('postgres', + q{ALTER SUBSCRIPTION sub_always SET (hot_indexed_on_apply = 'off')}); +is( $subscriber->safe_psql('postgres', + q{SELECT subhotindexedonapply FROM pg_subscription + WHERE subname = 'sub_always'}), + 'o', + 'ALTER sub_always SET hot_indexed_on_apply = off persists'); + +# Drive another batch of updates and confirm n_tup_hot_indexed_upd does NOT +# advance after the worker rereads the catalog. +my (undef, undef, $post_alter_hotidx) = + apply_updates_and_read('tab_extra', 'sub_always', 81, 100); +is($post_alter_hotidx, 0, + 'ALTER to off freezes n_tup_hot_indexed_upd after worker reread'); + +$subscriber->safe_psql('postgres', 'DROP SUBSCRIPTION sub_always'); + +# --- Subscriber INSERT-after-replicated-UPDATE per mode ------------------- +# +# Verify that a subscriber INSERT using the OLD value of a replicated +# UPDATE's indexed column succeeds without a spurious unique-violation +# under each apply mode. Use a dedicated table (tab_uk) so the unique +# constraint can be defined up-front and the test does not collide with +# pre-populated rows from the apply-path scenarios above. +# +# Publisher updates row $upd_id changing payload from 0 to 999. The +# subscriber then inserts a fresh row with payload=0 (the pre-update +# value). Under all three modes _bt_check_unique's recheck of the +# conflicting tuple's live key must recognize the stale leaf entry pointing +# at the chain root, so the INSERT succeeds. + +$publisher->safe_psql('postgres', + q{CREATE TABLE tab_uk ( + id int PRIMARY KEY, + payload int, + tag text, + UNIQUE (payload, tag))}); +$subscriber->safe_psql('postgres', + q{CREATE TABLE tab_uk ( + id int PRIMARY KEY, + payload int, + tag text, + UNIQUE (payload, tag))}); +$publisher->safe_psql('postgres', + q{ALTER PUBLICATION pub ADD TABLE tab_uk}); + +for my $mode ('off', 'subset_only', 'always') +{ + my $base_id = ($mode eq 'off') ? 1 + : ($mode eq 'subset_only') ? 100 : 200; + my $upd_id = $base_id + 1; + my $ins_id = $base_id + 2; + + # Seed a row that we will UPDATE on the publisher (payload starts at 0), + # and drain the apply for it before changing payload. + $publisher->safe_psql('postgres', + "INSERT INTO tab_uk VALUES ($upd_id, 0, 'mode_$mode')"); + + $subscriber->safe_psql('postgres', qq{ + CREATE SUBSCRIPTION sub_uk_$mode + CONNECTION '$pub_conninfo' + PUBLICATION pub + WITH (slot_name = 'sub_uk_${mode}_slot', create_slot = true, + hot_indexed_on_apply = '$mode', copy_data = true); + }); + $publisher->wait_for_catchup("sub_uk_$mode"); + + # Publisher UPDATE: payload 0 -> 999. + $publisher->safe_psql('postgres', + "UPDATE tab_uk SET payload = 999 WHERE id = $upd_id"); + $publisher->wait_for_catchup("sub_uk_$mode"); + + # Subscriber INSERT with the OLD payload value but a unique tag. The + # existing chain leaf with key (0, 'mode_$mode') is now stale: the + # live tuple at the chain root has payload=999. _bt_check_unique + # rechecks the conflicting tuple's live key and recognizes the stale + # leaf, allowing this INSERT to succeed. + my ($r, $out, $err) = $subscriber->psql('postgres', + "INSERT INTO tab_uk VALUES ($ins_id, 0, 'fresh_$mode')"); + is($r, 0, + "hot_indexed_on_apply = $mode: " + . "subscriber INSERT with old payload value succeeds"); + like($err, qr/^$/, + "hot_indexed_on_apply = $mode: " + . "INSERT did not raise an error"); + + $subscriber->safe_psql('postgres', + "DROP SUBSCRIPTION sub_uk_$mode"); +} + +# --- always-mode safety with indexed attrs beyond the replica identity ----- +# +# Amit's corner: under hot_indexed_on_apply = 'always' the apply worker may +# run a HOT-indexed update even when the table has an indexed attribute that +# is NOT covered by the replica identity. The read-side staleness mechanism +# must still let the apply worker's later replica-identity lookups find the +# row, and DELETE/UPDATE replication must converge, including when the +# replica-identity column itself is cycled away and back (ABA). +# +# tab_ri: replica identity is a UNIQUE index on rid (not the PK), and there +# is an extra secondary index on payload that is NOT part of the replica +# identity. So a payload change makes the payload leaf stale, and an rid +# ABA cycle makes the rid (replica-identity) leaf stale -- both while +# 'always' keeps the updates on the HOT chain. +$publisher->safe_psql('postgres', q{ + CREATE TABLE tab_ri (id int PRIMARY KEY, rid int NOT NULL, payload int); + CREATE UNIQUE INDEX tab_ri_rid_uk ON tab_ri(rid); + ALTER TABLE tab_ri REPLICA IDENTITY USING INDEX tab_ri_rid_uk; + CREATE PUBLICATION pub_ri FOR TABLE tab_ri; +}); +$subscriber->safe_psql('postgres', q{ + CREATE TABLE tab_ri (id int PRIMARY KEY, rid int NOT NULL, payload int); + CREATE UNIQUE INDEX tab_ri_rid_uk ON tab_ri(rid); + ALTER TABLE tab_ri REPLICA IDENTITY USING INDEX tab_ri_rid_uk; + CREATE INDEX tab_ri_payload_idx ON tab_ri(payload); +}); + +$publisher->safe_psql('postgres', + q{INSERT INTO tab_ri VALUES (1, 10, 0), (2, 20, 0)}); + +$subscriber->safe_psql('postgres', qq{ + CREATE SUBSCRIPTION sub_ri + CONNECTION '$pub_conninfo' + PUBLICATION pub_ri + WITH (slot_name = 'sub_ri_slot', create_slot = true, + hot_indexed_on_apply = 'always', copy_data = true); +}); +# Wait for the initial table COPY to finish, not just streaming catch-up, so +# the seeded rows are present before we start updating them. +$subscriber->wait_for_subscription_sync($publisher, 'sub_ri'); + +# Cycle the replica-identity column away and back (ABA), and also churn the +# extra payload index, all replicated under 'always'. Each step is a +# HOT-indexed update on the subscriber that leaves a stale leaf. +$publisher->safe_psql('postgres', q{ + UPDATE tab_ri SET rid = 11, payload = payload + 1 WHERE id = 1; + UPDATE tab_ri SET rid = 10, payload = payload + 1 WHERE id = 1; -- rid ABA + UPDATE tab_ri SET payload = payload + 1 WHERE id = 2; +}); +$publisher->wait_for_catchup('sub_ri'); + +# Confirm the apply worker actually took the HOT-indexed path on tab_ri (the +# whole point of 'always' with indexed attrs beyond the replica identity). +# Without this the convergence/verify_heapam asserts below could pass +# vacuously if eligibility silently regressed to plain non-HOT. +my (undef, undef, $ri_hotidx) = poll_counters($subscriber, 'tab_ri', 3); +cmp_ok($ri_hotidx, '>', 0, + 'always-mode: HOT-indexed path fired on tab_ri (rid/payload churn)'); + +# A subsequent replicated UPDATE keyed by the replica identity (rid) must +# find the row despite the stale rid/payload leaves the ABA left behind. +$publisher->safe_psql('postgres', + q{UPDATE tab_ri SET payload = 100 WHERE id = 1}); +# And a replicated DELETE resolved through the replica-identity index must +# also find and remove the right row. +$publisher->safe_psql('postgres', q{DELETE FROM tab_ri WHERE id = 2}); +$publisher->wait_for_catchup('sub_ri'); + +is( $subscriber->safe_psql('postgres', + q{SELECT rid || ':' || payload FROM tab_ri WHERE id = 1}), + '10:100', + 'always-mode: replicated UPDATE found the row via RI after rid ABA'); +is( $subscriber->safe_psql('postgres', + q{SELECT count(*) FROM tab_ri WHERE id = 2}), + '0', + 'always-mode: replicated DELETE found the row via RI with stale leaves'); +# Full convergence cross-check. +is( $subscriber->safe_psql('postgres', + q{SELECT string_agg(id || ',' || rid || ',' || payload, ';' ORDER BY id) + FROM tab_ri}), + $publisher->safe_psql('postgres', + q{SELECT string_agg(id || ',' || rid || ',' || payload, ';' ORDER BY id) + FROM tab_ri}), + 'always-mode: tab_ri converges between publisher and subscriber'); + +# verify_heapam finds no corruption in the HOT-indexed chains left behind. +$subscriber->safe_psql('postgres', 'CREATE EXTENSION IF NOT EXISTS amcheck'); +is( $subscriber->safe_psql('postgres', + q{SELECT count(*) FROM verify_heapam('tab_ri')}), + '0', + 'always-mode: verify_heapam clean on tab_ri after stale-leaf churn'); + +$subscriber->safe_psql('postgres', 'DROP SUBSCRIPTION sub_ri'); + +done_testing(); diff --git a/src/test/subscription/t/040_hot_indexed_replica_identity.pl b/src/test/subscription/t/040_hot_indexed_replica_identity.pl new file mode 100644 index 0000000000000..f801787b4c0d4 --- /dev/null +++ b/src/test/subscription/t/040_hot_indexed_replica_identity.pl @@ -0,0 +1,110 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Live logical replication of HOT-indexed updates under non-default replica +# identities. The apply worker locates the row to update or delete via the +# replica identity: a seqscan for REPLICA IDENTITY FULL, and the nominated +# index for REPLICA IDENTITY USING INDEX. On a subscriber whose tables carry +# extra indexes (so apply performs HOT-indexed updates and leaves stale index +# leaves), that lookup must still find the current row -- including after the +# identity column's value is cycled away and back (ABA). +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $publisher = PostgreSQL::Test::Cluster->new('publisher'); +$publisher->init(allows_streaming => 'logical'); +$publisher->start; + +my $subscriber = PostgreSQL::Test::Cluster->new('subscriber'); +$subscriber->init; +$subscriber->start; + +my $pub_conninfo = $publisher->connstr . ' dbname=postgres'; + +# tab_full: REPLICA IDENTITY FULL (apply uses a sequential scan). +# tab_idx: REPLICA IDENTITY USING INDEX on a non-PK unique index whose +# column is itself updated, so the apply-side index lookup must +# tolerate stale leaves left by earlier HOT-indexed updates. +$publisher->safe_psql('postgres', q{ + CREATE TABLE tab_full (a int, b int, c int); + ALTER TABLE tab_full REPLICA IDENTITY FULL; + CREATE TABLE tab_idx (k int NOT NULL, v int, w int); + CREATE UNIQUE INDEX tab_idx_k ON tab_idx (k); + ALTER TABLE tab_idx REPLICA IDENTITY USING INDEX tab_idx_k; + CREATE PUBLICATION pub FOR TABLE tab_full, tab_idx; +}); + +# The subscriber adds extra secondary indexes so that an UPDATE changing one +# indexed column stays HOT-indexed on apply. +$subscriber->safe_psql('postgres', q{ + CREATE TABLE tab_full (a int, b int, c int) WITH (fillfactor = 50); + CREATE INDEX tab_full_b ON tab_full (b); + CREATE INDEX tab_full_c ON tab_full (c); + ALTER TABLE tab_full REPLICA IDENTITY FULL; + CREATE TABLE tab_idx (k int NOT NULL, v int, w int) WITH (fillfactor = 50); + CREATE UNIQUE INDEX tab_idx_k ON tab_idx (k); + CREATE INDEX tab_idx_v ON tab_idx (v); + CREATE INDEX tab_idx_w ON tab_idx (w); + ALTER TABLE tab_idx REPLICA IDENTITY USING INDEX tab_idx_k; +}); + +# Allow HOT-indexed updates on the apply path. +$subscriber->safe_psql('postgres', qq{ + CREATE SUBSCRIPTION sub + CONNECTION '$pub_conninfo' + PUBLICATION pub + WITH (hot_indexed_on_apply = always); +}); +$subscriber->wait_for_subscription_sync($publisher, 'sub'); + +# Seed both tables. +$publisher->safe_psql('postgres', q{ + INSERT INTO tab_full VALUES (1, 10, 100), (2, 20, 200); + INSERT INTO tab_idx VALUES (1, 10, 1000), (2, 20, 2000); +}); +$publisher->wait_for_catchup('sub'); + +# A run of single-column updates: each stays HOT-indexed on the subscriber and +# leaves stale leaves, then the identity/PK row is matched again by the next +# change. Include an ABA cycle on the USING INDEX column k (1 -> 3 -> 1). +$publisher->safe_psql('postgres', q{ + UPDATE tab_full SET b = b + 1 WHERE a = 1; + UPDATE tab_full SET c = c + 1 WHERE a = 1; + UPDATE tab_full SET b = b + 1 WHERE a = 1; + UPDATE tab_idx SET v = v + 1 WHERE k = 1; + UPDATE tab_idx SET k = 3 WHERE k = 1; + UPDATE tab_idx SET w = w + 1 WHERE k = 3; + UPDATE tab_idx SET k = 1 WHERE k = 3; + DELETE FROM tab_full WHERE a = 2; + DELETE FROM tab_idx WHERE k = 2; +}); +$publisher->wait_for_catchup('sub'); + +# The subscriber must match the publisher exactly: the RI lookups found the +# right rows across the HOT-indexed chains and the ABA cycle. +my $pub_full = $publisher->safe_psql('postgres', + q{SELECT a, b, c FROM tab_full ORDER BY a}); +my $sub_full = $subscriber->safe_psql('postgres', + q{SELECT a, b, c FROM tab_full ORDER BY a}); +is($sub_full, $pub_full, 'REPLICA IDENTITY FULL: subscriber matches publisher'); + +my $pub_idx = $publisher->safe_psql('postgres', + q{SELECT k, v, w FROM tab_idx ORDER BY k}); +my $sub_idx = $subscriber->safe_psql('postgres', + q{SELECT k, v, w FROM tab_idx ORDER BY k}); +is($sub_idx, $pub_idx, + 'REPLICA IDENTITY USING INDEX: subscriber matches publisher across ABA'); + +# The subscriber's tables must be structurally consistent (stubs recognised). +$subscriber->safe_psql('postgres', q{CREATE EXTENSION amcheck}); +is( $subscriber->safe_psql('postgres', + q{SELECT count(*) FROM verify_heapam('tab_idx')}), + '0', + 'subscriber tab_idx has no heap corruption after HOT-indexed apply'); + +$subscriber->stop; +$publisher->stop; + +done_testing(); diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list index 56c1f997f88b1..646ca57943c84 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -1277,6 +1277,7 @@ HeapTupleFreeze HeapTupleHeader HeapTupleHeaderData HeapTupleTableSlot +HeapUpdateIndexMode HistControl HostCacheEntry HostsFileLoadResult @@ -3141,7 +3142,6 @@ TSVectorStat TState TStatus TStoreState -TU_UpdateIndexes TXNEntryFile TYPCATEGORY T_Action