Skip to content

Serve Query Store regressions (#2484) - #2507

Merged
erikdarlingdata merged 4 commits into
devfrom
feat/2484-query-store-regressions
Aug 22, 2026
Merged

Serve Query Store regressions (#2484)#2507
erikdarlingdata merged 4 commits into
devfrom
feat/2484-query-store-regressions

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Sixth item on #2484, and the one the issue called "the only #2477 tab that is entirely unreachable rather than reduced".

What the viewer could see that nothing else could

Every other Query Store read answers "what is expensive". This answers "what got WORSE" — and the second is not derivable from the first. The costliest query on a server is usually the one that has always been the costliest; the one that changed last Tuesday sits well down that list.

Each (database, query_id) group's averages inside the recent window are compared against its baseline — every capture collected BEFORE that window — giving baseline vs recent duration / CPU / logical reads with a regression percent for each, the plan counts on both sides, a duration-driven severity band, and additional_duration_ms, which is both the ranking key and the number that says whether the regression matters at all: a 5 ms regression executed a million times outranks a 5-second one executed twice.

The SQL is the viewer's — which is the Dashboard's report.query_store_regressions TVF — copied rather than re-derived. The gate (average CPU worse by more than 25%), the ranking, and the severity bands decide which queries a user is shown, and a browser that disagreed with the desktop viewer about that would be worse than one that showed nothing.

The dedup is correctness here, not performance

Query Store rows are cumulative per-interval snapshots and the collector re-fetches an open interval every cycle. This read is the most exposed of any to that: the baseline arm is UNBOUNDED (potentially months) while the recent arm is a short window, so the two sides being compared have systematically different re-collection density per interval. Un-deduped, that alone moves the averages the percent is computed from and the 25% gate — manufacturing and hiding regressions for reasons that have nothing to do with the query. Both arms dedup on the full interval identity, and there is a pin asserting the count is 2, not 1.

What the empty branches say

Zero rows is four states, and only one is good news. One probe, two booleans, run only on the empty path, against the same table the read uses.

Situation status message
Nothing ever collected unavailable "No Query Store data has EVER been collected … Query Store may be OFF on this server's databases …"
Recent rows, no baseline unavailable "Every Query Store capture … falls INSIDE the last N hour(s), so there is no baselineNOT a clean bill of health. Shorten hours_back …"
Baseline, nothing in the window empty "… history from before this window but nothing collected IN it … Widen hours_back, or check get_collection_health — a collector that stopped looks exactly like this."
Both sides, nothing regressed empty "No query … regressed … this IS the all-clear for this read."

The second row is why the branching earns its code. A server whose entire history sits inside the window has no BEFORE, so it cannot show a regression however badly it regressed — and "no regressions" there is a confident wrong answer, not a missing one. Its advice also runs the opposite way from every other read in this series: widening the window makes the baseline shorter, so it says shorten, and both suites pin that it never says "Widen".

Deliberate differences from the viewer

  • The row cap is a bound parameter (LIMIT $5, default 50 — the viewer's hardcoded number) so a caller can ask for fewer or more. The gate and ranking are untouched, so the first 50 rows of any call are the viewer's 50. Validated with McpHelpers.ValidateTop, which refuses out of range; truncation is observed by over-fetching one, not inferred from the count.
  • Lite's text sample comes from the fact rows only. Darling resolves it from collect.query_store_text ([BUG] 3.4.0 query_store collector runs 37–100 min on Azure SQL DB (3.3.0 median: 4.8 s) — starves all other collectors #2150) and falls back to the fact rows; that table is a Darling-store construct with no Lite equivalent, so Lite has only the fallback. Same text, one hop closer — a difference in where the text comes from, not in which rows the read returns.

SKU parity

Ported to Lite in the same change rather than added to the divergence ratchet. Census 105 → 106 tools, 80 → 81 shared, Darling-unique unchanged.

New tool class + reader live in their own files rather than being appended to DarlingMcpDataTools / DarlingDataReader, which several changes are touching concurrently.

How I verified

  • All six projects build clean on macOS; node --check clean; census re-derived with the same scan the pin uses (106 / 81 / 25), recounted against the current dev rather than the branch point.
  • Ungated pins: both arms dedup (count is 2, on the full interval identity, with the same ORDER BY); baseline < $2 and recent >= $2 share one boundary so nothing is double-counted or dropped; the TVF gate, ORDER BY additional_duration_ms DESC, the four severity bands and all seven NULLIF guards survive; the probe reads the same base table for both halves in one round trip; the collector DDL actually has every column the read projects.
  • Live round-trip (DARLING_TEST_PG) walks all four empty branches, asserts the four messages are four distinct sentences, then seeds a real regression: 1 ms → 4 ms with the baseline interval re-collected at a higher cumulative count, and asserts baseline_exec_count == 90 (the deduped final snapshot) rather than 140 (the sum). That single number is the dedup, and it is what the whole read leans on. Also asserts CRITICAL banding, +300%, additional_duration_ms == 600, the refused cap, and that a result exactly at the cap is not reported truncated.
  • Lite's half is pinned at the TOOL level with the same assertions, seeded under the derived server id.
  • The web panel goes through table(...), not an object literal.

What I could NOT verify

Both test suites target net10.0-windows — built here, run in CI.

No live instance or real Query Store was read. The dedup and the interval arithmetic are verified against seeded rows, not against a real store with months of baseline at a 60-minute INTERVAL_LENGTH_MINUTES. In particular I have not measured what an unbounded baseline scan costs on a large store — the read inherits the viewer's shape, which has shipped, but the viewer runs it against one server on demand and an agent may not.

The query_store_text join is exercised only through its COALESCE fallback: the seeded rows carry query_text on the fact row, and no collect.query_store_text row is planted, so the first arm of that COALESCE is pinned in SQL but not executed.

Comment thread Lite/Mcp/McpQueryTools.cs
lookback: it is everything collected before the window, so a longer hours_back makes
the recent window bigger AND the baseline shorter.
*/
baseline_is = "every Query Store capture collected BEFORE the recent window",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lite/Darling parity drift: Darling's get_query_store_regressions response includes recent_window_start and recent_window_end (see DarlingMcpQueryStoreRegressionTools.cs, right after database_name):

recent_window_start = start.ToString("o"),
recent_window_end = end.ToString("o"),
baseline_is = "every Query Store capture collected BEFORE recent_window_start",

Lite's response drops both fields entirely — baseline_is here just says "the recent window" without giving its bounds anywhere in the payload. Since the PR's own "Deliberate differences from the viewer" section only calls out the row-cap parameter and the query-text-source fallback as intentional Lite/Darling differences, this looks like an unintentional gap rather than a deliberate one. A client (or an agent) switching between the two SKUs for the same tool would see a different JSON shape and lose the ability to state which literal timestamps the "recent" window covered on Lite. Worth adding recent_window_start/recent_window_end here (using the startTime/endTime from GetTimeRange above) to match Darling.

Comment thread Lite/Mcp/McpQueryTools.cs
resolved.ServerId, hours_back, limit + 1, databases);

if (rows.Count == 0)
return await EmptyRegressionsAsync(dataService, resolved.ServerId, resolved.ServerName, hours_back);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: EmptyRegressionsAsync is passed only hours_back, not the start/end already computed a few lines above for the main query. Inside it, GetQueryStoreRegressionCoverageAsync calls GetTimeRange(hoursBack, null, null) again, which does a fresh DateTime.UtcNow.AddHours(-hoursBack) / DateTime.UtcNow — a different (later) window boundary than the one the main query just ran with.

Darling's equivalent (DarlingMcpQueryStoreRegressionTools.EmptyAsync) avoids this by reusing the exact same start/end it already computed for the main read, so the coverage probe is guaranteed to describe the same window the empty result came from. Here the two calls could disagree by however long the DB round-trip took, so in a narrow race a row collected between the two calls could flip has_recent/has_baseline relative to what the (already-returned) empty main query saw. Given collectors run on minute-scale cadences this is unlikely to matter in practice, but it's a real deviation from the "one probe answers exactly what the read just saw" invariant the file-header comment describes, and from Darling's implementation.

@claude

claude Bot commented Aug 22, 2026

Copy link
Copy Markdown

Reviewed the diff (Darling Postgres reader/tools + Lite DuckDB service/tool + shared web panel, plus both test suites). Overall this is careful, well-tested work — the SQL is genuinely copied verbatim between Darling/Lite/the viewer's TVF (dedup partition keys, gate, ranking, severity bands all match), the four empty-state branches are distinct and correctly ordered, query_store_text is keyed PRIMARY KEY (server_id, database_name, query_id) so the LEFT JOIN in the Darling SQL can't fan out rows, and the BuildDbInClause/parameter-binding usage is the same safe, established, fully-parameterized pattern used throughout the rest of Lite — no SQL injection concerns anywhere in this diff.

Left two inline comments on the Lite side, both around Lite/Mcp/McpQueryTools.cs:

  1. Parity drift (real): Darling's JSON response includes recent_window_start/recent_window_end; Lite's does not. This isn't listed among the PR description's "Deliberate differences from the viewer" and looks like an oversight — a client switching SKUs gets a different response shape for the same tool.
  2. Minor: Lite's empty-branch coverage probe recomputes DateTime.UtcNow independently rather than reusing the start/end already computed for the main query (Darling reuses the same values it passed to the main read). Low practical impact given collector cadence, but a real deviation from the "one probe, same window" invariant described in the header comment.

Nothing else stood out as a correctness, security, or performance issue — OPTION(RECOMPILE) doesn't apply here since no T-SQL collector files are touched, and the GROUP BY/ORDER BY single-line style matches the rest of the C#-embedded SQL in both Lite/Services/*.cs and Darling's reader files (the CONTRIBUTING.md one-term-per-line example targets the install/*.sql T-SQL collectors, not these).

/// two booleans, run only on this path.</para>
/// </summary>
private static async Task<string> EmptyAsync(
NpgsqlDataSource postgres, string serverName, int serverId, DateTime start, DateTime end, int hours_back)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage probe ignores database_name, so the four-states diagnosis can be wrong for the exact case this feature exists to get right.

GetQueryStoreRegressionsAsync (line 60-61) is filtered to database_name when the caller supplies it, but EmptyAsync here — and DarlingQueryStoreRegressionReader.GetCoverageAsync/RegressionCoverageSql that it calls — never receive database_name at all. The coverage probe checks baseline/recent existence across the whole server, not the requested database.

Concrete failure: server has Prod with a full baseline and recent window with zero regressions, and Marketing, which has never had Query Store collected. A caller asks for get_query_store_regressions(database_name: "Marketing"). The main query correctly returns 0 rows for Marketing, but EmptyAsync sees hasBaseline=true, hasRecent=true (from Prod) and returns:

"No query on {server} regressed in the last N hour(s) ... this IS the all-clear for this read."

That's a confident wrong answer for Marketing — exactly the failure mode the PR description calls out as the reason this read exists ("a confident wrong answer rather than a missing one"). The same gap exists in the "never collected" and "no baseline" branches too, since none of them are scoped to the database either.

Fix: add the same $4::text[] IS NULL OR database_name = ANY($4) predicate to RegressionCoverageSql, and thread database_name through GetCoverageAsyncEmptyAsync (called from line 64). The Lite twin (Lite/Mcp/McpQueryTools.cs EmptyRegressionsAsync / LocalDataService.GetQueryStoreRegressionCoverageAsync) has the identical gap.

Untested today — none of the new tests pass database_name through the four-states walk, so this wouldn't be caught by the current suite.

Comment thread Lite/Mcp/McpQueryTools.cs
/// no BEFORE, so it can never show a regression however badly it regressed, and answering "no
/// regressions" there is a confident wrong answer rather than a missing one.</para>
/// </summary>
private static async Task<string> EmptyRegressionsAsync(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same gap as the Darling twin (DarlingMcpQueryStoreRegressionTools.EmptyAsync): database_name is filtered on the main read (line 295-297 above) but EmptyRegressionsAsyncLocalDataService.GetQueryStoreRegressionCoverageAsync checks baseline/recent coverage for the whole server, ignoring database_name. A caller who scopes to a database with no Query Store history at all — while other databases on the server have full baseline+recent coverage — gets the "all-clear" (or wrong "no baseline"/"never collected") message instead of the truth for the database they asked about. Needs the same database_name filter that BuildDbInClause already applies to the main query, threaded into the coverage SQL/method.

@claude

claude Bot commented Aug 22, 2026

Copy link
Copy Markdown

Review summary

Reviewed the new get_query_store_regressions read (Darling + Lite, plus the Darling viewer tab).

Correctness

  • Left one inline finding: the empty-result "four states" diagnosis (EmptyAsync in Darling, EmptyRegressionsAsync in Lite) drops the database_name filter that the main query applies, so the coverage probe checks the whole server instead of the requested database. A caller scoping to a database with zero Query Store history can get a false "all-clear" (or wrong "no baseline"/"never collected") message sourced from a different database's coverage on the same server — the exact "confident wrong answer" failure class this feature was built to avoid. Same gap in both apps; not covered by the new tests since none of them pass database_name through the four-states walk.
  • Verified the dedup logic, the 25%-CPU gate, the severity bands, and the ranking key (additional_duration_ms) against report.query_store_regressions in install/47_create_reporting_views.sql — the Postgres/DuckDB SQL is a faithful port (the one deliberate change is the bound LIMIT vs. the TVF's hardcoded TOP (50), as the comments say).
  • Reader/parameter positional indices ($1..$5 in Darling, $1..$3 + dynamic db params + limit in Lite) line up correctly with the RegressionRow/QueryStoreRegressionRow column mapping — no off-by-one.
  • Truncation-by-over-fetch (limit + 1) is correct and avoids the exactly-at-cap false positive it's designed to avoid.

Lite/Darling parity

  • Tool surface, param contract, JSON field names, and all four empty-branch message strings are identical word-for-word between Darling and Lite (confirmed by diff). The one intentional divergence (Darling resolves query_text via collect.query_store_text/[BUG] 3.4.0 query_store collector runs 37–100 min on Azure SQL DB (3.3.0 median: 4.8 s) — starves all other collectors #2150 with a fallback; Lite only has the fallback, since it has no such table) is called out in both file headers.
  • No Lite WPF tab was added alongside the new Darling browser tab, but that matches the existing state for get_query_store_top too (Lite's Controls/ServerTab.xaml* has no Query Store grid at all today) — not a gap introduced by this PR.

Security: all inputs are bound via parameterized queries (NpgsqlParameter/DuckDBParameter, BuildDbInClause's positional $k placeholders) — no string concatenation of user-controlled values into SQL. No secrets, file, network, or process handling introduced.

Style: T-SQL style rules don't apply directly (this SQL is Postgres/DuckDB), and the expr AS alias pattern used here matches the existing convention in DarlingDataReader.cs and other Postgres readers rather than deviating from it.

No missing-index DMV recommendations to flag (none present).

erikdarlingdata and others added 3 commits August 22, 2026 09:03
The last of the #2484 list I am taking, and the only tab in the per-server
page that was entirely unreachable rather than merely reduced.

Every other Query Store read answers "what is expensive". This answers "what
got WORSE", and the second is not derivable from the first: the costliest
query on a server is usually the one that has always been the costliest, and
the one that changed last Tuesday sits well down that list.

The SQL is the viewer's, which is the Dashboard TVF's, copied rather than
re-derived. The gate (average CPU worse by more than 25%), the ranking
(execution-count-weighted extra duration -- a 5 ms regression run a million
times outranks a 5-second one run twice) and the severity bands are what
decide WHICH queries a user is shown, and a browser that disagreed with the
desktop viewer about that would be worse than one that showed nothing.

The dedup is correctness here, not performance, and more so than on any
sibling read. Query Store rows are cumulative per-interval snapshots that the
collector re-fetches while an interval stays open. The baseline arm is
UNBOUNDED -- potentially months -- while the recent arm is a short window, so
the two sides being compared have systematically different re-collection
density per interval. Un-deduped, that alone moves the averages the percent
is computed from and the 25% gate, manufacturing and hiding regressions for
reasons that have nothing to do with the query.

Zero rows is FOUR states, and only one is good news. Never collected. No
BASELINE, because every capture falls inside the requested window. Nothing
collected IN the window. And a genuine all-clear. One probe, two booleans,
run only on the empty path.

The second of those is the reason the branching is worth the code. A server
whose entire history sits inside the window has no BEFORE, so it cannot show
a regression however badly it regressed -- and "no regressions" there is a
confident wrong answer, not a missing one. Its advice also runs the other
way: widening the window makes the baseline SHORTER, so that branch says
shorten, and the tests pin that it never says widen.

One deliberate change from the viewer: the row cap is a bound parameter
rather than the hardcoded 50, so a caller can ask for fewer or more. The gate
and the ranking are untouched, so the first 50 rows of any call are the
viewer's 50.

Lite has the columns but not collect.query_store_text, so its text sample
comes from the fact rows only -- the same text, one hop closer. That is a
difference in where the text comes from, not in which rows the read returns.

Census 103 -> 104 tools, 78 -> 79 shared, Darling-unique unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…to a corner

The no-recent branch was asserted after a row had already been planted 30
minutes ago, and no window a caller can legally ask for excludes a row that
recent -- so that state was unreachable and the read correctly answered
all-clear instead. My assertion was wrong, not the read.

Seeding order now runs baseline-only first, so the empty-window branch is
reachable, and the no-baseline branch is reached from the SAME rows by
widening hours_back until every one of them falls inside the window. Four
states, one fixed set of rows, no deletes between assertions.

Both SKUs had the identical flaw, which is what porting a test alongside a
tool is supposed to catch and did not: I ported the mistake too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three tools landed on dev while this branch was open, and the rebase merged
the census sentence without knowing it is arithmetic. 105/80 was the number
for the dev I branched from; the real inventory is 106/81.

Derived with the same scan the pin uses rather than incremented by hand --
the sentence is prose an LLM plans against, and a stale one is worse than an
absent one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@erikdarlingdata
erikdarlingdata force-pushed the feat/2484-query-store-regressions branch from 06b8763 to 1f60583 Compare August 22, 2026 08:04
Both sides of the conflict were counts -- 106/81 here, 107/82 on dev -- and
after the merge NEITHER is right, because each described a tree without the
other's tools in it. Taking either would have produced a confidently wrong
sentence that happens to look like a considered resolution.

Recounted from the attributes the pin itself scans: 108 tools, 83 shared, 25
Darling-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread Lite/Mcp/McpQueryTools.cs
Comment on lines +358 to +364
private static async Task<string> EmptyRegressionsAsync(
LocalDataService dataService, int serverId, string serverName, int hours_back)
{
var (hasBaseline, hasRecent) = await dataService.GetQueryStoreRegressionCoverageAsync(serverId, hours_back);

if (!hasBaseline && !hasRecent)
{

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor Lite/Darling asymmetry: on the empty-rows path, Darling captures start/end once at the top of GetQueryStoreRegressions and threads them into EmptyAsync so the coverage probe checks the exact same window the main query just ran against:

var end = DateTime.UtcNow;
var start = end.AddHours(-hours_back);
...
return await EmptyAsync(postgres, resolved.ServerName, resolved.ServerId, start, end, hours_back);

Here, EmptyRegressionsAsync only receives hours_back and GetQueryStoreRegressionCoverageAsync re-derives the window itself via a second GetTimeRange(hoursBack, null, null) call, i.e. a fresh DateTime.UtcNow. Since the main regression query already ran (and found 0 rows) against the earlier window, the coverage probe a moment later is technically answering "is there baseline/recent data as of now", not "as of the window the empty result came from". In practice the drift is milliseconds against an hours-wide window, so this is unlikely to ever flip a status in reality — but it's worth threading the already-computed (startTime, endTime) through the same way Darling does, both for exact behavioral parity and to remove the theoretical race (a row landing in the gap between the two UtcNow calls could flip has_recent and change which of the four messages is returned).

@claude

claude Bot commented Aug 22, 2026

Copy link
Copy Markdown

Review summary

Reviewed the get_query_store_regressions addition across Darling (Postgres MCP tool + reader + viewer JS grid) and Lite (DuckDB MCP tool + LocalDataService), plus the accompanying tests.

What I checked closely:

  • The ported SQL (DarlingQueryStoreRegressionReader.QueryStoreRegressionsSql / LocalDataService.QueryStoreRegressions.cs) is a byte-for-byte match of the already-existing, previously-shipped ViewerDataService.QueryStoreRegressionsSql (only the LIMIT 50LIMIT $5 change is new), which is itself a documented, deliberate adaptation of the T-SQL report.query_store_regressions TVF (windowing on collection_time instead of server_last_execution_time, query_store_text join, etc.) — all differences are called out in comments. I hand-verified the dedup partitioning, the NULLIF-guarded percent math, the severity bands, and the additional_duration_ms ranking key against the pinned test values (including the CRITICAL/300%/deduped-90-not-140 live test scenario) and they check out.
  • Parameter binding in both the Postgres ($1..$5, text[] array param) and DuckDB (BuildDbInClause positional IN) paths is fully parameterized — no SQL injection surface from database_name or any other input.
  • The four empty-result branches (unavailable/unavailable/empty/empty) are implemented identically in Darling and Lite, with matching message substrings pinned by tests on both sides.
  • Tool surface parity: identical parameter names/defaults/descriptions on both MCP tools, and CrossAppMcpToolInventoryPinTests regex-derives the "108 tools / 83 shared" instruction text from source rather than hardcoding it, so that count can't drift silently.
  • LIMIT $5 uses the documented over-fetch-by-one pattern (limit + 1) so truncated is observed rather than inferred from the row count — correct in both implementations.
  • JS grid (server-tabs.js) column keys line up 1:1 with the JSON field names returned by the Darling tool; codeDisclosure (reused, not new) renders query text via pre textContent, not innerHTML.

One minor finding (posted inline on Lite/Mcp/McpQueryTools.cs): the empty-result coverage check in Lite recomputes the time window with a fresh DateTime.UtcNow instead of reusing the window the main query just ran against, unlike Darling which threads start/end through. Low practical impact given hour-granularity windows, but worth aligning for exact parity.

No SQL-injection, missing-index-DMV, or Lite/Darling capability-gap issues found. Nice test coverage on the four-branch empty-state logic and the dedup correctness story.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant