Skip to content
Merged
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
145 changes: 145 additions & 0 deletions Darling/Darling.Tests/DarlingMcpActiveQueriesBudgetLiveTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/*
* 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.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_active_queries' own response-budget pin. A busy production store's default call (limit 50,
/// hours_back 1) measured 56,586 bytes. Unlike get_deadlock_detail's one wide field per row, this tool's
/// overrun is TWENTY-THREE fields repeated across up to 50 rows: query_text is the widest single field, but
/// the fixed columns (collection_time, every wait/blocking/memory field, login_name, host_name,
/// program_name) add up across the page too. Plants 50 rows with realistic field widths — most query_text
/// around 700 characters (a parameterized statement with a modest literal list), ten near 2,800 characters
/// (a big IN-list, the realistic cause of an outsized capture) — and asserts the default call stays under
/// <see cref="McpResponseBudget.DefaultBytes"/>, that <c>full_text: true</c> (renamed from
/// <c>full_query_text</c> to match <c>get_store_query_stats</c>) opts back into the whole
/// text. New file (not the shared seeding in <c>DarlingMcpSessionToolsTests.cs</c>) because #4198 ran a
/// dozen lanes against this store tonight.
/// </summary>
[Collection("live-postgres")]
public sealed class DarlingMcpActiveQueriesBudgetLiveTests
{
private const string ServerName = "darling-mcp-active-queries-budget-e2e";
private static readonly int ServerId = ServerIdHelper.GetDeterministicHashCode(ServerName);
private static string? ConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG");
private readonly ITestOutputHelper _output;

public DarlingMcpActiveQueriesBudgetLiveTests(ITestOutputHelper output) => _output = output;

[Fact]
public async Task GetActiveQueries_Default_StaysUnderResponseBudget_WithFiftyRealisticRows()
{
var cs = ConnectionString;
Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live active-queries 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 baseTime = DarlingMcpTestData.TruncateToSeconds(DateTime.UtcNow).AddMinutes(-49);
var wideText = BuildQueryText(seed: 999, approxLength: 2_800);

for (var i = 0; i < 50; i++)
{
var t = DarlingMcpTestData.Naive(baseTime.AddMinutes(i));
var db = i % 3 == 0 ? "StackOverflow" : i % 3 == 1 ? "AdventureWorks" : "ReportingDW";
var isWide = i >= 40;
var text = isWide ? wideText : BuildQueryText(seed: i, approxLength: 700);
var hasWait = i % 4 == 0;

await DarlingMcpTestData.ExecAsync(connection, ct,
@"INSERT INTO query_snapshots (collection_id, collection_time, server_id, server_name, session_id, database_name, elapsed_time_formatted, query_text, status, blocking_session_id, wait_type, wait_time_ms, cpu_time_ms, total_elapsed_time_ms, reads, writes, logical_reads, granted_query_memory_gb, transaction_isolation_level, dop, parallel_worker_count, login_name, host_name, program_name, open_transaction_count, request_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26)",
CollectionIdGenerator.Next(), t, ServerId, ServerName, 100 + i, db,
"00 00:00:05.125", text, i % 5 == 0 ? "suspended" : "running", 0,
hasWait ? "PAGEIOLATCH_SH" : null, hasWait ? 250L + i : 0L, 1000L + (i * 37), 1500L + (i * 37),
10_000L + (i * 123), 50L + i, i % 7, i % 6 == 0 ? 0.75m : 0m,
i % 2 == 0 ? "Read Committed" : "Repeatable Read", i % 6 == 0 ? 8 : 1, i % 6 == 0 ? 4 : 0,
i % 2 == 0 ? "app_svc_prod" : @"CONTOSO\svc_reporting", $"APPSRV{i % 5:D2}",
i % 2 == 0 ? ".Net SqlClient Data Provider" : "MyOrderService.Worker", i % 3 == 0 ? 1 : 0, 0);
}

/* #4198's own default (25, down from 50) truncates the 50-row population — total_snapshots still
says 50 held, so a caller can tell there was more. */
var defaultJson = await DarlingMcpSessionTools.GetActiveQueries(postgres, ServerName);
DarlingMcpTestData.AssertEnvelope(defaultJson, ServerName, "queries");
JsonAssert.Contains("\"total_snapshots\": 50", defaultJson);
JsonAssert.Contains("\"snapshots_returned\": 25", defaultJson);
JsonAssert.Contains("\"truncated\": true", defaultJson);

var defaultBytes = Encoding.UTF8.GetByteCount(defaultJson);
_output.WriteLine($"get_active_queries default call: {defaultBytes:N0} bytes (budget {McpResponseBudget.DefaultBytes:N0}), 50 planted rows (page 25), 10 with a {wideText.Length:N0}-char query_text.");
Assert.True(defaultBytes < McpResponseBudget.DefaultBytes,
$"get_active_queries' default call is {defaultBytes:N0} bytes over 50 planted rows, at or over the {McpResponseBudget.DefaultBytes:N0}-byte budget.");

