From 7709a40877eda42fcb6141f90ffad631493a0e63 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:24:01 +0100 Subject: [PATCH 1/4] Wire the engine-capability question into every read that can answer it (#2532) #2511 wired the capability helper into the reads whose collectors are gated off on Azure SQL Database -- twelve collectors' worth, and the right scope for the EDITION axis, because a collector that runs on every SQL Server had nothing to say. The engine-KIND axis changes that arithmetic completely. On a PostgreSQL target EVERY SQL Server read is a permanent gap, so get_database_config, get_wait_stats, get_top_queries_by_cpu and the rest still answered unavailable - No database configuration data available. The config collector may not have run yet. about a collector that does not run on that engine at all. True, and pointing at the wrong cause. A human gets PostgreSQL tabs once #2530's UI waves land; an agent over MCP has no tabs and asks by name. 72 Darling reads and 71 Lite reads now ask, up from 17 each. Every call sits AFTER the read came back empty, so a server whose registry row disagrees with its collected rows still gets its data. Deliberately NOT wired, and why: - the rollups that read a dozen collectors and can name none of them: analyze_server, get_analysis_facts, compare_analysis, get_daily_summary, get_daily_summary_range, get_server_summary. The honest answer there is a rollup-level one, which this does not build. - the reads served by collection_log, which a PostgreSQL target genuinely populates: get_collection_health, get_collection_log. Their existing messages are about collection and are correct. - everything that resolves no server: list_servers, get_fleet_overview, get_store_metrics, the alert and custom-view tools, analyze_plan_xml. - the two branches that already name the right cause: get_wait_trend and get_perfmon_trend's "not a wait type / counter this server collects", which are only reached when the collector HAS run. CapturePathByCollector gains 28 noun phrases so the message names a surface rather than "the data this read is served from". Two pins changed with it, both derived rather than listed: EveryWiredRead_... now asserts the capability branch is REACHABLE on some engine the store can record, because demanding an Azure SQL Database gap would refuse exactly the wiring this issue is about; and the capture-path table must be gated by its own gate somewhere OR named by a wired read, which is what keeps it from becoming prose nothing reaches. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 + .../EngineCapabilityMissTests.cs | 110 +++++++++++++++--- .../Mcp/DarlingMcpBlockingTools.cs | 30 ++++- .../Mcp/DarlingMcpConfigHistoryTools.cs | 17 +-- .../Mcp/DarlingMcpConfigTools.cs | 7 +- .../Mcp/DarlingMcpDataTools.cs | 80 +++++++++---- .../Mcp/DarlingMcpLatchSpinlockTools.cs | 6 +- .../Mcp/DarlingMcpLongQueryTools.cs | 3 +- .../Mcp/DarlingMcpMemoryGrantTools.cs | 6 +- .../Mcp/DarlingMcpObjectStatsTools.cs | 12 +- .../Mcp/DarlingMcpPlanCacheSchedulerTools.cs | 3 +- .../Mcp/DarlingMcpPlanCorrectionTools.cs | 9 +- .../Mcp/DarlingMcpPlanTools.cs | 24 ++-- .../Mcp/DarlingMcpPvsTools.cs | 7 +- .../Mcp/DarlingMcpQueryHeatmapTools.cs | 7 +- .../DarlingMcpQueryStoreRegressionTools.cs | 7 +- .../Mcp/DarlingMcpSessionTools.cs | 9 +- .../Mcp/DarlingMcpTrendTools.cs | 52 +++++++-- Lite/Mcp/McpBlockingTools.cs | 30 ++++- Lite/Mcp/McpConfigHistoryTools.cs | 5 +- Lite/Mcp/McpConfigTools.cs | 21 ++-- Lite/Mcp/McpCpuTools.cs | 3 +- Lite/Mcp/McpHealthTools.cs | 12 ++ Lite/Mcp/McpIoTools.cs | 9 +- Lite/Mcp/McpLatchSpinlockTools.cs | 6 +- Lite/Mcp/McpLongQueryTools.cs | 3 +- Lite/Mcp/McpMemoryTools.cs | 22 +++- Lite/Mcp/McpObjectStatsTools.cs | 9 +- Lite/Mcp/McpPerfmonTools.cs | 12 +- Lite/Mcp/McpPlanCacheSchedulerTools.cs | 3 +- Lite/Mcp/McpPlanCorrectionTools.cs | 9 +- Lite/Mcp/McpPlanTools.cs | 29 +++-- Lite/Mcp/McpPvsTools.cs | 7 +- Lite/Mcp/McpQueryTools.cs | 48 ++++++-- Lite/Mcp/McpServerInfoTools.cs | 6 +- Lite/Mcp/McpSessionTools.cs | 6 +- Lite/Mcp/McpTempDbTools.cs | 3 +- Lite/Mcp/McpWaitTools.cs | 21 +++- .../CollectorEngineCapability.cs | 34 ++++++ 39 files changed, 516 insertions(+), 173 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18d4efad4e..e2593e8536 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,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 +- **On a PostgreSQL target, 55 more reads stopped telling you to go and check a collector that will never run** ([#2532]) - [#2511] wired the engine-capability helper into the reads whose collectors are gated off on Azure SQL Database, which was the right scope for the edition axis: a collector that runs on every SQL Server had nothing to say. The engine-KIND axis [#2530] added changes that arithmetic completely - on a PostgreSQL target **every** SQL Server read is a permanent gap - so `get_database_config`, `get_wait_stats`, `get_top_queries_by_cpu` and the rest still answered "unavailable - the config collector may not have run yet" about a collector that does not run on that engine at all. True, and pointing at the wrong cause. A human will get PostgreSQL tabs when the [#2530] UI waves land; an **agent** over MCP has no tabs and asks by name, which is half the product's surface. **72 Darling reads and 71 Lite reads** now ask on the miss path (up from 17 on each), covering every server-scoped read served by an identifiable collector. Deliberately NOT wired, with reasons: the rollups that read a dozen collectors and can name none of them (`analyze_server`, `get_analysis_facts`, `compare_analysis`, `get_daily_summary`, `get_daily_summary_range`, `get_server_summary`), the reads served by `collection_log`, which a PostgreSQL target genuinely populates (`get_collection_health`, `get_collection_log`), and everything that resolves no server at all (`list_servers`, `get_fleet_overview`, `get_store_metrics`, the alert and custom-view tools). `CapturePathByCollector` gains 28 noun phrases so the message names a surface - "the sys.dm_os_wait_stats cumulative totals" - rather than "the data this read is served from". Every call sits AFTER the read came back empty, so a server whose registry row disagrees with its collected rows still gets its data. - **The Overview lanes skewed apart when one lane's numbers got long** ([#2533]) - the five lanes are five SEPARATE plots, and `SyncXAxes` only ever gave them the same X-axis LIMITS. Nothing gave them the same pixel geometry: each plot sizes its own left gutter from its own Y tick labels, so on a server whose wait time runs into six figures the Wait ms/sec lane began its data area ~27 px right of the I/O latency lane reading `0.4`, and the lanes stopped lining up on the time axis. Every value stayed correct throughout - which is why the tooltips still read true, and what identified this as layout rather than data. **Not a regression**, and the reporter's "I think this worked before" is right for his machine: `MinimumSize`, `Layout.Fixed` and `PixelPadding` have never appeared in any SKU on any branch, so nothing was removed - alignment was always incidental, holding only while the five lanes happened to need labels of similar width, and five- or six-digit wait ms/sec is what breaks the coincidence. The same build therefore looks right on one server and skewed on another. The lanes now share ONE gutter, sized to the widest lane's own natural gutter and re-derived on every refresh - a frozen constant would have to cover six-digit wait times and would then steal that width from every server whose lanes read in single digits, and it ratchets back down when the big values go away. The mechanism is `Axes.Left.MinimumSize` rather than a fixed layout, because a fixed layout pins all four sides and the bottom lane legitimately needs more bottom room than the other four - it alone draws the time labels. This is the second alignment defect in these lanes and the first one shipped with nothing that could have caught it, so the fix comes with headless render tests: ScottPlot draws without a window, so CI now builds lanes with deliberately mismatched magnitudes, renders them, and compares their data rectangles - shown failing at 20.02 px on the unfixed control before it passed. **Darling viewer only.** Lite's Overview control is the same five separate plots and carries the same latent defect, but nobody has reported hitting it there; the aligner lives in the shared `PerformanceMonitor.Ui`, so picking Lite up later is one call site. Reported by @ghauan. - **A SQL Server read aimed at a PostgreSQL target says so, instead of telling you to check collection** ([#2530]) - [#2511] taught the miss vocabulary the engine-EDITION axis, so a read served by a collector gated off on Azure SQL Database answers `not_collected` naming the engine. A PostgreSQL target got `unavailable` - "never collected, check that collection is running" - which is true and points at the wrong cause: those collectors do not apply to that engine at all. That was the honest answer while nothing in the store distinguished a PostgreSQL target from an unconnected one; now something does. The answer is derived the same way the edition axis is, from the collectors' own dispatch gate rather than from a list, so flipping a definition's target engine moves it. **Unknown targets keep making no claim** - a pre-v82 row, a server that has not connected since the rung landed, and a token a newer build wrote all stay silent, because the distinction added is "known to be PostgreSQL" and never "not known to be SQL Server". It runs both ways: a `get_pg_*` read aimed at a known SQL Server target now says the same thing in reverse. Limit worth knowing: only the reads [#2511] wired to the capability helper can answer on either axis, so a read like `get_database_config` - whose collector runs on every SQL Server, so the edition axis gave it nothing to say - still reports `unavailable` on a PostgreSQL target. - **A fresh store never seeded, so the control plane silently overrode `darling.json`** ([#2524]) - the seed INSERT named sixteen columns and supplied fifteen: a bound parameter was never written into the VALUES list, so every value after it shifted one column left and PostgreSQL refused the whole statement with `42601`. The failure was caught and logged and the service carried on with the file config, which is why it shipped - the damage came next, because the control plane is authoritative after first contact and was answering from a row that had never been written, where every toggle is false. So `web.enabled` and `mcp.enabled` were **true** in `darling.json`, **false** in the store, the store won as designed, and neither endpoint ever listened. That was the entire first-run experience of a container deployment, and the only clue was one line saying the config would seed on a later start - it would not, because the statement was malformed rather than racing. Found by running the shipped nightly image against a live target rather than by reading it, since no test drove the seed against an empty store. @@ -2877,6 +2878,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#2520]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2520 [#2525]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2525 [#2530]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2530 +[#2532]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2532 [#2533]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2533 [#2480]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2480 [#2489]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2489 diff --git a/Darling/Darling.Tests/EngineCapabilityMissTests.cs b/Darling/Darling.Tests/EngineCapabilityMissTests.cs index e81c757680..45e5b8aa22 100644 --- a/Darling/Darling.Tests/EngineCapabilityMissTests.cs +++ b/Darling/Darling.Tests/EngineCapabilityMissTests.cs @@ -206,20 +206,54 @@ public void TheConnectorAndTheCapabilityHelper_ShareOneEditionTable() /// /// The prose table cannot outlive the gates it describes: every key must be a real collector, and every - /// key must be a collector that IS excluded somewhere — an entry for a collector nothing gates is prose - /// no message can ever reach, which is how a stale explanation survives unnoticed. + /// key must be a collector its OWN AppliesTo gate shuts out somewhere — an entry for a collector + /// nothing gates is prose no message can ever reach, which is how a stale explanation survives unnoticed. + /// + /// "Its own gate", not "any gap at all" (#2532). Since the kind axis landed, EVERY collector + /// is a permanent gap on some engine the store can record — a SQL Server one on both PostgreSQL tokens, + /// a PostgreSQL one on sqlserver — purely because of its DIALECT. So a check phrased as "is this a + /// gap somewhere" would now pass for every name anyone typed, which is the guard-that-stopped-guarding + /// shape. The dialect half is excluded deliberately, leaving exactly the original claim: the entry + /// describes a collector whose own precondition excludes it on some target it could otherwise be sent + /// at. That covers the twelve Azure SQL Database entries unchanged and the two Aurora-only PostgreSQL + /// ones on the same terms. /// [Fact] public void EveryCapturePathEntry_NamesARealCollectorThatIsActuallyGatedSomewhere() { - var catalog = CollectorCatalog.All.Select(c => c.Name).ToHashSet(StringComparer.Ordinal); - var azureGaps = GapsOn(AzureSqlDb).ToHashSet(StringComparer.Ordinal); + var catalog = CollectorCatalog.All.ToDictionary(c => c.Name, StringComparer.Ordinal); + var wired = EngineCapabilityReadWiringTests.AllWiredCollectors(); + + Assert.NotEmpty(wired); foreach (var name in CollectorEngineCapability.CapturePathByCollector.Keys) { - Assert.True(catalog.Contains(name), $"CapturePathByCollector names '{name}', which is not a CollectorCatalog collector"); - Assert.True(azureGaps.Contains(name), $"CapturePathByCollector still describes '{name}' as a gap, but its AppliesTo gate now lets it run on Azure SQL Database — drop the entry or re-check the gate"); + Assert.True(catalog.ContainsKey(name), $"CapturePathByCollector names '{name}', which is not a CollectorCatalog collector"); + Assert.True( + ExcludedByItsOwnGateSomewhere(catalog[name]) || wired.Contains(name), + $"CapturePathByCollector describes '{name}', but its AppliesTo gate now lets it run on every target " + + "of its own engine AND no shipped read asks the capability question about it — so the prose is " + + "unreachable. Drop the entry, re-check the gate, or wire the read that wanted it"); + } + } + + /// + /// True when a definition's own AppliesTo — not the dispatch gate's engine half — excludes it from + /// every target of some engine the store can record. Derived from the two axes rather than listed, and + /// deliberately asked only about engines this definition's DIALECT matches, so a foreign-dialect gap + /// (which every collector has, on every other engine) cannot answer it. + /// + private static bool ExcludedByItsOwnGateSomewhere(ICollectorSchemaInfo definition) + { + if (definition.TargetEngine == CollectorTargetEngine.SqlServer + && !CollectorEngineCapability.IsCollectedOnEngineEdition(definition, AzureSqlDb)) + { + return true; } + + return MonitoredEngineKind.All + .Where(kind => MonitoredEngineKind.EngineOf(kind) == definition.TargetEngine) + .Any(kind => !CollectorEngineCapability.IsCollectedOnEngineKind(definition, kind)); } /// @@ -284,8 +318,28 @@ public sealed class EngineCapabilityReadWiringTests private static readonly Regex CollectorConst = new( @"private const string (\w+) = ""([a-z_0-9]+)"";", RegexOptions.Compiled); + /// + /// Every collector name a shipped read asks the capability question about, across both SKUs. Exposed so + /// + /// can hold the prose table to the reads, rather than the reads being the only side that is checked. + /// + internal static SortedSet AllWiredCollectors() + { + var wired = new SortedSet(StringComparer.Ordinal); + + foreach (var directory in new[] { DarlingMcp, LiteMcp }) + { + foreach (var collectors in WiredReads(directory).Values) + { + wired.UnionWith(collectors); + } + } + + return wired; + } + /// tool name → the collector names its miss path asks the capability question about. - private static SortedDictionary> WiredReads(string mcpDirectory) + internal static SortedDictionary> WiredReads(string mcpDirectory) { var wired = new SortedDictionary>(StringComparer.Ordinal); @@ -335,7 +389,7 @@ private static HashSet ToolsIn(string mcpDirectory) [Theory] [InlineData(DarlingMcp)] [InlineData(LiteMcp)] - public void EveryWiredRead_NamesARealCollectorThatIsGatedOffOnAzureSqlDb(string mcpDirectory) + public void EveryWiredRead_NamesARealCollectorWhoseCapabilityBranchCanFire(string mcpDirectory) { var wired = WiredReads(mcpDirectory); var catalog = CollectorCatalog.All.Select(c => c.Name).ToHashSet(StringComparer.Ordinal); @@ -357,15 +411,45 @@ public void EveryWiredRead_NamesARealCollectorThatIsGatedOffOnAzureSqlDb(string $"{tool} names '{collector}', which has no CapturePathByCollector entry — its message would fall back " + "to the generic phrasing; add the noun phrase next to the others"); - Assert.False( - CollectorEngineCapability.IsCollectedOnEngineEdition(collector, CollectorEngineCapability.AzureSqlDatabaseEngineEdition), - $"{tool} asks about '{collector}', but that collector's AppliesTo gate now lets it run on Azure SQL " + - "Database, so the capability branch is dead code — either the gate changed and this read should stop " + - "asking, or the gate changed by accident"); + Assert.True( + ReachableOnSomeEngine(collector), + $"{tool} asks about '{collector}', but no engine the store can record produces a message for it, so " + + "the capability branch is dead code — either a gate opened up and this read should stop asking, or " + + "a gate changed by accident"); } } } + /// + /// True when SOME (engine kind, engine edition) pair the store can actually hold makes + /// speak about this collector — i.e. the + /// branch the read just added is reachable rather than dead. + /// + /// Weaker than the Azure-specific check it replaced, and deliberately so (#2532). That check + /// said "this collector is gated off on Azure SQL Database", which was the right claim while the EDITION + /// axis was the only one: a read wired to a collector that runs on every SQL Server had nothing to say + /// and the assertion caught it. The KIND axis changes that — every SQL Server collector is a permanent + /// gap on a PostgreSQL target and every PostgreSQL collector on a SQL Server one — so a read wired to + /// wait_stats now has something true and useful to say, and an assertion that still demanded an + /// Azure gap would refuse the wiring this issue is about. What is left is the property that actually + /// matters at a call site: the branch can fire. The sharper per-collector claims live where they belong, + /// in the derivation tests, over the gates rather than over the reads. + /// + private static bool ReachableOnSomeEngine(string collectorName) + { + var kinds = new string?[] { null }.Concat(MonitoredEngineKind.All); + var editions = new[] + { + CollectorEngineCapability.UnknownEngineEdition, + 3, /* Enterprise — a box edition, where nothing is an edition gap today */ + CollectorEngineCapability.AzureSqlDatabaseEngineEdition, + CollectorEngineCapability.AzureManagedInstanceEngineEdition, + }; + + return kinds.Any(kind => editions.Any(edition => + CollectorEngineCapability.NotCollectedMessage("probe", edition, kind, collectorName) is not null)); + } + /// /// The two SKUs wire the SAME reads to the SAME collectors. Compared only across tools BOTH surfaces /// expose, so a tool that exists on one SKU only (Darling's get_ag_health) is not reported as drift — diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs index 17979b0d3f..abdbe7ff18 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs @@ -67,7 +67,8 @@ public static async Task GetBlocking( var rows = await DarlingBlockingReader.GetRecentBlockedProcessReportsAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now); if (rows.Count == 0) - return McpHelpers.Status("empty", "No blocking events found in the specified time range."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "blocked_process_report") + ?? McpHelpers.Status("empty", "No blocking events found in the specified time range."); /* #2159: fingerprint the WHOLE window, then filter, then cap. Capping first would let `limit` discard the very incident the key names — the caller asked for one specific incident, not for @@ -170,7 +171,8 @@ public static async Task GetDeadlocks( var rows = await DarlingBlockingReader.GetRecentDeadlocksAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now); if (rows.Count == 0) - return McpHelpers.Status("empty", "No deadlocks found in the specified time range."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "deadlocks") + ?? McpHelpers.Status("empty", "No deadlocks found in the specified time range."); /* #2159: see get_blocking — fingerprint the window, filter, then cap. */ var examined = rows.Count; @@ -243,7 +245,8 @@ public static async Task GetDeadlockDetail( consume one of the `limit` slots the caller wanted spent on real graphs. */ var candidates = rows.Where(r => r.HasDeadlockXml).ToList(); if (candidates.Count == 0) - return McpHelpers.Status("empty", "No deadlock XML available in the specified time range."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "deadlocks") + ?? McpHelpers.Status("empty", "No deadlock XML available in the specified time range."); var examined = candidates.Count; var keys = DarlingIncidentFingerprint.DeadlockKeys( @@ -309,7 +312,8 @@ public static async Task GetBlockedProcessXml( postgres, resolved.ServerId, now.AddHours(-hours_back), now); var withXml = rows.Where(r => r.HasReportXml).Take(limit).ToList(); if (withXml.Count == 0) - return McpHelpers.Status("empty", "No blocked process report XML available in the specified time range."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "blocked_process_report") + ?? McpHelpers.Status("empty", "No blocked process report XML available in the specified time range."); var result = withXml.Select(r => new { @@ -366,6 +370,12 @@ means nothing at all is known about the window. The stored tables cannot tell th */ var captures = await DarlingBlockingTrendReader.GetBlockingCaptureCountsAsync( postgres, resolved.ServerId, start, now); + var gated = await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "blocked_process_report"); + if (gated != null) + { + return gated; + } + return await EmptyTrend( "blocking", resolved.ServerName, hours_back, captures, () => DarlingBlockingTrendReader.HasAnyBlockingCollectorRunAsync(postgres, resolved.ServerId)); @@ -409,6 +419,12 @@ public static async Task GetDeadlockTrend( /* Same two facts as the blocking trend above, same denominator, same reason. */ var captures = await DarlingBlockingTrendReader.GetDeadlockCaptureCountsAsync( postgres, resolved.ServerId, start, now); + var gated = await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "deadlocks"); + if (gated != null) + { + return gated; + } + return await EmptyTrend( /* SINGULAR: the subject lands in "No {subject} was recorded", and "no deadlocks was recorded" is not a sentence. It also reads correctly in the other two, @@ -466,6 +482,12 @@ The LCK% filter is deliberately NOT applied to the probe. A server that has coll wait stats for months and never taken a lock wait is the all-clear this branch is for, and filtering the probe the same way would call that server uncollected. */ + var gated = await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "wait_stats"); + if (gated != null) + { + return gated; + } + return await DarlingDataReader.HasAnyWaitStatAsync(postgres, resolved.ServerId) ? McpHelpers.Status( "empty", diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigHistoryTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigHistoryTools.cs index ceea70a691..f488370654 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigHistoryTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigHistoryTools.cs @@ -112,7 +112,8 @@ public static async Task GetDatabaseConfigChanges( var snapshots = await DarlingConfigHistoryReader.GetDatabaseConfigSnapshotsAsync(postgres, resolved.ServerId); var changes = ConfigChangeDiff.DiffDatabaseConfigChanges(snapshots, windowStart, UpperEdge(as_of, windowEndNaive)); if (changes.Count == 0) - return NoChanges(resolved.ServerName, hours_back, DistinctCaptures(snapshots.Select(s => s.CaptureTime))); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "database_config") + ?? NoChanges(resolved.ServerName, hours_back, DistinctCaptures(snapshots.Select(s => s.CaptureTime))); var result = changes.Select(c => new { @@ -199,9 +200,10 @@ public static async Task GetDatabaseScopedConfig( { var rows = await DarlingConfigHistoryReader.GetLatestDatabaseScopedConfigAsync(postgres, resolved.ServerId); if (rows.Count == 0) - return McpHelpers.Status( - "unavailable", - "No database-scoped configuration data available. The config collector may not have run yet."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "database_scoped_config") + ?? McpHelpers.Status( + "unavailable", + "No database-scoped configuration data available. The config collector may not have run yet."); IEnumerable filtered = rows; if (!string.IsNullOrEmpty(database_name)) @@ -246,9 +248,10 @@ public static async Task GetQueryStoreHealth( { var rows = await DarlingConfigHistoryReader.GetLatestQueryStoreHealthAsync(postgres, resolved.ServerId); if (rows.Count == 0) - return McpHelpers.Status( - "unavailable", - "No Query Store health data available. The query_store_health collector runs hourly (SQL Server 2016+); a server with no rows either predates Query Store or has not completed a cycle yet."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "query_store_health") + ?? McpHelpers.Status( + "unavailable", + "No Query Store health data available. The query_store_health collector runs hourly (SQL Server 2016+); a server with no rows either predates Query Store or has not completed a cycle yet."); IEnumerable filtered = rows; if (!string.IsNullOrEmpty(database_name)) diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigTools.cs index 513ff4dca4..5c98d5547b 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigTools.cs @@ -83,9 +83,10 @@ public static async Task GetDatabaseConfig( { var rows = await DarlingCurrentConfigReader.GetLatestDatabaseConfigAsync(postgres, resolved.ServerId); if (rows.Count == 0) - return McpHelpers.Status( - "unavailable", - "No database configuration data available. The config collector may not have run yet."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "database_config") + ?? McpHelpers.Status( + "unavailable", + "No database configuration data available. The config collector may not have run yet."); IEnumerable filtered = rows; if (!string.IsNullOrEmpty(database_name)) diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs index b81098a95a..8f3a54a47e 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs @@ -65,7 +65,8 @@ public static async Task GetCpuUtilization( { var rows = await DarlingDataReader.GetCpuUtilizationAsync(postgres, resolved.ServerId, windowEnd.AddHours(-hours_back), windowEnd); if (rows.Count == 0) - return McpHelpers.Status("unavailable", "No CPU utilization data available."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "cpu_utilization") + ?? McpHelpers.Status("unavailable", "No CPU utilization data available."); /* Downsample to 1-minute buckets to avoid overwhelming LLM context (Lite's projection). */ var bucketed = rows @@ -117,7 +118,8 @@ public static async Task GetWaitStats( var now = windowEnd; var rows = await DarlingDataReader.GetWaitStatsAsync(postgres, resolved.ServerId, now.AddHours(-hours_back), now); if (rows.Count == 0) - return McpHelpers.Status("unavailable", "No wait stats data available for the specified time range."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "wait_stats") + ?? McpHelpers.Status("unavailable", "No wait stats data available for the specified time range."); var result = rows.Take(limit).Select(r => { @@ -173,6 +175,12 @@ An empty list said nothing about which nothing this is. A server that collected wants somebody to look at collection, and widening will never fill it. Probed only here, against the SAME source the read walks. */ + var gated = await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "wait_stats"); + if (gated != null) + { + return gated; + } + return await DarlingDataReader.HasAnyWaitStatAsync(postgres, resolved.ServerId) ? McpHelpers.Status( "empty", @@ -220,10 +228,11 @@ public static async Task GetWaitTrend( ones that do have data — Lite's get_wait_trend miss vocabulary. */ var collected = await DarlingDataReader.GetDistinctWaitTypesAsync(postgres, resolved.ServerId, start, now); if (collected.Count == 0) - return McpHelpers.Status( - "unavailable", - $"No trend data for wait type '{wait_type}'. No wait stats have been collected for this server in the last {hours_back}h yet " + - "(the collector may not have run, or delta wait stats need a second collection cycle)."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "wait_stats") + ?? McpHelpers.Status( + "unavailable", + $"No trend data for wait type '{wait_type}'. No wait stats have been collected for this server in the last {hours_back}h yet " + + "(the collector may not have run, or delta wait stats need a second collection cycle)."); return McpHelpers.Status( "not_collected", @@ -264,7 +273,8 @@ public static async Task GetMemoryStats( { var stats = await DarlingDataReader.GetLatestMemoryStatsAsync(postgres, resolved.ServerId); if (stats == null) - return McpHelpers.Status("unavailable", "No memory stats available."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "memory_stats") + ?? McpHelpers.Status("unavailable", "No memory stats available."); var utilization = stats.TotalPhysicalMemoryMb > 0 ? (stats.TotalPhysicalMemoryMb - stats.AvailablePhysicalMemoryMb) / stats.TotalPhysicalMemoryMb * 100 @@ -312,9 +322,10 @@ with the read by construction and tell the caller nothing it did not already hav the caller does need is to be told that an empty clerk list is NEVER a quiet period, because on a live SQL Server it cannot be: the DMV always has clerks. */ - return McpHelpers.Status( - "unavailable", - $"No memory-clerk snapshot is available for {resolved.ServerName}. This read returns the LATEST snapshot rather than a window, so an empty result is never a quiet period — a live SQL Server always has memory clerks. It means nothing the memory_clerks collector stored is still retained, either because it has not run for this server or because its rows have aged out. Check get_collection_health and get_collection_log for the memory_clerks collector."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "memory_clerks") + ?? McpHelpers.Status( + "unavailable", + $"No memory-clerk snapshot is available for {resolved.ServerName}. This read returns the LATEST snapshot rather than a window, so an empty result is never a quiet period — a live SQL Server always has memory clerks. It means nothing the memory_clerks collector stored is still retained, either because it has not run for this server or because its rows have aged out. Check get_collection_health and get_collection_log for the memory_clerks collector."); var result = rows.Select(r => new { @@ -346,7 +357,8 @@ public static async Task GetFileIoStats( { var rows = await DarlingDataReader.GetLatestFileIoStatsAsync(postgres, resolved.ServerId); if (rows.Count == 0) - return McpHelpers.Status("unavailable", "No file I/O stats available."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "file_io_stats") + ?? McpHelpers.Status("unavailable", "No file I/O stats available."); var result = rows.Select(r => new { @@ -394,7 +406,8 @@ public static async Task GetTempDbTrend( { var rows = await DarlingDataReader.GetTempDbTrendAsync(postgres, resolved.ServerId, windowEnd.AddHours(-hours_back), windowEnd); if (rows.Count == 0) - return McpHelpers.Status("unavailable", "No TempDB data available."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "tempdb_stats") + ?? McpHelpers.Status("unavailable", "No TempDB data available."); var result = rows.Select(r => new { @@ -436,7 +449,8 @@ public static async Task GetPerfmonStats( { var rows = await DarlingDataReader.GetLatestPerfmonStatsAsync(postgres, resolved.ServerId); if (rows.Count == 0) - return McpHelpers.Status("unavailable", "No perfmon stats available."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "perfmon_stats") + ?? McpHelpers.Status("unavailable", "No perfmon stats available."); IEnumerable filtered = rows; if (!string.IsNullOrEmpty(counter_name)) @@ -502,7 +516,8 @@ the exact wrong conclusion this option exists to prevent. */ var rows = await DarlingDataReader.GetTopQueriesByCpuAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now, top, database_name, rollUpByHostObject: rollUp); if (rows.Count == 0) - return McpHelpers.Status("unavailable", "No query stats available for the specified time range."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "query_stats") + ?? McpHelpers.Status("unavailable", "No query stats available for the specified time range."); var filtered = rows .Where(r => !(parallel_only || min_dop > 1) || (r.MaxDop > 1 && r.MaxDop >= (min_dop > 1 ? min_dop : 2))) @@ -619,9 +634,10 @@ public static async Task GetTopProceduresByCpu( var now = windowEnd; var rows = await DarlingDataReader.GetTopProceduresByCpuAsync(postgres, resolved.ServerId, now.AddHours(-hours_back), now, top, database_name); if (rows.Count == 0) - return McpHelpers.Status( - "unavailable", - "No procedure stats available. Delta-based collection requires at least two collection cycles (~30 minutes) to produce non-zero values."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "procedure_stats") + ?? McpHelpers.Status( + "unavailable", + "No procedure stats available. Delta-based collection requires at least two collection cycles (~30 minutes) to produce non-zero values."); /* #2320: same attributed-CPU disclosure as the queries tool — one shared computation, same concurrent independent reads. */ @@ -716,13 +732,14 @@ that was served rather than echo the one that was asked for. */ var truncated = floor is DateTime f && f > requestedStart.AddMinutes(90); if (rows.Count == 0) - return McpHelpers.Status( - "unavailable", - $"No Query Store rows for this server in the {hours_back}-hour window searched. Query Store " + - "may not be enabled on the target databases -- or the window reaches past what the raw tier " + - "retains (query_store_stats is dropped at 4 days when the rollups are armed), in which case " + - "nothing was read for the older part of it. Try a shorter window before concluding the " + - "queries did not run."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "query_store") + ?? McpHelpers.Status( + "unavailable", + $"No Query Store rows for this server in the {hours_back}-hour window searched. Query Store " + + "may not be enabled on the target databases -- or the window reaches past what the raw tier " + + "retains (query_store_stats is dropped at 4 days when the rollups are armed), in which case " + + "nothing was read for the older part of it. Try a shorter window before concluding the " + + "queries did not run."); var result = rows.Select(r => new { @@ -1023,7 +1040,8 @@ public static async Task GetServerProperties( { var row = await DarlingDataReader.GetLatestServerPropertiesAsync(postgres, resolved.ServerId); if (row == null) - return McpHelpers.Status("unavailable", "No server properties available. The properties collector may not have run yet."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "server_properties") + ?? McpHelpers.Status("unavailable", "No server properties available. The properties collector may not have run yet."); return JsonSerializer.Serialize(new { @@ -1212,6 +1230,12 @@ away from exactly the read whose premise is looking FURTHER back than the defaul "nothing was waiting" reads as an all-clear, while the truth may be that the waiting_tasks collector never ran. A caller told all-clear stops looking. */ + var gated = await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "waiting_tasks"); + if (gated != null) + { + return gated; + } + var everCollected = await DarlingDataReader.HasAnyWaitingTaskSampleAsync(postgres, resolved.ServerId); return everCollected ? McpHelpers.Status( @@ -1295,6 +1319,12 @@ a false alarm sends someone to fix collection that is working. checked because either can be off alone, and the deadlock collector is separate from both -- the verdict covers its series too. */ + var gated = await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "blocked_process_report"); + if (gated != null) + { + return gated; + } + var everRan = await DarlingBlockingTrendReader.HasAnyBlockingCollectorRunAsync(postgres, resolved.ServerId) || await DarlingBlockingTrendReader.HasAnyDeadlockCollectorRunAsync(postgres, resolved.ServerId); diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLatchSpinlockTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLatchSpinlockTools.cs index e7b6a547b0..843650aa11 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLatchSpinlockTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLatchSpinlockTools.cs @@ -59,7 +59,8 @@ public static async Task GetLatchStats( var rows = await DarlingLatchSpinlockReader.GetLatchStatsTopNAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now, top); if (rows.Count == 0) - return McpHelpers.Status("unavailable", "No latch statistics available in the requested time range."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "latch_stats") + ?? McpHelpers.Status("unavailable", "No latch statistics available in the requested time range."); var latches = rows.Select(r => new { @@ -113,7 +114,8 @@ public static async Task GetSpinlockStats( var rows = await DarlingLatchSpinlockReader.GetSpinlockStatsTopNAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now, top); if (rows.Count == 0) - return McpHelpers.Status("unavailable", "No spinlock statistics available in the requested time range."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "spinlock_stats") + ?? McpHelpers.Status("unavailable", "No spinlock statistics available in the requested time range."); var spinlocks = rows.Select(r => new { diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLongQueryTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLongQueryTools.cs index 223a4187a1..3848bf047f 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLongQueryTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLongQueryTools.cs @@ -51,7 +51,8 @@ public static async Task GetLongQueryCompletions( var rows = await DarlingLongQueryReader.GetRecentLongQueryCompletionsAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now); if (rows.Count == 0) - return McpHelpers.Status("empty", "No long-running query completions found in the specified time range. The long_query_completions collector is opt-in (default OFF) — enable it in the collector schedule to capture data."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "long_query_completions") + ?? McpHelpers.Status("empty", "No long-running query completions found in the specified time range. The long_query_completions collector is opt-in (default OFF) — enable it in the collector schedule to capture data."); var result = rows.Take(limit).Select(r => new { diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpMemoryGrantTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpMemoryGrantTools.cs index a5edd3ed76..f406947567 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpMemoryGrantTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpMemoryGrantTools.cs @@ -49,7 +49,8 @@ public static async Task GetResourceSemaphore( var rows = await DarlingMemoryGrantReader.GetResourceSemaphoreLatestAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now); if (rows.Count == 0) - return McpHelpers.Status("unavailable", "No memory grant data available."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "memory_grant_stats") + ?? McpHelpers.Status("unavailable", "No memory grant data available."); var grants = rows.Select(r => new { @@ -101,7 +102,8 @@ public static async Task GetMemoryGrants( var rows = await DarlingMemoryGrantReader.GetMemoryGrantsLatestAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now); if (rows.Count == 0) - return McpHelpers.Status("unavailable", "No memory grant data available."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "memory_grant_stats") + ?? McpHelpers.Status("unavailable", "No memory grant data available."); var grants = rows.Select(r => new { diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpObjectStatsTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpObjectStatsTools.cs index a07a550580..28fc5fbe2f 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpObjectStatsTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpObjectStatsTools.cs @@ -49,7 +49,8 @@ public static async Task GetTableIndexSizes( var rows = await DarlingObjectStatsReader.GetObjectSizeGrowthAsync( postgres, resolved.ServerId, now.AddDays(-7), now.AddDays(-30), TableSizesTop); if (rows.Count == 0) - return McpHelpers.Status("unavailable", "No object size data available. Index/object stats are collected daily."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "index_object_stats") + ?? McpHelpers.Status("unavailable", "No object size data available. Index/object stats are collected daily."); var result = rows.Select(r => new { @@ -90,7 +91,8 @@ public static async Task GetIndexUsage( { var rows = await DarlingObjectStatsReader.GetIndexUsageAsync(postgres, resolved.ServerId, IndexUsageTop); if (rows.Count == 0) - return McpHelpers.Status("unavailable", "No index usage data available. Index/object stats are collected daily."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "index_object_stats") + ?? McpHelpers.Status("unavailable", "No index usage data available. Index/object stats are collected daily."); var result = rows.Select(r => new { @@ -134,7 +136,8 @@ public static async Task GetObjectLocking( { var rows = await DarlingObjectStatsReader.GetIndexLockingAsync(postgres, resolved.ServerId, ObjectLockingTop); if (rows.Count == 0) - return McpHelpers.Status("unavailable", "No locking/contention data recorded. Index/object stats are collected daily."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "index_object_stats") + ?? McpHelpers.Status("unavailable", "No locking/contention data recorded. Index/object stats are collected daily."); var result = rows.Select(r => new { @@ -178,7 +181,8 @@ public static async Task GetDatabaseSizes( { var rows = await DarlingObjectStatsReader.GetLatestDatabaseSizesAsync(postgres, resolved.ServerId); if (rows.Count == 0) - return McpHelpers.Status("unavailable", "No database size data available. The size collector may not have run yet."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "database_size_stats") + ?? McpHelpers.Status("unavailable", "No database size data available. The size collector may not have run yet."); return JsonSerializer.Serialize(new { diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanCacheSchedulerTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanCacheSchedulerTools.cs index 6435a74aa3..a0797d68bc 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanCacheSchedulerTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanCacheSchedulerTools.cs @@ -49,7 +49,8 @@ public static async Task GetPlanCacheBloat( var rows = await DarlingPlanCacheSchedulerReader.GetPlanCacheBloatAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now); if (rows.Count == 0) - return McpHelpers.Status("unavailable", "No plan cache statistics available in the requested time range."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "plan_cache_stats") + ?? McpHelpers.Status("unavailable", "No plan cache statistics available in the requested time range."); var totalPlans = rows.Sum(r => (long)r.TotalPlans); var totalSingleUse = rows.Sum(r => (long)r.SingleUsePlans); diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanCorrectionTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanCorrectionTools.cs index ab04a9a35c..72ffa013e1 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanCorrectionTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanCorrectionTools.cs @@ -55,10 +55,11 @@ public static async Task GetPlanCorrections( if (tuning.Count == 0 && rows.Count == 0) { - return McpHelpers.Status("empty", - "No plan correction data collected for this server. The collector runs against SQL Server 2017+ " + - "(sys.dm_db_tuning_recommendations); a server that has never produced a row here either predates " + - "that or has no databases with Query Store on."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "plan_correction") + ?? McpHelpers.Status("empty", + "No plan correction data collected for this server. The collector runs against SQL Server 2017+ " + + "(sys.dm_db_tuning_recommendations); a server that has never produced a row here either predates " + + "that or has no databases with Query Store on."); } var recommendations = rows.Take(limit).Select(r => new diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanTools.cs index 620e674038..9467a62f07 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanTools.cs @@ -60,9 +60,10 @@ public static async Task AnalyzeQueryPlan( var xml = await DarlingStoredPlanReader.GetQueryStatsPlanXmlByHashAsync( postgres, resolved.ServerId, query_hash, database_name); if (string.IsNullOrEmpty(xml)) - return McpHelpers.Status( - "unavailable", - $"No stored plan found for query_hash '{query_hash}'{DbSuffix(database_name)}. The plan collector may not have captured a plan for this query, or the row has aged out of the store."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "query_stats") + ?? McpHelpers.Status( + "unavailable", + $"No stored plan found for query_hash '{query_hash}'{DbSuffix(database_name)}. The plan collector may not have captured a plan for this query, or the row has aged out of the store."); return McpPlanAnalysisFormatter.BuildAnalysisResult(xml, resolved.ServerName, "query_stats", query_hash); } @@ -89,9 +90,10 @@ public static async Task AnalyzeProcedurePlan( var xml = await DarlingStoredPlanReader.GetProcedurePlanXmlBySqlHandleAsync( postgres, resolved.ServerId, sql_handle); if (string.IsNullOrEmpty(xml)) - return McpHelpers.Status( - "unavailable", - $"No stored plan found for sql_handle '{sql_handle}'. The plan collector may not have captured a plan for this procedure, or the row has aged out of the store."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "procedure_stats") + ?? McpHelpers.Status( + "unavailable", + $"No stored plan found for sql_handle '{sql_handle}'. The plan collector may not have captured a plan for this procedure, or the row has aged out of the store."); return McpPlanAnalysisFormatter.BuildAnalysisResult(xml, resolved.ServerName, "procedure_stats", sql_handle); } @@ -120,9 +122,10 @@ public static async Task AnalyzeQueryStorePlan( var xml = await DarlingStoredPlanReader.GetQueryStorePlanTextAsync( postgres, resolved.ServerId, database_name, query_id, plan_id); if (string.IsNullOrEmpty(xml)) - return McpHelpers.Status( - "unavailable", - $"No stored Query Store plan found for query_id {query_id} in database '{database_name}'{PlanSuffix(plan_id)}. Query Store plan capture may be disabled for this database, or the plan has been purged."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "query_store") + ?? McpHelpers.Status( + "unavailable", + $"No stored Query Store plan found for query_id {query_id} in database '{database_name}'{PlanSuffix(plan_id)}. Query Store plan capture may be disabled for this database, or the plan has been purged."); var identifier = plan_id is null ? $"{database_name}:{query_id}" : $"{database_name}:{query_id}:{plan_id}"; return McpPlanAnalysisFormatter.BuildAnalysisResult(xml, resolved.ServerName, "query_store", identifier); @@ -171,7 +174,8 @@ public static async Task GetPlanXml( var xml = await DarlingStoredPlanReader.GetQueryStatsPlanXmlByHashAsync( postgres, resolved.ServerId, query_hash, database_name); if (string.IsNullOrEmpty(xml)) - return McpHelpers.Status("unavailable", $"No stored plan found for query_hash '{query_hash}'{DbSuffix(database_name)}."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "query_stats") + ?? McpHelpers.Status("unavailable", $"No stored plan found for query_hash '{query_hash}'{DbSuffix(database_name)}."); return McpHelpers.Truncate(xml, 512_000) ?? "No plan XML available."; } diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPvsTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPvsTools.cs index 3c78cdd772..7ff673528e 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPvsTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPvsTools.cs @@ -51,9 +51,10 @@ public static async Task GetPvsStats( var rows = await DarlingPvsReader.GetPvsStatsLatestAsync(postgres, resolved.ServerId); if (rows.Count == 0) { - return McpHelpers.Status("empty", - "No PVS data collected for this server. The collector reads sys.dm_tran_persistent_version_store_stats " + - "(SQL Server 2019+); a server with no rows either predates ADR or has not completed a pvs_stats cycle yet."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "pvs_stats") + ?? McpHelpers.Status("empty", + "No PVS data collected for this server. The collector reads sys.dm_tran_persistent_version_store_stats " + + "(SQL Server 2019+); a server with no rows either predates ADR or has not completed a pvs_stats cycle yet."); } var databases = rows.Select(r => new diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryHeatmapTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryHeatmapTools.cs index 7e900da5fe..48db3ca5f3 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryHeatmapTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryHeatmapTools.cs @@ -177,9 +177,10 @@ private static async Task EmptyAsync( if (!hasAny) { - return McpHelpers.Status( - "unavailable", - $"No query stats have EVER been collected for {serverName}, so this is NOT a report of a quiet server — there is nothing to draw. query_stats is a PERIODIC table rather than an edge table: the collector writes rows every cycle for whatever is in the plan cache, so an empty history means nobody looked. Check get_collection_health for this server."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, serverId, serverName, "query_stats") + ?? McpHelpers.Status( + "unavailable", + $"No query stats have EVER been collected for {serverName}, so this is NOT a report of a quiet server — there is nothing to draw. query_stats is a PERIODIC table rather than an edge table: the collector writes rows every cycle for whatever is in the plan cache, so an empty history means nobody looked. Check get_collection_health for this server."); } if (!hasInWindow) diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreRegressionTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreRegressionTools.cs index ced50c3ea7..572e16c343 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreRegressionTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreRegressionTools.cs @@ -133,9 +133,10 @@ private static async Task EmptyAsync( if (!hasBaseline && !hasRecent) { - return McpHelpers.Status( - "unavailable", - $"No Query Store data has EVER been collected for {serverName}, so this is NOT a report of zero regressions — there is nothing to compare. Query Store may be OFF on this server's databases, which get_query_store_health will say; otherwise check that collection is running for this server."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, serverId, serverName, "query_store") + ?? McpHelpers.Status( + "unavailable", + $"No Query Store data has EVER been collected for {serverName}, so this is NOT a report of zero regressions — there is nothing to compare. Query Store may be OFF on this server's databases, which get_query_store_health will say; otherwise check that collection is running for this server."); } if (!hasBaseline) diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs index 2a40dbdcc7..af2f78bc74 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs @@ -49,7 +49,8 @@ public static async Task GetSessionStats( { var rows = await DarlingSessionReader.GetLatestSessionStatsAsync(postgres, resolved.ServerId); if (rows.Count == 0) - return McpHelpers.Status("unavailable", "No session statistics available. The session collector may not have run yet."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "session_stats") + ?? McpHelpers.Status("unavailable", "No session statistics available. The session collector may not have run yet."); var totalConnections = rows.Sum(r => r.ConnectionCount); var totalRunning = rows.Sum(r => r.RunningCount); @@ -110,7 +111,8 @@ public static async Task GetActiveQueries( var rows = await DarlingSessionReader.GetActiveQueriesAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now); if (rows.Count == 0) - return McpHelpers.Status("empty", "No active query snapshots found in the requested time range."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "query_snapshots") + ?? McpHelpers.Status("empty", "No active query snapshots found in the requested time range."); IEnumerable filtered = rows; @@ -184,7 +186,8 @@ public static async Task GetWaitingTasks( var rows = await DarlingSessionReader.GetWaitingTasksAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now); if (rows.Count == 0) - return McpHelpers.Status("empty", "No waiting tasks captured in the specified time range."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "waiting_tasks") + ?? McpHelpers.Status("empty", "No waiting tasks captured in the specified time range."); var result = rows.Take(limit).Select(r => new { diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs index 24a9d56488..80f80059ab 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs @@ -71,6 +71,12 @@ neither. A server that collected fine and was simply quiet in THIS window wants collection, and widening will never fill it. Probed only here, on the path that already found nothing, against the SAME source the trend read. */ + var gated = await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "memory_stats"); + if (gated != null) + { + return gated; + } + return await DarlingTrendReader.HasAnyMemoryStatAsync(postgres, resolved.ServerId) ? McpHelpers.Status( "empty", @@ -144,10 +150,11 @@ miss vocabulary. */ /* Nothing collected at all for this server in the window: the collector likely hasn't produced perfmon data yet (delta counters need two cycles). Not retrievable now. */ if (collected.Count == 0) - return McpHelpers.Status( - "unavailable", - $"No trend data for counter '{counter_name}'. No perfmon counters have been collected for this server in the last {hours_back}h yet " + - "(the collector may not have run, or delta counters need a second collection cycle)."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "perfmon_stats") + ?? McpHelpers.Status( + "unavailable", + $"No trend data for counter '{counter_name}'. No perfmon counters have been collected for this server in the last {hours_back}h yet " + + "(the collector may not have run, or delta counters need a second collection cycle)."); /* Other counters exist but not this one: the name is almost certainly wrong. Hand back the collected names so the caller can correct it. */ @@ -208,6 +215,12 @@ public static async Task GetFileIoTrend( carries one extra clause the others do not need: this read's top_files CTE requires delta_reads or delta_writes above zero, so a genuinely idle file set is empty here even on a server whose file_io_stats collector ran every cycle. */ + var gated = await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "file_io_stats"); + if (gated != null) + { + return gated; + } + return await DarlingTrendReader.HasAnyFileIoStatAsync(postgres, resolved.ServerId) ? McpHelpers.Status( "empty", @@ -264,12 +277,13 @@ public static async Task GetQueryTrend( last N hours" over a span the read never covered — for a query whose history had aged out of the raw tier that is a false statement, not an incomplete one, and an agent acts on it by concluding the query did not run. */ - return McpHelpers.Status( - "empty", - $"No history found for query_hash '{query_hash}' in database '{database_name}' in the " + - $"{history.Source} tier over the last {hours_back} hours. This means nothing was recorded " + - "for that query_hash in that window in the tier searched — confirm the hash and database " + - "with get_top_queries_by_cpu before concluding the query did not run."); + return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "query_stats") + ?? McpHelpers.Status( + "empty", + $"No history found for query_hash '{query_hash}' in database '{database_name}' in the " + + $"{history.Source} tier over the last {hours_back} hours. This means nothing was recorded " + + "for that query_hash in that window in the tier searched — confirm the hash and database " + + "with get_top_queries_by_cpu before concluding the query did not run."); } /* The hourly rollup keeps executions, CPU and elapsed and nothing else. Those columns arrive as @@ -347,6 +361,12 @@ public static async Task GetQueryDurationTrend( does — v_query_stats is the payload-resolving view on a V38+ store, and probing a different relation from the one the read walks is how an existence probe ends up reporting the wrong branch. */ + var gated = await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "query_stats"); + if (gated != null) + { + return gated; + } + return await DarlingTrendReader.HasAnyQueryStatAsync(postgres, resolved.ServerId) ? McpHelpers.Status( "empty", @@ -387,6 +407,12 @@ public static async Task GetProcedureDurationTrend( if (points.Count == 0) { + var gated = await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "procedure_stats"); + if (gated != null) + { + return gated; + } + return await EmptyTrendAsync( DarlingTrendReader.HasAnyProcedureStatAsync(postgres, resolved.ServerId), resolved.ServerName, hours_back, "stored-procedure", @@ -427,6 +453,12 @@ public static async Task GetQueryStoreDurationTrend( every database on the instance. A server with no Query Store data is not a server with no slow queries, so the message names that cause first. */ + var gated = await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "query_store"); + if (gated != null) + { + return gated; + } + return await EmptyTrendAsync( DarlingTrendReader.HasAnyQueryStoreStatAsync(postgres, resolved.ServerId), resolved.ServerName, hours_back, "Query Store", diff --git a/Lite/Mcp/McpBlockingTools.cs b/Lite/Mcp/McpBlockingTools.cs index e6e7f8ba65..766cd60b2b 100644 --- a/Lite/Mcp/McpBlockingTools.cs +++ b/Lite/Mcp/McpBlockingTools.cs @@ -32,7 +32,8 @@ public static async Task GetDeadlocks( var rows = await dataService.GetRecentDeadlocksAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) { - return McpHelpers.Status("empty", "No deadlocks found in the specified time range."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "deadlocks") + ?? McpHelpers.Status("empty", "No deadlocks found in the specified time range."); } var result = rows.Take(limit).Select(r => new @@ -83,7 +84,8 @@ public static async Task GetDeadlockDetail( var withXml = rows.Where(r => r.HasDeadlockXml).Take(limit).ToList(); if (withXml.Count == 0) { - return McpHelpers.Status("empty", "No deadlock XML available in the specified time range."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "deadlocks") + ?? McpHelpers.Status("empty", "No deadlock XML available in the specified time range."); } var result = withXml.Select(r => new @@ -130,7 +132,8 @@ public static async Task GetBlockedProcessReports( var rows = await dataService.GetRecentBlockedProcessReportsAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) { - return McpHelpers.Status("empty", "No blocked process reports found."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "blocked_process_report") + ?? McpHelpers.Status("empty", "No blocked process reports found."); } var result = rows.Take(limit).Select(r => new @@ -207,7 +210,8 @@ public static async Task GetBlockedProcessXml( var withXml = rows.Where(r => r.HasReportXml).Take(limit).ToList(); if (withXml.Count == 0) { - return McpHelpers.Status("empty", "No blocked process report XML available in the specified time range."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "blocked_process_report") + ?? McpHelpers.Status("empty", "No blocked process report XML available in the specified time range."); } var result = withXml.Select(r => new @@ -273,6 +277,12 @@ nothing. Darling's twin makes the same distinction with the same words. */ var captures = await dataService.GetBlockingCaptureCountsAsync( resolved.ServerId, hours_back, asOfUtc: anchorEnd); + var gated = await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "blocked_process_report"); + if (gated != null) + { + return gated; + } + return await EmptyTrend( "blocking", resolved.ServerName, hours_back, captures, () => dataService.HasAnyBlockingCollectorRunAsync(resolved.ServerId)); @@ -318,6 +328,12 @@ public static async Task GetDeadlockTrend( /* Same two facts as the blocking trend above, same denominator, same reason. */ var captures = await dataService.GetDeadlockCaptureCountsAsync( resolved.ServerId, hours_back, asOfUtc: windowEnd); + var gated = await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "deadlocks"); + if (gated != null) + { + return gated; + } + return await EmptyTrend( /* SINGULAR: the subject lands in "No {subject} was recorded", and "no deadlocks was recorded" is not a sentence. It also reads correctly in the other two, @@ -375,6 +391,12 @@ The LCK% filter is deliberately NOT applied to the probe. A server that has coll and filtering the probe the same way would call that server uncollected. Darling's twin makes the same distinction with the same words. */ + var gated = await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "wait_stats"); + if (gated != null) + { + return gated; + } + return await dataService.HasAnyWaitStatAsync(resolved.ServerId) ? McpHelpers.Status( "empty", diff --git a/Lite/Mcp/McpConfigHistoryTools.cs b/Lite/Mcp/McpConfigHistoryTools.cs index 96abc90ec7..2e0c081d3c 100644 --- a/Lite/Mcp/McpConfigHistoryTools.cs +++ b/Lite/Mcp/McpConfigHistoryTools.cs @@ -83,8 +83,9 @@ public static async Task GetDatabaseConfigChanges( var rows = await dataService.GetDatabaseConfigChangesAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) - return McpHelpers.Status("empty", - $"No database configuration changes detected in the last {hours_back}h. Config is captured on connect, so at least two snapshots are needed to detect a change."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "database_config") + ?? McpHelpers.Status("empty", + $"No database configuration changes detected in the last {hours_back}h. Config is captured on connect, so at least two snapshots are needed to detect a change."); return JsonSerializer.Serialize(new { diff --git a/Lite/Mcp/McpConfigTools.cs b/Lite/Mcp/McpConfigTools.cs index c4ebe3a3ff..33bdadd600 100644 --- a/Lite/Mcp/McpConfigTools.cs +++ b/Lite/Mcp/McpConfigTools.cs @@ -62,9 +62,10 @@ public static async Task GetDatabaseConfig( { var rows = await dataService.GetLatestDatabaseConfigAsync(resolved.ServerId); if (rows.Count == 0) - return McpHelpers.Status( - "unavailable", - "No database configuration data available. The config collector may not have run yet."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "database_config") + ?? McpHelpers.Status( + "unavailable", + "No database configuration data available. The config collector may not have run yet."); IEnumerable filtered = rows; if (!string.IsNullOrEmpty(database_name)) @@ -121,9 +122,10 @@ public static async Task GetDatabaseScopedConfig( { var rows = await dataService.GetLatestDatabaseScopedConfigAsync(resolved.ServerId); if (rows.Count == 0) - return McpHelpers.Status( - "unavailable", - "No database-scoped configuration data available. The config collector may not have run yet."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "database_scoped_config") + ?? McpHelpers.Status( + "unavailable", + "No database-scoped configuration data available. The config collector may not have run yet."); IEnumerable filtered = rows; if (!string.IsNullOrEmpty(database_name)) @@ -169,9 +171,10 @@ public static async Task GetQueryStoreHealth( { var rows = await dataService.GetLatestQueryStoreHealthAsync(resolved.ServerId); if (rows.Count == 0) - return McpHelpers.Status( - "unavailable", - "No Query Store health data available. The query_store_health collector runs hourly (SQL Server 2016+); a server with no rows either predates Query Store or has not completed a cycle yet."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "query_store_health") + ?? McpHelpers.Status( + "unavailable", + "No Query Store health data available. The query_store_health collector runs hourly (SQL Server 2016+); a server with no rows either predates Query Store or has not completed a cycle yet."); IEnumerable filtered = rows; if (!string.IsNullOrEmpty(database_name)) diff --git a/Lite/Mcp/McpCpuTools.cs b/Lite/Mcp/McpCpuTools.cs index 1034b1ab69..73dc202856 100644 --- a/Lite/Mcp/McpCpuTools.cs +++ b/Lite/Mcp/McpCpuTools.cs @@ -28,7 +28,8 @@ public static async Task GetCpuUtilization( var rows = await dataService.GetCpuUtilizationAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) { - return McpHelpers.Status("unavailable", "No CPU utilization data available."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "cpu_utilization") + ?? McpHelpers.Status("unavailable", "No CPU utilization data available."); } /* Downsample to 1-minute buckets to avoid overwhelming LLM context */ diff --git a/Lite/Mcp/McpHealthTools.cs b/Lite/Mcp/McpHealthTools.cs index d76d2a0662..cc9cb75149 100644 --- a/Lite/Mcp/McpHealthTools.cs +++ b/Lite/Mcp/McpHealthTools.cs @@ -486,6 +486,12 @@ away from exactly the read whose premise is looking FURTHER back than the defaul Both empty is two facts, and the wrong one is the REASSURING one -- "nothing was waiting" reads as all-clear and stops a caller looking. Same words as Darling's twin. */ + var gated = await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "waiting_tasks"); + if (gated != null) + { + return gated; + } + var everCollected = await dataService.HasAnyWaitingTaskSampleAsync(resolved.ServerId); return everCollected ? McpHelpers.Status( @@ -551,6 +557,12 @@ away from exactly the read whose premise is looking FURTHER back than the defaul /* The denominator is whether we LOOKED, not whether we ever FOUND anything: these are edge tables, and a healthy server that never blocked has no rows to find. Same words as Darling's twin. */ + var gated = await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "blocked_process_report"); + if (gated != null) + { + return gated; + } + var everRan = await dataService.HasAnyBlockingCollectorRunAsync(resolved.ServerId) || await dataService.HasAnyDeadlockCollectorRunAsync(resolved.ServerId); diff --git a/Lite/Mcp/McpIoTools.cs b/Lite/Mcp/McpIoTools.cs index 021e2ce783..4d326abb24 100644 --- a/Lite/Mcp/McpIoTools.cs +++ b/Lite/Mcp/McpIoTools.cs @@ -23,7 +23,8 @@ public static async Task GetFileIoStats( var rows = await dataService.GetLatestFileIoStatsAsync(resolved.ServerId); if (rows.Count == 0) { - return McpHelpers.Status("unavailable", "No file I/O stats available."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "file_io_stats") + ?? McpHelpers.Status("unavailable", "No file I/O stats available."); } var result = rows.Select(r => new @@ -79,6 +80,12 @@ public static async Task GetFileIoTrend( The quiet-window sentence carries one extra clause the others do not need: this read's top_files CTE requires delta_reads or delta_writes above zero, so a genuinely idle file set is empty here even on a server whose file_io_stats collector ran every cycle. */ + var gated = await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "file_io_stats"); + if (gated != null) + { + return gated; + } + return await dataService.HasAnyFileIoStatAsync(resolved.ServerId) ? McpHelpers.Status( "empty", diff --git a/Lite/Mcp/McpLatchSpinlockTools.cs b/Lite/Mcp/McpLatchSpinlockTools.cs index 6de3f5b4cd..cd2464f35e 100644 --- a/Lite/Mcp/McpLatchSpinlockTools.cs +++ b/Lite/Mcp/McpLatchSpinlockTools.cs @@ -33,7 +33,8 @@ public static async Task GetLatchStats( var rows = await dataService.GetLatchStatsSnapshotAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) - return McpHelpers.Status("unavailable", "No latch statistics available in the requested time range."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "latch_stats") + ?? McpHelpers.Status("unavailable", "No latch statistics available in the requested time range."); return JsonSerializer.Serialize(new { @@ -78,7 +79,8 @@ public static async Task GetSpinlockStats( var rows = await dataService.GetSpinlockStatsSnapshotAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) - return McpHelpers.Status("unavailable", "No spinlock statistics available in the requested time range."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "spinlock_stats") + ?? McpHelpers.Status("unavailable", "No spinlock statistics available in the requested time range."); return JsonSerializer.Serialize(new { diff --git a/Lite/Mcp/McpLongQueryTools.cs b/Lite/Mcp/McpLongQueryTools.cs index 38d93a25cc..f20efbdf32 100644 --- a/Lite/Mcp/McpLongQueryTools.cs +++ b/Lite/Mcp/McpLongQueryTools.cs @@ -39,7 +39,8 @@ public static async Task GetLongQueryCompletions( var rows = await dataService.GetRecentLongQueryCompletionsAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) { - return McpHelpers.Status("empty", "No long-running query completions found in the specified time range. The long_query_completions collector is opt-in (default OFF) — enable it in the collector schedule to capture data."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "long_query_completions") + ?? McpHelpers.Status("empty", "No long-running query completions found in the specified time range. The long_query_completions collector is opt-in (default OFF) — enable it in the collector schedule to capture data."); } var result = rows diff --git a/Lite/Mcp/McpMemoryTools.cs b/Lite/Mcp/McpMemoryTools.cs index b261053f84..8f97b980f9 100644 --- a/Lite/Mcp/McpMemoryTools.cs +++ b/Lite/Mcp/McpMemoryTools.cs @@ -23,7 +23,8 @@ public static async Task GetMemoryStats( var stats = await dataService.GetLatestMemoryStatsAsync(resolved.ServerId); if (stats == null) { - return McpHelpers.Status("unavailable", "No memory stats available."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "memory_stats") + ?? McpHelpers.Status("unavailable", "No memory stats available."); } return JsonSerializer.Serialize(new @@ -75,6 +76,12 @@ A bare empty array here told an MCP client nothing at all -- and Darling's twin a server the collector has never touched wants somebody to go look at collection, and widening will never fill it. Probed only here, against the SAME source the trend read. */ + var gated = await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "memory_stats"); + if (gated != null) + { + return gated; + } + return await dataService.HasAnyMemoryStatAsync(resolved.ServerId) ? McpHelpers.Status( "empty", @@ -129,9 +136,10 @@ with the read by construction. What the caller needs told is that an empty clerk NEVER a quiet period, because on a live SQL Server it cannot be. Same words as Darling's twin. */ - return McpHelpers.Status( - "unavailable", - $"No memory-clerk snapshot is available for {resolved.ServerName}. This read returns the LATEST snapshot rather than a window, so an empty result is never a quiet period — a live SQL Server always has memory clerks. It means nothing the memory_clerks collector stored is still retained, either because it has not run for this server or because its rows have aged out. Check get_collection_health and get_collection_log for the memory_clerks collector."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "memory_clerks") + ?? McpHelpers.Status( + "unavailable", + $"No memory-clerk snapshot is available for {resolved.ServerName}. This read returns the LATEST snapshot rather than a window, so an empty result is never a quiet period — a live SQL Server always has memory clerks. It means nothing the memory_clerks collector stored is still retained, either because it has not run for this server or because its rows have aged out. Check get_collection_health and get_collection_log for the memory_clerks collector."); var result = rows.Select(r => new { @@ -222,7 +230,8 @@ public static async Task GetResourceSemaphore( var rows = await dataService.GetResourceSemaphoreSnapshotAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) { - return McpHelpers.Status("unavailable", "No memory grant data available."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "memory_grant_stats") + ?? McpHelpers.Status("unavailable", "No memory grant data available."); } var result = rows.Select(r => new @@ -275,7 +284,8 @@ public static async Task GetMemoryGrants( var rows = await dataService.GetMemoryGrantChartDataAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) { - return McpHelpers.Status("unavailable", "No memory grant data available."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "memory_grant_stats") + ?? McpHelpers.Status("unavailable", "No memory grant data available."); } /* Return latest snapshot */ diff --git a/Lite/Mcp/McpObjectStatsTools.cs b/Lite/Mcp/McpObjectStatsTools.cs index c08ba34a17..9227e387f0 100644 --- a/Lite/Mcp/McpObjectStatsTools.cs +++ b/Lite/Mcp/McpObjectStatsTools.cs @@ -23,7 +23,8 @@ public static async Task GetTableIndexSizes( var rows = await dataService.GetObjectSizeGrowthAsync(resolved.ServerId); if (rows.Count == 0) { - return McpHelpers.Status("unavailable", "No object size data available. Index/object stats are collected daily."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "index_object_stats") + ?? McpHelpers.Status("unavailable", "No object size data available. Index/object stats are collected daily."); } var result = rows.Select(r => new @@ -67,7 +68,8 @@ public static async Task GetIndexUsage( var rows = await dataService.GetIndexUsageAsync(resolved.ServerId); if (rows.Count == 0) { - return McpHelpers.Status("unavailable", "No index usage data available. Index/object stats are collected daily."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "index_object_stats") + ?? McpHelpers.Status("unavailable", "No index usage data available. Index/object stats are collected daily."); } var result = rows.Select(r => new @@ -114,7 +116,8 @@ public static async Task GetObjectLocking( var rows = await dataService.GetIndexLockingAsync(resolved.ServerId); if (rows.Count == 0) { - return McpHelpers.Status("unavailable", "No locking/contention data recorded. Index/object stats are collected daily."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "index_object_stats") + ?? McpHelpers.Status("unavailable", "No locking/contention data recorded. Index/object stats are collected daily."); } var result = rows.Select(r => new diff --git a/Lite/Mcp/McpPerfmonTools.cs b/Lite/Mcp/McpPerfmonTools.cs index dc96cdd752..586824b086 100644 --- a/Lite/Mcp/McpPerfmonTools.cs +++ b/Lite/Mcp/McpPerfmonTools.cs @@ -25,7 +25,8 @@ public static async Task GetPerfmonStats( var rows = await dataService.GetLatestPerfmonStatsAsync(resolved.ServerId); if (rows.Count == 0) { - return McpHelpers.Status("unavailable", "No perfmon stats available."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "perfmon_stats") + ?? McpHelpers.Status("unavailable", "No perfmon stats available."); } IEnumerable filtered = rows; @@ -90,10 +91,11 @@ public static async Task GetPerfmonTrend( /* Nothing collected at all for this server in the window: the collector likely hasn't produced perfmon data yet (delta counters need two cycles). Not retrievable now. */ if (collected.Count == 0) - return McpHelpers.Status( - "unavailable", - $"No trend data for counter '{counter_name}'. No perfmon counters have been collected for this server in the last {hours_back}h yet " + - "(the collector may not have run, or delta counters need a second collection cycle)."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "perfmon_stats") + ?? McpHelpers.Status( + "unavailable", + $"No trend data for counter '{counter_name}'. No perfmon counters have been collected for this server in the last {hours_back}h yet " + + "(the collector may not have run, or delta counters need a second collection cycle)."); /* Other counters exist but not this one: the name is almost certainly wrong. Hand back the collected names so the caller can correct it. */ diff --git a/Lite/Mcp/McpPlanCacheSchedulerTools.cs b/Lite/Mcp/McpPlanCacheSchedulerTools.cs index 395bc76f05..3e599f6f0e 100644 --- a/Lite/Mcp/McpPlanCacheSchedulerTools.cs +++ b/Lite/Mcp/McpPlanCacheSchedulerTools.cs @@ -36,7 +36,8 @@ public static async Task GetPlanCacheBloat( var summary = await dataService.GetPlanCacheSummaryAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); var cacheTypes = await dataService.GetPlanCacheSnapshotAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (summary.TotalPlans == 0 && cacheTypes.Count == 0) - return McpHelpers.Status("unavailable", "No plan cache statistics available in the requested time range."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "plan_cache_stats") + ?? McpHelpers.Status("unavailable", "No plan cache statistics available in the requested time range."); var bloat = LocalDataService.ClassifyPlanCacheBloat(summary.TotalPlans, summary.SingleUsePlans); var singleUsePercent = summary.TotalPlans > 0 diff --git a/Lite/Mcp/McpPlanCorrectionTools.cs b/Lite/Mcp/McpPlanCorrectionTools.cs index 20aad054b6..a74b0b7e00 100644 --- a/Lite/Mcp/McpPlanCorrectionTools.cs +++ b/Lite/Mcp/McpPlanCorrectionTools.cs @@ -50,10 +50,11 @@ public static async Task GetPlanCorrections( if (tuning.Count == 0 && rows.Count == 0) { - return McpHelpers.Status("empty", - "No plan correction data collected for this server. The collector runs against SQL Server 2017+ " + - "(sys.dm_db_tuning_recommendations); a server that has never produced a row here either predates " + - "that or has no databases with Query Store on."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "plan_correction") + ?? McpHelpers.Status("empty", + "No plan correction data collected for this server. The collector runs against SQL Server 2017+ " + + "(sys.dm_db_tuning_recommendations); a server that has never produced a row here either predates " + + "that or has no databases with Query Store on."); } var recommendations = rows.Take(limit).Select(r => new diff --git a/Lite/Mcp/McpPlanTools.cs b/Lite/Mcp/McpPlanTools.cs index 987a67130b..d21a9e0768 100644 --- a/Lite/Mcp/McpPlanTools.cs +++ b/Lite/Mcp/McpPlanTools.cs @@ -29,9 +29,10 @@ public static async Task AnalyzeQueryPlan( { var xml = await dataService.GetCachedQueryPlanAsync(resolved.ServerId, query_hash); if (string.IsNullOrEmpty(xml)) - return McpHelpers.Status( - "unavailable", - $"No plan found for query_hash '{query_hash}'. The query may have been evicted from the plan cache since the last collection."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "query_stats") + ?? McpHelpers.Status( + "unavailable", + $"No plan found for query_hash '{query_hash}'. The query may have been evicted from the plan cache since the last collection."); return McpPlanAnalysisFormatter.BuildAnalysisResult(xml, resolved.ServerName, "query_stats", query_hash); } @@ -58,9 +59,10 @@ public static async Task AnalyzeProcedurePlan( { var xml = await dataService.GetCachedProcedurePlanAsync(resolved.ServerId, plan_handle); if (string.IsNullOrEmpty(xml)) - return McpHelpers.Status( - "unavailable", - $"No plan found for plan_handle '{plan_handle}'. The procedure may have been evicted from the plan cache since the last collection."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "procedure_stats") + ?? McpHelpers.Status( + "unavailable", + $"No plan found for plan_handle '{plan_handle}'. The procedure may have been evicted from the plan cache since the last collection."); return McpPlanAnalysisFormatter.BuildAnalysisResult(xml, resolved.ServerName, "procedure_stats", plan_handle); } @@ -75,6 +77,11 @@ public static async Task AnalyzeProcedurePlan( "Fetches the plan on-demand from the monitored SQL Server instance. " + "Use after get_query_store_top to understand why a query is expensive.")] public static async Task AnalyzeQueryStorePlan( + /* Injected for the engine-capability miss alone (#2532): unlike its three siblings this tool + fetches the plan LIVE from the monitored instance rather than from the store, so the store + handle is not otherwise needed here — but the capability question is the same question, and + Darling's twin asks it, so the two SKUs must not answer differently. */ + LocalDataService dataService, ServerManager serverManager, [Description("The database_name from get_query_store_top.")] string database_name, [Description("The plan_id from get_query_store_top.")] long plan_id, @@ -99,9 +106,10 @@ public static async Task AnalyzeQueryStorePlan( var xml = await LocalDataService.FetchQueryStorePlanAsync(connectionString, database_name, plan_id); if (string.IsNullOrEmpty(xml)) - return McpHelpers.Status( - "unavailable", - $"No plan found for plan_id {plan_id} in database '{database_name}'. Query Store may not be enabled or the plan may have been purged."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "query_store") + ?? McpHelpers.Status( + "unavailable", + $"No plan found for plan_id {plan_id} in database '{database_name}'. Query Store may not be enabled or the plan may have been purged."); return McpPlanAnalysisFormatter.BuildAnalysisResult(xml, resolved.ServerName, "query_store", $"{database_name}:{plan_id}"); } @@ -148,7 +156,8 @@ public static async Task GetPlanXml( { var xml = await dataService.GetCachedQueryPlanAsync(resolved.ServerId, query_hash); if (string.IsNullOrEmpty(xml)) - return McpHelpers.Status("unavailable", $"No plan found for query_hash '{query_hash}'."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "query_stats") + ?? McpHelpers.Status("unavailable", $"No plan found for query_hash '{query_hash}'."); return McpHelpers.Truncate(xml, 512_000) ?? "No plan XML available."; } diff --git a/Lite/Mcp/McpPvsTools.cs b/Lite/Mcp/McpPvsTools.cs index b12323508f..7062f78355 100644 --- a/Lite/Mcp/McpPvsTools.cs +++ b/Lite/Mcp/McpPvsTools.cs @@ -44,9 +44,10 @@ public static async Task GetPvsStats( var rows = await dataService.GetPvsStatsLatestAsync(resolved.ServerId); if (rows.Count == 0) { - return McpHelpers.Status("empty", - "No PVS data collected for this server. The collector reads sys.dm_tran_persistent_version_store_stats " + - "(SQL Server 2019+); a server with no rows either predates ADR or has not completed a pvs_stats cycle yet."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "pvs_stats") + ?? McpHelpers.Status("empty", + "No PVS data collected for this server. The collector reads sys.dm_tran_persistent_version_store_stats " + + "(SQL Server 2019+); a server with no rows either predates ADR or has not completed a pvs_stats cycle yet."); } var databases = rows.Select(r => new diff --git a/Lite/Mcp/McpQueryTools.cs b/Lite/Mcp/McpQueryTools.cs index a3e25605ab..7b42cb5fab 100644 --- a/Lite/Mcp/McpQueryTools.cs +++ b/Lite/Mcp/McpQueryTools.cs @@ -41,7 +41,8 @@ shrinks the numerator/denominator window skew from the ranking query's full dura var rows = await dataService.GetTopQueriesByCpuAsync(resolved.ServerId, hours_back, top, databaseNames: string.IsNullOrEmpty(database_name) ? null : new[] { database_name }, asOfUtc: windowEnd); if (rows.Count == 0) { - return McpHelpers.Status("unavailable", "No query stats available for the specified time range."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "query_stats") + ?? McpHelpers.Status("unavailable", "No query stats available for the specified time range."); } var filtered = rows @@ -151,9 +152,10 @@ public static async Task GetTopProceduresByCpu( var rows = await dataService.GetTopProceduresByCpuAsync(resolved.ServerId, hours_back, top, databaseNames: string.IsNullOrEmpty(database_name) ? null : new[] { database_name }, asOfUtc: windowEnd); if (rows.Count == 0) { - return McpHelpers.Status( - "unavailable", - "No procedure stats available. Delta-based collection requires at least two collection cycles (~30 minutes) to produce non-zero values."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "procedure_stats") + ?? McpHelpers.Status( + "unavailable", + "No procedure stats available. Delta-based collection requires at least two collection cycles (~30 minutes) to produce non-zero values."); } /* #2320: same attributed-CPU disclosure as the queries tool — one shared computation, one @@ -239,7 +241,8 @@ public static async Task GetQueryStoreTop( var rows = await dataService.GetQueryStoreTopQueriesAsync(resolved.ServerId, hours_back, top, databaseNames: string.IsNullOrEmpty(database_name) ? null : new[] { database_name }, asOfUtc: windowEnd); if (rows.Count == 0) { - return McpHelpers.Status("unavailable", "No Query Store data available. Query Store may not be enabled on target databases."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "query_store") + ?? McpHelpers.Status("unavailable", "No Query Store data available. Query Store may not be enabled on target databases."); } var result = rows.Select(r => new @@ -368,9 +371,10 @@ private static async Task EmptyRegressionsAsync( if (!hasBaseline && !hasRecent) { - return McpHelpers.Status( - "unavailable", - $"No Query Store data has EVER been collected for {serverName}, so this is NOT a report of zero regressions — there is nothing to compare. Query Store may be OFF on this server's databases, which get_query_store_health will say; otherwise check that collection is running for this server."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, serverId, serverName, "query_store") + ?? McpHelpers.Status( + "unavailable", + $"No Query Store data has EVER been collected for {serverName}, so this is NOT a report of zero regressions — there is nothing to compare. Query Store may be OFF on this server's databases, which get_query_store_health will say; otherwise check that collection is running for this server."); } if (!hasBaseline) @@ -541,9 +545,10 @@ private static async Task EmptyHeatmapAsync( if (!hasAny) { - return McpHelpers.Status( - "unavailable", - $"No query stats have EVER been collected for {serverName}, so this is NOT a report of a quiet server — there is nothing to draw. query_stats is a PERIODIC table rather than an edge table: the collector writes rows every cycle for whatever is in the plan cache, so an empty history means nobody looked. Check get_collection_health for this server."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, serverId, serverName, "query_stats") + ?? McpHelpers.Status( + "unavailable", + $"No query stats have EVER been collected for {serverName}, so this is NOT a report of a quiet server — there is nothing to draw. query_stats is a PERIODIC table rather than an edge table: the collector writes rows every cycle for whatever is in the plan cache, so an empty history means nobody looked. Check get_collection_health for this server."); } if (!hasInWindow) @@ -591,6 +596,12 @@ public static async Task GetQueryDurationTrend( /* Same two states again, same words as Darling's twin. The probe reads v_query_stats because THIS trend does; Darling's probes the base table because ITS trend does. Each probe follows its own read — what the caller sees is one sentence, not two. */ + var gated = await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "query_stats"); + if (gated != null) + { + return gated; + } + return await dataService.HasAnyQueryStatAsync(resolved.ServerId) ? McpHelpers.Status( "empty", @@ -629,6 +640,12 @@ public static async Task GetProcedureDurationTrend( var points = await dataService.GetProcedureDurationTrendAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (points.Count == 0) { + var gated = await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "procedure_stats"); + if (gated != null) + { + return gated; + } + return await EmptyTrendAsync( dataService.HasAnyProcedureStatAsync(resolved.ServerId), resolved.ServerName, hours_back, "stored-procedure", @@ -667,6 +684,12 @@ public static async Task GetQueryStoreDurationTrend( every database on the instance. A server with no Query Store data is not a server with no slow queries, so the message names that cause first. */ + var gated = await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "query_store"); + if (gated != null) + { + return gated; + } + return await EmptyTrendAsync( dataService.HasAnyQueryStoreStatAsync(resolved.ServerId), resolved.ServerName, hours_back, "Query Store", @@ -743,7 +766,8 @@ public static async Task GetQueryTrend( var rows = await dataService.GetQueryStatsHistoryAsync(resolved.ServerId, database_name, query_hash, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) { - return McpHelpers.Status("empty", $"No history found for query_hash '{query_hash}' in database '{database_name}' within the last {hours_back} hours."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "query_stats") + ?? McpHelpers.Status("empty", $"No history found for query_hash '{query_hash}' in database '{database_name}' within the last {hours_back} hours."); } var result = rows.Select(r => new diff --git a/Lite/Mcp/McpServerInfoTools.cs b/Lite/Mcp/McpServerInfoTools.cs index 8f171983a9..2832c992ce 100644 --- a/Lite/Mcp/McpServerInfoTools.cs +++ b/Lite/Mcp/McpServerInfoTools.cs @@ -22,7 +22,8 @@ public static async Task GetServerProperties( { var row = await dataService.GetLatestServerPropertiesAsync(resolved.ServerId); if (row == null) - return McpHelpers.Status("unavailable", "No server properties available. The properties collector may not have run yet."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "server_properties") + ?? McpHelpers.Status("unavailable", "No server properties available. The properties collector may not have run yet."); return JsonSerializer.Serialize(new { @@ -63,7 +64,8 @@ public static async Task GetDatabaseSizes( { var rows = await dataService.GetLatestDatabaseSizeStatsAsync(resolved.ServerId); if (rows.Count == 0) - return McpHelpers.Status("unavailable", "No database size data available. The size collector may not have run yet."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "database_size_stats") + ?? McpHelpers.Status("unavailable", "No database size data available. The size collector may not have run yet."); return JsonSerializer.Serialize(new { diff --git a/Lite/Mcp/McpSessionTools.cs b/Lite/Mcp/McpSessionTools.cs index 64fc2d8744..0557c26007 100644 --- a/Lite/Mcp/McpSessionTools.cs +++ b/Lite/Mcp/McpSessionTools.cs @@ -30,7 +30,8 @@ public static async Task GetActiveQueries( { var rows = await dataService.GetLatestQuerySnapshotsAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) - return McpHelpers.Status("empty", "No active query snapshots found in the requested time range."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "query_snapshots") + ?? McpHelpers.Status("empty", "No active query snapshots found in the requested time range."); IEnumerable filtered = rows; @@ -95,7 +96,8 @@ public static async Task GetSessionStats( { var rows = await dataService.GetLatestSessionStatsAsync(resolved.ServerId); if (rows.Count == 0) - return McpHelpers.Status("unavailable", "No session statistics available. The session collector may not have run yet."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "session_stats") + ?? McpHelpers.Status("unavailable", "No session statistics available. The session collector may not have run yet."); var totalConnections = rows.Sum(r => r.ConnectionCount); var totalRunning = rows.Sum(r => r.RunningCount); diff --git a/Lite/Mcp/McpTempDbTools.cs b/Lite/Mcp/McpTempDbTools.cs index 48b766d808..e65ec255c6 100644 --- a/Lite/Mcp/McpTempDbTools.cs +++ b/Lite/Mcp/McpTempDbTools.cs @@ -28,7 +28,8 @@ public static async Task GetTempDbTrend( var rows = await dataService.GetTempDbTrendAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) { - return McpHelpers.Status("unavailable", "No TempDB data available."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "tempdb_stats") + ?? McpHelpers.Status("unavailable", "No TempDB data available."); } var result = rows.Select(r => new diff --git a/Lite/Mcp/McpWaitTools.cs b/Lite/Mcp/McpWaitTools.cs index c8cf903522..076f7b861f 100644 --- a/Lite/Mcp/McpWaitTools.cs +++ b/Lite/Mcp/McpWaitTools.cs @@ -32,7 +32,8 @@ public static async Task GetWaitStats( var rows = await dataService.GetWaitStatsAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) { - return McpHelpers.Status("unavailable", "No wait stats data available for the specified time range."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "wait_stats") + ?? McpHelpers.Status("unavailable", "No wait stats data available for the specified time range."); } var result = rows.Take(limit).Select(r => new @@ -84,6 +85,12 @@ An empty list said nothing about which nothing this is. A server that collected wants somebody to look at collection, and widening will never fill it. Same words as Darling's twin. */ + var gated = await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "wait_stats"); + if (gated != null) + { + return gated; + } + return await dataService.HasAnyWaitStatAsync(resolved.ServerId) ? McpHelpers.Status( "empty", @@ -130,10 +137,11 @@ public static async Task GetWaitTrend( unknown here vs. nothing collected at all, and hand back the ones that do have data. */ var collected = await dataService.GetDistinctWaitTypesAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (collected.Count == 0) - return McpHelpers.Status( - "unavailable", - $"No trend data for wait type '{wait_type}'. No wait stats have been collected for this server in the last {hours_back}h yet " + - "(the collector may not have run, or delta wait stats need a second collection cycle)."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "wait_stats") + ?? McpHelpers.Status( + "unavailable", + $"No trend data for wait type '{wait_type}'. No wait stats have been collected for this server in the last {hours_back}h yet " + + "(the collector may not have run, or delta wait stats need a second collection cycle)."); return McpHelpers.Status( "not_collected", @@ -185,7 +193,8 @@ public static async Task GetWaitingTasks( var rows = await dataService.GetWaitingTasksAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) { - return McpHelpers.Status("empty", "No waiting tasks found."); + return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "waiting_tasks") + ?? McpHelpers.Status("empty", "No waiting tasks found."); } var result = rows.Take(limit).Select(r => new diff --git a/PerformanceMonitor.Collectors/CollectorEngineCapability.cs b/PerformanceMonitor.Collectors/CollectorEngineCapability.cs index fc457617bb..e087ac869a 100644 --- a/PerformanceMonitor.Collectors/CollectorEngineCapability.cs +++ b/PerformanceMonitor.Collectors/CollectorEngineCapability.cs @@ -110,6 +110,40 @@ public static class CollectorEngineCapability ["database_states"] = "the sys.databases state snapshot", ["ag_replica_states"] = "the Always On availability replica states", ["ag_database_replica_states"] = "the Always On per-database replica states", + + /* The collectors that run on EVERY SQL Server, and therefore had nothing to say while the + EDITION axis was the only one (#2532). On a PostgreSQL target every one of them is a + permanent gap, so the reads they serve now ask - and a read that asks without a noun phrase + here falls back to "the data this read is served from", which is true and tells an operator + nothing they did not already know. */ + ["cpu_utilization"] = "the SQL Server CPU utilization history", + ["wait_stats"] = "the sys.dm_os_wait_stats cumulative totals", + ["waiting_tasks"] = "the sys.dm_os_waiting_tasks samples", + ["memory_stats"] = "the instance memory snapshot - server memory, buffer pool and plan cache", + ["memory_clerks"] = "the sys.dm_os_memory_clerks consumer list", + ["memory_grant_stats"] = "the resource-semaphore and memory-grant snapshot", + ["file_io_stats"] = "the sys.dm_io_virtual_file_stats per-file I/O totals", + ["tempdb_stats"] = "the tempdb space-usage snapshot", + ["perfmon_stats"] = "the sys.dm_os_performance_counters values", + ["query_stats"] = "the sys.dm_exec_query_stats plan-cache history", + ["query_snapshots"] = "the sys.dm_exec_requests active-query snapshots", + ["procedure_stats"] = "the sys.dm_exec_procedure_stats history", + ["query_store"] = "the Query Store runtime statistics", + ["query_store_health"] = "the per-database Query Store configuration and health", + ["plan_cache_stats"] = "the plan-cache composition statistics", + ["plan_correction"] = "the sys.dm_db_tuning_recommendations automatic plan corrections", + ["pvs_stats"] = "the persistent version store statistics", + ["session_stats"] = "the connection and session snapshot", + ["blocked_process_report"] = "the blocked process report capture", + ["deadlocks"] = "the captured deadlock graphs", + ["latch_stats"] = "the sys.dm_os_latch_stats totals", + ["spinlock_stats"] = "the sys.dm_os_spinlock_stats totals", + ["long_query_completions"] = "the long-running query completion capture", + ["database_config"] = "the sys.databases per-database settings", + ["database_scoped_config"] = "the sys.database_scoped_configurations settings", + ["database_size_stats"] = "the per-database file size and space usage", + ["index_object_stats"] = "the per-index usage, size and contention statistics", + ["server_properties"] = "the SERVERPROPERTY instance properties", }; /// From f83c07c3b783a195d838c733718831a6895c6070 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:31:53 +0100 Subject: [PATCH 2/4] Flip the live pin that asserted the gap this PR closes EngineCapabilityMissLivePostgresTests deliberately pinned the LIMIT of #2530: get_database_config was not one of the reads #2511 wired, so on a PostgreSQL target it still reported "unavailable - the config collector may not have run yet". That line existed so the gap could not later be mistaken for a regression, and it did its job -- it is the one check that went red here. It now asserts the closed shape instead: get_database_config answers not_collected naming Aurora PostgreSQL and the collector, with the "may not have run yet" sentence gone. Kept as an explicit pair rather than deleted, because it is the read the issue quoted. Added beside it: get_wait_stats, from a family with nothing to do with configuration, whose collector is gated off on no engine edition at all -- so nothing but the kind axis could ever make it speak. A pin that only covered collectors #2511 had already wired could not tell this wiring apart from the twelve gates it started with. Co-Authored-By: Claude Opus 5 --- .../EngineCapabilityMissTests.cs | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/Darling/Darling.Tests/EngineCapabilityMissTests.cs b/Darling/Darling.Tests/EngineCapabilityMissTests.cs index 45e5b8aa22..d110185ab9 100644 --- a/Darling/Darling.Tests/EngineCapabilityMissTests.cs +++ b/Darling/Darling.Tests/EngineCapabilityMissTests.cs @@ -669,15 +669,28 @@ engine that has no extended events at all. */ Assert.Contains("server_config", auroraServerConfig, StringComparison.Ordinal); Assert.Contains("Aurora PostgreSQL", auroraServerConfig, StringComparison.Ordinal); - /* The LIMIT of this change, asserted rather than left to be discovered: only the reads #2511 - wired to the capability helper can answer on either axis. get_database_config is not one of - them — its collector runs on every SQL Server, so the edition axis gave it nothing to say and - it was never wired — and on a PostgreSQL target it therefore still reports the old - `unavailable`. The kind axis makes EVERY SQL Server read a permanent gap on a PostgreSQL - target, which is a much wider wiring job than the twelve gated collectors; it is filed rather - than smuggled in, and this line is what stops the gap being mistaken for a fix that regressed. */ - Assert.Equal("unavailable", DarlingMcpTestData.StatusOf( - await DarlingMcpConfigTools.GetDatabaseConfig(postgres, PostgresServerName))); + /* The limit this line used to assert is GONE (#2532). It read: only the reads #2511 wired to + the capability helper can answer on either axis, so get_database_config — whose collector runs + on every SQL Server, which gave the edition axis nothing to say — still reported the old + `unavailable` on a PostgreSQL target. The kind axis makes EVERY SQL Server read a permanent + gap there, and the wiring has now caught up, so the read that was the named example of the + gap is the one asserted here. + + Kept as an explicit pair rather than deleted: this is the read the issue quoted, and a pin + that only ever covered collectors #2511 had already wired could not tell the wiring apart + from the twelve gates it started with. */ + var auroraDatabaseConfig = await DarlingMcpConfigTools.GetDatabaseConfig(postgres, PostgresServerName); + Assert.Equal("not_collected", DarlingMcpTestData.StatusOf(auroraDatabaseConfig)); + Assert.Contains("database_config", auroraDatabaseConfig, StringComparison.Ordinal); + Assert.Contains("Aurora PostgreSQL", auroraDatabaseConfig, StringComparison.Ordinal); + Assert.DoesNotContain("may not have run yet", auroraDatabaseConfig, StringComparison.Ordinal); + + /* And a read from a family that has NOTHING to do with configuration, whose collector is gated + off on no engine edition at all — so nothing but the kind axis could ever make it speak. */ + var auroraWaitStats = await DarlingMcpDataTools.GetWaitStats(postgres, PostgresServerName); + Assert.Equal("not_collected", DarlingMcpTestData.StatusOf(auroraWaitStats)); + Assert.Contains("wait_stats", auroraWaitStats, StringComparison.Ordinal); + Assert.Contains("Aurora PostgreSQL", auroraWaitStats, StringComparison.Ordinal); /* ── Stock PostgreSQL: the same answer, named for the engine it actually is ── */ var stockFlags = await DarlingMcpConfigTools.GetTraceFlags(postgres, BoxServerName); From 814cd092088ac816468cc66ecba9a425a3b8ebbb Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:36:00 +0100 Subject: [PATCH 3/4] Tell an agent which read family answers on a PostgreSQL target The miss-vocabulary paragraph in both SKUs' instructions explained the permanent engine gap with one example -- Azure SQL Database -- which was the only shape that produced one when #2511 wrote it. After this PR a PostgreSQL target produces that answer from 72 reads, and the caller most likely to hit it is the one with no UI to fall back on: an agent asks a read by NAME, so the instructions are the only place it can learn that the get_pg_* family is what answers there. Pinned in McpMissMessageParityPinTests alongside the sentence it extends, because the paragraph lives twice and every shared sentence in this repo that was not pinned has eventually been reworded once. Co-Authored-By: Claude Opus 5 --- .../Mcp/DarlingMcpInstructions.cs | 2 +- Lite.Tests/McpMissMessageParityPinTests.cs | 5 +++++ Lite/Mcp/McpInstructions.cs | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs index 0a4e935973..74e5220a8a 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs @@ -66,7 +66,7 @@ public static string Build(DarlingPeerDirectory.Snapshot peers) ### Reading an empty result - When a read comes back with no data, the `status` word says WHICH kind of nothing it is, and the three are not interchangeable. `empty` is a true negative: we looked and there was nothing to find. `unavailable` means this server could have that data and does not have it right now, so collection health is worth a look. `not_collected` means this server does not collect that at all — and when the reason is the ENGINE, the gap is PERMANENT: the collector serving that read does not run on this server's engine (an Azure SQL Database has no system_health session, no default trace and no SQL Agent), so there is no session to start, no collector to enable, and nothing to check. The message names the engine and the collector. Do not send anyone to go and fix it. + When a read comes back with no data, the `status` word says WHICH kind of nothing it is, and the three are not interchangeable. `empty` is a true negative: we looked and there was nothing to find. `unavailable` means this server could have that data and does not have it right now, so collection health is worth a look. `not_collected` means this server does not collect that at all — and when the reason is the ENGINE, the gap is PERMANENT: the collector serving that read does not run on this server's engine (an Azure SQL Database has no system_health session, no default trace and no SQL Agent; a PostgreSQL target collects none of the SQL Server signals at all, and the `get_pg_*` reads are the ones that answer there), so there is no session to start, no collector to enable, and nothing to check. The message names the engine and the collector. Do not send anyone to go and fix it. ### Asking about a PAST window diff --git a/Lite.Tests/McpMissMessageParityPinTests.cs b/Lite.Tests/McpMissMessageParityPinTests.cs index 51c2695bf8..300284ed5d 100644 --- a/Lite.Tests/McpMissMessageParityPinTests.cs +++ b/Lite.Tests/McpMissMessageParityPinTests.cs @@ -82,6 +82,11 @@ quiet stretch. */ CollectorEngineCapability and is byte-identical by construction rather than by pinning; what lives twice, and therefore belongs here, is the paragraph that teaches a caller how to read it. */ "`not_collected` means this server does not collect that at all — and when the reason is the ENGINE, the gap is PERMANENT", + + /* The PostgreSQL half of the same paragraph (#2532). An agent over MCP has no tabs: it asks a + read by name, so the instructions are the only place it can be told which family answers on + this engine. */ + "a PostgreSQL target collects none of the SQL Server signals at all, and the `get_pg_*` reads are the ones that answer there", }; [Theory] diff --git a/Lite/Mcp/McpInstructions.cs b/Lite/Mcp/McpInstructions.cs index 1dc8be636d..dbb44ed450 100644 --- a/Lite/Mcp/McpInstructions.cs +++ b/Lite/Mcp/McpInstructions.cs @@ -34,7 +34,7 @@ You are connected to a SQL Server performance monitoring tool via Performance Mo ### Reading an empty result - When a read comes back with no data, the `status` word says WHICH kind of nothing it is, and the three are not interchangeable. `empty` is a true negative: we looked and there was nothing to find. `unavailable` means this server could have that data and does not have it right now, so collection health is worth a look. `not_collected` means this server does not collect that at all — and when the reason is the ENGINE, the gap is PERMANENT: the collector serving that read does not run on this server's engine (an Azure SQL Database has no system_health session, no default trace and no SQL Agent), so there is no session to start, no collector to enable, and nothing to check. The message names the engine and the collector. Do not send anyone to go and fix it. + When a read comes back with no data, the `status` word says WHICH kind of nothing it is, and the three are not interchangeable. `empty` is a true negative: we looked and there was nothing to find. `unavailable` means this server could have that data and does not have it right now, so collection health is worth a look. `not_collected` means this server does not collect that at all — and when the reason is the ENGINE, the gap is PERMANENT: the collector serving that read does not run on this server's engine (an Azure SQL Database has no system_health session, no default trace and no SQL Agent; a PostgreSQL target collects none of the SQL Server signals at all, and the `get_pg_*` reads are the ones that answer there), so there is no session to start, no collector to enable, and nothing to check. The message names the engine and the collector. Do not send anyone to go and fix it. ### Asking about a PAST window From 151434ae9952155385f15b40ce84a41a12ba5047 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:46:28 +0100 Subject: [PATCH 4/4] Ask the engine before the second probe, not after it Review finding: in six reads a SECOND store query ran ahead of the capability gate and was only consumed on the path the gate skips -- the capture-count denominator in get_blocking_trend and get_deadlock_trend, and the distinct-values probe in get_wait_trend and get_perfmon_trend, on both SKUs. The cost argument is the part worth recording. This is not a rare-path waste that amortises: on an engine with a permanent gap the branch is taken on EVERY call, forever, so the extra query repeats indefinitely for that server and tool. And the probe could never have told a gated engine anything -- there is nothing for it to find. Both queries are still on the miss path, so the ordering does not touch the property that matters: the DATA read has already come back empty before either runs. Asking the engine first makes the gated case one query instead of two, and leaves the ungated case -- every SQL Server target, which is all of them today -- byte-identical. Co-Authored-By: Claude Opus 5 --- .../Mcp/DarlingMcpBlockingTools.cs | 8 ++++---- .../Mcp/DarlingMcpDataTools.cs | 19 ++++++++++++++----- .../Mcp/DarlingMcpTrendTools.cs | 19 ++++++++++++++----- Lite/Mcp/McpBlockingTools.cs | 8 ++++---- Lite/Mcp/McpPerfmonTools.cs | 19 ++++++++++++++----- Lite/Mcp/McpWaitTools.cs | 19 ++++++++++++++----- 6 files changed, 64 insertions(+), 28 deletions(-) diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs index abdbe7ff18..2c96e7d458 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs @@ -368,14 +368,14 @@ means nothing at all is known about the window. The stored tables cannot tell th from collection_log, which records a SUCCESS with zero rows for a collector that ran and saw nothing. Probed only here, on the path that already found nothing. */ - var captures = await DarlingBlockingTrendReader.GetBlockingCaptureCountsAsync( - postgres, resolved.ServerId, start, now); var gated = await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "blocked_process_report"); if (gated != null) { return gated; } + var captures = await DarlingBlockingTrendReader.GetBlockingCaptureCountsAsync( + postgres, resolved.ServerId, start, now); return await EmptyTrend( "blocking", resolved.ServerName, hours_back, captures, () => DarlingBlockingTrendReader.HasAnyBlockingCollectorRunAsync(postgres, resolved.ServerId)); @@ -417,14 +417,14 @@ public static async Task GetDeadlockTrend( if (points.Count == 0) { /* Same two facts as the blocking trend above, same denominator, same reason. */ - var captures = await DarlingBlockingTrendReader.GetDeadlockCaptureCountsAsync( - postgres, resolved.ServerId, start, now); var gated = await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "deadlocks"); if (gated != null) { return gated; } + var captures = await DarlingBlockingTrendReader.GetDeadlockCaptureCountsAsync( + postgres, resolved.ServerId, start, now); return await EmptyTrend( /* SINGULAR: the subject lands in "No {subject} was recorded", and "no deadlocks was recorded" is not a sentence. It also reads correctly in the other two, diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs index 8f3a54a47e..e8efcb0973 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs @@ -224,15 +224,24 @@ public static async Task GetWaitTrend( var points = await DarlingDataReader.GetWaitTrendAsync(postgres, resolved.ServerId, wait_type, start, now); if (points.Count == 0) { + /* The engine question comes BEFORE the distinct-values probe, not after it. Both are on + the miss path, so either order keeps the property that matters — but a permanently gated + engine takes this branch on every call, forever, and the probe below could never tell it + anything. Asking first makes that case one query instead of two. */ + var gated = await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "wait_stats"); + if (gated != null) + { + return gated; + } + /* Distinguish "unknown wait type here" from "nothing collected at all", handing back the ones that do have data — Lite's get_wait_trend miss vocabulary. */ var collected = await DarlingDataReader.GetDistinctWaitTypesAsync(postgres, resolved.ServerId, start, now); if (collected.Count == 0) - return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "wait_stats") - ?? McpHelpers.Status( - "unavailable", - $"No trend data for wait type '{wait_type}'. No wait stats have been collected for this server in the last {hours_back}h yet " + - "(the collector may not have run, or delta wait stats need a second collection cycle)."); + return McpHelpers.Status( + "unavailable", + $"No trend data for wait type '{wait_type}'. No wait stats have been collected for this server in the last {hours_back}h yet " + + "(the collector may not have run, or delta wait stats need a second collection cycle)."); return McpHelpers.Status( "not_collected", diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs index 80f80059ab..fd24fbbcb8 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs @@ -133,6 +133,16 @@ public static async Task GetPerfmonTrend( var points = await DarlingTrendReader.GetPerfmonTrendAsync(postgres, resolved.ServerId, counter_name, start, now); if (points.Count == 0) { + /* The engine question comes BEFORE the distinct-counter probe, not after it. Both are on + the miss path, so either order keeps the property that matters — but a permanently gated + engine takes this branch on every call, forever, and neither the probe nor the PLE branch + below could tell it anything. Asking first makes that case one query instead of two. */ + var gated = await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "perfmon_stats"); + if (gated != null) + { + return gated; + } + /* No points can mean three different things to a caller. Distinguish them so an LLM doesn't read a bad counter name as "this metric looks fine" — Lite's get_perfmon_trend miss vocabulary. */ @@ -150,11 +160,10 @@ miss vocabulary. */ /* Nothing collected at all for this server in the window: the collector likely hasn't produced perfmon data yet (delta counters need two cycles). Not retrievable now. */ if (collected.Count == 0) - return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "perfmon_stats") - ?? McpHelpers.Status( - "unavailable", - $"No trend data for counter '{counter_name}'. No perfmon counters have been collected for this server in the last {hours_back}h yet " + - "(the collector may not have run, or delta counters need a second collection cycle)."); + return McpHelpers.Status( + "unavailable", + $"No trend data for counter '{counter_name}'. No perfmon counters have been collected for this server in the last {hours_back}h yet " + + "(the collector may not have run, or delta counters need a second collection cycle)."); /* Other counters exist but not this one: the name is almost certainly wrong. Hand back the collected names so the caller can correct it. */ diff --git a/Lite/Mcp/McpBlockingTools.cs b/Lite/Mcp/McpBlockingTools.cs index 766cd60b2b..cb2ea84641 100644 --- a/Lite/Mcp/McpBlockingTools.cs +++ b/Lite/Mcp/McpBlockingTools.cs @@ -275,14 +275,14 @@ means nothing at all is known about the window. The stored tables cannot tell th collection_log, which records a SUCCESS with zero rows for a collector that ran and saw nothing. Darling's twin makes the same distinction with the same words. */ - var captures = await dataService.GetBlockingCaptureCountsAsync( - resolved.ServerId, hours_back, asOfUtc: anchorEnd); var gated = await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "blocked_process_report"); if (gated != null) { return gated; } + var captures = await dataService.GetBlockingCaptureCountsAsync( + resolved.ServerId, hours_back, asOfUtc: anchorEnd); return await EmptyTrend( "blocking", resolved.ServerName, hours_back, captures, () => dataService.HasAnyBlockingCollectorRunAsync(resolved.ServerId)); @@ -326,14 +326,14 @@ public static async Task GetDeadlockTrend( if (points.Count == 0) { /* Same two facts as the blocking trend above, same denominator, same reason. */ - var captures = await dataService.GetDeadlockCaptureCountsAsync( - resolved.ServerId, hours_back, asOfUtc: windowEnd); var gated = await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "deadlocks"); if (gated != null) { return gated; } + var captures = await dataService.GetDeadlockCaptureCountsAsync( + resolved.ServerId, hours_back, asOfUtc: windowEnd); return await EmptyTrend( /* SINGULAR: the subject lands in "No {subject} was recorded", and "no deadlocks was recorded" is not a sentence. It also reads correctly in the other two, diff --git a/Lite/Mcp/McpPerfmonTools.cs b/Lite/Mcp/McpPerfmonTools.cs index 586824b086..37c5822a95 100644 --- a/Lite/Mcp/McpPerfmonTools.cs +++ b/Lite/Mcp/McpPerfmonTools.cs @@ -75,6 +75,16 @@ public static async Task GetPerfmonTrend( var points = await dataService.GetPerfmonTrendAsync(resolved.ServerId, counter_name, hours_back, asOfUtc: windowEnd); if (points.Count == 0) { + /* The engine question comes BEFORE the distinct-counter probe, not after it. Both are on + the miss path, so either order keeps the property that matters — but a permanently gated + engine takes this branch on every call, forever, and neither the probe nor the PLE branch + below could tell it anything. Asking first makes that case one query instead of two. */ + var gated = await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "perfmon_stats"); + if (gated != null) + { + return gated; + } + /* No points can mean three different things to a caller. Distinguish them so an LLM doesn't read a bad counter name as "this metric looks fine." */ var collected = await dataService.GetDistinctPerfmonCountersAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); @@ -91,11 +101,10 @@ public static async Task GetPerfmonTrend( /* Nothing collected at all for this server in the window: the collector likely hasn't produced perfmon data yet (delta counters need two cycles). Not retrievable now. */ if (collected.Count == 0) - return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "perfmon_stats") - ?? McpHelpers.Status( - "unavailable", - $"No trend data for counter '{counter_name}'. No perfmon counters have been collected for this server in the last {hours_back}h yet " + - "(the collector may not have run, or delta counters need a second collection cycle)."); + return McpHelpers.Status( + "unavailable", + $"No trend data for counter '{counter_name}'. No perfmon counters have been collected for this server in the last {hours_back}h yet " + + "(the collector may not have run, or delta counters need a second collection cycle)."); /* Other counters exist but not this one: the name is almost certainly wrong. Hand back the collected names so the caller can correct it. */ diff --git a/Lite/Mcp/McpWaitTools.cs b/Lite/Mcp/McpWaitTools.cs index 076f7b861f..6474ad7b1e 100644 --- a/Lite/Mcp/McpWaitTools.cs +++ b/Lite/Mcp/McpWaitTools.cs @@ -133,15 +133,24 @@ public static async Task GetWaitTrend( var points = await dataService.GetWaitStatsTrendAsync(resolved.ServerId, wait_type, hours_back, asOfUtc: windowEnd); if (points.Count == 0) { + /* The engine question comes BEFORE the distinct-values probe, not after it. Both are on + the miss path, so either order keeps the property that matters — but a permanently gated + engine takes this branch on every call, forever, and the probe below could never tell it + anything. Asking first makes that case one query instead of two. */ + var gated = await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "wait_stats"); + if (gated != null) + { + return gated; + } + /* Same shape as get_perfmon_trend: tell the caller whether the wait type is just unknown here vs. nothing collected at all, and hand back the ones that do have data. */ var collected = await dataService.GetDistinctWaitTypesAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (collected.Count == 0) - return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "wait_stats") - ?? McpHelpers.Status( - "unavailable", - $"No trend data for wait type '{wait_type}'. No wait stats have been collected for this server in the last {hours_back}h yet " + - "(the collector may not have run, or delta wait stats need a second collection cycle)."); + return McpHelpers.Status( + "unavailable", + $"No trend data for wait type '{wait_type}'. No wait stats have been collected for this server in the last {hours_back}h yet " + + "(the collector may not have run, or delta wait stats need a second collection cycle)."); return McpHelpers.Status( "not_collected",