From 2acafa4058b0b4413c8ef2c59a9ef04c826bff1f Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 07:54:56 -0400 Subject: [PATCH 1/4] MCP raw-window disclosure: get_top_queries_by_cpu / get_top_procedures_by_cpu (#4231) query_stats and procedure_stats are raw-only tables dropped at 4 days when the rollups are armed, and get_top_queries_by_cpu / get_top_procedures_by_cpu never reported how far back their window actually reached. Adds a shared RawWindowFloor probe (generalized from #2364's single-table QueryStoreWindowFloorSql) and wires both tools to publish effective_start / effective_hours_back / window_truncated, the same names and meaning get_query_store_top already uses. Part of #4231. WPF grid headers and the web viewer note are not in this commit; see the PR body for the handoff. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- .../McpPayloadContractCensusTests.cs | 10 +- .../DarlingMcpDataTools.txt | 4 +- .../Darling.Tests/McpToolsListBudgetTests.cs | 7 +- .../RawWindowFloorMcpLiveTests.cs | 145 ++++++++++++++++++ .../Mcp/DarlingDataReader.cs | 53 ++++--- .../Mcp/DarlingMcpDataTools.cs | 57 +++++-- .../RawWindowFloor.cs | 111 ++++++++++++++ 7 files changed, 351 insertions(+), 36 deletions(-) create mode 100644 Darling/Darling.Tests/RawWindowFloorMcpLiveTests.cs create mode 100644 Darling/PerformanceMonitor.Darling.Storage/RawWindowFloor.cs diff --git a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs index 787b062444..0b30d4e79a 100644 --- a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs +++ b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs @@ -1741,9 +1741,11 @@ public void TheAutovacuumRunHistory_BindsToItsCapAndObserves() /// public static readonly (string File, string Idiom, int Blocks)[] WindowFloorBlocks = [ - /* Two: get_query_store_top's payload, and (#4057) its module_name miss, which hands back the window it - read as hints so "no rows matched" is never read as a claim about the part the raw tier no longer holds. */ - ("DarlingMcpDataTools.cs", "initializer", 2), + /* Four: get_query_store_top's payload, and (#4057) its module_name miss, which hands back the window it + read as hints so "no rows matched" is never read as a claim about the part the raw tier no longer + holds; plus (#4231) get_top_queries_by_cpu's and get_top_procedures_by_cpu's payloads, the same + disclosure over query_stats and procedure_stats. */ + ("DarlingMcpDataTools.cs", "initializer", 4), ("DarlingMcpQueryStoreClutterTools.cs", "initializer", 1), ("DarlingMcpTrendTools.cs", "envelope", 1), ("DarlingMcpTrendTools.cs", "initializer", 1), @@ -1753,6 +1755,8 @@ read as hints so "no rows matched" is never read as a claim about the part the r public static readonly (string File, string Tool)[] WindowFloorTools = [ ("DarlingMcpDataTools.cs", "get_query_store_top"), + ("DarlingMcpDataTools.cs", "get_top_procedures_by_cpu"), + ("DarlingMcpDataTools.cs", "get_top_queries_by_cpu"), ("DarlingMcpQueryStoreClutterTools.cs", "get_query_store_clutter"), ("DarlingMcpTrendTools.cs", "get_procedure_duration_trend"), ("DarlingMcpTrendTools.cs", "get_query_duration_trend"), diff --git a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDataTools.txt b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDataTools.txt index 0c722c76bb..22f030bd41 100644 --- a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDataTools.txt +++ b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDataTools.txt @@ -61,14 +61,14 @@ param get_tempdb_trend.bucket_minutes 55 param get_tempdb_trend.hours_back 29 param get_tempdb_trend.server_name 28 -tool get_top_procedures_by_cpu 406 +tool get_top_procedures_by_cpu 678 param get_top_procedures_by_cpu.as_of 167 param get_top_procedures_by_cpu.database_name 30 param get_top_procedures_by_cpu.hours_back 29 param get_top_procedures_by_cpu.server_name 28 param get_top_procedures_by_cpu.top 37 -tool get_top_queries_by_cpu 474 +tool get_top_queries_by_cpu 742 param get_top_queries_by_cpu.as_of 167 param get_top_queries_by_cpu.database_name 30 param get_top_queries_by_cpu.group_by 167 diff --git a/Darling/Darling.Tests/McpToolsListBudgetTests.cs b/Darling/Darling.Tests/McpToolsListBudgetTests.cs index 520c47a7e2..01a5fb8741 100644 --- a/Darling/Darling.Tests/McpToolsListBudgetTests.cs +++ b/Darling/Darling.Tests/McpToolsListBudgetTests.cs @@ -120,7 +120,12 @@ so neither counts here. */ of active_queries (#4261) + object_locking (#4258) changes on top of dev. */ /* #4198 (collection_log, merge): re-measured after merging origin/dev (dev now includes #4261+#4258); combined total with collection_log (#4265) changes on top. */ - private const int TotalCeilingBytes = 172_760; + /* #4231: +550 bytes for get_top_queries_by_cpu's and get_top_procedures_by_cpu's new window_truncated / + effective_start / effective_hours_back disclosure — a short served-head sentence naming the raw tier's + retention floor (272 / 268 bytes), matching get_query_store_top's own #2364 disclosure. The full clause + (McpHelpers.WindowTruncatedDescription) lands in each tool's get_tool_guide tail, which this ceiling does + not count. */ + private const int TotalCeilingBytes = 173_310; private const int ConvertedHeadCap = 1_000; private const int ConvertedParameterCap = 200; diff --git a/Darling/Darling.Tests/RawWindowFloorMcpLiveTests.cs b/Darling/Darling.Tests/RawWindowFloorMcpLiveTests.cs new file mode 100644 index 0000000000..cabefb95ab --- /dev/null +++ b/Darling/Darling.Tests/RawWindowFloorMcpLiveTests.cs @@ -0,0 +1,145 @@ +/* + * 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.Linq; +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; + +/// +/// #4231 against live PostgreSQL: get_top_queries_by_cpu and get_top_procedures_by_cpu read +/// query_stats / procedure_stats raw only, and must disclose a floor the same way +/// get_query_store_top does (#2364) — a window whose raw tier does not reach the requested start comes +/// back window_truncated: true with the real effective_start / effective_hours_back, and a +/// floor inside of the request stays quiet. +/// +[Collection("live-postgres")] +public sealed class RawWindowFloorMcpLiveTests +{ + private const string ServerName = "darling-raw-window-floor-e2e"; + private static readonly int ServerId = ServerIdHelper.GetDeterministicHashCode(ServerName); + private const string Db = "StackOverflow"; + private static string? ConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + + [Fact] + public async Task RawTopReads_DiscloseATruncatedFloor_AndStayQuietInsideTheSlack() + { + var cs = ConnectionString; + Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live raw-window-floor test."); + + var ct = TestContext.Current.CancellationToken; + using var connection = new NpgsqlConnection(cs); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + await DeleteRowsAsync(connection, ct); + await using var postgres = NpgsqlDataSource.Create(cs!); + + var bodySucceeded = false; + try + { + await DarlingMcpTestData.RegisterServerAsync(connection, ServerId, ServerName, ct); + var now = DarlingMcpTestData.TruncateToSeconds(DateTime.UtcNow).AddMinutes(-2); + + /* The raw tier holds one row from five minutes ago; a 24-hour ask must report the real floor + rather than echo hours_back back unchanged (#4231, the #2364 shape). */ + var recent = now.AddMinutes(-5); + await PlantQueryAsync(connection, ct, recent); + await PlantProcedureAsync(connection, ct, recent); + + var queries = JsonDocument.Parse(await DarlingMcpDataTools.GetTopQueriesByCpu(postgres, ServerName, 24)).RootElement; + Assert.True(queries.GetProperty("window_truncated").GetBoolean()); + Assert.True(queries.GetProperty("effective_hours_back").GetDouble() < 1.0); + Assert.Contains("query_stats", queries.GetProperty("truncation_note").GetString(), StringComparison.Ordinal); + + var procedures = JsonDocument.Parse(await DarlingMcpDataTools.GetTopProceduresByCpu(postgres, ServerName, 24)).RootElement; + Assert.True(procedures.GetProperty("window_truncated").GetBoolean()); + Assert.True(procedures.GetProperty("effective_hours_back").GetDouble() < 1.0); + Assert.Contains("procedure_stats", procedures.GetProperty("truncation_note").GetString(), StringComparison.Ordinal); + + /* Inside the #2364 slack (90 minutes): a floor sitting 30 minutes after the requested start of a + 2-hour window must NOT be called truncated. */ + await DeleteRowsAsync(connection, ct); + await DarlingMcpTestData.RegisterServerAsync(connection, ServerId, ServerName, ct); + var insideSlack = now.AddHours(-2).AddMinutes(30); + await PlantQueryAsync(connection, ct, insideSlack); + + var quiet = JsonDocument.Parse(await DarlingMcpDataTools.GetTopQueriesByCpu(postgres, ServerName, 2)).RootElement; + Assert.False(quiet.GetProperty("window_truncated").GetBoolean()); + Assert.Equal(JsonValueKind.Null, quiet.GetProperty("truncation_note").ValueKind); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(cs!, bodySucceeded, async (cleanup, cleanupCt) => + await DeleteRowsAsync(cleanup, cleanupCt)); + } + } + + private static Task PlantQueryAsync(NpgsqlConnection connection, CancellationToken ct, DateTime at) => + DarlingMcpTestData.ExecAsync(connection, ct, + @"INSERT INTO query_stats (collection_id, collection_time, server_id, server_name, database_name, query_hash, query_plan_hash, sql_handle, plan_handle, query_text, delta_execution_count, delta_worker_time, delta_elapsed_time, delta_logical_reads, min_dop, max_dop) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)", + CollectionIdGenerator.Next(), at, ServerId, ServerName, Db, "0xFLOORHASH", "0xPLANHASH", "0xSQLH", "0xPLANH", "SELECT 1", 10L, 900_000L, 900_000L, 100L, 1, 1); + + private static Task PlantProcedureAsync(NpgsqlConnection connection, CancellationToken ct, DateTime at) => + DarlingMcpTestData.ExecAsync(connection, ct, + @"INSERT INTO procedure_stats (collection_id, collection_time, server_id, server_name, database_name, schema_name, object_name, object_type, sql_handle, plan_handle, delta_execution_count, delta_worker_time, delta_elapsed_time) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)", + CollectionIdGenerator.Next(), at, ServerId, ServerName, Db, "dbo", "usp_Floor", "SQL_STORED_PROCEDURE", "0xPROCH", "0xPROCPLANH", 5L, 900_000L, 900_000L); + + private static async Task DeleteRowsAsync(NpgsqlConnection connection, CancellationToken ct) + { + var sql = string.Join(" ", new[] { "query_stats", "procedure_stats" }.Select(tbl => $"DELETE FROM {tbl} WHERE server_id = {ServerId};")); + sql += $" DELETE FROM servers WHERE server_id = {ServerId};"; + using var cleanup = new NpgsqlCommand(sql, connection); + await cleanup.ExecuteNonQueryAsync(ct); + } +} + +/// +/// #4231: a source pin that query_stats, procedure_stats and query_store_stats all reach +/// their window floor through the ONE shared probe (), never a hand-copied SQL +/// constant per table — the defect #2364 would have repeated twice more. +/// +public sealed class RawWindowFloorSharedHelperSourcePinTests +{ + private static string ReaderSource => RepoFile.ReadRepoFile( + "Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingDataReader.cs"); + + [Fact] + public void EveryRawTableFloor_RoutesThroughTheSharedHelper() + { + var source = ReaderSource; + Assert.Contains("RawWindowFloor.GetAsync(postgres, RawWindowFloor.Table.QueryStats,", source, StringComparison.Ordinal); + Assert.Contains("RawWindowFloor.GetAsync(postgres, RawWindowFloor.Table.ProcedureStats,", source, StringComparison.Ordinal); + Assert.Contains("RawWindowFloor.GetAsync(postgres, RawWindowFloor.Table.QueryStoreStats,", source, StringComparison.Ordinal); + + /* Never a fourth, hand-rolled "SELECT MIN(collection_time) FROM " beside the shared one. */ + var occurrences = System.Text.RegularExpressions.Regex.Matches(source, "SELECT MIN\\(collection_time\\)").Count; + Assert.Equal(0, occurrences); + } + + [Fact] + public void TheTruncationBoundary_ReusesTheSharedSlackConstant_NeverA90MinuteLiteral() + { + Assert.DoesNotContain("AddMinutes(90)", ReaderSource, StringComparison.Ordinal); + var toolSource = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpDataTools.cs"); + Assert.DoesNotContain("AddMinutes(90)", toolSource, StringComparison.Ordinal); + Assert.Contains("RawWindowFloor.IsTruncated", toolSource, StringComparison.Ordinal); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs index 1cb8cc716d..82a725340e 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs @@ -1319,32 +1319,43 @@ public static async Task> GetTopProceduresByCpuAsync( /// /// Bounded on both sides, so it prunes chunks and answers from an ordered scan that stops at the first /// row rather than reading the window. $1 server_id, $2/$3 window (naive UTC). + /// + /// #4231 generalized this single-table probe into , which + /// query_stats and procedure_stats now share rather than each carrying its own copy; this + /// constant is for , + /// kept under its original name because DarlingMcpQueryStoreClutterTools and the #2364 tests still + /// reach it by this one. /// - public const string QueryStoreWindowFloorSql = """ - SELECT MIN(collection_time) - FROM query_store_stats - WHERE server_id = $1 - AND collection_time >= $2 - AND collection_time <= $3 - """; + public static readonly string QueryStoreWindowFloorSql = RawWindowFloor.FloorSql(RawWindowFloor.Table.QueryStoreStats); /// - /// Reads . Null when the window holds nothing at all, which the caller - /// reports as "nothing was read" rather than as an absence of activity. Deliberately unfiltered: the floor is - /// a property of the tier, so a database or module filter on the top read does not narrow it. + /// Reads through the shared , at + /// this surface's own MCP read deadline. Null when the window holds nothing at all, which the caller reports + /// as "nothing was read" rather than as an absence of activity. Deliberately unfiltered: the floor is a + /// property of the tier, so a database or module filter on the top read does not narrow it. /// - public static async Task GetQueryStoreWindowFloorAsync( + public static Task GetQueryStoreWindowFloorAsync( NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, - CancellationToken cancellationToken = default) - { - await using var command = postgres.CreateCommand(QueryStoreWindowFloorSql); - command.CommandTimeout = McpCommandDeadlines.ReadSeconds; - DarlingMcpReadParameters.AddInt(command, serverId); - DarlingMcpReadParameters.AddTimestamp(command, startUtc); - DarlingMcpReadParameters.AddTimestamp(command, endUtc); - var value = await command.ExecuteScalarAsync(cancellationToken); - return value is DateTime dt ? dt : null; - } + CancellationToken cancellationToken = default) => + RawWindowFloor.GetAsync(postgres, RawWindowFloor.Table.QueryStoreStats, serverId, startUtc, endUtc, McpCommandDeadlines.ReadSeconds, cancellationToken); + + /// + /// #4231's query_stats arm of the same probe: get_top_queries_by_cpu reads the raw table only, + /// which on a store with the rollups armed is dropped at 4 days, and its top-N-by-CPU rows say nothing about + /// how far back the window reached (the same reasoning as ). + /// + public static Task GetQueryStatsWindowFloorAsync( + NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, + CancellationToken cancellationToken = default) => + RawWindowFloor.GetAsync(postgres, RawWindowFloor.Table.QueryStats, serverId, startUtc, endUtc, McpCommandDeadlines.ReadSeconds, cancellationToken); + + /// + /// #4231's procedure_stats arm of the same probe, for get_top_procedures_by_cpu. + /// + public static Task GetProcedureStatsWindowFloorAsync( + NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, + CancellationToken cancellationToken = default) => + RawWindowFloor.GetAsync(postgres, RawWindowFloor.Table.ProcedureStats, serverId, startUtc, endUtc, McpCommandDeadlines.ReadSeconds, cancellationToken); /// /// Top Query Store groups over the window — a focused projection of the viewer's diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs index 9d647758b5..c50e415d49 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs @@ -512,7 +512,7 @@ public static async Task GetPerfmonStats( /* ═══════════════════════════ query performance ═══════════════════════════ */ - [McpServerTool(Name = "get_top_queries_by_cpu"), Description("Gets expensive cached queries from sys.dm_exec_query_stats, ranked by CPU over a window ending at as_of. Filters (database_name, parallel_only, min_dop) apply before the top-N cap: filter_applied names the floor in force, and an empty page under it is the window's real answer, not a miss. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes, not windowed; cpu_attribution's ratio is omitted, not invented, when its inputs are missing. <> Gets expensive queries from sys.dm_exec_query_stats (plan cache). Best for: currently cached queries with detailed per-execution stats, DOP, spills, and query_hash for trending. Returns query_hash, query_plan_hash, sql_handle, plan_handle, and host_object (the hosting procedure/function for proc-hosted statements, null for ad-hoc) — groups key on (database, query_hash, host_object), so INSERT...EXEC callers in different procedures report separately with their own text. distinct_texts counts statement texts merged into a group (>1 = ad-hoc literal variants or pre-upgrade history; query_text is one representative, 0 means only rows predating the text dimension). 'host_object' rolls all of a procedure's statements into one row — use it when dynamic SQL with per-value literals fragments one statement across many hashes, which no top-N-by-hash ranking can surface. Ad-hoc statements have no host object and stay grouped per hash in both modes. distinct_query_hashes reports how many hashes a row rolled up. Set group_by='host_object' to roll all of a procedure's statements into one row — necessary when dynamic SQL with per-value literals fragments one statement across many hashes, which no top-N-by-hash ranking can surface. Supports database and parallelism filtering; every filter is applied IN the query before the ranking and the cap, so the page is the top-N of the FILTERED population (filter_applied names the parallelism floor in force, null when none), and an empty page under parallel_only/min_dop is the window's answer rather than a page artefact. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes for the plan's time in cache (same semantics as max_dop), not windowed — totals and avgs are windowed deltas; rows where an extreme provably predates the window carry extremes_note. max_dop comes from sys.dm_exec_query_stats and is a lifetime-max for the plan's time in cache, so a plan compiled before MAXDOP was lowered keeps reporting the old higher value until it is evicted or recompiled; confirm current parallelism with analyze_query_plan, which reads the actual plan. " + McpToolGuideTopics.CpuTimeExtremesAndAttribution)] + [McpServerTool(Name = "get_top_queries_by_cpu"), Description("Gets expensive cached queries from sys.dm_exec_query_stats, ranked by CPU over a window ending at as_of. Filters (database_name, parallel_only, min_dop) apply before the top-N cap: filter_applied names the floor in force, and an empty page under it is the window's real answer, not a miss. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes, not windowed; cpu_attribution's ratio is omitted, not invented, when its inputs are missing. Darling: window_truncated marks a window floor, not a page cut — raw query_stats is dropped at 4 days when the rollups are armed; effective_start / effective_hours_back give the reach actually served. Lite: no such floor yet; the full requested window is always read. <> Gets expensive queries from sys.dm_exec_query_stats (plan cache). Best for: currently cached queries with detailed per-execution stats, DOP, spills, and query_hash for trending. Returns query_hash, query_plan_hash, sql_handle, plan_handle, and host_object (the hosting procedure/function for proc-hosted statements, null for ad-hoc) — groups key on (database, query_hash, host_object), so INSERT...EXEC callers in different procedures report separately with their own text. distinct_texts counts statement texts merged into a group (>1 = ad-hoc literal variants or pre-upgrade history; query_text is one representative, 0 means only rows predating the text dimension). 'host_object' rolls all of a procedure's statements into one row — use it when dynamic SQL with per-value literals fragments one statement across many hashes, which no top-N-by-hash ranking can surface. Ad-hoc statements have no host object and stay grouped per hash in both modes. distinct_query_hashes reports how many hashes a row rolled up. Set group_by='host_object' to roll all of a procedure's statements into one row — necessary when dynamic SQL with per-value literals fragments one statement across many hashes, which no top-N-by-hash ranking can surface. Supports database and parallelism filtering; every filter is applied IN the query before the ranking and the cap, so the page is the top-N of the FILTERED population (filter_applied names the parallelism floor in force, null when none), and an empty page under parallel_only/min_dop is the window's answer rather than a page artefact. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes for the plan's time in cache (same semantics as max_dop), not windowed — totals and avgs are windowed deltas; rows where an extreme provably predates the window carry extremes_note. max_dop comes from sys.dm_exec_query_stats and is a lifetime-max for the plan's time in cache, so a plan compiled before MAXDOP was lowered keeps reporting the old higher value until it is evicted or recompiled; confirm current parallelism with analyze_query_plan, which reads the actual plan. " + McpToolGuideTopics.CpuTimeExtremesAndAttribution + McpHelpers.WindowTruncatedDescription)] public static async Task GetTopQueriesByCpu( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, @@ -557,8 +557,18 @@ the exact wrong conclusion this option exists to prevent. */ try { var now = windowEnd; + var requestedStart = now.AddHours(-hours_back); var rows = await DarlingDataReader.GetTopQueriesByCpuAsync( - postgres, resolved.ServerId, now.AddHours(-hours_back), now, top, database_name, rollUpByHostObject: rollUp, minMaxDop: minMaxDop); + postgres, resolved.ServerId, requestedStart, now, top, database_name, rollUpByHostObject: rollUp, minMaxDop: minMaxDop); + + /* #4231: what the raw tier actually held, beside what was asked for. Rows above are top-N by CPU, + not by time, so their timestamps say nothing about how far back the window reached — raw + query_stats is dropped at 4 days on a store with the rollups armed, and a 7-day ask silently + got ~4. Same probe and the same #2364 disclosure get_query_store_top already makes. */ + var floor = await DarlingDataReader.GetQueryStatsWindowFloorAsync(postgres, resolved.ServerId, requestedStart, now); + var effectiveStart = RawWindowFloor.EffectiveStart(floor, requestedStart); + var windowTruncated = RawWindowFloor.IsTruncated(floor, requestedStart); + if (rows.Count == 0) { /* A filtered miss is not a collection miss: with the floor in the query, an empty page under @@ -580,14 +590,14 @@ the exact wrong conclusion this option exists to prevent. */ the caller-visible ranking (post top-N, post filters), denominator is measured, and the ratio is omitted rather than invented when a denominator piece is missing. The two reads are independent, so they run concurrently (review catch). */ - var cpuAggregateTask = DarlingDataReader.GetCpuWindowAggregateAsync(postgres, resolved.ServerId, now.AddHours(-hours_back), now); + var cpuAggregateTask = DarlingDataReader.GetCpuWindowAggregateAsync(postgres, resolved.ServerId, requestedStart, now); var propertiesTask = DarlingDataReader.GetLatestServerPropertiesAsync(postgres, resolved.ServerId); await Task.WhenAll(cpuAggregateTask, propertiesTask); var cpuAggregate = await cpuAggregateTask; var properties = await propertiesTask; var attribution = CpuAttribution.Compute( rows.Sum(r => r.TotalCpuUs) / 1_000_000.0, - now.AddHours(-hours_back), now, + requestedStart, now, cpuAggregate.SampleCount, cpuAggregate.FirstSample, cpuAggregate.LastSample, cpuAggregate.AvgSqlCpuPercent, properties?.CpuCount ?? 0); @@ -646,6 +656,10 @@ ratio is omitted rather than invented when a denominator piece is missing. The t { server = resolved.ServerName, hours_back, + /* #4231: what was served, beside what was asked for — the same disclosure get_query_store_top + makes (#2364), over query_stats instead of query_store_stats. */ + effective_start = effectiveStart.ToString("o"), + effective_hours_back = Math.Round((now - effectiveStart).TotalHours, 1), /* #2235: echoed so a stored or pasted payload cannot be misread as the other grouping — the two answer different questions and the rows look alike. */ group_by = rollUp ? "host_object" : "query_hash", @@ -658,6 +672,14 @@ the two answer different questions and the rows look alike. */ attributed_cpu_ratio = attribution.AttributedCpuRatio, note = attribution.Note }, + /* #4231: the WINDOW floor, spelled the way #3653 item 17 fixed the vocabulary — never bare + `truncated`, which on every paged tool in this file means a limit bit. Nothing the caller + sends changes it: the raw tier is where the rows were, and it stops where it stops. */ + window_truncated = windowTruncated, + truncation_note = windowTruncated + ? "The window reaches further back than this server's raw query_stats retains, so the older " + + "part of it was not read." + : null, queries = result }, McpHelpers.JsonOptions); } @@ -667,7 +689,7 @@ the two answer different questions and the rows look alike. */ } } - [McpServerTool(Name = "get_top_procedures_by_cpu"), Description("Gets the most expensive stored procedures ranked by total CPU time over a window ending at as_of. Delta-based: requires ~30 minutes after adding a new server before data appears. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes, not windowed (extremes_note flags a provably stale one); cpu_attribution's ratio is omitted, not invented, when its inputs are missing. <> Shows execution counts, CPU/elapsed times, and I/O metrics. Delta-based: requires ~30 minutes after adding a new server before data appears. " + McpToolGuideTopics.CpuTimeExtremesAndAttribution)] + [McpServerTool(Name = "get_top_procedures_by_cpu"), Description("Gets the most expensive stored procedures ranked by total CPU time over a window ending at as_of. Delta-based: requires ~30 minutes after adding a new server before data appears. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes, not windowed (extremes_note flags a provably stale one); cpu_attribution's ratio is omitted, not invented, when its inputs are missing. Darling: window_truncated marks a window floor, not a page cut — raw procedure_stats is dropped at 4 days when the rollups are armed; effective_start / effective_hours_back give the reach actually served. Lite: no such floor yet; the full requested window is always read. <> Shows execution counts, CPU/elapsed times, and I/O metrics. Delta-based: requires ~30 minutes after adding a new server before data appears. " + McpToolGuideTopics.CpuTimeExtremesAndAttribution + McpHelpers.WindowTruncatedDescription)] public static async Task GetTopProceduresByCpu( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, @@ -687,23 +709,30 @@ public static async Task GetTopProceduresByCpu( try { var now = windowEnd; - var rows = await DarlingDataReader.GetTopProceduresByCpuAsync(postgres, resolved.ServerId, now.AddHours(-hours_back), now, top, database_name); + var requestedStart = now.AddHours(-hours_back); + var rows = await DarlingDataReader.GetTopProceduresByCpuAsync(postgres, resolved.ServerId, requestedStart, now, top, database_name); if (rows.Count == 0) return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "procedure_stats") ?? McpHelpers.Status( "unavailable", "No procedure stats available. Delta-based collection requires at least two collection cycles (~30 minutes) to produce non-zero values."); + /* #4231: what the raw tier actually held, beside what was asked for — same probe and disclosure as + get_top_queries_by_cpu and get_query_store_top (#2364), over procedure_stats. */ + var floor = await DarlingDataReader.GetProcedureStatsWindowFloorAsync(postgres, resolved.ServerId, requestedStart, now); + var effectiveStart = RawWindowFloor.EffectiveStart(floor, requestedStart); + var windowTruncated = RawWindowFloor.IsTruncated(floor, requestedStart); + /* #2320: same attributed-CPU disclosure as the queries tool — one shared computation, same concurrent independent reads. */ - var cpuAggregateTask = DarlingDataReader.GetCpuWindowAggregateAsync(postgres, resolved.ServerId, now.AddHours(-hours_back), now); + var cpuAggregateTask = DarlingDataReader.GetCpuWindowAggregateAsync(postgres, resolved.ServerId, requestedStart, now); var propertiesTask = DarlingDataReader.GetLatestServerPropertiesAsync(postgres, resolved.ServerId); await Task.WhenAll(cpuAggregateTask, propertiesTask); var cpuAggregate = await cpuAggregateTask; var properties = await propertiesTask; var attribution = CpuAttribution.Compute( rows.Sum(r => r.TotalCpuUs) / 1_000_000.0, - now.AddHours(-hours_back), now, + requestedStart, now, cpuAggregate.SampleCount, cpuAggregate.FirstSample, cpuAggregate.LastSample, cpuAggregate.AvgSqlCpuPercent, properties?.CpuCount ?? 0); @@ -737,6 +766,9 @@ same concurrent independent reads. */ { server = resolved.ServerName, hours_back, + /* #4231: what was served, beside what was asked for. */ + effective_start = effectiveStart.ToString("o"), + effective_hours_back = Math.Round((now - effectiveStart).TotalHours, 1), cpu_attribution = new { ranked_cpu_seconds = attribution.RankedCpuSeconds, @@ -744,6 +776,12 @@ same concurrent independent reads. */ attributed_cpu_ratio = attribution.AttributedCpuRatio, note = attribution.Note }, + /* #4231: the WINDOW floor (#3653 item 17 vocabulary) — never bare `truncated`. */ + window_truncated = windowTruncated, + truncation_note = windowTruncated + ? "The window reaches further back than this server's raw procedure_stats retains, so the " + + "older part of it was not read." + : null, procedures = result }, McpHelpers.JsonOptions); } @@ -795,7 +833,8 @@ and this tool has no rollup to fall back to (the corrected CAGGs carry no query_ that was served rather than echo the one that was asked for. */ var floor = await DarlingDataReader.GetQueryStoreWindowFloorAsync(postgres, resolved.ServerId, requestedStart, now); var effectiveStart = floor ?? requestedStart; - var truncated = floor is DateTime f && f > requestedStart.AddMinutes(90); + /* #4231: the shared helper's own boundary, not a bare 90-minute literal restated here. */ + var truncated = RawWindowFloor.IsTruncated(floor, requestedStart); if (rows.Count == 0) { diff --git a/Darling/PerformanceMonitor.Darling.Storage/RawWindowFloor.cs b/Darling/PerformanceMonitor.Darling.Storage/RawWindowFloor.cs new file mode 100644 index 0000000000..97a144dc78 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Storage/RawWindowFloor.cs @@ -0,0 +1,111 @@ +/* + * 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.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Storage; + +/// +/// The raw-tier window floor probe (#4231), generalized from #2364's single-table +/// DarlingDataReader.QueryStoreWindowFloorSql to the three per-collection tables the Queries tab's +/// grids, and their MCP twins, read: query_stats, procedure_stats, query_store_stats. +/// All three are hypertables dropped at 4 days when TimescaleDB's rollups are armed, and a top-N-by-cost read +/// over a longer window silently serves whatever the raw tier still holds — #2364 fixed that for +/// get_query_store_top alone; this is the same probe, generalized so the other two raw tables get it +/// too, rather than two more hand-copied SQL constants. +/// +/// Why a separate probe rather than reading the returned rows. Every read this floor serves ranks +/// its top N by COST, not by time, so the timestamps on the returned rows say nothing about how far back the +/// window actually reached — the most expensive row in a month may have happened this morning. The floor is a +/// property of the TIER, not of the result set, and has to be asked for separately. +/// +/// Why a one-chunk probe. Bounded on both sides ($2 start, $3 end) exactly like the window it is +/// asked about, so TimescaleDB prunes every chunk outside it and the scan stops at the first row of the oldest +/// surviving chunk rather than reading the window. Unfiltered by database or module: the floor is the tier's, +/// so a filter on the caller's real read must not narrow it. +/// +/// Why this lives in Storage. The desktop viewer's Queries grids (ViewerDataService) and +/// the MCP tools (DarlingDataReader) both need the identical probe over the identical SQL, and the +/// viewer does not reference the service assembly (#1661 / #2530) — the same reason +/// and live here rather than in +/// the MCP reader alone. +/// +public static class RawWindowFloor +{ + /// The three raw, per-collection tables this probe answers for. A closed set: a fourth table + /// means adding a case below, never a fourth hand-copied SQL constant. + public enum Table + { + QueryStats, + ProcedureStats, + QueryStoreStats + } + + private static string TableName(Table table) => table switch + { + Table.QueryStats => "query_stats", + Table.ProcedureStats => "procedure_stats", + Table.QueryStoreStats => "query_store_stats", + _ => throw new ArgumentOutOfRangeException(nameof(table), table, "unknown raw window floor table") + }; + + /// + /// The probe SQL for — #2364's QueryStoreWindowFloorSql shape verbatim, + /// the table name the only thing that varies. $1 server_id, $2/$3 window (naive UTC). + /// + public static string FloorSql(Table table) => $""" + SELECT MIN(collection_time) + FROM {TableName(table)} + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + """; + + /// + /// The oldest collection_time actually has inside + /// [, ] for . Null when the + /// window holds nothing at all, which the caller reports as "nothing was read" rather than as an absence + /// of activity. + /// + /// The caller's deadline class — the MCP read deadline by default; the + /// viewer passes its interactive one. + public static async Task GetAsync( + NpgsqlDataSource postgres, Table table, int serverId, DateTime startUtc, DateTime endUtc, + int commandTimeoutSeconds = StorageCommandDeadlines.McpReadSeconds, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(postgres); + + await using var command = postgres.CreateCommand(FloorSql(table)); + command.CommandTimeout = commandTimeoutSeconds; + command.Parameters.AddWithValue(serverId); + /* SpecifyKind(Unspecified), not the bare value: Npgsql infers timestamptz from Kind=Utc and PostgreSQL + then zone-shifts the window against the store's NAIVE timestamp columns — the convention every other + PostgreSQL window read here follows (DarlingPgBlockingReader, DarlingPgXminReader, + BaselineDiscontinuityReader). */ + command.Parameters.AddWithValue(DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified)); + var value = await command.ExecuteScalarAsync(cancellationToken); + return value is DateTime dt ? dt : null; + } + + /// The window actually served: the floor when the tier had one, the requested start otherwise. + public static DateTime EffectiveStart(DateTime? floor, DateTime requestedStartUtc) => floor ?? requestedStartUtc; + + /// + /// Whether the floor sits far enough past the requested start to call the window window_truncated — + /// , the same 90-minute boundary get_query_store_top + /// (#2364) and the duration-trend tiers (#2353) already treat as the point past which a served window + /// counts as cut short, reused here rather than restated as a new 90-minute literal. + /// + public static bool IsTruncated(DateTime? floor, DateTime requestedStartUtc) => + floor is DateTime f && f > requestedStartUtc.Add(DurationTrendRouting.TruncationSlack); +} From 812f402ac2421d0d54f643bf3e29995742189591 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:28:20 -0400 Subject: [PATCH 2/4] MCP truncation_note parity, WPF Queries-tab grid disclosure, web viewer note (#4231) get_top_queries_by_cpu / get_top_procedures_by_cpu now carry Lite's (#4279) exact truncation_note sentence, including the "or this server has been monitored for less time than that" clause Q1a's version was missing. WPF: Top Queries / Top Procedures / Query Store grids each read their raw table's floor through the shared RawWindowFloor probe (ViewerDataService.*WindowFloorAsync) and show "Showing since " in the grid header when truncated, formatted the way Performance Trends already formats a truncated head. Wired into both the toolbar-window load and each slicer-drag re-read. Web: the same three MCP tools' truncation_note now renders through panels.js's existing #3278 noteKey opt-in, on the built-in server tabs and the starter dashboard template. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- .../RawWindowFloorMcpLiveTests.cs | 12 +- .../RawWindowFloorViewerPortTests.cs | 157 ++++++++++++++++++ .../Mcp/DarlingMcpDataTools.cs | 8 +- .../wwwroot/js/pages/server-tabs.js | 26 ++- .../wwwroot/js/view-templates.js | 5 + .../ViewerDataService.ProcedureStats.cs | 13 ++ .../ViewerDataService.QueryStats.cs | 13 ++ .../ViewerDataService.QueryStore.cs | 15 ++ .../ViewerServerTab.Queries.cs | 35 ++++ .../ViewerServerTab.xaml | 39 ++++- 10 files changed, 305 insertions(+), 18 deletions(-) create mode 100644 Darling/Darling.Tests/RawWindowFloorViewerPortTests.cs diff --git a/Darling/Darling.Tests/RawWindowFloorMcpLiveTests.cs b/Darling/Darling.Tests/RawWindowFloorMcpLiveTests.cs index cabefb95ab..7e8152ab21 100644 --- a/Darling/Darling.Tests/RawWindowFloorMcpLiveTests.cs +++ b/Darling/Darling.Tests/RawWindowFloorMcpLiveTests.cs @@ -63,12 +63,20 @@ rather than echo hours_back back unchanged (#4231, the #2364 shape). */ var queries = JsonDocument.Parse(await DarlingMcpDataTools.GetTopQueriesByCpu(postgres, ServerName, 24)).RootElement; Assert.True(queries.GetProperty("window_truncated").GetBoolean()); Assert.True(queries.GetProperty("effective_hours_back").GetDouble() < 1.0); - Assert.Contains("query_stats", queries.GetProperty("truncation_note").GetString(), StringComparison.Ordinal); + /* #4231 parity: exactly Lite's (#4279) sentence, "or this server has been monitored for less time + than that" included — a caller reading Darling and Lite side by side sees the same words. */ + Assert.Equal( + "The window reaches further back than this server's raw query_stats retains (or this server " + + "has been monitored for less time than that), so the older part of it was not read.", + queries.GetProperty("truncation_note").GetString()); var procedures = JsonDocument.Parse(await DarlingMcpDataTools.GetTopProceduresByCpu(postgres, ServerName, 24)).RootElement; Assert.True(procedures.GetProperty("window_truncated").GetBoolean()); Assert.True(procedures.GetProperty("effective_hours_back").GetDouble() < 1.0); - Assert.Contains("procedure_stats", procedures.GetProperty("truncation_note").GetString(), StringComparison.Ordinal); + Assert.Equal( + "The window reaches further back than this server's raw procedure_stats retains (or this server " + + "has been monitored for less time than that), so the older part of it was not read.", + procedures.GetProperty("truncation_note").GetString()); /* Inside the #2364 slack (90 minutes): a floor sitting 30 minutes after the requested start of a 2-hour window must NOT be called truncated. */ diff --git a/Darling/Darling.Tests/RawWindowFloorViewerPortTests.cs b/Darling/Darling.Tests/RawWindowFloorViewerPortTests.cs new file mode 100644 index 0000000000..79a96a3e64 --- /dev/null +++ b/Darling/Darling.Tests/RawWindowFloorViewerPortTests.cs @@ -0,0 +1,157 @@ +/* + * 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.Threading; +using System.Windows; +using System.Windows.Controls; +using PerformanceMonitor.Darling.Viewer; +using Xunit; +using static Darling.Tests.RepoFile; + +namespace Darling.Tests; + +/// +/// #4231, the second slice: the WPF Queries tab's three raw-table grids (Top Queries, Top Procedures, Query +/// Store) and the web viewer's twin panels disclose a truncated window the same way get_query_store_top +/// does over MCP (#2364) — through the shared +/// probe, never a hand-copied SELECT MIN(collection_time), and a "Showing since <effective start>" +/// grid-header banner (desktop) / truncation_note strip (web) when the raw tier does not reach back as +/// far as the window asked for. +/// +public sealed class RawWindowFloorViewerPortTests +{ + private static readonly DateTime RequestedStart = new(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc); + + [Fact] + public void UpdateTruncationBanner_ShowsSinceEffectiveStart_WhenTheFloorIsPastTheSlack() + { + OnStaThread(() => + { + var banner = new TextBlock(); + var floor = RequestedStart.AddDays(3); + + ViewerServerTab.UpdateTruncationBanner(banner, floor, RequestedStart); + + Assert.Equal(Visibility.Visible, banner.Visibility); + Assert.StartsWith("Showing since ", banner.Text, StringComparison.Ordinal); + }); + } + + [Fact] + public void UpdateTruncationBanner_StaysCollapsed_WhenTheFloorIsInsideTheSlack() + { + OnStaThread(() => + { + /* 30 minutes after the requested start — inside DurationTrendRouting.TruncationSlack's 90-minute + allowance, so a normal cadence-start lag, not a retention cut (the #2364 / #4231 ruling). Seeded + Visible/non-empty first so a no-op bug (never touching Visibility) cannot pass by accident. */ + var banner = new TextBlock { Visibility = Visibility.Visible, Text = "stale" }; + var floor = RequestedStart.AddMinutes(30); + + ViewerServerTab.UpdateTruncationBanner(banner, floor, RequestedStart); + + Assert.Equal(Visibility.Collapsed, banner.Visibility); + }); + } + + [Fact] + public void UpdateTruncationBanner_StaysCollapsed_WhenTheProbeFoundNoFloorAtAll() + { + OnStaThread(() => + { + var banner = new TextBlock(); + + ViewerServerTab.UpdateTruncationBanner(banner, null, RequestedStart); + + Assert.Equal(Visibility.Collapsed, banner.Visibility); + }); + } + + /// WPF objects require STA; same shape as Lite.Tests' MainWindowAccessKeyTests / Dashboard.Tests' + /// DataGridExport tests. + private static void OnStaThread(Action body) + { + Exception? error = null; + var thread = new Thread(() => + { + try { body(); } + catch (Exception ex) { error = ex; } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + + if (error is not null) + { + throw error; + } + } + + private static string ViewerFile(string file) => ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", file); + + [Theory] + [InlineData("ViewerDataService.QueryStats.cs", "RawWindowFloor.Table.QueryStats")] + [InlineData("ViewerDataService.ProcedureStats.cs", "RawWindowFloor.Table.ProcedureStats")] + [InlineData("ViewerDataService.QueryStore.cs", "RawWindowFloor.Table.QueryStoreStats")] + public void EveryQueriesTabGridRead_RoutesItsFloorThroughTheSharedHelper(string file, string table) + { + var source = ViewerFile(file); + Assert.Contains($"RawWindowFloor.GetAsync(_dataSource, {table},", source, StringComparison.Ordinal); + /* Never a second, hand-rolled floor query beside the shared probe — the defect #2364 would have + repeated twice more, and #4231's DarlingDataReader-side pin (RawWindowFloorSharedHelperSourcePinTests) + makes the same check on the MCP reader. */ + Assert.DoesNotContain("SELECT MIN(collection_time)", source, StringComparison.Ordinal); + } + + [Fact] + public void QueriesLoadPath_ReadsEveryGridsFloor_BesideItsRows() + { + var source = ViewerFile("ViewerServerTab.Queries.cs"); + Assert.Contains("_dataService.GetQueryStatsWindowFloorAsync(", source, StringComparison.Ordinal); + Assert.Contains("_dataService.GetProcedureStatsWindowFloorAsync(", source, StringComparison.Ordinal); + Assert.Contains("_dataService.GetQueryStoreWindowFloorAsync(", source, StringComparison.Ordinal); + } + + private static string ServerTabsJs => ReadRepoFileLf( + "Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js", "pages", "server-tabs.js"); + + private static string ViewTemplatesJs => ReadRepoFileLf( + "Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js", "view-templates.js"); + + /// + /// The web viewer's disclosure goes through panels.js's existing #3278 noteKey opt-in + /// (loadPanel: a server-authored string at noteKey renders as a notice strip above the + /// table). Every panel over the three raw-only tools must name "truncation_note" — counted, not + /// merely Contains-checked, so a panel added later without the key fails here instead of shipping + /// quiet: the CPU tab's Top Queries + Top Procedures and the Query Store tab's Top Procedures + Query + /// Store, four table() calls in server-tabs.js, plus the two matching panels in the starter dashboard + /// template (view-templates.js). topQueriesPanel (the Query Store tab's drill-down composite for + /// get_top_queries_by_cpu) calls VIZ.table directly rather than through loadPanel, so it renders the note + /// itself off res.data.truncation_note instead of a noteKey string. + /// + [Fact] + public void WebViewer_CarriesTheTruncationNoteKey_OnEveryRawTopPanel() + { + Assert.Equal(4, CountOf(ServerTabsJs, "\"truncation_note\"")); + Assert.Contains("res.data.truncation_note", ServerTabsJs, StringComparison.Ordinal); + Assert.Equal(2, CountOf(ViewTemplatesJs, "noteKey: \"truncation_note\"")); + } + + private static int CountOf(string haystack, string needle) + { + var count = 0; + var at = 0; + while ((at = haystack.IndexOf(needle, at, StringComparison.Ordinal)) >= 0) + { + count++; + at += needle.Length; + } + return count; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs index c50e415d49..d25c236978 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs @@ -677,8 +677,8 @@ the two answer different questions and the rows look alike. */ sends changes it: the raw tier is where the rows were, and it stops where it stops. */ window_truncated = windowTruncated, truncation_note = windowTruncated - ? "The window reaches further back than this server's raw query_stats retains, so the older " - + "part of it was not read." + ? "The window reaches further back than this server's raw query_stats retains (or this " + + "server has been monitored for less time than that), so the older part of it was not read." : null, queries = result }, McpHelpers.JsonOptions); @@ -779,8 +779,8 @@ same concurrent independent reads. */ /* #4231: the WINDOW floor (#3653 item 17 vocabulary) — never bare `truncated`. */ window_truncated = windowTruncated, truncation_note = windowTruncated - ? "The window reaches further back than this server's raw procedure_stats retains, so the " - + "older part of it was not read." + ? "The window reaches further back than this server's raw procedure_stats retains (or this " + + "server has been monitored for less time than that), so the older part of it was not read." : null, procedures = result }, McpHelpers.JsonOptions); diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js index e3dd8a376f..4f66753070 100644 --- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js @@ -349,6 +349,12 @@ export function topQueriesPanel(server, ctx) { "before it reports non-zero values.", }), ]; + /* #4231: this composite calls VIZ.table directly rather than going through table()/renderPanel, so it + misses loadPanel's automatic noteKey handling (#3278) and has to render `truncation_note` itself — + same field, same meaning, as the plain table() panels below it on this page. */ + if (typeof res.data.truncation_note === "string" && res.data.truncation_note.trim()) { + parts.unshift(noticeStrip(res.data.truncation_note)); + } /* get_query_trend keys on both values, so a row carrying neither cannot be trended and is not offered. That is a real case rather than defensive coding: rows collected before a column existed read as null, @@ -761,6 +767,9 @@ export const SERVER_TABS = [ emptyText: "No CPU samples in this window.", }), stat("Scheduler Pressure", "get_cpu_scheduler_pressure", { server }, SCHEDULER_STATS, SNAPSHOT, 2), + /* #4231: `noteKey` (#3278) carries `truncation_note` — raw query_stats/procedure_stats are dropped at + 4 days once the rollups are armed, and a window asking further back than that silently served less + than it asked for. Null when the floor did not bite, the same as every other noteKey panel. */ table( "Top Queries by CPU", "get_top_queries_by_cpu", @@ -768,7 +777,9 @@ export const SERVER_TABS = [ "queries", TOP_QUERY_COLUMNS, ctx.label, - "No query stats in this window. Delta-based collection needs at least two cycles (~30 minutes) before it reports non-zero values." + "No query stats in this window. Delta-based collection needs at least two cycles (~30 minutes) before it reports non-zero values.", + 2, + "truncation_note" ), table( "Top Procedures by CPU", @@ -777,7 +788,9 @@ export const SERVER_TABS = [ "procedures", TOP_PROC_COLUMNS, ctx.label, - "No procedure stats in this window. Delta-based collection needs at least two cycles (~30 minutes)." + "No procedure stats in this window. Delta-based collection needs at least two cycles (~30 minutes).", + 2, + "truncation_note" ), ], }, @@ -1132,6 +1145,7 @@ export const SERVER_TABS = [ plain table — the drill-down belongs where the second question gets asked, and two of these would mean two fetches of get_top_queries_by_cpu for one page's worth of the same twenty rows. */ topQueriesPanel(server, ctx), + /* #4231: same `truncation_note` disclosure as the CPU tab's plain tables. */ table( "Top Procedures by CPU", "get_top_procedures_by_cpu", @@ -1139,7 +1153,9 @@ export const SERVER_TABS = [ "procedures", TOP_PROC_COLUMNS, ctx.label, - "No procedure stats in this window. Delta-based collection needs at least two cycles (~30 minutes)." + "No procedure stats in this window. Delta-based collection needs at least two cycles (~30 minutes).", + 2, + "truncation_note" ), table( "Query Store", @@ -1148,7 +1164,9 @@ export const SERVER_TABS = [ "queries", QUERY_STORE_COLUMNS, ctx.label, - "No Query Store rows in this window." + "No Query Store rows in this window.", + 2, + "truncation_note" ), /* #2484: the Query Store Regressions tab -- the only tab in the per-server page that was entirely unreachable from a browser rather than merely reduced. Built with table(), not an object literal: diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/view-templates.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/view-templates.js index 2337e1c875..13a679bd8b 100644 --- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/view-templates.js +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/view-templates.js @@ -179,6 +179,10 @@ export const DASHBOARD_TEMPLATES = [ span: 2, rowsKey: "queries", emptyText: "No query stats in this window. Delta-based collection needs at least two cycles (~30 minutes).", + /* #4231: raw query_stats is dropped at 4 days once the rollups are armed; `noteKey` (#3278) + surfaces the read's own `truncation_note` when this panel's 24-hour default (or an edited, + wider one) outran what the raw tier still holds. */ + noteKey: "truncation_note", columns: [ { key: "database_name", label: "Database" }, { key: "query_text", label: "Query", wrap: true }, @@ -197,6 +201,7 @@ export const DASHBOARD_TEMPLATES = [ span: 2, rowsKey: "procedures", emptyText: "No procedure stats in this window. Delta-based collection needs at least two cycles (~30 minutes).", + noteKey: "truncation_note", columns: [ { key: "full_name", label: "Procedure" }, { key: "database_name", label: "Database" }, diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ProcedureStats.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ProcedureStats.cs index f50a5a92c5..ad5378d4fc 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ProcedureStats.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ProcedureStats.cs @@ -11,6 +11,7 @@ using System.Threading; using System.Threading.Tasks; using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Storage; using PerformanceMonitor.Ui; namespace PerformanceMonitor.Darling.Viewer; @@ -191,6 +192,18 @@ public async Task> GetTopProceduresByCpuAsync( return rows; } + /// + /// #4231: the raw floor for procedure_stats over [, + /// ] — the shared probe (), never a second, hand-copied + /// floor query. The Queries tab's LoadTopProceduresAsync reads + /// this beside so the grid header can disclose a window the raw + /// tier no longer fully holds, the same fact get_top_procedures_by_cpu reports over MCP. + /// + public Task GetProcedureStatsWindowFloorAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) => + RawWindowFloor.GetAsync(_dataSource, RawWindowFloor.Table.ProcedureStats, serverId, startUtc, endUtc, + ViewerCommandDeadlines.CurrentInteractiveReadSeconds, cancellationToken); + /// /// Top-Procedures comparison — Lite's GetProcedureStatsComparisonAsync ported. Same /// top-100-union / FULL OUTER JOIN shape as the query-stats comparison, keyed on diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStats.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStats.cs index 2cc7098319..e5a1c3acad 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStats.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStats.cs @@ -12,6 +12,7 @@ using System.Threading.Tasks; using Npgsql; using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Storage; using PerformanceMonitor.Ui; namespace PerformanceMonitor.Darling.Viewer; @@ -387,6 +388,18 @@ public async Task> GetTopQueriesByCpuAsync( return rows; } + /// + /// #4231: the raw floor for query_stats over [, ] + /// — the shared probe (), never a second, hand-copied floor query. The + /// Queries tab's LoadTopQueriesAsync reads this beside + /// so the grid header can disclose a window the raw tier no longer + /// fully holds, the same fact get_top_queries_by_cpu reports over MCP. + /// + public Task GetQueryStatsWindowFloorAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) => + RawWindowFloor.GetAsync(_dataSource, RawWindowFloor.Table.QueryStats, serverId, startUtc, endUtc, + ViewerCommandDeadlines.CurrentInteractiveReadSeconds, cancellationToken); + /// /// Top-Queries comparison — Lite's GetQueryStatsComparisonAsync ported. Unions the top-100 /// (by summed executions) hashes of the current and baseline windows, aggregates each period's diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStore.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStore.cs index f1bc0787f0..5f173fb4c9 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStore.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStore.cs @@ -11,6 +11,7 @@ using System.Threading; using System.Threading.Tasks; using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Storage; using PerformanceMonitor.Ui; namespace PerformanceMonitor.Darling.Viewer; @@ -399,6 +400,20 @@ public async Task> GetQueryStoreTopQueriesAsync( return rows; } + /// + /// #4231: the raw floor for query_store_stats over [, + /// ] — the shared probe (), never a second, hand-copied + /// floor query. The Queries tab's LoadQueryStoreAsync reads this + /// beside so the grid header can disclose a window the raw tier + /// no longer fully holds, the same fact get_query_store_top reports over MCP (#2364). The same + /// window backs 's initial (unsliced) read, so one floor read + /// covers both rather than a second round trip for the identical [start, end]. + /// + public Task GetQueryStoreWindowFloorAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) => + RawWindowFloor.GetAsync(_dataSource, RawWindowFloor.Table.QueryStoreStats, serverId, startUtc, endUtc, + ViewerCommandDeadlines.CurrentInteractiveReadSeconds, cancellationToken); + /// /// Query-Store comparison — Lite's GetQueryStoreComparisonAsync ported. Uses execution-count /// weighted averages (SUM(execution_count * avg_metric) / SUM(execution_count)) so periods with diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs index d97b86cf98..414e65fd2f 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs @@ -10,8 +10,10 @@ using System.Collections.Generic; using System.ComponentModel; using System.Threading.Tasks; +using System.Windows; using System.Windows.Controls; using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Storage; namespace PerformanceMonitor.Darling.Viewer; @@ -171,31 +173,58 @@ its last snapshot / hint until then. */ private async Task LoadTopQueriesAsync(DateTime startUtc, DateTime endUtc) { + var floorTask = _dataService.GetQueryStatsWindowFloorAsync(_server.ServerId, startUtc, endUtc); var rows = await _dataService.GetTopQueriesByCpuAsync(_server.ServerId, startUtc, endUtc, databaseNames: SelectedDatabaseFilter); _queryStatsFilterMgr!.UpdateData(rows); SetDefaultSortIfNone(QueryStatsGrid, "TotalElapsedMs", ListSortDirection.Descending); + UpdateTruncationBanner(QueryStatsTruncationBanner, await floorTask, startUtc); await LoadQueryStatsSlicerAsync(startUtc, endUtc); await RefreshQueryStatsComparisonAsync(startUtc, endUtc); } private async Task LoadTopProceduresAsync(DateTime startUtc, DateTime endUtc) { + var floorTask = _dataService.GetProcedureStatsWindowFloorAsync(_server.ServerId, startUtc, endUtc); var rows = await _dataService.GetTopProceduresByCpuAsync(_server.ServerId, startUtc, endUtc, databaseNames: SelectedDatabaseFilter); _procStatsFilterMgr!.UpdateData(rows); SetDefaultSortIfNone(ProcedureStatsGrid, "TotalElapsedMs", ListSortDirection.Descending); + UpdateTruncationBanner(ProcStatsTruncationBanner, await floorTask, startUtc); await LoadProcStatsSlicerAsync(startUtc, endUtc); await RefreshProcStatsComparisonAsync(startUtc, endUtc); } private async Task LoadQueryStoreAsync(DateTime startUtc, DateTime endUtc) { + var floorTask = _dataService.GetQueryStoreWindowFloorAsync(_server.ServerId, startUtc, endUtc); var rows = await _dataService.GetQueryStoreTopQueriesAsync(_server.ServerId, startUtc, endUtc, databaseNames: SelectedDatabaseFilter); _queryStoreFilterMgr!.UpdateData(rows); SetDefaultSortIfNone(QueryStoreGrid, "TotalDurationMs", ListSortDirection.Descending); + UpdateTruncationBanner(QueryStoreTruncationBanner, await floorTask, startUtc); await LoadQueryStoreSlicerAsync(startUtc, endUtc); await RefreshQueryStoreComparisonAsync(startUtc, endUtc); } + /// + /// #4231: the Queries-tab grid header's disclosure — "Showing since <effective start>" when + /// sits past 's slack after + /// , collapsed otherwise. The chart-title idiom + /// () states the same fact on Performance Trends; this is its grid-header + /// form, in 's own yyyy-MM-dd HH:mm, the format every trend + /// chart title already uses for a head timestamp. + /// + internal static void UpdateTruncationBanner(TextBlock banner, DateTime? floor, DateTime requestedStartUtc) + { + if (!RawWindowFloor.IsTruncated(floor, requestedStartUtc)) + { + banner.Visibility = Visibility.Collapsed; + return; + } + + var effectiveStart = ViewerTimeHelper.ForDisplay(RawWindowFloor.EffectiveStart(floor, requestedStartUtc)); + banner.Text = $"Showing since {effectiveStart:yyyy-MM-dd HH:mm}"; + banner.Visibility = Visibility.Visible; + } + /// /// Loads the Query Store Regressions grid — the Dashboard's regressions view (baseline-vs-recent Query /// Store performance) ported to Darling's store, over the toolbar's settable window. Like the Dashboard @@ -240,8 +269,10 @@ private async void OnQueryStatsSlicerChanged(object? sender, SlicerRangeEventArg { try { + var floorTask = _dataService.GetQueryStatsWindowFloorAsync(_server.ServerId, e.StartUtc, e.EndUtc); var rows = await _dataService.GetTopQueriesByCpuAsync(_server.ServerId, e.StartUtc, e.EndUtc, databaseNames: SelectedDatabaseFilter); _queryStatsFilterMgr!.UpdateData(rows); + UpdateTruncationBanner(QueryStatsTruncationBanner, await floorTask, e.StartUtc); await RefreshQueryStatsComparisonAsync(e.StartUtc, e.EndUtc); } catch (Exception ex) @@ -263,8 +294,10 @@ private async void OnProcStatsSlicerChanged(object? sender, SlicerRangeEventArgs { try { + var floorTask = _dataService.GetProcedureStatsWindowFloorAsync(_server.ServerId, e.StartUtc, e.EndUtc); var rows = await _dataService.GetTopProceduresByCpuAsync(_server.ServerId, e.StartUtc, e.EndUtc, databaseNames: SelectedDatabaseFilter); _procStatsFilterMgr!.UpdateData(rows); + UpdateTruncationBanner(ProcStatsTruncationBanner, await floorTask, e.StartUtc); await RefreshProcStatsComparisonAsync(e.StartUtc, e.EndUtc); } catch (Exception ex) @@ -286,8 +319,10 @@ private async void OnQueryStoreSlicerChanged(object? sender, SlicerRangeEventArg { try { + var floorTask = _dataService.GetQueryStoreWindowFloorAsync(_server.ServerId, e.StartUtc, e.EndUtc); var rows = await _dataService.GetQueryStoreTopQueriesAsync(_server.ServerId, e.StartUtc, e.EndUtc, databaseNames: SelectedDatabaseFilter); _queryStoreFilterMgr!.UpdateData(rows); + UpdateTruncationBanner(QueryStoreTruncationBanner, await floorTask, e.StartUtc); await RefreshQueryStoreComparisonAsync(e.StartUtc, e.EndUtc); } catch (Exception ex) diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml index debd62d7d8..a14f9c8217 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml @@ -894,6 +894,12 @@ + + - + + + + + - + + + + + Date: Fri, 25 Sep 2026 08:47:09 -0400 Subject: [PATCH 3/4] Checkpoint: LF-read adoption for the #4231 viewer pin, top-by-CPU description heads (#4231) Q1a2 stopped at its turn limit before re-running the 5 suite failures; unverified. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- Darling/Darling.Tests/RepoFileAdoptionTests.cs | 3 +++ .../Mcp/DarlingMcpDataTools.cs | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Darling/Darling.Tests/RepoFileAdoptionTests.cs b/Darling/Darling.Tests/RepoFileAdoptionTests.cs index 0abf146c3b..b4a5398267 100644 --- a/Darling/Darling.Tests/RepoFileAdoptionTests.cs +++ b/Darling/Darling.Tests/RepoFileAdoptionTests.cs @@ -165,6 +165,9 @@ anchors several lines apart (the Queries group's sub-tab block, a column array, column list), and its dispatch pin anchors `case QueryStoreClutterSubTabIndex:` on the awaited loader call on the NEXT line — anchors that a CRLF read would miss on every arm. */ "QueryStoreClutterViewerSurfacesTests.cs", + /* #4231: its web source pin counts noteKey: "truncation_note" occurrences across server-tabs.js and + view-templates.js, which an LF-normalised read makes exact instead of CRLF-fragile. */ + "RawWindowFloorViewerPortTests.cs", /* #3653 item 3: its twin pin extracts the multi-line GetPriorOccurrencesSql const body from BOTH finding stores' sources and asserts them equal up to one token, and its wiring pin slices each pass method by call sites that sit on their own lines — anchors spanning line breaks on every arm. */ diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs index d25c236978..f09b678b30 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs @@ -512,7 +512,7 @@ public static async Task GetPerfmonStats( /* ═══════════════════════════ query performance ═══════════════════════════ */ - [McpServerTool(Name = "get_top_queries_by_cpu"), Description("Gets expensive cached queries from sys.dm_exec_query_stats, ranked by CPU over a window ending at as_of. Filters (database_name, parallel_only, min_dop) apply before the top-N cap: filter_applied names the floor in force, and an empty page under it is the window's real answer, not a miss. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes, not windowed; cpu_attribution's ratio is omitted, not invented, when its inputs are missing. Darling: window_truncated marks a window floor, not a page cut — raw query_stats is dropped at 4 days when the rollups are armed; effective_start / effective_hours_back give the reach actually served. Lite: no such floor yet; the full requested window is always read. <> Gets expensive queries from sys.dm_exec_query_stats (plan cache). Best for: currently cached queries with detailed per-execution stats, DOP, spills, and query_hash for trending. Returns query_hash, query_plan_hash, sql_handle, plan_handle, and host_object (the hosting procedure/function for proc-hosted statements, null for ad-hoc) — groups key on (database, query_hash, host_object), so INSERT...EXEC callers in different procedures report separately with their own text. distinct_texts counts statement texts merged into a group (>1 = ad-hoc literal variants or pre-upgrade history; query_text is one representative, 0 means only rows predating the text dimension). 'host_object' rolls all of a procedure's statements into one row — use it when dynamic SQL with per-value literals fragments one statement across many hashes, which no top-N-by-hash ranking can surface. Ad-hoc statements have no host object and stay grouped per hash in both modes. distinct_query_hashes reports how many hashes a row rolled up. Set group_by='host_object' to roll all of a procedure's statements into one row — necessary when dynamic SQL with per-value literals fragments one statement across many hashes, which no top-N-by-hash ranking can surface. Supports database and parallelism filtering; every filter is applied IN the query before the ranking and the cap, so the page is the top-N of the FILTERED population (filter_applied names the parallelism floor in force, null when none), and an empty page under parallel_only/min_dop is the window's answer rather than a page artefact. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes for the plan's time in cache (same semantics as max_dop), not windowed — totals and avgs are windowed deltas; rows where an extreme provably predates the window carry extremes_note. max_dop comes from sys.dm_exec_query_stats and is a lifetime-max for the plan's time in cache, so a plan compiled before MAXDOP was lowered keeps reporting the old higher value until it is evicted or recompiled; confirm current parallelism with analyze_query_plan, which reads the actual plan. " + McpToolGuideTopics.CpuTimeExtremesAndAttribution + McpHelpers.WindowTruncatedDescription)] + [McpServerTool(Name = "get_top_queries_by_cpu"), Description("Gets expensive cached queries from sys.dm_exec_query_stats, ranked by CPU over a window ending at as_of. Filters (database_name, parallel_only, min_dop) apply before the top-N cap: filter_applied names the floor in force, and an empty page under it is the window's real answer, not a miss. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes, not windowed; cpu_attribution's ratio is omitted, not invented, when its inputs are missing. <> Gets expensive queries from sys.dm_exec_query_stats (plan cache). Best for: currently cached queries with detailed per-execution stats, DOP, spills, and query_hash for trending. Returns query_hash, query_plan_hash, sql_handle, plan_handle, and host_object (the hosting procedure/function for proc-hosted statements, null for ad-hoc) — groups key on (database, query_hash, host_object), so INSERT...EXEC callers in different procedures report separately with their own text. distinct_texts counts statement texts merged into a group (>1 = ad-hoc literal variants or pre-upgrade history; query_text is one representative, 0 means only rows predating the text dimension). 'host_object' rolls all of a procedure's statements into one row — use it when dynamic SQL with per-value literals fragments one statement across many hashes, which no top-N-by-hash ranking can surface. Ad-hoc statements have no host object and stay grouped per hash in both modes. distinct_query_hashes reports how many hashes a row rolled up. Set group_by='host_object' to roll all of a procedure's statements into one row — necessary when dynamic SQL with per-value literals fragments one statement across many hashes, which no top-N-by-hash ranking can surface. Supports database and parallelism filtering; every filter is applied IN the query before the ranking and the cap, so the page is the top-N of the FILTERED population (filter_applied names the parallelism floor in force, null when none), and an empty page under parallel_only/min_dop is the window's answer rather than a page artefact. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes for the plan's time in cache (same semantics as max_dop), not windowed — totals and avgs are windowed deltas; rows where an extreme provably predates the window carry extremes_note. max_dop comes from sys.dm_exec_query_stats and is a lifetime-max for the plan's time in cache, so a plan compiled before MAXDOP was lowered keeps reporting the old higher value until it is evicted or recompiled; confirm current parallelism with analyze_query_plan, which reads the actual plan. " + McpToolGuideTopics.CpuTimeExtremesAndAttribution + McpHelpers.WindowTruncatedDescription)] public static async Task GetTopQueriesByCpu( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, @@ -689,7 +689,7 @@ the two answer different questions and the rows look alike. */ } } - [McpServerTool(Name = "get_top_procedures_by_cpu"), Description("Gets the most expensive stored procedures ranked by total CPU time over a window ending at as_of. Delta-based: requires ~30 minutes after adding a new server before data appears. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes, not windowed (extremes_note flags a provably stale one); cpu_attribution's ratio is omitted, not invented, when its inputs are missing. Darling: window_truncated marks a window floor, not a page cut — raw procedure_stats is dropped at 4 days when the rollups are armed; effective_start / effective_hours_back give the reach actually served. Lite: no such floor yet; the full requested window is always read. <> Shows execution counts, CPU/elapsed times, and I/O metrics. Delta-based: requires ~30 minutes after adding a new server before data appears. " + McpToolGuideTopics.CpuTimeExtremesAndAttribution + McpHelpers.WindowTruncatedDescription)] + [McpServerTool(Name = "get_top_procedures_by_cpu"), Description("Gets the most expensive stored procedures ranked by total CPU time over a window ending at as_of. Delta-based: requires ~30 minutes after adding a new server before data appears. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes, not windowed (extremes_note flags a provably stale one); cpu_attribution's ratio is omitted, not invented, when its inputs are missing. <> Shows execution counts, CPU/elapsed times, and I/O metrics. Delta-based: requires ~30 minutes after adding a new server before data appears. " + McpToolGuideTopics.CpuTimeExtremesAndAttribution + McpHelpers.WindowTruncatedDescription)] public static async Task GetTopProceduresByCpu( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, From 3392e502e97e73d12e7786a37415b781425529b2 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:07:49 -0400 Subject: [PATCH 4/4] #4231: fix the CpuExtremes tail order, bank the shrunk heads, scope the twin-pin census check Three fixes to the checkpoint (fdda3d00) that shrank get_top_queries_by_cpu's and get_top_procedures_by_cpu's served heads back to dev's byte-identical values, and the one census assert that shrink left failing: - DarlingMcpDataTools.cs: swap the tail concatenation order on both tools to McpHelpers.WindowTruncatedDescription + McpToolGuideTopics.CpuTimeExtremesAndAttribution, so the tail ends with the shared CPU-extremes topic again (McpToolGuideHeadsDataTests.CpuTimeExtremesTopic_RidesOnBothTopByCpuTools). - DarlingMcpDataTools.txt: bank the two tool lines to the now-measured 406/474 bytes (down from the stale 678/742 sized for the removed head sentence). - McpToolsListBudgetTests.cs: rewrite the #4231 change-log comments; they claimed +550 head bytes that no longer exist now the head matches dev. - McpPayloadContractCensusTests.cs: EveryWindowFloorTool_CarriesTheSharedClause_AndNoOtherToolDoes required window_truncated / "not a page cut" in these two tools' HEAD, which is what the checkpoint's shrink had removed to satisfy D6's twin pin (McpToolGuideTests.EverySharedToolName_CarriesTheMarkerOnBothSkus_OrNeither_WithByteIdenticalHeads, shared with Lite's #4279). The two requirements are unsatisfiable together for a tool pair split across two PRs targeting the same tree: a Darling-only head sentence fails the twin pin regardless of merge order, so D6 wins. Scoped the census check to read both facts from the tail for just these two tools, with the reason and a note that a follow-up can move one identical sentence into both SKUs' heads once #4279 merges. Targeted classes (151 total, 0 failed): RepoFileAdoptionTests, McpToolGuideHeadsDataTests, McpToolGuideTests, McpPayloadContractCensusTests, McpToolsListBudgetTests, QueryStoreTopWindowTests, QueryStoreClutterTests, RawWindowFloorSharedHelperSourcePinTests. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- .../McpPayloadContractCensusTests.cs | 19 +++++++++++++++++-- .../DarlingMcpDataTools.txt | 4 ++-- .../Darling.Tests/McpToolsListBudgetTests.cs | 15 +++++++++------ .../Mcp/DarlingMcpDataTools.cs | 4 ++-- 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs index c91e4efa4e..b2a75e33fe 100644 --- a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs +++ b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs @@ -1994,8 +1994,23 @@ a caller needs to read the payload (the key, and that it is a retention floor, n is a guardrail and must be served in the head. Unconverted, the head is the whole description. */ Assert.Contains(clause, description!, StringComparison.Ordinal); var head = McpToolGuide.Split(description!).Head; - Assert.Contains(WindowFloorKey, head, StringComparison.Ordinal); - Assert.Contains("not a page cut", head, StringComparison.Ordinal); + if (tool is "get_top_queries_by_cpu" or "get_top_procedures_by_cpu") + { + /* #4231: these two are shared with Lite, and D6's twin pin + (McpToolGuideTests.EverySharedToolName_CarriesTheMarkerOnBothSkus_OrNeither_WithByteIdenticalHeads) + requires their served head stay byte-identical to Lite's until Lite's own #4279 ships the + same disclosure — a Darling-only head guardrail would fail that pin regardless of merge + order. Both facts still ride the wire today, in the tail's WindowTruncatedDescription + clause, so read them from the full description here rather than the head. A follow-up PR + can move one identical sentence into both SKUs' heads once #4279 merges. */ + Assert.Contains(WindowFloorKey, description!, StringComparison.Ordinal); + Assert.Contains("not a page cut", description!, StringComparison.Ordinal); + } + else + { + Assert.Contains(WindowFloorKey, head, StringComparison.Ordinal); + Assert.Contains("not a page cut", head, StringComparison.Ordinal); + } Assert.DoesNotContain("read truncated", description!, StringComparison.Ordinal); } } diff --git a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDataTools.txt b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDataTools.txt index 22f030bd41..0c722c76bb 100644 --- a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDataTools.txt +++ b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDataTools.txt @@ -61,14 +61,14 @@ param get_tempdb_trend.bucket_minutes 55 param get_tempdb_trend.hours_back 29 param get_tempdb_trend.server_name 28 -tool get_top_procedures_by_cpu 678 +tool get_top_procedures_by_cpu 406 param get_top_procedures_by_cpu.as_of 167 param get_top_procedures_by_cpu.database_name 30 param get_top_procedures_by_cpu.hours_back 29 param get_top_procedures_by_cpu.server_name 28 param get_top_procedures_by_cpu.top 37 -tool get_top_queries_by_cpu 742 +tool get_top_queries_by_cpu 474 param get_top_queries_by_cpu.as_of 167 param get_top_queries_by_cpu.database_name 30 param get_top_queries_by_cpu.group_by 167 diff --git a/Darling/Darling.Tests/McpToolsListBudgetTests.cs b/Darling/Darling.Tests/McpToolsListBudgetTests.cs index 78923434a6..05cb2130d7 100644 --- a/Darling/Darling.Tests/McpToolsListBudgetTests.cs +++ b/Darling/Darling.Tests/McpToolsListBudgetTests.cs @@ -128,17 +128,20 @@ so neither counts here. */ of active_queries (#4261) + object_locking (#4258) changes on top of dev. */ /* #4198 (collection_log, merge): re-measured after merging origin/dev (dev now includes #4261+#4258); combined total with collection_log (#4265) changes on top. */ - /* #4231: +550 bytes for get_top_queries_by_cpu's and get_top_procedures_by_cpu's new window_truncated / - effective_start / effective_hours_back disclosure — a short served-head sentence naming the raw tier's - retention floor (272 / 268 bytes), matching get_query_store_top's own #2364 disclosure. The full clause - (McpHelpers.WindowTruncatedDescription) lands in each tool's get_tool_guide tail, which this ceiling does - not count. */ + /* #4231: get_top_queries_by_cpu and get_top_procedures_by_cpu gained window_truncated / effective_start / + effective_hours_back, but unlike get_query_store_top's #2364 disclosure, the served head is unchanged: + these two are shared with Lite, and McpToolGuideTests's twin pin + (EverySharedToolName_CarriesTheMarkerOnBothSkus_OrNeither_WithByteIdenticalHeads) requires their served + heads stay byte-identical to Lite's until Lite's own #4279 ships the same disclosure — a Darling-only + head sentence would fail that pin on this side regardless of merge order. The full clause + (McpHelpers.WindowTruncatedDescription) lands in each tool's get_tool_guide tail instead, which this + ceiling does not count. Net zero bytes here; the per-tool lines below are unchanged from dev. */ /* #4198 (blocking, merge): re-measured after merging origin/dev (dev now includes #4261+#4258+#4265); combined total with blocking (#4267) changes on top. */ /* #4198 (qs-regressions, merge): re-measured after merging origin/dev (dev now includes #4261+#4258+#4265+#4267); combined total with qs-regressions (#4264) changes on top. */ /* #4231 (merge): re-measured after merging origin/dev (dev now includes #4267+#4264 on top of the base - #4231 branched from); combined total with #4231's own +550 on top. */ + #4231 branched from); combined total unchanged from the merge base since #4231 added no head bytes. */ private const int TotalCeilingBytes = 173_707; private const int ConvertedHeadCap = 1_000; diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs index f09b678b30..713379a93b 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs @@ -512,7 +512,7 @@ public static async Task GetPerfmonStats( /* ═══════════════════════════ query performance ═══════════════════════════ */ - [McpServerTool(Name = "get_top_queries_by_cpu"), Description("Gets expensive cached queries from sys.dm_exec_query_stats, ranked by CPU over a window ending at as_of. Filters (database_name, parallel_only, min_dop) apply before the top-N cap: filter_applied names the floor in force, and an empty page under it is the window's real answer, not a miss. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes, not windowed; cpu_attribution's ratio is omitted, not invented, when its inputs are missing. <> Gets expensive queries from sys.dm_exec_query_stats (plan cache). Best for: currently cached queries with detailed per-execution stats, DOP, spills, and query_hash for trending. Returns query_hash, query_plan_hash, sql_handle, plan_handle, and host_object (the hosting procedure/function for proc-hosted statements, null for ad-hoc) — groups key on (database, query_hash, host_object), so INSERT...EXEC callers in different procedures report separately with their own text. distinct_texts counts statement texts merged into a group (>1 = ad-hoc literal variants or pre-upgrade history; query_text is one representative, 0 means only rows predating the text dimension). 'host_object' rolls all of a procedure's statements into one row — use it when dynamic SQL with per-value literals fragments one statement across many hashes, which no top-N-by-hash ranking can surface. Ad-hoc statements have no host object and stay grouped per hash in both modes. distinct_query_hashes reports how many hashes a row rolled up. Set group_by='host_object' to roll all of a procedure's statements into one row — necessary when dynamic SQL with per-value literals fragments one statement across many hashes, which no top-N-by-hash ranking can surface. Supports database and parallelism filtering; every filter is applied IN the query before the ranking and the cap, so the page is the top-N of the FILTERED population (filter_applied names the parallelism floor in force, null when none), and an empty page under parallel_only/min_dop is the window's answer rather than a page artefact. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes for the plan's time in cache (same semantics as max_dop), not windowed — totals and avgs are windowed deltas; rows where an extreme provably predates the window carry extremes_note. max_dop comes from sys.dm_exec_query_stats and is a lifetime-max for the plan's time in cache, so a plan compiled before MAXDOP was lowered keeps reporting the old higher value until it is evicted or recompiled; confirm current parallelism with analyze_query_plan, which reads the actual plan. " + McpToolGuideTopics.CpuTimeExtremesAndAttribution + McpHelpers.WindowTruncatedDescription)] + [McpServerTool(Name = "get_top_queries_by_cpu"), Description("Gets expensive cached queries from sys.dm_exec_query_stats, ranked by CPU over a window ending at as_of. Filters (database_name, parallel_only, min_dop) apply before the top-N cap: filter_applied names the floor in force, and an empty page under it is the window's real answer, not a miss. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes, not windowed; cpu_attribution's ratio is omitted, not invented, when its inputs are missing. <> Gets expensive queries from sys.dm_exec_query_stats (plan cache). Best for: currently cached queries with detailed per-execution stats, DOP, spills, and query_hash for trending. Returns query_hash, query_plan_hash, sql_handle, plan_handle, and host_object (the hosting procedure/function for proc-hosted statements, null for ad-hoc) — groups key on (database, query_hash, host_object), so INSERT...EXEC callers in different procedures report separately with their own text. distinct_texts counts statement texts merged into a group (>1 = ad-hoc literal variants or pre-upgrade history; query_text is one representative, 0 means only rows predating the text dimension). 'host_object' rolls all of a procedure's statements into one row — use it when dynamic SQL with per-value literals fragments one statement across many hashes, which no top-N-by-hash ranking can surface. Ad-hoc statements have no host object and stay grouped per hash in both modes. distinct_query_hashes reports how many hashes a row rolled up. Set group_by='host_object' to roll all of a procedure's statements into one row — necessary when dynamic SQL with per-value literals fragments one statement across many hashes, which no top-N-by-hash ranking can surface. Supports database and parallelism filtering; every filter is applied IN the query before the ranking and the cap, so the page is the top-N of the FILTERED population (filter_applied names the parallelism floor in force, null when none), and an empty page under parallel_only/min_dop is the window's answer rather than a page artefact. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes for the plan's time in cache (same semantics as max_dop), not windowed — totals and avgs are windowed deltas; rows where an extreme provably predates the window carry extremes_note. max_dop comes from sys.dm_exec_query_stats and is a lifetime-max for the plan's time in cache, so a plan compiled before MAXDOP was lowered keeps reporting the old higher value until it is evicted or recompiled; confirm current parallelism with analyze_query_plan, which reads the actual plan. " + McpHelpers.WindowTruncatedDescription + McpToolGuideTopics.CpuTimeExtremesAndAttribution)] public static async Task GetTopQueriesByCpu( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, @@ -689,7 +689,7 @@ the two answer different questions and the rows look alike. */ } } - [McpServerTool(Name = "get_top_procedures_by_cpu"), Description("Gets the most expensive stored procedures ranked by total CPU time over a window ending at as_of. Delta-based: requires ~30 minutes after adding a new server before data appears. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes, not windowed (extremes_note flags a provably stale one); cpu_attribution's ratio is omitted, not invented, when its inputs are missing. <> Shows execution counts, CPU/elapsed times, and I/O metrics. Delta-based: requires ~30 minutes after adding a new server before data appears. " + McpToolGuideTopics.CpuTimeExtremesAndAttribution + McpHelpers.WindowTruncatedDescription)] + [McpServerTool(Name = "get_top_procedures_by_cpu"), Description("Gets the most expensive stored procedures ranked by total CPU time over a window ending at as_of. Delta-based: requires ~30 minutes after adding a new server before data appears. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes, not windowed (extremes_note flags a provably stale one); cpu_attribution's ratio is omitted, not invented, when its inputs are missing. <> Shows execution counts, CPU/elapsed times, and I/O metrics. Delta-based: requires ~30 minutes after adding a new server before data appears. " + McpHelpers.WindowTruncatedDescription + McpToolGuideTopics.CpuTimeExtremesAndAttribution)] public static async Task GetTopProceduresByCpu( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null,