Skip to content

Measure tempdb space against its ceiling, not its current allocation (#2515) - #2521

Merged
erikdarlingdata merged 3 commits into
devfrom
fix/2515-tempdb-ceiling-denominator
Aug 22, 2026
Merged

erikdarlingdata merged 3 commits into
devfrom
fix/2515-tempdb-ceiling-denominator

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Aug 22, 2026 •

Copy link
Copy Markdown
Owner

Closes #2515.

The mechanism

TempDbSpaceInfo.UsedPercent was TotalReservedMb / (TotalReservedMb + UnallocatedMb) * 100, and both halves come from tempdb.sys.dm_db_file_space_usage, which reports the data files as CURRENTLY ALLOCATED. That is distance to the next autogrow. It reads like real headroom on a pre-sized on-prem box only because such a tempdb has already grown to its cap.

The comment on #2515 settles what kind of defect that is: the ceiling is discoverable, so this is a denominator correction, not a policy call. On GP_S_Gen5_2, tempdb is four ROWS files of 16 MB — 62.44 MB allocated — and every one of them has max_size = 2,097,152 pages, a 65,536 MB ROWS ceiling. One ordinary ~57 MB #temp table reads 95.7% full against the allocation and 0.09% against the cap. At the shipped 80% default that is a page on the first busy minute of every Azure target.

The fix

  • Where max_size is a real value, the denominator is SUM(max_size) over ROWS files, converted to MB.
  • Where any data file is unlimited (-1), there is no ceiling to measure against and the current allocation stays the denominator — today's behaviour, so unlimited-growth on-prem and RDS targets do not move at all.

UsedPercent now means the same thing on every engine: distance to the point where tempdb cannot grow any further.

Not a size floor, per the issue: a floor suppresses the alert on a genuinely full large tempdb at the moment it starts growing, still fires at the autogrow boundary once cleared, and any value silently redefines "small" for every on-prem and RDS target already relying on today's behaviour.

What moves on existing targets

target shape before after
tempdb with unlimited max_size (typical on-prem) reserved ÷ allocation unchanged
snapshot collected before this rung (max_size_mb NULL → 0) reserved ÷ allocation unchanged
tempdb with a fixed max_size it has not grown into reserved ÷ allocation LOWER — 800 MB inside a 1,000 MB allocation that may reach 4,000 MB reads 20%, not 80%
tempdb with a fixed max_size it is approaching falls as autogrow adds unallocated extents rises toward the alert, which is the behaviour the old denominator could never produce

Row three is the one to be loud about: it is a correction, not a regression, but it will move live numbers on servers people are already watching. It is called out explicitly in the CHANGELOG and pinned by TheCappedOnPremShape_ReportsLowerThanItUsedTo.

Things the issue asked to get right

LOG files are excluded from the denominator, verified against the collector's columns. Every other column in tempdb_stats comes from dm_db_file_space_usage, which reports DATA allocation — folding the log's cap into the same ratio would understate usage on every server, not just Azure. The subquery is WHERE df.type = 0 /*ROWS*/, pinned by Query_ReadsTheCeilingFromTheRowsFilesOnly_AndSumsItWideEnough.

AlertEngineTests' 1000 MB fixture does not imply a cap, and should not start to. It carries no MaxSizeMb, so it describes a tempdb whose ceiling was never measured — exactly the case where the denominator stays the allocation. The 80% pin is unchanged, and that it is unchanged is the guarantee that no unlimited or not-yet-recollected target moves. There is a comment on the fixture saying so, and the capped case got its own test rather than being folded in.

FactScorer.ScoreTempDbVersionStore's 1/2/5 GB bars are unchanged and need no change. They score max_version_store_mb in absolute MB; the ceiling is a denominator, so it cannot move their reachability. They stay unreachable on an Azure tempdb still at ~62 MB for the reason #2516 gave — a version store cannot exceed 1 GB inside a 62 MB tempdb — and become reachable there exactly when tempdb autogrows past a gigabyte, which is the same rule as everywhere else. The arm is self-consistent; the assessment is recorded in its doc comment so the next reader does not have to re-derive it.

A migration WAS needed, and the derivation-instead alternative does not work. The obvious way to avoid new storage is to read the cap out of database_size_stats, which already carries max_size_mb per file. On Azure SQL Database that collector runs a database-scoped query against the connected database's sys.database_files (deliberately — #1631), so tempdb never appears in it. The one platform this fix exists for is the one where the derivation has nothing to read. So TempDbStatsCollector collects the cap itself, which is a payload/schema change:

  • PgMigrations.Scripts V81 tempdb-max-size + V81Sql, StorageVersion.SchemaVersion → 81
  • probe sentinel + reader.GetBoolean(56) + hasTempDbMaxSize param + newest-first arm returning 81
  • previous top rung demoted: CollectionLogFanoutRollupStoreTests gives up "80 is the maximum" and pins its own ordinal instead of mapParameters - 1
  • Lite twin: DuckDbInitializer.CurrentSchemaVersion → 56 with an idempotent ADD COLUMN IF NOT EXISTS, and the column appended to GoldenCollectorSchema (the one legal edit to that oracle)

The rung also refreshes collect.v_tempdb_stats. Postgres freezes a view's SELECT * column list at CREATE, and the analysis fact collector reads tempdb through that V4 passthrough — without the refresh an upgraded store would keep serving the old column list and analyze_server would go on scoring against the allocation while the alert had already moved. Lite needs no view work because it rebuilds every v_ passthrough on start.

Scope note: the analysis fact carried a second copy of the bug

PgFactCollector / DuckDbFactCollector compute the TEMPDB_USAGE fraction themselves rather than going through TempDbSpaceInfo, with the same reserved + unallocated denominator. Leaving it would have meant analyze_server scoring the same Azure target at 96% full while the pager stayed quiet — one server, two answers. Both are fixed with the same rule, and the fact gained a max_size_mb metadata key (added, never redefined — the existing keys are a consumer surface).

Also

The alert detail gained a Max Size field, because "95% used" is unreadable without knowing which denominator produced it. Three states, three words: a cap in MB, Unlimited, or Unknown for a snapshot taken before the ceiling was collected. Shared builder, so both SKUs render byte-identically.

get_tempdb_trend and the viewer's tempdb reads report raw MB and compute no percentage, so they are consistent as-is and were deliberately left alone rather than having their payload shape changed.

Testing

  • Darling.Tests/TempDbCeilingStoreTests — the rung, the DDL, the view refresh, the probe/reader/map agreement, and the arithmetic with the issue's real numbers: 62.44 MB allocated against a 65,536 MB cap with 59.75 MB used does not clear an 80% threshold, while the identical snapshot without a ceiling reads 95.69% and does. Plus the gated live E2E through the real adapter for all three states (cap, -1, NULL), cleanup via LiveStoreCleanup.RunAsync, INSERT column list copied from DarlingAlertReadAdapterTests.
  • Lite.Tests/TempDbCeilingStoreTests — the same three shapes through real DuckDB and the real reads, plus the analysis fact.
  • AlertEngineTests / LiteAlertForwardingTests — the same decision through the alert ENGINE on both SKUs, which is what actually pages someone.
  • TempDbStatsCollectorDefinitionTests — payload append, the ROWS-only filter, the bigint widen before the SUM, and NULL-vs--1 surviving the read as different values.

Ran locally on macOS: full solution builds clean, and a throwaway net10.0 harness against the real PerformanceMonitor.Alerting / .Collectors / .Darling.Storage assemblies exercised every arithmetic and rendering assertion above (all PASS), including the "without the fix" reading of 95.69% that proves these tests fail without the change. The Windows suites cannot execute on macOS; CI is the arbiter for those.

And the T-SQL was run against a real engine, not just against fixtures — the one thing no CI job here covers. BuildQuery(...).Text was dumped from the built assembly (the shipped string, not a retyped copy) and executed on SQL Server 2022 with 8 tempdb data files:

tempdb state max_size_mb
all files unlimited (the container default) -1.00
8 data files capped at 100 MB, log capped at 2,048 MB 800.00
one data file returned to UNLIMITED -1.00

Row two is the LOG exclusion proven rather than intended: 800, not 2,848. Row three is MIN doing its job — one unlimited data file means tempdb as a whole can grow without limit, so the whole answer goes back to -1.

Note that until #2516 lands, AppliesTo is still !target.IsAzureSqlDb, so this new subquery cannot reach an Azure target from dev at all; it runs only where tempdb.sys.database_files is unremarkable. The Azure read itself is the measurement recorded on #2515.

Coordination with #2516

#2516 (the Azure gate removal) can now ship — but it should not ship before this, and the two must not be merged blind. Until #2516 lands, AppliesTo is still !target.IsAzureSqlDb on dev, so nothing here can affect an Azure target yet; the new subquery only runs against on-prem, RDS and Managed Instance, where tempdb.sys.database_files is unremarkable.

I based on dev rather than stacking, so CI here is real. A trial merge of fix/2512-azure-tempdb-gate into this branch produced one small conflict and one thing git merges cleanly but wrongly — whoever merges second should expect:

  1. TempDbStatsCollector.cs class summary — a genuine conflict, both sides rewrote it. Keep Collect tempdb on Azure SQL Database: the gate's stated reason was false (#2512) #2516's "applies to every SQL Server target" sentence and this branch's ceiling paragraph.
  2. TempDbStatsCollector.AppliesTo's doc comment merges cleanly and is then out of date: Collect tempdb on Azure SQL Database: the gate's stated reason was false (#2512) #2516's paragraph says the ratio "needs a size floor before it can safely carry the tempdb Space alert — filed as The tempdb Space alert's percentage has no size floor, and on Azure SQL Database that makes it fire on one temp table #2515". It got a denominator instead. That paragraph needs a sentence saying so.
  3. Lite.Tests/TempDbStatsCollectorDefinitionTests.AzureSqlDb_MeasuredValues_ComposeThroughToThePayload (added by Collect tempdb on Azure SQL Database: the gate's stated reason was false (#2512) #2516) merges cleanly and then does not compile: it builds a five-column first result set and an eight-argument Row. It needs a sixth column and a ninth Row/writer value.
  4. CHANGELOG link definitions — both branches add [#2512]: and [#2515]:. Keep one copy of each.

…2515)

TempDbSpaceInfo.UsedPercent divided by total_reserved + unallocated, and
both halves come from dm_db_file_space_usage, which reports the data files
as CURRENTLY ALLOCATED. That is distance to the next autogrow. It reads as
real headroom on a pre-sized on-prem box only because such a tempdb has
already grown to its cap.

On GP_S_Gen5_2: four 16 MB data files, 62.44 MB allocated, max_size
2,097,152 pages each for a 65,536 MB ROWS ceiling. One ~57 MB #temp table
read 95.7% full against the allocation and 0.09% against the cap, so at the
shipped 80% default enabling Azure tempdb collection (#2512) would have
armed an alert that fires on the first busy minute of every Azure target.

The ceiling is discoverable, so this is a denominator correction rather than
a policy call. tempdb_stats now collects max_size_mb = SUM(max_size) over
tempdb's ROWS files only - dm_db_file_space_usage reports DATA allocation,
so folding the log's cap in would understate usage everywhere - and both
SKUs divide by it. Where any data file is unlimited (-1) there is no ceiling
to measure against and the allocation stays the denominator, which is
today's behaviour, so unlimited-growth on-prem and RDS targets do not move.

Deliberately not a size floor: a floor suppresses the alert on a genuinely
full large tempdb at the moment it starts growing, still fires at the
autogrow boundary once cleared, and any value silently redefines 'small' for
every on-prem and RDS target already relying on today's behaviour.

The analyze_server TEMPDB_USAGE fact carried its own copy of the same
denominator and is fixed with it, so the pager and the analysis pass cannot
describe one server two ways. The alert detail gained a Max Size field
naming which denominator produced the percentage.

Darling schema v81, Lite schema v56. The column is nullable with no
backfill; a historical row's NULL reads as 'no ceiling measured' and reports
the percentage it always did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment on lines 1369 to +1375
/// <summary>
/// Seeds tempdb_stats across 16 collection points.
/// </summary>
/// <summary>
/// <paramref name="maxSizeMb"/> is the #2515 growth ceiling: null seeds NULL, which is what every row
/// collected before the v56 migration looks like and the state the callers here want by default.
/// </summary>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor doc nit: this is two separate /// <summary>...</summary> blocks stacked back-to-back rather than one merged block. C# only recognizes a single <summary> element per member for IntelliSense/doc generation, so the second block (the maxSizeMb explanation) won't render where intended. Suggest merging into one summary, e.g.:

Suggested change
/// <summary>
/// Seeds tempdb_stats across 16 collection points.
/// </summary>
/// <summary>
/// <paramref name="maxSizeMb"/> is the #2515 growth ceiling: null seeds NULL, which is what every row
/// collected before the v56 migration looks like and the state the callers here want by default.
/// </summary>
/// <summary>
/// Seeds tempdb_stats across 16 collection points. <paramref name="maxSizeMb"/> is the #2515 growth
/// ceiling: null seeds NULL, which is what every row collected before the v56 migration looks like
/// and the state the callers here want by default.
/// </summary>

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 038d0bd — merged into one summary, same shape as your suggestion. DocCommentHygieneTests.NoMemberCarriesTwoStackedSummaryBlocks caught the same site in the Darling PostgreSQL job, so this was a red build rather than a nit; the two of you found it independently.

@claude

claude Bot commented Aug 22, 2026

Copy link
Copy Markdown

Reviewed the denominator fix end-to-end (collector T-SQL, both stores' migrations, both alert-read adapters, the shared TempDbSpaceInfo/AlertContextBuilders, and both analysis fact collectors).

Verified correct:

  • TempDbSpaceInfo.CapacityMb / UsedPercent arithmetic matches the issue's numbers exactly (Azure shape → 0.09%, unlimited on-prem → unchanged 80%, capped-but-not-full → drops to 20%, cap-below-allocation clamps via Math.Max so it can't exceed 100%).
  • Lite/Darling parity holds on every touched surface: the collector T-SQL, the MIN(max_size_mb) fact aggregation (identical text in PgFactCollector.Storage.cs and DuckDbFactCollector.Storage.cs), the read-adapter ordinal mapping (max_size_mb appended last, ordinal 7, in both DarlingAlertReadAdapter and LocalDataService.TempDb.cs), and the AlertContextBuilders/TempDbSpaceInfo types themselves, which are shared rather than duplicated.
  • Manually recounted the StoreSchemaProbeSql EXISTS list against the hardcoded ordinals in FileGrowthAlertStoreTests (54), CollectionLogFanoutRollupStoreTests (55), and the new TempDbCeilingStoreTests (56/mapParameters-1) — all three line up correctly with the new 57-sentinel MapProbedSchemaVersion arity.
  • T-SQL follows house style (AS on the alias, column_name = expression, OPTION(RECOMPILE) on the collector query and the correlated subquery's parent batch, ROWS-only filter with a WHY comment, bigint widen before SUM to avoid overflow).
  • Migration is schema-qualified (collect.tempdb_stats, collect.v_tempdb_stats), nullable/no-default/no-backfill, and correctly refreshes the frozen Postgres view; Lite's DuckDB twin correctly skips that step since it rebuilds v_ passthroughs on start.
  • NULL-vs--1-vs-positive handling is consistent (and tested) at every layer: NULL → 0 ("not measured", old denominator), -1 → unlimited (old denominator), positive → real ceiling.

One nit left as an inline comment: Lite.Tests/TestDataSeeder.cs has two stacked /// <summary> blocks on SeedTempDbAsync (should be merged into one) — cosmetic only, no functional impact.

Nothing else stood out — no correctness bugs, no Lite/Darling drift, no security concerns (no dynamic SQL/string concatenation introduced), and no missing-index-DMV suggestions to make.

… Server check

DocCommentHygieneTests caught a doc block I appended above an existing one
rather than into it: SeedTempDbAsync carried two <summary> elements, so XML
docs would take the LAST and a human reading top-down gets the first. Merged
into one. The review bot found the same site independently.

Also, having run the collector's shipped query text against a real SQL Server
2022 (8 tempdb data files) rather than only against fixtures, the doc comment
now records what it returned: unlimited files give -1; capping the eight data
files at 100 MB and the LOG at 2,048 MB gives 800.00, so the log's cap really
is excluded rather than merely intended to be; returning one data file to
UNLIMITED takes the whole answer back to -1.

Two accuracy fixes while here: the V81 rung's view-refresh comment said the
frozen list was eleven columns (tempdb_stats has twelve), and the live test's
-1 note claimed a mishandled sign would report 80,000% used, which the
allocation clamp would in fact hide - the sign matters because it is what
separates Unlimited from Unknown in the rendered detail, not because the
percentage moves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 22, 2026

Copy link
Copy Markdown

Review

Went through the full diff (production code + both Lite and Darling test suites) against CONTRIBUTING.md's T-SQL style rules and the Lite/Darling parity requirement.

Correctness of the denominator fix — TempDbSpaceInfo.CapacityMb/UsedPercent (PerformanceMonitor.Alerting/TempDbSpaceInfo.cs) is right: falls back to the old allocation-based ratio exactly when MaxSizeMb <= 0 (covers both -1 unlimited and 0/NULL "not measured"), and Math.Max(MaxSizeMb, AllocatedMb) correctly prevents >100% if a cap is ever lowered below what's already allocated. Verified against the worked Azure example (59.75 MB / 65,536 MB ≈ 0.09%) and the on-prem non-regression case.

T-SQL style — TempDbStatsCollector.BuildQuery follows the house style: AS on all aliases (dsu, df, ssu), column_name = expression aliasing, OPTION(RECOMPILE) present on both batch statements (correctly re-added to the one gaining the new subquery, per the "a statement added to an existing batch needs its own hint" rule), block comments only, lowercase types, widening max_size to bigint before summing (correctly noted, since int could overflow on a wide/maxed-out tempdb). The MIN(df.max_size) = -1 trick to detect any-unlimited-file is correct given -1 sorts below every real cap.

Lite/Darling parity — checked column-for-column: both PgFactCollector.Storage.cs and DuckDbFactCollector.Storage.cs compute totalSpace identically; both DarlingAlertReadAdapter and LocalDataService.TempDb.cs append max_size_mb at the same trailing ordinal and treat NULL as 0 identically; PgMigrations V81 and DuckDbInitializer v56 are twinned; ViewerDataService.MapProbedSchemaVersion and the Darling schema-version pin move together. Also good: the deprecated/Dashboard.Tests fixture for the shared AlertContextBuilders was updated too, so the shared code doesn't silently break the retained (if deprecated) test suite.

Minor, non-blocking observation: the MIN(max_size_mb) aggregate in the two analysis fact-collector queries picks the most conservative (smallest, including -1) ceiling value across the whole collection window. If a tempdb's ROWS files transition from a real cap to unlimited (or vice versa) mid-window, the window's fact will report "unlimited" (falls back to allocation-based fraction) even for the portion of the window that had a real cap. This is an intentional, documented tradeoff (comment: "MIN keeps the more conservative of the two") and matches a vanishingly rare production scenario (mid-window tempdb file reconfiguration), so I don't think it needs a change — just flagging that the conservative choice in this direction reintroduces the old (bug) denominator for that fact, rather than the corrected one, for windows that straddle such a transition.

No missing-index-DMV suggestions, no correctness bugs, no parity drift found. Test coverage (unit + live-store E2E on both SKUs) is thorough and specifically pins the three-state (positive cap / -1 unlimited / 0 unmeasured) semantics at every layer.

The arm's note claimed the service reads the ceiling column through the
viewer, so a viewer on a V80 store 'would be reading a store whose service
half cannot supply it'. No viewer query names max_size_mb — the service's
alert adapter is what reads it, and a store at 80 is being served by a
service at 80, which is behaving correctly for its own version.

The honest reason is the standing invariant every top rung has:
RequiredStoreSchemaVersion is StorageVersion.SchemaVersion, so a fully
migrated store has to map to exactly this. What the banner is worth on the
way is real and worth stating: a store still at 80 has no ceiling anywhere
in its history, so every tempdb percentage in it is the old
distance-to-next-autogrow measurement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 22, 2026

Copy link
Copy Markdown

Review

Went through the full diff (production code, migrations, and the new/changed tests across both SKUs). This is a solid, well-scoped fix.

Correctness

  • The denominator switch (CapacityMb = MaxSizeMb > 0 ? Max(MaxSizeMb, AllocatedMb) : AllocatedMb) correctly handles all three states (real cap, -1 unlimited, 0/NULL unmeasured), and the "cap edited down below current allocation" edge case is explicitly guarded against with Math.Max and covered by a test (shrunkCap in TempDbCeilingStoreTests).
  • MIN(df.max_size) = -1 correctly finds "any unlimited file" since -1 sorts below every real cap; SUM(CONVERT(bigint, df.max_size)) widens before summing, avoiding int overflow on a wide tempdb.
  • type = 0 correctly restricts to ROWS files, matching that every other column in tempdb_stats comes from dm_db_file_space_usage (DATA allocation only) — avoids folding the LOG cap in and understating usage.
  • NULL-vs--1 distinction is preserved end-to-end (collector reader, storage, alert adapter) rather than being collapsed to a single "no cap" sentinel — good, since they're different facts ("not measured" vs "no ceiling").

Lite/Darling parity

  • Checked pairwise: collector (PayloadColumns/query, shared), PgFactCollector.Storage.cs vs DuckDbFactCollector.Storage.cs (identical MIN/Math.Max logic), migrations (Darling V81 vs Lite v56, including the Postgres CREATE OR REPLACE VIEW refresh that Lite doesn't need since it rebuilds v_ views on start), read adapters (DarlingAlertReadAdapter vs LocalDataService.TempDb.cs, same column position/NULL handling), and alert-engine tests (AlertEngineTests vs LiteAlertForwardingTests, same numbers, same assertions). All consistent — no drift found.
  • AlertContextBuilders.cs is shared, so the "Max Size" field automatically parities across both SKUs; the deprecated Dashboard.Tests fixture was also updated for the new field index, even though Full Dashboard itself is deprecated and not otherwise touched.

Style

  • SQL follows CONTRIBUTING.md conventions: AS on all table aliases, column_name = expression aliasing, /* */ comments only, OPTION(RECOMPILE) present on both statements in the batch (the new subquery's parent statement already carries it, consistent with "a statement added to an existing batch needs its own hint").
  • Migration schema-qualifies collect.* objects in the Postgres migration, per the house rule.

Security / perf

  • No user input reaches the new SQL (static collector query); no injection surface. The added subquery is a single small system-catalog scan, uncorrelated, evaluated once against a non-GROUP-BY aggregate query — negligible overhead.

No blocking issues found. Nice attention to backward compatibility (NULL/-1 history correctly falls back to old behavior) and to the coordination note about the pending #2516 merge conflict.

@erikdarlingdata
erikdarlingdata merged commit 60c52c7 into dev Aug 22, 2026
6 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/2515-tempdb-ceiling-denominator branch September 12, 2026 20:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant