From aa72c0979ebd9db2f4a52fa42dfee1c18b6855e0 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:15:01 -0400 Subject: [PATCH] #4198: default get_query_store_regressions under the response budget Checkpoint: rig measured a 40-row/~4KB-text fixture at 25,527 bytes after the fix (was the documented 211 KB worst offender at old defaults). Darling and Lite both cut query_text to a 240-char preview behind full_text, round the ten numeric fields to 2 decimals, and drop the default row limit from 50 to 30. Web viewer row pinned to keep today's full-text behavior. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- ...McpQueryStoreRegressionsBudgetLiveTests.cs | 141 ++++++++++++++ .../DarlingQueryStoreRegressionsTests.cs | 2 +- .../McpPayloadContractCensusTests.cs | 13 ++ .../DarlingMcpQueryStoreRegressionTools.txt | 3 +- .../Darling.Tests/McpToolsListBudgetTests.cs | 4 +- .../QueryStoreRegressionsWebDefaultTests.cs | 88 +++++++++ .../DarlingWebEndpoints.cs | 8 +- .../DarlingMcpQueryStoreRegressionTools.cs | 46 +++-- .../McpToolsListBudget/McpQueryTools.txt | 3 +- Lite.Tests/McpToolsListBudgetTests.cs | 5 +- .../QueryStoreRegressionsBudgetTests.cs | 172 ++++++++++++++++++ Lite/Mcp/McpQueryTools.cs | 46 +++-- 12 files changed, 500 insertions(+), 31 deletions(-) create mode 100644 Darling/Darling.Tests/DarlingMcpQueryStoreRegressionsBudgetLiveTests.cs create mode 100644 Darling/Darling.Tests/QueryStoreRegressionsWebDefaultTests.cs create mode 100644 Lite.Tests/QueryStoreRegressionsBudgetTests.cs diff --git a/Darling/Darling.Tests/DarlingMcpQueryStoreRegressionsBudgetLiveTests.cs b/Darling/Darling.Tests/DarlingMcpQueryStoreRegressionsBudgetLiveTests.cs new file mode 100644 index 0000000000..1d0b158dfd --- /dev/null +++ b/Darling/Darling.Tests/DarlingMcpQueryStoreRegressionsBudgetLiveTests.cs @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Service.Mcp; +using PerformanceMonitor.Darling.Storage; +using Xunit; + +namespace Darling.Tests; + +/// +/// #4198: get_query_store_regressions' own response-budget pin - the worst #4198 offender, a busy production +/// store's default call (limit 50 then) measured 211 KB, almost all of it fifty rows' worth of unbounded +/// query_text plus ten un-rounded numeric fields each. Plants forty regressed queries with ~4,000-character +/// text (a realistic wide generated statement) and asserts the default call stays under +/// , that full_text: true opts back into the whole text, +/// and that the default row limit (now 30) truncates with truncated observed rather than inferred. New +/// file (not the shared seeding in ) because #4198 ran a +/// dozen lanes against this store tonight. +/// +[Collection("live-postgres")] +public sealed class DarlingMcpQueryStoreRegressionsBudgetLiveTests +{ + private const string ServerName = "darling-mcp-qs-regressions-budget-e2e"; + private static readonly int ServerId = ServerIdHelper.GetDeterministicHashCode(ServerName); + private const string Db = "AppDb"; + private const int RegressedQueryCount = 40; + private static string? ConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + private readonly ITestOutputHelper _output; + + public DarlingMcpQueryStoreRegressionsBudgetLiveTests(ITestOutputHelper output) => _output = output; + + [Fact] + public async Task GetQueryStoreRegressions_Default_StaysUnderResponseBudget_WithFortyWideRows() + { + var cs = ConnectionString; + Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live query-store-regressions budget test."); + + var ct = TestContext.Current.CancellationToken; + using var connection = new NpgsqlConnection(cs); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + await DeleteRowsAsync(connection, ct); + await using var postgres = NpgsqlDataSource.Create(cs!); + + var bodySucceeded = false; + try + { + await DarlingMcpTestData.RegisterServerAsync(connection, ServerId, ServerName, ct); + var baseNow = DarlingMcpTestData.TruncateToSeconds(DateTime.UtcNow); + var queryText = BuildLongQueryText(approxLength: 4_000); + + for (var i = 0; i < RegressedQueryCount; i++) + { + var queryId = 1000 + i; + await SeedAsync(connection, ct, baseNow.AddHours(-40), queryId, executions: 50, avgDurationUs: 1000, avgCpuUs: 1000, intervalId: 2 * i + 1, text: "SELECT 1"); + await SeedAsync(connection, ct, baseNow.AddMinutes(-30), queryId, executions: 200, avgDurationUs: 4000, avgCpuUs: 4000, intervalId: 2 * i + 2, text: queryText); + } + + var defaultJson = await DarlingMcpQueryStoreRegressionTools.GetQueryStoreRegressions(postgres, ServerName); + using var defaultParsed = JsonDocument.Parse(defaultJson); + Assert.Equal(30, defaultParsed.RootElement.GetProperty("regression_count").GetInt32()); + Assert.True(defaultParsed.RootElement.GetProperty("truncated").GetBoolean()); + + var defaultBytes = Encoding.UTF8.GetByteCount(defaultJson); + _output.WriteLine($"get_query_store_regressions default call: {defaultBytes:N0} bytes (budget {McpResponseBudget.DefaultBytes:N0}), {RegressedQueryCount} planted {queryText.Length:N0}-char rows."); + Assert.True(defaultBytes < McpResponseBudget.DefaultBytes, + $"get_query_store_regressions' default call is {defaultBytes:N0} bytes over {RegressedQueryCount} planted {queryText.Length:N0}-char rows, at or over the {McpResponseBudget.DefaultBytes:N0}-byte budget."); + + var firstRow = defaultParsed.RootElement.GetProperty("regressions")[0]; + Assert.True(firstRow.GetProperty("query_text_truncated").GetBoolean()); + var previewText = firstRow.GetProperty("query_text").GetString(); + Assert.NotNull(previewText); + Assert.True(previewText!.Length < queryText.Length, "the default call's query_text should be a preview shorter than the planted text."); + + /* full_text opts back into the whole text. */ + var fullJson = await DarlingMcpQueryStoreRegressionTools.GetQueryStoreRegressions(postgres, ServerName, full_text: true); + Assert.DoesNotContain("\"query_text_truncated\": true", fullJson, StringComparison.Ordinal); + using var fullParsed = JsonDocument.Parse(fullJson); + Assert.Equal(queryText, fullParsed.RootElement.GetProperty("regressions")[0].GetProperty("query_text").GetString()); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(cs!, bodySucceeded, async (cleanup, cleanupCt) => + await DeleteRowsAsync(cleanup, cleanupCt)); + } + } + + /// Builds SQL text near characters, ASCII only so its length in + /// .NET UTF-16 chars and its size in UTF-8 bytes stay close (production application-generated SQL is + /// almost entirely ASCII: identifiers, literals, punctuation). + private static string BuildLongQueryText(int approxLength) + { + var sb = new StringBuilder(); + sb.Append("INSERT INTO dbo.Widgets (Id, Name, Description, CreatedAt, OwnerId) VALUES "); + var i = 0; + while (sb.Length < approxLength) + { + if (i > 0) sb.Append(", "); + sb.Append($"({i}, N'widget-{i}', N'a generated row from a bulk insert batch', '2026-09-25T00:00:00', {i % 97})"); + i++; + } + + return sb.ToString(); + } + + private static async Task SeedAsync( + NpgsqlConnection connection, CancellationToken ct, DateTime collectionTime, long queryId, long executions, + long avgDurationUs, long avgCpuUs, long intervalId, string text) => + await DarlingMcpTestData.ExecAsync(connection, ct, @" +INSERT INTO query_store_stats + (collection_id, collection_time, server_id, server_name, database_name, query_id, plan_id, + execution_type_desc, execution_count, avg_duration_us, avg_cpu_time_us, avg_logical_io_reads, + runtime_stats_interval_id, query_text, last_execution_time) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)", + CollectionIdGenerator.Next(), DarlingMcpTestData.Naive(collectionTime), ServerId, ServerName, + Db, queryId, 9L, "Regular", executions, avgDurationUs, avgCpuUs, 100L, intervalId, + text, DarlingMcpTestData.Naive(collectionTime)); + + private static async Task DeleteRowsAsync(NpgsqlConnection connection, CancellationToken ct) + { + using var cleanup = new NpgsqlCommand( + $"DELETE FROM query_store_stats WHERE server_id = {ServerId}; DELETE FROM servers WHERE server_id = {ServerId}; DELETE FROM config_monitored_servers WHERE server_id = {ServerId};", + connection); + await cleanup.ExecuteNonQueryAsync(ct); + } +} diff --git a/Darling/Darling.Tests/DarlingQueryStoreRegressionsTests.cs b/Darling/Darling.Tests/DarlingQueryStoreRegressionsTests.cs index d6adfeae3d..d5f5013344 100644 --- a/Darling/Darling.Tests/DarlingQueryStoreRegressionsTests.cs +++ b/Darling/Darling.Tests/DarlingQueryStoreRegressionsTests.cs @@ -71,7 +71,7 @@ public void ParamContract_AllOptional_MatchesLite() .Select(x => (x.Name!, x.HasDefaultValue)) .ToArray(); - Assert.Equal(new[] { "server_name", "hours_back", "database_name", "limit", "as_of" }, p.Select(x => x.Item1).ToArray()); + Assert.Equal(new[] { "server_name", "hours_back", "database_name", "limit", "full_text", "as_of" }, p.Select(x => x.Item1).ToArray()); Assert.All(p, x => Assert.True(x.Item2, $"{x.Item1} must be optional")); } diff --git a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs index a0120b5adf..667db9d968 100644 --- a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs +++ b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs @@ -1475,6 +1475,12 @@ public void TheSharedTimestampParsers_RefuseWhatTheGeneralParserAccepts() /// cut BEFORE the store, by the collector. They keep their names because they are true and different: a /// caller can do nothing about them by re-paging, and folding them into truncated would tell that /// caller to raise a limit that changes nothing. + /// A field-level response-budget preview — : #4198 sizes + /// each tool's DEFAULT answer under the shared 32 KB McpResponseBudget.DefaultBytes by previewing + /// one wide field (query text, a plan fragment, a deadlock graph) rather than the page — unlike a + /// source-side cut, a caller CAN get the rest, with an opt-in argument (get_deadlock_detail's + /// full_graph, the same shape get_store_query_stats' full_text already used) or by + /// narrowing the page to one named thing (a dedup_key call always gets the whole field). /// The withheld summary — : #3594's own vocabulary for a /// reach verdict that withholds a figure rather than publishing a page's count under a whole's name. /// @@ -1519,6 +1525,12 @@ public static readonly (string Key, string[] Files, string WhatWasCut)[] SourceS "the statement text cut at COLLECTION to the collector's per-row text cap (track_activity_query_size on the target is the other cutter) — the store never held the rest"), ]; + public static readonly (string Key, string[] Files, string WhatWasCut)[] FieldPreviewCutKeys = + [ + ("query_text_truncated", ["DarlingMcpQueryStoreRegressionTools.cs", "McpQueryTools.cs"], + "#4198: get_query_store_regressions' own wide field — query_text is a 240-character preview by default (a busy production store measured 211 KB at default arguments, the worst of every #4198 offender), full_text opts back in"), + ]; + public static readonly (string Key, string[] Files, string WhatIsWithheld)[] WithheldSummaryKeys = [ ("answered_rows_withheld", ["DarlingMcpPgIndexTools.cs"], "#3594's reach verdict: the answered-index summary is withheld rather than published as a count of the page"), @@ -1567,6 +1579,7 @@ public static readonly (string Key, string[] Files, string WhatItActuallyIs)[] C SecondBoundCutKeys.Select(k => (k.Key, k.Files)) .Concat(CutNoteKeys.Select(k => (k.Key, k.Files))) .Concat(SourceSideCutKeys.Select(k => (k.Key, k.Files))) + .Concat(FieldPreviewCutKeys.Select(k => (k.Key, k.Files))) .Concat(WithheldSummaryKeys.Select(k => (k.Key, k.Files))) .Concat(PageCountsUnderANeutralNoun.Select(k => (k.Key, k.Files))) .Concat(CutHomonyms.Select(k => (k.Key, k.Files))); diff --git a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpQueryStoreRegressionTools.txt b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpQueryStoreRegressionTools.txt index b47831c609..54fad27903 100644 --- a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpQueryStoreRegressionTools.txt +++ b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpQueryStoreRegressionTools.txt @@ -2,6 +2,7 @@ tool get_query_store_regressions 605 param get_query_store_regressions.as_of 167 param get_query_store_regressions.database_name 46 +param get_query_store_regressions.full_text 84 param get_query_store_regressions.hours_back 110 -param get_query_store_regressions.limit 86 +param get_query_store_regressions.limit 164 param get_query_store_regressions.server_name 28 diff --git a/Darling/Darling.Tests/McpToolsListBudgetTests.cs b/Darling/Darling.Tests/McpToolsListBudgetTests.cs index 8455e7e26c..341b632e6d 100644 --- a/Darling/Darling.Tests/McpToolsListBudgetTests.cs +++ b/Darling/Darling.Tests/McpToolsListBudgetTests.cs @@ -91,7 +91,9 @@ public McpToolsListBudgetTests(ITestOutputHelper output) tools/list and so is not counted here. */ /* #4192/#4195/#4193/#4217: audit_config narrowed, regression baseline bounded, PG CPU bucketed. +82 bytes net after trimming. */ - private const int TotalCeilingBytes = 171_719; + /* #4198: get_query_store_regressions gained full_text (its own preview opt-in, +84 bytes) and its + limit description grew to explain the new lower default (+78 bytes over the old 86). +214 bytes net. */ + private const int TotalCeilingBytes = 171_933; private const int ConvertedHeadCap = 1_000; private const int ConvertedParameterCap = 200; diff --git a/Darling/Darling.Tests/QueryStoreRegressionsWebDefaultTests.cs b/Darling/Darling.Tests/QueryStoreRegressionsWebDefaultTests.cs new file mode 100644 index 0000000000..762d13b943 --- /dev/null +++ b/Darling/Darling.Tests/QueryStoreRegressionsWebDefaultTests.cs @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.IO; +using System.Runtime.CompilerServices; +using Xunit; + +namespace Darling.Tests; + +/// +/// #4198 cut get_query_store_regressions's default query_text to a 240-character preview, +/// gated behind a new full_text opt-in that defaults to false on the MCP signature. The web +/// viewer's /api/read dispatch calls that same MCP method, so without its own default it would +/// silently inherit the preview. The viewer has always rendered the whole query text (via codeDisclosure +/// on the Query Store Regressions tab), and #4198 changed an MCP default only - not what the viewer should +/// show. This pins the web row's full_text default at true, independent of (and opposite to) +/// the MCP signature's own default. +/// +public sealed class QueryStoreRegressionsWebDefaultTests +{ + private const string WebEndpoints = + "Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs"; + + [Fact] + public void GetQueryStoreRegressions_WebRowKeepsTheFullQueryTextByDefault() + { + var web = StripComments(ReadSource(WebEndpoints)); + + Assert.Contains("full_text: QueryBool(c, \"full_text\", true)", web, StringComparison.Ordinal); + Assert.Contains("PLimit(50), PBool(\"full_text\", true)", web, StringComparison.Ordinal); + Assert.DoesNotContain("full_text: QueryBool(c, \"full_text\", false)", web, StringComparison.Ordinal); + } + + private static string StripComments(string source) + { + var stripped = CSharpSourceWalker.StripCommentsAndStrings(source).ToCharArray(); + + foreach (var (start, text) in CSharpSourceWalker.StringLiteralBodies(source)) + { + for (var i = 0; i < text.Length && start + i < stripped.Length; i++) + { + stripped[start + i] = text[i]; + } + } + + var code = CSharpSourceWalker.CodeMask(source); + + for (var i = 0; i < source.Length; i++) + { + if (!code[i] && source[i] is '"' or '@' or '$') + { + stripped[i] = source[i]; + } + } + + return new string(stripped); + } + + private static string ReadSource(string relative) + { + var path = Path.Combine(RepoRoot(), relative); + + Assert.True(File.Exists(path), $"#4198 scan target not found: {path}"); + + return File.ReadAllText(path); + } + + private static string RepoRoot([CallerFilePath] string thisFile = "") + { + var dir = Path.GetDirectoryName(thisFile)!; + + while (dir is not null + && !File.Exists(Path.Combine(dir, "PerformanceMonitor.sln")) + && !Directory.Exists(Path.Combine(dir, ".git"))) + { + dir = Path.GetDirectoryName(dir); + } + + Assert.NotNull(dir); + return dir!; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs index 30c27c57c0..ae74d73f1a 100644 --- a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs @@ -1961,7 +1961,7 @@ private static CatalogRead R(string category, string description, params Catalog ["get_memory_stats"] = R(CatData, "Server memory summary counters.", PServer()), ["get_perfmon_stats"] = R(CatData, "Perfmon counter values, filtered by counter/instance.", PServer(), PText("counter_name"), PText("instance_name")), ["get_query_heatmap"] = R(CatData, "Query counts per (time bin x log-magnitude bucket) - the viewer's Query Heatmap as a table.", PServer(), PHours(24), PText("metric"), PText("database_name"), PInt("bucket_minutes", 5), PLimit(500), PAsOf()), - ["get_query_store_regressions"] = R(CatData, "Queries whose Query Store performance got WORSE vs their baseline.", PServer(), PHours(24), PText("database_name"), PLimit(50), PAsOf()), + ["get_query_store_regressions"] = R(CatData, "Queries whose Query Store performance got WORSE vs their baseline.", PServer(), PHours(24), PText("database_name"), PLimit(50), PBool("full_text", true), PAsOf()), ["get_query_store_clutter"] = R(CatData, "The Query Store CLUTTER view: per database the collector's read cost (how often and by how much it was the slowest fan-out item), plan churn (plans per query, arrivals per day, one-shot plans) and the options row, each with raw numbers and a decomposed verdict; ONE per-server overhead block (non-sleep QDS_* wait deltas with the excluded sleep waits named, and the Query Store memory clerk). Replicas excluded by architecture with the reason on the row; query_capture_mode (ALL / AUTO / CUSTOM / NONE) carried with capture_mode_known beside it, null meaning a capture older than the V137 rung rather than NONE. window_truncated says the raw tier did not hold the whole window. Composed from collected rows - no new query against the server.", PServer(), PHours(24), PLimit(DarlingMcpQueryStoreClutterTools.DefaultLimit), PBool("include_fleet_median", false), PAsOf()), ["get_query_store_top"] = R(CatData, "Top Query Store queries in the window, optionally filtered by execution outcome or to one exact module before ranking; window_truncated says the raw tier did not hold the whole window (effective_hours_back how far it reached).", PServer(), PHours(24), PTop(20), PText("database_name"), PAsOf(), PText("execution_type"), PText("module_name")), ["get_long_query_completions"] = R(CatData, "Completed long-running queries captured by the XE trace.", PServer(), PHours(24), PLimit(30), PAsOf()), @@ -2670,7 +2670,11 @@ logger is the tool's logger seat — the web host's SERVICE logger when MapAll b ["get_memory_stats"] = (c, pg, an) => DarlingMcpDataTools.GetMemoryStats(pg, Server(c)), ["get_perfmon_stats"] = (c, pg, an) => DarlingMcpDataTools.GetPerfmonStats(pg, Server(c), Str(c, "counter_name"), Str(c, "instance_name")), ["get_query_heatmap"] = (c, pg, an) => DarlingMcpQueryHeatmapTools.GetQueryHeatmap(pg, Server(c), Hours(c, 24), Str(c, "metric"), Str(c, "database_name"), QueryInt(c, "bucket_minutes", null, 5), Rows(c, "limit", 500), as_of: AsOf(c)), - ["get_query_store_regressions"] = (c, pg, an) => DarlingMcpQueryStoreRegressionTools.GetQueryStoreRegressions(pg, Server(c), Hours(c, 24), Str(c, "database_name"), Rows(c, "limit", 50), as_of: AsOf(c)), + /* #4198: full_text defaults false on the MCP signature (a 240-character preview keeps a busy + production store's default call under the shared response budget), but the web viewer has + always shown the whole query text. The row pins its OWN default to true so the MCP-side + budget cut does not silently shrink what the viewer renders. */ + ["get_query_store_regressions"] = (c, pg, an) => DarlingMcpQueryStoreRegressionTools.GetQueryStoreRegressions(pg, Server(c), Hours(c, 24), Str(c, "database_name"), Rows(c, "limit", 50), full_text: QueryBool(c, "full_text", true), as_of: AsOf(c)), ["get_query_store_clutter"] = (c, pg, an) => DarlingMcpQueryStoreClutterTools.GetQueryStoreClutter(pg, Server(c), Hours(c, 24), Rows(c, "limit", DarlingMcpQueryStoreClutterTools.DefaultLimit), QueryBool(c, "include_fleet_median", false), AsOf(c)), ["get_query_store_top"] = (c, pg, an) => DarlingMcpDataTools.GetQueryStoreTop(pg, Server(c), Hours(c, 24), Rows(c, "top", 20), Str(c, "database_name"), as_of: AsOf(c), execution_type: Str(c, "execution_type"), module_name: Str(c, "module_name")), ["get_long_query_completions"] = (c, pg, an) => DarlingMcpLongQueryTools.GetLongQueryCompletions(pg, Server(c), Hours(c, 24), Rows(c, "limit", 30), as_of: AsOf(c)), diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreRegressionTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreRegressionTools.cs index ae2a4a7680..6bd237a0b6 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreRegressionTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreRegressionTools.cs @@ -34,13 +34,23 @@ namespace PerformanceMonitor.Darling.Service.Mcp; [McpServerToolType] public sealed class DarlingMcpQueryStoreRegressionTools { + /// + /// #4198: query_text is this tool's own wide field - up to 50 rows of numeric columns plus a full, + /// unbounded Query Store text each measured 211 KB at default arguments on a busy production store, the + /// worst of every #4198 offender ('s own doc comment). Previewed to this + /// length per row at default (full_text: true opts back in), the same preview-plus-opt-in shape + /// get_store_query_stats uses for its own full_text. + /// + private const int QueryTextPreviewLength = 240; + [McpServerTool(Name = "get_query_store_regressions"), Description("Finds queries whose Query Store performance got WORSE: recent window (hours_back, ending at as_of) vs a fixed 7-day baseline before it (see baseline_start/baseline_end). get_query_store_top ranks EXPENSIVE, this ranks CHANGED. Gated: average CPU regressed over 25%. duration_regression_percent, io_regression_percent and severity are null, not 0%, when their baseline is 0. additional_duration_ms is the ranking key. empty: no regression, or nothing yet in the baseline window. unavailable: no baseline exists yet. not_collected: this server's engine cannot run Query Store. <> Finds queries whose Query Store performance got WORSE, by comparing each (database, query_id) group's averages inside a recent window against its baseline - a FIXED 7-day lookback ending at that window's start (#4195; before this it was every capture EVER collected before the window, so its cost tracked how much history the store still retained rather than the window asked for, and the comparison period silently grew on a server with more retention). baseline_start and baseline_end report exactly which period was compared - a regression against something older than the baseline lookback is not caught; a store retaining less than that is unaffected. Returns baseline vs recent duration, CPU and logical reads with the regression percent for each, the execution-count-weighted extra duration (the ranking key: a 5 ms regression executed a million times outranks a 5-second one executed twice), the plan counts on both sides, and a duration-driven severity band. get_query_store_top answers what is EXPENSIVE; the most expensive query is usually the one that always was. This answers what CHANGED. Rows are kept only where average CPU regressed by more than 25%. A regression percent whose BASELINE side is 0 has no denominator and is returned as null, with the reason under undefined_percents - never as 0, which would read as no change when the truth is the largest possible one; compare the two absolute figures instead. The ranking key is the absolute, execution-weighted duration delta, which exists whether or not a ratio does, so a null percent never sorts as 0. severity is banded from the duration percent and is null when that percent is.")] public static async Task GetQueryStoreRegressions( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, [Description("Size of the RECENT window, in hours back from now. Everything collected before it is the baseline. Default 24.")] int hours_back = 24, [Description("Limit to one database. Omit for all databases.")] string? database_name = null, - [Description("Maximum rows to return, worst first. Default 50 (the number the desktop viewer shows).")] int limit = 50, + [Description("Maximum rows to return, worst first. Default 30, sized to keep a default call under the shared response budget. Read truncated to know whether the window held more.")] int limit = 30, + [Description("Return each row's full query text instead of a 240-character preview. Default false.")] bool full_text = false, [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); @@ -99,22 +109,22 @@ before it" - the baseline no longer grows with retention. */ row with NO duration ratio is a verdict about a number that does not exist. Null there (#3541 A12); the SQL's band is kept verbatim for the viewer it is shared with. */ severity = r.DurationRegressionPercent is null ? null : r.Severity, - baseline_duration_ms = r.BaselineDurationMs, - recent_duration_ms = r.RecentDurationMs, - duration_regression_percent = r.DurationRegressionPercent, - baseline_cpu_ms = r.BaselineCpuMs, - recent_cpu_ms = r.RecentCpuMs, - cpu_regression_percent = r.CpuRegressionPercent, - baseline_reads = r.BaselineReads, - recent_reads = r.RecentReads, - io_regression_percent = r.IoRegressionPercent, + baseline_duration_ms = Round(r.BaselineDurationMs), + recent_duration_ms = Round(r.RecentDurationMs), + duration_regression_percent = Round(r.DurationRegressionPercent), + baseline_cpu_ms = Round(r.BaselineCpuMs), + recent_cpu_ms = Round(r.RecentCpuMs), + cpu_regression_percent = Round(r.CpuRegressionPercent), + baseline_reads = Round(r.BaselineReads), + recent_reads = Round(r.RecentReads), + io_regression_percent = Round(r.IoRegressionPercent), /* Null percents, and why (#3541 A12): a 0 baseline has no ratio, and the reader used to publish that as 0 — "no change" — for the row that changed the most. */ undefined_percents = UndefinedPercentNotes(r), /* The ranking key, and the one number that says whether this regression MATTERS: a 5 ms regression executed a million times outranks a 5-second one executed twice. It is an absolute delta, so it exists for every row and a null ratio never sorts as 0. */ - additional_duration_ms = r.AdditionalDurationMs, + additional_duration_ms = Round(r.AdditionalDurationMs), baseline_exec_count = r.BaselineExecCount, recent_exec_count = r.RecentExecCount, /* A plan count that moved between the two sides is the first thing to check: a query @@ -122,7 +132,8 @@ 5 ms regression executed a million times outranks a 5-second one executed twice. baseline_plan_count = r.BaselinePlanCount, recent_plan_count = r.RecentPlanCount, last_execution_time = r.LastExecutionTime?.ToString("o"), - query_text = r.QueryTextSample, + query_text = full_text ? r.QueryTextSample : McpHelpers.Truncate(r.QueryTextSample, QueryTextPreviewLength), + query_text_truncated = !full_text && r.QueryTextSample.Length > QueryTextPreviewLength, }), }, McpHelpers.JsonOptions); } @@ -133,6 +144,17 @@ 5 ms regression executed a million times outranks a 5-second one executed twice. } + /// + /// #4198: the reader's ms/percent/read figures come straight off AVG() over microsecond + /// integers, so most rows serialize with a long, meaningless decimal tail (a double's round-trip + /// representation, not a display one) - real weight across ten numeric fields on up to 30 rows. Rounded + /// to 2 decimal places here, at the JSON edge only; the reader's SQL and the viewer's own read are + /// untouched, and every existing pin uses whole-number fixtures that round trip unchanged. + /// + private static double Round(double value) => Math.Round(value, 2); + + private static double? Round(double? value) => value is null ? null : Math.Round(value.Value, 2); + /// /// Which of a row's three regression percents are undefined, and why (#3541 A12, contract rule 5). Each /// percent divides through NULLIF(baseline, 0), so a NULL means the baseline side was 0 — there is diff --git a/Lite.Tests/McpToolsListBudget/McpQueryTools.txt b/Lite.Tests/McpToolsListBudget/McpQueryTools.txt index 6afa604513..e43d778b1b 100644 --- a/Lite.Tests/McpToolsListBudget/McpQueryTools.txt +++ b/Lite.Tests/McpToolsListBudget/McpQueryTools.txt @@ -28,8 +28,9 @@ param get_query_store_duration_trend.server_name 28 tool get_query_store_regressions 605 param get_query_store_regressions.as_of 167 param get_query_store_regressions.database_name 46 +param get_query_store_regressions.full_text 84 param get_query_store_regressions.hours_back 110 -param get_query_store_regressions.limit 86 +param get_query_store_regressions.limit 164 param get_query_store_regressions.server_name 28 tool get_query_store_top 422 diff --git a/Lite.Tests/McpToolsListBudgetTests.cs b/Lite.Tests/McpToolsListBudgetTests.cs index 4dc551590c..583ec57524 100644 --- a/Lite.Tests/McpToolsListBudgetTests.cs +++ b/Lite.Tests/McpToolsListBudgetTests.cs @@ -83,7 +83,10 @@ public McpToolsListBudgetTests(ITestOutputHelper output) /* #4199 (M2b): +162 bytes for get_collection_log's fleet-form server_name/limit descriptions, after trimming both to the D2 200-char parameter cap and moving the rest to the tool's tail (get_tool_guide), which is not served in tools/list and so is not counted here. Matches Darling's twin change exactly. */ - private const int TotalCeilingBytes = 89_881; + /* #4198: get_query_store_regressions gained full_text (its own preview opt-in, +84 bytes) and its + limit description grew to explain the new lower default (+78 bytes over the old 86). +203 bytes net; + Darling's own total differs because its head text carries the #4195/#4198 issue numbers Lite's doesn't. */ + private const int TotalCeilingBytes = 90_084; private const int ConvertedHeadCap = 1_000; private const int ConvertedParameterCap = 200; diff --git a/Lite.Tests/QueryStoreRegressionsBudgetTests.cs b/Lite.Tests/QueryStoreRegressionsBudgetTests.cs new file mode 100644 index 0000000000..f8674b138b --- /dev/null +++ b/Lite.Tests/QueryStoreRegressionsBudgetTests.cs @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using PerformanceMonitor.Common; +using PerformanceMonitorLite.Database; +using PerformanceMonitorLite.Mcp; +using PerformanceMonitorLite.Models; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// +/// #4198: get_query_store_regressions' own response-budget pin, Lite's twin of +/// Darling.Tests.DarlingMcpQueryStoreRegressionsBudgetLiveTests - the worst #4198 offender, a busy +/// production store's default call (limit 50 then) measured 211 KB, almost all of it fifty rows' worth of +/// unbounded query_text plus ten un-rounded numeric fields each. Plants forty regressed queries with +/// ~4,000-character text (a realistic wide generated statement) and asserts the default call stays under +/// , that full_text: true opts back into the whole text, +/// and that the default row limit (now 30) truncates with truncated observed rather than inferred. New +/// file (not the shared seeding in ) because #4198 ran a dozen +/// lanes against this store tonight. +/// +public sealed class QueryStoreRegressionsBudgetTests : IClassFixture, IDisposable +{ + private const string ServerName = "RegressionsBudgetSrv"; + private const string Db = "AppDb"; + private const int RegressedQueryCount = 40; + + private readonly int _serverId; + private readonly DuckDbInitializer _duckDb; + private readonly string _configDir; + private readonly ServerManager _serverManager; + private DuckDBConnection? _seedConn; + private long _nextId = 1; + + public QueryStoreRegressionsBudgetTests(SharedDuckDbFixture fixture) + { + fixture.ResetData(); + _duckDb = fixture.DuckDb; + + _configDir = Path.Combine(Path.GetTempPath(), "pmlite-regressions-budget-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_configDir); + _serverManager = new ServerManager(_configDir); + + var server = new ServerConnection + { + Id = Guid.NewGuid().ToString(), + ServerName = ServerName, + IsEnabled = true, + }; + _serverManager.AddServer(server); + _serverId = RemoteCollectorService.GetDeterministicHashCode( + RemoteCollectorService.GetServerNameForStorage(server)); + } + + public void Dispose() + { + _seedConn?.Dispose(); + try { Directory.Delete(_configDir, recursive: true); } catch (IOException) { /* temp dir */ } + } + + [Fact] + public async Task GetQueryStoreRegressions_Default_StaysUnderResponseBudget_WithFortyWideRows() + { + var service = new LocalDataService(_duckDb); + var baseNow = Truncate(DateTime.UtcNow); + var queryText = BuildLongQueryText(approxLength: 4_000); + + for (var i = 0; i < RegressedQueryCount; i++) + { + var queryId = 1000 + i; + await SeedAsync(baseNow.AddHours(-40), executions: 50, avgDurationUs: 1000, avgCpuUs: 1000, intervalId: 2 * i + 1, queryId: queryId, text: "SELECT 1"); + await SeedAsync(baseNow.AddMinutes(-30), executions: 200, avgDurationUs: 4000, avgCpuUs: 4000, intervalId: 2 * i + 2, queryId: queryId, text: queryText); + } + + var defaultJson = await McpQueryTools.GetQueryStoreRegressions(service, _serverManager, ServerName); + var defaultRoot = Root(defaultJson); + Assert.Equal(30, defaultRoot.GetProperty("regression_count").GetInt32()); + Assert.True(defaultRoot.GetProperty("truncated").GetBoolean()); + + var defaultBytes = Encoding.UTF8.GetByteCount(defaultJson); + Assert.True(defaultBytes < McpResponseBudget.DefaultBytes, + $"get_query_store_regressions' default call is {defaultBytes:N0} bytes over {RegressedQueryCount} planted {queryText.Length:N0}-char rows, at or over the {McpResponseBudget.DefaultBytes:N0}-byte budget."); + + var firstRow = defaultRoot.GetProperty("regressions")[0]; + Assert.True(firstRow.GetProperty("query_text_truncated").GetBoolean()); + var previewText = firstRow.GetProperty("query_text").GetString(); + Assert.NotNull(previewText); + Assert.True(previewText!.Length < queryText.Length, "the default call's query_text should be a preview shorter than the planted text."); + + /* full_text opts back into the whole text. */ + var fullJson = await McpQueryTools.GetQueryStoreRegressions(service, _serverManager, ServerName, full_text: true); + Assert.DoesNotContain("\"query_text_truncated\":true", fullJson, StringComparison.Ordinal); + var fullRoot = Root(fullJson); + Assert.Equal(queryText, fullRoot.GetProperty("regressions")[0].GetProperty("query_text").GetString()); + } + + /// Builds SQL text near characters, ASCII only so its length in + /// .NET UTF-16 chars and its size in UTF-8 bytes stay close (production application-generated SQL is + /// almost entirely ASCII: identifiers, literals, punctuation). + private static string BuildLongQueryText(int approxLength) + { + var sb = new StringBuilder(); + sb.Append("INSERT INTO dbo.Widgets (Id, Name, Description, CreatedAt, OwnerId) VALUES "); + var i = 0; + while (sb.Length < approxLength) + { + if (i > 0) sb.Append(", "); + sb.Append($"({i}, N'widget-{i}', N'a generated row from a bulk insert batch', '2026-09-25T00:00:00', {i % 97})"); + i++; + } + + return sb.ToString(); + } + + private static JsonElement Root(string json) => JsonDocument.Parse(json).RootElement; + + private static DateTime Truncate(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond)), DateTimeKind.Unspecified); + + private async Task SeedConnectionAsync() + { + if (_seedConn is null) + { + _seedConn = _duckDb.CreateConnection(); + await _seedConn.OpenAsync(); + } + return _seedConn; + } + + private async Task SeedAsync( + DateTime collectionTime, long executions, long avgDurationUs, long avgCpuUs, long intervalId, long queryId, string text) + { + using var readLock = _duckDb.AcquireReadLock(); + var connection = await SeedConnectionAsync(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +INSERT INTO query_store_stats + (collection_id, collection_time, server_id, server_name, database_name, query_id, plan_id, + execution_type_desc, execution_count, avg_duration_us, avg_cpu_time_us, avg_logical_io_reads, + runtime_stats_interval_id, query_text, last_execution_time) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)"; + cmd.Parameters.Add(new DuckDBParameter { Value = _nextId++ }); + cmd.Parameters.Add(new DuckDBParameter { Value = collectionTime }); + cmd.Parameters.Add(new DuckDBParameter { Value = _serverId }); + cmd.Parameters.Add(new DuckDBParameter { Value = ServerName }); + cmd.Parameters.Add(new DuckDBParameter { Value = Db }); + cmd.Parameters.Add(new DuckDBParameter { Value = queryId }); + cmd.Parameters.Add(new DuckDBParameter { Value = 9L }); + cmd.Parameters.Add(new DuckDBParameter { Value = "Regular" }); + cmd.Parameters.Add(new DuckDBParameter { Value = executions }); + cmd.Parameters.Add(new DuckDBParameter { Value = avgDurationUs }); + cmd.Parameters.Add(new DuckDBParameter { Value = avgCpuUs }); + cmd.Parameters.Add(new DuckDBParameter { Value = 100L }); + cmd.Parameters.Add(new DuckDBParameter { Value = intervalId }); + cmd.Parameters.Add(new DuckDBParameter { Value = text }); + cmd.Parameters.Add(new DuckDBParameter { Value = collectionTime }); + await cmd.ExecuteNonQueryAsync(); + } +} diff --git a/Lite/Mcp/McpQueryTools.cs b/Lite/Mcp/McpQueryTools.cs index 1d507311bb..a8ff38b6e7 100644 --- a/Lite/Mcp/McpQueryTools.cs +++ b/Lite/Mcp/McpQueryTools.cs @@ -336,6 +336,15 @@ the thing that cannot read it. */ } } + /// + /// #4198: query_text is this tool's own wide field - up to 50 rows of numeric columns plus a full, + /// unbounded Query Store text each measured 211 KB at default arguments on a busy production store (the + /// Darling twin's own measurement; the read and its payload shape are identical). Previewed to this + /// length per row at default (full_text: true opts back in), the same preview-plus-opt-in shape + /// get_store_query_stats uses for its own full_text. + /// + private const int QueryTextPreviewLength = 240; + [McpServerTool(Name = "get_query_store_regressions"), Description("Finds queries whose Query Store performance got WORSE: recent window (hours_back, ending at as_of) vs a fixed 7-day baseline before it (see baseline_start/baseline_end). get_query_store_top ranks EXPENSIVE, this ranks CHANGED. Gated: average CPU regressed over 25%. duration_regression_percent, io_regression_percent and severity are null, not 0%, when their baseline is 0. additional_duration_ms is the ranking key. empty: no regression, or nothing yet in the baseline window. unavailable: no baseline exists yet. not_collected: this server's engine cannot run Query Store. <> Finds queries whose Query Store performance got WORSE, by comparing each (database, query_id) group's averages inside a recent window against its baseline - a FIXED 7-day lookback ending at that window's start (before this it was every capture EVER collected before the window, so its cost tracked how much history the store still retained rather than the window asked for, and the comparison period silently grew on a server with more retention). baseline_start and baseline_end report exactly which period was compared - a regression against something older than the baseline lookback is not caught; a store retaining less than that is unaffected. Returns baseline vs recent duration, CPU and logical reads with the regression percent for each, the execution-count-weighted extra duration (the ranking key: a 5 ms regression executed a million times outranks a 5-second one executed twice), the plan counts on both sides, and a duration-driven severity band. get_query_store_top answers what is EXPENSIVE; the most expensive query is usually the one that always was. This answers what CHANGED. Rows are kept only where average CPU regressed by more than 25%. A regression percent whose BASELINE side is 0 has no denominator and is returned as null, with the reason under undefined_percents - never as 0, which would read as no change when the truth is the largest possible one; compare the two absolute figures instead. The ranking key is the absolute, execution-weighted duration delta, which exists whether or not a ratio does, so a null percent never sorts as 0. severity is banded from the duration percent and is null when that percent is.")] public static async Task GetQueryStoreRegressions( LocalDataService dataService, @@ -343,7 +352,8 @@ public static async Task GetQueryStoreRegressions( [Description("Server name or display name.")] string? server_name = null, [Description("Size of the RECENT window, in hours back from now. Everything collected before it is the baseline. Default 24.")] int hours_back = 24, [Description("Limit to one database. Omit for all databases.")] string? database_name = null, - [Description("Maximum rows to return, worst first. Default 50 (the number the desktop viewer shows).")] int limit = 50, + [Description("Maximum rows to return, worst first. Default 30, sized to keep a default call under the shared response budget. Read truncated to know whether the window held more.")] int limit = 30, + [Description("Return each row's full query text instead of a 240-character preview. Default false.")] bool full_text = false, [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); @@ -392,21 +402,21 @@ public static async Task GetQueryStoreRegressions( row with NO duration ratio is a verdict about a number that does not exist. Null there (#3541 A12); the SQL's band is kept verbatim for the viewer it is shared with. */ severity = r.DurationRegressionPercent is null ? null : r.Severity, - baseline_duration_ms = r.BaselineDurationMs, - recent_duration_ms = r.RecentDurationMs, - duration_regression_percent = r.DurationRegressionPercent, - baseline_cpu_ms = r.BaselineCpuMs, - recent_cpu_ms = r.RecentCpuMs, - cpu_regression_percent = r.CpuRegressionPercent, - baseline_reads = r.BaselineReads, - recent_reads = r.RecentReads, - io_regression_percent = r.IoRegressionPercent, + baseline_duration_ms = Round(r.BaselineDurationMs), + recent_duration_ms = Round(r.RecentDurationMs), + duration_regression_percent = Round(r.DurationRegressionPercent), + baseline_cpu_ms = Round(r.BaselineCpuMs), + recent_cpu_ms = Round(r.RecentCpuMs), + cpu_regression_percent = Round(r.CpuRegressionPercent), + baseline_reads = Round(r.BaselineReads), + recent_reads = Round(r.RecentReads), + io_regression_percent = Round(r.IoRegressionPercent), /* Null percents, and why (#3541 A12): a 0 baseline has no ratio, and the reader used to publish that as 0 — "no change" — for the row that changed the most. */ undefined_percents = UndefinedPercentNotes(r), /* The ranking key, and the one number that says whether this regression MATTERS. It is an absolute delta, so it exists for every row and a null ratio never sorts as 0. */ - additional_duration_ms = r.AdditionalDurationMs, + additional_duration_ms = Round(r.AdditionalDurationMs), baseline_exec_count = r.BaselineExecCount, recent_exec_count = r.RecentExecCount, /* A plan count that moved between the two sides is the first thing to check: a query @@ -414,7 +424,8 @@ row with NO duration ratio is a verdict about a number that does not exist. Null baseline_plan_count = r.BaselinePlanCount, recent_plan_count = r.RecentPlanCount, last_execution_time = r.LastExecutionTime?.ToString("o"), - query_text = r.QueryTextSample, + query_text = full_text ? r.QueryTextSample : McpHelpers.Truncate(r.QueryTextSample, QueryTextPreviewLength), + query_text_truncated = !full_text && r.QueryTextSample.Length > QueryTextPreviewLength, }), }, McpHelpers.JsonOptions); } @@ -424,6 +435,17 @@ row with NO duration ratio is a verdict about a number that does not exist. Null } } + /// + /// #4198: the reader's ms/percent/read figures come straight off an AVG() over microsecond + /// integers, so most rows serialize with a long, meaningless decimal tail (a double's round-trip + /// representation, not a display one) - real weight across ten numeric fields on up to 30 rows. Rounded + /// to 2 decimal places here, at the JSON edge only; every existing pin uses whole-number fixtures that + /// round trip unchanged. + /// + private static double Round(double value) => Math.Round(value, 2); + + private static double? Round(double? value) => value is null ? null : Math.Round(value.Value, 2); + /// /// Which of a row's three regression percents are undefined, and why (#3541 A12, contract rule 5). Each /// percent divides through NULLIF(baseline, 0), so a NULL means the baseline side was 0 — there is