Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Darling/Darling.Tests/DarlingCustomViewsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ public void Catalog_ParamsNameTheActualWireQueryKeys_NotCSharpParamNames()
{
/* Builder 2 binds these keys onto ?query=strings, so they MUST be the literal keys the dispatch lambdas
read — the whole reason the metadata is hand-authored (reflection would emit the C# param names). */
AssertParamKeys("get_query_store_top", "server", "hours", "top", "database_name", "as_of", "execution_type", "module_name");
AssertParamKeys("get_query_store_top", "server", "hours", "top", "database_name", "as_of", "execution_type", "module_name", "full_text");
AssertParamKeys("get_wait_stats", "server", "hours", "limit", "as_of");
AssertParamKeys("get_wait_trend", "wait_type", "server", "hours", "as_of", "bucket_minutes");
AssertParamKeys("get_plan_xml", "query_hash", "server", "database_name");
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 @@ -1529,8 +1529,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", "McpPlanCorrectionTools.cs"],
"the row's own query_text previewed at READ TIME to QueryTextPreviewLength (150 chars) for #4198's response-size budget - the full text IS in the store (plan_correction.query_text is not collector-capped the way SourceSideCutKeys' rows are) and a caller gets it back by passing full_text=true, the opt-in get_store_query_stats already offers for its own preview"),
("query_text_truncated", ["DarlingMcpDataTools.cs", "DarlingMcpPlanCorrectionTools.cs", "McpPlanCorrectionTools.cs", "McpQueryTools.cs"],
"the row's own query_text previewed at READ TIME to a QueryTextPreviewLength constant (150 chars on plan_correction, 400 on get_query_store_top -- each tool measured its own row width) for #4198's response-size budget - the full text IS in the store (plan_correction.query_text and query_store_stats.query_text are not collector-capped the way SourceSideCutKeys' rows are) and a caller gets it back by passing full_text=true, the opt-in get_store_query_stats already offers for its own preview. get_query_store_top's MCP signature forwards to an internal previewLength overload so the web viewer can keep the OLD 2000-char cap that field already had, rather than switching to full text the way get_deadlock_detail's never-capped field does"),
];

public static readonly (string Key, string[] Files, string WhatIsWithheld)[] WithheldSummaryKeys =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ tool get_query_store_top 422
param get_query_store_top.as_of 167
param get_query_store_top.database_name 30
param get_query_store_top.execution_type 72
param get_query_store_top.full_text 84
param get_query_store_top.hours_back 29
param get_query_store_top.module_name 186
param get_query_store_top.server_name 28
Expand Down
7 changes: 6 additions & 1 deletion Darling/Darling.Tests/McpToolsListBudgetTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,12 @@ 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). */
private const int TotalCeilingBytes = 172_220;
/* #4198 (lane TJ, get_query_store_top): +136 bytes for the new full_text opt-in parameter (84 bytes of
description plus its JSON schema wrapper). The head is unchanged (its new sentence lives after
<<GUIDE>>, in the tail get_tool_guide serves, not the served head); the default query_text preview
dropped 2,000 chars -> 400, which took the default call from 48 KB (#4198's measurement) to under the
shared 32 KB budget and is not a served description either. */
private const int TotalCeilingBytes = 172_356;

