Repository navigation
Wire the engine-capability question into every read that can answer it (#2532) - #2537
Conversation
#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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
ReviewReviewed this against Verification performed:
Parity: Every collector name used on the Darling side has a byte-identical counterpart on the Lite side for the same tool (verified via the full Correctness: The miss-path-only placement ( Nothing correctness-, security-, or parity-affecting found. One truly minor, non-blocking observation: in a handful of trend handlers (e.g. |
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 <noreply@anthropic.com>
| 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; | ||
| } |
There was a problem hiding this comment.
Minor efficiency nit, not specific to this call site — it's the same shape in ~8 trend tools in both Darling and Lite (GetDeadlockTrend, GetWaitTrend/get_wait_trend's miss branch, GetMemoryTrend, GetFileIoTrend, GetQueryDurationTrend, GetProcedureDurationTrend, GetQueryStoreDurationTrend, and their Lite twins).
captures/points is fetched before the new NotCollectedStatusAsync gate check, but is only used in the EmptyTrend(...) call that's skipped when gated != null. For an engine with a permanent capability gap (e.g. any SQL-Server-only trend tool called against a PostgreSQL target), this branch is hit on every single call, forever — not a one-off miss — so the wasted capture-count query repeats indefinitely for that server/tool combination rather than being a rare-path cost.
Would it make sense to run the gate check first and only fetch captures when it comes back null? That would turn the permanently-gated case into one query instead of two, without changing behavior for the ungated case (SQL Server targets, which is the common case today).
ReviewReviewed the diff against Parity check (mechanical, not just eyeballed): extracted every Dictionary/call-site correctness: the 28 new Signature change: Lite's One minor finding, posted inline on Nothing else stood out — no SQL injection surface (no new dynamic SQL), no secrets, and the miss-vocabulary instructions text update is byte-identical between |
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 <noreply@anthropic.com>
|
Right, and the part of that worth recording is the cost argument rather than the query count: 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/tool pair — and the probe could never have told a gated engine anything, because there is nothing for it to find. Fixed in 151434a across six reads on both SKUs:
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. The ungated case (every SQL Server target, which is all of them today) is byte-identical. Checked the rest of the list and the other five are already in the right order: |
|
Reviewed against CONTRIBUTING.md conventions, Lite/Darling parity, correctness, security, and performance. This is a C#-only change (no T-SQL touched), so the T-SQL style rules don't apply here. No issues found. Specifically checked:
Nice work — this is a large, mechanically repetitive change and it's internally consistent throughout. |
#2536 landed the PostgreSQL half of CapturePathByCollector while this branch added the SQL Server half, so the conflict is two sets of ADDITIONS to one table rather than two versions of anything. Kept both. Taking a side would have dropped one engine's noun phrases silently, and the reads that lost theirs do not fail -- they fall back to "the data this read is served from", which is true, explains nothing, and looks deliberate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Reviewed the diff (466/-155 across 41 files), the PR description, Lite/Darling parity — verified, not just trusted. I extracted every
Gate-before-probe reordering — spot-checked
No correctness, security, or performance issues found. This is a source-scan/derivation-tested change (the pin tests read the actual MCP source rather than a hand-maintained list), which is exactly the right shape for keeping 70+ call sites honest across two SKUs, and my independent extraction corroborates that it held here. |
The user-visible half of #2532. The other half — the PostgreSQL
AppliesToaxis and the IL guard it needed first — is #2536, also againstdev. Neither depends on the other; see Merge order at the bottom.What was wrong
#2511 wired the capability helper into the reads whose collectors are gated off on Azure SQL Database — twelve collectors' worth. That was 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, not just twelve. So
get_database_config,get_wait_stats,get_top_queries_by_cpuand the rest still answered…about a collector that does not run on that engine at all. True, and pointing at the wrong cause.
EngineCapabilityMissLivePostgresTests.APostgresTarget_AnswersNotCollectedNamingTheEngine_WhileAnUnprobedServerKeepsItsMissasserts that behaviour explicitly today, which is how the gap stayed visible rather than becoming folklore.Once #2530's UI waves land a human pointed at a PostgreSQL target sees PostgreSQL tabs and never asks a SQL Server read. But an agent over MCP has no tabs — it asks by name — and agents are half the product's surface.
What it does now
72 Darling reads and 71 Lite reads ask the question, up from 17 on each. Every call site sits after the read came back empty — never before it — so a server whose registry row says one engine while its collected rows say another still gets its DATA rather than a confident explanation of why it cannot have any. That is the property
DarlingEngineCapability's "called only on the miss path" note exists to protect, and it is why #2532's Option 3 (answer at the resolver) was not taken: it is cheaper and it is the one shape that can hand a mis-registered server an explanation instead of its rows.CapturePathByCollectorgains 28 noun phrases, so the sentence names a surface — "the sys.dm_os_wait_stats cumulative totals", "the sys.dm_io_virtual_file_stats per-file I/O totals" — instead of falling back to "the data this read is served from".What I deliberately did NOT wire, and why
#2532 warned against blanket-wiring 83 reads without thinking, and four groups genuinely have a better answer than
not_collectedor no answer to give:analyze_server,get_analysis_facts,compare_analysis,get_daily_summary,get_daily_summary_range,get_server_summaryget_collection_health,get_collection_logcollection_log, which a PostgreSQL target genuinely populates. Their existing messages are about collection, and on an Aurora target they are correct. Wiring these would have been the same mistake in the other direction.list_servers,get_fleet_overview,get_store_metrics,add_servers,remove_server, the six alert tools, the eight custom-view toolsget_alert_historytakes an optional server and is fleet-wide by default;get_ag_healthalready handles that shape correctly and is the model if these ever need it.analyze_plan_xmlget_wait_trend/get_perfmon_trend's "not a wait type / counter this server collects" branchesunavailablebranches — "nothing collected at all" — are wired.get_pg_*readsget_blocked_process_reports(Lite-only) andget_blocking(Darling-only) are each wired on their own SKU; they are not compared by the parity pin because the pin only looks at tools both surfaces expose.One signature change
Lite's
analyze_query_store_planfetches the plan live from the monitored instance rather than from the store, so it was the one plan tool with noLocalDataServiceto ask. Its three siblings all take one. I added it — DI-injected, no user-facing parameter — because the capability question is the same question andBothSkus_WireTheSameReadsToTheSameCollectorsis right to insist the two SKUs answer it identically. On Lite it is inert today (engineKindis still passed asnull, which I re-checked rather than assumed), and it cannot become wrong.Two pins changed meaning, both still derived
EveryWiredRead_NamesARealCollectorThatIsGatedOffOnAzureSqlDb→…NamesARealCollectorWhoseCapabilityBranchCanFire. This is weaker and the doc comment says so. The Azure-specific claim was right while the edition axis was the only one; the kind axis makes every SQL Server collector a permanent gap on a PostgreSQL target, so demanding an Azure gap would refuse the wiring this issue is about. What survives is the property that matters at a call site — the branch is reachable on some (kind, edition) pair the store can hold — and it is derived by askingNotCollectedMessage, not by a list.EveryCapturePathEntry_NamesARealCollectorThatIsActuallyGatedSomewherenow requires an entry to be gated by its ownAppliesTosomewhere or named by a wired read. Two derived arms, no hand list, and it catches dead prose in both directions: an entry for a collector nothing gates and nothing reads now fails.AllNineHealthParserReads_AskTheQuestionandBothSkus_WireTheSameReadsToTheSameCollectorsare untouched and both still hold.Verification
The suites are
net10.0-windowsand cannot run on macOS, so CI is the arbiter — but the wiring is a source-scan property, so all of it was run locally in a throwawaynet10.0harness against the shipped MCP trees and the real builtPerformanceMonitor.Collectors, using the same regexes the pins use:CollectorCatalog, has aCapturePathByCollectorentry, and has a reachable capability branch;wait_statson a SQL Server box is still silent, and so is any collector when the kind is unknown (a pre-PostgreSQL targets have no UI on either SKU, and both UIs render SQL Server tabs at them #2530 row, or Lite).Proven red: dropping one collector's
CapturePathByCollectorentry fails the wiring pin naming the tool and the collector; pointing a wired read at a name the catalog does not carry fails the first assertion. Both were run.Both SKUs and both test projects build clean on macOS with
EnableWindowsTargeting.Merge order
#2536 and this PR both edit
CollectorEngineCapability.CapturePathByCollector(adjacent entries) andEngineCapabilityMissTests.cs. The shared hunks in the test file are byte-identical in the two branches on purpose — the wiring-pin rewrite and the capture-path rewrite are the same edit on both sides, so they merge without a conflict whichever lands first. The only region this PR touches alone is the live-Postgres test, which #2536 leaves untouched. Just the dictionary needs the second PR to carry both sets of entries, which is a one-hunk rebase. Neither PR depends on the other's behaviour.The pin that caught the one real defect
EngineCapabilityMissLivePostgresTestsdeliberately pinned the LIMIT this PR removes — thatget_database_configstill answeredunavailableon a PostgreSQL target — so the gap could not later be mistaken for a regression. It is the one check that went red here, which is the pin doing exactly what it was written for. It now asserts the closed shape, plusget_wait_statsbeside it: a read 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.Closes nothing on its own. #2530 stays open for the UI waves; #2532 stays open for the rollup residue named above.
🤖 Generated with Claude Code