From 6f4a8de5934b32c0e0f3f25dc2ea66741ee67991 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:46:05 -0400 Subject: [PATCH 1/5] Size get_query_heatmap defaults under the #4198 response budget Part of #4198. A default call on a busy server measured 144,757 bytes - 4.4x the shared 32 KB budget - because 500 cells alone, before any query text, already ran past it. Cuts the default cell cap to 100 and the per-cell query text preview to 80 characters, adds a per-cell top_query_text_truncated flag and a full_text opt-in for the whole statement, on both Darling and Lite. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- .../DarlingQueryHeatmapBudgetLiveTests.cs | 174 ++++++++++++++++ .../Darling.Tests/DarlingQueryHeatmapTests.cs | 14 +- .../DarlingMcpQueryHeatmapTools.txt | 1 + .../Darling.Tests/McpToolsListBudgetTests.cs | 5 +- .../Mcp/DarlingMcpQueryHeatmapTools.cs | 37 +++- .../Mcp/DarlingQueryHeatmapReader.cs | 41 ++-- .../McpToolsListBudget/McpQueryTools.txt | 1 + Lite.Tests/McpToolsListBudgetTests.cs | 4 +- Lite.Tests/QueryHeatmapBudgetTests.cs | 196 ++++++++++++++++++ Lite/Mcp/McpQueryTools.cs | 32 ++- .../Services/LocalDataService.QueryHeatmap.cs | 19 +- 11 files changed, 490 insertions(+), 34 deletions(-) create mode 100644 Darling/Darling.Tests/DarlingQueryHeatmapBudgetLiveTests.cs create mode 100644 Lite.Tests/QueryHeatmapBudgetTests.cs diff --git a/Darling/Darling.Tests/DarlingQueryHeatmapBudgetLiveTests.cs b/Darling/Darling.Tests/DarlingQueryHeatmapBudgetLiveTests.cs new file mode 100644 index 000000000..e13d2048f --- /dev/null +++ b/Darling/Darling.Tests/DarlingQueryHeatmapBudgetLiveTests.cs @@ -0,0 +1,174 @@ +/* + * 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.Collections.Generic; +using System.Linq; +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; + +/// +/// Gated (DARLING_TEST_PG) #4198 byte-budget pin for get_query_heatmap, on its own seeded store rather than +/// the shared DarlingQueryHeatmapLiveTests fixture — a dozen #4198 lanes seed in parallel tonight, and +/// a shared seeding helper is exactly the kind of file every one of them would collide on. +/// +/// A heatmap payload is CELLS (queries times time buckets) plus TEXT. #4198 measured a real busy store +/// at default arguments — 144,757 bytes for 500 cells of a 120-character preview each — and the seed below +/// reproduces that shape: enough distinct (time bin, magnitude bucket) cells to fill the default cap several +/// times over, each carrying a 227-character statement (comfortably past both the old 120-character preview +/// and the new 80-character one), so both the CAP and the TEXT WIDTH are exercised, not just one of them. +/// +[Collection("live-postgres")] +public sealed class DarlingQueryHeatmapBudgetLiveTests +{ + private const int ServerId = -419841; + private const string ServerName = "query-heatmap-budget"; + private const string Db = "AppDb"; + + /* Realistic, not minimal: a two-table join with a WHERE and an ORDER BY, the shape of statement an actual + OLTP server runs constantly. 227 characters — past both the old 120-char preview and the new 80-char + default, so every seeded cell's top query is truncated at default and NOT truncated under full_text. */ + private const string QueryText = + "SELECT o.OrderId, o.CustomerId, o.OrderDate, o.TotalAmount, c.CustomerName FROM dbo.Orders AS o " + + "JOIN dbo.Customers AS c ON o.CustomerId = c.CustomerId WHERE o.OrderDate >= @start AND o.Status = @status " + + "ORDER BY o.OrderDate DESC"; + + private const int SeedBins = 60; + + private static string? ConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + + [Fact] + public async Task DefaultCall_OnABusyStore_StaysUnderTheSharedBudget() + { + var cs = ConnectionString; + Assert.SkipWhen(string.IsNullOrEmpty(cs), + "Set DARLING_TEST_PG to a Postgres connection string to run the live heatmap budget test."); + + Assert.True(QueryText.Length > 120, "seed text must exceed both the old and new preview widths"); + + 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); + + /* + SeedBins (60) x 7 magnitude buckets = 420 cells, well past the new default cap (100) and the + old one (500) both — a server busy enough that the cap, not the window, is what bounds the + default call either way. One 5-minute-aligned bin per iteration, all seven buckets touched by + choosing delta_elapsed_time (execution count pinned at 1, so metric_value is elapsed/1000 ms + directly) so every one of the seven bands has at least one populated cell per bin. + */ + var t0 = FloorToHour(DarlingMcpTestData.TruncateToSeconds(DateTime.UtcNow)).AddHours(-1); + long[] elapsedMicrosByBucket = { 500, 5_000, 50_000, 500_000, 5_000_000, 50_000_000, 500_000_000 }; + + for (var bin = 0; bin < SeedBins; bin++) + { + var t = t0.AddMinutes(-5 * bin); + for (var bucket = 0; bucket < 7; bucket++) + { + /* 18 characters - CONVERT(varchar(64), query_hash, 1)'s real width for an 8-byte hash + (0x + 16 hex), not a shortened test stand-in, so the measured byte count below is not + flattered by an unrealistically small hash field. */ + await SeedAsync(connection, ct, t, $"0x{bin:X8}{bucket:X8}", elapsedMicrosByBucket[bucket]); + } + } + + /* ── default call: the fix under test ── */ + var defaultJson = await DarlingMcpQueryHeatmapTools.GetQueryHeatmap(postgres, ServerName); + var defaultBytes = Encoding.UTF8.GetByteCount(defaultJson); + var root = JsonDocument.Parse(defaultJson).RootElement; + + Assert.True(defaultBytes < McpResponseBudget.DefaultBytes, + $"get_query_heatmap default call is {defaultBytes:N0} bytes, at or over the {McpResponseBudget.DefaultBytes:N0}-byte budget."); + + /* + Every seeded bin is fully populated (all seven buckets), so the cap lands mid-bin and the + "no partial column" rule (DarlingMcpQueryHeatmapTools.GetQueryHeatmap) drops the one bin the + cap only partly reached — cell_count is therefore the cap rounded DOWN to a whole number of + bins, not the cap itself. + */ + var cellCount = root.GetProperty("cell_count").GetInt32(); + Assert.True(cellCount % 7 == 0 && cellCount <= DarlingMcpQueryHeatmapTools.DefaultCellLimit + && cellCount > DarlingMcpQueryHeatmapTools.DefaultCellLimit - 7, + $"cell_count {cellCount} should be the default cap ({DarlingMcpQueryHeatmapTools.DefaultCellLimit}) rounded down to whole bins"); + Assert.True(root.GetProperty("truncated").GetBoolean(), "420 populated cells at the default cap must report truncated"); + Assert.False(root.GetProperty("full_text").GetBoolean()); + + var cells = root.GetProperty("cells").EnumerateArray().ToArray(); + Assert.Equal(cellCount, cells.Length); + Assert.All(cells, c => + { + Assert.True(c.GetProperty("top_query_text_truncated").GetBoolean()); + Assert.Equal(DarlingMcpQueryHeatmapTools.DefaultPreviewLength, c.GetProperty("top_query_text").GetString()!.Length); + Assert.Equal(QueryText[..DarlingMcpQueryHeatmapTools.DefaultPreviewLength], c.GetProperty("top_query_text").GetString()); + }); + + /* ── full_text opts back into the whole statement, honestly marked as not truncated ── */ + var fullJson = await DarlingMcpQueryHeatmapTools.GetQueryHeatmap( + postgres, ServerName, 24, null, null, DarlingQueryHeatmapReader.ViewerBucketMinutes, + DarlingMcpQueryHeatmapTools.DefaultCellLimit, null, true); + var fullRoot = JsonDocument.Parse(fullJson).RootElement; + Assert.True(fullRoot.GetProperty("full_text").GetBoolean()); + var fullCells = fullRoot.GetProperty("cells").EnumerateArray().ToArray(); + Assert.All(fullCells, c => + { + Assert.False(c.GetProperty("top_query_text_truncated").GetBoolean()); + Assert.Equal(QueryText, c.GetProperty("top_query_text").GetString()); + }); + /* An explicit ask still gets what it asks for (#4198's ruling) even past the budget. */ + var fullBytes = Encoding.UTF8.GetByteCount(fullJson); + Assert.True(fullBytes > defaultBytes, "full_text=true must not be smaller than the truncated default"); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(cs!, bodySucceeded, async (cleanup, cleanupCt) => + await DeleteRowsAsync(cleanup, cleanupCt)); + } + } + + private static DateTime FloorToHour(DateTime value) => + new(value.Ticks - (value.Ticks % TimeSpan.TicksPerHour), value.Kind); + + private static async Task SeedAsync( + NpgsqlConnection connection, CancellationToken ct, DateTime collectionTime, string queryHash, long deltaElapsedMicros) => + await DarlingMcpTestData.ExecAsync(connection, ct, @" +INSERT INTO query_stats + (collection_id, collection_time, server_id, server_name, database_name, query_hash, + sample_interval_seconds, delta_execution_count, delta_worker_time, delta_elapsed_time, + delta_logical_reads, delta_logical_writes, query_text) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)", + CollectionIdGenerator.Next(), DarlingMcpTestData.Naive(collectionTime), ServerId, ServerName, + Db, queryHash, 60, 1L, 0L, deltaElapsedMicros, 0L, 0L, QueryText); + + private static async Task DeleteRowsAsync(NpgsqlConnection connection, CancellationToken ct) + { + await DarlingMcpTestData.ExecAsync(connection, ct, "DELETE FROM query_stats WHERE server_id = $1", ServerId); + await DarlingMcpTestData.ExecAsync(connection, ct, "DELETE FROM servers WHERE server_id = $1", ServerId); + await DarlingMcpTestData.ExecAsync(connection, ct, "DELETE FROM config_monitored_servers WHERE server_id = $1", ServerId); + } +} diff --git a/Darling/Darling.Tests/DarlingQueryHeatmapTests.cs b/Darling/Darling.Tests/DarlingQueryHeatmapTests.cs index 7d73a50b7..725a080f6 100644 --- a/Darling/Darling.Tests/DarlingQueryHeatmapTests.cs +++ b/Darling/Darling.Tests/DarlingQueryHeatmapTests.cs @@ -76,7 +76,7 @@ public void ParamContract_AllOptional_MatchesLite() .ToArray(); Assert.Equal( - new[] { "server_name", "hours_back", "metric", "database_name", "bucket_minutes", "limit", "as_of" }, + new[] { "server_name", "hours_back", "metric", "database_name", "bucket_minutes", "limit", "as_of", "full_text" }, p.Select(x => x.Item1).ToArray()); Assert.All(p, x => Assert.True(x.Item2, $"{x.Item1} must be optional")); @@ -126,11 +126,13 @@ public void HeatmapSql_BinsWithABoundWidth_FromTheUnixEpoch_ForEveryMetric() } /// - /// The viewer's magnitude CASE, its filters and its 120-character preview, kept verbatim. These decide - /// which cell a query lands in, so they are the other half of "the two surfaces agree". + /// The viewer's magnitude CASE and its filters, kept verbatim. These decide which cell a query lands in, + /// so they are the other half of "the two surfaces agree". The preview WIDTH is not verbatim (#4198): the + /// viewer's chart still hardcodes 120, but this read's preview is a bound parameter so the tool can size + /// its default under the shared response budget and still offer full_text back up to it. /// [Fact] - public void HeatmapSql_KeepsTheViewersMagnitudeBands_Filters_AndPreview() + public void HeatmapSql_KeepsTheViewersMagnitudeBands_AndFilters() { var sql = DarlingQueryHeatmapReader.BuildQueryHeatmapSql(HeatmapMetric.Duration); @@ -149,7 +151,9 @@ public void HeatmapSql_KeepsTheViewersMagnitudeBands_Filters_AndPreview() } Assert.Contains("delta_execution_count > 0", sql, StringComparison.Ordinal); - Assert.Contains("LEFT(query_text, 120) AS query_preview", sql, StringComparison.Ordinal); + /* Bound, not a literal 120 (#4198) — see HeatmapSql_PreviewWidthIsABoundParameter below. */ + Assert.Contains("LEFT(query_text, $7) AS query_preview", sql, StringComparison.Ordinal); + Assert.DoesNotContain("LEFT(query_text, 120)", sql, StringComparison.Ordinal); Assert.Contains("FROM v_query_stats", sql, StringComparison.Ordinal); /* DuckDB's ARG_MAX has no Postgres equivalent; the viewer's replacement is a top-1 window over the diff --git a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpQueryHeatmapTools.txt b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpQueryHeatmapTools.txt index dc8252391..201f77a5c 100644 --- a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpQueryHeatmapTools.txt +++ b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpQueryHeatmapTools.txt @@ -3,6 +3,7 @@ tool get_query_heatmap 799 param get_query_heatmap.as_of 167 param get_query_heatmap.bucket_minutes 160 param get_query_heatmap.database_name 46 +param get_query_heatmap.full_text 94 param get_query_heatmap.hours_back 43 param get_query_heatmap.limit 195 param get_query_heatmap.metric 123 diff --git a/Darling/Darling.Tests/McpToolsListBudgetTests.cs b/Darling/Darling.Tests/McpToolsListBudgetTests.cs index 2feef4e28..615a7cd6b 100644 --- a/Darling/Darling.Tests/McpToolsListBudgetTests.cs +++ b/Darling/Darling.Tests/McpToolsListBudgetTests.cs @@ -84,7 +84,10 @@ public McpToolsListBudgetTests(ITestOutputHelper output) get_collection_log's fleet-form server_name/limit descriptions, after trimming both to the D2 200-char parameter cap and moving the rest to each tool's tail (get_tool_guide), which is not served in tools/list and so is not counted here. */ - private const int TotalCeilingBytes = 171_637; + /* #4198 (get_query_heatmap): +147 bytes for the new full_text opt-in parameter (94 bytes of description + plus its JSON schema property scaffolding), part of shrinking the default cell cap and preview width + under the shared response budget. */ + private const int TotalCeilingBytes = 171_784; private const int ConvertedHeadCap = 1_000; private const int ConvertedParameterCap = 200; diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryHeatmapTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryHeatmapTools.cs index 20bc96209..530db620f 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryHeatmapTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryHeatmapTools.cs @@ -35,9 +35,25 @@ namespace PerformanceMonitor.Darling.Service.Mcp; [McpServerToolType] public sealed class DarlingMcpQueryHeatmapTools { - /// The web panel's cap and this tool's default: 500 cells, which is a full day of 5-minute bins - /// on a server whose queries land in two or three magnitude buckets per bin. - public const int DefaultCellLimit = 500; + /// This tool's default cell cap (#4198, down from the web panel's own 500): at 500 cells the + /// fixed per-cell fields alone (time bin, bucket, count, hash) ran to roughly 75 KB before one byte of + /// query text, already more than double the shared 32 KB response budget + /// () — so the cap had to fall regardless of the text width + /// chosen. 100 cells covers 14-plus 5-minute bins fully populated across all seven magnitude buckets, or + /// most of a business day on a server whose queries land in the usual two or three; raise bucket_minutes + /// for more of the window in the same cell count, or limit for more cells at this width. + public const int DefaultCellLimit = 100; + + /// The default top_query_text preview width, in characters (#4198). Shrinking this alone + /// barely moves the total — seven-field JSON structure plus already spends + /// most of the budget — but it is still cut well below the old fixed 120, and full_text opts back + /// into the whole statement. + public const int DefaultPreviewLength = 80; + + /// What full_text = true asks for: not a literally unbounded fetch (one absurd statement + /// could still blow an opt-in call past the budget the caller explicitly waived), but a bound generous + /// enough that no real T-SQL statement is cut by it. + public const int FullTextPreviewLength = 32_000; [McpServerTool(Name = "get_query_heatmap"), Description("Draws the desktop viewer's Query Heatmap as a table: how many distinct queries fell into each (time bin x log-magnitude bucket) cell over a window, plus the most-executed query in each cell. It answers when a server was slow and how slow at the same time - get_top_queries_by_cpu ranks queries over a whole window and cannot show that the window had two very different halves. Bins are 5 minutes wide by default because that is exactly what the desktop viewer uses, so a browser, an agent and a desktop pointed at the same server draw the same picture; raise bucket_minutes for a longer window, which is also the lever that fits more of the window inside the cell cap. Magnitude buckets are the viewer's seven, in the metric's own unit: under 1, 1-10, 10-100, 100-1K, 1K-10K, 10K-100K and over 100K.")] public static async Task GetQueryHeatmap( @@ -47,8 +63,9 @@ public static async Task GetQueryHeatmap( [Description("Which per-execution metric to bucket by: duration, cpu, logical_reads, logical_writes or execution_count. Default duration.")] string? metric = null, [Description("Limit to one database. Omit for all databases.")] string? database_name = null, [Description("Width of each time bin, in minutes. Default 5 - the desktop viewer's own bin width, so the two surfaces agree. Raise it to cover a longer window in fewer cells.")] int bucket_minutes = DarlingQueryHeatmapReader.ViewerBucketMinutes, - [Description("Maximum CELLS to return, most recent bins first. Default 500. A full day of 5-minute bins can reach 2,016 cells on a busy server; raise bucket_minutes rather than the cap to see the whole window.")] int limit = DefaultCellLimit, - [Description(McpHelpers.AsOfDescription)] string? as_of = null) + [Description("Maximum CELLS to return, most recent bins first. Default 100. A full day of 5-minute bins can reach 2,016 cells on a busy server; raise bucket_minutes rather than the cap to see the whole window.")] int limit = DefaultCellLimit, + [Description(McpHelpers.AsOfDescription)] string? as_of = null, + [Description("Return each cell's top query at full length instead of an 80-character preview. Default false.")] bool full_text = false) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; @@ -86,8 +103,9 @@ cell cap. happens to have exactly `limit` cells and nothing more, which is a false positive in the one field whose whole reason for existing is that the cap should not have to be inferred. */ + var previewLength = full_text ? FullTextPreviewLength : DefaultPreviewLength; var rows = await DarlingQueryHeatmapReader.GetQueryHeatmapAsync( - postgres, resolved.ServerId, parsedMetric, start, end, database_name, bucket_minutes, limit + 1); + postgres, resolved.ServerId, parsedMetric, start, end, database_name, bucket_minutes, limit + 1, previewLength); if (rows.Count == 0) return await EmptyAsync(postgres, resolved.ServerName, resolved.ServerId, start, end, hours_back); @@ -135,6 +153,10 @@ because the SQL sorts time DESC and bucket ASC. The DESC exists only so the cap /* The same bin width the desktop viewer hardcodes, so the two surfaces cannot disagree about the same server over the same window. */ bucket_minutes_matches_desktop_viewer = bucket_minutes == DarlingQueryHeatmapReader.ViewerBucketMinutes, + /* Echoed rather than left implicit in the cell-level flags alone (#4198): a caller who never + looks at an individual cell still learns, from this one field, that a second call with + full_text=true gets more than what came back. */ + full_text, /* A bare bucket_index is unreadable, and the labels differ by metric family: duration and CPU are milliseconds, the other three are counts. */ magnitude_buckets = labels.Select((label, index) => new { bucket_index = index, label }), @@ -156,6 +178,9 @@ way to see how much of the window is missing. */ query_count = c.QueryCount, top_query_hash = c.TopQueryHash, top_query_text = c.TopQueryText, + /* #4198: honest about the preview it just spent bytes on. True whenever the stored + statement is longer than this call's preview width, at either preview length. */ + top_query_text_truncated = c.TopQueryTextTruncated, }), }, McpHelpers.JsonOptions); } diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingQueryHeatmapReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingQueryHeatmapReader.cs index f19c187d4..f72ff82d6 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingQueryHeatmapReader.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingQueryHeatmapReader.cs @@ -30,12 +30,15 @@ public enum HeatmapMetric /// /// The service-side read behind get_query_heatmap (#2484) — the viewer's /// ViewerDataService.BuildQueryHeatmapSql, which is itself the Postgres port of Lite's DuckDB -/// heatmap. Copied VERBATIM apart from the two things a desktop chart does not need and an MCP read does: +/// heatmap. Copied VERBATIM apart from the things a desktop chart does not need and an MCP read does: /// the bin width is a bound parameter instead of the literal INTERVAL '5 minutes' (defaulting to -/// that same 5), and the tail carries ORDER BY time_bin DESC + LIMIT so a capped call keeps -/// the most RECENT bins rather than the oldest ones. Every other clause — the magnitude CASE, the -/// delta_execution_count > 0 and metric IS NOT NULL filters, the LEFT(query_text, 120) -/// preview, the top-1 window that replaced DuckDB's ARG_MAX — is the viewer's. +/// that same 5), the tail carries ORDER BY time_bin DESC + LIMIT so a capped call keeps +/// the most RECENT bins rather than the oldest ones, and the LEFT(query_text, 120) preview width is +/// itself a bound parameter rather than a literal 120 (#4198: at 500 cells the FIXED per-cell fields alone — +/// before one byte of query text — already ran well past the shared response budget, so the cell cap had to +/// fall regardless of the text width chosen). Every other clause — the magnitude CASE, the +/// delta_execution_count > 0 and metric IS NOT NULL filters, the top-1 window that replaced +/// DuckDB's ARG_MAX — is the viewer's. /// /// The bucketing is the viewer's, deliberately. The whole point of the web/MCP surface is that /// it answers the same question the desktop does, so a browser and a desktop pointed at the same server over @@ -73,13 +76,17 @@ internal static class DarlingQueryHeatmapReader public const int BucketCount = 7; /// One heatmap cell: the query count in a (time bin x magnitude bucket) plus the most-executed - /// query in it, which is what the desktop shows on hover. + /// query in it, which is what the desktop shows on hover. is true + /// when is a preview shorter than the stored statement (#4198) — the caller's + /// only way to tell "this is the whole thing" from "this is cut off" without re-asking with full text. + /// public sealed record HeatmapCellRow( DateTime TimeBucket, int BucketIndex, long QueryCount, string TopQueryHash, - string TopQueryText); + string TopQueryText, + bool TopQueryTextTruncated); /// The viewer's per-metric magnitude labels, verbatim. Duration and CPU are milliseconds per /// execution; the rest are plain counts, so the two families label the same seven buckets differently. @@ -157,7 +164,10 @@ public static bool TryParseMetric(string? metric, out HeatmapMetric parsed) /// /// The viewer's heatmap read for one metric. $1 server_id, $2 window start, $3 window end, $4 database - /// filter (text[] or NULL), $5 bin width in minutes, $6 cell cap. + /// filter (text[] or NULL), $5 bin width in minutes, $6 cell cap, $7 preview width in characters + /// (#4198's GetQueryHeatmapAsync binds this to one more than the caller's preview length, the + /// same over-fetch-by-one idiom the cell cap already uses, so the extra character IS the truncation + /// signal instead of a second round trip or a computed LENGTH(query_text) column). /// Ordered newest bin first ONLY so the cap keeps the recent end of the window; the tool re-sorts /// chronologically before returning. The viewer needs no cap and orders ascending. /// @@ -170,7 +180,7 @@ WITH base AS ( date_bin(($5::integer * INTERVAL '1 minute'), collection_time, TIMESTAMP '1970-01-01 00:00:00') AS time_bin, {metricExpr} AS metric_value, query_hash, - LEFT(query_text, 120) AS query_preview, + LEFT(query_text, $7) AS query_preview, delta_execution_count FROM v_query_stats WHERE server_id = $1 @@ -247,10 +257,13 @@ FROM v_query_stats ) AS has_in_window """; - /// Runs . Rows come back newest bin first. + /// Runs . Rows come back newest bin first. Fetches + /// + 1 characters of query text so the extra character — present only + /// when the stored statement ran past the preview — is the truncation signal (#4198); trimmed back to + /// before it reaches . public static async Task> GetQueryHeatmapAsync( NpgsqlDataSource postgres, int serverId, HeatmapMetric metric, DateTime startUtc, DateTime endUtc, - string? databaseName, int bucketMinutes, int limit, CancellationToken cancellationToken = default) + string? databaseName, int bucketMinutes, int limit, int previewLength, CancellationToken cancellationToken = default) { var rows = new List(); await using var command = postgres.CreateCommand(BuildQueryHeatmapSql(metric)); @@ -263,16 +276,20 @@ public static async Task> GetQueryHeatmapAsync( }); command.Parameters.Add(new NpgsqlParameter { TypedValue = bucketMinutes }); command.Parameters.Add(new NpgsqlParameter { TypedValue = limit }); + command.Parameters.Add(new NpgsqlParameter { TypedValue = previewLength + 1 }); await using var reader = await command.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) { + var fetchedText = reader.IsDBNull(4) ? "" : reader.GetString(4); + var truncated = fetchedText.Length > previewLength; rows.Add(new HeatmapCellRow( reader.GetDateTime(0), reader.IsDBNull(1) ? 0 : Convert.ToInt32(reader.GetValue(1)), reader.IsDBNull(2) ? 0 : Convert.ToInt64(reader.GetValue(2)), reader.IsDBNull(3) ? "" : reader.GetString(3), - reader.IsDBNull(4) ? "" : reader.GetString(4))); + truncated ? fetchedText[..previewLength] : fetchedText, + truncated)); } return rows; diff --git a/Lite.Tests/McpToolsListBudget/McpQueryTools.txt b/Lite.Tests/McpToolsListBudget/McpQueryTools.txt index 8253ff6f6..b9ced6adb 100644 --- a/Lite.Tests/McpToolsListBudget/McpQueryTools.txt +++ b/Lite.Tests/McpToolsListBudget/McpQueryTools.txt @@ -15,6 +15,7 @@ tool get_query_heatmap 799 param get_query_heatmap.as_of 167 param get_query_heatmap.bucket_minutes 160 param get_query_heatmap.database_name 46 +param get_query_heatmap.full_text 94 param get_query_heatmap.hours_back 43 param get_query_heatmap.limit 195 param get_query_heatmap.metric 123 diff --git a/Lite.Tests/McpToolsListBudgetTests.cs b/Lite.Tests/McpToolsListBudgetTests.cs index 4dc551590..84e30bce5 100644 --- a/Lite.Tests/McpToolsListBudgetTests.cs +++ b/Lite.Tests/McpToolsListBudgetTests.cs @@ -83,7 +83,9 @@ public McpToolsListBudgetTests(ITestOutputHelper output) /* #4199 (M2b): +162 bytes for get_collection_log's fleet-form server_name/limit descriptions, after trimming both to the D2 200-char parameter cap and moving the rest to the tool's tail (get_tool_guide), which is not served in tools/list and so is not counted here. Matches Darling's twin change exactly. */ - private const int TotalCeilingBytes = 89_881; + /* #4198 (get_query_heatmap): +147 bytes for the new full_text opt-in parameter, matching Darling's twin + change exactly. */ + private const int TotalCeilingBytes = 90_028; private const int ConvertedHeadCap = 1_000; private const int ConvertedParameterCap = 200; diff --git a/Lite.Tests/QueryHeatmapBudgetTests.cs b/Lite.Tests/QueryHeatmapBudgetTests.cs new file mode 100644 index 000000000..856d6b4e4 --- /dev/null +++ b/Lite.Tests/QueryHeatmapBudgetTests.cs @@ -0,0 +1,196 @@ +/* + * 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.Linq; +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 byte-budget pin for Lite's get_query_heatmap, the twin of +/// Darling.Tests.DarlingQueryHeatmapBudgetLiveTests. Needs no rig: Lite reads its own DuckDB file, so +/// this seeds a fresh one directly, same as QueryHeatmapToolTests does for the shape pins. +/// +/// A heatmap payload is CELLS (queries times time buckets) plus TEXT. #4198 measured a real busy store +/// at default arguments — 144,757 bytes for 500 cells of a 120-character preview each — and the seed below +/// reproduces that shape: enough distinct (time bin, magnitude bucket) cells to fill the default cap several +/// times over, each carrying a 227-character statement (past both the old 120-character preview and the new +/// 80-character one), so both the CAP and the TEXT WIDTH are exercised, not just one of them. +/// +public sealed class QueryHeatmapBudgetTests : IClassFixture, IDisposable +{ + private const string ServerName = "HeatmapBudgetSrv"; + private const string Db = "AppDb"; + + /* Realistic, not minimal: a two-table join with a WHERE and an ORDER BY, 227 characters - past both the + old 120-char preview and the new 80-char default, so every seeded cell's top query is truncated at + default and NOT truncated under full_text. Matches Darling's twin seed exactly. */ + private const string QueryText = + "SELECT o.OrderId, o.CustomerId, o.OrderDate, o.TotalAmount, c.CustomerName FROM dbo.Orders AS o " + + "JOIN dbo.Customers AS c ON o.CustomerId = c.CustomerId WHERE o.OrderDate >= @start AND o.Status = @status " + + "ORDER BY o.OrderDate DESC"; + + private const int SeedBins = 60; + + private readonly int _serverId; + private readonly DuckDbInitializer _duckDb; + private readonly string _configDir; + private readonly ServerManager _serverManager; + private DuckDBConnection? _seedConn; + private long _nextId = 1; + + public QueryHeatmapBudgetTests(SharedDuckDbFixture fixture) + { + fixture.ResetData(); + _duckDb = fixture.DuckDb; + + _configDir = Path.Combine(Path.GetTempPath(), "pmlite-heatmap-budget-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_configDir); + _serverManager = new ServerManager(_configDir); + + var server = new ServerConnection + { + Id = Guid.NewGuid().ToString(), + ServerName = ServerName, + IsEnabled = true, + }; + _serverManager.AddServer(server); + _serverId = RemoteCollectorService.GetDeterministicHashCode( + RemoteCollectorService.GetServerNameForStorage(server)); + } + + public void Dispose() + { + _seedConn?.Dispose(); + try { Directory.Delete(_configDir, recursive: true); } catch (IOException) { /* temp dir */ } + } + + [Fact] + public async Task DefaultCall_OnABusyStore_StaysUnderTheSharedBudget() + { + Assert.True(QueryText.Length > 120, "seed text must exceed both the old and new preview widths"); + + var service = new LocalDataService(_duckDb); + var t0 = FloorToHour(Truncate(DateTime.UtcNow)).AddHours(-1); + long[] elapsedMicrosByBucket = { 500, 5_000, 50_000, 500_000, 5_000_000, 50_000_000, 500_000_000 }; + + /* SeedBins (60) x 7 magnitude buckets = 420 cells, well past both the new default cap (100) and the + old one (500) - a server busy enough that the cap, not the window, bounds the default call either + way. Execution count pinned at 1, so metric_value is delta_elapsed/1000 ms directly. */ + for (var bin = 0; bin < SeedBins; bin++) + { + var t = t0.AddMinutes(-5 * bin); + for (var bucket = 0; bucket < 7; bucket++) + { + /* 18 characters - CONVERT(varchar(64), query_hash, 1)'s real width for an 8-byte hash, not a + shortened test stand-in. */ + await SeedAsync(t, $"0x{bin:X8}{bucket:X8}", elapsedMicrosByBucket[bucket]); + } + } + + /* ── default call: the fix under test ── */ + var defaultJson = await McpQueryTools.GetQueryHeatmap(service, _serverManager, ServerName); + var defaultBytes = Encoding.UTF8.GetByteCount(defaultJson); + var root = Root(defaultJson); + + Assert.True(defaultBytes < McpResponseBudget.DefaultBytes, + $"get_query_heatmap default call is {defaultBytes:N0} bytes, at or over the {McpResponseBudget.DefaultBytes:N0}-byte budget."); + + /* + Every seeded bin is fully populated (all seven buckets), so the cap lands mid-bin and the "no + partial column" rule drops the one bin the cap only partly reached — cell_count is the cap + rounded DOWN to a whole number of bins, not the cap itself. Darling twin asserts the same shape. + */ + var cellCount = root.GetProperty("cell_count").GetInt32(); + Assert.True(cellCount % 7 == 0 && cellCount <= 100 && cellCount > 100 - 7, + $"cell_count {cellCount} should be the default cap (100) rounded down to whole bins"); + Assert.True(root.GetProperty("truncated").GetBoolean(), "420 populated cells at the default cap must report truncated"); + Assert.False(root.GetProperty("full_text").GetBoolean()); + + var cells = root.GetProperty("cells").EnumerateArray().ToArray(); + Assert.Equal(cellCount, cells.Length); + Assert.All(cells, c => + { + Assert.True(c.GetProperty("top_query_text_truncated").GetBoolean()); + Assert.Equal(80, c.GetProperty("top_query_text").GetString()!.Length); + Assert.Equal(QueryText[..80], c.GetProperty("top_query_text").GetString()); + }); + + /* ── full_text opts back into the whole statement, honestly marked as not truncated ── */ + var fullJson = await McpQueryTools.GetQueryHeatmap( + service, _serverManager, ServerName, 24, null, null, LocalDataService.ViewerHeatmapBucketMinutes, + 100, null, true); + var fullRoot = Root(fullJson); + Assert.True(fullRoot.GetProperty("full_text").GetBoolean()); + var fullCells = fullRoot.GetProperty("cells").EnumerateArray().ToArray(); + Assert.All(fullCells, c => + { + Assert.False(c.GetProperty("top_query_text_truncated").GetBoolean()); + Assert.Equal(QueryText, c.GetProperty("top_query_text").GetString()); + }); + /* An explicit ask still gets what it asks for (#4198's ruling) even past the budget. */ + var fullBytes = Encoding.UTF8.GetByteCount(fullJson); + Assert.True(fullBytes > defaultBytes, "full_text=true must not be smaller than the truncated default"); + } + + private static JsonElement Root(string json) => JsonDocument.Parse(json).RootElement; + + private static DateTime Truncate(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond)), DateTimeKind.Unspecified); + + private static DateTime FloorToHour(DateTime value) => + new(value.Ticks - (value.Ticks % TimeSpan.TicksPerHour), value.Kind); + + private async Task SeedConnectionAsync() + { + if (_seedConn is null) + { + _seedConn = _duckDb.CreateConnection(); + await _seedConn.OpenAsync(); + } + return _seedConn; + } + + private async Task SeedAsync(DateTime collectionTime, string queryHash, long deltaElapsed) + { + using var readLock = _duckDb.AcquireReadLock(); + var connection = await SeedConnectionAsync(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +INSERT INTO query_stats + (collection_id, collection_time, server_id, server_name, database_name, + query_hash, sql_handle, last_execution_time, delta_execution_count, + delta_worker_time, delta_elapsed_time, query_text) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)"; + var naive = DateTime.SpecifyKind(collectionTime, DateTimeKind.Unspecified); + cmd.Parameters.Add(new DuckDBParameter { Value = _nextId++ }); + cmd.Parameters.Add(new DuckDBParameter { Value = naive }); + cmd.Parameters.Add(new DuckDBParameter { Value = _serverId }); + cmd.Parameters.Add(new DuckDBParameter { Value = ServerName }); + cmd.Parameters.Add(new DuckDBParameter { Value = Db }); + cmd.Parameters.Add(new DuckDBParameter { Value = queryHash }); + cmd.Parameters.Add(new DuckDBParameter { Value = "0xSQLH" }); + cmd.Parameters.Add(new DuckDBParameter { Value = naive }); + cmd.Parameters.Add(new DuckDBParameter { Value = 1L }); + cmd.Parameters.Add(new DuckDBParameter { Value = 0L }); + cmd.Parameters.Add(new DuckDBParameter { Value = deltaElapsed }); + cmd.Parameters.Add(new DuckDBParameter { Value = QueryText }); + await cmd.ExecuteNonQueryAsync(); + } +} diff --git a/Lite/Mcp/McpQueryTools.cs b/Lite/Mcp/McpQueryTools.cs index c0d80b361..d98f74aeb 100644 --- a/Lite/Mcp/McpQueryTools.cs +++ b/Lite/Mcp/McpQueryTools.cs @@ -492,8 +492,9 @@ public static async Task GetQueryHeatmap( [Description("Which per-execution metric to bucket by: duration, cpu, logical_reads, logical_writes or execution_count. Default duration.")] string? metric = null, [Description("Limit to one database. Omit for all databases.")] string? database_name = null, [Description("Width of each time bin, in minutes. Default 5 - the desktop viewer's own bin width, so the two surfaces agree. Raise it to cover a longer window in fewer cells.")] int bucket_minutes = LocalDataService.ViewerHeatmapBucketMinutes, - [Description("Maximum CELLS to return, most recent bins first. Default 500. A full day of 5-minute bins can reach 2,016 cells on a busy server; raise bucket_minutes rather than the cap to see the whole window.")] int limit = DefaultHeatmapCellLimit, - [Description(McpHelpers.AsOfDescription)] string? as_of = null) + [Description("Maximum CELLS to return, most recent bins first. Default 100. A full day of 5-minute bins can reach 2,016 cells on a busy server; raise bucket_minutes rather than the cap to see the whole window.")] int limit = DefaultHeatmapCellLimit, + [Description(McpHelpers.AsOfDescription)] string? as_of = null, + [Description("Return each cell's top query at full length instead of an 80-character preview. Default false.")] bool full_text = false) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; @@ -531,8 +532,9 @@ call let the service compute its own window from UtcNow and the reported window after the call returned, so the two disagreed by however long the read took — on the one read whose entire output is a time axis (review catch). One instant now decides both. */ var databases = string.IsNullOrWhiteSpace(database_name) ? null : new[] { database_name }; + var previewLength = full_text ? FullTextHeatmapPreviewLength : DefaultHeatmapPreviewLength; var rows = await dataService.GetQueryHeatmapCellsAsync( - resolved.ServerId, parsedMetric, hours_back, bucket_minutes, limit + 1, databases, asOfUtc: windowEnd); + resolved.ServerId, parsedMetric, hours_back, bucket_minutes, limit + 1, databases, asOfUtc: windowEnd, previewLength); if (rows.Count == 0) return await EmptyHeatmapAsync(dataService, resolved.ServerId, resolved.ServerName, hours_back, windowEnd); @@ -579,6 +581,10 @@ because the SQL sorts time DESC and bucket ASC. The DESC exists only so the cap /* The same bin width the desktop viewer hardcodes, so the two surfaces cannot disagree about the same server over the same window. */ bucket_minutes_matches_desktop_viewer = bucket_minutes == LocalDataService.ViewerHeatmapBucketMinutes, + /* Echoed rather than left implicit in the cell-level flags alone (#4198): a caller who never + looks at an individual cell still learns, from this one field, that a second call with + full_text=true gets more than what came back. */ + full_text, /* A bare bucket_index is unreadable, and the labels differ by metric family: duration and CPU are milliseconds, the other three are counts. */ magnitude_buckets = labels.Select((label, index) => new { bucket_index = index, label }), @@ -600,6 +606,9 @@ way to see how much of the window is missing. */ query_count = c.QueryCount, top_query_hash = c.TopQueryHash, top_query_text = c.TopQueryText, + /* #4198: honest about the preview it just spent bytes on. True whenever the stored + statement is longer than this call's preview width, at either preview length. */ + top_query_text_truncated = c.TopQueryTextTruncated, }), }, McpHelpers.JsonOptions); } @@ -609,9 +618,20 @@ way to see how much of the window is missing. */ } } - /// The web panel's cap and this tool's default: 500 cells, which is a full day of 5-minute - /// bins on a server whose queries land in two or three magnitude buckets per bin. - private const int DefaultHeatmapCellLimit = 500; + /// This tool's default cell cap (#4198, down from the web panel's own 500): at 500 cells the + /// fixed per-cell fields alone (time bin, bucket, count, hash) ran to roughly 75 KB before one byte of + /// query text, already more than double the shared 32 KB response budget + /// () — so the cap had to fall regardless of the text width + /// chosen. Darling twin: DarlingMcpQueryHeatmapTools.DefaultCellLimit. + private const int DefaultHeatmapCellLimit = 100; + + /// The default top_query_text preview width, in characters (#4198). Darling twin: + /// DarlingMcpQueryHeatmapTools.DefaultPreviewLength. + private const int DefaultHeatmapPreviewLength = 80; + + /// What full_text = true asks for: a generous bound, not a literally unbounded fetch. + /// Darling twin: DarlingMcpQueryHeatmapTools.FullTextPreviewLength. + private const int FullTextHeatmapPreviewLength = 32_000; /// The seven log-magnitude rows of the grid — the viewer's, not a new banding. private const int HeatmapBucketCount = 7; diff --git a/Lite/Services/LocalDataService.QueryHeatmap.cs b/Lite/Services/LocalDataService.QueryHeatmap.cs index 6806734c3..338eba066 100644 --- a/Lite/Services/LocalDataService.QueryHeatmap.cs +++ b/Lite/Services/LocalDataService.QueryHeatmap.cs @@ -23,6 +23,11 @@ public sealed class QueryHeatmapCellRow public long QueryCount { get; set; } public string TopQueryHash { get; set; } = ""; public string TopQueryText { get; set; } = ""; + + /// True when is a preview shorter than the stored statement (#4198) + /// — the caller's only way to tell "this is the whole thing" from "this is cut off" without re-asking + /// with full_text. + public bool TopQueryTextTruncated { get; set; } } /* @@ -61,7 +66,7 @@ public partial class LocalDataService public async Task> GetQueryHeatmapCellsAsync( int serverId, HeatmapMetric metric, int hoursBack = 24, int bucketMinutes = ViewerHeatmapBucketMinutes, int maxRows = 500, - IReadOnlyList? databaseNames = null, DateTime? asOfUtc = null) + IReadOnlyList? databaseNames = null, DateTime? asOfUtc = null, int previewLength = 120) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); @@ -71,14 +76,18 @@ public async Task> GetQueryHeatmapCellsAsync( var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues); var bucketIndex = 4 + dbValues.Count; var limitIndex = 5 + dbValues.Count; + var previewIndex = 6 + dbValues.Count; + /* Preview width is a bound parameter, not the literal 120 (#4198): fetched at previewLength + 1 + characters (Darling's twin over-fetch-by-one idiom, DarlingQueryHeatmapReader), so the one extra + character IS the truncation signal read back in C# below rather than a second round trip. */ command.CommandText = $@" WITH per_query AS ( SELECT time_bucket(to_minutes(CAST(${bucketIndex} AS INTEGER)), collection_time, TIMESTAMP '1970-01-01 00:00:00') AS time_bin, {metricExpr} AS metric_value, query_hash, - LEFT(query_text, 120) AS query_preview, + LEFT(query_text, ${previewIndex}) AS query_preview, delta_execution_count FROM v_query_stats WHERE server_id = $1 @@ -122,18 +131,22 @@ FROM binned command.Parameters.Add(new DuckDBParameter { Value = db }); command.Parameters.Add(new DuckDBParameter { Value = bucketMinutes }); command.Parameters.Add(new DuckDBParameter { Value = maxRows }); + command.Parameters.Add(new DuckDBParameter { Value = previewLength + 1 }); var rows = new List(); using var reader = await command.ExecuteReaderAsync(); while (await reader.ReadAsync()) { + var fetchedText = reader.IsDBNull(4) ? "" : reader.GetString(4); + var truncated = fetchedText.Length > previewLength; rows.Add(new QueryHeatmapCellRow { TimeBucket = reader.GetDateTime(0), BucketIndex = reader.IsDBNull(1) ? 0 : (int)ToDouble(reader.GetValue(1)), QueryCount = reader.IsDBNull(2) ? 0 : (long)ToDouble(reader.GetValue(2)), TopQueryHash = reader.IsDBNull(3) ? "" : reader.GetString(3), - TopQueryText = reader.IsDBNull(4) ? "" : reader.GetString(4), + TopQueryText = truncated ? fetchedText[..previewLength] : fetchedText, + TopQueryTextTruncated = truncated, }); } From 78f4d5f2dc425e17461d551d08cbbb0ae0eca166 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:02:46 -0400 Subject: [PATCH 2/5] Classify get_query_heatmap's new truncation field in the payload census Part of #4198. top_query_text_truncated is a read-side preview cut (the full text is already in the store; full_text opts back into it), not a page cut or a source-side one, so it needed its own roster class rather than joining an existing one that would have misdescribed it. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- .../Darling.Tests/McpPayloadContractCensusTests.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs index a0120b5ad..091a1906b 100644 --- a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs +++ b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs @@ -1475,6 +1475,10 @@ public void TheSharedTimestampParsers_RefuseWhatTheGeneralParserAccepts() /// cut BEFORE the store, by the collector. They keep their names because they are true and different: a /// caller can do nothing about them by re-paging, and folding them into truncated would tell that /// caller to raise a limit that changes nothing. + /// A read-side preview — (#4198): the full value IS in + /// the store; the READ chose a preview width for its default response-size budget, and a caller opts back + /// into the whole thing with full_text rather than re-paging — the opposite direction from a + /// source-side cut, where nothing left to fetch would help. /// The withheld summary — : #3594's own vocabulary for a /// reach verdict that withholds a figure rather than publishing a page's count under a whole's name. /// @@ -1531,6 +1535,12 @@ public static readonly (string Key, string[] Files, string WhyItSurvives)[] Page "the page's count beside an honest WHOLE total (total_entries / total_events / total_facts, or a _count over the whole in-memory set) — the cut is exact and disclosed by the pair; the #3594 spelling is *_returned + truncated, and the rename is fenced tonight because four PgTarget* test files read shown off get_analysis_facts"), ]; + public static readonly (string Key, string[] Files, string WhatWasCut)[] ReadSidePreviewCutKeys = + [ + ("top_query_text_truncated", ["DarlingMcpQueryHeatmapTools.cs", "McpQueryTools.cs"], + "get_query_heatmap's (#4198) per-cell top-query preview width (DefaultPreviewLength on both SKUs) — the full statement is already in the store; full_text opts back into it rather than re-paging, so this is not the page dialect's truncated and nothing was lost the way a source-side cut loses it"), + ]; + public static readonly (string Key, string[] Files, string WhatItActuallyIs)[] CutHomonyms = [ ("is_partial", ["DarlingMcpPgIndexUsageTools.cs"], "a PARTIAL INDEX (CREATE INDEX … WHERE) — an index property the collector reads off pg_index, not a cut"), @@ -1567,6 +1577,7 @@ public static readonly (string Key, string[] Files, string WhatItActuallyIs)[] C SecondBoundCutKeys.Select(k => (k.Key, k.Files)) .Concat(CutNoteKeys.Select(k => (k.Key, k.Files))) .Concat(SourceSideCutKeys.Select(k => (k.Key, k.Files))) + .Concat(ReadSidePreviewCutKeys.Select(k => (k.Key, k.Files))) .Concat(WithheldSummaryKeys.Select(k => (k.Key, k.Files))) .Concat(PageCountsUnderANeutralNoun.Select(k => (k.Key, k.Files))) .Concat(CutHomonyms.Select(k => (k.Key, k.Files))); From 6a239f100592fd3cab1671e8b0590f909ec963f9 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:47:38 -0400 Subject: [PATCH 3/5] Trigger CI (empty commit) Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ From 5bcebeabcb8eac7b11bc4008c3b3a9bc6150e317 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:53:34 -0400 Subject: [PATCH 4/5] Trailing newline to trigger CI path filter Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ --- Lite.Tests/QueryHeatmapBudgetTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Lite.Tests/QueryHeatmapBudgetTests.cs b/Lite.Tests/QueryHeatmapBudgetTests.cs index 856d6b4e4..075b2f0de 100644 --- a/Lite.Tests/QueryHeatmapBudgetTests.cs +++ b/Lite.Tests/QueryHeatmapBudgetTests.cs @@ -194,3 +194,4 @@ INSERT INTO query_stats await cmd.ExecuteNonQueryAsync(); } } + From 1b706c2d3c1e49c543fa68b05083acb6e16a010f Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:36:18 -0400 Subject: [PATCH 5/5] Census: fold top_query_text_truncated into FieldPreviewCutKeys ReadSidePreviewCutKeys (branch's own class, not in Concat chain) held top_query_text_truncated for get_query_heatmap. Merged into FieldPreviewCutKeys and removed the unused ReadSidePreviewCutKeys declaration. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ --- Darling/Darling.Tests/McpPayloadContractCensusTests.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs index ed6265d3c..efdf19236 100644 --- a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs +++ b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs @@ -1531,6 +1531,8 @@ 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"), + ("top_query_text_truncated", ["DarlingMcpQueryHeatmapTools.cs", "McpQueryTools.cs"], + "get_query_heatmap's (#4198) per-cell top-query preview width (DefaultPreviewLength on both SKUs) — the full statement is already in the store; full_text opts back into it rather than re-paging, so this is not the page dialect's truncated and nothing was lost the way a source-side cut loses it"), ]; public static readonly (string Key, string[] Files, string WhatIsWithheld)[] WithheldSummaryKeys = @@ -1545,11 +1547,6 @@ public static readonly (string Key, string[] Files, string WhyItSurvives)[] Page "the page's count beside an honest WHOLE total (total_entries / total_events / total_facts, or a _count over the whole in-memory set) — the cut is exact and disclosed by the pair; the #3594 spelling is *_returned + truncated, and the rename is fenced tonight because four PgTarget* test files read shown off get_analysis_facts"), ]; - public static readonly (string Key, string[] Files, string WhatWasCut)[] ReadSidePreviewCutKeys = - [ - ("top_query_text_truncated", ["DarlingMcpQueryHeatmapTools.cs", "McpQueryTools.cs"], - "get_query_heatmap's (#4198) per-cell top-query preview width (DefaultPreviewLength on both SKUs) — the full statement is already in the store; full_text opts back into it rather than re-paging, so this is not the page dialect's truncated and nothing was lost the way a source-side cut loses it"), - ]; public static readonly (string Key, string[] Files, string WhatItActuallyIs)[] CutHomonyms = [