From 09cab7c94bd12839582cbd0795be4979653edebb Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:42:18 -0400 Subject: [PATCH 1/4] #4198: size get_blocking / get_blocked_process_reports under the response budget 30 default rows at ~37 fields/row already ran 89,096 bytes with sub-2000-char (never-truncated) SQL text -- 2.7x the 32 KB budget -- before either text column was even the driver. Halves the default row limit (30 -> 15) and previews blocked_sql_text/blocking_sql_text to 150 chars (full_text opts back in; a dedup_key call always gets the whole text). Web viewer keeps today's 30-row, 2000-char-preview result through an internal GetBlocking overload the /api/read row calls explicitly, the way #3897's trend tools pass TrendBudget.Chart. Lite twin gets the same defaults. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- .../DarlingMcpBlockingBudgetLiveTests.cs | 163 ++++++++++++++++++ .../DarlingWebEndpoints.cs | 6 +- .../Mcp/DarlingMcpBlockingTools.cs | 53 +++++- Lite/Mcp/McpBlockingTools.cs | 17 +- 4 files changed, 228 insertions(+), 11 deletions(-) create mode 100644 Darling/Darling.Tests/DarlingMcpBlockingBudgetLiveTests.cs diff --git a/Darling/Darling.Tests/DarlingMcpBlockingBudgetLiveTests.cs b/Darling/Darling.Tests/DarlingMcpBlockingBudgetLiveTests.cs new file mode 100644 index 000000000..c3b9f33ef --- /dev/null +++ b/Darling/Darling.Tests/DarlingMcpBlockingBudgetLiveTests.cs @@ -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; + +/// +/// #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 +/// , that full_text opts back into the whole text on both +/// columns, and that a dedup_key call (naming one incident) returns the whole text even without +/// full_text. New file (not the shared seeding in ) +/// because #4198 ran a dozen lanes against this store tonight. +/// +[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, + "", "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)); + } + } + + /// Builds a T-SQL string near characters by repeating a + /// realistic clause, ASCII only so its .NET UTF-16 char length and its UTF-8 byte size stay close. + 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); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs index 5a9116b55..ae27c53d9 100644 --- a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs @@ -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 diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs index cb6c32eb7..c3a9e06e7 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs @@ -44,14 +44,49 @@ namespace PerformanceMonitor.Darling.Service.Mcp; [McpServerToolType] public sealed class DarlingMcpBlockingTools { - [McpServerTool(Name = "get_blocking"), Description("Blocked process report XE + DMV fallback events, newest first, window ends at as_of. not_collected wins if the engine can't run blocked_process_report; else empty means none in the window, or none collected in it. limit caps ROWS, not hours_back: truncated true means raise limit or narrow the window, not widen hours_back. dedup_key scans the whole window before limit, up to a stated ceiling (rows_examined/scan_truncated); a no-match answer is still empty. wait_time_ms is milliseconds. Timestamps are UTC; last_tran/last_batch stamps are de-skewed for direct comparison to event_time.<>Gets blocking events captured by the blocked process report extended event (plus the always-on DMV blocking-snapshot fallback), NEWEST FIRST. Shows the blocked and blocking sessions, wait types, wait times, and query text for both. Use this first for a quick overview, then use get_blocked_process_xml for deep analysis of prolonged blocking. THE PAGE IS BOUNDED BY limit, NOT BY hours_back: hours_back is the window you ASKED for, events_returned is how many rows you GOT, truncated says the window held more than limit, and oldest_returned_event_time / newest_returned_event_time bound the page you are looking at. Because the page is a contiguous newest-first slice, oldest_returned_event_time IS how far back this read reached — on a server blocking steadily, a 24-hour request at the default limit is answered by the newest few minutes, and nothing in the rows themselves says so. When truncated is true, raise limit or narrow hours_back (or anchor as_of) before drawing a conclusion about the window; widening hours_back cannot help, because the cap is on rows, not time. With dedup_key the read scans the window for the fingerprint BEFORE limit applies (so a matching incident is never lost to the cap), up to a stated scan ceiling: rows_examined is how many rows were fingerprinted and scan_truncated says whether the window held more than the scan could reach. Every timestamp here is UTC: event_time already was, and the six blocked_/blocking_ last_tran/last_batch stamps are de-skewed from the monitored server's local clock by this read, so comparing them against event_time to see whether a transaction predates the block is direct. dedup_key: Optional 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. The fingerprint scan runs over the window BEFORE limit, up to the scan ceiling the payload reports as rows_examined / scan_truncated.")] - public static async Task GetBlocking( + /// + /// #4198: get_blocking's default page is wide before either text column is counted — ~37 fields per row + /// (isolation levels, client app/host/login for both sides, six last-tran/last-batch stamps, a + /// dedup_key) — so 30 rows of even modest (sub-2000-char, i.e. never hitting the old truncation) SQL + /// text still measured 89,096 bytes, 2.7x the shared budget. Both levers move: the default row limit + /// halves (30 -> 15, the same proportion #4198's get_plan_corrections used), and blocked_sql_text / + /// blocking_sql_text preview to (150, get_plan_corrections' + /// QueryTextPreviewLength value) rather than the old 2000, with full_text the opt-in back to the + /// whole text — the same shape get_store_query_stats already uses. A dedup_key call is + /// exempt (it already named one incident) and always gets the whole text, same as get_deadlock_detail. + /// + public const int DefaultLimit = 15; + + /// See 's doc comment. + private const int SqlTextPreviewLength = 150; + + /// + /// The web viewer's /api/read mirror passes this instead (#4198): the OLD 2000-char cap, so the + /// page it renders does not change. It is not full_text (unbounded) because the web never showed + /// more than 2000 chars of either column before this lane, and passing true would grow the web's own + /// payload past what it has always been. + /// + public const int WebSqlTextPreviewLength = 2000; + + [McpServerTool(Name = "get_blocking"), Description("Blocked process report XE + DMV fallback events, newest first, window ends at as_of. not_collected wins if the engine can't run blocked_process_report; else empty means none in the window, or none collected in it. limit caps ROWS, not hours_back: truncated true means raise limit or narrow the window, not widen hours_back. dedup_key scans the whole window before limit, up to a stated ceiling (rows_examined/scan_truncated); a no-match answer is still empty. wait_time_ms is milliseconds. Timestamps are UTC; last_tran/last_batch stamps are de-skewed for direct comparison to event_time. blocked_sql_text/blocking_sql_text are a preview by default (*_truncated: true) — pass full_text for the whole text; a dedup_key call always gets the whole text.<>Gets blocking events captured by the blocked process report extended event (plus the always-on DMV blocking-snapshot fallback), NEWEST FIRST. Shows the blocked and blocking sessions, wait types, wait times, and query text for both. Use this first for a quick overview, then use get_blocked_process_xml for deep analysis of prolonged blocking. THE PAGE IS BOUNDED BY limit, NOT BY hours_back: hours_back is the window you ASKED for, events_returned is how many rows you GOT, truncated says the window held more than limit, and oldest_returned_event_time / newest_returned_event_time bound the page you are looking at. Because the page is a contiguous newest-first slice, oldest_returned_event_time IS how far back this read reached — on a server blocking steadily, a 24-hour request at the default limit is answered by the newest few minutes, and nothing in the rows themselves says so. When truncated is true, raise limit or narrow hours_back (or anchor as_of) before drawing a conclusion about the window; widening hours_back cannot help, because the cap is on rows, not time. With dedup_key the read scans the window for the fingerprint BEFORE limit applies (so a matching incident is never lost to the cap), up to a stated scan ceiling: rows_examined is how many rows were fingerprinted and scan_truncated says whether the window held more than the scan could reach. Every timestamp here is UTC: event_time already was, and the six blocked_/blocking_ last_tran/last_batch stamps are de-skewed from the monitored server's local clock by this read, so comparing them against event_time to see whether a transaction predates the block is direct. dedup_key: Optional 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. The fingerprint scan runs over the window BEFORE limit, up to the scan ceiling the payload reports as rows_examined / scan_truncated.")] + public static Task GetBlocking( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24, - [Description("Maximum rows to return, newest first. Default 30. This is what bounds the page — read truncated to know whether the window held more.")] int limit = 30, + [Description("Maximum rows to return, newest first. Default 15. This is what bounds the page — read truncated to know whether the window held more.")] int limit = DefaultLimit, [Description("Optional alert fingerprint (the alert's Dedup Key). The key is scoped to the server's display name and the incident's involved objects. The fingerprint scan runs over the window BEFORE limit.")] string? dedup_key = null, - [Description(McpHelpers.AsOfDescription)] string? as_of = null) + [Description("Return each row's full blocked_sql_text/blocking_sql_text instead of a 150-character preview. Default false. A dedup_key call ignores this and always returns the full text.")] bool full_text = false, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) => + GetBlocking(postgres, server_name, hours_back, limit, dedup_key, full_text, as_of, SqlTextPreviewLength); + + /// + /// get_blocking under an explicit (#4198): the MCP tool passes + /// its own default (or a dedup_key/full_text call passes through to the whole text below), the web + /// viewer's /api/read mirror passes so its page does not + /// change. + /// + internal static async Task GetBlocking( + NpgsqlDataSource postgres, string? server_name, int hours_back, int limit, string? dedup_key, bool full_text, string? as_of, int sqlTextPreviewLength) { var (resolved, error) = await DarlingServerResolver.ResolveWithFingerprintNameAsync(postgres, server_name); if (error != null) return error; @@ -123,6 +158,10 @@ the newest `limit` rows that happen to include it. Without a key the scan IS the var truncated = rows.Count > limit; var page = rows.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 SqlTextPreviewLength's doc comment. */ + var showFullText = full_text || filtering; + var result = page.Select((r, i) => new { event_time = r.EventTime?.ToString("o"), @@ -143,13 +182,15 @@ the newest `limit` rows that happen to include it. Without a key the scan IS the blocked_client_app = r.BlockedClientApp, blocked_host_name = r.BlockedHostName, blocked_login_name = r.BlockedLoginName, - blocked_sql_text = McpHelpers.Truncate(r.BlockedSqlText, 2000), + blocked_sql_text = showFullText ? r.BlockedSqlText : McpHelpers.Truncate(r.BlockedSqlText, sqlTextPreviewLength), + blocked_sql_text_truncated = !showFullText && r.BlockedSqlText != null && r.BlockedSqlText.Length > sqlTextPreviewLength, blocking_status = r.BlockingStatus, blocking_isolation_level = r.BlockingIsolationLevel, blocking_client_app = r.BlockingClientApp, blocking_host_name = r.BlockingHostName, blocking_login_name = r.BlockingLoginName, - blocking_sql_text = McpHelpers.Truncate(r.BlockingSqlText, 2000), + blocking_sql_text = showFullText ? r.BlockingSqlText : McpHelpers.Truncate(r.BlockingSqlText, sqlTextPreviewLength), + blocking_sql_text_truncated = !showFullText && r.BlockingSqlText != null && r.BlockingSqlText.Length > sqlTextPreviewLength, blocked_transaction_name = r.BlockedTransactionName, blocking_transaction_name = r.BlockingTransactionName, blocked_last_tran_started = r.BlockedLastTranStartedUtc?.ToString("o"), diff --git a/Lite/Mcp/McpBlockingTools.cs b/Lite/Mcp/McpBlockingTools.cs index 21fde1e75..a7cd0d5b5 100644 --- a/Lite/Mcp/McpBlockingTools.cs +++ b/Lite/Mcp/McpBlockingTools.cs @@ -150,13 +150,20 @@ older graphs sat behind the cap. */ } } - [McpServerTool(Name = "get_blocked_process_reports"), Description("Blocked process report XE + DMV fallback events, newest first, window ends at as_of. not_collected wins if the engine can't run blocked_process_report; else empty means none in the window, or none collected in it. limit caps ROWS, not hours_back: truncated true means raise limit or narrow the window, not widen hours_back. wait_time_ms is milliseconds. Timestamps are UTC; last_tran/last_batch stamps are de-skewed for direct comparison to event_time. <> Gets detailed blocked process reports from extended events (parsed via sp_HumanEventsBlockViewer) plus the always-on DMV blocking-snapshot fallback, NEWEST FIRST. Provides detailed blocked/blocking session info: isolation levels, transaction names, full query text for both sessions. Use for deep analysis of prolonged blocking. THE PAGE IS BOUNDED BY limit, NOT BY hours_back: hours_back is the window you ASKED for, reports_returned is how many rows you GOT, truncated says the window held more than limit, and oldest_returned_event_time / newest_returned_event_time bound the page you are looking at. Because the page is a contiguous newest-first slice, oldest_returned_event_time IS how far back this read reached — on a server blocking steadily, a 24-hour request at the default limit is answered by the newest few minutes, and nothing in the rows themselves says so. When truncated is true, raise limit or narrow hours_back (or anchor as_of) before drawing a conclusion about the window; widening hours_back cannot help, because the cap is on rows, not time. Every timestamp here is UTC: event_time already was, and the six blocked_/blocking_ last_tran/last_batch stamps are de-skewed from the monitored server's local clock by this read, so comparing them against event_time to see whether a transaction predates the block is direct.")] + /// See DarlingMcpBlockingTools.DefaultLimit's doc comment (#4198): the default row limit + /// halves (30 -> 15) and blocked_sql_text/blocking_sql_text preview to + /// (150) rather than the old 2000, with full_text the opt-in + /// back to the whole text. Lite has no web viewer mirror to hold the old cap for, unlike Darling's twin. + private const int SqlTextPreviewLength = 150; + + [McpServerTool(Name = "get_blocked_process_reports"), Description("Blocked process report XE + DMV fallback events, newest first, window ends at as_of. not_collected wins if the engine can't run blocked_process_report; else empty means none in the window, or none collected in it. limit caps ROWS, not hours_back: truncated true means raise limit or narrow the window, not widen hours_back. wait_time_ms is milliseconds. Timestamps are UTC; last_tran/last_batch stamps are de-skewed for direct comparison to event_time. blocked_sql_text/blocking_sql_text are a preview by default (*_truncated: true) — pass full_text for the whole text. <> Gets detailed blocked process reports from extended events (parsed via sp_HumanEventsBlockViewer) plus the always-on DMV blocking-snapshot fallback, NEWEST FIRST. Provides detailed blocked/blocking session info: isolation levels, transaction names, full query text for both sessions. Use for deep analysis of prolonged blocking. THE PAGE IS BOUNDED BY limit, NOT BY hours_back: hours_back is the window you ASKED for, reports_returned is how many rows you GOT, truncated says the window held more than limit, and oldest_returned_event_time / newest_returned_event_time bound the page you are looking at. Because the page is a contiguous newest-first slice, oldest_returned_event_time IS how far back this read reached — on a server blocking steadily, a 24-hour request at the default limit is answered by the newest few minutes, and nothing in the rows themselves says so. When truncated is true, raise limit or narrow hours_back (or anchor as_of) before drawing a conclusion about the window; widening hours_back cannot help, because the cap is on rows, not time. Every timestamp here is UTC: event_time already was, and the six blocked_/blocking_ last_tran/last_batch stamps are de-skewed from the monitored server's local clock by this read, so comparing them against event_time to see whether a transaction predates the block is direct.")] public static async Task GetBlockedProcessReports( 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 rows to return, newest first. Default 30. This is what bounds the page — read truncated to know whether the window held more.")] int limit = 30, + [Description("Maximum rows to return, newest first. Default 15. This is what bounds the page — read truncated to know whether the window held more.")] int limit = 15, + [Description("Return each row's full blocked_sql_text/blocking_sql_text instead of a 150-character preview. Default false.")] bool full_text = false, [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); @@ -209,13 +216,15 @@ server blocking steadily was answered from its newest few minutes with nothing s blocked_client_app = r.BlockedClientApp, blocked_host_name = r.BlockedHostName, blocked_login_name = r.BlockedLoginName, - blocked_sql_text = McpHelpers.Truncate(r.BlockedSqlText, 2000), + blocked_sql_text = full_text ? r.BlockedSqlText : McpHelpers.Truncate(r.BlockedSqlText, SqlTextPreviewLength), + blocked_sql_text_truncated = !full_text && r.BlockedSqlText != null && r.BlockedSqlText.Length > SqlTextPreviewLength, blocking_status = r.BlockingStatus, blocking_isolation_level = r.BlockingIsolationLevel, blocking_client_app = r.BlockingClientApp, blocking_host_name = r.BlockingHostName, blocking_login_name = r.BlockingLoginName, - blocking_sql_text = McpHelpers.Truncate(r.BlockingSqlText, 2000), + blocking_sql_text = full_text ? r.BlockingSqlText : McpHelpers.Truncate(r.BlockingSqlText, SqlTextPreviewLength), + blocking_sql_text_truncated = !full_text && r.BlockingSqlText != null && r.BlockingSqlText.Length > SqlTextPreviewLength, blocked_transaction_name = r.BlockedTransactionName, blocking_transaction_name = r.BlockingTransactionName, blocked_last_tran_started = r.BlockedLastTranStarted?.AddMinutes(-utcOffsetMinutes).ToString("o"), From 76c59f4f7735dfc6a3efb91d120100cdf328171f Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:47:00 -0400 Subject: [PATCH 2/4] #4198: census + tools/list budget pins for get_blocking's new full_text opt-in FieldPreviewCutKeys gets blocked_sql_text_truncated / blocking_sql_text_truncated (both SKUs). McpToolsListBudget pins and each TotalCeilingBytes ceiling move with the new full_text parameter, on both Darling and Lite. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- Darling/Darling.Tests/McpPayloadContractCensusTests.cs | 4 ++++ .../McpToolsListBudget/DarlingMcpBlockingTools.txt | 3 ++- Darling/Darling.Tests/McpToolsListBudgetTests.cs | 7 ++++++- Lite.Tests/McpToolsListBudget/McpBlockingTools.txt | 3 ++- Lite.Tests/McpToolsListBudgetTests.cs | 8 +++++++- 5 files changed, 21 insertions(+), 4 deletions(-) diff --git a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs index 77bcbd184..0e77108a7 100644 --- a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs +++ b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs @@ -1531,6 +1531,10 @@ public static readonly (string Key, string[] Files, string WhatWasCut)[] FieldPr "#4198: get_deadlock_detail's own wide field — deadlock_graph_xml is a 2000-character preview by default (a busy production store measured 120,454 bytes for 3 graphs), full_graph or a dedup_key call gets the whole XML"), ("query_text_truncated", ["DarlingMcpPlanCorrectionTools.cs", "McpPlanCorrectionTools.cs"], "the row's own query_text previewed at READ TIME to QueryTextPreviewLength (150 chars) for #4198's response-size budget - the full text IS in the store (plan_correction.query_text is not collector-capped the way SourceSideCutKeys' rows are) and a caller gets it back by passing full_text=true, the opt-in get_store_query_stats already offers for its own preview"), + ("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"), ]; public static readonly (string Key, string[] Files, string WhatIsWithheld)[] WithheldSummaryKeys = diff --git a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpBlockingTools.txt b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpBlockingTools.txt index 91158debe..499d97c3f 100644 --- a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpBlockingTools.txt +++ b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpBlockingTools.txt @@ -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 781 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 diff --git a/Darling/Darling.Tests/McpToolsListBudgetTests.cs b/Darling/Darling.Tests/McpToolsListBudgetTests.cs index 47a154984..defb5c7d7 100644 --- a/Darling/Darling.Tests/McpToolsListBudgetTests.cs +++ b/Darling/Darling.Tests/McpToolsListBudgetTests.cs @@ -99,7 +99,12 @@ 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). */ - private const int TotalCeilingBytes = 172_220; + /* #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. */ + private const int TotalCeilingBytes = 172_611; private const int ConvertedHeadCap = 1_000; private const int ConvertedParameterCap = 200; diff --git a/Lite.Tests/McpToolsListBudget/McpBlockingTools.txt b/Lite.Tests/McpToolsListBudget/McpBlockingTools.txt index a2e3d3b09..8dadad5e4 100644 --- a/Lite.Tests/McpToolsListBudget/McpBlockingTools.txt +++ b/Lite.Tests/McpToolsListBudget/McpBlockingTools.txt @@ -1,6 +1,7 @@ # McpBlockingTools: tools/list budget for #3898. Ceilings only go down; see McpToolsListBudgetTests. One block per tool, blank line between blocks. -tool get_blocked_process_reports 483 +tool get_blocked_process_reports 600 param get_blocked_process_reports.as_of 167 +param get_blocked_process_reports.full_text 108 param get_blocked_process_reports.hours_back 29 param get_blocked_process_reports.limit 133 param get_blocked_process_reports.server_name 28 diff --git a/Lite.Tests/McpToolsListBudgetTests.cs b/Lite.Tests/McpToolsListBudgetTests.cs index 1c963812d..f85527cde 100644 --- a/Lite.Tests/McpToolsListBudgetTests.cs +++ b/Lite.Tests/McpToolsListBudgetTests.cs @@ -91,7 +91,13 @@ which is not served in tools/list and so is not counted here. Matches Darling's 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_271; + /* #4198 (lane TI): +271 bytes for get_blocked_process_reports' 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, since the row's other ~37 + fields, not the two text columns, were most of the default page's weight). Darling's twin grew by a + different amount (+391): Darling's description also covers the dedup_key exemption, which Lite's + get_blocked_process_reports has no dedup_key parameter to need. */ + private const int TotalCeilingBytes = 90_542; private const int ConvertedHeadCap = 1_000; private const int ConvertedParameterCap = 200; From c02ce536c31424607718e1c63e8fb85e38658f8e Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 05:10:53 -0400 Subject: [PATCH 3/4] #4198: Lite parity budget test for get_blocked_process_reports Lite had the same default-limit-halving and SQL-text-preview fix as Darling's get_blocking, but no budget test pinning it. Plants 30 reports with blocked/blocking text near 700-870 characters and asserts the default call stays under McpResponseBudget.DefaultBytes while full_text still returns the whole text. Proved once (temporarily reverting SqlTextPreviewLength to 2000 and limit to 30) that it fails on the old defaults, then reverted. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- Lite.Tests/McpPageContractTests.cs | 87 ++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/Lite.Tests/McpPageContractTests.cs b/Lite.Tests/McpPageContractTests.cs index 2e9dbd4de..6ea279353 100644 --- a/Lite.Tests/McpPageContractTests.cs +++ b/Lite.Tests/McpPageContractTests.cs @@ -238,6 +238,93 @@ public async Task GetBlockedProcessXml_LimitCountsReportsWithXml_NotRows() AssertPage(cut, "reports", "reports_returned", returned: 1, truncated: true); } + /// + /// #4198: blocked_sql_text/blocking_sql_text are the wide fields here — Darling's twin + /// (DarlingMcpBlockingBudgetLiveTests) measured 89,096 bytes at the old defaults on a real + /// production store. Plants 30 reports with both text fields near 700-870 characters (a realistic + /// blocked/blocking statement width, every other field populated) and asserts the default call + /// previews them under , and that full_text: true + /// opts back into the whole text. + /// + [Fact] + public async Task GetBlockedProcessReports_Default_StaysUnderResponseBudget_WithThirtyWideReports() + { + var now = WholeSecondsNow(); + var blockedTexts = new string[30]; + var blockingTexts = new string[30]; + for (var i = 0; i < 30; i++) + { + blockedTexts[i] = BuildWideSqlText(700 + i * 6, "Orders"); + blockingTexts[i] = BuildWideSqlText(700 + i * 5, "Posts"); + await ExecAsync(@" +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, + 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)", + _nextId--, Naive(now.AddMinutes(-i)), _serverId, ServerName, Naive(now.AddMinutes(-i)), "Db", + 50 + i, 0, 90 + i, 0, 8000L + i, "KEY: 6:72057594057000000 (deadbeefcafe)", "X", + "SUSPENDED", "READ COMMITTED", 256L, 1, + "MyApp.exe", "APPSRV01", "svc_app", blockedTexts[i], + "RUNNING", "READ COMMITTED", "MyApp.exe", "APPSRV02", "svc_app2", blockingTexts[i], + "user_transaction", "user_transaction", + Naive(now.AddMinutes(-i - 5)), Naive(now.AddMinutes(-i - 5)), Naive(now.AddMinutes(-i - 1)), Naive(now.AddMinutes(-i - 1)), + Naive(now.AddMinutes(-i)), Naive(now.AddMinutes(-i)), 7, 3, + "dbo.Orders"); + } + + var defaultJson = await McpBlockingTools.GetBlockedProcessReports(_dataService, _serverManager, ServerName); + var root = Parse(defaultJson); + AssertPage(root, "reports", "reports_returned", returned: 15, truncated: true); + Assert.All(root.GetProperty("reports").EnumerateArray(), r => + { + Assert.True(r.GetProperty("blocked_sql_text_truncated").GetBoolean()); + Assert.True(r.GetProperty("blocking_sql_text_truncated").GetBoolean()); + Assert.True(r.GetProperty("blocked_sql_text").GetString()!.Length < 700); + Assert.True(r.GetProperty("blocking_sql_text").GetString()!.Length < 700); + }); + + var defaultBytes = System.Text.Encoding.UTF8.GetByteCount(defaultJson); + Assert.True(defaultBytes < McpResponseBudget.DefaultBytes, + $"get_blocked_process_reports's default call is {defaultBytes:N0} bytes over 30 planted ~700-870-char reports, at or over the {McpResponseBudget.DefaultBytes:N0}-byte budget."); + + var fullJson = await McpBlockingTools.GetBlockedProcessReports(_dataService, _serverManager, ServerName, 24, 30, full_text: true); + var fullRoot = Parse(fullJson); + AssertPage(fullRoot, "reports", "reports_returned", returned: 30, truncated: false); + var fullReports = fullRoot.GetProperty("reports").EnumerateArray().ToList(); + for (var i = 0; i < 30; i++) + { + Assert.False(fullReports[i].GetProperty("blocked_sql_text_truncated").GetBoolean()); + Assert.False(fullReports[i].GetProperty("blocking_sql_text_truncated").GetBoolean()); + Assert.Equal(blockedTexts[i], fullReports[i].GetProperty("blocked_sql_text").GetString()); + Assert.Equal(blockingTexts[i], fullReports[i].GetProperty("blocking_sql_text").GetString()); + } + } + + /// Builds a SQL statement string near characters, ASCII only so its + /// length and its UTF-8 byte count stay equal (production blocked/blocking statement text is almost + /// entirely ASCII: object names, predicates, literals). + private static string BuildWideSqlText(int approxLength, string table) + { + var sb = new System.Text.StringBuilder(); + sb.Append($"UPDATE dbo.{table} SET Status = 'Processing' WHERE "); + var i = 0; + while (sb.Length < approxLength) + { + sb.Append($"{table}Id = {i} OR "); + i++; + } + + sb.Length = approxLength; + return sb.ToString(); + } + /// /// The hidden filter, stated and measured. Default read: dismissed rows are gone, the payload says so /// and says HOW MANY; include_dismissed brings them back with each row labelled. Truncation is From 171b60e5cb52e5c0e75f2d950217c26132c797ee Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 05:41:14 -0400 Subject: [PATCH 4/4] #4198: trim get_blocking's served head back under the 620-char D3 cap The full-suite run this lane ran as step 4 caught what lane TI's own change had done: adding the blocked_sql_text/blocking_sql_text preview note to get_blocking's served head pushed it from 620 to 781 characters, over McpToolGuideTests' D3 ceiling. Moved the note to the tail (after <>), which is not served in tools/list, the same way get_plan_corrections' full_text note lives in its tail rather than its head. Every HeadFacts substring McpToolGuideHeadsBlockingSlotsTests requires stays verbatim in the head, which now measures 576 served characters. Banked DarlingMcpBlockingTools.txt's per-tool ceiling from 781 to 576 to hold the saving. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- .../McpToolsListBudget/DarlingMcpBlockingTools.txt | 2 +- .../Mcp/DarlingMcpBlockingTools.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpBlockingTools.txt b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpBlockingTools.txt index 499d97c3f..c0c9ce2ed 100644 --- a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpBlockingTools.txt +++ b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpBlockingTools.txt @@ -5,7 +5,7 @@ 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 781 +tool get_blocking 576 param get_blocking.as_of 167 param get_blocking.dedup_key 191 param get_blocking.full_text 172 diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs index c3a9e06e7..b897d00a6 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs @@ -68,7 +68,7 @@ public sealed class DarlingMcpBlockingTools /// public const int WebSqlTextPreviewLength = 2000; - [McpServerTool(Name = "get_blocking"), Description("Blocked process report XE + DMV fallback events, newest first, window ends at as_of. not_collected wins if the engine can't run blocked_process_report; else empty means none in the window, or none collected in it. limit caps ROWS, not hours_back: truncated true means raise limit or narrow the window, not widen hours_back. dedup_key scans the whole window before limit, up to a stated ceiling (rows_examined/scan_truncated); a no-match answer is still empty. wait_time_ms is milliseconds. Timestamps are UTC; last_tran/last_batch stamps are de-skewed for direct comparison to event_time. blocked_sql_text/blocking_sql_text are a preview by default (*_truncated: true) — pass full_text for the whole text; a dedup_key call always gets the whole text.<>Gets blocking events captured by the blocked process report extended event (plus the always-on DMV blocking-snapshot fallback), NEWEST FIRST. Shows the blocked and blocking sessions, wait types, wait times, and query text for both. Use this first for a quick overview, then use get_blocked_process_xml for deep analysis of prolonged blocking. THE PAGE IS BOUNDED BY limit, NOT BY hours_back: hours_back is the window you ASKED for, events_returned is how many rows you GOT, truncated says the window held more than limit, and oldest_returned_event_time / newest_returned_event_time bound the page you are looking at. Because the page is a contiguous newest-first slice, oldest_returned_event_time IS how far back this read reached — on a server blocking steadily, a 24-hour request at the default limit is answered by the newest few minutes, and nothing in the rows themselves says so. When truncated is true, raise limit or narrow hours_back (or anchor as_of) before drawing a conclusion about the window; widening hours_back cannot help, because the cap is on rows, not time. With dedup_key the read scans the window for the fingerprint BEFORE limit applies (so a matching incident is never lost to the cap), up to a stated scan ceiling: rows_examined is how many rows were fingerprinted and scan_truncated says whether the window held more than the scan could reach. Every timestamp here is UTC: event_time already was, and the six blocked_/blocking_ last_tran/last_batch stamps are de-skewed from the monitored server's local clock by this read, so comparing them against event_time to see whether a transaction predates the block is direct. dedup_key: Optional 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. The fingerprint scan runs over the window BEFORE limit, up to the scan ceiling the payload reports as rows_examined / scan_truncated.")] + [McpServerTool(Name = "get_blocking"), Description("Blocked process report XE + DMV fallback events, newest first, window ends at as_of. not_collected wins if the engine can't run blocked_process_report; empty means none in the window, or none collected in it. limit caps ROWS, not hours_back: raise limit or narrow the window, not widen hours_back. dedup_key scans the whole window before limit, up to a stated ceiling (rows_examined/scan_truncated); a no-match answer is still empty. wait_time_ms is milliseconds; last_tran/last_batch stamps are de-skewed to compare directly against event_time.<>Gets blocking events captured by the blocked process report extended event (plus the always-on DMV blocking-snapshot fallback), NEWEST FIRST. Shows the blocked and blocking sessions, wait types, wait times, and query text for both. Use this first for a quick overview, then use get_blocked_process_xml for deep analysis of prolonged blocking. THE PAGE IS BOUNDED BY limit, NOT BY hours_back: hours_back is the window you ASKED for, events_returned is how many rows you GOT, truncated says the window held more than limit, and oldest_returned_event_time / newest_returned_event_time bound the page you are looking at. Because the page is a contiguous newest-first slice, oldest_returned_event_time IS how far back this read reached — on a server blocking steadily, a 24-hour request at the default limit is answered by the newest few minutes, and nothing in the rows themselves says so. When truncated is true, raise limit or narrow hours_back (or anchor as_of) before drawing a conclusion about the window; widening hours_back cannot help, because the cap is on rows, not time. With dedup_key the read scans the window for the fingerprint BEFORE limit applies (so a matching incident is never lost to the cap), up to a stated scan ceiling: rows_examined is how many rows were fingerprinted and scan_truncated says whether the window held more than the scan could reach. Every timestamp here is UTC: event_time already was, and the six blocked_/blocking_ last_tran/last_batch stamps are de-skewed from the monitored server's local clock by this read, so comparing them against event_time to see whether a transaction predates the block is direct. blocked_sql_text/blocking_sql_text are a preview by default (*_truncated marks the cut rows) — pass full_text for the whole text on every row; a dedup_key call always gets the whole text regardless of full_text. dedup_key: Optional 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. The fingerprint scan runs over the window BEFORE limit, up to the scan ceiling the payload reports as rows_examined / scan_truncated.")] public static Task GetBlocking( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null,