Skip to content
163 changes: 163 additions & 0 deletions Darling/Darling.Tests/DarlingMcpBlockingBudgetLiveTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/*
* 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_blocking's own response-budget pin. Every row on this tool carries ~37 fields (isolation
/// levels, client app/host/login for both sides, six last-tran/last-batch stamps, a dedup_key) before either
/// SQL text column is counted, so the default page's own width times the default row LIMIT is most of the
/// bytes here — not one wide field the way get_deadlock_detail's graph is. Plants 30 rows (the default
/// limit) with realistic-width blocked/blocking SQL text and asserts the default call stays under
/// <see cref="McpResponseBudget.DefaultBytes"/>, that <c>full_text</c> opts back into the whole text on both
/// columns, and that a <c>dedup_key</c> call (naming one incident) returns the whole text even without
/// <c>full_text</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 DarlingMcpBlockingBudgetLiveTests
{
private const string ServerName = "darling-mcp-blocking-budget-e2e";
private static readonly int ServerId = ServerIdHelper.GetDeterministicHashCode(ServerName);
private static string? ConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG");
private readonly ITestOutputHelper _output;

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

[Fact]
public async Task GetBlocking_Default_StaysUnderResponseBudget_WithThirtyRealisticRows()
{
var cs = ConnectionString;
Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live get_blocking 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(-60);
var blockedSql = BuildQueryText("UPDATE dbo.Posts SET Score = Score + 1, LastActivityDate = GETUTCDATE()", approxLength: 650);
var blockingSql = BuildQueryText("SELECT p.Id, p.Title, p.Body, u.DisplayName FROM dbo.Posts p JOIN dbo.Users u ON u.Id = p.OwnerUserId", approxLength: 820);

for (var i = 0; i < 30; i++)
{
var t = baseTime.AddMinutes(i);
await DarlingMcpTestData.ExecAsync(connection, ct,
@"INSERT INTO blocked_process_reports
(blocked_report_id, collection_time, server_id, server_name, event_time, database_name,
blocked_spid, blocked_ecid, blocking_spid, blocking_ecid, wait_time_ms, wait_resource, lock_mode,
blocked_status, blocked_isolation_level, blocked_log_used, blocked_transaction_count,
blocked_client_app, blocked_host_name, blocked_login_name, blocked_sql_text,
blocking_status, blocking_isolation_level, blocking_client_app, blocking_host_name, blocking_login_name,
blocking_sql_text, blocked_transaction_name, blocking_transaction_name,
blocked_last_tran_started, blocking_last_tran_started, blocked_last_batch_started, blocking_last_batch_started,
blocked_last_batch_completed, blocking_last_batch_completed, blocked_priority, blocking_priority,
blocked_process_report_xml, contentious_object)
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,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38,$39)",
CollectionIdGenerator.Next(), t, ServerId, ServerName, t, "StackOverflow",
100 + i, 0, 50 + (i % 5), 0, 5000L + i * 137, "KEY: 6:72057594057849856 (3a1c2b4e5f6a)", "X",
"suspended", "READ COMMITTED", 4096L, 1,
".Net SqlClient Data Provider", "APPSERVER01", "CONTOSO\\svc_app", blockedSql + $" /* row {i} */",
"running", "READ COMMITTED", "Microsoft SQL Server Management Studio", "DBASERVER02", "CONTOSO\\dba_erik",
blockingSql + $" /* row {i} */", "user_transaction", "user_transaction",
t, t, t, t,
t, t, 0, 0,
"<blocked-process-report/>", "dbo.Posts");
}

var defaultJson = await DarlingMcpBlockingTools.GetBlocking(postgres, ServerName);
DarlingMcpTestData.AssertEnvelope(defaultJson, ServerName, "events");
JsonAssert.Contains($"\"events_returned\": {DarlingMcpBlockingTools.DefaultLimit}", defaultJson);
JsonAssert.Contains("\"truncated\": true", defaultJson);

/* #4198: BEFORE this lane's fix, 30 planted rows at this realistic-but-modest text width (well
under the old 2000-char cap, so neither text column was even truncated) measured 89,096 bytes
— 2.7x McpResponseBudget.DefaultBytes — because the row's other ~37 fields (isolation levels,
client app/host/login for both sides, six last-tran/last-batch stamps) are most of the weight
at the default 30-row page, not the two text columns alone. */
JsonAssert.Contains("\"blocked_sql_text_truncated\": true", defaultJson);
JsonAssert.Contains("\"blocking_sql_text_truncated\": true", defaultJson);

