Repository navigation
An MCP read checks a Query Store plan before anyone forces it (#5632) - #6065
Merged
Merged
Conversation
added 4 commits
October 18, 2026 04:56
…eep shown out of the cut-key census (#5632)
WebReadCancellationPinTests: the cancelled request now carries plan_id, so the new route reaches its store call instead of returning a missing-parameter envelope. WebDatabaseFilterBehaviourTests: the runtime census has ten identity reads, not nine.
erikdarlingdata
marked this pull request as ready for review
October 18, 2026 09:59
This was referenced Oct 18, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #5632.
Why
#5632 will let agents force Query Store plans. Real cases on Darling stores showed what a force needs checked first: statements with a #temp table failed when replayed, a force on a Parameter Sensitive Plan (PSP) variant got 0 executions, Query Store goes READ_ONLY, a target can stop running, and (#5630) the "best" plan of an
OPTION (RECOMPILE)statement or of a statement whose plans return very different row counts is the wrong plan to force. This step (A2) builds the one read that answers "may this plan be forced, and what should a person see first". Nothing calls it automatically yet.What changes
New Darling MCP read
get_plan_force_precheck(inputs:database_name,query_id,plan_id, optionalcompare_plan_id,server_name,hours_back,as_of). Store-only, read-only. Verdict isrefuseorno_refusal_found, never "safe". Output hasrefusals(stable code, one sentence, evidence),shown,unknown.Refusal codes: the force gate's own blocker names via
ForcePlanBotPolicy.Blockers(plusstate_unavailable,apc_enabled_for_database,apc_names_this_plan_as_regressed),recompile_hint,temp_table,query_store_read_only,not_run_recently(constantNotRunRecentlyAfter, 2 h),too_few_executions(floor taken fromForcePlanBotSettings.Default.MinReviewExecutions, 25, the same floor as PLAN_REGRESSION's per-plan group),psp_variant,output_differs(PlanInputComparison.OutputGrowthRatioandRowFloor).shown: both plans' executions, CPU and duration per execution, average rows, last execution, forced flag, failure count and reason, forcing type. It also holds the compiled values under thecompiled_valuekey (masked undermaskLiterals, withheld undermetadataOnly, never logged), andPlanInputComparison.JudgePlansas a fact (never a refusal). It lists the indexes the candidate plan names that are not in the newestindex_object_statscapture ("not in the latest capture", because the capture may not list every index, as the tool description says). It lists agent job and step names that mention forcing (name match only, no step command in the store).Reuse, not copy: the history tool's raw dedupe CTE and its interval-table FROM/WHERE are now shared constants (
HistoryDedupeCte,HistoryTableFromWhere), and its tier gate is a genericTryReadFromTableAsync<T>.HistorySqlandHistoryTableSqlare byte-identical to before.Registered: MCP host, web route and catalog entry,
WebReadReach(query_store),plain-text.jsMCP_TOOL_NAMES,DarlingMcpToolClassification,CatalogCensus(206 / 99 unique), the webIDENTITYread list (the read names one plan, like the history read). No schema change, no new collector read, nothing writes to a monitored server, no Lite twin.Gate target: when the query has a current PLAN_REGRESSION finding, the target is that finding's own (
FactRemediation.ExtractPlanRegressionTargetsoutput, persisted on the finding) withPlanIdset to the plan asked about. With no finding the target is built from the store rows: database, query, plan,BestPlanLastSeenUtc= the plan's last run. Defaults:ReplicaRolenull,ParameterSensitivityCoFiredfalse,RegressionFactor0, hashes and CPU per execution unset (display only). Because those two blockers cannot be answered without a finding,parameter_sensitivity_cofiredandsecondary_replica_evidenceare listed underunknownin that case.enablementIsCollectedis false (the read never asks the collector catalog), soapc_enablement_unknowncannot fire. See the follow-ups.Query Store state matches
get_query_store_health:actual_stateother than READ_WRITE refuses, with the decodedreadonly_reason. No row at or beforeas_ofisunknown.Test plan
PlanForcePrecheckLiveTests(8 pins) withVM_PG=1:Total: 8, Errors: 0, Failed: 0(first run, HEAD 88ce0ce).OPTION (RECOMPILE)->recompile_hint), and pattern 2 (about 26,000 vs 50 rows per call ->output_differs)unknown), and a gate blocker (already_forced)not_run_recently+too_few_executions,temp_table(a@tablevariable does not fire), andpsp_variant(siblings go tounknown)query_store_read_onlywith a READ_ONLY row, and a database with no health row isunknown, not a refusal?undermaskLiterals, absent undermetadataOnlyTotal: 8, Failed: 5(recompile, output_differs, run-stopped/thin, temp/PSP, read-only). The clean-candidate, gate-blocker and compiled-value pins do not depend on those checks by design.VM_PG=1, merged with dev:Total: 845, Errors: 0, Failed: 0(HEAD 598745a). Includes McpToolGuide, McpReadCommandTimeout, McpPayloadContractCensus, DatabaseNameParamTwin, EngineCapabilityMiss, WebRouteStatementCensus, DarlingWebEndpointsTests, StorageCommandTimeout, WebReadReach, WebDatabaseFilterReads, DarlingMcpWritePolicy, DarlingMcpInstructions, McpToolsListBudget, DarlingMcpHostTests, DarlingMcpToolsTests, QueryStoreHistory* and ForcePlan.verify-full.shatcdc715684.Darling.Tests Total: 34085, Errors: 0, Failed: 0, Skipped: 3922, Not Run: 1.Lite.Tests Total: 10703, Errors: 0, Failed: 0, Skipped: 5. The first run at598745af3failed 2 census pins that count every web read (WebReadCancellationPinTestsneededplan_idin its cancelled request, andWebDatabaseFilterBehaviourTestscounts ten identity reads now). The seat fixed both incdc715684.Source-text pins I updated:
WebDatabaseFilterReadsTests(identity reads nine -> ten) andMcpPayloadContractCensusTests(unknownrostered as a section name, not a severity band). To dodge the cut-key census, the answer is a dictionary, because the census reads an anonymous-objectshown =member as a page cut.Where the readers' rows come from in the running app:
query_store_stats/query_store_interval_wide(Query Store collectors),query_store_text,query_plan_dimviaquery_store_plan_map,query_store_health(hourly collector),index_object_stats(daily),job_history, andplan_correction/ findings for the gate.CHANGELOG entry
SECTION: Added
ENTRY: - An MCP read checks a Query Store plan before anyone forces it ([#5632]) -
get_plan_force_precheckruns the force gate and refuses a plan when its statement recompiles, uses a temporary table or has stopped running, when the plan is a Parameter Sensitive Plan variant, when Query Store is read-only, or when the two plans return very different row counts. It shows the compiled values, row counts and missing indexes a person needs before forcing. Nothing forces a plan yet.REF: [#5632]: #5632
Deferred and follow-ups
Deferred (in this PR but unpinned, or not built):
index_object_statsandjob_historyseeds.unknownsays so (psp_variant_siblings).LiteToolNameslist is untouched.Follow-ups proposed:
enablementIsCollectedfromCollectorCatalog.AppliesTo(PlanCorrectionCollector.Instance, target)soapc_enablement_unknowncan fire here as it does for the bot.docs/mcp-server.md(204) anddocs/editions.md(202) already trailed the code's 205 before this PR. They are not touched here.parameter_sensitivity_cofiredis listed twice underunknown. A stored interval with no CPU counts as 0 CPU in the shown CPU per execution. Neither changes a refusal.