From 77476f02982916d2a84f2f2e45b3f825c567614b Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:31:01 -0400 Subject: [PATCH 1/3] get_deadlock_detail: preview the deadlock graph XML at default, full graph on request (#4198) deadlock_graph_xml is the wide field on get_deadlock_detail: a busy production store's default call measured 120,454 bytes for 3 graphs (about 40 KB/graph), over the shared 32 KB MCP response-size budget. Truncates it to a 2000-character preview by default (deadlock_graph_xml_truncated: true), with a full_graph opt-in for the whole XML, the same preview-plus-opt-in shape get_store_query_stats uses for full_text. A dedup_key call (naming one incident) always gets the whole graph, since the caller already paid the cost of naming it. Same fix on Lite's get_deadlock_detail twin (no dedup_key there). Adds a new Darling live test class seeding wide graphs against Postgres, measuring the default call's UTF-8 bytes under the budget; adds a Lite.Tests case doing the same against DuckDB. Updates both products' tools/list budget pins for the new full_graph parameter and the changed tool description. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- ...DarlingMcpDeadlockDetailBudgetLiveTests.cs | 136 ++++++++++++++++++ .../DarlingMcpBlockingTools.txt | 3 +- .../Darling.Tests/McpToolsListBudgetTests.cs | 5 +- .../Mcp/DarlingMcpBlockingTools.cs | 20 ++- Lite.Tests/McpPageContractTests.cs | 60 ++++++++ .../McpToolsListBudget/McpBlockingTools.txt | 3 +- Lite.Tests/McpToolsListBudgetTests.cs | 6 +- Lite/Mcp/McpBlockingTools.cs | 14 +- 8 files changed, 239 insertions(+), 8 deletions(-) create mode 100644 Darling/Darling.Tests/DarlingMcpDeadlockDetailBudgetLiveTests.cs diff --git a/Darling/Darling.Tests/DarlingMcpDeadlockDetailBudgetLiveTests.cs b/Darling/Darling.Tests/DarlingMcpDeadlockDetailBudgetLiveTests.cs new file mode 100644 index 0000000000..1ddaa591ec --- /dev/null +++ b/Darling/Darling.Tests/DarlingMcpDeadlockDetailBudgetLiveTests.cs @@ -0,0 +1,136 @@ +/* + * 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_deadlock_detail's own response-budget pin. deadlock_graph_xml is the wide field — a busy +/// production store's default call (limit 5, only 3 deadlocks carrying a graph in the window) measured +/// 120,454 bytes, almost all of it this one field (about 40 KB/graph). Plants five graphs near that width +/// (the worst realistic default page) and asserts the default call stays under +/// , that full_graph: true opts back into the whole +/// graph, and that a dedup_key call (naming one incident) returns the whole graph even without +/// full_graph. New file (not the shared seeding in ) +/// because #4198 ran a dozen lanes against this store tonight. +/// +[Collection("live-postgres")] +public sealed class DarlingMcpDeadlockDetailBudgetLiveTests +{ + private const string ServerName = "darling-mcp-deadlock-detail-budget-e2e"; + private static readonly int ServerId = ServerIdHelper.GetDeterministicHashCode(ServerName); + private static string? ConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + private readonly ITestOutputHelper _output; + + public DarlingMcpDeadlockDetailBudgetLiveTests(ITestOutputHelper output) => _output = output; + + [Fact] + public async Task GetDeadlockDetail_Default_StaysUnderResponseBudget_WithFiveWideGraphs() + { + var cs = ConnectionString; + Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live deadlock-detail 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(-30); + var graphXml = BuildDeadlockGraphXml(approxLength: 42_000); + + for (var i = 0; i < 5; i++) + { + var t = baseTime.AddMinutes(i); + await DarlingMcpTestData.ExecAsync(connection, ct, + @"INSERT INTO deadlocks (deadlock_id, collection_time, server_id, server_name, deadlock_time, victim_process_id, victim_sql_text, deadlock_graph_xml) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8)", + CollectionIdGenerator.Next(), t, ServerId, ServerName, t, $"process{i}", "DELETE FROM Posts", graphXml); + } + + var defaultJson = await DarlingMcpBlockingTools.GetDeadlockDetail(postgres, ServerName); + DarlingMcpTestData.AssertEnvelope(defaultJson, ServerName, "deadlock_graph_xml"); + JsonAssert.Contains("\"deadlocks_returned\": 5", defaultJson); + JsonAssert.Contains("\"deadlock_graph_xml_truncated\": true", defaultJson); + + var defaultBytes = Encoding.UTF8.GetByteCount(defaultJson); + _output.WriteLine($"get_deadlock_detail default call: {defaultBytes:N0} bytes (budget {McpResponseBudget.DefaultBytes:N0}), 5 planted {graphXml.Length:N0}-char graphs."); + Assert.True(defaultBytes < McpResponseBudget.DefaultBytes, + $"get_deadlock_detail's default call is {defaultBytes:N0} bytes over five planted {graphXml.Length:N0}-char graphs, at or over the {McpResponseBudget.DefaultBytes:N0}-byte budget."); + + using var defaultParsed = JsonDocument.Parse(defaultJson); + var defaultFirstGraph = defaultParsed.RootElement.GetProperty("deadlocks")[0].GetProperty("deadlock_graph_xml").GetString(); + Assert.NotNull(defaultFirstGraph); + Assert.True(defaultFirstGraph!.Length < graphXml.Length, + "the default call's graph should be a preview shorter than the planted graph."); + + /* full_graph opts back into the whole XML. */ + var fullJson = await DarlingMcpBlockingTools.GetDeadlockDetail(postgres, ServerName, full_graph: true); + Assert.DoesNotContain("\"deadlock_graph_xml_truncated\": true", fullJson, StringComparison.Ordinal); + using var fullParsed = JsonDocument.Parse(fullJson); + Assert.Equal(graphXml, fullParsed.RootElement.GetProperty("deadlocks")[0].GetProperty("deadlock_graph_xml").GetString()); + + /* Naming one incident (dedup_key) returns the whole graph even without full_graph. */ + var firstKey = defaultParsed.RootElement.GetProperty("deadlocks")[0].GetProperty("dedup_key").GetString(); + Assert.False(string.IsNullOrEmpty(firstKey)); + + var byKeyJson = await DarlingMcpBlockingTools.GetDeadlockDetail(postgres, ServerName, dedup_key: firstKey, full_graph: false); + Assert.DoesNotContain("\"deadlock_graph_xml_truncated\": true", byKeyJson, StringComparison.Ordinal); + using var byKeyParsed = JsonDocument.Parse(byKeyJson); + Assert.Equal(graphXml, byKeyParsed.RootElement.GetProperty("deadlocks")[0].GetProperty("deadlock_graph_xml").GetString()); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(cs!, bodySucceeded, async (cleanup, cleanupCt) => + await DeleteRowsAsync(cleanup, cleanupCt)); + } + } + + /// Builds an XML string near characters, ASCII only so its + /// length in .NET UTF-16 chars and its size in UTF-8 bytes stay close (production deadlock graphs are + /// almost entirely ASCII: object names, wait-resource strings, T-SQL). + private static string BuildDeadlockGraphXml(int approxLength) + { + var sb = new StringBuilder(); + sb.Append(""); + var i = 0; + while (sb.Length < approxLength) + { + sb.Append($"UPDATE dbo.Posts SET Score = Score + 1 WHERE Id = {i};"); + i++; + } + + sb.Append(""); + return sb.ToString(); + } + + private static async Task DeleteRowsAsync(NpgsqlConnection connection, System.Threading.CancellationToken ct) + { + using var cleanup = new NpgsqlCommand( + $"DELETE FROM deadlocks WHERE server_id = {ServerId}; DELETE FROM servers WHERE server_id = {ServerId};", + connection); + await cleanup.ExecuteNonQueryAsync(ct); + } +} diff --git a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpBlockingTools.txt b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpBlockingTools.txt index b4b088c5f8..91158debe5 100644 --- a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpBlockingTools.txt +++ b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpBlockingTools.txt @@ -17,9 +17,10 @@ param get_blocking_trend.as_of 167 param get_blocking_trend.hours_back 29 param get_blocking_trend.server_name 28 -tool get_deadlock_detail 492 +tool get_deadlock_detail 652 param get_deadlock_detail.as_of 167 param get_deadlock_detail.dedup_key 272 +param get_deadlock_detail.full_graph 145 param get_deadlock_detail.hours_back 29 param get_deadlock_detail.limit 119 param get_deadlock_detail.server_name 28 diff --git a/Darling/Darling.Tests/McpToolsListBudgetTests.cs b/Darling/Darling.Tests/McpToolsListBudgetTests.cs index 2feef4e280..2fb0fb10d4 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 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_001; private const int ConvertedHeadCap = 1_000; private const int ConvertedParameterCap = 200; diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs index ce08e0533d..cb6c32eb74 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs @@ -311,13 +311,24 @@ the epoch. */ } } - [McpServerTool(Name = "get_deadlock_detail"), Description("Gets the full deadlock graph XML for a specific time range, NEWEST FIRST. Returns the raw XML that can be analyzed for lock resources, process details, and deadlock chains. Only deadlocks that CARRY a graph are counted against limit, so the page is limit graphs rather than limit rows; deadlocks_returned, truncated and oldest_returned_deadlock_time / newest_returned_deadlock_time describe the page the same way get_deadlocks does, and truncated means the window held more graphs than limit.")] + /// + /// #4198: deadlock_graph_xml is the wide field on this tool — a busy production store's default call + /// (limit 5, only 3 deadlocks carried a graph in the window) measured 120,454 bytes, almost all of it + /// this one field. Previewed to this length per graph at default (full_graph: true opts back + /// in), the same preview-plus-opt-in shape get_store_query_stats uses for full_text. A + /// dedup_key narrows the page to one named incident, so that call is exempt from the preview — + /// the caller already paid the cost of naming it and came for the graph. + /// + private const int DeadlockGraphPreviewLength = 2000; + + [McpServerTool(Name = "get_deadlock_detail"), Description("Gets the deadlock graph XML for a specific time range, NEWEST FIRST. Returns the raw XML that can be analyzed for lock resources, process details, and deadlock chains. Only deadlocks that CARRY a graph are counted against limit, so the page is limit graphs rather than limit rows; deadlocks_returned, truncated and oldest_returned_deadlock_time / newest_returned_deadlock_time describe the page the same way get_deadlocks does, and truncated means the window held more graphs than limit. deadlock_graph_xml is a preview by default (deadlock_graph_xml_truncated: true) — pass full_graph for the whole graph; a dedup_key call always gets the whole graph.")] public static async Task GetDeadlockDetail( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24, [Description("Maximum deadlocks WITH a graph to return, newest first. Default 5. Read truncated to know whether the window held more.")] int limit = 5, [Description("Optional #1140 alert fingerprint (the alert's Dedup Key). When supplied, returns only the incident with that key — paste it straight from an alert or ticket instead of scanning the window. The key is scoped to the server's display name and the incident's involved objects.")] string? dedup_key = null, + [Description("Return each graph's full XML instead of a 2000-character preview. Default false. A dedup_key call ignores this and always returns the full graph.")] bool full_graph = false, [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveWithFingerprintNameAsync(postgres, server_name); @@ -376,13 +387,18 @@ consume one of the `limit` slots the caller wanted spent on real graphs. It is n var truncated = candidates.Count > limit; var withXml = candidates.Take(limit).ToList(); + /* #4198: filtering (a dedup_key) already narrowed the page to one named incident, so that call + is exempt from the preview cut — see DeadlockGraphPreviewLength's doc comment. */ + var showFullGraph = full_graph || filtering; + var result = withXml.Select((r, i) => new { collection_time = r.CollectionTime.ToString("o"), deadlock_time = r.DeadlockTime?.ToString("o"), victim_process_id = r.VictimProcessId, dedup_key = keys[i], - deadlock_graph_xml = r.DeadlockGraphXml + deadlock_graph_xml = showFullGraph ? r.DeadlockGraphXml : McpHelpers.Truncate(r.DeadlockGraphXml, DeadlockGraphPreviewLength), + deadlock_graph_xml_truncated = !showFullGraph && r.DeadlockGraphXml.Length > DeadlockGraphPreviewLength }); return JsonSerializer.Serialize(new diff --git a/Lite.Tests/McpPageContractTests.cs b/Lite.Tests/McpPageContractTests.cs index 368b97c36f..2e9dbd4de9 100644 --- a/Lite.Tests/McpPageContractTests.cs +++ b/Lite.Tests/McpPageContractTests.cs @@ -122,6 +122,66 @@ public async Task GetDeadlockDetail_LimitCountsGraphs_NotRows() AssertPage(cut, "deadlocks", "deadlocks_returned", returned: 1, truncated: true); } + /// + /// #4198: deadlock_graph_xml is the wide field here — Darling's twin + /// (DarlingMcpDeadlockDetailBudgetLiveTests) measured 120,454 bytes for 3 real production graphs + /// (about 40 KB/graph). Plants five graphs near that width (the worst realistic default page, limit's + /// default) and asserts the default call previews them under , + /// and that full_graph: true opts back into the whole XML. + /// + [Fact] + public async Task GetDeadlockDetail_Default_StaysUnderResponseBudget_WithFiveWideGraphs() + { + var now = WholeSecondsNow(); + var graphXml = BuildWideDeadlockGraphXml(approxLength: 42_000); + for (var i = 0; i < 5; i++) + { + await ExecAsync(@" +INSERT INTO deadlocks (deadlock_id, collection_time, server_id, server_name, deadlock_time, victim_process_id, victim_sql_text, deadlock_graph_xml) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + _nextId--, Naive(now.AddMinutes(-i)), _serverId, ServerName, Naive(now.AddMinutes(-i)), $"process{i}", "DELETE FROM Posts", graphXml); + } + + var defaultJson = await McpBlockingTools.GetDeadlockDetail(_dataService, _serverManager, ServerName); + var root = Parse(defaultJson); + AssertPage(root, "deadlocks", "deadlocks_returned", returned: 5, truncated: false); + Assert.All(root.GetProperty("deadlocks").EnumerateArray(), d => + { + Assert.True(d.GetProperty("deadlock_graph_xml_truncated").GetBoolean()); + Assert.True(d.GetProperty("deadlock_graph_xml").GetString()!.Length < graphXml.Length); + }); + + var defaultBytes = System.Text.Encoding.UTF8.GetByteCount(defaultJson); + Assert.True(defaultBytes < McpResponseBudget.DefaultBytes, + $"get_deadlock_detail's default call is {defaultBytes:N0} bytes over five planted {graphXml.Length:N0}-char graphs, at or over the {McpResponseBudget.DefaultBytes:N0}-byte budget."); + + var fullJson = await McpBlockingTools.GetDeadlockDetail(_dataService, _serverManager, ServerName, 24, 5, full_graph: true); + var fullRoot = Parse(fullJson); + Assert.All(fullRoot.GetProperty("deadlocks").EnumerateArray(), d => + { + Assert.False(d.GetProperty("deadlock_graph_xml_truncated").GetBoolean()); + Assert.Equal(graphXml, d.GetProperty("deadlock_graph_xml").GetString()); + }); + } + + /// Builds an XML string near characters, ASCII only so its + /// length and its UTF-8 byte count stay close (production deadlock graphs are almost entirely ASCII: + /// object names, wait-resource strings, T-SQL). + private static string BuildWideDeadlockGraphXml(int approxLength) + { + var sb = new System.Text.StringBuilder(); + sb.Append(""); + var i = 0; + while (sb.Length < approxLength) + { + sb.Append($"UPDATE dbo.Posts SET Score = Score + 1 WHERE Id = {i};"); + i++; + } + + sb.Append(""); + return sb.ToString(); + } + [Fact] public async Task GetBlockedProcessReports_TruncationIsObservedAtTheBoundary() { diff --git a/Lite.Tests/McpToolsListBudget/McpBlockingTools.txt b/Lite.Tests/McpToolsListBudget/McpBlockingTools.txt index 00c379f588..a2e3d3b09f 100644 --- a/Lite.Tests/McpToolsListBudget/McpBlockingTools.txt +++ b/Lite.Tests/McpToolsListBudget/McpBlockingTools.txt @@ -16,8 +16,9 @@ param get_blocking_trend.as_of 167 param get_blocking_trend.hours_back 29 param get_blocking_trend.server_name 28 -tool get_deadlock_detail 492 +tool get_deadlock_detail 606 param get_deadlock_detail.as_of 167 +param get_deadlock_detail.full_graph 80 param get_deadlock_detail.hours_back 29 param get_deadlock_detail.limit 119 param get_deadlock_detail.server_name 28 diff --git a/Lite.Tests/McpToolsListBudgetTests.cs b/Lite.Tests/McpToolsListBudgetTests.cs index 4dc551590c..d7ffae4d78 100644 --- a/Lite.Tests/McpToolsListBudgetTests.cs +++ b/Lite.Tests/McpToolsListBudgetTests.cs @@ -83,7 +83,11 @@ 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 TB): +253 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). Darling's twin grew by a different amount (+364): Darling's description also + covers the dedup_key exemption, which Lite's get_deadlock_detail has no dedup_key parameter to need. */ + private const int TotalCeilingBytes = 90_134; private const int ConvertedHeadCap = 1_000; private const int ConvertedParameterCap = 200; diff --git a/Lite/Mcp/McpBlockingTools.cs b/Lite/Mcp/McpBlockingTools.cs index fa3badeed5..21fde1e757 100644 --- a/Lite/Mcp/McpBlockingTools.cs +++ b/Lite/Mcp/McpBlockingTools.cs @@ -78,13 +78,22 @@ answer nobody should be given without being told. */ } } - [McpServerTool(Name = "get_deadlock_detail"), Description("Gets the full deadlock graph XML for a specific time range, NEWEST FIRST. Returns the raw XML that can be analyzed for lock resources, process details, and deadlock chains. Only deadlocks that CARRY a graph are counted against limit, so the page is limit graphs rather than limit rows; deadlocks_returned, truncated and oldest_returned_deadlock_time / newest_returned_deadlock_time describe the page the same way get_deadlocks does, and truncated means the window held more graphs than limit.")] + /// + /// #4198: deadlock_graph_xml is the wide field on this tool — see Darling's + /// DarlingMcpBlockingTools.DeadlockGraphPreviewLength twin for the measured bytes. Previewed to + /// this length per graph at default (full_graph: true opts back in), the same shape + /// get_store_query_stats uses for full_text. + /// + private const int DeadlockGraphPreviewLength = 2000; + + [McpServerTool(Name = "get_deadlock_detail"), Description("Gets the deadlock graph XML for a specific time range, NEWEST FIRST. Returns the raw XML that can be analyzed for lock resources, process details, and deadlock chains. Only deadlocks that CARRY a graph are counted against limit, so the page is limit graphs rather than limit rows; deadlocks_returned, truncated and oldest_returned_deadlock_time / newest_returned_deadlock_time describe the page the same way get_deadlocks does, and truncated means the window held more graphs than limit. deadlock_graph_xml is a preview by default (deadlock_graph_xml_truncated: true) — pass full_graph for the whole graph.")] public static async Task GetDeadlockDetail( LocalDataService dataService, ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24, [Description("Maximum deadlocks WITH a graph to return, newest first. Default 5. Read truncated to know whether the window held more.")] int limit = 5, + [Description("Return each graph's full XML instead of a 2000-character preview. Default false.")] bool full_graph = false, [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); @@ -117,7 +126,8 @@ older graphs sat behind the cap. */ collection_time = r.CollectionTime.ToString("o"), deadlock_time = r.DeadlockTime?.ToString("o"), victim_process_id = r.VictimProcessId, - deadlock_graph_xml = r.DeadlockGraphXml + deadlock_graph_xml = full_graph ? r.DeadlockGraphXml : McpHelpers.Truncate(r.DeadlockGraphXml, DeadlockGraphPreviewLength), + deadlock_graph_xml_truncated = !full_graph && r.DeadlockGraphXml.Length > DeadlockGraphPreviewLength }); return JsonSerializer.Serialize(new From 4743a6c993f12406f225b23cfac65b415c90c402 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:51:56 -0400 Subject: [PATCH 2/3] Classify deadlock_graph_xml_truncated in the cut-key census (#4198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_deadlock_detail's new field-level preview flag needed a class: not a page cut (truncated), not a second bound, not a source-side (collector-time) cut, and not a homonym. Adds FieldPreviewCutKeys for #4198's shape — one wide field previewed under the response budget, with a caller opt-in (full_graph, or a dedup_key call) that gets the whole field back, unlike a source-side cut the caller can do nothing about. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- .../Darling.Tests/McpPayloadContractCensusTests.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs index a0120b5adf..bceae01fe4 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, the same shape get_store_query_stats' full_text already used) or by + /// narrowing the page to one named thing (a dedup_key call always gets the whole field). /// 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 = + [ + ("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"), + ]; + 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))); From 721953f66fd0fc459e1b4c5d1bdcb8c68be25aac Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:40:39 -0400 Subject: [PATCH 3/3] Web viewer keeps the full deadlock graph after #4198's default cut (#4254) get_deadlock_detail's new full_graph MCP default (false) previews the graph XML. The web viewer's /api/read row calls the same MCP method, so without its own default it would inherit the preview. Pin the row's full_graph to true so the viewer keeps showing the whole graph, and add a source-text pin so a future edit that drops the argument fails a test instead of silently shrinking what the viewer renders. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- .../DeadlockDetailWebDefaultTests.cs | 87 +++++++++++++++++++ .../DarlingWebEndpoints.cs | 8 +- 2 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 Darling/Darling.Tests/DeadlockDetailWebDefaultTests.cs diff --git a/Darling/Darling.Tests/DeadlockDetailWebDefaultTests.cs b/Darling/Darling.Tests/DeadlockDetailWebDefaultTests.cs new file mode 100644 index 0000000000..33e08b683e --- /dev/null +++ b/Darling/Darling.Tests/DeadlockDetailWebDefaultTests.cs @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.IO; +using System.Runtime.CompilerServices; +using Xunit; + +namespace Darling.Tests; + +/// +/// #4198 cut get_deadlock_detail's default deadlock_graph_xml to a 2000-character preview, +/// gated behind a new full_graph opt-in that defaults to false on the MCP signature. The web +/// viewer's /api/read dispatch calls that same MCP method, so without its own default it would +/// silently inherit the preview. The viewer has always rendered the whole graph, and #4198 changed an MCP +/// default only - not what the viewer should show. This pins the web row's full_graph default at +/// true, independent of (and opposite to) the MCP signature's own default. +/// +public sealed class DeadlockDetailWebDefaultTests +{ + private const string WebEndpoints = + "Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs"; + + [Fact] + public void GetDeadlockDetail_WebRowKeepsTheFullGraphByDefault() + { + var web = StripComments(ReadSource(WebEndpoints)); + + Assert.Contains("full_graph: QueryBool(c, \"full_graph\", true)", web, StringComparison.Ordinal); + Assert.Contains("PBool(\"full_graph\", true)", web, StringComparison.Ordinal); + Assert.DoesNotContain("QueryBool(c, \"full_graph\", false)", web, StringComparison.Ordinal); + } + + private static string StripComments(string source) + { + var stripped = CSharpSourceWalker.StripCommentsAndStrings(source).ToCharArray(); + + foreach (var (start, text) in CSharpSourceWalker.StringLiteralBodies(source)) + { + for (var i = 0; i < text.Length && start + i < stripped.Length; i++) + { + stripped[start + i] = text[i]; + } + } + + var code = CSharpSourceWalker.CodeMask(source); + + for (var i = 0; i < source.Length; i++) + { + if (!code[i] && source[i] is '"' or '@' or '$') + { + stripped[i] = source[i]; + } + } + + return new string(stripped); + } + + private static string ReadSource(string relative) + { + var path = Path.Combine(RepoRoot(), relative); + + Assert.True(File.Exists(path), $"#4254 scan target not found: {path}"); + + return File.ReadAllText(path); + } + + private static string RepoRoot([CallerFilePath] string thisFile = "") + { + var dir = Path.GetDirectoryName(thisFile)!; + + while (dir is not null + && !File.Exists(Path.Combine(dir, "PerformanceMonitor.sln")) + && !Directory.Exists(Path.Combine(dir, ".git"))) + { + dir = Path.GetDirectoryName(dir); + } + + Assert.NotNull(dir); + return dir!; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs index 30c27c57c0..ec31061c4a 100644 --- a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs @@ -1932,7 +1932,7 @@ private static CatalogRead R(string category, string description, params Catalog ["get_blocked_process_xml"] = R(CatBlocking, "Blocked-process-report XML captures.", PServer(), PHours(24), PLimit(5), PAsOf()), ["get_blocking"] = R(CatBlocking, "Blocking chains observed in the window.", PServer(), PHours(24), PLimit(30), PAsOf()), ["get_blocking_trend"] = R(CatBlocking, "Blocking-event counts over time.", PServer(), PHours(24), PAsOf()), - ["get_deadlock_detail"] = R(CatBlocking, "Deadlock graph detail for recent deadlocks.", PServer(), PHours(24), PLimit(5), PAsOf()), + ["get_deadlock_detail"] = R(CatBlocking, "Deadlock graph detail for recent deadlocks.", PServer(), PHours(24), PLimit(5), PBool("full_graph", true), PAsOf()), ["get_deadlock_trend"] = R(CatBlocking, "Deadlock counts over time.", PServer(), PHours(24), PAsOf()), ["get_deadlocks"] = R(CatBlocking, "Recent deadlocks with victim/resource summary.", PServer(), PHours(24), PLimit(20), PAsOf()), ["get_lock_wait_trend"] = R(CatBlocking, "The LCK% family's summed wait ms/sec over time - the aggregate lock-wait lane - with a legend of the types that waited.", PServer(), PHours(24), PAsOf(), PInt("bucket_minutes")), @@ -2633,7 +2633,11 @@ logger is the tool's logger seat — the web host's SERVICE logger when MapAll b ["get_blocked_process_xml"] = (c, pg, an) => DarlingMcpBlockingTools.GetBlockedProcessXml(pg, Server(c), Hours(c, 24), Rows(c, "limit", 5), as_of: AsOf(c)), ["get_blocking"] = (c, pg, an) => DarlingMcpBlockingTools.GetBlocking(pg, Server(c), Hours(c, 24), Rows(c, "limit", 30), as_of: AsOf(c)), ["get_blocking_trend"] = (c, pg, an) => DarlingMcpBlockingTools.GetBlockingTrend(pg, Server(c), Hours(c, 24), as_of: AsOf(c)), - ["get_deadlock_detail"] = (c, pg, an) => DarlingMcpBlockingTools.GetDeadlockDetail(pg, Server(c), Hours(c, 24), Rows(c, "limit", 5), as_of: AsOf(c)), + /* #4254: full_graph defaults false on the MCP signature (a preview keeps a busy production + store's tools/list-driven call under the shared response budget), but the web viewer has + always shown the whole graph. The row pins its OWN default to true so #4198's MCP-side + budget cut does not silently shrink what the viewer renders. */ + ["get_deadlock_detail"] = (c, pg, an) => DarlingMcpBlockingTools.GetDeadlockDetail(pg, Server(c), Hours(c, 24), Rows(c, "limit", 5), full_graph: QueryBool(c, "full_graph", true), as_of: AsOf(c)), ["get_deadlock_trend"] = (c, pg, an) => DarlingMcpBlockingTools.GetDeadlockTrend(pg, Server(c), Hours(c, 24), as_of: AsOf(c)), ["get_deadlocks"] = (c, pg, an) => DarlingMcpBlockingTools.GetDeadlocks(pg, Server(c), Hours(c, 24), Rows(c, "limit", 20), as_of: AsOf(c)), ["get_lock_wait_trend"] = (c, pg, an) => OptionalInt(c, "bucket_minutes", out var bucketMinutes)