var defaultBytes = Encoding.UTF8.GetByteCount(defaultJson);
_output.WriteLine($"get_blocking default call: {defaultBytes:N0} bytes (budget {McpResponseBudget.DefaultBytes:N0}), 30 planted rows, blocked/blocking SQL text {blockedSql.Length:N0}/{blockingSql.Length:N0} chars.");
Assert.True(defaultBytes < McpResponseBudget.DefaultBytes,
$"get_blocking's default call is {defaultBytes:N0} bytes, at or over the {McpResponseBudget.DefaultBytes:N0}-byte budget.");

using var defaultParsed = JsonDocument.Parse(defaultJson);
var defaultFirstBlocked = defaultParsed.RootElement.GetProperty("events")[0].GetProperty("blocked_sql_text").GetString();
Assert.NotNull(defaultFirstBlocked);
Assert.True(defaultFirstBlocked!.Length < blockedSql.Length,
"the default call's blocked_sql_text should be a preview shorter than the planted text.");

/* full_text opts back into the whole text on both columns. */
var fullJson = await DarlingMcpBlockingTools.GetBlocking(postgres, ServerName, limit: 30, full_text: true);
Assert.DoesNotContain("_truncated\": true", fullJson, StringComparison.Ordinal);
using var fullParsed = JsonDocument.Parse(fullJson);
var fullFirstBlocked = fullParsed.RootElement.GetProperty("events")[0].GetProperty("blocked_sql_text").GetString();
/* Newest first: events[0] is row 29 (latest event_time), not row 0 — just check the text is
whole (starts with the planted clause, un-truncated) rather than hardcode which row sorts first. */
Assert.NotNull(fullFirstBlocked);
Assert.StartsWith(blockedSql, fullFirstBlocked!, StringComparison.Ordinal);
Assert.True(fullFirstBlocked!.Length > blockedSql.Length, "the full_text call should include the ' /* row N */' suffix past the planted clause.");

/* Naming one incident (dedup_key) returns the whole text even without full_text. */
var firstKey = defaultParsed.RootElement.GetProperty("events")[0].GetProperty("dedup_key").GetString();
Assert.False(string.IsNullOrEmpty(firstKey));

var byKeyJson = await DarlingMcpBlockingTools.GetBlocking(postgres, ServerName, dedup_key: firstKey, full_text: false);
Assert.DoesNotContain("_truncated\": true", byKeyJson, StringComparison.Ordinal);

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

/// <summary>Builds a T-SQL string near <paramref name="approxLength"/> characters by repeating a
/// realistic clause, ASCII only so its .NET UTF-16 char length and its UTF-8 byte size stay close.</summary>
private static string BuildQueryText(string clause, int approxLength)
{
var sb = new StringBuilder(clause);
var i = 0;
while (sb.Length < approxLength)
{
sb.Append($" AND p.Id NOT IN (SELECT TOP (1) Id FROM dbo.Posts WHERE ParentId = {i})");
i++;
}

return sb.ToString();
}

