Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// #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
/// <see cref="McpResponseBudget.DefaultBytes"/>, that <c>full_text: true</c> opts back into the whole text,
/// and that the default row limit (now 30) truncates with <c>truncated</c> observed rather than inferred. New
/// file (not the shared seeding in <see cref="DarlingQueryStoreRegressionsLiveTests"/>) because #4198 ran a
/// dozen lanes against this store tonight.
/// </summary>
[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));
}
}

/// <summary>Builds SQL text near <paramref name="approxLength"/> 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).</summary>
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);
}
}
2 changes: 1 addition & 1 deletion Darling/Darling.Tests/DarlingQueryStoreRegressionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}

Expand Down
4 changes: 2 additions & 2 deletions Darling/Darling.Tests/McpPayloadContractCensusTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1530,8 +1530,8 @@ public static readonly (string Key, string[] Files, string WhatWasCut)[] FieldPr
[
("deadlock_graph_xml_truncated", ["DarlingMcpBlockingTools.cs", "McpBlockingTools.cs"],
"#4198: get_deadlock_detail's own wide field — deadlock_graph_xml is a 2000-character preview by default (a busy production store measured 120,454 bytes for 3 graphs), full_graph or a dedup_key call gets the whole XML"),
("query_text_truncated", ["DarlingMcpPlanCorrectionTools.cs", "DarlingMcpSessionTools.cs", "McpPlanCorrectionTools.cs", "McpSessionTools.cs"],
"#4198: query_text is previewed at read time by two tools: get_active_queries previews at 500 chars (full_text gets the whole text; a synthetic 50-row page measured 81,489 bytes), get_plan_corrections previews at 150 chars (full_text gets the whole text; the full text IS in the store, not collector-capped)"),
("query_text_truncated", ["DarlingMcpPlanCorrectionTools.cs", "DarlingMcpQueryStoreRegressionTools.cs", "DarlingMcpSessionTools.cs", "McpPlanCorrectionTools.cs", "McpQueryTools.cs", "McpSessionTools.cs"],
"#4198: query_text is previewed at read time by three tools: get_active_queries at 500 chars (full_text gets the whole text; a synthetic 50-row page measured 81,489 bytes), get_query_store_regressions at 240 chars (full_text opts back in; a busy production store measured 211 KB at default arguments), get_plan_corrections at 150 chars (full_text gets the whole text; the full text IS in the store, not collector-capped)"),
("error_message_truncated", ["DarlingMcpDataTools.cs", "McpHealthTools.cs"],
"#4198: get_collection_log's own wide field — error_message is a 500-character preview by default (a seeded store measured 90,514 bytes for 200 rows at the old 200-row default), full_text opts back into the whole (up to 4000-character, DarlingObservability.LogCollectionAsync's own write-time ceiling) field"),
("blocked_sql_text_truncated", ["DarlingMcpBlockingTools.cs", "McpBlockingTools.cs"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 6 additions & 1 deletion Darling/Darling.Tests/McpToolsListBudgetTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ so neither counts here. */
/* #4198 (lane TB): +364 bytes for get_deadlock_detail's default-preview note in its served description
and its new full_graph opt-in parameter (deadlock_graph_xml, the wide field, is now a 2000-char
preview by default). */
/* #4198 (lane TR): +214 bytes for get_query_store_regressions gaining full_text (its own preview
opt-in, +84 bytes) and its limit description growing to explain the new lower default (+78 bytes
over the old 86). */
/* #4198 (lane TI): +391 bytes for get_blocking's default-preview note in its served description and its
new full_text opt-in parameter (blocked_sql_text/blocking_sql_text are now a 150-char preview by
default). The default row limit also dropped 30 -> 15 -- 30 rows of even sub-2000-char (never
Expand All @@ -127,7 +130,9 @@ of active_queries (#4261) + object_locking (#4258) changes on top of dev. */
combined total with collection_log (#4265) changes on top. */
/* #4198 (blocking, merge): re-measured after merging origin/dev (dev now includes #4261+#4258+#4265);
combined total with blocking (#4267) changes on top. */
private const int TotalCeilingBytes = 172_942;
/* #4198 (qs-regressions, merge): re-measured after merging origin/dev (dev now includes #4261+#4258+#4265+#4267);
combined total with qs-regressions (#4264) changes on top. */
private const int TotalCeilingBytes = 173_157;

private const int ConvertedHeadCap = 1_000;
private const int ConvertedParameterCap = 200;
Expand Down
88 changes: 88 additions & 0 deletions Darling/Darling.Tests/QueryStoreRegressionsWebDefaultTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// #4198 cut <c>get_query_store_regressions</c>'s default <c>query_text</c> to a 240-character preview,
/// gated behind a new <c>full_text</c> opt-in that defaults to <c>false</c> on the MCP signature. The web
/// viewer's <c>/api/read</c> 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 <c>codeDisclosure</c>
/// 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 <c>full_text</c> default at <c>true</c>, independent of (and opposite to)
/// the MCP signature's own default.
/// </summary>
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!;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down Expand Up @@ -2687,7 +2687,11 @@ web viewer keeps that behavior (an operator reading the Collection Log grid gets
["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)),
Expand Down
Loading
Loading