Repository navigation
A runtime-precondition miss vocabulary, evaluated at read time (#2546) - #2557
Conversation
…2546) The miss vocabulary had three words and none of them fitted a setup step somebody can change: `empty` claims we looked and there was nothing, `unavailable` sends the reader to collection health where they find a collector that is running and doing its best, and `not_collected` is the PERMANENT answer and tells them to stop looking. The store already held every one of these facts. The runners classify a denied grant, a missing source object and a disabled feature out of the general ERROR bucket and write the remedy into collection_log.error_message - the SQLSTATE 42P01 case says CREATE EXTENSION in so many words - and the hourly query_store_health collector records actual_state per database. No read reported any of it. Two Query Store reads were guessing in prose ("Query Store may not be enabled on target databases"), which is equally true of a server where it IS enabled and the window simply reached past what the raw tier retains. CollectorRuntimePrecondition is the shared vocabulary: the `precondition` status word and the two message shapes, with the remedy text FRAMED rather than authored - a second copy of prose whose whole value is being accurate about one server's answer is the copy that drifts. The noun phrase comes from CollectorEngineCapability.CapturePathByCollector rather than a second table. DarlingRuntimePrecondition and McpRuntimePrecondition are the two store halves and hold no vocabulary of their own, so the SKUs are byte-identical here by construction rather than by pinning. Evaluated at READ time, not gate time, and that is the whole point. An AppliesTo gate is decided once when the connection is made, so it would go on reporting a precondition after somebody satisfied it - the operator does what the message asked and nothing changes. This re-derives on every call. No sweep change and no IL-guard change: the capability derivation is untouched and this asks a different question of the store, not a new question of the gates. Wired on five reads per SKU - get_deadlocks, get_blocked_process_xml, get_running_jobs, get_long_query_completions, get_query_store_top - plus Darling's get_pg_top_queries, always AFTER the engine-capability answer so a permanent gap still wins. Nothing in the type system enforces that order and a `??` chain is one cut-and-paste from reversing it, so a source scan pins it across both trees, along with SKU parity and a non-vacuity floor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| var off = states.Where(s => !IsReadOnly(s.ActualState)).ToList(); | ||
|
|
||
| /* READ_ONLY first when both shapes are present: it is the one somebody is most likely to have got | ||
| wrong, because the database WAS configured and stopped collecting on its own. Saying "Query Store | ||
| is off" about a database whose desired state is READ_WRITE would send the operator to re-run an | ||
| ALTER that is already in effect. */ | ||
| if (readOnly.Count > 0) | ||
| { | ||
| return $"Query Store is READ_ONLY and recording nothing new on {Describe(readOnly)} of " + | ||
| $"{serverName}{observed}, and no database this read covers is in READ_WRITE. A READ_ONLY " + | ||
| "Query Store still answers queries about what it already holds, which is why this looks " + | ||
| "like a quiet server rather than a stopped one. It is almost always the storage cap: " + | ||
| "check readonly_reason with get_query_store_health, raise MAX_STORAGE_SIZE_MB or clear " + | ||
| "space, then ALTER DATABASE [name] SET QUERY_STORE (OPERATION_MODE = READ_WRITE). " + | ||
| PreconditionEpilogue; | ||
| } | ||
|
|
||
| return $"Query Store is not enabled on {Describe(off)} of {serverName}{observed}, so there is nothing " + | ||
| "for this read to return and no amount of waiting will change that. Turn it on with ALTER " + | ||
| "DATABASE [name] SET QUERY_STORE = ON (OPERATION_MODE = READ_WRITE), and the query_store " + | ||
| "collector starts storing runtime statistics on its next cycle. " + | ||
| PreconditionEpilogue; |
There was a problem hiding this comment.
actual_state = 'ERROR' gets the "not enabled, turn it on" message — which is wrong for that state.
IsCollecting (line 227) deliberately excludes "ERROR" from "collecting" — correct, since Query Store isn't actively recording in that state. But the bucketing right after only splits READ_ONLY out; everything else (OFF, ERROR, null/unrecognised-but-falsy) lands in off and gets:
"Query Store is not enabled on … so there is nothing for this read to return … Turn it on with ALTER DATABASE [name] SET QUERY_STORE = ON …"
ERROR is a documented actual_state_desc value distinct from OFF (per sys.database_query_store_options) — it means Query Store was configured (desired_state is typically still READ_WRITE) but hit an internal error and stopped recording, usually needing investigation/repair (or a disable+re-enable cycle), not a bare SET QUERY_STORE = ON (which the database may already have in effect). Telling an operator to "turn it on" when it's already on and broken sends them down the wrong path, and re-running the suggested ALTER may silently no-op.
The READ_ONLY case got its own branch specifically to avoid this class of mistake (see the comment right above it: "Saying 'Query Store is off' about a database whose desired state is READ_WRITE would send the operator to re-run an ALTER that is already in effect") — ERROR deserves the same treatment but currently falls through to the OFF wording instead. There's also no test coverage for actual_state = "ERROR" in QueryStoreDisabledMessage/QueryStoreCollectingOrUnknown_MakesNoPreconditionClaim (only collection_log's unrelated status = "ERROR" is pinned).
|
Reviewed the diff (C# only — no T-SQL touched). Overall this is well-structured: the vocabulary/store separation keeps the two SKUs byte-identical by construction, the capability→precondition→miss ordering is pinned by a source-scan test, and parity between Lite and Darling looks solid (shared wiring test, bumped pin count, matching instructions text). Verified the One correctness issue found in No security, SQL-injection, or performance concerns — all new queries are parameterized and read from existing collected data (no new probes against monitored servers). |
claude[bot] caught that `actual_state = 'ERROR'` fell through to the OFF wording. IsCollecting already excluded it correctly, but the bucketing right after only split READ_ONLY out, so an ERROR database was told "Query Store is not enabled ... turn it on with ALTER DATABASE SET QUERY_STORE = ON" - which is exactly the mistake the READ_ONLY branch exists to avoid, one state over. ERROR means Query Store WAS configured and stopped after an internal failure, so desired_state is typically still READ_WRITE, the suggested ALTER is already in effect, and re-running it silently no-ops while the actual repair goes unmentioned. Worse, with a mixed snapshot the sentence named the wrong database: the ERROR row was swept into the `off` bucket, so a scope holding BrokenDb (ERROR) and OffDb (OFF) produced "Query Store is not enabled on the database this read covers (OffDb)" and dropped the broken one entirely. There are three not-collecting states and each has its own remedy, so each now gets its own sentence, ordered by how badly the OFF wording would mislead. ERROR points at sys.sp_query_store_consistency_check first, then SET QUERY_STORE CLEAR or an OFF/ON cycle, and says both discard the data already held. Two tests, both proven red against the previous code: the ERROR sentence itself (including that it must NOT say "Turn it on with ALTER"), and that ERROR outranks READ_ONLY when a scope holds both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #2546 — no, deliberately does not close it: see "What I did not wire" below.
The distinction, and why it is not a fourth capability axis
Engine edition (#2511) and engine kind (#2536) are properties of the target, fixed for the life of a connection, and both answer "this collector can never run here". That fixedness is what makes the sweep-and-derive machinery sound.
A precondition is neither. It is mutable — somebody can start the session, issue the GRANT or create the extension while the service is running — and it is actionable, where the other two are final. A gate decided at connect time would go on reporting a precondition after somebody satisfied it: the operator does exactly what the message asked for and nothing changes, with no way to tell why. That is the worst direction to be wrong in.
So this is answered at read time, from what collection already recorded, and re-derived on every call.
The defect it closes: the store already knew, and no read reported it
This turned out not to be speculative machinery for PostgreSQL. Every fact needed is already collected, already classified, and already carries its own remedy — it just had nowhere to go:
DarlingWorker.PostgresFaultOutcomesorts a denied grant, a missing source object and a disabled feature out of the general ERROR bucket and writes the actionable sentence intocollection_log.error_message. The SQLSTATE 42P01 case saysCREATE EXTENSION pg_stat_statementsin so many words.SqlServerPermissionErrorspredicate, on both SKUs.SESSION_MISSINGexists as a distinct status for a capture session that is gone.query_store_healthcollector has recordedactual_stateper database all along.Meanwhile two Query Store reads were literally guessing in prose — "Query Store may not be enabled on target databases" — which is equally true of a server where it is enabled and the window simply reached past what the raw tier retains. That is the "captured data that no read reports" shape.
What is in this PR
The vocabulary —
PerformanceMonitor.Collectors/CollectorRuntimePrecondition.cs: thepreconditionstatus word and two message shapes. The remedy text is framed, not authored — a second copy of prose whose whole value is being accurate about one server's answer is the copy that drifts, so the message quotes what the server said. The noun phrase is borrowed fromCollectorEngineCapability.CapturePathByCollectorrather than restated in a second table.The two store halves —
DarlingRuntimePrecondition(Npgsql) andMcpRuntimePrecondition+LocalDataService.RuntimePrecondition(DuckDB). Neither holds any vocabulary, so the SKUs are byte-identical here by construction rather than by pinning. A failed diagnostic read answersnull, for the same reason the capability probe does.Six call sites, five of them on both SKUs, always in the order capability → precondition → the read's own miss:
get_deadlocksdeadlocksget_blocked_process_xmlblocked_process_reportget_running_jobsrunning_jobsget_long_query_completionslong_query_completionsget_query_store_topquery_store+ the health snapshotget_pg_top_queries(Darling only)pg_statement_statsCREATE EXTENSION, quotedInvariants respected
CollectorEngineCapabilityis read from, never modified. This asks a different question of the store, not a new question of the gates.??chain is one cut-and-paste from reversing it, so a source scan pins it.What I did not wire, and why
system_healthstopped. The issue names it, and it is not detectable from the store today.SystemHealthEventsCollectorjoinssys.dm_xe_session_targetstosys.dm_xe_sessions; a stopped session produces zero rows and the collector logsSUCCESS. It never goes throughRunXeTolerantAsync, so it cannot raiseSESSION_MISSING. The three captures that do classify it —deadlocks,blocked_process_report,long_query_completions— are wired instead. Making the system_health case reportable needs the collector to record session state, which is its own change.HasMsdbAccessas a gate. See below.SESSION_MISSINGstatus. Its runner records a failed XE-session ensure asPERMISSIONS(a denial) orERROR(anything else), so theSESSION_MISSINGbranch is wired and correct but currently silent on that SKU. Wired anyway: the day Lite classifies it, every read starts answering without another edit, and wiring only the statuses a SKU writes today is how the two drift apart.The
HasMsdbAccessdeterminationChecked first, as asked. The framing in #2546 is right about the fact and slightly off about where the wrongness lives.
HasMsdbAccesscomes fromHAS_DBACCESS(N'msdb'), probed once inDarlingServerConnector.ConnectAsync, and it is a GRANT somebody can change without a restart — so on both of the properties #2546 uses to separate the axes, it belongs in this vocabulary and not on the capability axis.But it is not currently reported on the capability axis either.
TargetsWithEngineEditiondeliberately varies it, soAFixableGate_IsNotReportedAsAnEngineGapalready assertsjob_history/running_jobs/agent_statusare not engine gaps on a box edition. The capability answer is correct. The wrongness is two other things:get_running_jobsansweredempty— "No running SQL Agent jobs found" — which is an affirmative claim about the server's Agent, not "we cannot see it". Fixed here for the case whereHAS_DBACCESSreturns 1 but the job tables deny: the collector runs, is refused, and records the denial, which this read now surfaces.ServerRuntimeis cached for the life of the connection. It is dropped only on config change, removal, or a connect/collection failure — so on a healthy server the msdb probe is frozen until the service restarts, and a GRANT genuinely does not take effect. Not fixed here, because the only two honest fixes are both a different lane:HasMsdbAccessfrom the threeAppliesTogates and let the collectors run and fail intoPERMISSIONS. That fixes it completely and needs no sweep change — but it re-introduces the wasted per-cycle query that gating it off deliberately removed, and it is a change to collection dispatch, not to a read vocabulary. Your call, and worth its own issue.has_msdb_accessontoservers(a migration rung + Lite twin + viewer probe) — which would let the read state it positively, but the value would still be connect-frozen, so we would be shipping advice that does not take. Worse than not shipping it.So: the determination is yes, it belongs in this vocabulary, this issue is the right home for something the SQL Server side already gets slightly wrong, and half of it is fixed here. The remaining half is a gate decision I have deliberately surfaced rather than taken.
Verification
Darling.TestsandLite.Testsarenet10.0-windowsand cannot run on macOS, so pure logic and the source scans were verified against the shipped build in a throwawaynet10.0harness — 63 checks, all passing — and every guard was proven red first:McpJobToolsget_running_jobs: precondition asked BEFORE capabilityquery_storewiringLite wires get_query_store_topany READ_WRITE in scope makes no claim,an unrecognised state makes no claimOffDbwhile dropping the broken database entirelyAlso added, running in CI:
RuntimePreconditionMissLivePostgresTests— end to end against a live store, both directions, including the property a gate cannot give: the same read on the same server stops saying it the moment collection records a healthy run.McpMissMessageParityPinTests, floor bumped 9 → 11.Full solution builds clean on macOS with no new warnings.
🤖 Generated with Claude Code
Review
claude[bot]found one real defect:actual_state = 'ERROR'fell through to the OFF wording, so a database whose Query Store was configured and then broke got "turn it on" — an ALTER already in effect that silently no-ops — and in a mixed scope the sentence named the OFF database while dropping the broken one entirely. Fixed in 1ec0737: three not-collecting states, three remedies, ordered by how badly the OFF wording would mislead. Two tests, proven red first.