diff --git a/CHANGELOG.md b/CHANGELOG.md index 002b9d722e..ec33b3e2fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Lite's portable ZIP is self-contained, which HALVED it** ([#2501]) - `Publish Lite` is now `-r win-x64 --self-contained` in both `build.yml` and `nightly.yml`, so neither Lite artifact has a .NET prerequisite any more and the failure [#2489] documented stops existing: a tester who unzips onto a stock Windows Server no longer meets the .NET host's bare `You must install .NET to run this application` before a line of our code runs. **The size went the opposite way from what bundling a runtime suggests.** The old publish was RID-agnostic, so it copied every platform its packages ship - **537 MB of `runtimes\` on a 565 MB tree** (osx 130, linux-x64 116, linux-arm64 70, win-arm64 56, then win-x86, musl, loongarch64 and riscv64), of which only the **52 MB `win-x64`** folder could ever load on Windows. `DuckDB.NET.Bindings.Full` is most of it, SkiaSharp and SqlClient behind it. Dropping ~485 MB of unloadable native payload beats the cost of bundling .NET, WPF and ASP.NET Core by roughly two to one: measured on one commit and one SDK, **565 MB tree / 212.7 MB zipped becomes 277 MB / 114.2 MB**. It matters most for the **nightly** ZIP, which is the UAT download and is not offered as a `Setup.exe` at all. **A RID-specific publish needed two more files than the flag.** `Lite/packages.lock.json` had only a `net10.0-windows7.0` target, and a RID restore adds `net10.0-windows7.0/win-x64` to it - after which the `dotnet restore --locked-mode` that BOTH workflows run before the publish fails `NU1004: the project's runtime identifiers have changed`, because locked mode compares the PROJECT's RID set (empty) against the lock file's (win-x64). Reproduced locally; that is a red CI run on every PR, not the future `--no-restore` trap it was filed as. The fix is `win-x64` in `PerformanceMonitorLite.csproj`, so the project itself asks for that graph and one committed lock file satisfies the RID-less locked-mode restore and the RID publish alike; `RuntimeIdentifiers` (plural) sets no RID on the build, so a plain `dotnet build` stays RID-agnostic and `Lite.Tests` is untouched. **SignPath needed nothing** - the `Lite` artifact-configuration slug already receives both shapes today, and the signed re-zip reads `signed/Lite/*`, inheriting whatever shape `publish/Lite` has. Auto-update is unaffected; the ZIP is not a Velopack channel. `LiteRuntimePrerequisiteDocsTests` went red on the flag alone (3 of its 7 facts) and was rewritten to state every claim BOTH ways round: [#2499]'s version asserted only that the docs DID name the runtimes, so two of its facts stayed green while the prose went stale. It now also derives the lock file's RID coverage from the `-r` flags in the workflows, and every new assertion was proven red with its fix reverted. ### Fixed +- **The `tempdb Space` alert measured distance to the next autogrow, not distance to the ceiling** ([#2515]) - `TempDbSpaceInfo.UsedPercent` was `total_reserved / (total_reserved + unallocated)`, and both halves come from `tempdb.sys.dm_db_file_space_usage`, which reports the data files **as currently allocated**. On a pre-sized on-prem box that reads like real headroom only because such a tempdb has already grown to its cap. On **Azure SQL Database** it is not: the platform creates tempdb small and grows it toward the tier limit, so on `GP_S_Gen5_2` - four data files of 16 MB, **62.44 MB allocated**, each with `max_size` 2,097,152 pages for a **65,536 MB** ROWS ceiling - one ordinary ~57 MB `#temp` table read **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, which is what made enabling Azure tempdb collection ([#2512]) unsafe to ship. **The ceiling is discoverable, so this was a denominator bug rather than a policy call**: `tempdb_stats` now collects `max_size_mb` = `SUM(max_size)` over tempdb's **ROWS** files (`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 (`max_size` -1) there is no ceiling to measure against and the current allocation stays the denominator - today's behaviour exactly, so **unlimited-growth on-prem and RDS targets do not move**. **What DOES move, deliberately:** any target whose tempdb has a fixed `max_size` it has not yet grown into now reports a **LOWER** percentage than before - 800 MB reserved inside a 1,000 MB allocation that may grow to 4,000 MB reads 20%, not 80% - and climbs back to the alert as the files actually approach the cap. That is a correction, not a regression, but it will move live numbers on existing servers. Deliberately **not** a size floor, the other candidate: 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 depending on today's behaviour. The `analyze_server` `TEMPDB_USAGE` fact carried its own copy of the same denominator and was 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 (a cap in MB, `Unlimited`, or `Unknown` for a snapshot taken before the ceiling was collected). `FactScorer.ScoreTempDbVersionStore`'s 1/2/5 GB bars are unchanged and needed no change - they are absolute MB and a denominator cannot move their reachability. Darling schema **v81**, Lite schema **v56**; the column is nullable with no backfill, and a historical row's NULL reads as "no ceiling measured", which reports the percentage it always did. - **Nineteen Darling reads (eighteen on Lite) told an Azure SQL Database user to go and start a capture that cannot exist there** ([#2511]) - `sys.dm_xe_sessions` does not exist on Azure SQL Database (verified live on `EngineEdition` 5, General Purpose and Hyperscale), so the `system_health` ring buffer can never be read on that engine. `SystemHealthEventsCollector` knows this and gates itself off, correctly and permanently. **The reads did not**: all nine `get_health_parser_*` tools answered an Azure caller with *"check that collection is running for this server and that its system_health session is started"* - collection **was** running, and the session cannot be started on that engine. Thirteen collectors gate off on Azure SQL Database and no read anywhere branched on engine edition, so the same confident, specific, wrong instruction was waiting behind `get_server_config`, `get_trace_flags`, `get_default_trace_events`, `get_cpu_scheduler_pressure`, `get_memory_pressure_events`, `get_tempdb_trend`, `get_running_jobs` and the config-change reads too. Every one of those now answers **`not_collected`** - the miss vocabulary's existing word for *"the input names something this server does not collect"* - naming the engine, the collector and the capture path, and saying plainly that the gap is permanent. `unavailable` is kept for a server whose engine **does** support the path and simply has no rows, which is the case that is still worth chasing. **The capability answer is derived from the collectors' own `AppliesTo` gates**, never from a list: a collector is reported as a permanent engine gap only when *no* target of that engine edition runs it, swept across every version, msdb-access and RDS combination - so a gate that reads a **fixable** fact (msdb access, a version floor) is deliberately *not* reported as an engine gap, and a gate that opens up silently stops the claim rather than leaving a stale one standing. - **Lite's .NET runtime prerequisites were documented against the wrong artifact** ([#2489]) - the README hung "requires .NET 10 Desktop Runtime" on the **`Setup.exe`** download line. That is the one artifact with no prerequisite at all: it is packed by `vpk` from a `--self-contained -r win-x64` publish, and its `runtimeconfig.json` carries `includedFrameworks`, not `frameworks`. Meanwhile the portable **ZIP** - a framework-dependent publish whose `runtimeconfig.json` names **three** shared frameworks - had its prerequisites written down nowhere, and `Lite/README.md` had no prerequisites section at all. So a tester who read the one runtime sentence we published installed a runtime they did not need, while a tester who unzipped onto a stock Windows Server got the .NET host's bare `You must install .NET to run this application` with nothing of ours on it. Each artifact now carries its OWN prerequisites, in the root README and in a new **Prerequisites** section in `Lite/README.md`. **ASP.NET Core Runtime 10 is required unconditionally** for the ZIP, and that is the part nobody expects: `ModelContextProtocol.AspNetCore` brings the framework reference in transitively, so it is named at build time and leaving MCP switched off does not remove it - and because the host reports only the FIRST framework it cannot find, installing just the Desktop Runtime buys a second identical failure. Lite cannot gate on this the way [#2481] did for Darling, because Lite has no install script and the host error precedes every line of our code; what ships instead is `READ-ME-FIRST.txt` beside `PerformanceMonitorLite.exe`, the one surface of ours a stranded reader can still reach. `LiteRuntimePrerequisiteDocsTests` keeps all of it honest by DERIVING the claims - the required runtimes from the built `runtimeconfig.json`, and which artifact is self-contained from the `dotnet publish` lines in `build.yml` and `nightly.yml` - so dropping the MCP package, making the ZIP self-contained, or moving to a new .NET major turns the docs red instead of stale. `README.md` and `Lite/README.md` are named in build.yml's `lite` path filter for the same reason: markdown is carved out of every area filter and a docs-only PR skips .NET setup entirely, so the guard would otherwise be unrunnable on precisely the change it exists to catch. @@ -2862,6 +2863,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#2495]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2495 [#2506]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2506 [#2511]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2511 +[#2512]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2512 +[#2515]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2515 [#2480]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2480 [#2489]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2489 [#2481]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2481 diff --git a/Darling/Darling.Tests/AlertEngineTests.cs b/Darling/Darling.Tests/AlertEngineTests.cs index 8ad3b34e23..f944c60bf4 100644 --- a/Darling/Darling.Tests/AlertEngineTests.cs +++ b/Darling/Darling.Tests/AlertEngineTests.cs @@ -1052,6 +1052,12 @@ public async Task TempDb_FiresAtThreshold_AndResolutionCarriesTheCurrentPercent( h.Settings.TempDbSpaceEnabled = true; var engine = h.Build(); + /* #2515: 1000 MB total and NO MaxSizeMb, which is deliberate and stays that way. The fixture predates + the ceiling, so it describes a tempdb whose cap was never measured — and that is exactly the case + where the denominator remains the allocation. It does NOT imply a 1000 MB cap; if it did, this pin + would have to move, and the fact that it does not is the guarantee that no existing on-prem or RDS + target with an unlimited (or uncollected) tempdb sees its number change. The capped case gets its + own test below rather than being folded in here. */ h.Adapter.TempDb = new TempDbSpaceInfo { TotalReservedMb = 800, UnallocatedMb = 200 }; /* 80% used */ await engine.EvaluateServerAsync(Harness.Snapshot()); var fired = Assert.Single(h.Deliverer.Outcomes); @@ -1066,6 +1072,36 @@ public async Task TempDb_FiresAtThreshold_AndResolutionCarriesTheCurrentPercent( Assert.Equal("SRV-A: tempdb usage back to 20%", resolution.Message); /* :461,:464 */ } + /// + /// #2515, through the ENGINE rather than the arithmetic: the Azure shape from the issue must not fire, and + /// the same allocation without a ceiling must. Same 59.75 MB of reserved tempdb in both, same 80% default — + /// the only difference is whether the collector could see how far the files are allowed to grow. + /// + /// This is the assertion the whole change exists for. proves + /// the arithmetic and the store round-trip; this proves the alert engine's decision follows it, which is + /// what actually pages someone. + /// + [Fact] + public async Task TempDb_TheAzureCeiling_SuppressesTheAlert_ThatTheAllocationWouldFire() + { + var h = new Harness(); + h.Settings.TempDbSpaceEnabled = true; + Assert.Equal(80, h.Settings.TempDbSpaceThresholdPercent); + var engine = h.Build(); + + /* GP_S_Gen5_2 with one ~57 MB #temp table: 62.44 MB allocated, 65,536 MB of headroom behind it. */ + h.Adapter.TempDb = new TempDbSpaceInfo { TotalReservedMb = 59.75, UnallocatedMb = 2.69, MaxSizeMb = 65_536 }; + await engine.EvaluateServerAsync(Harness.Snapshot()); + Assert.Empty(h.Deliverer.Outcomes); + + /* The identical snapshot with the ceiling unmeasured is the pre-#2515 reading, and it pages. */ + h.Adapter.TempDb = new TempDbSpaceInfo { TotalReservedMb = 59.75, UnallocatedMb = 2.69 }; + await engine.EvaluateServerAsync(Harness.Snapshot()); + var fired = Assert.Single(h.Deliverer.Outcomes); + Assert.Equal("tempdb Space", fired.MetricName); + Assert.Equal("96% used (60 MB)", fired.CurrentValue); + } + /* ---------------- low disk ---------------- */ [Fact] diff --git a/Darling/Darling.Tests/CollectionLogFanoutRollupStoreTests.cs b/Darling/Darling.Tests/CollectionLogFanoutRollupStoreTests.cs index e15c496b96..9f54c7e261 100644 --- a/Darling/Darling.Tests/CollectionLogFanoutRollupStoreTests.cs +++ b/Darling/Darling.Tests/CollectionLogFanoutRollupStoreTests.cs @@ -17,7 +17,9 @@ namespace Darling.Tests; /// -/// The V80 rung (#2472) — the per-database fan-out rollup on collection_log, and the top of the ladder. +/// The V80 rung (#2472) — the per-database fan-out rollup on collection_log. No longer the top of the +/// ladder: V81 (#2515) took that, and the assertions that belong to whichever rung is newest moved with it to +/// . What stays here is everything that is true of THIS rung forever. /// /// What it is for. Five collectors run once per DATABASE and the run writes ONE row whose /// duration_ms is the sum across all of them. So 8 databases at 10.1s and one at 62s beside seven at @@ -32,14 +34,16 @@ namespace Darling.Tests; public class CollectionLogFanoutRollupStoreTests { [Fact] - public void TheRungIsRegisteredAndIsTheTopOfADenseLadder() + public void TheRungIsRegisteredInADenseLadder() { var versions = PgMigrations.Scripts.Select(s => s.Version).ToList(); Assert.Equal("collection-log-fanout-rollup", PgMigrations.Scripts.Single(s => s.Version == 80).Name); - Assert.Equal(80, versions.Max()); - Assert.Equal(80, PgMigrations.Scripts[^1].Version); - Assert.Equal(80, StorageVersion.SchemaVersion); + + /* Demoted at V81 (#2515): "80 is the maximum" was true of the LADDER while this was its top rung, + not of this rung, and leaving it here is how a demotion turns into a red build on the next one. + The density and ordering checks below stay — those are properties of the whole ladder that every + rung's test may assert. */ Assert.Equal(StorageVersion.SchemaVersion, versions.Max()); Assert.Equal(versions.Distinct().OrderBy(v => v), versions); @@ -90,28 +94,22 @@ public void TheRungRefreshesThePassthroughView() } [Fact] - public void TheProbeAsksForTheColumn_AndTheThreePlacesAgree() + public void TheProbeAsksForTheColumn_AndTheReaderStillFetchesItsOrdinal() { Assert.Contains( "table_name = 'collection_log' AND column_name = 'slowest_item_ms'", ViewerDataService.StoreSchemaProbeSql, StringComparison.Ordinal); - var mapParameters = typeof(ViewerDataService) - .GetMethod("MapProbedSchemaVersion", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)! - .GetParameters().Length; - - var viewerSource = ReadViewerSource(); - - /* Ordinals are positional: the sentinel was APPENDED, so the reader must ask for exactly one more - than it did and no more than that. */ - Assert.Contains($"reader.GetBoolean({mapParameters - 1})", viewerSource, StringComparison.Ordinal); - Assert.DoesNotContain($"reader.GetBoolean({mapParameters})", viewerSource, StringComparison.Ordinal); + /* Demoted at V81 (#2515): this used to read `mapParameters - 1`, which is the NEWEST sentinel's + ordinal, so once a rung was appended it silently started testing that rung's wiring instead of + this one's. Pinned at 55 — this rung's own ordinal — which cannot slide. The "and no more than + that" half is the top rung's to assert, and it moved to TempDbCeilingStoreTests with it. */ + Assert.Contains("reader.GetBoolean(55)", ReadViewerSource(), StringComparison.Ordinal); } [Fact] - public void TheProbeMapsAFullyMigratedStoreTo80() + public void TheProbeMapsAStoreAtExactly80To80() { - Assert.Equal(80, StorageVersion.SchemaVersion); Assert.Equal(StorageVersion.SchemaVersion, ViewerDataService.RequiredStoreSchemaVersion); var method = typeof(ViewerDataService) @@ -121,7 +119,8 @@ public void TheProbeMapsAFullyMigratedStoreTo80() /* 55 positional sentinels, then this rung's own, then FALSE for anything a later rung appends. The leading count is FIXED at this rung's ordinal deliberately — see the note on V79's twin: deriving it from arity reads identically while this is the top rung, then slides one place right per new - rung, and the assertion keeps passing while quietly testing a newer arm. */ + rung, and the assertion keeps passing while quietly testing a newer arm. V81 has since been + appended and this test needed no edit, which is the fixed count doing exactly what it is for. */ var all = Enumerable.Repeat(true, 55).Cast().ToArray(); object[] Args(bool ownFlag) => all .Concat(new object[] { ownFlag }) diff --git a/Darling/Darling.Tests/FileGrowthAlertStoreTests.cs b/Darling/Darling.Tests/FileGrowthAlertStoreTests.cs index de2c60c662..15895f8961 100644 --- a/Darling/Darling.Tests/FileGrowthAlertStoreTests.cs +++ b/Darling/Darling.Tests/FileGrowthAlertStoreTests.cs @@ -58,20 +58,18 @@ public void TheRungAddsTheKnobs_Idempotently_AndTheAlertShipsOff() } [Fact] - public void TheProbeAsksForTheColumn_AndTheThreePlacesAgree() + public void TheProbeAsksForTheColumn_AndTheReaderStillFetchesItsOrdinal() { Assert.Contains( "table_name = 'config_alert_settings' AND column_name = 'file_growth_enabled'", ViewerDataService.StoreSchemaProbeSql, StringComparison.Ordinal); - var mapParameters = typeof(ViewerDataService) - .GetMethod("MapProbedSchemaVersion", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)! - .GetParameters().Length; - - var viewerSource = ReadViewerSource(); - - Assert.Contains($"reader.GetBoolean({mapParameters - 1})", viewerSource, StringComparison.Ordinal); - Assert.DoesNotContain($"reader.GetBoolean({mapParameters})", viewerSource, StringComparison.Ordinal); + /* Demoted at V81 (#2515), and it should have been at V80. This read `mapParameters - 1`, which is + the NEWEST sentinel's ordinal — correct only while V79 was the top rung, and from V80 onwards it + was quietly asserting a later rung's wiring while reading as though it still guarded this one. + Pinned at 54, this rung's own ordinal, which cannot slide. The "and no more than that" half + belongs to whichever rung is newest and lives there. */ + Assert.Contains("reader.GetBoolean(54)", ReadViewerSource(), StringComparison.Ordinal); } [Fact] diff --git a/Darling/Darling.Tests/TempDbCeilingStoreTests.cs b/Darling/Darling.Tests/TempDbCeilingStoreTests.cs new file mode 100644 index 0000000000..d50d34f254 --- /dev/null +++ b/Darling/Darling.Tests/TempDbCeilingStoreTests.cs @@ -0,0 +1,375 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Alerting; +using PerformanceMonitor.Darling.Analysis; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// The V81 rung (#2515) — tempdb's growth CEILING on tempdb_stats, and the top of the ladder. +/// +/// What it fixes. 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. +/// +/// The measurement that settled it. On GP_S_Gen5_2 tempdb is four data files of 16 MB +/// with max_size 2,097,152 pages each — a 65,536 MB ceiling against 62.44 MB allocated. One ~57 MB +/// #temp table reads 95.7% full against the allocation and 0.09% against the cap, so shipping the +/// Azure collection gate removal (#2512 / #2516) on the old denominator would have armed an alert that fires +/// on the first busy minute of every Azure target at the shipped 80% default. +/// +/// Not a size floor, which was the other candidate: 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 +/// picks a value that silently redefines "small" for every on-prem and RDS target already relying on today's +/// behaviour. +/// +[Collection("live-postgres")] +public sealed class TempDbCeilingStoreTests +{ + /// Distinctive fake id — a real server_id is a storage-name hash, never this. + private const int TestServerId = -815151; + private static readonly string TestServerKey = TestServerId.ToString(CultureInfo.InvariantCulture); + private const string TestServerName = "tempdb-ceiling-e2e"; + + /* ---------------- the rung ---------------- */ + + [Fact] + public void TheRungIsRegisteredAndIsTheTopOfADenseLadder() + { + var versions = PgMigrations.Scripts.Select(s => s.Version).ToList(); + + Assert.Equal("tempdb-max-size", PgMigrations.Scripts.Single(s => s.Version == 81).Name); + Assert.Equal(81, versions.Max()); + Assert.Equal(81, PgMigrations.Scripts[^1].Version); + Assert.Equal(81, StorageVersion.SchemaVersion); + Assert.Equal(StorageVersion.SchemaVersion, versions.Max()); + + Assert.Equal(versions.Distinct().OrderBy(v => v), versions); + var above = versions.Where(v => v > 45).OrderBy(v => v).ToList(); + Assert.Equal(Enumerable.Range(above[0], above.Count), above); + } + + /// + /// One nullable column and a view refresh. Nullable with no DEFAULT keeps this a catalog-only change in + /// PostgreSQL, which stays instant on a compressed hypertable. No backfill, and none is possible: a row + /// collected before this rung genuinely does not know the ceiling, and NULL says so where any number + /// would claim a measurement nobody took. + /// + [Fact] + public void TheRungAddsTheColumn_Idempotently_AndWithoutADefault() + { + var sql = PgMigrations.Scripts.Single(s => s.Version == 81).Sql; + + Assert.Contains("ALTER TABLE collect.tempdb_stats", sql, StringComparison.Ordinal); + Assert.Contains("ADD COLUMN IF NOT EXISTS max_size_mb numeric(18,2)", sql, StringComparison.Ordinal); + + Assert.DoesNotContain("DEFAULT", sql, StringComparison.Ordinal); + Assert.DoesNotContain("UPDATE ", sql, StringComparison.Ordinal); + } + + /// + /// The view refresh, which is the half of this rung that is easy to forget and impossible to notice. + /// Postgres FREEZES a view's SELECT * column list at CREATE, and the analysis fact collector reads + /// tempdb through that V4 passthrough — so without this, 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 to the ceiling. Two surfaces, one server, two different answers. + /// + [Fact] + public void TheRungRefreshesThePassthroughView_WhichTheAnalysisReadGoesThrough() + { + var sql = PgMigrations.Scripts.Single(s => s.Version == 81).Sql; + + Assert.Contains( + "CREATE OR REPLACE VIEW collect.v_tempdb_stats AS SELECT * FROM collect.tempdb_stats;", + sql, StringComparison.Ordinal); + + Assert.Contains("FROM v_tempdb_stats", PgFactCollector.TempDbSql, StringComparison.Ordinal); + Assert.Contains("MIN(max_size_mb)", PgFactCollector.TempDbSql, StringComparison.Ordinal); + } + + /// + /// The collector column has to exist for the rung to be adding the same thing a FRESH store generates. + /// Both stores build their DDL from PayloadColumns, so a rung whose type disagreed with the + /// generator would split the two populations permanently and invisibly. + /// + [Fact] + public void TheGeneratedSchemaAndTheRungAgreeOnTheColumn() + { + var generated = PgSchemaGenerator.CreateTable(PerformanceMonitor.Collectors.TempDbStatsCollector.Instance); + + Assert.Contains("max_size_mb numeric(18,2)", generated, StringComparison.Ordinal); + Assert.Contains("max_size_mb numeric(18,2)", PgMigrations.Scripts.Single(s => s.Version == 81).Sql, StringComparison.Ordinal); + } + + [Fact] + public void TheProbeAsksForTheColumn_AndTheThreePlacesAgree() + { + Assert.Contains( + "table_name = 'tempdb_stats' AND column_name = 'max_size_mb'", + ViewerDataService.StoreSchemaProbeSql, StringComparison.Ordinal); + + var mapParameters = typeof(ViewerDataService) + .GetMethod("MapProbedSchemaVersion", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)! + .GetParameters().Length; + + var viewerSource = ReadViewerSource(); + + /* Ordinals are positional: the sentinel was APPENDED, so the reader must ask for exactly one more + than it did and no more than that. This form belongs to whichever rung is NEWEST — the older + rungs' twins pin their own fixed ordinal instead, because `mapParameters - 1` slides one place + right per new rung and keeps passing while testing somebody else's wiring. */ + Assert.Contains($"reader.GetBoolean({mapParameters - 1})", viewerSource, StringComparison.Ordinal); + Assert.DoesNotContain($"reader.GetBoolean({mapParameters})", viewerSource, StringComparison.Ordinal); + } + + [Fact] + public void TheProbeMapsAFullyMigratedStoreTo81() + { + Assert.Equal(81, StorageVersion.SchemaVersion); + Assert.Equal(StorageVersion.SchemaVersion, ViewerDataService.RequiredStoreSchemaVersion); + + var method = typeof(ViewerDataService) + .GetMethod("MapProbedSchemaVersion", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!; + var arity = method.GetParameters().Length; + + /* 56 positional sentinels, then this rung's own, then FALSE for anything a later rung appends. The + leading count is FIXED at this rung's ordinal deliberately: deriving it from arity reads + identically while this is the top rung, then slides one place right per new rung — the assertion + keeps passing while quietly testing a newer arm. */ + var all = Enumerable.Repeat(true, 56).Cast().ToArray(); + object[] Args(bool ownFlag) => all + .Concat(new object[] { ownFlag }) + .Concat(Enumerable.Repeat((object)false, arity - 57)) + .ToArray(); + + Assert.Equal(81, (int)method.Invoke(null, Args(true))!); + Assert.Equal(80, (int)method.Invoke(null, Args(false))!); + } + + /* ---------------- the arithmetic ---------------- */ + + /// + /// THE MEASUREMENT, as arithmetic. The Azure shape is the one from the issue verbatim — 59.75 MB reserved + /// and 2.69 MB unallocated inside four 16 MB files whose max_size sums to 65,536 MB — and the whole + /// point of the fix is that this must NOT clear the shipped 80% default. + /// + [Fact] + public void TheAzureShape_ReadsAsEmptyAgainstItsCeiling_AndDoesNotClearTheDefaultThreshold() + { + var azure = new TempDbSpaceInfo { TotalReservedMb = 59.75, UnallocatedMb = 2.69, MaxSizeMb = 65_536 }; + + /* 62.44 MB allocated — the number the old denominator used, and it is a thousandth of the ceiling. */ + Assert.Equal(62.44, azure.AllocatedMb, precision: 2); + Assert.Equal(65_536d, azure.CapacityMb, precision: 2); + + Assert.Equal(0.0912, azure.UsedPercent, precision: 4); + Assert.True(azure.UsedPercent < DefaultTempDbThresholdPercent); + + /* And the number the old denominator produced, kept here so the size of the correction is on the + record rather than being re-derived by hand: the same snapshot used to read 95.7% full. */ + var withoutTheCeiling = new TempDbSpaceInfo { TotalReservedMb = 59.75, UnallocatedMb = 2.69 }; + Assert.Equal(95.69, withoutTheCeiling.UsedPercent, precision: 2); + Assert.True(withoutTheCeiling.UsedPercent >= DefaultTempDbThresholdPercent); + } + + /// + /// The on-prem shape that must NOT move: an unlimited data file has no ceiling to measure against, so the + /// current allocation stays the denominator and the percentage is exactly what it was before this rung. + /// That is what keeps every unlimited-growth on-prem and RDS target reporting the number it reports today. + /// + [Fact] + public void TheUnlimitedOnPremShape_StillDividesByTheAllocation() + { + var unlimited = new TempDbSpaceInfo { TotalReservedMb = 800, UnallocatedMb = 200, MaxSizeMb = -1 }; + + Assert.Equal(1000d, unlimited.CapacityMb, precision: 2); + Assert.Equal(80d, unlimited.UsedPercent, precision: 3); + Assert.True(unlimited.UsedPercent >= DefaultTempDbThresholdPercent); + + /* A snapshot from before the column existed reports 0, a different fact with the same answer: nobody + measured a ceiling, so the allocation is the only honest denominator. */ + var notMeasured = new TempDbSpaceInfo { TotalReservedMb = 800, UnallocatedMb = 200 }; + Assert.Equal(unlimited.UsedPercent, notMeasured.UsedPercent, precision: 9); + } + + /// + /// The on-prem shape that DOES move, stated plainly because it is the one thing this rung changes about + /// numbers an operator is already looking at: a tempdb with a fixed max_size it has not grown into + /// reports a LOWER percentage than before. That is the correction — 800 MB inside a 1,000 MB allocation + /// that may grow to 4,000 MB is 20% of the way to the wall, not 80%. + /// + [Fact] + public void TheCappedOnPremShape_ReportsLowerThanItUsedTo() + { + var capped = new TempDbSpaceInfo { TotalReservedMb = 800, UnallocatedMb = 200, MaxSizeMb = 4_000 }; + + Assert.Equal(20d, capped.UsedPercent, precision: 3); + Assert.True(capped.UsedPercent < DefaultTempDbThresholdPercent); + + /* And it climbs back to the alert as the files actually approach the cap, which is the behaviour the + old denominator could never produce: 3,400 MB reserved inside a 4,000 MB ceiling is 85%. */ + var nearlyFull = new TempDbSpaceInfo { TotalReservedMb = 3_400, UnallocatedMb = 100, MaxSizeMb = 4_000 }; + Assert.Equal(85d, nearlyFull.UsedPercent, precision: 3); + Assert.True(nearlyFull.UsedPercent >= DefaultTempDbThresholdPercent); + } + + /// + /// A ceiling below what is already allocated cannot make the percentage exceed 100. The larger of the two + /// wins, because a file that has grown past a later-lowered cap is at its wall rather than beyond it. + /// + [Fact] + public void ACeilingBelowTheAllocation_DoesNotProduceAPercentageAbove100() + { + var shrunkCap = new TempDbSpaceInfo { TotalReservedMb = 950, UnallocatedMb = 50, MaxSizeMb = 400 }; + + Assert.Equal(1000d, shrunkCap.CapacityMb, precision: 2); + Assert.Equal(95d, shrunkCap.UsedPercent, precision: 3); + } + + /// + /// The shipped default, read off the config rather than typed here — the threshold the assertions above + /// compare against has to be the one the product actually ships or they prove nothing about it. + /// + private static int DefaultTempDbThresholdPercent => new DarlingConfig().Alerts.TempDbSpaceThresholdPercent; + + [Fact] + public void TheDefaultThresholdIsStill80() + { + Assert.Equal(80, DefaultTempDbThresholdPercent); + } + + /* ---------------- gated live E2E ---------------- */ + + /// + /// All three states of the ceiling through the REAL read against live Postgres. The pure arithmetic + /// above cannot see a column that is selected but not mapped, an ordinal off by one, or a NULL that + /// arrives as something other than zero — and every one of those would put the alert back on the + /// allocation silently. + /// + [Fact] + public async Task EndToEnd_TheCeilingSurvivesTheStoreRoundTrip_ForAllThreeStates() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live tempdb-ceiling test."); + + var ct = TestContext.Current.CancellationToken; + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + + await DeleteTestRowsAsync(connection, ct); + + await using var postgres = NpgsqlDataSource.Create(connectionString!); + var adapter = new DarlingAlertReadAdapter(postgres); + + var bodySucceeded = false; + try + { + var collectionTime = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified).AddMinutes(-1); + + /* The Azure shape, verbatim from the issue's measurement. */ + await InsertAsync(connection, collectionTime, 5.44m, 1.81m, 0m, 59.75m, 2.69m, 65_536m, ct); + + var azure = await adapter.GetTempDbSpaceAsync(TestServerKey, ct); + Assert.NotNull(azure); + Assert.Equal(65_536d, azure!.MaxSizeMb, precision: 2); + Assert.Equal(0.0912, azure.UsedPercent, precision: 4); + Assert.True(azure.UsedPercent < DefaultTempDbThresholdPercent, + "62 MB allocated against a 65,536 MB cap must not clear the 80% default."); + + /* The unlimited on-prem shape, newer so it wins the ORDER BY. -1 must survive the round trip AS + -1: the percentage would come out at 80 either way here, but the SIGN is the only thing that + separates "this tempdb has no ceiling" from "nobody measured one", and it is what the alert + detail renders as Unlimited rather than Unknown. */ + await InsertAsync(connection, collectionTime.AddSeconds(30), 500m, 250m, 50m, 800m, 200m, -1m, ct); + + var unlimited = await adapter.GetTempDbSpaceAsync(TestServerKey, ct); + Assert.Equal(-1d, unlimited!.MaxSizeMb, precision: 2); + Assert.Equal(80d, unlimited.UsedPercent, precision: 3); + + /* And a pre-rung row, whose ceiling is genuinely NULL. It must arrive as 0 rather than throwing + or reading as a zero-megabyte cap — this is what every historical row in a real store looks + like the moment the migration lands. */ + await InsertAsync(connection, collectionTime.AddSeconds(60), 500m, 250m, 50m, 800m, 200m, null, ct); + + var history = await adapter.GetTempDbSpaceAsync(TestServerKey, ct); + Assert.Equal(0d, history!.MaxSizeMb, precision: 2); + Assert.Equal(80d, history.UsedPercent, precision: 3); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(connectionString!, bodySucceeded, async (cleanup, cleanupCt) => + await DeleteTestRowsAsync(cleanup, cleanupCt)); + } + } + + private static async Task InsertAsync( + NpgsqlConnection connection, DateTime collectionTime, + decimal userMb, decimal internalMb, decimal versionStoreMb, + decimal totalReservedMb, decimal unallocatedMb, decimal? maxSizeMb, + System.Threading.CancellationToken ct) + { + using var command = new NpgsqlCommand( + "INSERT INTO tempdb_stats (collection_id, collection_time, server_id, server_name, user_object_reserved_mb, internal_object_reserved_mb, version_store_reserved_mb, total_reserved_mb, unallocated_mb, top_session_id, top_session_tempdb_mb, max_size_mb) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", + connection); + + command.Parameters.AddWithValue(collectionTime.Ticks); + command.Parameters.AddWithValue(collectionTime); + command.Parameters.AddWithValue(TestServerId); + command.Parameters.AddWithValue(TestServerName); + command.Parameters.AddWithValue(userMb); + command.Parameters.AddWithValue(internalMb); + command.Parameters.AddWithValue(versionStoreMb); + command.Parameters.AddWithValue(totalReservedMb); + command.Parameters.AddWithValue(unallocatedMb); + command.Parameters.AddWithValue(55); + command.Parameters.AddWithValue(12.5m); + command.Parameters.AddWithValue(maxSizeMb.HasValue ? maxSizeMb.Value : (object)DBNull.Value); + + await command.ExecuteNonQueryAsync(ct); + } + + private static async Task DeleteTestRowsAsync(NpgsqlConnection connection, System.Threading.CancellationToken ct) + { + using var cleanup = new NpgsqlCommand( + $"DELETE FROM tempdb_stats WHERE server_id = {TestServerId};", connection); + await cleanup.ExecuteNonQueryAsync(ct); + } + + private static string ReadViewerSource() => + System.IO.File.ReadAllText(System.IO.Path.Combine( + RepoRoot(), "Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.cs")); + + private static string RepoRoot([System.Runtime.CompilerServices.CallerFilePath] string thisFile = "") + { + for (var dir = new System.IO.DirectoryInfo(System.IO.Path.GetDirectoryName(thisFile)!); dir is not null; dir = dir.Parent) + { + if (System.IO.Directory.Exists(System.IO.Path.Combine(dir.FullName, "PerformanceMonitor.Common"))) + { + return dir.FullName; + } + } + + throw new System.IO.DirectoryNotFoundException($"Could not locate the repo root walking up from {thisFile}"); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Storage.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Storage.cs index 829da57fc9..ceb6a8030a 100644 --- a/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Storage.cs +++ b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Storage.cs @@ -140,7 +140,12 @@ private async Task CollectIoLatencyFactsAsync(AnalysisContext context, List= $2 @@ -170,11 +175,18 @@ private async Task CollectTempDbFactsAsync(AnalysisContext context, List f var maxVersionStore = reader.IsDBNull(3) ? 0.0 : Convert.ToDouble(reader.GetValue(3)); var minUnallocated = reader.IsDBNull(4) ? 0.0 : Convert.ToDouble(reader.GetValue(4)); var avgReserved = reader.IsDBNull(5) ? 0.0 : Convert.ToDouble(reader.GetValue(5)); + var maxSizeMb = reader.IsDBNull(6) ? 0.0 : Convert.ToDouble(reader.GetValue(6)); if (maxReserved <= 0) return; - // TempDB usage as fraction of total space (reserved + unallocated) - var totalSpace = maxReserved + minUnallocated; + /* #2515: against the CEILING where tempdb has one, against the current allocation where it does + not (-1 unlimited, or 0 for a window collected before the ceiling was captured). reserved + + unallocated is the files AS ALLOCATED, so on its own this fraction measures distance to the + next autogrow — which reads as 96% full on an Azure SQL Database target holding one temp + table. TempDbSpaceInfo.CapacityMb is the alert's twin of this, and the two must agree or + analyze_server and the pager describe the same server differently. */ + var allocatedMb = maxReserved + minUnallocated; + var totalSpace = maxSizeMb > 0 ? Math.Max(maxSizeMb, allocatedMb) : allocatedMb; var usageFraction = totalSpace > 0 ? maxReserved / totalSpace : 0; facts.Add(new Fact @@ -191,6 +203,9 @@ private async Task CollectTempDbFactsAsync(AnalysisContext context, List f ["max_internal_object_mb"] = maxInternalObj, ["max_version_store_mb"] = maxVersionStore, ["min_unallocated_mb"] = minUnallocated, + /* Added, never redefined — the existing keys are a consumer surface (FactAdvice reads + them by name). -1 unlimited, 0 not measured, positive = the ROWS ceiling in MB. */ + ["max_size_mb"] = maxSizeMb, ["usage_fraction"] = usageFraction } }); diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingAlertReadAdapter.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingAlertReadAdapter.cs index 16fed51e7c..c068f4ec79 100644 --- a/Darling/PerformanceMonitor.Darling.Service/DarlingAlertReadAdapter.cs +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingAlertReadAdapter.cs @@ -654,7 +654,8 @@ public async Task> GetPvsPressureAsync( internal_object_reserved_mb, version_store_reserved_mb, top_session_tempdb_mb, - top_session_id + top_session_id, + max_size_mb FROM tempdb_stats WHERE server_id = $1 ORDER BY collection_time DESC @@ -680,7 +681,11 @@ ORDER BY collection_time DESC InternalObjectReservedMb = reader.IsDBNull(3) ? 0 : ToDouble(reader.GetValue(3)), VersionStoreReservedMb = reader.IsDBNull(4) ? 0 : ToDouble(reader.GetValue(4)), TopConsumerMb = reader.IsDBNull(5) ? 0 : ToDouble(reader.GetValue(5)), - TopConsumerSessionId = reader.IsDBNull(6) ? 0 : reader.GetInt32(6) + TopConsumerSessionId = reader.IsDBNull(6) ? 0 : reader.GetInt32(6), + /* NULL on every row collected before the V81 rung, and 0 is what "no ceiling measured" + is spelled as — so history keeps reporting the percentage it always did rather than + dividing by a zero cap. */ + MaxSizeMb = reader.IsDBNull(7) ? 0 : ToDouble(reader.GetValue(7)) }; } diff --git a/Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs b/Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs index 2d18dd42f6..059911a7bf 100644 --- a/Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs +++ b/Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs @@ -137,6 +137,7 @@ here costs a fresh-through-this-rung store nothing and rung 54's own copy no-ops new Migration(78, "compose-statement-timeout", V78Sql), new Migration(79, "file-growth-alert", V79Sql), new Migration(80, "collection-log-fanout-rollup", V80Sql), + new Migration(81, "tempdb-max-size", V81Sql), }; /// @@ -1695,6 +1696,39 @@ PRIMARY KEY (server_id, queryid) CREATE INDEX IF NOT EXISTS idx_pg_statement_text_last_seen ON collect.pg_statement_text(last_seen);"; + /// + /// V81 — tempdb's growth CEILING on tempdb_stats (#2515). dm_db_file_space_usage, which is + /// where every other column in this table comes from, describes the data files AS CURRENTLY ALLOCATED. So + /// total_reserved ÷ (total_reserved + unallocated) — the tempdb Space alert's + /// percentage — is distance to the next AUTOGROW, and it only looks like real headroom on a pre-sized + /// on-prem box because such a tempdb has already grown to its cap. + /// + /// What made this a bug rather than a tuning question. Measured on GP_S_Gen5_2: four + /// tempdb data files of 16 MB each, 62.44 MB allocated, and max_size of 2,097,152 pages on every one + /// of them — a 65,536 MB ceiling. One ~57 MB #temp table reads 95.7% full against the allocation and + /// 0.09% against the cap, so enabling Azure collection (#2512) would have armed an alert that fires on the + /// first busy minute of every Azure target at the shipped 80% default. + /// + /// Why a column and not a derivation. The obvious alternative is to read the cap out of + /// database_size_stats, which already carries max_size_mb per file — and on Azure SQL + /// Database that collector reads only the CONNECTED database's sys.database_files, so tempdb never + /// appears in it. The one platform this exists for is the one where the derivation has nothing to read. + /// + /// Nullable with no DEFAULT and no backfill, like V80 and for the same reasons: it is a catalog-only + /// change in PostgreSQL and stays instant on a compressed hypertable, and a row collected before this rung + /// genuinely does not know the ceiling. NULL reads as 0 in the adapter, which is the "no ceiling measured" + /// state — history keeps reporting the percentage it always did rather than dividing by a zero cap. + /// + private const string V81Sql = @" +ALTER TABLE collect.tempdb_stats + ADD COLUMN IF NOT EXISTS max_size_mb numeric(18,2); + +/* Postgres FREEZES a view's SELECT * column list at CREATE, so the V4 passthrough the analysis fact + collector reads would keep serving the twelve columns it had forever — the V14 lesson, restated by V80. + Appending is the one alteration CREATE OR REPLACE VIEW permits, which is exactly what an ADD COLUMN + produces. */ +CREATE OR REPLACE VIEW collect.v_tempdb_stats AS SELECT * FROM collect.tempdb_stats;"; + /// /// V80 — the fan-out rollup on collection_log (#2472). A collector that runs once per DATABASE /// writes ONE row whose duration_ms is the sum across every database, so an 80.8-second run is diff --git a/Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs b/Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs index af688a137a..c9d4392734 100644 --- a/Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs +++ b/Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs @@ -16,5 +16,5 @@ namespace PerformanceMonitor.Darling.Storage; /// public static class StorageVersion { - public const int SchemaVersion = 80; + public const int SchemaVersion = 81; } diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs index 6f1d6d141f..0392ee222c 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs @@ -599,7 +599,8 @@ OR NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'query_store_text' AND column_name = 'query_hash'), EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'config_service' AND column_name = 'compose_statement_timeout_seconds'), EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'config_alert_settings' AND column_name = 'file_growth_enabled'), - EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'collection_log' AND column_name = 'slowest_item_ms')"; + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'collection_log' AND column_name = 'slowest_item_ms'), + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'tempdb_stats' AND column_name = 'max_size_mb')"; /// The store schema version this viewer build requires — the highest migration it knows /// (). The connect-time gate blocks a store below this. @@ -620,7 +621,7 @@ OR NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') await using var reader = await command.ExecuteReaderAsync(cancellationToken); if (await reader.ReadAsync(cancellationToken)) { - return MapProbedSchemaVersion(reader.GetBoolean(0), reader.GetBoolean(1), reader.GetBoolean(2), reader.GetBoolean(3), reader.GetBoolean(4), reader.GetBoolean(5), reader.GetBoolean(6), reader.GetBoolean(7), reader.GetBoolean(8), reader.GetBoolean(9), reader.GetBoolean(10), reader.GetBoolean(11), reader.GetBoolean(12), reader.GetBoolean(13), reader.GetBoolean(14), reader.GetBoolean(15), reader.GetBoolean(16), reader.GetBoolean(17), reader.GetBoolean(18), reader.GetBoolean(19), reader.GetBoolean(20), reader.GetBoolean(21), reader.GetBoolean(22), reader.GetBoolean(23), reader.GetBoolean(24), reader.GetBoolean(25), reader.GetBoolean(26), reader.GetBoolean(27), reader.GetBoolean(28), reader.GetBoolean(29), reader.GetBoolean(30), reader.GetBoolean(31), reader.GetBoolean(32), reader.GetBoolean(33), reader.GetBoolean(34), reader.GetBoolean(35), reader.GetBoolean(36), reader.GetBoolean(37), reader.GetBoolean(38), reader.GetBoolean(39), reader.GetBoolean(40), reader.GetBoolean(41), reader.GetBoolean(42), reader.GetBoolean(43), reader.GetBoolean(44), reader.GetBoolean(45), reader.GetBoolean(46), reader.GetBoolean(47), reader.GetBoolean(48), reader.GetBoolean(49), reader.GetBoolean(50), reader.GetBoolean(51), reader.GetBoolean(52), reader.GetBoolean(53), reader.GetBoolean(54), reader.GetBoolean(55)); + return MapProbedSchemaVersion(reader.GetBoolean(0), reader.GetBoolean(1), reader.GetBoolean(2), reader.GetBoolean(3), reader.GetBoolean(4), reader.GetBoolean(5), reader.GetBoolean(6), reader.GetBoolean(7), reader.GetBoolean(8), reader.GetBoolean(9), reader.GetBoolean(10), reader.GetBoolean(11), reader.GetBoolean(12), reader.GetBoolean(13), reader.GetBoolean(14), reader.GetBoolean(15), reader.GetBoolean(16), reader.GetBoolean(17), reader.GetBoolean(18), reader.GetBoolean(19), reader.GetBoolean(20), reader.GetBoolean(21), reader.GetBoolean(22), reader.GetBoolean(23), reader.GetBoolean(24), reader.GetBoolean(25), reader.GetBoolean(26), reader.GetBoolean(27), reader.GetBoolean(28), reader.GetBoolean(29), reader.GetBoolean(30), reader.GetBoolean(31), reader.GetBoolean(32), reader.GetBoolean(33), reader.GetBoolean(34), reader.GetBoolean(35), reader.GetBoolean(36), reader.GetBoolean(37), reader.GetBoolean(38), reader.GetBoolean(39), reader.GetBoolean(40), reader.GetBoolean(41), reader.GetBoolean(42), reader.GetBoolean(43), reader.GetBoolean(44), reader.GetBoolean(45), reader.GetBoolean(46), reader.GetBoolean(47), reader.GetBoolean(48), reader.GetBoolean(49), reader.GetBoolean(50), reader.GetBoolean(51), reader.GetBoolean(52), reader.GetBoolean(53), reader.GetBoolean(54), reader.GetBoolean(55), reader.GetBoolean(56)); } return null; @@ -645,7 +646,7 @@ OR NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') /// is unit-tested without a live store; any schema bump past the newest arm trips the pinning test that keeps /// this in step with . /// - internal static int MapProbedSchemaVersion(bool hasConfigControlPlane, bool hasAlertDeliveryOverride, bool hasAnalysisState, bool hasAlertTuningKnobs, bool hasDefaultTraceEvents, bool hasIndexObjectStatsLatestIndex, bool hasCollectionLogHypertableOrPlainPg, bool hasJobHistory, bool hasAgentStatus, bool hasGenericWebhook, bool hasDeadlocksDatabaseName, bool hasQueryStoreReplicaRole, bool hasLongQueryCompletions, bool hasWebDashboardConfig, bool hasCustomViews, bool hasServerTags, bool hasConnectionRefireKnobs = false, bool hasAgCollectors = false, bool hasAgAlertKnobs = false, bool hasAgLatencyColumns = false, bool hasAgDisconnectRefire = false, bool hasPayloadDimensions = false, bool hasDimFloorIndexes = false, bool hasBlockingWaitThreshold = false, bool hasQueryStoreIntervalIdentity = false, bool hasPagerDutyWebhook = false, bool hasPagerDutyProxy = false, bool hasCollectorState = false, bool hasPlanCorrection = false, bool hasPvsStats = false, bool hasPvsPressureKnobs = false, bool hasDatabaseStateAlert = false, bool hasServerTagColour = false, bool hasQueryStatsHostObject = false, bool hasFindingDrillDown = false, bool hasStoreMetrics = false, bool hasPlanDimGzip = false, bool hasSelfAlertKnobs = false, bool hasJobMetricsColumns = false, bool hasJobCadenceKnob = false, bool hasBackfillSwitch = false, bool hasCollectorMemoryKnobs = false, bool hasDatabaseStateEdgeMemory = false, bool hasIncidentOccurrences = false, bool hasPlanXmlCompressionKnob = false, bool hasMonitoredServerEngine = false, bool hasPgBlockingEdges = false, bool hasQueryStorePlanMap = false, bool hasPgStatementText = false, bool hasQueryStoreText = false, bool hasPlanContentRetentionKnob = false, bool hasQueryStoreHealth = false, bool hasQueryStoreTextHash = false, bool hasComposeTimeoutKnob = false, bool hasFileGrowthAlert = false, bool hasCollectionLogFanoutRollup = false) + internal static int MapProbedSchemaVersion(bool hasConfigControlPlane, bool hasAlertDeliveryOverride, bool hasAnalysisState, bool hasAlertTuningKnobs, bool hasDefaultTraceEvents, bool hasIndexObjectStatsLatestIndex, bool hasCollectionLogHypertableOrPlainPg, bool hasJobHistory, bool hasAgentStatus, bool hasGenericWebhook, bool hasDeadlocksDatabaseName, bool hasQueryStoreReplicaRole, bool hasLongQueryCompletions, bool hasWebDashboardConfig, bool hasCustomViews, bool hasServerTags, bool hasConnectionRefireKnobs = false, bool hasAgCollectors = false, bool hasAgAlertKnobs = false, bool hasAgLatencyColumns = false, bool hasAgDisconnectRefire = false, bool hasPayloadDimensions = false, bool hasDimFloorIndexes = false, bool hasBlockingWaitThreshold = false, bool hasQueryStoreIntervalIdentity = false, bool hasPagerDutyWebhook = false, bool hasPagerDutyProxy = false, bool hasCollectorState = false, bool hasPlanCorrection = false, bool hasPvsStats = false, bool hasPvsPressureKnobs = false, bool hasDatabaseStateAlert = false, bool hasServerTagColour = false, bool hasQueryStatsHostObject = false, bool hasFindingDrillDown = false, bool hasStoreMetrics = false, bool hasPlanDimGzip = false, bool hasSelfAlertKnobs = false, bool hasJobMetricsColumns = false, bool hasJobCadenceKnob = false, bool hasBackfillSwitch = false, bool hasCollectorMemoryKnobs = false, bool hasDatabaseStateEdgeMemory = false, bool hasIncidentOccurrences = false, bool hasPlanXmlCompressionKnob = false, bool hasMonitoredServerEngine = false, bool hasPgBlockingEdges = false, bool hasQueryStorePlanMap = false, bool hasPgStatementText = false, bool hasQueryStoreText = false, bool hasPlanContentRetentionKnob = false, bool hasQueryStoreHealth = false, bool hasQueryStoreTextHash = false, bool hasComposeTimeoutKnob = false, bool hasFileGrowthAlert = false, bool hasCollectionLogFanoutRollup = false, bool hasTempDbMaxSize = false) { /* V71 (the PostgreSQL blocking-edges rung): a table-existence sentinel and now the newest-first arm. A collector table would ordinarily get no arm at all — see the V63-V69 note below — but the TOP @@ -707,6 +708,22 @@ This rung's gate earns its place rather than being bookkeeping. The service writ rollup on every productive per-database run and Collection Health reads it; a viewer on a V79 store would find the column missing and the read would throw rather than degrade, so the banner has to fire before the tab does. */ + /* #2515: newest first. The arm below STAYS — a store migrated to exactly 80 must map to 80 rather + than falling through. The column is named only in the probe line, not in this prose, per the V71 + finding: the coverage ratchet strips information_schema lines but cannot strip a comment. + + The reason to gate is the standing invariant rather than a read that would throw — no viewer query + names this column; the SERVICE's alert adapter is what reads it. RequiredStoreSchemaVersion is + StorageVersion.SchemaVersion, so a fully-migrated store has to map to EXACTLY this or the banner + reports a mismatch on a store that is current. What the banner buys on the way is worth having: a + store still at 80 has no ceiling recorded anywhere in its history, so every tempdb percentage in it + is the old distance-to-next-autogrow measurement, and the operator should know that before reading + one. */ + if (hasTempDbMaxSize) + { + return 81; + } + if (hasCollectionLogFanoutRollup) { return 80; diff --git a/Lite.Tests/AlertContextBuildersTests.cs b/Lite.Tests/AlertContextBuildersTests.cs index 823a795110..a481702116 100644 --- a/Lite.Tests/AlertContextBuildersTests.cs +++ b/Lite.Tests/AlertContextBuildersTests.cs @@ -276,6 +276,10 @@ public void BuildTempDbSpaceContext_RendersAllFields_WithTopConsumer() { ("Total Reserved", $"{800d:F0} MB"), ("Unallocated", $"{200d:F0} MB"), + /* #2515: no MaxSizeMb on this fixture, so the ceiling was never measured and the + percentage above is still against the allocation — which the detail now says out loud + rather than leaving the reader to assume a denominator. */ + ("Max Size", "Unknown"), ("User Objects", $"{500d:F0} MB"), ("Internal Objects", $"{250d:F0} MB"), ("Version Store", $"{50d:F0} MB"), @@ -284,6 +288,27 @@ public void BuildTempDbSpaceContext_RendersAllFields_WithTopConsumer() item.Fields); } + /// + /// #2515: the three states of the ceiling render as three different words, because they are three + /// different facts. "Unlimited" and "Unknown" take the same denominator but they do not mean the same + /// thing, and printing either as a number would claim a measurement that was never taken. + /// + [Theory] + [InlineData(65536d, "65536 MB")] + [InlineData(-1d, "Unlimited")] + [InlineData(0d, "Unknown")] + public void BuildTempDbSpaceContext_RendersTheCeilingsThreeStates(double maxSizeMb, string expected) + { + var context = AlertContextBuilders.BuildTempDbSpaceContext(new TempDbSpaceInfo + { + TotalReservedMb = 800, + UnallocatedMb = 200, + MaxSizeMb = maxSizeMb + }); + + Assert.Equal(("Max Size", expected), context!.Details[0].Fields[2]); + } + [Fact] public void BuildTempDbSpaceContext_NoTopConsumer_RendersNone() { @@ -294,7 +319,7 @@ public void BuildTempDbSpaceContext_NoTopConsumer_RendersNone() TopConsumerSessionId = 0 }); - Assert.Equal(("Top Consumer", "None"), context!.Details[0].Fields[5]); + Assert.Equal(("Top Consumer", "None"), context!.Details[0].Fields[6]); } /* ---------------- anomalous jobs ---------------- */ diff --git a/Lite.Tests/GoldenCollectorSchema.cs b/Lite.Tests/GoldenCollectorSchema.cs index 30c43ead08..4b472daeb1 100644 --- a/Lite.Tests/GoldenCollectorSchema.cs +++ b/Lite.Tests/GoldenCollectorSchema.cs @@ -14,7 +14,8 @@ // ever land at the END of its table; reflecting it here keeps the oracle describing the schema a fresh // store is actually built with, while every pre-existing column stays frozen. Precedent: ag_replica_role // (v36), replica_role (v47), runtime_stats_interval_id + interval_start_time_utc (v49, #1841 tier 2). A -// NOT NULL relaxation is NOT an append — that goes in IntentionalStorageDivergences instead. +// NOT NULL relaxation is NOT an append — that goes in IntentionalStorageDivergences instead. Also +// max_size_mb on tempdb_stats (v56, #2515). // using System.Collections.Generic; @@ -132,7 +133,8 @@ total_reserved_mb DECIMAL(18,2), unallocated_mb DECIMAL(18,2), total_sessions_using_tempdb BIGINT, top_session_id INTEGER, - top_session_tempdb_mb DECIMAL(18,2) + top_session_tempdb_mb DECIMAL(18,2), + max_size_mb DECIMAL(18,2) )", ["memory_grant_stats"] = @"CREATE TABLE IF NOT EXISTS memory_grant_stats ( collection_id BIGINT PRIMARY KEY, diff --git a/Lite.Tests/LiteAlertForwardingTests.cs b/Lite.Tests/LiteAlertForwardingTests.cs index 9809a7300a..85969814a1 100644 --- a/Lite.Tests/LiteAlertForwardingTests.cs +++ b/Lite.Tests/LiteAlertForwardingTests.cs @@ -710,6 +710,35 @@ public async Task TempDb_ToastBody_MatchesTheOldLoop() Assert.Equal("tempdb 91% used", fired.ShortMessage); /* :435 toast body */ } + /// + /// #2515, the SKU-parity half: Lite and Darling share the engine and the type, so the ceiling has to + /// change the decision identically on both. Darling's twin is + /// AlertEngineTests.TempDb_TheAzureCeiling_SuppressesTheAlert_ThatTheAllocationWouldFire, with the + /// same numbers — an operator must not get a page on one product and silence on the other for the same + /// tempdb. + /// + [Fact] + public async Task TempDb_TheAzureCeiling_SuppressesTheAlert_ThatTheAllocationWouldFire() + { + DisableAllChecks(); + App.AlertTempDbSpaceEnabled = true; + Assert.Equal(80, App.AlertTempDbSpaceThresholdPercent); + + var h = new Harness(); + /* GP_S_Gen5_2 with one ~57 MB #temp table: 62.44 MB allocated, 65,536 MB of headroom behind it. */ + h.Adapter.TempDb = new TempDbSpaceInfo { TotalReservedMb = 59.75, UnallocatedMb = 2.69, MaxSizeMb = 65_536 }; + await h.Build().EvaluateServerAsync(Harness.Snapshot()); + Assert.Empty(h.Deliverer.Outcomes); + + /* The identical snapshot with the ceiling unmeasured is the pre-#2515 reading, and it pages. */ + var withoutCeiling = new Harness(); + withoutCeiling.Adapter.TempDb = new TempDbSpaceInfo { TotalReservedMb = 59.75, UnallocatedMb = 2.69 }; + await withoutCeiling.Build().EvaluateServerAsync(Harness.Snapshot()); + var fired = Assert.Single(withoutCeiling.Deliverer.Outcomes); + Assert.Equal("96% used (60 MB)", fired.CurrentValue); + Assert.Equal("tempdb 96% used", fired.ShortMessage); + } + [Fact] public async Task LongRunningJob_ToastBody_CarriesJobNamePercentAndMinutes() { diff --git a/Lite.Tests/TempDbCeilingStoreTests.cs b/Lite.Tests/TempDbCeilingStoreTests.cs new file mode 100644 index 0000000000..8dd15288ca --- /dev/null +++ b/Lite.Tests/TempDbCeilingStoreTests.cs @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Linq; +using System.Threading.Tasks; +using PerformanceMonitorLite.Analysis; +using PerformanceMonitorLite.Database; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// +/// #2515, Lite's half: the tempdb growth ceiling through the REAL DuckDB store and the REAL reads. +/// +/// Darling's TempDbCeilingStoreTests pins the same behaviour against live Postgres. Both are +/// needed and neither substitutes for the other: the two SKUs write their own SQL against their own engine, +/// share the TempDbSpaceInfo that does the arithmetic, and a column selected in one adapter but not +/// the other puts exactly one product silently back on the old denominator. +/// +/// These go through a real database rather than a source pin because the defect this guards against — +/// an ordinal off by one, a NULL arriving as something other than zero, a column added to the schema but not +/// to the read — is invisible to any assertion over query TEXT. +/// +public sealed class TempDbCeilingStoreTests : IClassFixture +{ + private readonly DuckDbInitializer _duckDb; + + public TempDbCeilingStoreTests(SharedDuckDbFixture fixture) + { + fixture.ResetData(); + _duckDb = fixture.DuckDb; + } + + /// + /// The migration is the Lite twin of Darling's V81 rung, and the version has to move with it or an + /// existing Lite database never gets the column and every read of it fails. + /// + [Fact] + public void TheSchemaVersionMovedWithTheColumn() + { + Assert.Equal(56, DuckDbInitializer.CurrentSchemaVersion); + + var ddl = DuckDbSchemaGenerator.CreateTable(PerformanceMonitor.Collectors.TempDbStatsCollector.Instance); + Assert.Contains("max_size_mb DECIMAL(18,2)", ddl, System.StringComparison.Ordinal); + } + + /// + /// The Azure shape from the issue's measurement, round-tripped: 59.75 MB reserved and 2.69 MB unallocated + /// inside four 16 MB files whose max_size sums to 65,536 MB. It must read as 0.09% full rather than + /// the 95.7% the allocation produces, which is the whole difference between silence and a page. + /// + [Fact] + public async Task TheAzureShape_RoundTripsItsCeiling_AndReadsAsEmpty() + { + using var seeder = new TestDataSeeder(_duckDb); + await seeder.ClearTestDataAsync(); + await seeder.SeedTestServerAsync(); + await seeder.SeedTempDbAsync( + reservedMb: 59.75, unallocatedMb: 2.69, + userObjectMb: 5.44, internalObjectMb: 1.81, versionStoreMb: 0.01, + maxSizeMb: 65_536); + + var info = await new LocalDataService(_duckDb).GetLatestTempDbSpaceAsync(TestDataSeeder.TestServerId); + + Assert.NotNull(info); + Assert.Equal(65_536d, info!.MaxSizeMb, precision: 2); + Assert.Equal(0.0912, info.UsedPercent, precision: 4); + Assert.True(info.UsedPercent < 80, "62 MB allocated against a 65,536 MB cap must not clear the 80% default."); + } + + /// + /// Unlimited survives as -1 rather than being flattened, and takes the allocation as its denominator — + /// so every unlimited-growth on-prem and RDS target reports exactly the number it reports today. + /// + [Fact] + public async Task AnUnlimitedCeiling_SurvivesAsMinusOne_AndKeepsTheAllocationDenominator() + { + using var seeder = new TestDataSeeder(_duckDb); + await seeder.ClearTestDataAsync(); + await seeder.SeedTestServerAsync(); + await seeder.SeedTempDbAsync(reservedMb: 800, unallocatedMb: 200, maxSizeMb: -1); + + var info = await new LocalDataService(_duckDb).GetLatestTempDbSpaceAsync(TestDataSeeder.TestServerId); + + Assert.Equal(-1d, info!.MaxSizeMb, precision: 2); + Assert.Equal(80d, info.UsedPercent, precision: 3); + } + + /// + /// And a row from before the migration, whose ceiling is genuinely NULL. It has to arrive as 0 — the + /// "not measured" state — rather than as a zero-megabyte cap, which would divide by nothing. This is what + /// every historical row in a real Lite database looks like the moment the upgrade lands. + /// + [Fact] + public async Task ANullCeiling_ReadsAsNotMeasured_AndTheNumberDoesNotMove() + { + using var seeder = new TestDataSeeder(_duckDb); + await seeder.ClearTestDataAsync(); + await seeder.SeedTestServerAsync(); + await seeder.SeedTempDbAsync(reservedMb: 800, unallocatedMb: 200); + + var info = await new LocalDataService(_duckDb).GetLatestTempDbSpaceAsync(TestDataSeeder.TestServerId); + + Assert.Equal(0d, info!.MaxSizeMb, precision: 2); + Assert.Equal(80d, info.UsedPercent, precision: 3); + } + + /// + /// The ANALYSIS surface has to agree with the alert, or analyze_server scores the same Azure target + /// at 96% full while the pager stays quiet — two answers about one server, which is the shape of defect + /// this whole change exists to remove. The fact carries its own denominator, so it needed the same fix. + /// + [Fact] + public async Task TheAnalysisFact_ScoresAgainstTheCeilingToo() + { + using var seeder = new TestDataSeeder(_duckDb); + await seeder.ClearTestDataAsync(); + await seeder.SeedTestServerAsync(); + await seeder.SeedTempDbAsync( + reservedMb: 59.75, unallocatedMb: 2.69, + userObjectMb: 5.44, internalObjectMb: 1.81, versionStoreMb: 0.01, + maxSizeMb: 65_536); + + var facts = await new DuckDbFactCollector(_duckDb).CollectFactsAsync(TestDataSeeder.CreateTestContext()); + var tempdb = facts.First(f => f.Key == "TEMPDB_USAGE"); + + Assert.Equal(0.000912, tempdb.Value, precision: 6); + Assert.Equal(65_536d, tempdb.Metadata["max_size_mb"], precision: 2); + + /* The severity arm concerns at 0.75 and criticals at 0.90, so the corrected fraction scores nothing — + where the allocation fraction (0.957) would have pinned it at the top of the scale. */ + Assert.True(tempdb.Value < 0.75); + } +} diff --git a/Lite.Tests/TempDbStatsCollectorDefinitionTests.cs b/Lite.Tests/TempDbStatsCollectorDefinitionTests.cs index 0afc93a4e9..35b548aeda 100644 --- a/Lite.Tests/TempDbStatsCollectorDefinitionTests.cs +++ b/Lite.Tests/TempDbStatsCollectorDefinitionTests.cs @@ -38,6 +38,10 @@ public void PayloadColumns_MatchSchemaOrder() "total_sessions_using_tempdb", "top_session_id", "top_session_tempdb_mb", + /* #2515, APPENDED. Both stores generate their DDL from this list in order and both row + writers are positional, so the ceiling could only ever go last — inserting it beside + unallocated_mb, where it belongs semantically, would re-map every historical row. */ + "max_size_mb", }, names); } @@ -52,11 +56,37 @@ public void Query_TargetsBothTempDbDmvs() Assert.Equal("tempdb_stats", TempDbStatsCollector.Instance.TargetTable); } + /// + /// #2515: the ceiling comes from tempdb's own catalog, and the two things that make it the RIGHT + /// ceiling are both in the query rather than in the reader — so they can only be pinned here. + /// + /// LOG files are excluded because dm_db_file_space_usage, which supplies every other + /// column, reports DATA allocation: folding the log's cap into the same denominator would understate + /// usage on every server, not just Azure. And max_size is an int of 8 KB pages that tops + /// out at 16 TB per file, so a wide tempdb can overflow a plain SUM — the widen has to happen + /// before the sum, not after it. + /// + [Fact] + public void Query_ReadsTheCeilingFromTheRowsFilesOnly_AndSumsItWideEnough() + { + var queryText = TempDbStatsCollector.Instance.BuildQuery(CollectorTestContext.Make(new RecordingCollectorDeltaCalculator())).Text; + + Assert.Contains("tempdb.sys.database_files AS df", queryText, System.StringComparison.Ordinal); + Assert.Contains("WHERE df.type = 0 /*ROWS*/", queryText, System.StringComparison.Ordinal); + Assert.Contains("SUM(CONVERT(bigint, df.max_size))", queryText, System.StringComparison.Ordinal); + + /* -1 on any one data file means tempdb as a whole grows without limit, and MIN is what finds it. */ + Assert.Contains("WHEN MIN(df.max_size) = -1", queryText, System.StringComparison.Ordinal); + + /* House convention, and it is load-bearing on a query that now carries a second aggregate. */ + Assert.Contains("OPTION(RECOMPILE)", queryText, System.StringComparison.Ordinal); + } + [Fact] public async Task ReadAsync_CombinesTwoResultSets_IntoOneRow() { using var reader = FakeCollectorDataReader.WithResultSets( - new[] { new object[] { 1.5m, 2.5m, 3.5m, 7.5m, 10.0m } }, + new[] { new object[] { 1.5m, 2.5m, 3.5m, 7.5m, 10.0m, 65536.0m } }, new[] { new object[] { 55, 12.25m, 9L } }); var context = CollectorTestContext.Make(new RecordingCollectorDeltaCalculator()); @@ -64,7 +94,46 @@ public async Task ReadAsync_CombinesTwoResultSets_IntoOneRow() var rows = await TempDbStatsCollector.Instance.ReadAsync(reader, context, CancellationToken.None); var row = Assert.Single(rows); - Assert.Equal(new TempDbStatsCollector.Row(1.5m, 2.5m, 3.5m, 7.5m, 10.0m, 9L, 55, 12.25m), row); + Assert.Equal(new TempDbStatsCollector.Row(1.5m, 2.5m, 3.5m, 7.5m, 10.0m, 9L, 55, 12.25m, 65536.0m), row); + } + + /// + /// #2515: a tempdb with no ROWS files visible makes the ceiling subquery return NULL, and NULL is not a + /// ceiling of zero. It has to land on 0 — the "not measured" state every consumer answers by dividing by + /// the allocation, exactly as it did before this column existed. A zero cap would divide the alert's + /// percentage by nothing at all. + /// + [Fact] + public async Task ReadAsync_NullCeiling_ReadsAsNotMeasured_NotAsAZeroCap() + { + using var reader = FakeCollectorDataReader.WithResultSets( + new[] { new object[] { 1.5m, 2.5m, 3.5m, 7.5m, 10.0m, System.DBNull.Value } }, + new[] { new object[] { 55, 12.25m, 9L } }); + + var context = CollectorTestContext.Make(new RecordingCollectorDeltaCalculator()); + + var rows = await TempDbStatsCollector.Instance.ReadAsync(reader, context, CancellationToken.None); + + Assert.Equal(0m, Assert.Single(rows).MaxSizeMb); + } + + /// + /// And the unlimited answer survives the read AS -1 rather than being flattened to 0. They take the same + /// denominator, but they are different facts — "this tempdb has no ceiling" versus "nobody looked" — and + /// the alert detail says which. + /// + [Fact] + public async Task ReadAsync_UnlimitedCeiling_StaysMinusOne() + { + using var reader = FakeCollectorDataReader.WithResultSets( + new[] { new object[] { 1.5m, 2.5m, 3.5m, 7.5m, 10.0m, -1m } }, + new[] { new object[] { 55, 12.25m, 9L } }); + + var context = CollectorTestContext.Make(new RecordingCollectorDeltaCalculator()); + + var rows = await TempDbStatsCollector.Instance.ReadAsync(reader, context, CancellationToken.None); + + Assert.Equal(-1m, Assert.Single(rows).MaxSizeMb); } [Fact] @@ -87,11 +156,11 @@ public void WritePayload_EmitsSchemaOrder_NoDeltas() { var deltas = new RecordingCollectorDeltaCalculator(); var writer = new RecordingCollectorRowWriter(); - var row = new TempDbStatsCollector.Row(1.5m, 2.5m, 3.5m, 7.5m, 10.0m, 9L, 55, 12.25m); + var row = new TempDbStatsCollector.Row(1.5m, 2.5m, 3.5m, 7.5m, 10.0m, 9L, 55, 12.25m, 65536.0m); TempDbStatsCollector.Instance.WritePayload(row, writer, CollectorTestContext.Make(deltas)); - Assert.Equal(new object?[] { 1.5m, 2.5m, 3.5m, 7.5m, 10.0m, 9L, 55, 12.25m }, writer.Values); + Assert.Equal(new object?[] { 1.5m, 2.5m, 3.5m, 7.5m, 10.0m, 9L, 55, 12.25m, 65536.0m }, writer.Values); Assert.Empty(deltas.Calls); } } diff --git a/Lite.Tests/TestDataSeeder.cs b/Lite.Tests/TestDataSeeder.cs index 7d7729891f..cca1af50a7 100644 --- a/Lite.Tests/TestDataSeeder.cs +++ b/Lite.Tests/TestDataSeeder.cs @@ -1367,10 +1367,13 @@ INSERT INTO file_io_stats } /// - /// Seeds tempdb_stats across 16 collection points. + /// Seeds tempdb_stats across 16 collection points. 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. /// internal async Task SeedTempDbAsync(double reservedMb, double unallocatedMb, - double userObjectMb = 0, double internalObjectMb = 0, double versionStoreMb = 0) + double userObjectMb = 0, double internalObjectMb = 0, double versionStoreMb = 0, + double? maxSizeMb = null) { if (userObjectMb == 0) userObjectMb = reservedMb * 0.6; if (internalObjectMb == 0) internalObjectMb = reservedMb * 0.3; @@ -1387,8 +1390,8 @@ internal async Task SeedTempDbAsync(double reservedMb, double unallocatedMb, INSERT INTO tempdb_stats (collection_id, collection_time, server_id, server_name, user_object_reserved_mb, internal_object_reserved_mb, - version_store_reserved_mb, total_reserved_mb, unallocated_mb) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)"; + version_store_reserved_mb, total_reserved_mb, unallocated_mb, max_size_mb) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)"; var t = TestPeriodStart.AddMinutes(i * 15); cmd.Parameters.Add(new DuckDBParameter { Value = _nextId-- }); @@ -1400,6 +1403,7 @@ INSERT INTO tempdb_stats cmd.Parameters.Add(new DuckDBParameter { Value = versionStoreMb }); cmd.Parameters.Add(new DuckDBParameter { Value = reservedMb }); cmd.Parameters.Add(new DuckDBParameter { Value = unallocatedMb }); + cmd.Parameters.Add(new DuckDBParameter { Value = maxSizeMb.HasValue ? maxSizeMb.Value : (object)DBNull.Value }); await cmd.ExecuteNonQueryAsync(); } diff --git a/Lite/Analysis/DuckDbFactCollector.Storage.cs b/Lite/Analysis/DuckDbFactCollector.Storage.cs index 1c85d58c63..7b01d09e37 100644 --- a/Lite/Analysis/DuckDbFactCollector.Storage.cs +++ b/Lite/Analysis/DuckDbFactCollector.Storage.cs @@ -153,7 +153,12 @@ private async Task CollectTempDbFactsAsync(AnalysisContext context, List f MAX(internal_object_reserved_mb) AS max_internal_object_mb, MAX(version_store_reserved_mb) AS max_version_store_mb, MIN(unallocated_mb) AS min_unallocated_mb, - AVG(total_reserved_mb) AS avg_total_reserved_mb + AVG(total_reserved_mb) AS avg_total_reserved_mb, + /* #2515: the growth ceiling, and MIN is the right aggregate for it twice over. -1 (at least one + unlimited data file) sorts below every real cap, so MIN finds the unlimited case anywhere in the + window; and where the cap was raised mid-window MIN keeps the more conservative of the two. NULL + rows — collected before the v56 migration — are skipped rather than dragging the answer down. */ + MIN(max_size_mb) AS min_max_size_mb FROM v_tempdb_stats WHERE server_id = $1 AND collection_time >= $2 @@ -172,11 +177,18 @@ FROM v_tempdb_stats var maxVersionStore = reader.IsDBNull(3) ? 0.0 : Convert.ToDouble(reader.GetValue(3)); var minUnallocated = reader.IsDBNull(4) ? 0.0 : Convert.ToDouble(reader.GetValue(4)); var avgReserved = reader.IsDBNull(5) ? 0.0 : Convert.ToDouble(reader.GetValue(5)); + var maxSizeMb = reader.IsDBNull(6) ? 0.0 : Convert.ToDouble(reader.GetValue(6)); if (maxReserved <= 0) return; - // TempDB usage as fraction of total space (reserved + unallocated) - var totalSpace = maxReserved + minUnallocated; + /* #2515: against the CEILING where tempdb has one, against the current allocation where it does + not (-1 unlimited, or 0 for a window collected before the ceiling was captured). reserved + + unallocated is the files AS ALLOCATED, so on its own this fraction measures distance to the + next autogrow — which reads as 96% full on an Azure SQL Database target holding one temp + table. TempDbSpaceInfo.CapacityMb is the alert's twin of this, and the two must agree or + analyze_server and the pager describe the same server differently. */ + var allocatedMb = maxReserved + minUnallocated; + var totalSpace = maxSizeMb > 0 ? Math.Max(maxSizeMb, allocatedMb) : allocatedMb; var usageFraction = totalSpace > 0 ? maxReserved / totalSpace : 0; facts.Add(new Fact @@ -193,6 +205,9 @@ FROM v_tempdb_stats ["max_internal_object_mb"] = maxInternalObj, ["max_version_store_mb"] = maxVersionStore, ["min_unallocated_mb"] = minUnallocated, + /* Added, never redefined — the existing keys are a consumer surface (FactAdvice reads + them by name). -1 unlimited, 0 not measured, positive = the ROWS ceiling in MB. */ + ["max_size_mb"] = maxSizeMb, ["usage_fraction"] = usageFraction } }); diff --git a/Lite/Database/DuckDbInitializer.cs b/Lite/Database/DuckDbInitializer.cs index 459de0855c..ec534e78da 100644 --- a/Lite/Database/DuckDbInitializer.cs +++ b/Lite/Database/DuckDbInitializer.cs @@ -227,7 +227,7 @@ public void Dispose() /// /// Current schema version. Increment this when schema changes require table rebuilds. /// - internal const int CurrentSchemaVersion = 55; + internal const int CurrentSchemaVersion = 56; private readonly string _archivePath; @@ -1404,6 +1404,34 @@ await ExecuteNonQueryAsync(connection, _logger?.LogWarning("Migration to v55 encountered an error (non-fatal): {Error}", ex.Message); } } + + if (fromVersion < 56) + { + /* v56 (#2515): tempdb's growth CEILING on tempdb_stats, twinning Darling's V81. Every other + column here comes from dm_db_file_space_usage, which reports the data files AS CURRENTLY + ALLOCATED — so the tempdb Space alert's percentage measured distance to the next AUTOGROW + rather than distance to the point where tempdb cannot grow at all. SUM(max_size) over the + ROWS files is the denominator that means the same thing on every engine. + + Fresh installs get the column from GetAllTableStatements(); this is for an existing database + and is idempotent. Nothing to backfill: a row collected before the upgrade genuinely does not + know the ceiling, and NULL says so — the read maps it to 0, which is the "no ceiling measured" + state where the denominator stays the allocation and the reported percentage does not move. + + The v_tempdb_stats view needs no work here: Lite rebuilds every v_ passthrough on start + (CreateArchiveViewsAsync, called after this), which is the difference from Darling, where the + view's SELECT * column list is frozen at CREATE and the rung has to refresh it. */ + _logger?.LogInformation("Running migration to v56: adding max_size_mb to tempdb_stats"); + try + { + await ExecuteNonQueryAsync(connection, + "ALTER TABLE tempdb_stats ADD COLUMN IF NOT EXISTS max_size_mb DECIMAL(18,2)"); + } + catch (Exception ex) + { + _logger?.LogWarning("Migration to v56 encountered an error (non-fatal): {Error}", ex.Message); + } + } } /// diff --git a/Lite/Services/LocalDataService.TempDb.cs b/Lite/Services/LocalDataService.TempDb.cs index 6881b73e88..2a83ecf0c3 100644 --- a/Lite/Services/LocalDataService.TempDb.cs +++ b/Lite/Services/LocalDataService.TempDb.cs @@ -83,7 +83,8 @@ FROM v_tempdb_stats internal_object_reserved_mb, version_store_reserved_mb, top_session_tempdb_mb, - top_session_id + top_session_id, + max_size_mb FROM v_tempdb_stats WHERE server_id = $1 ORDER BY collection_time DESC @@ -102,7 +103,11 @@ ORDER BY collection_time DESC InternalObjectReservedMb = reader.IsDBNull(3) ? 0 : ToDouble(reader.GetValue(3)), VersionStoreReservedMb = reader.IsDBNull(4) ? 0 : ToDouble(reader.GetValue(4)), TopConsumerMb = reader.IsDBNull(5) ? 0 : ToDouble(reader.GetValue(5)), - TopConsumerSessionId = reader.IsDBNull(6) ? 0 : reader.GetInt32(6) + TopConsumerSessionId = reader.IsDBNull(6) ? 0 : reader.GetInt32(6), + /* NULL on every row collected before the v56 migration, and 0 is what "no ceiling + measured" is spelled as — so history keeps reporting the percentage it always did + rather than dividing by a zero cap. Darling's twin reads the same column. */ + MaxSizeMb = reader.IsDBNull(7) ? 0 : ToDouble(reader.GetValue(7)) }; } diff --git a/PerformanceMonitor.Alerting/AlertContextBuilders.cs b/PerformanceMonitor.Alerting/AlertContextBuilders.cs index 6cc61cdd81..c8481cce49 100644 --- a/PerformanceMonitor.Alerting/AlertContextBuilders.cs +++ b/PerformanceMonitor.Alerting/AlertContextBuilders.cs @@ -692,6 +692,17 @@ public static string FormatPvsThreshold(double thresholdPercent, double floorGb) { ("Total Reserved", $"{tempDb.TotalReservedMb:F0} MB"), ("Unallocated", $"{tempDb.UnallocatedMb:F0} MB"), + /* #2515: the percentage above is against the CEILING where there is one and against the + current allocation where there is not, and Total Reserved + Unallocated only ever shows + the allocation — so without this the reader cannot tell which denominator produced the + number they are being paged about. Three states, reported as three different words: + a cap, no cap at all, and a snapshot taken before the ceiling was collected. */ + ("Max Size", tempDb.MaxSizeMb switch + { + > 0 => $"{tempDb.MaxSizeMb:F0} MB", + < 0 => "Unlimited", + _ => "Unknown" + }), ("User Objects", $"{tempDb.UserObjectReservedMb:F0} MB"), ("Internal Objects", $"{tempDb.InternalObjectReservedMb:F0} MB"), ("Version Store", $"{tempDb.VersionStoreReservedMb:F0} MB"), diff --git a/PerformanceMonitor.Alerting/TempDbSpaceInfo.cs b/PerformanceMonitor.Alerting/TempDbSpaceInfo.cs index ab76303a6d..ad629dd0ab 100644 --- a/PerformanceMonitor.Alerting/TempDbSpaceInfo.cs +++ b/PerformanceMonitor.Alerting/TempDbSpaceInfo.cs @@ -6,6 +6,8 @@ * Licensed under the MIT License. See LICENSE file in the project root for full license information. */ +using System; + namespace PerformanceMonitor.Alerting; /// @@ -23,7 +25,45 @@ public class TempDbSpaceInfo public int TopConsumerSessionId { get; set; } public double TopConsumerMb { get; set; } - public double UsedPercent => TotalReservedMb + UnallocatedMb > 0 - ? TotalReservedMb / (TotalReservedMb + UnallocatedMb) * 100 + /// + /// The tempdb ROWS files' growth CEILING in MB — SUM(max_size) over the data files, collected + /// beside the allocation by tempdb_stats (#2515). + /// + /// Three states, and only one of them is a ceiling. A POSITIVE value is a real cap. -1 + /// means at least one data file grows without limit, so there is nothing to measure against. 0 + /// means the snapshot predates the column — an upgraded store's history, or a Lite database that has + /// not re-collected yet. The last two are different facts but they take the same denominator, which is + /// why tests for a positive value rather than for -1. + /// + public double MaxSizeMb { get; set; } + + /// How much tempdb the files hold RIGHT NOW — reserved plus unallocated, both from + /// dm_db_file_space_usage, which reports the files as currently allocated. + public double AllocatedMb => TotalReservedMb + UnallocatedMb; + + /// + /// The denominator divides by: the ceiling where there is one, the current + /// allocation where there is not. + /// + /// Why the ceiling and not the allocation (#2515). Against the allocation the percentage + /// measures distance to the next AUTOGROW, which is worth paging nobody — it reads as real headroom on + /// a pre-sized on-prem box only because such a tempdb has already grown to its cap. On Azure SQL + /// Database the platform creates tempdb small and grows it toward the tier's limit, so one ordinary + /// #temp table takes the ratio from 3% to 96% with 60 MB at stake and a 16 GB per-file cap + /// untouched. Against the ceiling the number means the same thing on every engine: distance to the + /// point where tempdb cannot grow any further. + /// + /// Deliberately NOT a size floor, which was the other candidate. A floor suppresses the alert on + /// a genuinely full LARGE tempdb at the moment it starts growing, still fires at the autogrow boundary + /// once the floor is cleared, and picks a value that silently redefines "small" for every on-prem and + /// RDS target already relying on today's behaviour. + /// + /// The ceiling can never be below what is already allocated, so the larger of the two wins: a cap + /// edited down below the current file size would otherwise report over 100% used. + /// + public double CapacityMb => MaxSizeMb > 0 ? Math.Max(MaxSizeMb, AllocatedMb) : AllocatedMb; + + public double UsedPercent => CapacityMb > 0 + ? TotalReservedMb / CapacityMb * 100 : 0; } diff --git a/PerformanceMonitor.Analysis/FactScorer.cs b/PerformanceMonitor.Analysis/FactScorer.cs index e3555672d3..2995fd9ac7 100644 --- a/PerformanceMonitor.Analysis/FactScorer.cs +++ b/PerformanceMonitor.Analysis/FactScorer.cs @@ -245,7 +245,8 @@ private static double ScoreIoFact(Fact fact) } /// - /// Scores TempDB usage. Value is usage fraction (reserved / total space). + /// Scores TempDB usage. Value is usage fraction — reserved ÷ tempdb's growth CEILING where it has one, + /// and ÷ the current allocation where it does not (#2515). /// private static double ScoreTempDbFact(Fact fact) { @@ -273,6 +274,14 @@ private static double ScoreTempDbFact(Fact fact) /// caps at 1.0 here; the CRITICAL band is earned only via corroboration. tempdb_contention_analysis /// corroborates the > 1 GB bar (version_store_high_warning fires at 1 GB — install/47:2504, /// install/34:146). Absent metadata (older facts) scores 0, preserving prior behavior. + /// + /// #2515 moved the FRACTION arm's denominator to tempdb's growth ceiling and deliberately left these + /// bars alone. They are absolute MB and the ceiling is a denominator, so nothing about their reachability + /// moved: they are unreachable on an Azure SQL Database tempdb still at its initial ~62 MB for the reason + /// #2516 gave — a version store cannot exceed 1 GB inside a 62 MB tempdb — and they become reachable there + /// exactly when tempdb autogrows past a gigabyte, which is the same rule as everywhere else. The arm stays + /// self-consistent and needs no change; the fraction arm is what covers RCSI (on by default on Azure SQL + /// Database) until tempdb has grown that far. /// private static double ScoreTempDbVersionStore(Fact fact) { diff --git a/PerformanceMonitor.Collectors/TempDbStatsCollector.cs b/PerformanceMonitor.Collectors/TempDbStatsCollector.cs index c8c44720ad..77dda3eb25 100644 --- a/PerformanceMonitor.Collectors/TempDbStatsCollector.cs +++ b/PerformanceMonitor.Collectors/TempDbStatsCollector.cs @@ -20,6 +20,20 @@ namespace PerformanceMonitor.Collectors; /// RemoteCollectorService.TempDb.cs. Always yields exactly one row — zeros when the result /// sets are empty — matching the original collector's behavior. Not applicable to Azure SQL /// Database; see . +/// +/// The third thing it captures, and why the DMV alone was not enough (#2515). +/// dm_db_file_space_usage describes the tempdb data files AS CURRENTLY ALLOCATED, so +/// total_reserved ÷ (total_reserved + unallocated) is distance to the next +/// AUTOGROW, not distance to the point where tempdb can grow no further. That reads the same on a +/// pre-sized on-prem box only because such a tempdb has already reached its ceiling. Where the ceiling +/// is a real value it is discoverable — SUM(max_size) over tempdb's ROWS files — so +/// max_size_mb ships beside the allocation and the consumers divide by the ceiling instead. +/// +/// Run verbatim against SQL Server 2022 (8 tempdb data files, 1 log file): unlimited files report +/// -1; capping the eight data files at 100 MB and the LOG at 2,048 MB reports 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, which is the correct reading — tempdb as a whole can then grow +/// without limit. /// public sealed class TempDbStatsCollector : CollectorDefinitionBase { @@ -37,7 +51,11 @@ public readonly record struct Row( decimal UnallocatedMb, long TotalSessions, int TopSessionId, - decimal TopSessionMb); + decimal TopSessionMb, + /* The ROWS-file growth ceiling in MB. -1 = at least one data file is unlimited, so there is no + ceiling; 0 = the query returned NULL (no ROWS files visible). Appended LAST because both + writers are positional and the payload column had to append too. */ + decimal MaxSizeMb); public override string Name => "tempdb_stats"; @@ -81,7 +99,25 @@ public readonly record struct Row( internal_object_reserved_mb = CONVERT(decimal(18,2), SUM(dsu.internal_object_reserved_page_count) * 8 / 1024.0), version_store_reserved_mb = CONVERT(decimal(18,2), SUM(dsu.version_store_reserved_page_count) * 8 / 1024.0), total_reserved_mb = CONVERT(decimal(18,2), SUM(dsu.user_object_reserved_page_count + dsu.internal_object_reserved_page_count + dsu.version_store_reserved_page_count) * 8 / 1024.0), - unallocated_mb = CONVERT(decimal(18,2), SUM(dsu.unallocated_extent_page_count) * 8 / 1024.0) + unallocated_mb = CONVERT(decimal(18,2), SUM(dsu.unallocated_extent_page_count) * 8 / 1024.0), + /* #2515: the ROWS-file growth ceiling, which is what the space alert's percentage divides by. + Restricted to type = 0 deliberately — dm_db_file_space_usage above reports DATA allocation, so + folding the log's cap into the same denominator would understate usage on every server. + MIN() decides the unlimited case rather than any(): one unlimited data file means tempdb as a + whole can grow without limit, and -1 sorts below every real cap so MIN finds it. max_size is int + and a wide tempdb can carry several files at the 16 TB page maximum, so it is widened to bigint + BEFORE the SUM rather than after it. */ + max_size_mb = + ( + SELECT + CASE + WHEN MIN(df.max_size) = -1 + THEN CONVERT(decimal(18,2), -1) + ELSE CONVERT(decimal(18,2), SUM(CONVERT(bigint, df.max_size)) * 8 / 1024.0) + END + FROM tempdb.sys.database_files AS df + WHERE df.type = 0 /*ROWS*/ + ) FROM tempdb.sys.dm_db_file_space_usage AS dsu OPTION(RECOMPILE); @@ -103,6 +139,9 @@ ORDER BY (ssu.user_objects_alloc_page_count + ssu.internal_objects_alloc_page_co new CollectorColumn("total_sessions_using_tempdb", CollectorColumnType.BigInt), new CollectorColumn("top_session_id", CollectorColumnType.Integer), new CollectorColumn("top_session_tempdb_mb", CollectorColumnType.Decimal, 18, 2), + /* Appended, never inserted: both stores generate their DDL from this list in order and both row + writers are positional, so a column added anywhere else would silently re-map history. */ + new CollectorColumn("max_size_mb", CollectorColumnType.Decimal, 18, 2), }; public override async ValueTask> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) @@ -111,6 +150,7 @@ public override async ValueTask> ReadAsync(DbDataReader reader, Collec int topSessionId = 0; long totalSessions = 0; decimal topSessionMb = 0; + decimal maxSizeMb = 0; if (await reader.ReadAsync(cancellationToken)) { @@ -119,6 +159,10 @@ public override async ValueTask> ReadAsync(DbDataReader reader, Collec versionStoreMb = reader.IsDBNull(2) ? 0m : reader.GetDecimal(2); totalReservedMb = reader.IsDBNull(3) ? 0m : reader.GetDecimal(3); unallocatedMb = reader.IsDBNull(4) ? 0m : reader.GetDecimal(4); + /* NULL means tempdb showed no ROWS files, which is not a measurement; 0 carries that through + to the consumers, where it reads as "no ceiling measured" and the denominator stays the + allocation — exactly what they did before this column existed. */ + maxSizeMb = reader.IsDBNull(5) ? 0m : reader.GetDecimal(5); } if (await reader.NextResultAsync(cancellationToken) && await reader.ReadAsync(cancellationToken)) @@ -130,7 +174,7 @@ public override async ValueTask> ReadAsync(DbDataReader reader, Collec return new List { - new(userObjMb, internalObjMb, versionStoreMb, totalReservedMb, unallocatedMb, totalSessions, topSessionId, topSessionMb), + new(userObjMb, internalObjMb, versionStoreMb, totalReservedMb, unallocatedMb, totalSessions, topSessionId, topSessionMb, maxSizeMb), }; } @@ -144,6 +188,7 @@ public override void WritePayload(Row row, ICollectorRowWriter writer, Collector .Value(row.UnallocatedMb) /* unallocated_mb DECIMAL */ .Value(row.TotalSessions) /* total_sessions_using_tempdb BIGINT */ .Value(row.TopSessionId) /* top_session_id INTEGER */ - .Value(row.TopSessionMb); /* top_session_tempdb_mb DECIMAL */ + .Value(row.TopSessionMb) /* top_session_tempdb_mb DECIMAL */ + .Value(row.MaxSizeMb); /* max_size_mb DECIMAL */ } } diff --git a/README.md b/README.md index b5174fcd24..9c956f56ea 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ That was not always true: through 3.5.0 the ZIP was a portable build with no run | file_io_stats | 1 min | `sys.dm_io_virtual_file_stats` (deltas) | | memory_stats | 1 min | `sys.dm_os_sys_memory` + memory counters | | memory_grant_stats | 1 min | `sys.dm_exec_query_memory_grants` | -| tempdb_stats | 1 min | `sys.dm_db_file_space_usage` | +| tempdb_stats | 1 min | `sys.dm_db_file_space_usage` + `tempdb.sys.database_files` (the ROWS files' growth ceiling) | | perfmon_stats | 1 min | `sys.dm_os_performance_counters` (deltas) | | deadlocks | 5 min | dedicated `PerformanceMonitor_Deadlock` XE session (`xml_deadlock_report`; `database_xml_deadlock_report` on Azure SQL DB) | | dmv_blocking_snapshot | 1 min | `sys.dm_os_waiting_tasks` + `sys.dm_exec_*` (always-on blocking fallback when the blocked-process-report XE is unavailable) | @@ -254,7 +254,7 @@ Every edition includes a real-time alert engine that monitors for performance is | **Deadlocks** | 1 | Fires when new deadlocks are detected since the last check | | **Poison waits** | 100 ms avg | Fires when any poison wait type exceeds the average-ms-per-wait threshold | | **Long-running queries** | 5 minutes | Fires when any query exceeds the elapsed-time threshold | -| **TempDB space** | 80% | Fires when TempDB usage exceeds the percentage threshold | +| **TempDB space** | 80% | Fires when TempDB usage exceeds the percentage threshold. Measured against tempdb's **growth ceiling** (`SUM(max_size)` over the ROWS files) where there is one, and against the current allocation where the files grow without limit — so the percentage means "distance to the point where tempdb cannot grow further" on every engine | | **Long-running agent jobs** | 3× average | Fires when a job's current duration exceeds a multiple of its historical average | | **High CPU** | 80% | Fires when total CPU (SQL + other) exceeds the threshold | | **Volume free space** | 10% or 5 GB free | Fires when a monitored volume's free space drops below the percentage or absolute threshold (either check can be disabled). Never fires on Azure SQL Database. | diff --git a/deprecated/Dashboard.Tests/AlertContextBuildersTests.cs b/deprecated/Dashboard.Tests/AlertContextBuildersTests.cs index d178a51733..17ddb8705c 100644 --- a/deprecated/Dashboard.Tests/AlertContextBuildersTests.cs +++ b/deprecated/Dashboard.Tests/AlertContextBuildersTests.cs @@ -256,6 +256,9 @@ public void BuildTempDbSpaceContext_RendersAllFields_WithTopConsumer() { ("Total Reserved", $"{800d:F0} MB"), ("Unallocated", $"{200d:F0} MB"), + /* #2515: no MaxSizeMb on this fixture, so the ceiling was never measured and the + percentage above is still against the allocation. */ + ("Max Size", "Unknown"), ("User Objects", $"{500d:F0} MB"), ("Internal Objects", $"{250d:F0} MB"), ("Version Store", $"{50d:F0} MB"), @@ -274,7 +277,7 @@ public void BuildTempDbSpaceContext_NoTopConsumer_RendersNone() TopConsumerSessionId = 0 }); - Assert.Equal(("Top Consumer", "None"), context!.Details[0].Fields[5]); + Assert.Equal(("Top Consumer", "None"), context!.Details[0].Fields[6]); } /* ---------------- anomalous jobs ---------------- */