private static async Task DeleteRowsAsync(NpgsqlConnection connection, System.Threading.CancellationToken ct)
{
using var cleanup = new NpgsqlCommand(
$"DELETE FROM blocked_process_reports WHERE server_id = {ServerId}; DELETE FROM servers WHERE server_id = {ServerId};",
connection);
await cleanup.ExecuteNonQueryAsync(ct);
}
}
4 changes: 4 additions & 0 deletions Darling/Darling.Tests/McpPayloadContractCensusTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1534,6 +1534,10 @@ public static readonly (string Key, string[] Files, string WhatWasCut)[] FieldPr
"#4198: query_text is previewed at read time by two tools: get_active_queries previews at 500 chars (full_text gets the whole text; a synthetic 50-row page measured 81,489 bytes), get_plan_corrections previews at 150 chars (full_text gets the whole text; the full text IS in the store, not collector-capped)"),
("error_message_truncated", ["DarlingMcpDataTools.cs", "McpHealthTools.cs"],
"#4198: get_collection_log's own wide field — error_message is a 500-character preview by default (a seeded store measured 90,514 bytes for 200 rows at the old 200-row default), full_text opts back into the whole (up to 4000-character, DarlingObservability.LogCollectionAsync's own write-time ceiling) field"),
("blocked_sql_text_truncated", ["DarlingMcpBlockingTools.cs", "McpBlockingTools.cs"],
"#4198: get_blocking/get_blocked_process_reports' blocked_sql_text previewed to SqlTextPreviewLength (150 chars) — the default row LIMIT also halved (30 -> 15), because the row's other ~37 fields, not this column alone, were most of the default page's weight; full_text or a dedup_key call (Darling only) gets the whole text"),
("blocking_sql_text_truncated", ["DarlingMcpBlockingTools.cs", "McpBlockingTools.cs"],
"#4198: get_blocking/get_blocked_process_reports' blocking_sql_text, previewed the same way as blocked_sql_text_truncated"),
("top_query_text_truncated", ["DarlingMcpQueryHeatmapTools.cs", "McpQueryTools.cs"],
"get_query_heatmap's (#4198) per-cell top-query preview width (DefaultPreviewLength on both SKUs) — the full statement is already in the store; full_text opts back into it rather than re-paging, so this is not the page dialect's truncated and nothing was lost the way a source-side cut loses it"),
];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ param get_blocked_process_xml.hours_back 29
param get_blocked_process_xml.limit 113
param get_blocked_process_xml.server_name 28

tool get_blocking 619
tool get_blocking 576
param get_blocking.as_of 167
param get_blocking.dedup_key 191
param get_blocking.full_text 172
param get_blocking.hours_back 29
param get_blocking.limit 133
param get_blocking.server_name 28
Expand Down
9 changes: 8 additions & 1 deletion Darling/Darling.Tests/McpToolsListBudgetTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ so neither counts here. */
/* #4198 (lane TB): +364 bytes for get_deadlock_detail's default-preview note in its served description
and its new full_graph opt-in parameter (deadlock_graph_xml, the wide field, is now a 2000-char
preview by default). */
/* #4198 (lane TI): +391 bytes for get_blocking's default-preview note in its served description and its
new full_text opt-in parameter (blocked_sql_text/blocking_sql_text are now a 150-char preview by
default). The default row limit also dropped 30 -> 15 -- 30 rows of even sub-2000-char (never
truncated under the OLD cap) text measured 89,096 bytes, 2.7x the budget, because the row's other ~37
fields were most of the weight -- but that is not a served description, so it does not count here. */
/* #4198: get_collection_log's per-server form gained full_text (its error_message preview opt-in,
76 bytes) and limit's own description banked 1 byte describing the new lower default. +127 net. */
/* #4198 (per-tool lane, get_object_locking): +79 bytes for the new limit parameter (default lowered from
Expand All @@ -120,7 +125,9 @@ so neither counts here. */
of active_queries (#4261) + object_locking (#4258) changes on top of dev. */
/* #4198 (collection_log, merge): re-measured after merging origin/dev (dev now includes #4261+#4258);
combined total with collection_log (#4265) changes on top. */
private const int TotalCeilingBytes = 172_760;
/* #4198 (blocking, merge): re-measured after merging origin/dev (dev now includes #4261+#4258+#4265);
combined total with blocking (#4267) changes on top. */
private const int TotalCeilingBytes = 172_942;

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 @@ -2631,7 +2631,11 @@ logger is the tool's logger seat — the web host's SERVICE logger when MapAll b

/* ── blocking / deadlocks ── */
["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)),
/* #4198: the internal overload, not the MCP tool wrapper — pins the OLD row limit (30) and the
OLD 2000-char text cap (WebSqlTextPreviewLength) explicitly, so this page does not change even
though the tool's own MCP defaults (limit 15, 150-char preview) did. Same shape #3897's trend
tools use to pass TrendBudget.Chart here instead of their own MCP point budget. */
["get_blocking"] = (c, pg, an) => DarlingMcpBlockingTools.GetBlocking(pg, Server(c), Hours(c, 24), Rows(c, "limit", 30), null, false, AsOf(c), DarlingMcpBlockingTools.WebSqlTextPreviewLength),
["get_blocking_trend"] = (c, pg, an) => DarlingMcpBlockingTools.GetBlockingTrend(pg, Server(c), Hours(c, 24), 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
Expand Down
Loading
Loading