From a3f951653d56126b77f08c0dfe1e7f3c47d9044f Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Wed, 17 Jun 2026 21:38:11 -0400 Subject: [PATCH v59 08/10] Add HOT-indexed statistics and the comprehensive test suite Expose the HOT-indexed activity counters maintained by the write path: pg_stat_all_tables.n_tup_hot_indexed_upd, the per-index n_tup_hot_indexed_upd_matched / n_tup_hot_indexed_upd_skipped counters in pg_stat_all_indexes, and pg_relation_hot_indexed_stats() reporting per-relation HOT-indexed chain composition. Document them in monitoring.sgml and the README. With statistics, prune/collapse, and amcheck recognition all in place, add the full feature test suite, which uses those facilities to verify behavior: - hot_indexed_updates (regression): eligibility and classification; selective maintenance across multiple/composite indexes; the crossed-attribute read path for equality, range, and inequality scans; a key cycled away and back (ABA), including across two distinct live rows; TOASTed indexed columns; partial-index predicate flips (key and non-key predicate columns); trigger-modified indexed columns; exclusion-constraint tables; partitioned tables; non-btree access methods (hash, GIN, GiST); a UNIQUE index on a type where image equality differs from operator equality; CREATE INDEX / REINDEX and DROP INDEX over live chains; prune reclamation, stub mixes, and re-collapse across partial VACUUMs; the never-all-visible guard; and DDL after a chain exists (ADD COLUMN crossing a bitmap-size boundary, DROP COLUMN). - hot_indexed_adversarial (isolation): concurrent UPDATE / VACUUM / prune and index scans, key cycling, aborts, and reader consistency across a concurrent collapse. - 054_hot_indexed_recovery (recovery): WAL replay of the chain and its collapse under wal_consistency_checking. - pg_surgery handling of HOT-indexed tuples and collapse-survivor stubs. Authored-by: Greg Burd --- contrib/pg_surgery/Makefile | 1 + contrib/pg_surgery/expected/heap_surgery.out | 83 + contrib/pg_surgery/heap_surgery.c | 16 + contrib/pg_surgery/sql/heap_surgery.sql | 53 + doc/src/sgml/monitoring.sgml | 57 + src/backend/access/heap/Makefile | 1 + src/backend/access/heap/hot_indexed_stats.c | 188 ++ src/backend/access/heap/meson.build | 1 + src/backend/catalog/system_views.sql | 4 + src/backend/utils/adt/pgstatfuncs.c | 12 + src/include/catalog/pg_proc.dat | 28 + .../expected/hot_indexed_adversarial.out | 139 ++ src/test/isolation/isolation_schedule | 1 + .../specs/hot_indexed_adversarial.spec | 123 ++ src/test/recovery/Makefile | 3 +- src/test/recovery/meson.build | 1 + .../recovery/t/055_hot_indexed_recovery.pl | 149 ++ .../regress/expected/hot_indexed_updates.out | 1911 +++++++++++++++++ src/test/regress/expected/rules.out | 12 + src/test/regress/parallel_schedule | 1 + src/test/regress/sql/hot_indexed_updates.sql | 1316 ++++++++++++ 21 files changed, 4099 insertions(+), 1 deletion(-) create mode 100644 src/backend/access/heap/hot_indexed_stats.c create mode 100644 src/test/isolation/expected/hot_indexed_adversarial.out create mode 100644 src/test/isolation/specs/hot_indexed_adversarial.spec create mode 100644 src/test/recovery/t/055_hot_indexed_recovery.pl create mode 100644 src/test/regress/expected/hot_indexed_updates.out create mode 100644 src/test/regress/sql/hot_indexed_updates.sql diff --git a/contrib/pg_surgery/Makefile b/contrib/pg_surgery/Makefile index a66776c4c41..da752a81147 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 df7d13b0908..1a5fef5b2bb 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 b8ce1095782..7fc653cc334 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 6526b27535d..be4fed2af65 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/doc/src/sgml/monitoring.sgml b/doc/src/sgml/monitoring.sgml index 858788b227c..87c80257e6c 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/src/backend/access/heap/Makefile b/src/backend/access/heap/Makefile index 1d27ccb916e..dfaf3350736 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/hot_indexed_stats.c b/src/backend/access/heap/hot_indexed_stats.c new file mode 100644 index 00000000000..895a29654b5 --- /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 00ec07d7f30..b5c2a8d5cb6 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/catalog/system_views.sql b/src/backend/catalog/system_views.sql index 6c1c5545cb5..02c8a049a32 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 diff --git a/src/backend/utils/adt/pgstatfuncs.c b/src/backend/utils/adt/pgstatfuncs.c index 565d0e70768..552f3540cde 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/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat index 3cb84359adf..175fa88eb57 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/test/isolation/expected/hot_indexed_adversarial.out b/src/test/isolation/expected/hot_indexed_adversarial.out new file mode 100644 index 00000000000..22f62801513 --- /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 b8ebe92553c..e9afb48199e 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 00000000000..64705bf37e0 --- /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/recovery/Makefile b/src/test/recovery/Makefile index d41aaaf8ae1..2736caa1a1b 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 ad0d85f4189..8dbcda35775 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 00000000000..b6f077b3159 --- /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/hot_indexed_updates.out b/src/test/regress/expected/hot_indexed_updates.out new file mode 100644 index 00000000000..56d9ab0b17a --- /dev/null +++ b/src/test/regress/expected/hot_indexed_updates.out @@ -0,0 +1,1911 @@ +-- +-- 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(); + 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) + +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; +-- --------------------------------------------------------------------------- +-- 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/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out index 39905c2de14..d8285423220 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, @@ -2332,6 +2335,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)); @@ -2348,6 +2353,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, @@ -2387,6 +2393,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)); @@ -2403,6 +2411,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, @@ -2458,6 +2467,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))) @@ -2475,6 +2485,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)); @@ -2498,6 +2509,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/parallel_schedule b/src/test/regress/parallel_schedule index bd95cc24977..4fde5b6b0c6 100644 --- a/src/test/regress/parallel_schedule +++ b/src/test/regress/parallel_schedule @@ -147,6 +147,7 @@ test: fast_default # HOT updates tests # ---------- test: hot_updates +test: hot_indexed_updates # run tablespace test at the end because it drops the tablespace created during # setup that other tests may use. diff --git a/src/test/regress/sql/hot_indexed_updates.sql b/src/test/regress/sql/hot_indexed_updates.sql new file mode 100644 index 00000000000..93e1a2b6098 --- /dev/null +++ b/src/test/regress/sql/hot_indexed_updates.sql @@ -0,0 +1,1316 @@ +-- +-- 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'); +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; + +-- --------------------------------------------------------------------------- +-- 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. -- 2.50.1