private const int ConvertedHeadCap = 1_000;
private const int ConvertedParameterCap = 200;
Expand Down
225 changes: 225 additions & 0 deletions Darling/Darling.Tests/QueryStoreTopBudgetLiveTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
/*
* 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: <c>get_query_store_top</c> at default arguments (top=20) measured 48 KB on a busy production
/// store's single server -- over the shared 32 KB response budget (<see cref="McpResponseBudget.DefaultBytes"/>).
/// The default page's <c>query_text</c> was truncated only at 2,000 characters, with no opt-in for the whole
/// statement and no disclosure that a row had been cut.
///
/// <para>Twenty-five rows across five query-text lengths (120 to 2,600 characters -- some land under the new
/// 400-character preview, some between it and the old 2,000-character cap, some past even that) reproduce the
/// shape here without a production store. Twenty-five so the tool's own top+5 over-fetch (for the WAITFOR
/// self-exclusion) has a full house to rank from at the default top=20.</para>
/// </summary>
[Collection("live-postgres")]
public sealed class QueryStoreTopBudgetLiveTests
{
private const string ServerName = "query-store-top-budget-4198";
private static readonly int ServerId = ServerIdHelper.GetDeterministicHashCode(ServerName);

/// <summary>One length lands under the new preview, two land between it and the old cap, two land past
/// the old cap entirely -- so both the new query_text_truncated boundary and the old blanket 2,000-char
/// truncation it replaces are exercised.</summary>
private static readonly int[] QueryTextLengths = { 120, 600, 1400, 2000, 2600 };

private const int RowCount = 25;

private static string? ConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG");

[Fact]
public async Task DefaultCall_StaysUnderTheResponseBudget_AndFullTextOptInStillGetsTheWholeStatement()
{
var cs = ConnectionString;
Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live query-store-top budget census.");

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 longestQueryText = await SeedAsync(connection, ct);

var defaultAnswer = await DarlingMcpDataTools.GetQueryStoreTop(postgres, ServerName);
Assert.False(McpHelpers.IsErrorEnvelope(defaultAnswer), $"tool returned an error: {defaultAnswer}");

var defaultBytes = Encoding.UTF8.GetByteCount(defaultAnswer);
Assert.True(
defaultBytes < McpResponseBudget.DefaultBytes,
$"get_query_store_top at default arguments answered {defaultBytes} bytes, ranked from "
+ $"{RowCount} seeded rows spanning query_text lengths {string.Join(",", QueryTextLengths)} "
+ $"-- over the {McpResponseBudget.DefaultBytes}-byte budget (#4198 measured 48 KB on a "
+ "production store's single server).");

using var defaultDoc = JsonDocument.Parse(defaultAnswer);
var defaultRoot = defaultDoc.RootElement;
var queries = defaultRoot.GetProperty("queries").EnumerateArray();

var sawTruncatedPreview = false;
var sawUntouchedPreview = false;
var rowCount = 0;
foreach (var row in queries)
{
rowCount++;
var preview = row.GetProperty("query_text").GetString();
Assert.NotNull(preview);
var wasTruncated = row.GetProperty("query_text_truncated").GetBoolean();

if (wasTruncated)
{
sawTruncatedPreview = true;
Assert.True(preview!.Length < 2000, "a row marked query_text_truncated still carried the old 2,000-character preview -- the wide field was not cut.");
}
else
{
sawUntouchedPreview = true;
}
}

Assert.Equal(20, rowCount);
Assert.True(sawTruncatedPreview, "no row in the default page reported query_text_truncated=true, so the 1,400/2,000/2,600-character seeded rows were not previewed.");
Assert.True(sawUntouchedPreview, "no row in the default page reported query_text_truncated=false, so a short seeded row (120-char) was previewed when it should not have been.");

/* ── the explicit ask still gets the whole thing (#4198's "keep the envelope honest") ── */
var fullTextAnswer = await DarlingMcpDataTools.GetQueryStoreTop(postgres, ServerName, top: 1, full_text: true);
Assert.False(McpHelpers.IsErrorEnvelope(fullTextAnswer), $"tool returned an error: {fullTextAnswer}");

using var fullTextDoc = JsonDocument.Parse(fullTextAnswer);
var fullRow = Assert.Single(fullTextDoc.RootElement.GetProperty("queries").EnumerateArray());
Assert.Equal(longestQueryText, fullRow.GetProperty("query_text").GetString());
Assert.False(fullRow.GetProperty("query_text_truncated").GetBoolean());

bodySucceeded = true;
}
finally
{
await LiveStoreCleanup.RunAsync(cs!, bodySucceeded, async (cleanup, cleanupCt) =>
await DeleteRowsAsync(cleanup, cleanupCt));
}
}

/// <summary>
/// Twenty-five distinct (database, query_id, plan_id) groups, twenty minutes apart so all fall inside the
/// default 24-hour window; execution_count grows with <c>i</c> so the ranking (SUM(execution_count) *
/// AVG(avg_duration_us)) is deterministic and the most expensive row is the last one seeded -- the one
/// whose full, untruncated text the full_text assertion checks.
/// </summary>
private static async Task<string> SeedAsync(NpgsqlConnection connection, CancellationToken ct)
{
var newest = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-2));
string? mostExpensiveQueryText = null;
var highestCost = -1L;

