Skip to content
Merged
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 @@ -1539,8 +1539,8 @@ public static readonly (string Key, string[] Files, string WhatWasCut)[] FieldPr
"#4198: get_analysis_findings' advice.investigation / advice.remediation — free prose repeated on every finding — preview to 160 characters by default; full_text returns both whole. remediation_command is never previewed at any setting, so it carries no *_truncated key of its own"),
("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", "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)"),
("query_text_truncated", ["DarlingMcpDataTools.cs", "DarlingMcpPlanCorrectionTools.cs", "DarlingMcpQueryStoreRegressionTools.cs", "DarlingMcpSessionTools.cs", "McpPlanCorrectionTools.cs", "McpQueryTools.cs", "McpSessionTools.cs"],
"#4198: query_text is previewed at read time by four 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), get_query_store_top at 400 chars (full_text gets the whole text; query_store_stats.query_text is not collector-capped either). 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"),
("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 @@ -48,6 +48,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
9 changes: 8 additions & 1 deletion Darling/Darling.Tests/McpToolsListBudgetTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,14 @@ combined total with blocking (#4267) changes on top. */
combined total with collection-health (#4268) changes on top. */
/* #4198 (custom-view-catalog, merge): re-measured after merging origin/dev (dev includes #4261+#4258+#4265+#4267+#4264+#4266+#4268);
combined total with custom-view-catalog (#4272) changes on top. */
private const int TotalCeilingBytes = 174_236;
/* #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. */
/* #4198 (qs-top, merge): re-measured after merging origin/dev (dev now includes #4261+#4258+#4265+#4267+#4264+#4266+#4268+#4272);
combined total with qs-top (#4273) changes on top. */
private const int TotalCeilingBytes = 174_373;


private const int ConvertedHeadCap = 1_000;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,14 @@ public void GetQueryStoreRegressions_WebRowKeepsTheFullQueryTextByDefault()

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);
/* Scoped to this tool's own dispatch entry: get_query_store_top's web entry keeps full_text OFF by
default on purpose (#4273 - the viewer keeps that field's old 2,000-char cap through previewLength),
so a whole-file check would flag the other tool's deliberate default rather than this one. */
var regressionsEntry = Array.Find(
web.Split('\n'),
line => line.Contains("[\"get_query_store_regressions\"] = (c, pg, an) =>", StringComparison.Ordinal));
Assert.NotNull(regressionsEntry);
Assert.DoesNotContain("full_text: QueryBool(c, \"full_text\", false)", regressionsEntry, StringComparison.Ordinal);
}

private static string StripComments(string source)
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);
}
Loading
Loading