using var defaultParsed = JsonDocument.Parse(defaultJson);
var wideRow = defaultParsed.RootElement.GetProperty("queries")[0];
Assert.True(wideRow.GetProperty("query_text").GetString()!.Length < wideText.Length,
"the default call's widest row should preview shorter than the planted text.");
Assert.True(wideRow.GetProperty("query_text_truncated").GetBoolean());

/* full_text opts back into the whole text for every row, including the newest (widest) one. */
var fullJson = await DarlingMcpSessionTools.GetActiveQueries(postgres, ServerName, full_text: true);
Assert.DoesNotContain("\"query_text_truncated\": true", fullJson, StringComparison.Ordinal);
using var fullParsed = JsonDocument.Parse(fullJson);
Assert.Equal(wideText, fullParsed.RootElement.GetProperty("queries")[0].GetProperty("query_text").GetString());

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

/// <summary>Builds ASCII SQL text (a big literal IN-list, a realistic cause of an outsized capture) near
/// <paramref name="approxLength"/> characters, so its length in .NET UTF-16 chars and its size in UTF-8
/// bytes stay close.</summary>
private static string BuildQueryText(int seed, int approxLength)
{
var sb = new StringBuilder();
sb.Append("SELECT o.OrderId, o.CustomerId, o.OrderDate, o.TotalAmount, c.CustomerName, c.Region FROM dbo.Orders o JOIN dbo.Customers c ON c.CustomerId = o.CustomerId WHERE o.OrderDate >= '2026-01-01' AND o.Status IN (");
var i = 0;
while (sb.Length < approxLength)
{
sb.Append(seed * 100_000 + i);
sb.Append(',');
i++;
}

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

private static async Task DeleteRowsAsync(NpgsqlConnection connection, System.Threading.CancellationToken ct)
{
using var cleanup = new NpgsqlCommand(
$"DELETE FROM query_snapshots WHERE server_id = {ServerId}; DELETE FROM servers WHERE server_id = {ServerId};",
connection);
await cleanup.ExecuteNonQueryAsync(ct);
}
}
2 changes: 1 addition & 1 deletion Darling/Darling.Tests/DarlingMcpSessionToolsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ private static (string Name, bool Optional)[] McpParams(string toolName)

[Theory]
[InlineData("get_session_stats", "server_name")]
[InlineData("get_active_queries", "server_name,hours_back,database_name,blocking_only,limit,as_of")]
[InlineData("get_active_queries", "server_name,hours_back,database_name,blocking_only,limit,full_text,as_of")]
[InlineData("get_waiting_tasks", "server_name,hours_back,limit,as_of")]
public void ParamContract_MatchesLite(string toolName, string expectedCsv)
{
Expand Down
15 changes: 15 additions & 0 deletions Darling/Darling.Tests/DarlingWebEndpointsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,21 @@ public void ReadEndpoints_IncludeTheAgHealthTool()
Assert.Contains("get_ag_health", DarlingWebEndpoints.BuildReadDispatch().Keys);
}

[Fact]
public void ReadEndpoints_ActiveQueries_KeepsTheTwoThousandCharacterWebPreview()
{
/* #4198 lane W2: the MCP default fell to a 500-char query_text preview (QueryTextPreviewLength), but
the web viewer isn't that budget's caller — its /api/read row calls the internal budget-taking
overload with an explicit 2000, the pre-#4198 McpHelpers.Truncate budget every caller got, so the
Active Queries tab doesn't shrink under it. A regression here (dropping the overload, or the literal
2000) silently starves that tab's query text down to 500 characters. Source-text pin rather than a
live call: no rig in this lane. */
var source = RepoFile.ReadRepoFileLf("Darling", "PerformanceMonitor.Darling.Service", "DarlingWebEndpoints.cs");
Assert.Contains(
"[\"get_active_queries\"] = (c, pg, an) => DarlingMcpSessionTools.GetActiveQueries(pg, Server(c), Hours(c, 1), Str(c, \"database_name\"), QueryBool(c, \"blocking_only\", false), Rows(c, \"limit\", 50), 2000, AsOf(c)),",
source, StringComparison.Ordinal);
}

/* ── response-kind mapping (the error envelope -> 500, the invalid envelope -> 400 as the body, the '{'-sniff -> 200, miss envelope -> 200) ── */

