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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions Darling/Darling.Tests/DarlingMcpDeadlockDetailBudgetLiveTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// #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
/// <see cref="McpResponseBudget.DefaultBytes"/>, that <c>full_graph: true</c> opts back into the whole
/// graph, and that a <c>dedup_key</c> call (naming one incident) returns the whole graph even without
/// <c>full_graph</c>. New file (not the shared seeding in <see cref="DarlingMcpBlockingToolsLivePostgresTests"/>)
/// because #4198 ran a dozen lanes against this store tonight.
/// </summary>
[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));
}
}

/// <summary>Builds an XML string near <paramref name="approxLength"/> 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).</summary>
private static string BuildDeadlockGraphXml(int approxLength)
{
var sb = new StringBuilder();
sb.Append("<deadlock><victim-list><victimProcess id=\"process0\"/></victim-list><process-list>");
var i = 0;
while (sb.Length < approxLength)
{
sb.Append($"<process id=\"process{i}\" waitresource=\"KEY: 5:72057594057{i:D8}\"><inputbuf>UPDATE dbo.Posts SET Score = Score + 1 WHERE Id = {i};</inputbuf></process>");
i++;
}

sb.Append("</process-list><resource-list><keylock objectname=\"StackOverflow.dbo.Posts\"/></resource-list></deadlock>");
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);
}
}
87 changes: 87 additions & 0 deletions Darling/Darling.Tests/DeadlockDetailWebDefaultTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// #4198 cut <c>get_deadlock_detail</c>'s default <c>deadlock_graph_xml</c> to a 2000-character preview,
/// gated behind a new <c>full_graph</c> opt-in that defaults to <c>false</c> on the MCP signature. The web
/// viewer's <c>/api/read</c> 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 <c>full_graph</c> default at
/// <c>true</c>, independent of (and opposite to) the MCP signature's own default.
/// </summary>
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!;
}
}
13 changes: 13 additions & 0 deletions Darling/Darling.Tests/McpPayloadContractCensusTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <c>truncated</c> would tell that
/// caller to raise a limit that changes nothing.</item>
/// <item><b>A field-level response-budget preview</b> — <see cref="FieldPreviewCutKeys"/>: #4198 sizes
/// each tool's DEFAULT answer under the shared 32 KB <c>McpResponseBudget.DefaultBytes</c> by previewing
/// one wide field (query text, a plan fragment, a deadlock graph) rather than the page — unlike a
/// source-side cut, a caller CAN get the rest, with an opt-in argument (<c>get_deadlock_detail</c>'s
/// <c>full_graph</c>, the same shape <c>get_store_query_stats</c>' <c>full_text</c> already used) or by
/// narrowing the page to one named thing (a <c>dedup_key</c> call always gets the whole field).</item>
/// <item><b>The withheld summary</b> — <see cref="WithheldSummaryKeys"/>: #3594's own vocabulary for a
/// reach verdict that withholds a figure rather than publishing a page's count under a whole's name.</item>
/// </list>
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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)));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion Darling/Darling.Tests/McpToolsListBudgetTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,10 @@ 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. */
private const int TotalCeilingBytes = 171_719;
/* #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_083;

private const int ConvertedHeadCap = 1_000;
private const int ConvertedParameterCap = 200;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading