Re: Tepid: selective index updates for heap relations

From: "Greg Burd" <greg(at)burd(dot)me>
To: pgsql-hackers <pgsql-hackers(at)postgresql(dot)org>
Subject: Re: Tepid: selective index updates for heap relations
Date: 2026-07-13 16:26:23
Message-ID: ed69804a-aa09-489b-af3f-6c804b2efd94@app.fastmail.com
Views: Whole Thread | Raw Message | Download mbox | Resend email
Thread:
Lists: pgsql-hackers

Hello,

Attached is v63 which has a few fixes and is rebased (0b3e646fd7f) as well.

What changed vs v60?

Eight of the ten patches changed in content since v60; only 0001 (heap
HOT-update tests) and 0007 (amcheck) are unchanged apart from rebase-induced
hunk offsets. The headline is the CLUSTER data-loss fix (0004, with its
regression test in 0008); the rest are correctness, robustness, and
consistency fixes found in review, plus a test relocation.

The headline: CLUSTER data loss on HOT-indexed (SIU) tables

CLUSTER ... USING <secondary index> silently dropped live rows. On a table
with an SIU chain where the clustering column changed (or ABA-cycled) in an
earlier hop and a different indexed column changed in the last hop, with the
chain left uncollapsed (no VACUUM), CLUSTER returned 14 of 20 rows; six
live rows gone from the heap (seqscan-verified). Row count depending on
which index you cluster by is an unambiguous correctness bug.

Root cause:

CLUSTER-by-index copies the heap via an index scan under SnapshotAny,
trusting the clustering index to reach every live tuple directly. On an
uncollapsed SIU chain, a live tuple whose last hop changed a different
column has no clustering-index entry pointing at it, so it's unreachable
through that index -> never copied -> lost.

This affects any clusterable index AM, not just btree. Both btree and GiST
(the only two amclusterable core AMs) lose the same rows, and a btree-only
fix would have left GiST (and any out-of-tree clusterable AM) exposed.

The fix (patch 0004):

In the heap AM (heapam_relation_copy_for_cluster, heapam_handler.c). The
copy routes through the relation_copy_for_cluster tableam callback, so the
heap AM -- the layer that actually creates index-unreachable live tuples --
refuses the lossy index-scan copy path itself when it holds SIU chains (heap
has >1 index):

- btree clustering index -> switch to seqscan+sort (cluster order
preserved);
- any other clusterable AM (GiST, or out-of-tree) -> plain seqscan copy
(order dropped, no row lost).

Both direct-scan paths visit every heap tuple, the same way VACUUM FULL
already does. No index AM (core or out-of-tree) needs any change; the heap
guarantees its own invariant regardless of which index drove the CLUSTER.
(An earlier iteration carried a btree-only guard in repack.c; that is
reverted to pristine and the guard now lives in the heap AM.)

The regression test (patch 0008, hot_indexed_updates.sql): reproduces the
actual losing shape (a-ABA early, b in the last hop, fillfactor=40,
autovacuum_enabled=false, no VACUUM before CLUSTER), asserting all 20 rows
survive and remain findable, plus bt_index_check(heapallindexed => true). A
GiST case (CLUSTER ... USING <gist index>) proves the fix is AM-agnostic --
a guard a btree-only fix would miss. Both cases lose 6 rows without the fix
and pass with it.

Changes since v60

0002: identify modified indexed attributes in the executor

- Comment-only; the code is unchanged from v60. Documents why
ExecCompareSlotAttrs (used by ExecUpdateModifiedIdxAttrs on the executor
path) compares slots with datum_image_eq while the non-executor path
(simple_heap_update -> HeapUpdateModifiedIdxAttrs -> heap_attr_equals)
uses datumIsEqual. The difference is intentional: at the executor stage
the slot values are logical and cannot be TOASTed (TOAST is a heapam
storage detail applied later), the executor must stay agnostic of how a
table AM stores variable-length data, and datumIsEqual would force a
needless slot->Datum transform on a hot path.
- Also notes on heap_attr_equals and HeapUpdateModifiedIdxAttrs that they
are not public API -- their only consumer is simple_heap_update(), and
they are targeted for eventual removal once catalog-tuple updates track
their own changed columns.

0003: on-disk format

- Comment corrections only. (1) The inline attribute bitmap is located using
the tuple's own write-time attribute count, not the relation's current
natts -- after ADD COLUMN a chain can hold tuples whose bitmaps were sized
for a smaller natts, and sizing from the relation's current natts would
read past the tuple's data. (2) A new README.HOT-INDEXED section spells out
the datum_image_eq-vs-datumIsEqual rationale described under 0002.

