From af916fec7457f239b6ea3fe314d67a19a232e410 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:21:53 -0400 Subject: [PATCH 1/5] Size get_active_queries' defaults under the #4198 response budget A busy production store's default call (limit 50) measured 56,586 bytes, over McpResponseBudget.DefaultBytes (32 KB). Twenty-three fields per row mean the fixed columns alone run about 700 bytes/row before query_text is counted, so both the default row limit (50 -> 25) and query_text's preview (now 500 chars, full_query_text opts back in) come down, on both Darling and Lite. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- .../DarlingMcpActiveQueriesBudgetLiveTests.cs | 144 ++++++++++++++++++ .../McpPayloadContractCensusTests.cs | 13 ++ .../DarlingMcpSessionTools.txt | 5 +- .../Darling.Tests/McpToolsListBudgetTests.cs | 5 +- .../Mcp/DarlingMcpSessionTools.cs | 26 +++- Lite.Tests/McpPageContractTests.cs | 78 ++++++++++ .../McpToolsListBudget/McpSessionTools.txt | 5 +- Lite.Tests/McpToolsListBudgetTests.cs | 5 +- Lite/Mcp/McpSessionTools.cs | 15 +- 9 files changed, 284 insertions(+), 12 deletions(-) create mode 100644 Darling/Darling.Tests/DarlingMcpActiveQueriesBudgetLiveTests.cs diff --git a/Darling/Darling.Tests/DarlingMcpActiveQueriesBudgetLiveTests.cs b/Darling/Darling.Tests/DarlingMcpActiveQueriesBudgetLiveTests.cs new file mode 100644 index 000000000..c7c62b141 --- /dev/null +++ b/Darling/Darling.Tests/DarlingMcpActiveQueriesBudgetLiveTests.cs @@ -0,0 +1,144 @@ +/* + * 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; + +/// +/// #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 +/// , that full_query_text: true opts back into the whole +/// text. New file (not the shared seeding in ) because #4198 ran a +/// dozen lanes against this store tonight. +/// +[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_query_text opts back into the whole text for every row, including the newest (widest) one. */ + var fullJson = await DarlingMcpSessionTools.GetActiveQueries(postgres, ServerName, full_query_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)); + } + } + + /// Builds ASCII SQL text (a big literal IN-list, a realistic cause of an outsized capture) near + /// characters, so its length in .NET UTF-16 chars and its size in UTF-8 + /// bytes stay close. + 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); + } +} diff --git a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs index a0120b5ad..be89bf6fc 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, get_active_queries' full_query_text, the same shape + /// get_store_query_stats' full_text already used). /// 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", ["DarlingMcpSessionTools.cs", "McpSessionTools.cs"], + "#4198: get_active_queries' own wide field — query_text is a 500-character preview by default (a synthetic 50-row page shaped like a busy server measured 81,489 bytes), full_query_text gets the whole text"), + ]; + 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/DarlingMcpSessionTools.txt b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpSessionTools.txt index 4f725a71e..131943bf9 100644 --- a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpSessionTools.txt +++ b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpSessionTools.txt @@ -1,10 +1,11 @@ # DarlingMcpSessionTools: tools/list budget for #3898. Ceilings only go down; see McpToolsListBudgetTests. One block per tool, blank line between blocks. -tool get_active_queries 616 +tool get_active_queries 723 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_query_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 diff --git a/Darling/Darling.Tests/McpToolsListBudgetTests.cs b/Darling/Darling.Tests/McpToolsListBudgetTests.cs index 2feef4e28..73dc22163 100644 --- a/Darling/Darling.Tests/McpToolsListBudgetTests.cs +++ b/Darling/Darling.Tests/McpToolsListBudgetTests.cs @@ -84,7 +84,10 @@ public McpToolsListBudgetTests(ITestOutputHelper output) 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 each tool's tail (get_tool_guide), which is not served in tools/list and so is not counted here. */ - private const int TotalCeilingBytes = 171_637; + /* #4198 (lane TH): +305 bytes for get_active_queries' default-preview note in its served description, + its 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). */ + private const int TotalCeilingBytes = 171_942; private const int ConvertedHeadCap = 1_000; private const int ConvertedParameterCap = 200; diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs index 680db874d..e951512a0 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs @@ -92,14 +92,33 @@ public static async Task GetSessionStats( } } - [McpServerTool(Name = "get_active_queries"), Description("Active query snapshots (sys.dm_exec_requests) in a window ending at as_of: query text, wait, CPU/elapsed ms, blocking, DOP, memory grant GB. database_name/blocking_only filter IN SQL: total_snapshots is the filtered count; truncated means over limit. wait_time_ms, dop, granted_query_memory_gb, open_transaction_count: null = zero or not applicable. Head blockers are never stripped (is_head_blocker); a victim's blocker_not_shown is not_captured, filtered, or past_page. Empty: no snapshot in the window, or none matching filters; not_collected (unfiltered only): engine can't run it. <> Gets active query snapshots captured from sys.dm_exec_requests. Shows what queries were running at each collection point: session ID, query text, wait type, CPU time, elapsed time, blocking info, DOP, and memory grants. Use hours_back to look at a specific time window — critical for finding what was running during a CPU spike or blocking event. EVERY FILTER IS PART OF THE QUERY: database_name and blocking_only are applied in SQL before the page is cut, total_snapshots is the count of snapshot rows in the window that pass your filters, snapshots_returned is how many you got, and truncated says the filtered population held more than limit — raise limit or narrow hours_back when it is true (NEWEST CAPTURE FIRST, highest CPU first within a capture; oldest_returned_collection_time / newest_returned_collection_time bound the page). Applied in SQL, so total_snapshots counts the blocking population and truncated is measured against it. HEAD BLOCKERS ARE NEVER STRIPPED: a session another row in the same capture names as its blocker is on the page whatever its text (including a WAITFOR shell holding locks), flagged is_head_blocker. A victim whose blocker is NOT on the page says why in blocker_not_shown: not_captured (the blocker held no running request at that capture — an idle open transaction is the classic case; get_blocking has its input buffer from the blocked-process report), filtered (your database_name filter excluded it), or past_page (it is in the filtered population but beyond limit).")] + /// + /// #4198: two things make get_active_queries' default call wide, not one dominant field the way + /// get_deadlock_detail's deadlock_graph_xml is. This row has TWENTY-THREE fields, so the fixed columns + /// alone (collection_time, every wait/blocking/memory field, login_name, host_name, program_name) run + /// about 700 bytes/row before query_text is counted — 50 of them is already near the budget on an + /// empty query_text. A busy production store's default call (limit 50, hours_back 1) measured 56,586 + /// bytes; a synthetic 50-row page shaped like a busy server (every optional field populated, a fifth of + /// the rows carrying a long literal list) measured 81,489. So both come down: the default row limit to + /// , and query_text previews to + /// (full_query_text opts back in, the shape get_store_query_stats' full_text and + /// get_deadlock_detail's full_graph already use). truncated / total_snapshots + /// already tell a caller who wants more to raise limit or narrow hours_back. + /// + private const int QueryTextPreviewLength = 500; + + /// + private const int DefaultLimit = 25; + + [McpServerTool(Name = "get_active_queries"), Description("Active query snapshots (sys.dm_exec_requests) in a window ending at as_of: query text, wait, CPU/elapsed ms, blocking, DOP, memory grant GB. database_name/blocking_only filter IN SQL: total_snapshots is the filtered count; truncated means over limit. wait_time_ms, dop, granted_query_memory_gb, open_transaction_count: null = zero or not applicable. Head blockers are never stripped (is_head_blocker); a victim's blocker_not_shown is not_captured, filtered, or past_page. query_text is a preview by default (query_text_truncated: true) — pass full_query_text for the whole text. Empty: no snapshot in the window, or none matching filters; not_collected (unfiltered only): engine can't run it. <> Gets active query snapshots captured from sys.dm_exec_requests. Shows what queries were running at each collection point: session ID, query text, wait type, CPU time, elapsed time, blocking info, DOP, and memory grants. Use hours_back to look at a specific time window — critical for finding what was running during a CPU spike or blocking event. EVERY FILTER IS PART OF THE QUERY: database_name and blocking_only are applied in SQL before the page is cut, total_snapshots is the count of snapshot rows in the window that pass your filters, snapshots_returned is how many you got, and truncated says the filtered population held more than limit — raise limit or narrow hours_back when it is true (NEWEST CAPTURE FIRST, highest CPU first within a capture; oldest_returned_collection_time / newest_returned_collection_time bound the page). Applied in SQL, so total_snapshots counts the blocking population and truncated is measured against it. HEAD BLOCKERS ARE NEVER STRIPPED: a session another row in the same capture names as its blocker is on the page whatever its text (including a WAITFOR shell holding locks), flagged is_head_blocker. A victim whose blocker is NOT on the page says why in blocker_not_shown: not_captured (the blocker held no running request at that capture — an idle open transaction is the classic case; get_blocking has its input buffer from the blocked-process report), filtered (your database_name filter excluded it), or past_page (it is in the filtered population but beyond limit).")] public static async Task GetActiveQueries( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of data to retrieve. Default 1.")] int hours_back = 1, [Description("Filter to a specific database. Applied in SQL; a head blocker in ANOTHER database is then not on the page, and its victims say blocker_not_shown = filtered.")] string? database_name = null, [Description("Show only queries involved in blocking: rows with blocking_session_id > 0, plus the head blockers those rows name in the same capture.")] bool blocking_only = false, - [Description("Maximum number of rows to return. Default 50. The page is bounded by limit, not by hours_back — truncated says whether the filtered window held more.")] int limit = 50, + [Description("Maximum number of rows to return. Default 25 (#4198, down from 50 — sized to fit the response budget). The page is bounded by limit, not hours_back — truncated says whether the window held more.")] int limit = DefaultLimit, + [Description("Return each row's full query text instead of a 500-character preview. Default false.")] bool full_query_text = false, [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); @@ -173,7 +192,8 @@ database_name or blocking_only means the window held no snapshot matching them. login_name = r.LoginName, host_name = r.HostName, program_name = r.ProgramName, - query_text = McpHelpers.Truncate(r.QueryText, 2000) + query_text = full_query_text ? r.QueryText : McpHelpers.Truncate(r.QueryText, QueryTextPreviewLength), + query_text_truncated = !full_query_text && (r.QueryText?.Length ?? 0) > QueryTextPreviewLength }).ToList(); return JsonSerializer.Serialize(new diff --git a/Lite.Tests/McpPageContractTests.cs b/Lite.Tests/McpPageContractTests.cs index 368b97c36..7ef7d6872 100644 --- a/Lite.Tests/McpPageContractTests.cs +++ b/Lite.Tests/McpPageContractTests.cs @@ -560,6 +560,84 @@ page and its victim says so. */ Assert.StartsWith("Invalid hours_back value '-1'", McpHelpers.ErrorMessageOf(await McpHealthTools.GetBlockingStats(_dataService, _serverManager, ServerName, -1)), StringComparison.Ordinal); } + /// + /// #4198: get_active_queries' own response-budget pin. Darling's twin + /// (DarlingMcpActiveQueriesBudgetLiveTests) measured 81,489 bytes for a synthetic 50-row page + /// shaped like a busy server (every optional field populated, a fifth of the rows carrying a long + /// literal list) — this tool has TWENTY-THREE fields per row rather than one dominant wide field, so the + /// fixed columns add up across the page even before query_text is counted. Plants the same shape and + /// asserts the default call (limit down to 25) stays under , + /// and that full_query_text: true opts back into the whole text. + /// + [Fact] + public async Task GetActiveQueries_Default_StaysUnderResponseBudget_WithFiftyRealisticRows() + { + var baseTime = WholeSecondsNow().AddMinutes(-49); + var wideText = BuildQueryText(seed: 999, approxLength: 2_800); + + for (var i = 0; i < 50; i++) + { + var t = baseTime.AddMinutes(i); + var db = i % 3 == 0 ? "StackOverflow" : i % 3 == 1 ? "AdventureWorks" : "ReportingDW"; + var text = i >= 40 ? wideText : BuildQueryText(seed: i, approxLength: 700); + var hasWait = i % 4 == 0; + + await ExecAsync(@" +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) +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)", + _nextId--, Naive(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.75 : 0, + 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); + } + + /* Not AssertPage: get_active_queries' total_snapshots is a deliberate exception to "no total_* key" + (#3541 A13) — the FILTERED population's own size, not a window count. */ + var defaultJson = await McpSessionTools.GetActiveQueries(_dataService, _serverManager, ServerName); + var root = Parse(defaultJson); + Assert.Equal(25, root.GetProperty("queries").GetArrayLength()); + Assert.Equal(25, root.GetProperty("snapshots_returned").GetInt32()); + Assert.Equal(50, root.GetProperty("total_snapshots").GetInt64()); + Assert.True(root.GetProperty("truncated").GetBoolean()); + + var defaultBytes = System.Text.Encoding.UTF8.GetByteCount(defaultJson); + 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."); + + var wideRow = root.GetProperty("queries")[0]; + Assert.True(wideRow.GetProperty("query_text").GetString()!.Length < wideText.Length); + Assert.True(wideRow.GetProperty("query_text_truncated").GetBoolean()); + + var fullJson = await McpSessionTools.GetActiveQueries(_dataService, _serverManager, ServerName, full_query_text: true); + var fullRoot = Parse(fullJson); + Assert.False(fullRoot.GetProperty("queries")[0].GetProperty("query_text_truncated").GetBoolean()); + Assert.Equal(wideText, fullRoot.GetProperty("queries")[0].GetProperty("query_text").GetString()); + } + + /// Builds ASCII SQL text (a big literal IN-list, a realistic cause of an outsized capture) near + /// characters, so its length and its UTF-8 byte count stay close. + private static string BuildQueryText(int seed, int approxLength) + { + var sb = new System.Text.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(); + } + /* ───────────────────────── #3739: a refusal carries a status word ───────────────────────── */ /// diff --git a/Lite.Tests/McpToolsListBudget/McpSessionTools.txt b/Lite.Tests/McpToolsListBudget/McpSessionTools.txt index c3ba7c48c..89d78953e 100644 --- a/Lite.Tests/McpToolsListBudget/McpSessionTools.txt +++ b/Lite.Tests/McpToolsListBudget/McpSessionTools.txt @@ -1,10 +1,11 @@ # McpSessionTools: tools/list budget for #3898. Ceilings only go down; see McpToolsListBudgetTests. One block per tool, blank line between blocks. -tool get_active_queries 616 +tool get_active_queries 723 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_query_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 diff --git a/Lite.Tests/McpToolsListBudgetTests.cs b/Lite.Tests/McpToolsListBudgetTests.cs index 4dc551590..edc18d2bb 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 (lane TH): +305 bytes for get_active_queries' default-preview note in its served description, + its new full_query_text opt-in parameter, and its limit description's #4198 note. Matches Darling's + twin change exactly. */ + private const int TotalCeilingBytes = 90_186; private const int ConvertedHeadCap = 1_000; private const int ConvertedParameterCap = 200; diff --git a/Lite/Mcp/McpSessionTools.cs b/Lite/Mcp/McpSessionTools.cs index 27a7f8af3..8022a693c 100644 --- a/Lite/Mcp/McpSessionTools.cs +++ b/Lite/Mcp/McpSessionTools.cs @@ -9,7 +9,14 @@ namespace PerformanceMonitorLite.Mcp; [McpServerToolType] public sealed class McpSessionTools { - [McpServerTool(Name = "get_active_queries"), Description("Active query snapshots (sys.dm_exec_requests) in a window ending at as_of: query text, wait, CPU/elapsed ms, blocking, DOP, memory grant GB. database_name/blocking_only filter IN SQL: total_snapshots is the filtered count; truncated means over limit. wait_time_ms, dop, granted_query_memory_gb, open_transaction_count: null = zero or not applicable. Head blockers are never stripped (is_head_blocker); a victim's blocker_not_shown is not_captured, filtered, or past_page. Empty: no snapshot in the window, or none matching filters; not_collected (unfiltered only): engine can't run it. <> Gets active query snapshots captured from sys.dm_exec_requests. Shows what queries were running at each collection point: session ID, query text, wait type, CPU time, elapsed time, blocking info, DOP, and memory grants. Use hours_back to look at a specific time window — critical for finding what was running during a CPU spike or blocking event. EVERY FILTER IS PART OF THE QUERY: database_name and blocking_only are applied in SQL before the page is cut, total_snapshots is the count of snapshot rows in the window that pass your filters, snapshots_returned is how many you got, and truncated says the filtered population held more than limit — raise limit or narrow hours_back when it is true (NEWEST CAPTURE FIRST, highest CPU first within a capture; oldest_returned_collection_time / newest_returned_collection_time bound the page). Applied in SQL, so total_snapshots counts the blocking population and truncated is measured against it. HEAD BLOCKERS ARE NEVER STRIPPED: a session another row in the same capture names as its blocker is on the page whatever its text (including a WAITFOR shell holding locks), flagged is_head_blocker. A victim whose blocker is NOT on the page says why in blocker_not_shown: not_captured (the blocker held no running request at that capture — an idle open transaction is the classic case; get_blocked_process_reports has its input buffer from the blocked-process report), filtered (your database_name filter excluded it), or past_page (it is in the filtered population but beyond limit).")] + /// #4198: see Darling's DarlingMcpSessionTools.QueryTextPreviewLength twin for the + /// measured bytes both defaults come down from. + private const int QueryTextPreviewLength = 500; + + /// + private const int DefaultLimit = 25; + + [McpServerTool(Name = "get_active_queries"), Description("Active query snapshots (sys.dm_exec_requests) in a window ending at as_of: query text, wait, CPU/elapsed ms, blocking, DOP, memory grant GB. database_name/blocking_only filter IN SQL: total_snapshots is the filtered count; truncated means over limit. wait_time_ms, dop, granted_query_memory_gb, open_transaction_count: null = zero or not applicable. Head blockers are never stripped (is_head_blocker); a victim's blocker_not_shown is not_captured, filtered, or past_page. query_text is a preview by default (query_text_truncated: true) — pass full_query_text for the whole text. Empty: no snapshot in the window, or none matching filters; not_collected (unfiltered only): engine can't run it. <> Gets active query snapshots captured from sys.dm_exec_requests. Shows what queries were running at each collection point: session ID, query text, wait type, CPU time, elapsed time, blocking info, DOP, and memory grants. Use hours_back to look at a specific time window — critical for finding what was running during a CPU spike or blocking event. EVERY FILTER IS PART OF THE QUERY: database_name and blocking_only are applied in SQL before the page is cut, total_snapshots is the count of snapshot rows in the window that pass your filters, snapshots_returned is how many you got, and truncated says the filtered population held more than limit — raise limit or narrow hours_back when it is true (NEWEST CAPTURE FIRST, highest CPU first within a capture; oldest_returned_collection_time / newest_returned_collection_time bound the page). Applied in SQL, so total_snapshots counts the blocking population and truncated is measured against it. HEAD BLOCKERS ARE NEVER STRIPPED: a session another row in the same capture names as its blocker is on the page whatever its text (including a WAITFOR shell holding locks), flagged is_head_blocker. A victim whose blocker is NOT on the page says why in blocker_not_shown: not_captured (the blocker held no running request at that capture — an idle open transaction is the classic case; get_blocked_process_reports has its input buffer from the blocked-process report), filtered (your database_name filter excluded it), or past_page (it is in the filtered population but beyond limit).")] public static async Task GetActiveQueries( LocalDataService dataService, ServerManager serverManager, @@ -17,7 +24,8 @@ public static async Task GetActiveQueries( [Description("Hours of data to retrieve. Default 1.")] int hours_back = 1, [Description("Filter to a specific database. Applied in SQL; a head blocker in ANOTHER database is then not on the page, and its victims say blocker_not_shown = filtered.")] string? database_name = null, [Description("Show only queries involved in blocking: rows with blocking_session_id > 0, plus the head blockers those rows name in the same capture.")] bool blocking_only = false, - [Description("Maximum number of rows to return. Default 50. The page is bounded by limit, not by hours_back — truncated says whether the filtered window held more.")] int limit = 50, + [Description("Maximum number of rows to return. Default 25 (#4198, down from 50 — sized to fit the response budget). The page is bounded by limit, not hours_back — truncated says whether the window held more.")] int limit = DefaultLimit, + [Description("Return each row's full query text instead of a 500-character preview. Default false.")] bool full_query_text = false, [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); @@ -86,7 +94,8 @@ public static async Task GetActiveQueries( login_name = r.LoginName, host_name = r.HostName, program_name = r.ProgramName, - query_text = McpHelpers.Truncate(r.QueryText, 2000) + query_text = full_query_text ? r.QueryText : McpHelpers.Truncate(r.QueryText, QueryTextPreviewLength), + query_text_truncated = !full_query_text && (r.QueryText?.Length ?? 0) > QueryTextPreviewLength }).ToList(); return JsonSerializer.Serialize(new From 18e7ec019929233d8153cae1553151d628b9e16f Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:43:53 -0400 Subject: [PATCH 2/5] Fix post-merge regressions: param pin, 620-char head cap, cref prose The full-suite run surfaced five failures, all from the active-queries budget change: a hardcoded param-order pin missing full_query_text, the served head over its own separate 620-character target (moved the preview note after <> instead, keeping the head at its original 616 bytes), and a doc comment that crefed a file name instead of a real type. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- .../DarlingMcpActiveQueriesBudgetLiveTests.cs | 2 +- Darling/Darling.Tests/DarlingMcpSessionToolsTests.cs | 2 +- .../McpToolsListBudget/DarlingMcpSessionTools.txt | 2 +- Darling/Darling.Tests/McpToolsListBudgetTests.cs | 9 +++++---- .../Mcp/DarlingMcpSessionTools.cs | 2 +- Lite.Tests/McpToolsListBudget/McpSessionTools.txt | 2 +- Lite.Tests/McpToolsListBudgetTests.cs | 8 ++++---- Lite/Mcp/McpSessionTools.cs | 2 +- 8 files changed, 15 insertions(+), 14 deletions(-) diff --git a/Darling/Darling.Tests/DarlingMcpActiveQueriesBudgetLiveTests.cs b/Darling/Darling.Tests/DarlingMcpActiveQueriesBudgetLiveTests.cs index c7c62b141..fae6dc7fa 100644 --- a/Darling/Darling.Tests/DarlingMcpActiveQueriesBudgetLiveTests.cs +++ b/Darling/Darling.Tests/DarlingMcpActiveQueriesBudgetLiveTests.cs @@ -28,7 +28,7 @@ namespace Darling.Tests; /// 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 /// , that full_query_text: true opts back into the whole -/// text. New file (not the shared seeding in ) because #4198 ran a +/// text. New file (not the shared seeding in DarlingMcpSessionToolsTests.cs) because #4198 ran a /// dozen lanes against this store tonight. /// [Collection("live-postgres")] diff --git a/Darling/Darling.Tests/DarlingMcpSessionToolsTests.cs b/Darling/Darling.Tests/DarlingMcpSessionToolsTests.cs index b77b656ab..c002035e0 100644 --- a/Darling/Darling.Tests/DarlingMcpSessionToolsTests.cs +++ b/Darling/Darling.Tests/DarlingMcpSessionToolsTests.cs @@ -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_query_text,as_of")] [InlineData("get_waiting_tasks", "server_name,hours_back,limit,as_of")] public void ParamContract_MatchesLite(string toolName, string expectedCsv) { diff --git a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpSessionTools.txt b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpSessionTools.txt index 131943bf9..f6faf9934 100644 --- a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpSessionTools.txt +++ b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpSessionTools.txt @@ -1,5 +1,5 @@ # DarlingMcpSessionTools: tools/list budget for #3898. Ceilings only go down; see McpToolsListBudgetTests. One block per tool, blank line between blocks. -tool get_active_queries 723 +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 diff --git a/Darling/Darling.Tests/McpToolsListBudgetTests.cs b/Darling/Darling.Tests/McpToolsListBudgetTests.cs index 624c59cad..ab7c24d17 100644 --- a/Darling/Darling.Tests/McpToolsListBudgetTests.cs +++ b/Darling/Darling.Tests/McpToolsListBudgetTests.cs @@ -91,10 +91,11 @@ 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. */ - /* #4198 (lane TH): +305 bytes for get_active_queries' default-preview note in its served description, - its 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). */ - private const int TotalCeilingBytes = 172_024; + /* #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 <> to stay under the head's + own 620-char target, so the served head is unchanged at 616. */ + private const int TotalCeilingBytes = 171_912; private const int ConvertedHeadCap = 1_000; private const int ConvertedParameterCap = 200; diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs index e951512a0..e00c4fe1f 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs @@ -110,7 +110,7 @@ public static async Task GetSessionStats( /// private const int DefaultLimit = 25; - [McpServerTool(Name = "get_active_queries"), Description("Active query snapshots (sys.dm_exec_requests) in a window ending at as_of: query text, wait, CPU/elapsed ms, blocking, DOP, memory grant GB. database_name/blocking_only filter IN SQL: total_snapshots is the filtered count; truncated means over limit. wait_time_ms, dop, granted_query_memory_gb, open_transaction_count: null = zero or not applicable. Head blockers are never stripped (is_head_blocker); a victim's blocker_not_shown is not_captured, filtered, or past_page. query_text is a preview by default (query_text_truncated: true) — pass full_query_text for the whole text. Empty: no snapshot in the window, or none matching filters; not_collected (unfiltered only): engine can't run it. <> Gets active query snapshots captured from sys.dm_exec_requests. Shows what queries were running at each collection point: session ID, query text, wait type, CPU time, elapsed time, blocking info, DOP, and memory grants. Use hours_back to look at a specific time window — critical for finding what was running during a CPU spike or blocking event. EVERY FILTER IS PART OF THE QUERY: database_name and blocking_only are applied in SQL before the page is cut, total_snapshots is the count of snapshot rows in the window that pass your filters, snapshots_returned is how many you got, and truncated says the filtered population held more than limit — raise limit or narrow hours_back when it is true (NEWEST CAPTURE FIRST, highest CPU first within a capture; oldest_returned_collection_time / newest_returned_collection_time bound the page). Applied in SQL, so total_snapshots counts the blocking population and truncated is measured against it. HEAD BLOCKERS ARE NEVER STRIPPED: a session another row in the same capture names as its blocker is on the page whatever its text (including a WAITFOR shell holding locks), flagged is_head_blocker. A victim whose blocker is NOT on the page says why in blocker_not_shown: not_captured (the blocker held no running request at that capture — an idle open transaction is the classic case; get_blocking has its input buffer from the blocked-process report), filtered (your database_name filter excluded it), or past_page (it is in the filtered population but beyond limit).")] + [McpServerTool(Name = "get_active_queries"), Description("Active query snapshots (sys.dm_exec_requests) in a window ending at as_of: query text, wait, CPU/elapsed ms, blocking, DOP, memory grant GB. database_name/blocking_only filter IN SQL: total_snapshots is the filtered count; truncated means over limit. wait_time_ms, dop, granted_query_memory_gb, open_transaction_count: null = zero or not applicable. Head blockers are never stripped (is_head_blocker); a victim's blocker_not_shown is not_captured, filtered, or past_page. Empty: no snapshot in the window, or none matching filters; not_collected (unfiltered only): engine can't run it. <> query_text is a preview by default (query_text_truncated: true) — pass full_query_text for the whole text. Gets active query snapshots captured from sys.dm_exec_requests. Shows what queries were running at each collection point: session ID, query text, wait type, CPU time, elapsed time, blocking info, DOP, and memory grants. Use hours_back to look at a specific time window — critical for finding what was running during a CPU spike or blocking event. EVERY FILTER IS PART OF THE QUERY: database_name and blocking_only are applied in SQL before the page is cut, total_snapshots is the count of snapshot rows in the window that pass your filters, snapshots_returned is how many you got, and truncated says the filtered population held more than limit — raise limit or narrow hours_back when it is true (NEWEST CAPTURE FIRST, highest CPU first within a capture; oldest_returned_collection_time / newest_returned_collection_time bound the page). Applied in SQL, so total_snapshots counts the blocking population and truncated is measured against it. HEAD BLOCKERS ARE NEVER STRIPPED: a session another row in the same capture names as its blocker is on the page whatever its text (including a WAITFOR shell holding locks), flagged is_head_blocker. A victim whose blocker is NOT on the page says why in blocker_not_shown: not_captured (the blocker held no running request at that capture — an idle open transaction is the classic case; get_blocking has its input buffer from the blocked-process report), filtered (your database_name filter excluded it), or past_page (it is in the filtered population but beyond limit).")] public static async Task GetActiveQueries( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, diff --git a/Lite.Tests/McpToolsListBudget/McpSessionTools.txt b/Lite.Tests/McpToolsListBudget/McpSessionTools.txt index 89d78953e..137fe084a 100644 --- a/Lite.Tests/McpToolsListBudget/McpSessionTools.txt +++ b/Lite.Tests/McpToolsListBudget/McpSessionTools.txt @@ -1,5 +1,5 @@ # McpSessionTools: tools/list budget for #3898. Ceilings only go down; see McpToolsListBudgetTests. One block per tool, blank line between blocks. -tool get_active_queries 723 +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 diff --git a/Lite.Tests/McpToolsListBudgetTests.cs b/Lite.Tests/McpToolsListBudgetTests.cs index edc18d2bb..491865eb2 100644 --- a/Lite.Tests/McpToolsListBudgetTests.cs +++ b/Lite.Tests/McpToolsListBudgetTests.cs @@ -83,10 +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. */ - /* #4198 (lane TH): +305 bytes for get_active_queries' default-preview note in its served description, - its new full_query_text opt-in parameter, and its limit description's #4198 note. Matches Darling's - twin change exactly. */ - private const int TotalCeilingBytes = 90_186; + /* #4198 (lane TH): +182 bytes for get_active_queries' new full_query_text opt-in parameter and its + limit description's #4198 note; the preview note itself moved after <> to stay under the + head's own 620-char target, so the served head is unchanged. Matches Darling's twin change. */ + private const int TotalCeilingBytes = 90_063; private const int ConvertedHeadCap = 1_000; private const int ConvertedParameterCap = 200; diff --git a/Lite/Mcp/McpSessionTools.cs b/Lite/Mcp/McpSessionTools.cs index 8022a693c..cf019fc50 100644 --- a/Lite/Mcp/McpSessionTools.cs +++ b/Lite/Mcp/McpSessionTools.cs @@ -16,7 +16,7 @@ public sealed class McpSessionTools /// private const int DefaultLimit = 25; - [McpServerTool(Name = "get_active_queries"), Description("Active query snapshots (sys.dm_exec_requests) in a window ending at as_of: query text, wait, CPU/elapsed ms, blocking, DOP, memory grant GB. database_name/blocking_only filter IN SQL: total_snapshots is the filtered count; truncated means over limit. wait_time_ms, dop, granted_query_memory_gb, open_transaction_count: null = zero or not applicable. Head blockers are never stripped (is_head_blocker); a victim's blocker_not_shown is not_captured, filtered, or past_page. query_text is a preview by default (query_text_truncated: true) — pass full_query_text for the whole text. Empty: no snapshot in the window, or none matching filters; not_collected (unfiltered only): engine can't run it. <> Gets active query snapshots captured from sys.dm_exec_requests. Shows what queries were running at each collection point: session ID, query text, wait type, CPU time, elapsed time, blocking info, DOP, and memory grants. Use hours_back to look at a specific time window — critical for finding what was running during a CPU spike or blocking event. EVERY FILTER IS PART OF THE QUERY: database_name and blocking_only are applied in SQL before the page is cut, total_snapshots is the count of snapshot rows in the window that pass your filters, snapshots_returned is how many you got, and truncated says the filtered population held more than limit — raise limit or narrow hours_back when it is true (NEWEST CAPTURE FIRST, highest CPU first within a capture; oldest_returned_collection_time / newest_returned_collection_time bound the page). Applied in SQL, so total_snapshots counts the blocking population and truncated is measured against it. HEAD BLOCKERS ARE NEVER STRIPPED: a session another row in the same capture names as its blocker is on the page whatever its text (including a WAITFOR shell holding locks), flagged is_head_blocker. A victim whose blocker is NOT on the page says why in blocker_not_shown: not_captured (the blocker held no running request at that capture — an idle open transaction is the classic case; get_blocked_process_reports has its input buffer from the blocked-process report), filtered (your database_name filter excluded it), or past_page (it is in the filtered population but beyond limit).")] + [McpServerTool(Name = "get_active_queries"), Description("Active query snapshots (sys.dm_exec_requests) in a window ending at as_of: query text, wait, CPU/elapsed ms, blocking, DOP, memory grant GB. database_name/blocking_only filter IN SQL: total_snapshots is the filtered count; truncated means over limit. wait_time_ms, dop, granted_query_memory_gb, open_transaction_count: null = zero or not applicable. Head blockers are never stripped (is_head_blocker); a victim's blocker_not_shown is not_captured, filtered, or past_page. Empty: no snapshot in the window, or none matching filters; not_collected (unfiltered only): engine can't run it. <> query_text is a preview by default (query_text_truncated: true) — pass full_query_text for the whole text. Gets active query snapshots captured from sys.dm_exec_requests. Shows what queries were running at each collection point: session ID, query text, wait type, CPU time, elapsed time, blocking info, DOP, and memory grants. Use hours_back to look at a specific time window — critical for finding what was running during a CPU spike or blocking event. EVERY FILTER IS PART OF THE QUERY: database_name and blocking_only are applied in SQL before the page is cut, total_snapshots is the count of snapshot rows in the window that pass your filters, snapshots_returned is how many you got, and truncated says the filtered population held more than limit — raise limit or narrow hours_back when it is true (NEWEST CAPTURE FIRST, highest CPU first within a capture; oldest_returned_collection_time / newest_returned_collection_time bound the page). Applied in SQL, so total_snapshots counts the blocking population and truncated is measured against it. HEAD BLOCKERS ARE NEVER STRIPPED: a session another row in the same capture names as its blocker is on the page whatever its text (including a WAITFOR shell holding locks), flagged is_head_blocker. A victim whose blocker is NOT on the page says why in blocker_not_shown: not_captured (the blocker held no running request at that capture — an idle open transaction is the classic case; get_blocked_process_reports has its input buffer from the blocked-process report), filtered (your database_name filter excluded it), or past_page (it is in the filtered population but beyond limit).")] public static async Task GetActiveQueries( LocalDataService dataService, ServerManager serverManager, From 857988aca48247d128673e5c35739f0b865a8754 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:59:25 -0400 Subject: [PATCH 3/5] #4198: keep the web viewer's Active Queries tab at a 2,000-char preview DarlingMcpSessionTools.GetActiveQueries now delegates to an internal overload that takes an explicit query-text preview length (the split #3897's trend tools use: one MCP-facing method, one budget-taking overload). The MCP tool passes 500 (or null for full_query_text), and DarlingWebEndpoints' /api/read row passes 2000 - the pre-#4198 budget every caller got - so the web viewer's page doesn't shrink under the new MCP default. query_text_truncated stays honest on both paths. Adds a no-rig source-text pin (ReadEndpoints_ActiveQueries_ KeepsTheTwoThousandCharacterWebPreview) that fails if the web row stops calling the overload with 2000; verified it fails against the pre-fix row shape, then confirmed it passes again after restoring the fix. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- .../Darling.Tests/DarlingWebEndpointsTests.cs | 15 +++++++++++ .../DarlingWebEndpoints.cs | 2 +- .../Mcp/DarlingMcpSessionTools.cs | 27 +++++++++++++++---- 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/Darling/Darling.Tests/DarlingWebEndpointsTests.cs b/Darling/Darling.Tests/DarlingWebEndpointsTests.cs index 62ff2cd5a..ad6fcc404 100644 --- a/Darling/Darling.Tests/DarlingWebEndpointsTests.cs +++ b/Darling/Darling.Tests/DarlingWebEndpointsTests.cs @@ -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] diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs index 30c27c57c..62605b52e 100644 --- a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs @@ -2607,7 +2607,7 @@ internal static IReadOnlyDictionary 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)), diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs index e00c4fe1f..b5d7e5e83 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs @@ -103,7 +103,10 @@ public static async Task GetSessionStats( /// , and query_text previews to /// (full_query_text opts back in, the shape get_store_query_stats' full_text and /// get_deadlock_detail's full_graph already use). truncated / total_snapshots - /// already tell a caller who wants more to raise limit or narrow hours_back. + /// already tell a caller who wants more to raise limit or narrow hours_back. The web viewer is not this + /// budget's caller (#4198 lane W2): its /api/read mirror keeps the pre-#4198 2,000-character + /// preview every caller got before this tool's default fell to 500, through the internal overload below + /// (the split #3897's trend tools use — one MCP-facing method, one budget-taking overload). /// private const int QueryTextPreviewLength = 500; @@ -111,7 +114,7 @@ public static async Task GetSessionStats( private const int DefaultLimit = 25; [McpServerTool(Name = "get_active_queries"), Description("Active query snapshots (sys.dm_exec_requests) in a window ending at as_of: query text, wait, CPU/elapsed ms, blocking, DOP, memory grant GB. database_name/blocking_only filter IN SQL: total_snapshots is the filtered count; truncated means over limit. wait_time_ms, dop, granted_query_memory_gb, open_transaction_count: null = zero or not applicable. Head blockers are never stripped (is_head_blocker); a victim's blocker_not_shown is not_captured, filtered, or past_page. Empty: no snapshot in the window, or none matching filters; not_collected (unfiltered only): engine can't run it. <> query_text is a preview by default (query_text_truncated: true) — pass full_query_text for the whole text. Gets active query snapshots captured from sys.dm_exec_requests. Shows what queries were running at each collection point: session ID, query text, wait type, CPU time, elapsed time, blocking info, DOP, and memory grants. Use hours_back to look at a specific time window — critical for finding what was running during a CPU spike or blocking event. EVERY FILTER IS PART OF THE QUERY: database_name and blocking_only are applied in SQL before the page is cut, total_snapshots is the count of snapshot rows in the window that pass your filters, snapshots_returned is how many you got, and truncated says the filtered population held more than limit — raise limit or narrow hours_back when it is true (NEWEST CAPTURE FIRST, highest CPU first within a capture; oldest_returned_collection_time / newest_returned_collection_time bound the page). Applied in SQL, so total_snapshots counts the blocking population and truncated is measured against it. HEAD BLOCKERS ARE NEVER STRIPPED: a session another row in the same capture names as its blocker is on the page whatever its text (including a WAITFOR shell holding locks), flagged is_head_blocker. A victim whose blocker is NOT on the page says why in blocker_not_shown: not_captured (the blocker held no running request at that capture — an idle open transaction is the classic case; get_blocking has its input buffer from the blocked-process report), filtered (your database_name filter excluded it), or past_page (it is in the filtered population but beyond limit).")] - public static async Task GetActiveQueries( + public static Task GetActiveQueries( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of data to retrieve. Default 1.")] int hours_back = 1, @@ -119,7 +122,21 @@ public static async Task GetActiveQueries( [Description("Show only queries involved in blocking: rows with blocking_session_id > 0, plus the head blockers those rows name in the same capture.")] bool blocking_only = false, [Description("Maximum number of rows to return. Default 25 (#4198, down from 50 — sized to fit the response budget). The page is bounded by limit, not hours_back — truncated says whether the window held more.")] int limit = DefaultLimit, [Description("Return each row's full query text instead of a 500-character preview. Default false.")] bool full_query_text = false, - [Description(McpHelpers.AsOfDescription)] string? as_of = null) + [Description(McpHelpers.AsOfDescription)] string? as_of = null) => + GetActiveQueries(postgres, server_name, hours_back, database_name, blocking_only, limit, + full_query_text ? null : QueryTextPreviewLength, as_of); + + /// + /// get_active_queries under an explicit query-text preview length (#4198 lane W2): the MCP tool above + /// passes , or null when its full_query_text opt-in asks for the + /// whole text, and the web viewer's /api/read mirror passes 2000 (the pre-#4198 budget every + /// caller got, sized for a page that redraws every 30 seconds rather than a model's context). One body, + /// so the two surfaces differ only in preview length; null means no truncation at all, not "unbounded + /// preview length" — the ternary below never truncates on a null budget. + /// + internal static async Task GetActiveQueries( + NpgsqlDataSource postgres, string? server_name, int hours_back, string? database_name, bool blocking_only, + int limit, int? queryTextPreviewLength, string? as_of) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; @@ -192,8 +209,8 @@ database_name or blocking_only means the window held no snapshot matching them. login_name = r.LoginName, host_name = r.HostName, program_name = r.ProgramName, - query_text = full_query_text ? r.QueryText : McpHelpers.Truncate(r.QueryText, QueryTextPreviewLength), - query_text_truncated = !full_query_text && (r.QueryText?.Length ?? 0) > QueryTextPreviewLength + query_text = queryTextPreviewLength.HasValue ? McpHelpers.Truncate(r.QueryText, queryTextPreviewLength.Value) : r.QueryText, + query_text_truncated = queryTextPreviewLength.HasValue && (r.QueryText?.Length ?? 0) > queryTextPreviewLength.Value }).ToList(); return JsonSerializer.Serialize(new From c419a7971501ad69c1e8aef94e40a8baa1a50b2b Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:06:06 -0400 Subject: [PATCH 4/5] #4198: rename get_active_queries' full_query_text opt-in to full_text Matches get_store_query_stats' full_text (the #4198 precedent) and the other #4198 tools, on both Darling and Lite. Description text is unchanged, so each param's pinned byte count stays 84; the shorter property name shrinks the served tools/list JSON by 6 bytes on each product, banked by lowering TotalCeilingBytes to the measured total (171,906 Darling, 90,057 Lite). Lite's UI reads LocalDataService directly, not this MCP tool (grep confirms no Lite UI call site), so no UI-facing change there. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- .../DarlingMcpActiveQueriesBudgetLiveTests.cs | 7 ++++--- .../Darling.Tests/DarlingMcpSessionToolsTests.cs | 2 +- .../McpPayloadContractCensusTests.cs | 7 ++++--- .../DarlingMcpSessionTools.txt | 2 +- Darling/Darling.Tests/McpToolsListBudgetTests.cs | 5 ++++- .../Mcp/DarlingMcpSessionTools.cs | 16 +++++++++------- Lite.Tests/McpPageContractTests.cs | 5 +++-- .../McpToolsListBudget/McpSessionTools.txt | 2 +- Lite.Tests/McpToolsListBudgetTests.cs | 4 +++- Lite/Mcp/McpSessionTools.cs | 8 ++++---- 10 files changed, 34 insertions(+), 24 deletions(-) diff --git a/Darling/Darling.Tests/DarlingMcpActiveQueriesBudgetLiveTests.cs b/Darling/Darling.Tests/DarlingMcpActiveQueriesBudgetLiveTests.cs index fae6dc7fa..24cc6847a 100644 --- a/Darling/Darling.Tests/DarlingMcpActiveQueriesBudgetLiveTests.cs +++ b/Darling/Darling.Tests/DarlingMcpActiveQueriesBudgetLiveTests.cs @@ -27,7 +27,8 @@ namespace Darling.Tests; /// 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 -/// , that full_query_text: true opts back into the whole +/// , that full_text: true (renamed from +/// full_query_text to match get_store_query_stats) opts back into the whole /// text. New file (not the shared seeding in DarlingMcpSessionToolsTests.cs) because #4198 ran a /// dozen lanes against this store tonight. /// @@ -100,8 +101,8 @@ await DarlingMcpTestData.ExecAsync(connection, ct, "the default call's widest row should preview shorter than the planted text."); Assert.True(wideRow.GetProperty("query_text_truncated").GetBoolean()); - /* full_query_text opts back into the whole text for every row, including the newest (widest) one. */ - var fullJson = await DarlingMcpSessionTools.GetActiveQueries(postgres, ServerName, full_query_text: true); + /* 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()); diff --git a/Darling/Darling.Tests/DarlingMcpSessionToolsTests.cs b/Darling/Darling.Tests/DarlingMcpSessionToolsTests.cs index c002035e0..f4a363786 100644 --- a/Darling/Darling.Tests/DarlingMcpSessionToolsTests.cs +++ b/Darling/Darling.Tests/DarlingMcpSessionToolsTests.cs @@ -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,full_query_text,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) { diff --git a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs index be89bf6fc..947c94b59 100644 --- a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs +++ b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs @@ -1479,8 +1479,9 @@ public void TheSharedTimestampParsers_RefuseWhatTheGeneralParserAccepts() /// 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, get_active_queries' full_query_text, the same shape - /// get_store_query_stats' full_text already used). + /// full_graph; get_active_queries and get_store_query_stats both take + /// full_text, the same name — get_active_queries' own was renamed from full_query_text to + /// match). /// 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. /// @@ -1528,7 +1529,7 @@ public static readonly (string Key, string[] Files, string WhatWasCut)[] SourceS public static readonly (string Key, string[] Files, string WhatWasCut)[] FieldPreviewCutKeys = [ ("query_text_truncated", ["DarlingMcpSessionTools.cs", "McpSessionTools.cs"], - "#4198: get_active_queries' own wide field — query_text is a 500-character preview by default (a synthetic 50-row page shaped like a busy server measured 81,489 bytes), full_query_text gets the whole text"), + "#4198: get_active_queries' own wide field — query_text is a 500-character preview by default (a synthetic 50-row page shaped like a busy server measured 81,489 bytes), full_text (renamed from full_query_text) gets the whole text"), ]; public static readonly (string Key, string[] Files, string WhatIsWithheld)[] WithheldSummaryKeys = diff --git a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpSessionTools.txt b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpSessionTools.txt index f6faf9934..b84a88435 100644 --- a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpSessionTools.txt +++ b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpSessionTools.txt @@ -3,7 +3,7 @@ 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_query_text 84 +param get_active_queries.full_text 84 param get_active_queries.hours_back 37 param get_active_queries.limit 194 param get_active_queries.server_name 28 diff --git a/Darling/Darling.Tests/McpToolsListBudgetTests.cs b/Darling/Darling.Tests/McpToolsListBudgetTests.cs index ab7c24d17..7dac04aea 100644 --- a/Darling/Darling.Tests/McpToolsListBudgetTests.cs +++ b/Darling/Darling.Tests/McpToolsListBudgetTests.cs @@ -95,7 +95,10 @@ public McpToolsListBudgetTests(ITestOutputHelper output) 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 <> to stay under the head's own 620-char target, so the served head is unchanged at 616. */ - private const int TotalCeilingBytes = 171_912; + /* #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. */ + private const int TotalCeilingBytes = 171_906; private const int ConvertedHeadCap = 1_000; private const int ConvertedParameterCap = 200; diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs index b5d7e5e83..217bff482 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs @@ -101,8 +101,9 @@ public static async Task GetSessionStats( /// bytes; a synthetic 50-row page shaped like a busy server (every optional field populated, a fifth of /// the rows carrying a long literal list) measured 81,489. So both come down: the default row limit to /// , and query_text previews to - /// (full_query_text opts back in, the shape get_store_query_stats' full_text and - /// get_deadlock_detail's full_graph already use). truncated / total_snapshots + /// (full_text opts back in — #4198 renamed this from full_query_text to match + /// get_store_query_stats' full_text and get_deadlock_detail's full_graph, + /// the shape those already use). truncated / total_snapshots /// already tell a caller who wants more to raise limit or narrow hours_back. The web viewer is not this /// budget's caller (#4198 lane W2): its /api/read mirror keeps the pre-#4198 2,000-character /// preview every caller got before this tool's default fell to 500, through the internal overload below @@ -113,7 +114,7 @@ public static async Task GetSessionStats( /// private const int DefaultLimit = 25; - [McpServerTool(Name = "get_active_queries"), Description("Active query snapshots (sys.dm_exec_requests) in a window ending at as_of: query text, wait, CPU/elapsed ms, blocking, DOP, memory grant GB. database_name/blocking_only filter IN SQL: total_snapshots is the filtered count; truncated means over limit. wait_time_ms, dop, granted_query_memory_gb, open_transaction_count: null = zero or not applicable. Head blockers are never stripped (is_head_blocker); a victim's blocker_not_shown is not_captured, filtered, or past_page. Empty: no snapshot in the window, or none matching filters; not_collected (unfiltered only): engine can't run it. <> query_text is a preview by default (query_text_truncated: true) — pass full_query_text for the whole text. Gets active query snapshots captured from sys.dm_exec_requests. Shows what queries were running at each collection point: session ID, query text, wait type, CPU time, elapsed time, blocking info, DOP, and memory grants. Use hours_back to look at a specific time window — critical for finding what was running during a CPU spike or blocking event. EVERY FILTER IS PART OF THE QUERY: database_name and blocking_only are applied in SQL before the page is cut, total_snapshots is the count of snapshot rows in the window that pass your filters, snapshots_returned is how many you got, and truncated says the filtered population held more than limit — raise limit or narrow hours_back when it is true (NEWEST CAPTURE FIRST, highest CPU first within a capture; oldest_returned_collection_time / newest_returned_collection_time bound the page). Applied in SQL, so total_snapshots counts the blocking population and truncated is measured against it. HEAD BLOCKERS ARE NEVER STRIPPED: a session another row in the same capture names as its blocker is on the page whatever its text (including a WAITFOR shell holding locks), flagged is_head_blocker. A victim whose blocker is NOT on the page says why in blocker_not_shown: not_captured (the blocker held no running request at that capture — an idle open transaction is the classic case; get_blocking has its input buffer from the blocked-process report), filtered (your database_name filter excluded it), or past_page (it is in the filtered population but beyond limit).")] + [McpServerTool(Name = "get_active_queries"), Description("Active query snapshots (sys.dm_exec_requests) in a window ending at as_of: query text, wait, CPU/elapsed ms, blocking, DOP, memory grant GB. database_name/blocking_only filter IN SQL: total_snapshots is the filtered count; truncated means over limit. wait_time_ms, dop, granted_query_memory_gb, open_transaction_count: null = zero or not applicable. Head blockers are never stripped (is_head_blocker); a victim's blocker_not_shown is not_captured, filtered, or past_page. Empty: no snapshot in the window, or none matching filters; not_collected (unfiltered only): engine can't run it. <> query_text is a preview by default (query_text_truncated: true) — pass full_text for the whole text. Gets active query snapshots captured from sys.dm_exec_requests. Shows what queries were running at each collection point: session ID, query text, wait type, CPU time, elapsed time, blocking info, DOP, and memory grants. Use hours_back to look at a specific time window — critical for finding what was running during a CPU spike or blocking event. EVERY FILTER IS PART OF THE QUERY: database_name and blocking_only are applied in SQL before the page is cut, total_snapshots is the count of snapshot rows in the window that pass your filters, snapshots_returned is how many you got, and truncated says the filtered population held more than limit — raise limit or narrow hours_back when it is true (NEWEST CAPTURE FIRST, highest CPU first within a capture; oldest_returned_collection_time / newest_returned_collection_time bound the page). Applied in SQL, so total_snapshots counts the blocking population and truncated is measured against it. HEAD BLOCKERS ARE NEVER STRIPPED: a session another row in the same capture names as its blocker is on the page whatever its text (including a WAITFOR shell holding locks), flagged is_head_blocker. A victim whose blocker is NOT on the page says why in blocker_not_shown: not_captured (the blocker held no running request at that capture — an idle open transaction is the classic case; get_blocking has its input buffer from the blocked-process report), filtered (your database_name filter excluded it), or past_page (it is in the filtered population but beyond limit).")] public static Task GetActiveQueries( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, @@ -121,15 +122,16 @@ public static Task GetActiveQueries( [Description("Filter to a specific database. Applied in SQL; a head blocker in ANOTHER database is then not on the page, and its victims say blocker_not_shown = filtered.")] string? database_name = null, [Description("Show only queries involved in blocking: rows with blocking_session_id > 0, plus the head blockers those rows name in the same capture.")] bool blocking_only = false, [Description("Maximum number of rows to return. Default 25 (#4198, down from 50 — sized to fit the response budget). The page is bounded by limit, not hours_back — truncated says whether the window held more.")] int limit = DefaultLimit, - [Description("Return each row's full query text instead of a 500-character preview. Default false.")] bool full_query_text = false, + [Description("Return each row's full query text instead of a 500-character preview. Default false.")] bool full_text = false, [Description(McpHelpers.AsOfDescription)] string? as_of = null) => GetActiveQueries(postgres, server_name, hours_back, database_name, blocking_only, limit, - full_query_text ? null : QueryTextPreviewLength, as_of); + full_text ? null : QueryTextPreviewLength, as_of); /// /// get_active_queries under an explicit query-text preview length (#4198 lane W2): the MCP tool above - /// passes , or null when its full_query_text opt-in asks for the - /// whole text, and the web viewer's /api/read mirror passes 2000 (the pre-#4198 budget every + /// passes , or null when its full_text opt-in (#4198, renamed from + /// full_query_text to match get_store_query_stats' full_text and the rest of the #4198 tools) asks for + /// the whole text, and the web viewer's /api/read mirror passes 2000 (the pre-#4198 budget every /// caller got, sized for a page that redraws every 30 seconds rather than a model's context). One body, /// so the two surfaces differ only in preview length; null means no truncation at all, not "unbounded /// preview length" — the ternary below never truncates on a null budget. diff --git a/Lite.Tests/McpPageContractTests.cs b/Lite.Tests/McpPageContractTests.cs index 7ef7d6872..67c9d1ac6 100644 --- a/Lite.Tests/McpPageContractTests.cs +++ b/Lite.Tests/McpPageContractTests.cs @@ -567,7 +567,8 @@ page and its victim says so. */ /// literal list) — this tool has TWENTY-THREE fields per row rather than one dominant wide field, so the /// fixed columns add up across the page even before query_text is counted. Plants the same shape and /// asserts the default call (limit down to 25) stays under , - /// and that full_query_text: true opts back into the whole text. + /// and that full_text: true (renamed from full_query_text to match + /// get_store_query_stats) opts back into the whole text. /// [Fact] public async Task GetActiveQueries_Default_StaysUnderResponseBudget_WithFiftyRealisticRows() @@ -614,7 +615,7 @@ INSERT INTO query_snapshots Assert.True(wideRow.GetProperty("query_text").GetString()!.Length < wideText.Length); Assert.True(wideRow.GetProperty("query_text_truncated").GetBoolean()); - var fullJson = await McpSessionTools.GetActiveQueries(_dataService, _serverManager, ServerName, full_query_text: true); + var fullJson = await McpSessionTools.GetActiveQueries(_dataService, _serverManager, ServerName, full_text: true); var fullRoot = Parse(fullJson); Assert.False(fullRoot.GetProperty("queries")[0].GetProperty("query_text_truncated").GetBoolean()); Assert.Equal(wideText, fullRoot.GetProperty("queries")[0].GetProperty("query_text").GetString()); diff --git a/Lite.Tests/McpToolsListBudget/McpSessionTools.txt b/Lite.Tests/McpToolsListBudget/McpSessionTools.txt index 137fe084a..e9060cc37 100644 --- a/Lite.Tests/McpToolsListBudget/McpSessionTools.txt +++ b/Lite.Tests/McpToolsListBudget/McpSessionTools.txt @@ -3,7 +3,7 @@ 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_query_text 84 +param get_active_queries.full_text 84 param get_active_queries.hours_back 37 param get_active_queries.limit 194 param get_active_queries.server_name 28 diff --git a/Lite.Tests/McpToolsListBudgetTests.cs b/Lite.Tests/McpToolsListBudgetTests.cs index 491865eb2..3b5eadaa3 100644 --- a/Lite.Tests/McpToolsListBudgetTests.cs +++ b/Lite.Tests/McpToolsListBudgetTests.cs @@ -86,7 +86,9 @@ which is not served in tools/list and so is not counted here. Matches Darling's /* #4198 (lane TH): +182 bytes for get_active_queries' new full_query_text opt-in parameter and its limit description's #4198 note; the preview note itself moved after <> to stay under the head's own 620-char target, so the served head is unchanged. Matches Darling's twin change. */ - private const int TotalCeilingBytes = 90_063; + /* #4198 (lane W2): -6 bytes for renaming get_active_queries' full_query_text opt-in to full_text — + matches Darling's twin change. Lowered to the measured total, banking the saving. */ + private const int TotalCeilingBytes = 90_057; private const int ConvertedHeadCap = 1_000; private const int ConvertedParameterCap = 200; diff --git a/Lite/Mcp/McpSessionTools.cs b/Lite/Mcp/McpSessionTools.cs index cf019fc50..5e6de9ad8 100644 --- a/Lite/Mcp/McpSessionTools.cs +++ b/Lite/Mcp/McpSessionTools.cs @@ -16,7 +16,7 @@ public sealed class McpSessionTools /// private const int DefaultLimit = 25; - [McpServerTool(Name = "get_active_queries"), Description("Active query snapshots (sys.dm_exec_requests) in a window ending at as_of: query text, wait, CPU/elapsed ms, blocking, DOP, memory grant GB. database_name/blocking_only filter IN SQL: total_snapshots is the filtered count; truncated means over limit. wait_time_ms, dop, granted_query_memory_gb, open_transaction_count: null = zero or not applicable. Head blockers are never stripped (is_head_blocker); a victim's blocker_not_shown is not_captured, filtered, or past_page. Empty: no snapshot in the window, or none matching filters; not_collected (unfiltered only): engine can't run it. <> query_text is a preview by default (query_text_truncated: true) — pass full_query_text for the whole text. Gets active query snapshots captured from sys.dm_exec_requests. Shows what queries were running at each collection point: session ID, query text, wait type, CPU time, elapsed time, blocking info, DOP, and memory grants. Use hours_back to look at a specific time window — critical for finding what was running during a CPU spike or blocking event. EVERY FILTER IS PART OF THE QUERY: database_name and blocking_only are applied in SQL before the page is cut, total_snapshots is the count of snapshot rows in the window that pass your filters, snapshots_returned is how many you got, and truncated says the filtered population held more than limit — raise limit or narrow hours_back when it is true (NEWEST CAPTURE FIRST, highest CPU first within a capture; oldest_returned_collection_time / newest_returned_collection_time bound the page). Applied in SQL, so total_snapshots counts the blocking population and truncated is measured against it. HEAD BLOCKERS ARE NEVER STRIPPED: a session another row in the same capture names as its blocker is on the page whatever its text (including a WAITFOR shell holding locks), flagged is_head_blocker. A victim whose blocker is NOT on the page says why in blocker_not_shown: not_captured (the blocker held no running request at that capture — an idle open transaction is the classic case; get_blocked_process_reports has its input buffer from the blocked-process report), filtered (your database_name filter excluded it), or past_page (it is in the filtered population but beyond limit).")] + [McpServerTool(Name = "get_active_queries"), Description("Active query snapshots (sys.dm_exec_requests) in a window ending at as_of: query text, wait, CPU/elapsed ms, blocking, DOP, memory grant GB. database_name/blocking_only filter IN SQL: total_snapshots is the filtered count; truncated means over limit. wait_time_ms, dop, granted_query_memory_gb, open_transaction_count: null = zero or not applicable. Head blockers are never stripped (is_head_blocker); a victim's blocker_not_shown is not_captured, filtered, or past_page. Empty: no snapshot in the window, or none matching filters; not_collected (unfiltered only): engine can't run it. <> query_text is a preview by default (query_text_truncated: true) — pass full_text for the whole text. Gets active query snapshots captured from sys.dm_exec_requests. Shows what queries were running at each collection point: session ID, query text, wait type, CPU time, elapsed time, blocking info, DOP, and memory grants. Use hours_back to look at a specific time window — critical for finding what was running during a CPU spike or blocking event. EVERY FILTER IS PART OF THE QUERY: database_name and blocking_only are applied in SQL before the page is cut, total_snapshots is the count of snapshot rows in the window that pass your filters, snapshots_returned is how many you got, and truncated says the filtered population held more than limit — raise limit or narrow hours_back when it is true (NEWEST CAPTURE FIRST, highest CPU first within a capture; oldest_returned_collection_time / newest_returned_collection_time bound the page). Applied in SQL, so total_snapshots counts the blocking population and truncated is measured against it. HEAD BLOCKERS ARE NEVER STRIPPED: a session another row in the same capture names as its blocker is on the page whatever its text (including a WAITFOR shell holding locks), flagged is_head_blocker. A victim whose blocker is NOT on the page says why in blocker_not_shown: not_captured (the blocker held no running request at that capture — an idle open transaction is the classic case; get_blocked_process_reports has its input buffer from the blocked-process report), filtered (your database_name filter excluded it), or past_page (it is in the filtered population but beyond limit).")] public static async Task GetActiveQueries( LocalDataService dataService, ServerManager serverManager, @@ -25,7 +25,7 @@ public static async Task GetActiveQueries( [Description("Filter to a specific database. Applied in SQL; a head blocker in ANOTHER database is then not on the page, and its victims say blocker_not_shown = filtered.")] string? database_name = null, [Description("Show only queries involved in blocking: rows with blocking_session_id > 0, plus the head blockers those rows name in the same capture.")] bool blocking_only = false, [Description("Maximum number of rows to return. Default 25 (#4198, down from 50 — sized to fit the response budget). The page is bounded by limit, not hours_back — truncated says whether the window held more.")] int limit = DefaultLimit, - [Description("Return each row's full query text instead of a 500-character preview. Default false.")] bool full_query_text = false, + [Description("Return each row's full query text instead of a 500-character preview. Default false.")] bool full_text = false, [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); @@ -94,8 +94,8 @@ public static async Task GetActiveQueries( login_name = r.LoginName, host_name = r.HostName, program_name = r.ProgramName, - query_text = full_query_text ? r.QueryText : McpHelpers.Truncate(r.QueryText, QueryTextPreviewLength), - query_text_truncated = !full_query_text && (r.QueryText?.Length ?? 0) > QueryTextPreviewLength + query_text = full_text ? r.QueryText : McpHelpers.Truncate(r.QueryText, QueryTextPreviewLength), + query_text_truncated = !full_text && (r.QueryText?.Length ?? 0) > QueryTextPreviewLength }).ToList(); return JsonSerializer.Serialize(new From 775c00716a90f53b5702461e816b8b61c2d6bcd6 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:45:34 -0400 Subject: [PATCH 5/5] Adopt DarlingWebEndpointsTests in s_lfSubjects Lane W2 added ReadRepoFileLf to DarlingWebEndpointsTests.cs for a single-line dispatch-table pin; no line-break anchor, so it belongs in s_lfSubjects. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ --- Darling/Darling.Tests/RepoFileAdoptionTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Darling/Darling.Tests/RepoFileAdoptionTests.cs b/Darling/Darling.Tests/RepoFileAdoptionTests.cs index a34a54c7f..0abf146c3 100644 --- a/Darling/Darling.Tests/RepoFileAdoptionTests.cs +++ b/Darling/Darling.Tests/RepoFileAdoptionTests.cs @@ -199,6 +199,10 @@ the CODE shape is told apart from the same words inside the comments beside it. /// 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