Size pg_index_bloat's ceiling and cycle budget against the measured block rate, not an assumed one (#3164) - #3176
Conversation
…lock rate, not an assumed one PessimisticBlocksPerSecond asserted 2,000 blocks/s with no measurement behind it. The first SUCCESS row this collector has produced puts the rate at ~1,013, which is what the budget's own doc pre-registered as the figure that would settle this - and it argues the budget down rather than up. MeasuredBlocksPerSecond replaces it at 1,013, and both MeasureCeilingBytes and CycleMeasureBudgetBytes drop from 2 GiB to 1 GiB in lockstep. The ceiling had to move whatever the budget did: one index just under 2 GiB is 259 s in a single statement at the measured rate, past the whole 150 s allowance, so no cycle budget could have rescued it. That also settles the decoupling #3153 deferred - its goal was to keep the ceiling at 2 GiB while the budget fell, and ceiling + budget <= allowance has no solution. ThePerIndexCeiling_FitsTheDeadline_OnItsOwn asserts the ceiling's deadline cost directly instead of inheriting it from budget >= ceiling, because that inheritance is what a decoupling would silently remove.
…does not depend on a pin naming it Mutating ThePerIndexCeiling_FitsTheDeadline_OnItsOwn to read CycleMeasureBudgetBytes instead of MeasureCeilingBytes is one token and leaves every pin green, because the two figures are equal - and equal is the value TheCycleBudget_IsNeverBelowThePerIndexCeiling documents as preferred. So the two pins are indistinguishable for as long as the collector is correct, and diverge only in the decoupled state one of them exists to catch. EveryDeclaredByteBound_FitsTheDeadline_CensusedFromTheType reads the bounds off the type's own public const longs instead, so the ceiling is covered whatever any single pin names. It asserts a non-empty census and requires both figures by name, because a reflection filter that matches nothing reports success for having looked.
…e I/O rate sql_duration_ms on a per-database collector sums connect + execute + drain across databases, and the run this figure comes from swept two, one of which failed fast on a missing extension. So the figure is blocks over everything the statement spent, which makes the true per-block read rate faster than it - the safe direction, and the right quantity, because what the constant feeds is a comparison against a deadline that covers connect and drain too.
… takes every long The deadline pin's doc said the reserve also paid for a tail index charged past a nearly-spent budget. The shipped gate is not that shape: measured_bytes_through_here is a window sum over ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, so it includes the row being tested and the tail index is admitted only if it fits. Prefix sums are non-decreasing, so the admitted set is a prefix and the total read is bounded by the budget exactly - there is nothing to reserve against. The wrong version made the reserve look like it covered a 2x read, leaving the rate error uncovered, and the same property is what lets a SUCCESS row's duration bound the block rate from above. The census filter takes every long rather than every name ending in Bytes, because the suffix fails green - a bound named otherwise escapes it - while the broad filter fails noisy.
|
Reviewed. This is a shared-library change ( Checked the substance:
No correctness, parity, or security issues found. Nothing to flag on T-SQL style either — this collector's query targets PostgreSQL and already follows the repo's |
|
Reviewed. This PR only touches doc comments, two constants ( Checked and confirmed:
No correctness, parity, security, or performance concerns found. The self-documented "not verified" caveats in the PR description (real-target index count between 1–2 GiB, n=1 rate sample) are honestly disclosed rather than hidden, and the mitigation (band-emptiness floor, liveness guarantee) is unaffected since |
|
Reviewed this against CONTRIBUTING.md's parity and style rules. No correctness, parity, security, or performance-regression findings. Parity check: Arithmetic spot-check, since the whole PR rests on it:
New test ( Not a T-SQL change (Postgres query embedded as a C# string, existing style untouched), so One non-blocking observation: this doesn't add a CHANGELOG.md entry, but neither did the related #3153/#3163 PRs that touched the same file, so that looks consistent with existing practice rather than an omission. |
…of the PR bodies The entry text does not only ride the PR description. #3150's own account of the previous batch says 11 of its 66 entries came from a lane's handoff file in `~/Documents/pm-issue-queue-inbox/inbox/` rather than the body, and three files there carry dedicated entry text inside this range: `3164-changelog-entry.md`, `3166-changelog-entry.md` and `3169-changelog-entry.md`. Searching only the bodies missed the first two. - **#3164** (PR #3176) is no longer written from the PR description. Its lane wrote a full entry, and that text is used verbatim: it carries the 1,036 blocks/s upper bound, the reason the decoupling #3153 deferred is unreachable, the declined command-deadline route, the census deliberately not restated at the new ceiling, and the mutation that caught nothing. `[#2997]` joins the definition block at its ascending position, which that text cites and the file did not define. - **#3166** (PR #3168, absorbed and superseded by #3178) had no entry at all. #3178 grouped it into its own citation with a one-line summary, but its lane wrote a separate entry for the census that took `HeaviestHourlyRefreshObservedCeilingSeconds` from 594 s to 896 s and inverted the watch-line ordering. It sits beside #3178's, on #3178's merge. - **#3191's entry** said "#3184 named four of the six". #3184 was squash-merged and the four-of-six state never reached `dev`; it was a commit inside that PR, corrected before merge. "shipped" in this file means merged, so the clause now says a commit in #3184, which is what the #3187 lane's own report establishes. `CHANGELOG.md` only, 60 insertions and no deletions against `origin/dev`: 33 entries (6 Added, 3 Changed, 24 Fixed) and 27 definitions. All 15 definition runs keep the ordering they had, 9 of 15 ascending before and after.
…ix merged pull requests (#3213) * Record the CHANGELOG entries for thirty-one changes across twenty-five merged pull requests Applies the `[Unreleased]` entries for the pull requests merged to `dev` from #3150 (the previous batch) through #3203. Lanes do not edit `CHANGELOG.md` -- every one of them appends to the same block, so a per-PR edit conflicts with whichever sibling merges first -- and the entry text rides each PR's own description instead. This is the periodic commit that applies it. `CHANGELOG.md` only, 56 insertions and no deletions: 31 entries prepended inside `## [Unreleased]` (6 under `### Added`, 2 under `### Changed`, 23 under `### Fixed`), newest merge first within each section, plus the 25 link-reference definitions they need appended at the foot of the file. Nothing already in the block was edited, reworded or reordered. Three of the 28 pull requests in the range earn no entry, each on measured precedent rather than judgement: #3150 is the previous batch pass itself, and no batch pass -- #2260, #2980, #3150 -- is cited anywhere in the file; #3130 and #3131 are routine Dependabot bumps, and none of the six merged Dependabot pull requests in the repository's history is cited either. Two pull requests carried no entry text anywhere, so theirs are written from their descriptions: #3176 (`pg_index_bloat`'s measured block rate) and #3184 (the PostgreSQL target's permissions section). * Record #3205's entry, which arrived on the merged base mid-batch #3205 merged while this batch was assembled, so `origin/dev` was merged in rather than rebased and its entry lands on top. An entry omitted because it arrived mid-batch is how a backlog restarts, and the batch's purpose is to make `[Unreleased]` current at its own merged base. One entry under `### Changed`, newest merge first ahead of #3195's, plus its link-reference definition. #3205 has no other pull request in the range behind it. * Take #3164's and #3166's entries from the lane handoff files instead of the PR bodies The entry text does not only ride the PR description. #3150's own account of the previous batch says 11 of its 66 entries came from a lane's handoff file in `~/Documents/pm-issue-queue-inbox/inbox/` rather than the body, and three files there carry dedicated entry text inside this range: `3164-changelog-entry.md`, `3166-changelog-entry.md` and `3169-changelog-entry.md`. Searching only the bodies missed the first two. - **#3164** (PR #3176) is no longer written from the PR description. Its lane wrote a full entry, and that text is used verbatim: it carries the 1,036 blocks/s upper bound, the reason the decoupling #3153 deferred is unreachable, the declined command-deadline route, the census deliberately not restated at the new ceiling, and the mutation that caught nothing. `[#2997]` joins the definition block at its ascending position, which that text cites and the file did not define. - **#3166** (PR #3168, absorbed and superseded by #3178) had no entry at all. #3178 grouped it into its own citation with a one-line summary, but its lane wrote a separate entry for the census that took `HeaviestHourlyRefreshObservedCeilingSeconds` from 594 s to 896 s and inverted the watch-line ordering. It sits beside #3178's, on #3178's merge. - **#3191's entry** said "#3184 named four of the six". #3184 was squash-merged and the four-of-six state never reached `dev`; it was a commit inside that PR, corrected before merge. "shipped" in this file means merged, so the clause now says a commit in #3184, which is what the #3187 lane's own report establishes. `CHANGELOG.md` only, 60 insertions and no deletions against `origin/dev`: 33 entries (6 Added, 3 Changed, 24 Fixed) and 27 definitions. All 15 definition runs keep the ordering they had, 9 of 15 ascending before and after. * Use the staged #3184 entry text instead of one written from the PR description #3184's entry now exists, staged by the lane that did the work, so the batch no longer invents one. Used verbatim, in `### Fixed` where its author targeted it and for the reason they gave: the section did not merely lack detail, it asserted "One role covers every collector", which is false in the direction that fails silently and which an operator would act on. Their text carries what a description-derived entry could not: the `pg_read_all_data` grant is PostgreSQL 14+ and the pre-14 fallback the section gave was `GRANT SELECT` on a schema, which is not valid PostgreSQL at all; the six collectors that need an extension, four of which additionally need `shared_preload_libraries` and a server restart; and why `pg_index_bloat` is unaffected by any grant. Both figures are live pre-change store reads rather than estimates. The citation is `[#3184]`, the pull request, because the change has no issue behind it, and its definition uses the `/pull/` form. `CHANGELOG.md` only, still 60 insertions and no deletions against `origin/dev`.
Fixes #3164.
PgIndexBloatCollector.PessimisticBlocksPerSecondwas2_000with no measurement behind it. The name asserted pessimism and the value was optimistic by roughly 2x: the first SUCCESS row this collector has ever produced puts the real rate at ~1,013 blocks/s.MeasuredBlocksPerSecondreplaces it at 1,013, andMeasureCeilingBytesandCycleMeasureBudgetBytesboth drop from 2 GiB to 1 GiB.The rate, and the instrument that produced each number
The instrument
CycleMeasureBudgetBytes' own summary pre-registered is a SUCCESS row'scollection_log.sql_duration_ms(#2997). One exists: a 252,940 ms SUCCESS row, 2,500 rows collected, the first this collector has ever produced on any target.That row alone bounds the rate from above with no further data. The gate admits an index only when the running total through that index is still within budget, so the admitted set is a prefix and the bytes a run reads cannot exceed the budget — and that run's budget was 2 GiB. So the rate is at most
262,144 blocks / 252.94 s= 1,036 blocks/s, i.e. at most 52% of the assumed 2,000. The ~1,013 figure derived from the bytes that run actually measured sits just under that bound, so the issue's number is confirmed rather than merely repeated.The denominator is not pgstatindex time alone, and that matters for reading the constant.
sql_duration_mson aRunsPerDatabasecollector is the sum across databases of connect + execute + drain, and that run swept two databases, one of which failed fast on a missing extension. Every one of those terms inflates the denominator, so the true per-block read rate is faster than this figure. That is the safe direction and the right quantity: what the constant feeds is a comparison against a command deadline, and the deadline covers connect and drain too — a pure I/O rate would understate the budget's real cost by exactly the terms it left out. The constant's doc says so, because "blocks per second" reads like a pure I/O rate and is not one.Three things about provenance, because two of them read like corroboration and are not:
MeasureCeilingBytes' "about 11 hours" for the over-ceiling set and "roughly an hour" for its largest member are this same rate restated at coarse precision, not independent derivations. 311 GiB at 1,013 blocks/s is 11.2 h; 27 GiB is 58 min. Citing them as agreement would be citing one measurement three times.get_store_metricsproduced none of the figures here. Its background-job series samples one reading an hour and its daily point is the day's last, so it cannot answer a maximum question (The hourly refresh envelope is stated at 306 s of slot margin; a measured run leaves 121.6 s and sits inside the warning band, and the daily series structurally cannot show it #3119). The duration above came from acollection_logread (wide window,truncated: false); the ceiling/census figures are the collector's own committed doc comments.The run also spent 84% of its 300 s deadline, against a pin that allows half. That falsifies the pin's premise directly, before any rate arithmetic.
Why the ceiling had to move, and why the decoupling #3153 deferred could not have saved it
#3164 recommends the decoupling #3153 left undone — an unconditional first-admission rule plus the deadline constraint restated as a sum (
ceiling + budget) — so the budget could fall to fit the real rate while the ceiling stayed at 2 GiB. That goal is unreachable at the measured rate, and the arithmetic is one line.One index just under the ceiling is the largest amount of work a single statement can be asked to do. At 1,013 blocks/s, 2 GiB is 262,144 blocks and 259 s in one statement — past the entire 150 s allowance before a budget is chosen at all.
ceiling + budget <= allowancetherefore has no solution with a 2 GiB ceiling, and neither doesmax(ceiling, budget) <= allowance. The binding constraint was never the ordering between the two figures; it was the ceiling's own deadline cost, which equal values had been hiding.So the ceiling comes down regardless of what the budget does. Once it does, setting the budget equal to it satisfies every constraint with no new mechanism, and decoupling would only permit the one direction (
budget < ceiling) that reopens the band. 1 GiB is 131,072 blocks and ~129 s, inside the 150 s allowance with 21 s of margin — it absorbs a further 14% rate degradation (it breaches at 874 blocks/s), and it is off the edge of its own plausible range rather than at it.How the band-emptiness floor survives
By construction, and unchanged:
CycleMeasureBudgetBytes == MeasureCeilingBytes == 1 GiB, sobudget >= ceilingstill holds andTheCycleBudget_IsNeverBelowThePerIndexCeilingis green without modification. There is no band between the two figures, because there is no gap.#3163's liveness guarantee is untouched for the same reason. Because
budget >= ceiling, the first sub-ceiling candidate at or below the rotation cursor is still admitted unconditionally — its own size is under the ceiling, hence under the budget, so it is the first FILTERed row in the window frame andmeasured_bytes_through_herefor it equals its own size, which cannot already have exceeded the budget. The cursor therefore still advances by at least one index every cycle, which is what makes "a later run in this pass reaches this index" a fact. Nothing about this change touches the gate's shape, the four reason arms, the cursor, or the prune.The other arithmetic route, investigated rather than assumed
#3164 asks that raising
CommandTimeoutSecondsOverrideabove 300 s not be taken without first establishing how it coexists with "the 120-second whole-server wall-clock budget that abandons cycles". Established: they do not interact, because that budget does not apply to this collector.120 s is not a product-wide constant. It is the value three SQL Server collectors (
procedure_stats,query_stats,plan_correction) chose forICollectorSchemaInfo.PerItemWallClockBudget, the opt-in per-item budget #2673 added.CollectorDefinitionBase.PerItemWallClockBudgetdefaults tonull;query_storealready sets 600 s, longer than this deadline.PgIndexBloatCollectordoes not override it at all, soStartItemBudgetreturns null, the item token is the cancellation token, and no wall clock bounds this collector. A per-collector budget above 300 s is established precedent, not novel.It is still not the move, for reasons about cost rather than mechanism, and they are now recorded on
CommandTimeoutSecondsOverride: ~518 s would fit 2 GiB, which is over eight minutes ofpgstatindexin one statement against a production instance — and because the deadline is a backstop rather than a bound, it is also eight minutes a mis-budgeted run spends before reporting nothing. It would be sized to make one n=1 measurement fit, which is the move the budget's own pin exists to prevent. And it would leave the rate constant wrong, which is the actual defect.A second correction, stated rather than quietly rewritten: what the half-deadline reserve covers
TheCycleBudget_FitsTheDeadline_...' doc said the reserve paid for the catalog scan, connection setup, and "the tail index admitted while the running total was still just under budget — that index is charged for itself, so the last admission can be almost a whole index past the point where the budget was nearly spent."That describes a gate of the form
total BEFORE this row <= budget. The shipped gate is not that shape:measured_bytes_through_hereis a window sum overROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, so it includes the row being tested, andmeasured_bytes_through_here <= budgetadmits the tail index only if it fits in the headroom left. Prefix sums are non-decreasing, so the admitted set is a prefix and the total read is bounded by the budget exactly. There is no overshoot to reserve against.Two reasons this is worth a paragraph rather than a silent edit. The wrong version made the reserve look as though it were covering a possible ~2x read, which would leave the actual rate error — the thing that just went wrong — uncovered. And the same prefix property is what lets a SUCCESS row's duration bound the block rate from above at all, so the upper bound above rests on it.
The reserve's job is now stated as being wrong about the rate, and only that.
Cadence is deliberately not touched
Coverage is cadence-limited after #3153. Lowering the budget makes passes longer, so moving both at once would conflate two effects and leave neither figure meaning anything. The cadence stays at the 1,440 minutes in
CollectorScheduleDefaultsand gets re-derived from whatever the budget is, separately.What this costs
Coverage rate: at most 2x. Pass length is measurable blocks over the budget, so halving the budget at most doubles the pass — ~75 cycles becomes at most ~150, and at a daily cadence at most ~150 days rather than ~75. "At most", because the same change lowers the ceiling, which removes the indexes between the old and new ceilings from the measurable set entirely: a smaller numerator against the halved denominator, so the true figure lands below the doubling.
Permanently lost: the indexes between 1 and 2 GiB. Over-ceiling indexes are terminal, so these move from "measurable, eventually" to "recorded but never measured". How many there are on the real target is not measured — see below. The old census of 43 indexes / 311 GB is a figure at a 2 GiB ceiling, so it is now restated in the doc as belonging to the ceiling it was taken at rather than left attached to a ceiling it no longer describes. What is claimed is the direction and the mechanism, not a new count.
Their
skipped_reasonalready states permanence rather than implying deferral, and already namespg_index_usage_statsas what carries their size trend instead — same target, same daily cadence, same retention, no extension needed, and it covers them whatever this ceiling is.Evidence
Lite.Testscannot execute on macOS, so the pins were run by compiling the actualLite.Tests/PgIndexBloatCollectorDefinitionTests.cs(unmodified, plus its realCollectorDefinitionTestFakes.cs) into anet10.0console harness against a minimal xUnit shim, and reflecting over[Fact]. Baseline on the committed tree: 35 ran, 35 passed. CI is the arbiter; this is what was checkable locally.Red first. Changing only
PessimisticBlocksPerSecondfrom 2,000 to 1,013 and touching nothing else redsTheCycleBudget_FitsTheDeadline_AtThePessimisticBlockRatewith its own failure text: "a full cycle budget of 2147483648 bytes is 262144 blocks, which at 1013 blocks/s takes 259s — past the 150s..."Nine mutations, each confirmed applied by
git diff --numstatbefore the run, each restored afterwards with the tree verified clean. Anchor counts asserted at 1 before every edit.ThePerIndexCeiling_FitsTheDeadline_OnItsOwn,TheCycleBudget_IsNeverBelowThePerIndexCeiling,TheBudgetLiterals_AgreeWithTheirConstantsTheCycleBudget_FitsTheDeadline_AtTheMeasuredBlockRate,TheBudgetLiterals_AgreeWithTheirConstantsCeilingLiteralleft at the old 2 GiB SQL valueTheBudgetLiterals_AgreeWithTheirConstantsCycleByteBudgetLiteralleft at the old 2 GiB SQL valueTheBudgetLiterals_AgreeWithTheirConstantsEveryDeclaredByteBound_FitsTheDeadline_CensusedFromTheType, namingMeasureCeilingBytesAssert.NotEmptyThe uncaught mutation, and the pin it produced
Mutating
ThePerIndexCeiling_FitsTheDeadline_OnItsOwnto readCycleMeasureBudgetBytesinstead ofMeasureCeilingBytesis one token, and left all 34 pins green. A pin's subject is the one thing it cannot check about itself, and here the two figures are equal — which is exactly the valueTheCycleBudget_IsNeverBelowThePerIndexCeilingdocuments as preferred. So the two pins are arithmetically indistinguishable for as long as this collector is correct, and diverge only in the decoupled state one of them exists to catch.EveryDeclaredByteBound_FitsTheDeadline_CensusedFromTheTyperemoves the hand-typed subject: it reads the bounds off the type's ownpublic const longfields and requires each to fit the same allowance, so the ceiling's coverage no longer depends on any single pin naming it, and a third byte bound added later is covered without anyone remembering. It asserts a non-empty census and requires both figures by name, because a reflection filter that matches nothing reports success for having looked — and those two guards catch different mutations, as the last two rows above show.ThePerIndexCeiling_FitsTheDeadline_OnItsOwnstays, because it is where the decoupling argument is written down; it is no longer where the coverage lives.The census filter takes every
longconstant rather than every name ending inBytes. The suffix reads more precisely and fails the wrong way — a byte bound added under some other name escapes the census and is never checked, which is green and wrong — whereas the broad filter means an unrelatedlongadded later gets asserted against the deadline too, which is noisy and wrong and someone has to come and look at it. Fail toward the label that costs attention, not the one that costs coverage. The population today is exactlyMeasureCeilingBytesandCycleMeasureBudgetBytes: the type's other constants areint(BlockSizeBytes,MeasuredBlocksPerSecond,MaxSplicedCursors) orstring, and neither base class declares any.Not verified
Token has expired and refresh failed) with nodarling-pgmonMCP in this session — so neither route was available.awsis not classifier-blocked here; the token is expired, which is a credential path not pursued. An inbox request is filed.No issue is referenced by a claim I did not check
CHANGELOG.md:926contains the digits "1,013" in an unrelated Query Store row-count context. It is a numeral collision, not a second sighting of this rate.