for (var i = 0; i < RowCount; i++)
{
var collectionTime = newest.AddMinutes(-20 * (RowCount - i));
var databaseName = $"query_store_top_budget_db_{i % 4}";
var queryId = 5_000_000_000L + i;
var planId = 6_000_000_000L + i;
var queryText = BuildQueryText(i, QueryTextLengths[i % QueryTextLengths.Length]);
var executionCount = 100L + (i * 37);
var avgDurationUs = 5_000L + (i * 211);
var cost = executionCount * avgDurationUs;

if (cost > highestCost)
{
highestCost = cost;
mostExpensiveQueryText = queryText;
}

using var command = new NpgsqlCommand(
"""
INSERT INTO query_store_stats
(collection_id, collection_time, server_id, server_name, database_name, query_id, plan_id,
execution_type_desc, last_execution_time, module_name, query_text, query_hash, query_plan_hash,
execution_count, avg_duration_us, avg_cpu_time_us, avg_logical_io_reads, avg_logical_io_writes,
avg_physical_io_reads, avg_rowcount)
VALUES
($1, $2, $3, $4, $5, $6, $7,
'Regular', $2, $8, $9, $10, $11,
$12, $13, $14, $15, $16,
$17, $18)
""", connection);

command.Parameters.AddWithValue(CollectionIdGenerator.Next());
command.Parameters.AddWithValue(collectionTime);
command.Parameters.AddWithValue(ServerId);
command.Parameters.AddWithValue(ServerName);
command.Parameters.AddWithValue(databaseName);
command.Parameters.AddWithValue(queryId);
command.Parameters.AddWithValue(planId);
command.Parameters.AddWithValue((object?)(i % 3 == 0 ? null : $"dbo.usp_QueryStoreBudgetProbe_{i}") ?? DBNull.Value);
command.Parameters.AddWithValue(queryText);
command.Parameters.AddWithValue("0xQ" + queryId.ToString(System.Globalization.CultureInfo.InvariantCulture));
command.Parameters.AddWithValue("0xP" + planId.ToString(System.Globalization.CultureInfo.InvariantCulture));
command.Parameters.AddWithValue(executionCount);
command.Parameters.AddWithValue(avgDurationUs);
command.Parameters.AddWithValue(avgDurationUs / 2);
command.Parameters.AddWithValue(120.5 + i);
command.Parameters.AddWithValue(3.25 + (i % 5));
command.Parameters.AddWithValue(45.0 + i);
command.Parameters.AddWithValue(1_000.0 + (i * 10));
await command.ExecuteNonQueryAsync(ct);
}

return mostExpensiveQueryText!;
}

/// <summary>
/// A synthetic but Query-Store-shaped statement, an IN-list padded out to <paramref name="length"/>
/// characters -- the shape a heavy analytical statement with a wide filter list actually has, rather than
/// repeated filler that would not exercise anything about how a real query reads.
/// </summary>
private static string BuildQueryText(int index, int length)
{
var sb = new StringBuilder(length + 64);
sb.Append("SELECT o.OrderId, o.CustomerId, o.OrderDate, o.TotalAmount FROM Sales.Orders AS o ")
.Append("WHERE o.RegionId = ").Append(index % 12).Append(" AND o.StatusCode IN (");

var n = 0;
while (sb.Length < length)
{
sb.Append(n).Append(',');
n++;
}

sb.Append(") ORDER BY o.OrderDate DESC;");
return sb.ToString()[..length];
}

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};", connection);
await cleanup.ExecuteNonQueryAsync(ct);
}

private static DateTime TruncateToSeconds(DateTime value) =>
DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond)), DateTimeKind.Unspecified);
}
101 changes: 101 additions & 0 deletions Darling/Darling.Tests/QueryStoreTopWebDefaultTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* 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_top</c>'s default <c>query_text</c> to a 400-character preview, gated behind
/// a new <c>full_text</c> opt-in that defaults to <c>false</c> on the MCP signature. Unlike
/// <c>get_deadlock_detail</c>'s <c>deadlock_graph_xml</c> (<see cref="DeadlockDetailWebDefaultTests"/>), this
/// field already had a cap before #4198: a blanket, undisclosed 2,000-character truncation. The web viewer's
/// <c>/api/read</c> dispatch calls the same MCP method, so without its own default it would silently inherit
/// the new 400-character preview -- a real shrink, not just a change of number, since the viewer has always
/// shown up to 2,000 characters. This pins the web row to that exact old number through the internal
/// <c>previewLength</c> overload (the same shape #3897's trend tools use for <c>TrendBudget.Chart</c>), not to
/// <c>full_text: true</c>: full text would be MORE than the viewer ever rendered, which is still a change.
/// </summary>
public sealed class QueryStoreTopWebDefaultTests
{
private const string WebEndpoints =
"Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs";

[Fact]
public void GetQueryStoreTop_WebRowKeepsTheOld2000CharacterPreview()
{
var web = StripComments(ReadSource(WebEndpoints));

var marker = "[\"get_query_store_top\"] = (c, pg, an) => DarlingMcpDataTools.GetQueryStoreTop(";
var start = web.IndexOf(marker, StringComparison.Ordinal);
Assert.True(start >= 0, "get_query_store_top's /api/read row was not found (renamed or moved?).");
var end = web.IndexOf("),\r\n", start, StringComparison.Ordinal);
if (end < 0) end = web.IndexOf("),\n", start, StringComparison.Ordinal);
Assert.True(end > start, "could not find the end of get_query_store_top's /api/read row.");
var row = web[start..end];

/* Other rows on this same page (get_plan_corrections, get_deadlock_detail) legitimately default
their own full_text/full_graph to true, because THEIR field had no cap before #4198 — so the
negative check below is scoped to this tool's own row, not the whole file. */
Assert.Contains("previewLength: 2000", row, StringComparison.Ordinal);
Assert.Contains("full_text: QueryBool(c, \"full_text\", false)", row, StringComparison.Ordinal);
Assert.DoesNotContain("full_text: QueryBool(c, \"full_text\", true)", row, 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!;
}
}
Loading
Loading