From a5feae86b0afb26c567edd74a98af3bde6e5562b Mon Sep 17 00:00:00 2001 From: Peter Geoghegan Date: Sat, 11 Jul 2026 14:49:09 -0400 Subject: [PATCH v31 02/12] Limit get_actual_variable_range leaf page reads. get_actual_variable_range scans an index to find actual min/max values for planner selectivity estimation. Since this happens during planning, we can't afford to spend too much time on it. Commit 9c6ad5eaa9 added VISITED_PAGES_LIMIT (a limit of 100 heap page visits) to bound the amount of work performed, giving up and falling back to the pg_statistic extremal value when the limit is exceeded. But that isn't effective in cases with more extreme concentrations of dead index tuples. Benchmark results from Mark Callaghan show that VISITED_PAGES_LIMIT stops being effective once the dead index tuple problem gets out of hand (which is expected with queue-like tables that continually delete older records and insert newer ones). VISITED_PAGES_LIMIT counts heap page visits, but when many index tuples are marked LP_DEAD, _bt_readpage traverses arbitrarily many index pages without returning any tuples. The heap page counter never gets a chance to increment, so VISITED_PAGES_LIMIT never triggers. The more LP_DEAD bits we set, the less effective the limit becomes at bailing out early. Add a complementary mechanism that limits get_actual_variable_range to scanning only three index leaf pages (INDEX_PAGES_LIMIT-many pages) that have exactly zero matching items. When the limit is exceeded, the scan returns without returning any matches, forcing get_actual_variable_range to give up. INDEX_PAGES_LIMIT provides a backstop against reading an excessive number of leaf pages, without fundamentally altering the existing VISITED_PAGES_LIMIT design. Leaf page reads that find at least one matching item aren't tallied against the new limit. This balances the need for get_actual_variable_range to locate a min/max value when that's feasible against the need to bound the amount of work it must perform to do so. The first leaf page read isn't counted against INDEX_PAGES_LIMIT, so as to avoid adding handling to _bt_readfirstpage. Only _bt_readnextpage tallies the number of pages read, to avoid adding any overhead to simple point queries. XXX Once "Add slot-based table AM index scan interface" is committed, this patch can be spun off into its own independent project, tracked through another CF entry. Author: Peter Geoghegan Discussion: https://postgr.es/m/CAH2-Wzkt1WkKp4VRJu3qHfmKXc8W+XYv1RXg5d2d3fSvAeO=rg@mail.gmail.com --- src/include/access/nbtree.h | 6 ++++++ src/include/access/relscan.h | 5 +++-- src/backend/access/index/genam.c | 1 + src/backend/access/nbtree/nbtree.c | 1 + src/backend/access/nbtree/nbtsearch.c | 15 +++++++++++++++ src/backend/utils/adt/selfuncs.c | 10 ++++++++++ 6 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/include/access/nbtree.h b/src/include/access/nbtree.h index 3097e9bb1..d777c1601 100644 --- a/src/include/access/nbtree.h +++ b/src/include/access/nbtree.h @@ -1067,6 +1067,12 @@ typedef struct BTScanOpaqueData FmgrInfo *orderProcs; /* ORDER procs for required equality keys */ MemoryContext arrayContext; /* scan-lifespan context for array data */ + /* + * Running count of leaf pages read without finding a match, compared + * against scan->xs_index_pages_limit to bound planner scans + */ + int numNoMatchPages; /* no-match leaf page count */ + /* info about killed items if any (killedItems is NULL if never used) */ int *killedItems; /* currPos.items indexes of killed items */ int numKilled; /* number of currently stored items */ diff --git a/src/include/access/relscan.h b/src/include/access/relscan.h index 9d8a18ac6..868b6bbed 100644 --- a/src/include/access/relscan.h +++ b/src/include/access/relscan.h @@ -207,12 +207,13 @@ typedef struct IndexScanDescData MemoryContext xs_name_cstring_cxt; /* - * An approximate limit on the amount of work, measured in pages touched, + * Approximate limits on the amount of work, measured in pages touched, * imposed on the index scan. The default, 0, means no limit. Only - * index-only scans may set a limit (plain index scans leave this zero). + * index-only scans may set a limit (plain index scans leave these zero). * Used by selfuncs.c to bound the cost of get_actual_variable_endpoint(). */ uint8 xs_visited_pages_limit; + uint8 xs_index_pages_limit; /* parallel index scan information, in shared memory */ struct ParallelIndexScanDescData *parallel_scan; diff --git a/src/backend/access/index/genam.c b/src/backend/access/index/genam.c index 5fa2c3cbf..78be2bec1 100644 --- a/src/backend/access/index/genam.c +++ b/src/backend/access/index/genam.c @@ -134,6 +134,7 @@ RelationGetIndexScan(Relation indexRelation, int nkeys, int norderbys) scan->xs_name_cstring_cxt = NULL; scan->xs_visited_pages_limit = 0; + scan->xs_index_pages_limit = 0; return scan; } diff --git a/src/backend/access/nbtree/nbtree.c b/src/backend/access/nbtree/nbtree.c index 3df2c752e..ca74bca75 100644 --- a/src/backend/access/nbtree/nbtree.c +++ b/src/backend/access/nbtree/nbtree.c @@ -363,6 +363,7 @@ btbeginscan(Relation rel, int nkeys, int norderbys) so->arrayKeys = NULL; so->orderProcs = NULL; so->arrayContext = NULL; + so->numNoMatchPages = 0; so->killedItems = NULL; /* until needed */ so->numKilled = 0; diff --git a/src/backend/access/nbtree/nbtsearch.c b/src/backend/access/nbtree/nbtsearch.c index aae6acb7f..ce082a0fe 100644 --- a/src/backend/access/nbtree/nbtsearch.c +++ b/src/backend/access/nbtree/nbtsearch.c @@ -1938,6 +1938,21 @@ _bt_readnextpage(IndexScanDesc scan, BlockNumber blkno, /* no matching tuples on this page */ _bt_relbuf(rel, so->currPos.buf); seized = false; /* released by _bt_readpage (or by us) */ + + /* + * Give up if an opted-in planner scan (selfuncs.c) has now read too + * many leaf pages without a match. This bounds planning time when + * the scanned end of the index is full of LP_DEAD-marked items. + * (numNoMatchPages is only ever incremented here, so the first leaf + * page, read by _bt_readfirstpage, never counts against the limit.) + */ + if (unlikely(scan->xs_index_pages_limit > 0) && + ++so->numNoMatchPages >= scan->xs_index_pages_limit) + { + BTScanPosInvalidate(so->currPos); + _bt_parallel_done(scan); + return false; + } } /* diff --git a/src/backend/utils/adt/selfuncs.c b/src/backend/utils/adt/selfuncs.c index d4dbb3d5b..c067f931a 100644 --- a/src/backend/utils/adt/selfuncs.c +++ b/src/backend/utils/adt/selfuncs.c @@ -7284,8 +7284,17 @@ get_actual_variable_endpoint(Relation heapRel, * We set xs_visited_pages_limit to tell the table AM to give up once it * has switched heap pages this many times without finding a visible * tuple. + * + * We also set xs_index_pages_limit to independently tell the index AM to + * give up when this many leaf pages that lack even one matching index + * tuple have been read. This acts as a backstop against pages entirely + * full of index entries that were already marked killed (typically by + * prior calls here). That way we avoid hopelessly searching through an + * unbounded number of index leaf pages that don't contain even a single + * still-live entry (which can't trigger xs_visited_pages_limit). */ #define VISITED_PAGES_LIMIT 100 +#define INDEX_PAGES_LIMIT 3 InitNonVacuumableSnapshot(SnapshotNonVacuumable, GlobalVisTestFor(heapRel)); @@ -7295,6 +7304,7 @@ get_actual_variable_endpoint(Relation heapRel, SO_NONE); Assert(index_scan->xs_want_itup); index_scan->xs_visited_pages_limit = VISITED_PAGES_LIMIT; + index_scan->xs_index_pages_limit = INDEX_PAGES_LIMIT; index_rescan(index_scan, scankeys, 1, NULL, 0); /* Fetch first/next tuple in specified direction */ -- 2.53.0