Bound pg_index_bloat by what pgstatindex is applied to, not by a join qual - #3114
Conversation
…n qual The size ceiling and both work budgets select the relation pgstatindex reads, and the measurements join back onto the full candidate set afterwards. Lower the ceiling and the cycle budget together to 2 GB, sized against a per-block read rate rather than a bulk throughput estimate. Part of #3109
|
Reviewed the diff (PgIndexBloatCollector.cs + its test file). The core fix is sound: the old shape put the three work bounds as an Two minor nits, neither blocking:
|
Counts pgstatindex invocations on the shipped query's own EXPLAIN ANALYZE and asserts the function is entered only for the indexes the budget admitted. No assertion on query text can distinguish a budget that bounds reads from one that only labels rows. Part of #3109
|
Reviewed the query restructure, the new pins, and the live-Postgres test. This is solid — the Two small things worth a look before merge:
Everything else — the 2 GB derivation (262,144 blocks at a pessimistic 2,000 blocks/s ≈ 131s, inside half the 300s deadline), the |
…gstattuple where the collector calls it Teardown goes through LiveStoreCleanup.RunAsync so a cleanup failure cannot replace the body's exception (#1902). The extension is created with SCHEMA public and the precondition is asserted as public.pgstatindex being callable: the store's search_path is "collect, config, public", so a bare CREATE EXTENSION installs into collect and the collector's qualified call raises 42883 while the extension is otherwise installed. Part of #3109
…ured at Which indexes count as sub-ceiling is what the ceiling decides, so 286 GB and 71 GB are readings of a configuration rather than of the collector. Stating the ceiling alongside them keeps the figure checkable instead of merely stale. Part of #3109
|
Reviewed the diff (collector SQL + both test suites). Summary: Correctness — the fix is sound. The core bug (budget quals on the
Budget math checks out. Lite/Darling parity — no drift. Tests. The Security/style. No user input reaches the query (all bounds are compile-time constants substituted into the SQL string); the new test file only ever writes to a hardcoded, test-only schema. Style (uppercase keywords, No correctness, parity, security, or performance issues found. This is a substantial and well-verified fix. |
|
Reviewed the collector rewrite and both test additions. Correctness of the core fix: the restructure is sound. Live-Postgres test: One correctness note posted inline: the reduction to a 2 GB ceiling means Parity: no concerns. This only touches the shared Style: T-SQL style guide doesn't apply (this is a PostgreSQL query embedded in C#), and the No missing-index-DMV concerns (not applicable — this is a Postgres bloat collector) and no security issues (all dynamic SQL in the new test uses compile-time constants, not external input). |
pg_index_bloat's size ceiling and both work budgets were quals on aLEFT JOIN LATERALtopgstatindex. That placement bounds nothing: the planner cannot skip an inner side it has not evaluated, so the function is invoked once per candidate row and the qual only decides whether its answer is kept. Every attempt read every b-tree index on the instance — 1,517 indexes, 461 GB on the reported target — while everyskipped_reasonin the output was correct. So the collector has been indistinguishable from a working one in everything except whether it ever returns, across #2561's original shape, #2617's count budget and #2997's byte budget alike.What changed
The bounds select the relation
pgstatindexis applied to. A fencedin_budgetrelation carries the ceiling, the count bound and the byte bound;measuredcross-joins the function to that relation only; the finalSELECTstill drives off the ungatedrankedand joins the measurements back. Every candidate returns exactly one row, unmeasured ones with their reason, which is the property theLEFT JOIN LATERALwas there to provide and the reason a bareWHEREon the output would be wrong.This is the same category as the btree filter three lines above it, which is fenced with
OFFSET 0for the same stated reason — correctness should not depend on plan shape when the failure is total. The budget was the one bound in the query that still rested on it.The ceiling and the cycle budget both drop from 20 GB to 2 GB. They move together because the pinned invariant forbids the band between them, and 2 GB is derived rather than guessed:
pgstatindexwalks the index one block at a time with no prefetch, so its cost is a count of potentially-synchronous single-block reads, and a bulk-throughput estimate overstates the achievable rate by orders of magnitude on network-attached storage. 2 GB is 262,144 blocks, ~131 s at a pessimistic 2,000 blocks/s, inside half the 300 s deadline. The deadline is untouched, per the collector's own error text.The coverage that was missing, and where it now lives
PgIndexBloatBudgetLivePostgresTests(Darling/Darling.Tests/) runs the shipped query against a live PostgreSQL and countsActual Loopson thepgstatindexfunction-scan node of its ownEXPLAIN ANALYZE. Three numbers come out of the query itself rather than being written into the test:Ccandidates returned,Mreported as measured,Linvocations. It assertsL == M, andM < Cso the first assertion cannot pass vacuously — where everything fits the budget,L == M == Cholds under the broken shape too.No assertion on query text can distinguish these two shapes, which is why both prior budgets shipped pinned and inert. The broken one and the fixed one differ in what the executor does, not in what the SQL says. Measured on PostgreSQL 17.11 with 215 candidates against the untouched 200-index count bound:
origin/devCcandidatesMmeasuredLpgstatindexinvocationsNested Loop Left Join,Rows Removed by Join Filter: 15Filteron the input, thenloops=200Nothing about the query is rewritten for that test — not the literals, not the shape. It belongs in Darling on its merits:
DarlingWorkeris what dispatches this collector, the target that has never returned a row is a Darling target, and this is the only suite with a live PostgreSQL to execute against.Two things that test has to get right to run at all, both of them properties of the store rather than of the collector. It creates pgstattuple with
SCHEMA public, because the store'ssearch_pathiscollect, config, publicand a bareCREATE EXTENSIONtherefore installs intocollect— after which the collector's deliberately-qualifiedpublic.pgstatindexraises42883while the extension is by every other measure installed. The precondition is asserted as the function being callable where the collector calls it, read from the catalog, rather than asCREATE EXTENSIONhaving returned without error; those two come apart exactly here. Teardown goes throughLiveStoreCleanup.RunAsyncwith abodySucceededflag (#1902), so a cleanup failure cannot replace the body's exception — verified by the mutation below, which reports the assertion rather than cleanup noise and still drops the probe schema.What else is pinned
TheCycleBudget_FitsTheDeadline_AtThePessimisticBlockRaterelates the budget, the deadline and a named rate assumption — three numbers that each had their own justification and nothing comparing them. It is the pin that makes raising any one of them argue against the other two.NoWorkBoundSitsOnTheNullableSideOfAnOuterJoinis stated as an absence rather than a shape, because this failure has now arrived twice by two different expressions and the next one will not look like either.TheFunctionCall_IsAppliedToTheGatedRelation,TheGateRelation_IsFencedLikeTheCandidateSetandEverySkippedIndexStillReturnsARow_ThroughTheOuterJoincover the restructure;TheCycleHasAWorkBudget_NotJustAPerIndexCeiling,TheCycleBudget_BoundsBytes_NotJustIndexCountandOverBudgetIndexesAreReturnedWithAReasonpreviously asserted the ON-clause placement as the fix and now read the bounds out of the gate relation instead.Red-first, both halves, by mutation against the committed branch. Reverting only the two values fails the arithmetic pin (
1311sagainst150sallowed). Splicing inorigin/dev's ownQueryTextand keeping the values fails 7 of the 21 Lite pins and fails the live test withExpected: 200, Actual: 215— it names the 15 indexes that were read and discarded.Verification scope
The live test and the 21
PgIndexBloatCollectorDefinitionTestsfacts were executed onnet10.0by compiling the actual test files into console harnesses, against PostgreSQL 17.11; both suites targetnet10.0-windowsand cannot run on macOS. CI is the arbiter for the suites as a whole.The acceptance criterion is not verifiable pre-merge. It requires the next drifted attempt after a deploy to complete
SUCCESSwith a non-zero row count, so it is a POST-merge check. What is verified here is that the bound now binds, and that it binds without changing the answer. Whether 2 GB fits the deadline on the real target is an argument from a cost model, not a measurement — no SUCCESS row has ever supplied a duration for this collector, and producing the first one is what turns the next adjustment into a measurement.Per #3109's own follow-up, verify against the launch instant, not the completion instant: the drift prediction holds only while every attempt dies on the same deadline, and the first attempt that does not breaks it.
The bar for the post-merge check — the pre-registered one no longer applies
#3109's criteria (~1,517 rows, ~200 carrying
avg_leaf_density) were set against a 20 GB ceiling. Both bounds are now 2 GB, so the second figure is wrong in a way that matters: a run reporting ~200 measured would mean the byte budget is NOT binding, which is the defect this PR fixes, not the success it used to describe. Stated here so the check has something to fail against rather than reading "it returned some rows" as success.What is unchanged, and still the right first assertion:
What changes, with the arithmetic behind it. The 200 largest sub-20 GB indexes on that target totalled 286 GB, a mean of ~1.43 GB, so the distribution is dense right where the new 2 GB ceiling cuts. Largest-first into a 2 GB budget therefore admits very few:
avg_leaf_density: expected single digits, floor 1, hard ceiling 200. Not ~200.SUM(index_bytes) WHERE avg_leaf_density IS NOT NULL≤ 2,147,483,648. The sharpest check available and the one that tests this PR's actual claim — it is the budget, read straight off the stored rows. Over it means the bound is not binding in production.MAX(index_bytes) WHERE avg_leaf_density IS NOT NULL< 2,147,483,648, the ceiling.skipped_reason IS NULL AND avg_leaf_density IS NULL— every admitted index came back with a measurement.Three outcomes that must NOT be read as success:
rows > 0with zero carrying density — the budget admitted nothing, a distinct failure with its own reason string.sql_duration_msat or near 300,000 — still does not fit, and the next move is a smaller budget, not a longer deadline.sql_duration_mson the first SUCCESS is the number that retires the guesswork. ~131 s would vindicate the pessimistic 2,000 blocks/s; far below it means the rate assumption was too conservative and the budget can be raised on a measurement for the first time in this collector's life.Two cautions for whoever runs the check. Verify against the launch instant, not completion — the drift line holds only while every attempt dies on the same deadline. And prefer
collection_logplus a direct row count over reading only throughget_pg_index_bloat: an empty b-tree index reportsavg_leaf_densityasNaN, and the reader's reclaimable-bytes cast raisesbigint out of rangeon such a row, so a successful collection can present as a broken read. Filed separately; this change is what makes it live, since the collector previously returned nothing to store. Where it bites is specific, and it is not the target in #3109. An empty index is 8 KB and therefore sorts LAST under largest-first, so it is admitted only where a database's entire sub-ceiling index footprint fits inside the 2 GB budget — small databases, which this per-database collector meets on almost any real cluster. On a target whose sub-ceiling indexes run to hundreds of GB the budget is spent long before it reaches an 8 KB index, so the ordering rules it out there. Do not read a cleanget_pg_index_bloaton the big target as evidence the NaN defect is absent.Deliberately left out
Rotation of the measured set. Coverage of the tail still depends on it and it still does not happen, so an index below the ceiling but past the budget is reported as deferred every run. That is recorded in
CycleMeasureBudgetBytes' own summary as a standing gap rather than fixed here: it needs stored per-server state to be anything other than a clock trick, and it does not help the acceptance criterion, which is a first success.Raising the budget on the strength of the new headroom. The point of the small value is to produce the
sql_duration_msthat makes the next choice a measurement.The
Darling PostgreSQL testspath gate, which is #3116 and not this PR's to change. Worth stating plainly: on the first push here that job reported success in 17 seconds withRun Darling PG testsskipped, because the filter isDarling/**/!(*.md)and this change began life entirely outsideDarling/. The Darling test above makes the gate true as a side effect, but it earns its place on coverage grounds, not as a gate flip.CHANGELOG entry text
Not applied to
CHANGELOG.mdin this branch on purpose: every open lane appends to the same[Unreleased]block, so each one that edits the file conflicts with the rest. The entry is stated here for whoever sequences the merge.### Fixedpg_index_bloat's size ceiling and work budgets are enforced by selecting the relationpgstatindexis applied to, rather than by quals on aLEFT JOIN LATERALto it. Placed on the join, they bounded nothing — the function ran once per candidate row and the qual only chose whether to keep its answer — so every attempt read every b-tree index on the instance while reporting correct skip reasons for the ones it had already paid for. The per-index ceiling and the cycle budget drop to 2 GB together, sized against a per-block read rate rather than a bulk-throughput estimate; a new pin relates the budget, the command deadline and that rate so none of the three can move alone; and a live-PostgreSQL test countspgstatindexinvocations on the shipped query, which is the only way to tell a budget that bounds reads from one that labels rows.