0004: selective index maintenance and reads, beyond the CLUSTER fix above

- Ordered/distance index scans (GiST/SP-GiST, ORDER BY <->) now drop stale
HOT-indexed entries, which the non-ordered scan path already did; the
ordered path (IndexNextWithReorder) was the one remaining gap.
- The HOT/SIU chain walk in heap_hot_search_buffer is bounded by a per-page
hop guard and now calls CHECK_FOR_INTERRUPTS. A corrupt page whose forward
links form a cycle among valid in-range offsets would otherwise spin
forever under a buffer share-lock.
- The unique-insert self-check tolerates the legitimate repeat self-arrival
(the canonical direct entry plus stale chain-walk entries resolving to the
same TID, in either arrival order) while still raising on a genuine
duplicate TID.
- In _bt_check_unique, the right-sibling buffer is kept pinned while scanning
equal tuples on the HOT-indexed skip paths: page, opaque, and curitup may
point into it, so releasing it early would dereference a freed buffer.
- The palloc'd per-row modified-attrs bitmap is freed after the index
inserts consume it (a bulk UPDATE previously accumulated one Bitmapset per
updated row for the statement's lifetime), and the "update all indexes"
sentinel is accepted on input for an index whose expression references a
whole-row Var.

0005: BitmapAnd/BitmapOr false negative

- Assert that the stale marker (ItemPointerSetSIUMaybeStale) is only ever set
on a genuine in-range offset, not a sentinel offset.

0006: collapse dead chains to xid-free stubs

- Bound the stub-forward walk in heap_prune_chain with a per-page hop guard,
so a corrupt stub->stub cycle cannot spin forever (mirroring the guard
heap_prune_chain_find_live already has).
- Drop the got_cleanup_lock plumbing through lazy_vacuum_heap_page; rely on
heap_page_would_be_all_visible()'s own SIU-redirect and stub guards to
refuse marking a page all-visible while an unreclaimed HOT-indexed member
is still present.

0008: statistics + comprehensive test suite

- The SIU regression tests that depend on pageinspect/amcheck/btree_gist
moved from src/test/regress into contrib/pageinspect (core "make check"
does not install contrib, so those CREATE EXTENSION calls would fail under
a standard build; contrib/pageinspect is the idiomatic home and pulls the
other two in via EXTRA_INSTALL). The suite gained the CLUSTER and
GiST-CLUSTER cases described above, plus a KNN case for the ordered-scan
fix in 0004.

0009: gate HOT-indexed updates on the logical-replication apply path

- Derive the apply mode directly from MySubscription instead of caching it
in a worker.c static, removing a second copy that had to be kept in sync
across subscription reloads.

0010: benchmark harness

- DO NOT MERGE. Script-only robustness/comment fixes (results-dir creation,
early validation of the resolved base revision, skipping failed pgbench
iterations rather than recording NA rows, and a workload that actually
changes the indexed value); no change to what it benchmarks.

best.

-greg

Attachment Content-Type Size
v63-0001-Add-tests-to-cover-a-variety-of-heap-HOT-update-.patch text/x-patch 45.4 KB
v63-0002-Identify-modified-indexed-attributes-in-the-exec.patch text/x-patch 64.7 KB
v63-0003-Add-the-HOT-indexed-on-disk-format-inline-attr-b.patch text/x-patch 38.3 KB
v63-0004-Add-HOT-indexed-updates-selective-index-maintena.patch text/x-patch 219.8 KB
v63-0005-Fix-a-BitmapAnd-BitmapOr-false-negative-on-HOT-i.patch text/x-patch 33.9 KB
v63-0006-Collapse-dead-HOT-indexed-chains-to-xid-free-stu.patch text/x-patch 51.1 KB
v63-0007-Teach-amcheck-to-recognize-HOT-indexed-chains-an.patch text/x-patch 17.2 KB
v63-0008-Add-HOT-indexed-statistics-and-the-comprehensive.patch text/x-patch 205.4 KB
v63-0009-Gate-HOT-indexed-updates-on-the-logical-replicat.patch text/x-patch 113.8 KB
v63-0010-DO-NOT-MERGE-Add-a-HOT-SIU-benchmark-harness.patch text/x-patch 39.7 KB

In response to

Responses

Browse pgsql-hackers by date

  From Date Subject
Next Message Bohyun Lee 2026-07-13 16:36:49 Re: [PATCH] pg_upgrade: add --initdb option to create the new cluster automatically
Previous Message Nick Ivanov 2026-07-13 16:26:00 Re: incremental backup issue