[Theory]
Expand Down
11 changes: 6 additions & 5 deletions Darling/Darling.Tests/McpPayloadContractCensusTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1475,12 +1475,13 @@ 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 <c>truncated</c> would tell that
/// caller to raise a limit that changes nothing.</item>
/// <item><b>A field-level response-budget preview</b> — <see cref="FieldPreviewCutKeys"/>: #4198 sizes
/// <item><b>A field-level response-budget preview</b> — <see cref="FieldPreviewCutKeys"/>: #4198 sizes
/// each tool's DEFAULT answer under the shared 32 KB <c>McpResponseBudget.DefaultBytes</c> 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 (<c>get_deadlock_detail</c>'s
/// <c>full_graph</c>, the same shape <c>get_store_query_stats</c>' <c>full_text</c> already used) or by
/// narrowing the page to one named thing (a <c>dedup_key</c> call always gets the whole field).</item>
/// <c>full_graph</c>; <c>get_active_queries</c> and <c>get_store_query_stats</c> both take
/// <c>full_text</c>, the same name — get_active_queries' own was renamed from <c>full_query_text</c> to
/// match).</item>
/// <item><b>The withheld summary</b> — <see cref="WithheldSummaryKeys"/>: #3594's own vocabulary for a
/// reach verdict that withholds a figure rather than publishing a page's count under a whole's name.</item>
/// </list>
Expand Down Expand Up @@ -1529,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", "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", ["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)"),
("top_query_text_truncated", ["DarlingMcpQueryHeatmapTools.cs", "McpQueryTools.cs"],
"get_query_heatmap's (#4198) per-cell top-query preview width (DefaultPreviewLength on both SKUs) — the full statement is already in the store; full_text opts back into it rather than re-paging, so this is not the page dialect's truncated and nothing was lost the way a source-side cut loses it"),
];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ tool get_active_queries 616
param get_active_queries.as_of 167
param get_active_queries.blocking_only 134
param get_active_queries.database_name 156
param get_active_queries.full_text 84
param get_active_queries.hours_back 37
param get_active_queries.limit 149
param get_active_queries.limit 194
param get_active_queries.server_name 28

tool get_session_stats 391
Expand Down
19 changes: 14 additions & 5 deletions Darling/Darling.Tests/McpToolsListBudgetTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,15 +99,24 @@ under the shared response budget. */
so neither counts here. */
/* #4192/#4195/#4193/#4217: audit_config narrowed, regression baseline bounded, PG CPU bucketed.
+82 bytes net after trimming. */

/* #4198 (lane TH): +193 bytes for get_active_queries' new full_query_text opt-in parameter and its
limit description's #4198 note (query_text, the wide field, is now a 500-char preview by default;
limit is 25, down from 50); the preview note itself moved after <<GUIDE>> to stay under the head's
own 620-char target, so the served head is unchanged at 616. */
/* #4198 (lane W2): -6 bytes for renaming get_active_queries' full_query_text opt-in to full_text — the
shorter property name in the served schema, not a description change (the param's own description
byte count is unchanged at 84). Lowered to the measured total, banking the saving. */
/* #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 (per-tool lane, get_object_locking): +79 bytes for the new limit parameter (default lowered from
a 200-row hard cap to 75, measured under McpResponseBudget.DefaultBytes on a seeded fixture). Merged
with origin/dev's own #4192/#4195/#4193/#4217 bump above; the constant below is the measured total
with both changes applied, not the two deltas added by hand. */
/* #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_446;

/* #4198 (lane TH, merge with get_object_locking): re-measured after merging origin/dev; combined total
of active_queries (#4261) + object_locking (#4258) changes on top of dev. */
private const int TotalCeilingBytes = 172_632;
private const int ConvertedHeadCap = 1_000;
private const int ConvertedParameterCap = 200;

Expand Down
4 changes: 4 additions & 0 deletions Darling/Darling.Tests/RepoFileAdoptionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,10 @@ the CODE shape is told apart from the same words inside the comments beside it.
/// </summary>
private static readonly string[] s_lfSubjects =
{
/* #4198 (lane W2): its source-text pin asserts a single-line entry in the endpoint dispatch
table (the `get_active_queries` row in DarlingWebEndpoints.cs) — the pin contains no line
break, so LF normalization does not affect what the assertion can match. */
"DarlingWebEndpointsTests.cs",
/* The equivalence test FOR the reader, parameterised over both spellings and calling each of them
directly. It compares the reader's output against the bytes on disk put through the same
transform, so it performs the CRLF-to-LF normalisation itself rather than depending on the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2607,7 +2607,7 @@ internal static IReadOnlyDictionary<string, ReadToolHandler> BuildReadDispatch(I
["get_analysis_findings"] = (c, pg, an) => DarlingMcpTools.GetAnalysisFindings(an, pg, Server(c), Hours(c, 24), QueryBool(c, "include_drilldown", false), as_of: AsOf(c)),

/* ── sessions ── */
["get_active_queries"] = (c, pg, an) => DarlingMcpSessionTools.GetActiveQueries(pg, Server(c), Hours(c, 1), Str(c, "database_name"), QueryBool(c, "blocking_only", false), Rows(c, "limit", 50), as_of: AsOf(c)),
["get_active_queries"] = (c, pg, an) => DarlingMcpSessionTools.GetActiveQueries(pg, Server(c), Hours(c, 1), Str(c, "database_name"), QueryBool(c, "blocking_only", false), Rows(c, "limit", 50), 2000, AsOf(c)),
["get_session_stats"] = (c, pg, an) => DarlingMcpSessionTools.GetSessionStats(pg, Server(c)),
["get_waiting_tasks"] = (c, pg, an) => DarlingMcpSessionTools.GetWaitingTasks(pg, Server(c), Hours(c, 1), Rows(c, "limit", 30), as_of: AsOf(c)),

Expand Down
Loading
Loading