From 978d6b42633d571066d673a7249f9479d3660eda Mon Sep 17 00:00:00 2001 From: Andrey Borodin Date: Tue, 21 Jul 2026 19:40:54 +0500 Subject: [PATCH v6 2/4] amcheck: detect dangling index entries in indexallkeysmatch When the MVCC heap fetch finds no visible tuple, the index entry usually references a dead heap tuple awaiting VACUUM, which is not corruption. But it can also be dangling: pointing to a heap slot that holds no tuple at all, which indicates the index entry outlived its heap tuple (e.g. a buggy VACUUM freed the slot without removing the index entry first). Try to tell these apart where it is safe: * A TID beyond the current end of the heap is reported as corruption regardless of lock level: extension only grows the relation and truncation requires AccessExclusiveLock, blocked by our lock. * Under ShareLock (bt_index_parent_check), concurrent VACUUM is blocked, so an index-referenced slot cannot become LP_UNUSED and the line pointer array cannot shrink. Opportunistic pruning may still run, but it only marks HOT-chain member slots LP_UNUSED, and those are never referenced from indexes. Report out-of-range offsets and LP_UNUSED slots as corruption by inspecting the line pointer directly. * Under AccessShareLock (bt_index_check), a reclaimed slot is indistinguishable from a concurrently pruned tuple, so nothing is reported. Direct line pointer inspection assumes heap page layout and is skipped for non-heap table access methods. Author: Jakub Wartak Author: Andrey Borodin Reviewed-by: Wenbo Lin Discussion: https://www.postgresql.org/message-id/flat/432626F9-65DF-4F0D-B345-26CFC3E2CFAC@yandex-team.ru --- contrib/amcheck/verify_nbtree.c | 103 +++++++++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 1 deletion(-) diff --git a/contrib/amcheck/verify_nbtree.c b/contrib/amcheck/verify_nbtree.c index 4f0dc61efb2..73e223e04fd 100644 --- a/contrib/amcheck/verify_nbtree.c +++ b/contrib/amcheck/verify_nbtree.c @@ -242,6 +242,10 @@ static void bt_verify_index_tuple_points_to_heap(BtreeCheckState *state, IndexTuple itup, BlockNumber targetblock, OffsetNumber offset); +static void bt_report_dangling_index_entry(BtreeCheckState *state, + ItemPointer tid, + BlockNumber targetblock, + OffsetNumber offset); static IndexTuple bt_normalize_tuple(BtreeCheckState *state, IndexTuple itup); static inline IndexTuple bt_posting_plain_tuple(IndexTuple itup, int n); @@ -3037,7 +3041,15 @@ bt_verify_index_tuple_points_to_heap(BtreeCheckState *state, IndexTuple itup, &call_again, NULL); if (!found) { - /* Dead or concurrently pruned heap tuple; nothing to compare */ + /* + * No visible tuple at this TID. Normally that just means the + * index entry is for a dead (or concurrently pruned) heap tuple + * and there is nothing to compare, but it can also mean the + * index entry is dangling: pointing to a heap slot that holds no + * tuple at all. Try to tell these cases apart when it is safe + * to do so. + */ + bt_report_dangling_index_entry(state, tid, targetblock, offset); return; } @@ -3071,6 +3083,95 @@ bt_verify_index_tuple_points_to_heap(BtreeCheckState *state, IndexTuple itup, } } +/* + * Try to detect a dangling index entry: an index tuple whose TID points to + * a heap slot that holds no tuple at all (as opposed to a dead one awaiting + * VACUUM). Called when an MVCC fetch of the TID found no visible tuple. + * + * A TID pointing beyond the current end of the heap is corruption + * regardless of lock level: relation extension only ever grows the relation + * and truncation requires AccessExclusiveLock, which our AccessShareLock on + * the heap relation blocks. Beyond-EOF references typically appear when a + * relation segment is lost or truncated by external forces. + * + * Distinguishing other dangling cases (out-of-range offset, LP_UNUSED slot) + * from a concurrently pruned tuple requires that the heap slot cannot + * change under us. Under ShareLock (bt_index_parent_check) concurrent + * VACUUM is blocked, so an index-referenced slot cannot become LP_UNUSED + * and the line pointer array cannot be truncated: opportunistic pruning may + * mark slots LP_DEAD or redirect them, but it only marks HOT-chain member + * slots LP_UNUSED, and those are never referenced from indexes. Under + * AccessShareLock (bt_index_check) concurrent VACUUM may reclaim the slot + * between our Bloom filter probe and the fetch, so we must not report + * anything. + * + * Direct line pointer inspection requires heap page layout, so it is + * skipped for non-heap table AMs. + */ +static void +bt_report_dangling_index_entry(BtreeCheckState *state, ItemPointer tid, + BlockNumber targetblock, OffsetNumber offset) +{ + BlockNumber blkno = ItemPointerGetBlockNumber(tid); + OffsetNumber offnum = ItemPointerGetOffsetNumber(tid); + Buffer buffer; + Page page; + ItemId lp; + + if (blkno >= RelationGetNumberOfBlocks(state->heaprel)) + ereport(ERROR, + (errcode(ERRCODE_INDEX_CORRUPTED), + errmsg("index tuple in index \"%s\" points to heap tuple in table \"%s\" beyond end of heap", + RelationGetRelationName(state->rel), + RelationGetRelationName(state->heaprel)), + errdetail_internal("Index tid=(%u,%u) points to heap tid=(%u,%u), but heap has only %u blocks.", + targetblock, offset, + blkno, offnum, + RelationGetNumberOfBlocks(state->heaprel)), + errhint("This can be caused by a lost relation segment (missing or removed file)."))); + + /* + * Without ShareLock a missing slot is indistinguishable from a + * concurrently pruned tuple; report nothing. + */ + if (!state->readonly) + return; + + /* Line pointer inspection below assumes heap page layout */ + if (state->heaprel->rd_rel->relam != HEAP_TABLE_AM_OID) + return; + + buffer = ReadBufferExtended(state->heaprel, MAIN_FORKNUM, blkno, + RBM_NORMAL, state->checkstrategy); + LockBuffer(buffer, BUFFER_LOCK_SHARE); + page = BufferGetPage(buffer); + + if (offnum > PageGetMaxOffsetNumber(page)) + ereport(ERROR, + (errcode(ERRCODE_INDEX_CORRUPTED), + errmsg("index tuple in index \"%s\" points to heap tuple in table \"%s\" with out-of-range offset", + RelationGetRelationName(state->rel), + RelationGetRelationName(state->heaprel)), + errdetail_internal("Index tid=(%u,%u) points to heap tid=(%u,%u), but page has only %u line pointers.", + targetblock, offset, + blkno, offnum, + (unsigned) PageGetMaxOffsetNumber(page)))); + + lp = PageGetItemId(page, offnum); + if (!ItemIdIsUsed(lp)) + ereport(ERROR, + (errcode(ERRCODE_INDEX_CORRUPTED), + errmsg("index tuple in index \"%s\" points to unused heap slot in table \"%s\"", + RelationGetRelationName(state->rel), + RelationGetRelationName(state->heaprel)), + errdetail_internal("Index tid=(%u,%u) points to heap tid=(%u,%u) marked LP_UNUSED.", + targetblock, offset, + blkno, offnum))); + + /* LP_DEAD, LP_REDIRECT or a dead LP_NORMAL tuple: not corruption */ + UnlockReleaseBuffer(buffer); +} + /* * Normalize an index tuple for fingerprinting. * -- 2.50.1 (Apple Git-155)