From 3c558141d9855bb20a67dc0c247fcb9ec4166ec8 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:48:57 -0400 Subject: [PATCH 1/5] #4198: get_query_store_top default under the 32 KB MCP response budget query_text was truncated only at 2,000 characters with no opt-in. At the default top=20 that measured 48 KB on a busy production store's single server, over McpResponseBudget.DefaultBytes (32 KB). Cuts the default preview to 400 characters, adds a full_text opt-in and a per-row query_text_truncated flag, on both Darling and Lite. Darling's MCP signature forwards to an internal overload taking an explicit preview length, so the web viewer can keep the old 2000-char cap unchanged (wired in a follow-up commit). New live test (Darling, rig) and DuckDB test (Lite) seed 25 rows across five query-text lengths and assert the default call stays under budget while full_text still returns the whole statement. Part of #4198. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- .../QueryStoreTopBudgetLiveTests.cs | 225 +++++++++++++++++ .../Mcp/DarlingMcpDataTools.cs | 33 ++- Lite.Tests/QueryStoreTopBudgetTests.cs | 226 ++++++++++++++++++ Lite/Mcp/McpQueryTools.cs | 14 +- 4 files changed, 491 insertions(+), 7 deletions(-) create mode 100644 Darling/Darling.Tests/QueryStoreTopBudgetLiveTests.cs create mode 100644 Lite.Tests/QueryStoreTopBudgetTests.cs diff --git a/Darling/Darling.Tests/QueryStoreTopBudgetLiveTests.cs b/Darling/Darling.Tests/QueryStoreTopBudgetLiveTests.cs new file mode 100644 index 000000000..e54343c49 --- /dev/null +++ b/Darling/Darling.Tests/QueryStoreTopBudgetLiveTests.cs @@ -0,0 +1,225 @@ +/* + * 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; +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_query_store_top at default arguments (top=20) measured 48 KB on a busy production +/// store's single server -- over the shared 32 KB response budget (). +/// The default page's query_text was truncated only at 2,000 characters, with no opt-in for the whole +/// statement and no disclosure that a row had been cut. +/// +/// Twenty-five rows across five query-text lengths (120 to 2,600 characters -- some land under the new +/// 400-character preview, some between it and the old 2,000-character cap, some past even that) reproduce the +/// shape here without a production store. Twenty-five so the tool's own top+5 over-fetch (for the WAITFOR +/// self-exclusion) has a full house to rank from at the default top=20. +/// +[Collection("live-postgres")] +public sealed class QueryStoreTopBudgetLiveTests +{ + private const string ServerName = "query-store-top-budget-4198"; + private static readonly int ServerId = ServerIdHelper.GetDeterministicHashCode(ServerName); + + /// One length lands under the new preview, two land between it and the old cap, two land past + /// the old cap entirely -- so both the new query_text_truncated boundary and the old blanket 2,000-char + /// truncation it replaces are exercised. + private static readonly int[] QueryTextLengths = { 120, 600, 1400, 2000, 2600 }; + + private const int RowCount = 25; + + private static string? ConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + + [Fact] + public async Task DefaultCall_StaysUnderTheResponseBudget_AndFullTextOptInStillGetsTheWholeStatement() + { + var cs = ConnectionString; + Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live query-store-top budget census."); + + 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 longestQueryText = await SeedAsync(connection, ct); + + var defaultAnswer = await DarlingMcpDataTools.GetQueryStoreTop(postgres, ServerName); + Assert.False(McpHelpers.IsErrorEnvelope(defaultAnswer), $"tool returned an error: {defaultAnswer}"); + + var defaultBytes = Encoding.UTF8.GetByteCount(defaultAnswer); + Assert.True( + defaultBytes < McpResponseBudget.DefaultBytes, + $"get_query_store_top at default arguments answered {defaultBytes} bytes, ranked from " + + $"{RowCount} seeded rows spanning query_text lengths {string.Join(",", QueryTextLengths)} " + + $"-- over the {McpResponseBudget.DefaultBytes}-byte budget (#4198 measured 48 KB on a " + + "production store's single server)."); + + using var defaultDoc = JsonDocument.Parse(defaultAnswer); + var defaultRoot = defaultDoc.RootElement; + var queries = defaultRoot.GetProperty("queries").EnumerateArray(); + + var sawTruncatedPreview = false; + var sawUntouchedPreview = false; + var rowCount = 0; + foreach (var row in queries) + { + rowCount++; + var preview = row.GetProperty("query_text").GetString(); + Assert.NotNull(preview); + var wasTruncated = row.GetProperty("query_text_truncated").GetBoolean(); + + if (wasTruncated) + { + sawTruncatedPreview = true; + Assert.True(preview!.Length < 2000, "a row marked query_text_truncated still carried the old 2,000-character preview -- the wide field was not cut."); + } + else + { + sawUntouchedPreview = true; + } + } + + Assert.Equal(20, rowCount); + Assert.True(sawTruncatedPreview, "no row in the default page reported query_text_truncated=true, so the 1,400/2,000/2,600-character seeded rows were not previewed."); + Assert.True(sawUntouchedPreview, "no row in the default page reported query_text_truncated=false, so a short seeded row (120-char) was previewed when it should not have been."); + + /* ── the explicit ask still gets the whole thing (#4198's "keep the envelope honest") ── */ + var fullTextAnswer = await DarlingMcpDataTools.GetQueryStoreTop(postgres, ServerName, top: 1, full_text: true); + Assert.False(McpHelpers.IsErrorEnvelope(fullTextAnswer), $"tool returned an error: {fullTextAnswer}"); + + using var fullTextDoc = JsonDocument.Parse(fullTextAnswer); + var fullRow = Assert.Single(fullTextDoc.RootElement.GetProperty("queries").EnumerateArray()); + Assert.Equal(longestQueryText, fullRow.GetProperty("query_text").GetString()); + Assert.False(fullRow.GetProperty("query_text_truncated").GetBoolean()); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(cs!, bodySucceeded, async (cleanup, cleanupCt) => + await DeleteRowsAsync(cleanup, cleanupCt)); + } + } + + /// + /// Twenty-five distinct (database, query_id, plan_id) groups, twenty minutes apart so all fall inside the + /// default 24-hour window; execution_count grows with i so the ranking (SUM(execution_count) * + /// AVG(avg_duration_us)) is deterministic and the most expensive row is the last one seeded -- the one + /// whose full, untruncated text the full_text assertion checks. + /// + private static async Task SeedAsync(NpgsqlConnection connection, CancellationToken ct) + { + var newest = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-2)); + string? mostExpensiveQueryText = null; + var highestCost = -1L; + + for (var i = 0; i < RowCount; i++) + { + var collectionTime = newest.AddMinutes(-20 * (RowCount - i)); + var databaseName = $"query_store_top_budget_db_{i % 4}"; + var queryId = 5_000_000_000L + i; + var planId = 6_000_000_000L + i; + var queryText = BuildQueryText(i, QueryTextLengths[i % QueryTextLengths.Length]); + var executionCount = 100L + (i * 37); + var avgDurationUs = 5_000L + (i * 211); + var cost = executionCount * avgDurationUs; + + if (cost > highestCost) + { + highestCost = cost; + mostExpensiveQueryText = queryText; + } + + using var command = new NpgsqlCommand( + """ + INSERT INTO query_store_stats + (collection_id, collection_time, server_id, server_name, database_name, query_id, plan_id, + execution_type_desc, last_execution_time, module_name, query_text, query_hash, query_plan_hash, + execution_count, avg_duration_us, avg_cpu_time_us, avg_logical_io_reads, avg_logical_io_writes, + avg_physical_io_reads, avg_rowcount) + VALUES + ($1, $2, $3, $4, $5, $6, $7, + 'Regular', $2, $8, $9, $10, $11, + $12, $13, $14, $15, $16, + $17, $18) + """, connection); + + command.Parameters.AddWithValue(CollectionIdGenerator.Next()); + command.Parameters.AddWithValue(collectionTime); + command.Parameters.AddWithValue(ServerId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(databaseName); + command.Parameters.AddWithValue(queryId); + command.Parameters.AddWithValue(planId); + command.Parameters.AddWithValue((object?)(i % 3 == 0 ? null : $"dbo.usp_QueryStoreBudgetProbe_{i}") ?? DBNull.Value); + command.Parameters.AddWithValue(queryText); + command.Parameters.AddWithValue("0xQ" + queryId.ToString(System.Globalization.CultureInfo.InvariantCulture)); + command.Parameters.AddWithValue("0xP" + planId.ToString(System.Globalization.CultureInfo.InvariantCulture)); + command.Parameters.AddWithValue(executionCount); + command.Parameters.AddWithValue(avgDurationUs); + command.Parameters.AddWithValue(avgDurationUs / 2); + command.Parameters.AddWithValue(120.5 + i); + command.Parameters.AddWithValue(3.25 + (i % 5)); + command.Parameters.AddWithValue(45.0 + i); + command.Parameters.AddWithValue(1_000.0 + (i * 10)); + await command.ExecuteNonQueryAsync(ct); + } + + return mostExpensiveQueryText!; + } + + /// + /// A synthetic but Query-Store-shaped statement, an IN-list padded out to + /// characters -- the shape a heavy analytical statement with a wide filter list actually has, rather than + /// repeated filler that would not exercise anything about how a real query reads. + /// + private static string BuildQueryText(int index, int length) + { + var sb = new StringBuilder(length + 64); + sb.Append("SELECT o.OrderId, o.CustomerId, o.OrderDate, o.TotalAmount FROM Sales.Orders AS o ") + .Append("WHERE o.RegionId = ").Append(index % 12).Append(" AND o.StatusCode IN ("); + + var n = 0; + while (sb.Length < length) + { + sb.Append(n).Append(','); + n++; + } + + sb.Append(") ORDER BY o.OrderDate DESC;"); + return sb.ToString()[..length]; + } + + private static async Task DeleteRowsAsync(NpgsqlConnection connection, CancellationToken ct) + { + using var cleanup = new NpgsqlCommand( + $"DELETE FROM query_store_stats WHERE server_id = {ServerId}; " + + $"DELETE FROM servers WHERE server_id = {ServerId};", connection); + await cleanup.ExecuteNonQueryAsync(ct); + } + + private static DateTime TruncateToSeconds(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond)), DateTimeKind.Unspecified); +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs index 66cb3e7af..07c29d895 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs @@ -753,8 +753,20 @@ same concurrent independent reads. */ } } - [McpServerTool(Name = "get_query_store_top"), Description("Cost-ranked top Query Store queries (heaviest first), not time-ordered. Requires Query Store enabled on target databases. Darling: window_truncated marks a window floor, not a page cut — no limit changes it — because raw retention can be shorter than asked; effective_start / effective_hours_back give the reach actually served. Lite: no such floor; the full requested window is always read. <> Gets expensive queries from Query Store (persistent, survives restarts). Best for: historical analysis, queries no longer in plan cache. Requires Query Store enabled on target databases. Supports database and module filtering. Reads the raw tier only (the corrected rollups carry no query_id or plan_id), which on a store with the rollups armed is dropped at 4 days. Rows are per Query Store execution outcome (execution_type: Regular, Aborted, Exception): a plan with aborted executions returns one row per outcome, each with its own counts and averages. The execution_type filter keeps one outcome, and module_name keeps one module: the exact, case-sensitive schema-qualified name the collector records (get_top_procedures_by_cpu's full_name; Adhoc for ad-hoc statements, Unknown for an object it could not resolve), applied after interval deduplication and before ranking. When a filter matches nothing but the same read without the filters has rows, the answer is empty (a measured zero), not a Query Store precondition; a module_name miss also carries the window read (effective_start, effective_hours_back, window_truncated) as hints." + McpHelpers.WindowTruncatedDescription)] - public static async Task GetQueryStoreTop( + /// + /// #4198: the default page's query_text preview length -- the wide FIELD, not the row count (top + /// stays 20 here; unlike get_plan_corrections and get_query_store_regressions, this row is narrow enough + /// on its own that only the text needed cutting). At the old blanket 2,000-character truncation with no + /// opt-in, twenty rows on a busy production store measured 48 KB, over the shared 32 KB budget + /// (). Unlike get_deadlock_detail's deadlock_graph_xml, this + /// field already HAD a cap (2,000) before #4198, so the web viewer's /api/read mirror keeps that + /// exact number through the overload below rather than switching to + /// full text. + /// + private const int QueryTextPreviewLength = 400; + + [McpServerTool(Name = "get_query_store_top"), Description("Cost-ranked top Query Store queries (heaviest first), not time-ordered. Requires Query Store enabled on target databases. Darling: window_truncated marks a window floor, not a page cut — no limit changes it — because raw retention can be shorter than asked; effective_start / effective_hours_back give the reach actually served. Lite: no such floor; the full requested window is always read. <> Gets expensive queries from Query Store (persistent, survives restarts). Best for: historical analysis, queries no longer in plan cache. Requires Query Store enabled on target databases. Supports database and module filtering. Reads the raw tier only (the corrected rollups carry no query_id or plan_id), which on a store with the rollups armed is dropped at 4 days. Rows are per Query Store execution outcome (execution_type: Regular, Aborted, Exception): a plan with aborted executions returns one row per outcome, each with its own counts and averages. The execution_type filter keeps one outcome, and module_name keeps one module: the exact, case-sensitive schema-qualified name the collector records (get_top_procedures_by_cpu's full_name; Adhoc for ad-hoc statements, Unknown for an object it could not resolve), applied after interval deduplication and before ranking. When a filter matches nothing but the same read without the filters has rows, the answer is empty (a measured zero), not a Query Store precondition; a module_name miss also carries the window read (effective_start, effective_hours_back, window_truncated) as hints. query_text is a 400-character preview by default (query_text_truncated marks a cut row); full_text=true returns each row's whole statement." + McpHelpers.WindowTruncatedDescription)] + public static Task GetQueryStoreTop( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24, @@ -762,7 +774,19 @@ public static async Task GetQueryStoreTop( [Description("Filter to a specific database.")] string? database_name = null, [Description(McpHelpers.AsOfDescription)] string? as_of = null, [Description("Filter by Query Store execution outcome: Regular, Aborted, or Exception.")] string? execution_type = null, - [Description("Exact schema-qualified module name, as get_top_procedures_by_cpu returns it in full_name (e.g. dbo.usp_ProcessOrder). Case-sensitive; applied before ranking. Ad-hoc statements are Adhoc.")] string? module_name = null) + [Description("Exact schema-qualified module name, as get_top_procedures_by_cpu returns it in full_name (e.g. dbo.usp_ProcessOrder). Case-sensitive; applied before ranking. Ad-hoc statements are Adhoc.")] string? module_name = null, + [Description("Return each row's full query_text instead of a 400-character preview. Default false.")] bool full_text = false) => + GetQueryStoreTop(postgres, server_name, hours_back, top, database_name, as_of, execution_type, module_name, full_text, QueryTextPreviewLength); + + /// + /// get_query_store_top under an explicit (#4198): the MCP tool passes + /// , the web viewer's /api/read mirror passes 2000 -- the cap + /// query_text already had before this opt-in existed, so the viewer's page does not change. Same overload + /// shape #3897's trend tools use TrendBudget.Chart for. + /// + internal static async Task GetQueryStoreTop( + NpgsqlDataSource postgres, string? server_name, int hours_back, int top, string? database_name, string? as_of, + string? execution_type, string? module_name, bool full_text, int previewLength) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; @@ -854,7 +878,8 @@ the thing that cannot read it. */ avg_physical_reads = r.AvgPhysicalReads, avg_rowcount = r.AvgRowcount, last_execution_time = r.LastExecutionTime?.ToString("o"), - query_text = McpHelpers.Truncate(r.QueryText, 2000), + query_text = full_text ? r.QueryText : McpHelpers.Truncate(r.QueryText, previewLength), + query_text_truncated = !full_text && r.QueryText != null && r.QueryText.Length > previewLength, /* Emitted because it is a grouping key: on a 2022+ AG the same query can appear once per replica role, and without this the caller would see duplicate-looking rows with no way to tell them apart. NULL when the server did not attribute the row. */ diff --git a/Lite.Tests/QueryStoreTopBudgetTests.cs b/Lite.Tests/QueryStoreTopBudgetTests.cs new file mode 100644 index 000000000..433e443f2 --- /dev/null +++ b/Lite.Tests/QueryStoreTopBudgetTests.cs @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using PerformanceMonitor.Common; +using PerformanceMonitorLite.Database; +using PerformanceMonitorLite.Mcp; +using PerformanceMonitorLite.Models; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// +/// #4198, Lite's twin of Darling's QueryStoreTopBudgetLiveTests (kept in its own file for the same +/// reason is: several #4198 lanes touch Lite.Tests tonight, and a +/// shared seeding method would conflict across every one of them). Same shape, DuckDB instead of Postgres: +/// twenty-five rows across five query-text lengths (120 to 2,600 characters), ranked so the twenty highest-cost +/// rows (the default top=20 page) still span every length. +/// +public sealed class QueryStoreTopBudgetTests : IClassFixture, IDisposable +{ + private const string ServerName = "query-store-top-budget-4198"; + + private static readonly int[] QueryTextLengths = { 120, 600, 1400, 2000, 2600 }; + private const int RowCount = 25; + + private readonly string _tempDir; + private readonly DuckDbInitializer _duckDb; + private readonly LocalDataService _dataService; + private readonly ServerManager _serverManager; + private readonly int _serverId; + private long _nextId = -1; + private DuckDBConnection? _seedConn; + + public QueryStoreTopBudgetTests(SharedDuckDbFixture fixture) + { + fixture.ResetData(); + _duckDb = fixture.DuckDb; + + _tempDir = Path.Combine(Path.GetTempPath(), "QueryStoreTopBudget_" + Guid.NewGuid().ToString("N")[..8]); + var configDir = Path.Combine(_tempDir, "config"); + Directory.CreateDirectory(configDir); + + _dataService = new LocalDataService(_duckDb); + _serverManager = new ServerManager(configDir); + + var server = new ServerConnection { ServerName = ServerName, DisplayName = ServerName }; + _serverManager.AddServer(server); + + _serverId = RemoteCollectorService.GetDeterministicHashCode( + RemoteCollectorService.GetServerNameForStorage(server)); + } + + public void Dispose() + { + _seedConn?.Dispose(); + try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); } + catch (IOException) { /* best-effort cleanup */ } + catch (UnauthorizedAccessException) { /* best-effort cleanup */ } + } + + [Fact] + public async Task DefaultCall_StaysUnderTheResponseBudget_AndFullTextOptInStillGetsTheWholeStatement() + { + var mostExpensiveQueryText = await SeedAsync(); + + var defaultAnswer = await McpQueryTools.GetQueryStoreTop(_dataService, _serverManager, ServerName); + Assert.False(McpHelpers.IsErrorEnvelope(defaultAnswer), $"tool returned an error: {defaultAnswer}"); + + var defaultBytes = Encoding.UTF8.GetByteCount(defaultAnswer); + Assert.True( + defaultBytes < McpResponseBudget.DefaultBytes, + $"get_query_store_top at default arguments answered {defaultBytes} bytes, ranked from {RowCount} " + + $"seeded rows spanning query_text lengths {string.Join(",", QueryTextLengths)} — over the " + + $"{McpResponseBudget.DefaultBytes}-byte budget (#4198 measured 48 KB on a production store's " + + "single server)."); + + using var defaultDoc = JsonDocument.Parse(defaultAnswer); + var defaultRoot = defaultDoc.RootElement; + + var sawTruncatedPreview = false; + var sawUntouchedPreview = false; + var rowCount = 0; + foreach (var row in defaultRoot.GetProperty("queries").EnumerateArray()) + { + rowCount++; + var preview = row.GetProperty("query_text").GetString(); + Assert.NotNull(preview); + var wasTruncated = row.GetProperty("query_text_truncated").GetBoolean(); + + if (wasTruncated) + { + sawTruncatedPreview = true; + Assert.True(preview!.Length < 2000, "a row marked query_text_truncated still carried the old 2,000-character preview."); + } + else + { + sawUntouchedPreview = true; + } + } + + Assert.Equal(20, rowCount); + Assert.True(sawTruncatedPreview, "no row in the default page reported query_text_truncated=true."); + Assert.True(sawUntouchedPreview, "no row in the default page reported query_text_truncated=false."); + + var fullTextAnswer = await McpQueryTools.GetQueryStoreTop(_dataService, _serverManager, ServerName, top: 1, full_text: true); + Assert.False(McpHelpers.IsErrorEnvelope(fullTextAnswer), $"tool returned an error: {fullTextAnswer}"); + + using var fullTextDoc = JsonDocument.Parse(fullTextAnswer); + var fullRowEnumerator = fullTextDoc.RootElement.GetProperty("queries").EnumerateArray(); + Assert.True(fullRowEnumerator.MoveNext()); + var fullRow = fullRowEnumerator.Current; + Assert.Equal(mostExpensiveQueryText, fullRow.GetProperty("query_text").GetString()); + Assert.False(fullRow.GetProperty("query_text_truncated").GetBoolean()); + } + + /// + /// Twenty-five distinct (database, query_id, plan_id) groups, twenty minutes apart, with execution_count + /// and avg_duration_us both growing with i so the ranking is deterministic and the single most + /// expensive row (the last one seeded) is the one the full_text assertion checks. + /// + private async Task SeedAsync() + { + var newest = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-2)); + string? mostExpensiveQueryText = null; + var highestCost = -1L; + + for (var i = 0; i < RowCount; i++) + { + var collectionTime = newest.AddMinutes(-20 * (RowCount - i)); + var databaseName = $"query_store_top_budget_db_{i % 4}"; + var queryId = 5_000_000_000L + i; + var planId = 6_000_000_000L + i; + var queryText = BuildQueryText(i, QueryTextLengths[i % QueryTextLengths.Length]); + var executionCount = 100L + (i * 37); + var avgDurationUs = 5_000L + (i * 211); + var cost = executionCount * avgDurationUs; + + if (cost > highestCost) + { + highestCost = cost; + mostExpensiveQueryText = queryText; + } + + await ExecAsync( + """ + INSERT INTO query_store_stats + (collection_id, collection_time, server_id, server_name, database_name, + query_id, plan_id, execution_type_desc, first_execution_time, last_execution_time, + module_name, query_text, query_hash, execution_count, avg_cpu_time_us, avg_duration_us, + avg_logical_io_reads, avg_logical_io_writes, avg_physical_io_reads, avg_rowcount, + query_plan_hash, runtime_stats_interval_id) + VALUES + ($1, $2, $3, $4, $5, + $6, $7, $8, $2, $2, + $9, $10, $11, $12, $13, $14, + $15, $16, $17, $18, + $19, $20) + """, + _nextId--, collectionTime, _serverId, ServerName, databaseName, + queryId, planId, "Regular", + i % 3 == 0 ? null : $"dbo.usp_QueryStoreBudgetProbe_{i}", + queryText, "0xQ" + queryId, executionCount, avgDurationUs / 2, avgDurationUs, + 120.5 + i, 3.25 + (i % 5), 45.0 + i, 1_000.0 + (i * 10), + "0xP" + planId, i); + } + + return mostExpensiveQueryText!; + } + + /// + /// A synthetic but Query-Store-shaped statement, an IN-list padded out to + /// characters. + /// + private static string BuildQueryText(int index, int length) + { + var sb = new StringBuilder(length + 64); + sb.Append("SELECT o.OrderId, o.CustomerId, o.OrderDate, o.TotalAmount FROM Sales.Orders AS o ") + .Append("WHERE o.RegionId = ").Append(index % 12).Append(" AND o.StatusCode IN ("); + + var n = 0; + while (sb.Length < length) + { + sb.Append(n).Append(','); + n++; + } + + sb.Append(") ORDER BY o.OrderDate DESC;"); + return sb.ToString()[..length]; + } + + private async Task SeedConnectionAsync() + { + if (_seedConn == null) + { + _seedConn = _duckDb.CreateConnection(); + await _seedConn.OpenAsync(); + } + return _seedConn; + } + + private async Task ExecAsync(string sql, params object?[] values) + { + using var readLock = _duckDb.AcquireReadLock(); + var conn = await SeedConnectionAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + foreach (var v in values) + cmd.Parameters.Add(new DuckDBParameter { Value = v ?? DBNull.Value }); + await cmd.ExecuteNonQueryAsync(); + } + + private static DateTime TruncateToSeconds(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond)), DateTimeKind.Unspecified); +} diff --git a/Lite/Mcp/McpQueryTools.cs b/Lite/Mcp/McpQueryTools.cs index 1d507311b..6a2e4c908 100644 --- a/Lite/Mcp/McpQueryTools.cs +++ b/Lite/Mcp/McpQueryTools.cs @@ -237,7 +237,13 @@ public static async Task GetTopProceduresByCpu( } } - [McpServerTool(Name = "get_query_store_top"), Description("Cost-ranked top Query Store queries (heaviest first), not time-ordered. Requires Query Store enabled on target databases. Darling: window_truncated marks a window floor, not a page cut — no limit changes it — because raw retention can be shorter than asked; effective_start / effective_hours_back give the reach actually served. Lite: no such floor; the full requested window is always read. <> Gets expensive queries from Query Store (persistent, survives restarts). Best for: historical analysis, queries no longer in plan cache. Requires Query Store enabled on target databases. Supports database and module filtering. Rows are per Query Store execution outcome (execution_type: Regular, Aborted, Exception): a plan with aborted executions returns one row per outcome, each with its own counts and averages. The execution_type filter keeps one outcome, and module_name keeps one module: the exact, case-sensitive schema-qualified name the collector records (get_top_procedures_by_cpu's full_name; Adhoc for ad-hoc statements, Unknown for an object it could not resolve), applied after interval deduplication and before ranking. When a filter matches nothing but the same read without the filters has rows, the answer is empty (a measured zero), not a Query Store precondition.")] + /// + /// #4198: the default page's query_text preview length. Mirrors + /// DarlingMcpDataTools.QueryTextPreviewLength -- see that constant's remarks for why 400. + /// + private const int QueryTextPreviewLength = 400; + + [McpServerTool(Name = "get_query_store_top"), Description("Cost-ranked top Query Store queries (heaviest first), not time-ordered. Requires Query Store enabled on target databases. Darling: window_truncated marks a window floor, not a page cut — no limit changes it — because raw retention can be shorter than asked; effective_start / effective_hours_back give the reach actually served. Lite: no such floor; the full requested window is always read. <> Gets expensive queries from Query Store (persistent, survives restarts). Best for: historical analysis, queries no longer in plan cache. Requires Query Store enabled on target databases. Supports database and module filtering. Rows are per Query Store execution outcome (execution_type: Regular, Aborted, Exception): a plan with aborted executions returns one row per outcome, each with its own counts and averages. The execution_type filter keeps one outcome, and module_name keeps one module: the exact, case-sensitive schema-qualified name the collector records (get_top_procedures_by_cpu's full_name; Adhoc for ad-hoc statements, Unknown for an object it could not resolve), applied after interval deduplication and before ranking. When a filter matches nothing but the same read without the filters has rows, the answer is empty (a measured zero), not a Query Store precondition. query_text is a 400-character preview by default (query_text_truncated marks a cut row); full_text=true returns each row's whole statement.")] public static async Task GetQueryStoreTop( LocalDataService dataService, ServerManager serverManager, @@ -247,7 +253,8 @@ public static async Task GetQueryStoreTop( [Description("Filter to a specific database.")] string? database_name = null, [Description(McpHelpers.AsOfDescription)] string? as_of = null, [Description("Filter by Query Store execution outcome: Regular, Aborted, or Exception.")] string? execution_type = null, - [Description("Exact schema-qualified module name, as get_top_procedures_by_cpu returns it in full_name (e.g. dbo.usp_ProcessOrder). Case-sensitive; applied before ranking. Ad-hoc statements are Adhoc.")] string? module_name = null) + [Description("Exact schema-qualified module name, as get_top_procedures_by_cpu returns it in full_name (e.g. dbo.usp_ProcessOrder). Case-sensitive; applied before ranking. Ad-hoc statements are Adhoc.")] string? module_name = null, + [Description("Return each row's full query_text instead of a 400-character preview. Default false.")] bool full_text = false) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; @@ -320,7 +327,8 @@ the thing that cannot read it. */ avg_physical_reads = r.AvgPhysicalReads, avg_rowcount = r.AvgRowcount, last_execution_time = r.LastExecutionTime?.ToString("o"), - query_text = McpHelpers.Truncate(r.QueryText, 2000) + query_text = full_text ? r.QueryText : McpHelpers.Truncate(r.QueryText, QueryTextPreviewLength), + query_text_truncated = !full_text && r.QueryText != null && r.QueryText.Length > QueryTextPreviewLength }); return JsonSerializer.Serialize(new From 269c8a0a5f0e64a1713957669da715d7119cf93b Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:54:25 -0400 Subject: [PATCH 2/5] #4198: wire get_query_store_top's web row, census, and tools/list budget Web viewer /api/read keeps the exact old 2000-char query_text cap (through the new previewLength overload) instead of inheriting the MCP tool's new 400-char default or switching to full text, pinned by a no-rig source test. Census FieldPreviewCutKeys grows to cover this tool's query_text_truncated key. tools/list budget gets the new full_text parameter on both products, ceilings raised by the measured growth (Darling +136 bytes, Lite +125). Part of #4198. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- .../McpPayloadContractCensusTests.cs | 4 +- .../DarlingMcpDataTools.txt | 1 + .../Darling.Tests/McpToolsListBudgetTests.cs | 7 +- .../QueryStoreTopWebDefaultTests.cs | 101 ++++++++++++++++++ .../Darling.Tests/QueryStoreTopWindowTests.cs | 5 +- .../DarlingWebEndpoints.cs | 8 +- .../McpToolsListBudget/McpQueryTools.txt | 1 + Lite.Tests/McpToolsListBudgetTests.cs | 7 +- 8 files changed, 127 insertions(+), 7 deletions(-) create mode 100644 Darling/Darling.Tests/QueryStoreTopWebDefaultTests.cs diff --git a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs index 77bcbd184..db519a234 100644 --- a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs +++ b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs @@ -1529,8 +1529,8 @@ public static readonly (string Key, string[] Files, string WhatWasCut)[] FieldPr [ ("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"), - ("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"), + ("query_text_truncated", ["DarlingMcpDataTools.cs", "DarlingMcpPlanCorrectionTools.cs", "McpPlanCorrectionTools.cs", "McpQueryTools.cs"], + "the row's own query_text previewed at READ TIME to a QueryTextPreviewLength constant (150 chars on plan_correction, 400 on get_query_store_top -- each tool measured its own row width) for #4198's response-size budget - the full text IS in the store (plan_correction.query_text and query_store_stats.query_text are 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. get_query_store_top's MCP signature forwards to an internal previewLength overload so the web viewer can keep the OLD 2000-char cap that field already had, rather than switching to full text the way get_deadlock_detail's never-capped field does"), ]; public static readonly (string Key, string[] Files, string WhatIsWithheld)[] WithheldSummaryKeys = diff --git a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDataTools.txt b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDataTools.txt index 98611e363..d64a3a9f8 100644 --- a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDataTools.txt +++ b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDataTools.txt @@ -46,6 +46,7 @@ tool get_query_store_top 422 param get_query_store_top.as_of 167 param get_query_store_top.database_name 30 param get_query_store_top.execution_type 72 +param get_query_store_top.full_text 84 param get_query_store_top.hours_back 29 param get_query_store_top.module_name 186 param get_query_store_top.server_name 28 diff --git a/Darling/Darling.Tests/McpToolsListBudgetTests.cs b/Darling/Darling.Tests/McpToolsListBudgetTests.cs index 47a154984..03b0b360e 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 TJ, get_query_store_top): +136 bytes for the new full_text opt-in parameter (84 bytes of + description plus its JSON schema wrapper). The head is unchanged (its new sentence lives after + <>, in the tail get_tool_guide serves, not the served head); the default query_text preview + dropped 2,000 chars -> 400, which took the default call from 48 KB (#4198's measurement) to under the + shared 32 KB budget and is not a served description either. */ + private const int TotalCeilingBytes = 172_356; private const int ConvertedHeadCap = 1_000; private const int ConvertedParameterCap = 200; diff --git a/Darling/Darling.Tests/QueryStoreTopWebDefaultTests.cs b/Darling/Darling.Tests/QueryStoreTopWebDefaultTests.cs new file mode 100644 index 000000000..ce7f030bb --- /dev/null +++ b/Darling/Darling.Tests/QueryStoreTopWebDefaultTests.cs @@ -0,0 +1,101 @@ +/* + * 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_query_store_top's default query_text to a 400-character preview, gated behind +/// a new full_text opt-in that defaults to false on the MCP signature. Unlike +/// get_deadlock_detail's deadlock_graph_xml (), this +/// field already had a cap before #4198: a blanket, undisclosed 2,000-character truncation. The web viewer's +/// /api/read dispatch calls the same MCP method, so without its own default it would silently inherit +/// the new 400-character preview -- a real shrink, not just a change of number, since the viewer has always +/// shown up to 2,000 characters. This pins the web row to that exact old number through the internal +/// previewLength overload (the same shape #3897's trend tools use for TrendBudget.Chart), not to +/// full_text: true: full text would be MORE than the viewer ever rendered, which is still a change. +/// +public sealed class QueryStoreTopWebDefaultTests +{ + private const string WebEndpoints = + "Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs"; + + [Fact] + public void GetQueryStoreTop_WebRowKeepsTheOld2000CharacterPreview() + { + var web = StripComments(ReadSource(WebEndpoints)); + + var marker = "[\"get_query_store_top\"] = (c, pg, an) => DarlingMcpDataTools.GetQueryStoreTop("; + var start = web.IndexOf(marker, StringComparison.Ordinal); + Assert.True(start >= 0, "get_query_store_top's /api/read row was not found (renamed or moved?)."); + var end = web.IndexOf("),\r\n", start, StringComparison.Ordinal); + if (end < 0) end = web.IndexOf("),\n", start, StringComparison.Ordinal); + Assert.True(end > start, "could not find the end of get_query_store_top's /api/read row."); + var row = web[start..end]; + + /* Other rows on this same page (get_plan_corrections, get_deadlock_detail) legitimately default + their own full_text/full_graph to true, because THEIR field had no cap before #4198 — so the + negative check below is scoped to this tool's own row, not the whole file. */ + Assert.Contains("previewLength: 2000", row, StringComparison.Ordinal); + Assert.Contains("full_text: QueryBool(c, \"full_text\", false)", row, StringComparison.Ordinal); + Assert.DoesNotContain("full_text: QueryBool(c, \"full_text\", true)", row, 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), $"#4198 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/Darling.Tests/QueryStoreTopWindowTests.cs b/Darling/Darling.Tests/QueryStoreTopWindowTests.cs index fbaf926e3..7bfb61015 100644 --- a/Darling/Darling.Tests/QueryStoreTopWindowTests.cs +++ b/Darling/Darling.Tests/QueryStoreTopWindowTests.cs @@ -103,7 +103,10 @@ private static string QueryStoreTopMethod() { var source = ToolSource; - var start = source.IndexOf("public static async Task GetQueryStoreTop(", StringComparison.Ordinal); + /* #4198 split the MCP-facing method into a one-line wrapper (full_text default, no async) and + this internal previewLength overload, the same shape #3897's trend tools use for TrendBudget.Chart + — the wrapper has no body of its own, so the window-floor logic this file pins lives here. */ + var start = source.IndexOf("internal static async Task GetQueryStoreTop(", StringComparison.Ordinal); Assert.True(start > 0, "get_query_store_top's declaration moved — this pin needs re-anchoring"); /* Bounded at the NEXT tool attribute, so the slice is one tool's body and cannot absorb a sibling's. diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs index 5a9116b55..2c0bee793 100644 --- a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs @@ -1963,7 +1963,7 @@ private static CatalogRead R(string category, string description, params Catalog ["get_query_heatmap"] = R(CatData, "Query counts per (time bin x log-magnitude bucket) - the viewer's Query Heatmap as a table.", PServer(), PHours(24), PText("metric"), PText("database_name"), PInt("bucket_minutes", 5), PLimit(500), PAsOf()), ["get_query_store_regressions"] = R(CatData, "Queries whose Query Store performance got WORSE vs their baseline.", PServer(), PHours(24), PText("database_name"), PLimit(50), PAsOf()), ["get_query_store_clutter"] = R(CatData, "The Query Store CLUTTER view: per database the collector's read cost (how often and by how much it was the slowest fan-out item), plan churn (plans per query, arrivals per day, one-shot plans) and the options row, each with raw numbers and a decomposed verdict; ONE per-server overhead block (non-sleep QDS_* wait deltas with the excluded sleep waits named, and the Query Store memory clerk). Replicas excluded by architecture with the reason on the row; query_capture_mode (ALL / AUTO / CUSTOM / NONE) carried with capture_mode_known beside it, null meaning a capture older than the V137 rung rather than NONE. window_truncated says the raw tier did not hold the whole window. Composed from collected rows - no new query against the server.", PServer(), PHours(24), PLimit(DarlingMcpQueryStoreClutterTools.DefaultLimit), PBool("include_fleet_median", false), PAsOf()), - ["get_query_store_top"] = R(CatData, "Top Query Store queries in the window, optionally filtered by execution outcome or to one exact module before ranking; window_truncated says the raw tier did not hold the whole window (effective_hours_back how far it reached).", PServer(), PHours(24), PTop(20), PText("database_name"), PAsOf(), PText("execution_type"), PText("module_name")), + ["get_query_store_top"] = R(CatData, "Top Query Store queries in the window, optionally filtered by execution outcome or to one exact module before ranking; window_truncated says the raw tier did not hold the whole window (effective_hours_back how far it reached).", PServer(), PHours(24), PTop(20), PText("database_name"), PAsOf(), PText("execution_type"), PText("module_name"), PBool("full_text", false)), ["get_long_query_completions"] = R(CatData, "Completed long-running queries captured by the XE trace.", PServer(), PHours(24), PLimit(30), PAsOf()), ["get_server_properties"] = R(CatData, "Server properties/inventory for a server.", PServer()), ["get_tempdb_trend"] = R(CatData, "tempdb space usage over time.", PServer(), PHours(24), PAsOf(), PInt("bucket_minutes")), @@ -2680,7 +2680,11 @@ budget cut does not silently shrink what the viewer renders. */ ["get_query_heatmap"] = (c, pg, an) => DarlingMcpQueryHeatmapTools.GetQueryHeatmap(pg, Server(c), Hours(c, 24), Str(c, "metric"), Str(c, "database_name"), QueryInt(c, "bucket_minutes", null, 5), Rows(c, "limit", 500), as_of: AsOf(c)), ["get_query_store_regressions"] = (c, pg, an) => DarlingMcpQueryStoreRegressionTools.GetQueryStoreRegressions(pg, Server(c), Hours(c, 24), Str(c, "database_name"), Rows(c, "limit", 50), as_of: AsOf(c)), ["get_query_store_clutter"] = (c, pg, an) => DarlingMcpQueryStoreClutterTools.GetQueryStoreClutter(pg, Server(c), Hours(c, 24), Rows(c, "limit", DarlingMcpQueryStoreClutterTools.DefaultLimit), QueryBool(c, "include_fleet_median", false), AsOf(c)), - ["get_query_store_top"] = (c, pg, an) => DarlingMcpDataTools.GetQueryStoreTop(pg, Server(c), Hours(c, 24), Rows(c, "top", 20), Str(c, "database_name"), as_of: AsOf(c), execution_type: Str(c, "execution_type"), module_name: Str(c, "module_name")), + /* #4198: query_text already had a 2000-character cap before this tool had a full_text opt-in at + all, so the viewer keeps that exact number through the previewLength overload -- QueryBool + still lets an operator ask for the whole statement via ?full_text=true, but the default (no + query override) reproduces the page exactly as it always rendered. */ + ["get_query_store_top"] = (c, pg, an) => DarlingMcpDataTools.GetQueryStoreTop(pg, Server(c), Hours(c, 24), Rows(c, "top", 20), Str(c, "database_name"), as_of: AsOf(c), execution_type: Str(c, "execution_type"), module_name: Str(c, "module_name"), full_text: QueryBool(c, "full_text", false), previewLength: 2000), ["get_long_query_completions"] = (c, pg, an) => DarlingMcpLongQueryTools.GetLongQueryCompletions(pg, Server(c), Hours(c, 24), Rows(c, "limit", 30), as_of: AsOf(c)), ["get_server_properties"] = (c, pg, an) => DarlingMcpDataTools.GetServerProperties(pg, Server(c)), ["get_tempdb_trend"] = (c, pg, an) => OptionalInt(c, "bucket_minutes", out var bucketMinutes) diff --git a/Lite.Tests/McpToolsListBudget/McpQueryTools.txt b/Lite.Tests/McpToolsListBudget/McpQueryTools.txt index 6afa60451..6ca8a0b67 100644 --- a/Lite.Tests/McpToolsListBudget/McpQueryTools.txt +++ b/Lite.Tests/McpToolsListBudget/McpQueryTools.txt @@ -36,6 +36,7 @@ tool get_query_store_top 422 param get_query_store_top.as_of 167 param get_query_store_top.database_name 30 param get_query_store_top.execution_type 72 +param get_query_store_top.full_text 84 param get_query_store_top.hours_back 29 param get_query_store_top.module_name 186 param get_query_store_top.server_name 28 diff --git a/Lite.Tests/McpToolsListBudgetTests.cs b/Lite.Tests/McpToolsListBudgetTests.cs index 1c963812d..745f6081b 100644 --- a/Lite.Tests/McpToolsListBudgetTests.cs +++ b/Lite.Tests/McpToolsListBudgetTests.cs @@ -91,7 +91,12 @@ 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 TJ, get_query_store_top): +125 bytes for the new full_text opt-in parameter (84 bytes of + description plus its JSON schema wrapper; Darling's twin grew by a different amount, +136, per its own + schema shape). The head is unchanged (the new sentence lives in the tail get_tool_guide serves). + Default query_text preview dropped 2,000 chars -> 400; not a served description, so it does not count + here. */ + private const int TotalCeilingBytes = 90_396; private const int ConvertedHeadCap = 1_000; private const int ConvertedParameterCap = 200; From 4fd57f9f38c226bcaf43680ecf4e95f8c94bec0e Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 05:18:44 -0400 Subject: [PATCH 3/5] #4198: fix Custom Views catalog pin for get_query_store_top's new param Catalog_ParamsNameTheActualWireQueryKeys_NotCSharpParamNames asserts the exact wire-key list per tool; add full_text now that the tool has one. Part of #4198. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- Darling/Darling.Tests/DarlingCustomViewsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Darling/Darling.Tests/DarlingCustomViewsTests.cs b/Darling/Darling.Tests/DarlingCustomViewsTests.cs index a961b4c65..ab5173e92 100644 --- a/Darling/Darling.Tests/DarlingCustomViewsTests.cs +++ b/Darling/Darling.Tests/DarlingCustomViewsTests.cs @@ -188,7 +188,7 @@ public void Catalog_ParamsNameTheActualWireQueryKeys_NotCSharpParamNames() { /* Builder 2 binds these keys onto ?query=strings, so they MUST be the literal keys the dispatch lambdas read — the whole reason the metadata is hand-authored (reflection would emit the C# param names). */ - AssertParamKeys("get_query_store_top", "server", "hours", "top", "database_name", "as_of", "execution_type", "module_name"); + AssertParamKeys("get_query_store_top", "server", "hours", "top", "database_name", "as_of", "execution_type", "module_name", "full_text"); AssertParamKeys("get_wait_stats", "server", "hours", "limit", "as_of"); AssertParamKeys("get_wait_trend", "wait_type", "server", "hours", "as_of", "bucket_minutes"); AssertParamKeys("get_plan_xml", "query_hash", "server", "database_name"); From 90359df9aa663fe1b044035712daadd4db2a283c Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 05:47:46 -0400 Subject: [PATCH 4/5] Trigger CI (draft PR skipped the Build workflow) Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ From cc03ac2b865b0b93a1061bbf09e397f863e29565 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 05:58:10 -0400 Subject: [PATCH 5/5] Add comment to QueryStoreTopWindowTests.cs to trigger Build CI GitHub did not fire pull_request events for this draft-opened PR. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ --- Darling/Darling.Tests/QueryStoreTopWindowTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Darling/Darling.Tests/QueryStoreTopWindowTests.cs b/Darling/Darling.Tests/QueryStoreTopWindowTests.cs index 7bfb61015..7c3eb15c6 100644 --- a/Darling/Darling.Tests/QueryStoreTopWindowTests.cs +++ b/Darling/Darling.Tests/QueryStoreTopWindowTests.cs @@ -6,6 +6,7 @@ * Licensed under the MIT License. See LICENSE file in the project root for full license information. */ +// Window contract tests for get_query_store_top. using System; using System.IO; using System.Text.RegularExpressions;