From 772b0736064a167bc49ef03c876c92298d22b631 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 07:58:26 -0400 Subject: [PATCH 1/8] Lite: window-floor disclosure for get_top_queries_by_cpu, get_top_procedures_by_cpu, get_query_store_top (#4231) query_stats, procedure_stats and query_store_stats are raw-only in Lite (no rollup fallback), and the default 30-day retention_days is per-collector and user-settable, so a window can silently serve less than asked for. Adds one shared floor probe (LocalDataService.GetQueryWindowFloorAsync) reading the same v_ view (hot table UNION archived parquet) each grid/tool reads, and wires effective_start / effective_hours_back / window_truncated into the three MCP tools that read those tables, using McpQueryTools.TruncationSlack (already defined for the trend tools) as the threshold. Part 1 of #4231's Lite half (helper + MCP tools + tests). Grids (Lite/Controls/ServerTab.*) are next. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- Lite.Tests/QueryWindowTruncationTests.cs | 292 ++++++++++++++++++ Lite/Mcp/McpQueryTools.cs | 87 +++++- .../LocalDataService.QueryWindowFloor.cs | 80 +++++ PerformanceMonitor.Common/Mcp/McpHelpers.cs | 5 +- 4 files changed, 454 insertions(+), 10 deletions(-) create mode 100644 Lite.Tests/QueryWindowTruncationTests.cs create mode 100644 Lite/Services/LocalDataService.QueryWindowFloor.cs diff --git a/Lite.Tests/QueryWindowTruncationTests.cs b/Lite.Tests/QueryWindowTruncationTests.cs new file mode 100644 index 000000000..7c32e97b9 --- /dev/null +++ b/Lite.Tests/QueryWindowTruncationTests.cs @@ -0,0 +1,292 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Diagnostics; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using PerformanceMonitorLite.Database; +using PerformanceMonitorLite.Mcp; +using PerformanceMonitorLite.Models; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// +/// #4231: Lite's twin of Darling's #2364. query_stats, procedure_stats and +/// query_store_stats are raw-only (no rollup fallback), and Lite's default 30-day +/// retention_days is per-collector and user-settable — lower it, or run a young install, and a +/// "Last 7 days" ask can be served from far less. Pins the shared floor helper +/// () and the three MCP tools' disclosure. +/// Own per test (not SharedDuckDbFixture) because the archive +/// tests need control of the database's archive directory, to COPY hot rows out to parquet exactly like +/// ArchiveViewDedupTests does. +/// +public sealed class QueryWindowTruncationTests : IDisposable +{ + private readonly int ServerId; + private readonly string _tempDir; + private readonly string _archivePath; + private readonly DuckDbInitializer _duckDb; + private readonly ServerManager _serverManager; + private long _nextId = 1; + + public QueryWindowTruncationTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "QueryWindowTrunc_" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(Path.Combine(_tempDir, "config")); + _archivePath = Path.Combine(_tempDir, "archive"); + Directory.CreateDirectory(_archivePath); + _duckDb = new DuckDbInitializer(Path.Combine(_tempDir, "test.duckdb")); + + _serverManager = new ServerManager(Path.Combine(_tempDir, "config")); + var server = new ServerConnection { ServerName = "TestServer", DisplayName = "TestServer" }; + _serverManager.AddServer(server); + ServerId = RemoteCollectorService.GetDeterministicHashCode( + RemoteCollectorService.GetServerNameForStorage(server)); + } + + public void Dispose() + { + try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); } + catch { /* best-effort cleanup */ } + } + + private async Task OpenSeedConnectionAsync() + { + var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + return connection; + } + + private async Task SeedQueryStatsAsync(DuckDBConnection connection, DateTime collected, string queryHash) + { + using var readLock = _duckDb.AcquireReadLock(); + 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)"; + cmd.Parameters.Add(new DuckDBParameter { Value = _nextId++ }); + cmd.Parameters.Add(new DuckDBParameter { Value = collected }); + cmd.Parameters.Add(new DuckDBParameter { Value = ServerId }); + cmd.Parameters.Add(new DuckDBParameter { Value = "TestServer" }); + cmd.Parameters.Add(new DuckDBParameter { Value = "TestDb" }); + cmd.Parameters.Add(new DuckDBParameter { Value = queryHash }); + cmd.Parameters.Add(new DuckDBParameter { Value = "0xH" + queryHash }); + cmd.Parameters.Add(new DuckDBParameter { Value = collected }); + cmd.Parameters.Add(new DuckDBParameter { Value = 10L }); + cmd.Parameters.Add(new DuckDBParameter { Value = 5_000L }); + cmd.Parameters.Add(new DuckDBParameter { Value = 5_000L }); + cmd.Parameters.Add(new DuckDBParameter { Value = "SELECT " + queryHash }); + await cmd.ExecuteNonQueryAsync(); + } + + private async Task SeedProcedureStatsAsync(DuckDBConnection connection, DateTime collected, string objectName) + { + using var readLock = _duckDb.AcquireReadLock(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +INSERT INTO procedure_stats + (collection_id, collection_time, server_id, server_name, database_name, + schema_name, object_name, object_type, last_execution_time, + delta_execution_count, delta_worker_time, delta_elapsed_time) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)"; + cmd.Parameters.Add(new DuckDBParameter { Value = _nextId++ }); + cmd.Parameters.Add(new DuckDBParameter { Value = collected }); + cmd.Parameters.Add(new DuckDBParameter { Value = ServerId }); + cmd.Parameters.Add(new DuckDBParameter { Value = "TestServer" }); + cmd.Parameters.Add(new DuckDBParameter { Value = "TestDb" }); + cmd.Parameters.Add(new DuckDBParameter { Value = "dbo" }); + cmd.Parameters.Add(new DuckDBParameter { Value = objectName }); + cmd.Parameters.Add(new DuckDBParameter { Value = "SQL_STORED_PROCEDURE" }); + cmd.Parameters.Add(new DuckDBParameter { Value = collected }); + cmd.Parameters.Add(new DuckDBParameter { Value = 10L }); + cmd.Parameters.Add(new DuckDBParameter { Value = 5_000L }); + cmd.Parameters.Add(new DuckDBParameter { Value = 5_000L }); + await cmd.ExecuteNonQueryAsync(); + } + + private async Task SeedQueryStoreStatsAsync(DuckDBConnection connection, DateTime collected, long queryId) + { + using var readLock = _duckDb.AcquireReadLock(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +INSERT INTO query_store_stats + (collection_id, collection_time, server_id, server_name, database_name, + query_id, plan_id, execution_type_desc, first_execution_time, last_execution_time, + module_name, query_text, query_hash, execution_count, avg_cpu_time_us, avg_duration_us, + avg_logical_io_reads, avg_logical_io_writes, avg_physical_io_reads, + query_plan_hash, is_forced_plan, force_failure_count, + runtime_stats_interval_id, interval_start_time_utc) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)"; + cmd.Parameters.Add(new DuckDBParameter { Value = _nextId++ }); + cmd.Parameters.Add(new DuckDBParameter { Value = collected }); + cmd.Parameters.Add(new DuckDBParameter { Value = ServerId }); + cmd.Parameters.Add(new DuckDBParameter { Value = "TestServer" }); + cmd.Parameters.Add(new DuckDBParameter { Value = "TestDb" }); + cmd.Parameters.Add(new DuckDBParameter { Value = queryId }); + cmd.Parameters.Add(new DuckDBParameter { Value = queryId * 10 }); + cmd.Parameters.Add(new DuckDBParameter { Value = "Regular" }); + cmd.Parameters.Add(new DuckDBParameter { Value = collected }); + cmd.Parameters.Add(new DuckDBParameter { Value = collected }); + cmd.Parameters.Add(new DuckDBParameter { Value = "Adhoc" }); + cmd.Parameters.Add(new DuckDBParameter { Value = "SELECT " + queryId }); + cmd.Parameters.Add(new DuckDBParameter { Value = "0xQ" + queryId }); + cmd.Parameters.Add(new DuckDBParameter { Value = 10L }); + cmd.Parameters.Add(new DuckDBParameter { Value = 5_000L }); + cmd.Parameters.Add(new DuckDBParameter { Value = 5_000L }); + cmd.Parameters.Add(new DuckDBParameter { Value = 10L }); + cmd.Parameters.Add(new DuckDBParameter { Value = 0L }); + cmd.Parameters.Add(new DuckDBParameter { Value = 0L }); + cmd.Parameters.Add(new DuckDBParameter { Value = "0xP" + queryId }); + cmd.Parameters.Add(new DuckDBParameter { Value = false }); + cmd.Parameters.Add(new DuckDBParameter { Value = 0L }); + cmd.Parameters.Add(new DuckDBParameter { Value = (object?)DBNull.Value }); + cmd.Parameters.Add(new DuckDBParameter { Value = (object?)DBNull.Value }); + await cmd.ExecuteNonQueryAsync(); + } + + [Fact] + public async Task GetTopQueriesByCpu_ReportsTruncation_WhenRawStartsAfterTheWindow() + { + await _duckDb.InitializeAsync(); + var collected = DateTime.SpecifyKind(DateTime.UtcNow.AddDays(-2), DateTimeKind.Unspecified); + using (var connection = await OpenSeedConnectionAsync()) + await SeedQueryStatsAsync(connection, collected, "0xTRUNC"); + + var json = await McpQueryTools.GetTopQueriesByCpu(new LocalDataService(_duckDb), _serverManager, "TestServer", hours_back: 168); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.True(root.GetProperty("window_truncated").GetBoolean()); + var effectiveStart = DateTime.Parse(root.GetProperty("effective_start").GetString()!).ToUniversalTime(); + Assert.True(Math.Abs((effectiveStart - collected.ToUniversalTime()).TotalMinutes) < 2, + $"effective_start {effectiveStart:o} should track the seeded floor {collected:o}"); + Assert.InRange(root.GetProperty("effective_hours_back").GetDouble(), 46, 50); + Assert.NotEqual(JsonValueKind.Null, root.GetProperty("truncation_note").ValueKind); + } + + [Fact] + public async Task GetTopProceduresByCpu_ReportsTruncation_WhenRawStartsAfterTheWindow() + { + await _duckDb.InitializeAsync(); + var collected = DateTime.SpecifyKind(DateTime.UtcNow.AddDays(-2), DateTimeKind.Unspecified); + using (var connection = await OpenSeedConnectionAsync()) + await SeedProcedureStatsAsync(connection, collected, "usp_Trunc"); + + var json = await McpQueryTools.GetTopProceduresByCpu(new LocalDataService(_duckDb), _serverManager, "TestServer", hours_back: 168); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.True(root.GetProperty("window_truncated").GetBoolean()); + var effectiveStart = DateTime.Parse(root.GetProperty("effective_start").GetString()!).ToUniversalTime(); + Assert.True(Math.Abs((effectiveStart - collected.ToUniversalTime()).TotalMinutes) < 2, + $"effective_start {effectiveStart:o} should track the seeded floor {collected:o}"); + Assert.NotEqual(JsonValueKind.Null, root.GetProperty("truncation_note").ValueKind); + } + + [Fact] + public async Task GetQueryStoreTop_ReportsTruncation_WhenRawStartsAfterTheWindow() + { + await _duckDb.InitializeAsync(); + var collected = DateTime.SpecifyKind(DateTime.UtcNow.AddDays(-2), DateTimeKind.Unspecified); + using (var connection = await OpenSeedConnectionAsync()) + await SeedQueryStoreStatsAsync(connection, collected, 900001); + + var json = await McpQueryTools.GetQueryStoreTop(new LocalDataService(_duckDb), _serverManager, "TestServer", hours_back: 168); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.True(root.GetProperty("window_truncated").GetBoolean()); + var effectiveStart = DateTime.Parse(root.GetProperty("effective_start").GetString()!).ToUniversalTime(); + Assert.True(Math.Abs((effectiveStart - collected.ToUniversalTime()).TotalMinutes) < 2, + $"effective_start {effectiveStart:o} should track the seeded floor {collected:o}"); + Assert.NotEqual(JsonValueKind.Null, root.GetProperty("truncation_note").ValueKind); + } + + /// + /// #4231 ruling: "a floor inside the slack shows no note." The seeded floor sits 60 minutes after the + /// requested start — inside McpQueryTools.TruncationSlack's 90-minute allowance — so this is a normal raw + /// series opening a collection cadence or two late, not a retention cut. + /// + [Fact] + public async Task GetTopQueriesByCpu_NoNote_WhenFloorIsInsideTheSlack() + { + await _duckDb.InitializeAsync(); + var requestedStart = DateTime.UtcNow.AddHours(-24); + var collected = DateTime.SpecifyKind(requestedStart.AddMinutes(60), DateTimeKind.Unspecified); + using (var connection = await OpenSeedConnectionAsync()) + await SeedQueryStatsAsync(connection, collected, "0xINSLACK"); + + var json = await McpQueryTools.GetTopQueriesByCpu(new LocalDataService(_duckDb), _serverManager, "TestServer", hours_back: 24); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.False(root.GetProperty("window_truncated").GetBoolean()); + Assert.Equal(JsonValueKind.Null, root.GetProperty("truncation_note").ValueKind); + } + + /// + /// Ruling 1: the floor "comes from that view" when the grid/tool reads a view over the hot table and the + /// archived parquet files — never just the hot table. Archives an OLDER set to parquet (mirroring + /// ArchiveViewDedupTests' staging), keeps a NEWER set in the hot table, and confirms the probe returns the + /// archived (older) floor. Also times the probe, per the #4231 ruling to measure it on a store that has + /// archived files. + /// + [Fact] + public async Task FloorHelper_ReadsTheArchivedFloor_NotJustTheHotTable() + { + await _duckDb.InitializeAsync(); + var archivedFloor = DateTime.SpecifyKind(DateTime.UtcNow.AddDays(-6), DateTimeKind.Unspecified); + var hotStart = DateTime.SpecifyKind(DateTime.UtcNow.AddHours(-12), DateTimeKind.Unspecified); + + using (var connection = await OpenSeedConnectionAsync()) + { + for (var i = 0; i < 500; i++) + await SeedQueryStatsAsync(connection, archivedFloor.AddMinutes(i), $"0xARCH{i}"); + + var parquetPath = Path.Combine(_archivePath, "20260101_0000_query_stats.parquet").Replace("\\", "/"); + using (var readLock = _duckDb.AcquireReadLock()) + using (var copyCmd = connection.CreateCommand()) + { + copyCmd.CommandText = $"COPY query_stats TO '{parquetPath}' (FORMAT PARQUET)"; + await copyCmd.ExecuteNonQueryAsync(); + } + using (var readLock = _duckDb.AcquireReadLock()) + using (var deleteCmd = connection.CreateCommand()) + { + deleteCmd.CommandText = "DELETE FROM query_stats"; + await deleteCmd.ExecuteNonQueryAsync(); + } + + for (var i = 0; i < 500; i++) + await SeedQueryStatsAsync(connection, hotStart.AddMinutes(i), $"0xHOT{i}"); + } + + await _duckDb.CreateArchiveViewsAsync(); + + var service = new LocalDataService(_duckDb); + var requestedStart = DateTime.UtcNow.AddDays(-7); + var windowEnd = DateTime.UtcNow; + + var stopwatch = Stopwatch.StartNew(); + var floor = await service.GetQueryWindowFloorAsync(QueryWindowRelation.QueryStats, ServerId, requestedStart, windowEnd); + stopwatch.Stop(); + Console.WriteLine($"#4231 GetQueryWindowFloorAsync (500 hot + 500 archived rows): {stopwatch.ElapsedMilliseconds} ms"); + + Assert.NotNull(floor); + Assert.True(Math.Abs((floor!.Value - archivedFloor).TotalMinutes) < 2, + $"floor {floor:o} should be the ARCHIVED start {archivedFloor:o}, not the hot table's {hotStart:o}"); + } +} diff --git a/Lite/Mcp/McpQueryTools.cs b/Lite/Mcp/McpQueryTools.cs index 836525255..e02903c7f 100644 --- a/Lite/Mcp/McpQueryTools.cs +++ b/Lite/Mcp/McpQueryTools.cs @@ -10,7 +10,7 @@ namespace PerformanceMonitorLite.Mcp; [McpServerToolType] public sealed class McpQueryTools { - [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 no stored text for the group). 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. <> 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 no stored text for the group). 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. Reads the raw tier only, which on a store with a lowered retention_days or less history than the window can be shorter than hours_back: window_truncated is the store's retention floor, not a page cut, and effective_start / effective_hours_back say where the answer begins. " + McpToolGuideTopics.CpuTimeExtremesAndAttribution + McpHelpers.WindowTruncatedDescription)] public static async Task GetTopQueriesByCpu( LocalDataService dataService, ServerManager serverManager, @@ -51,6 +51,7 @@ min_dop when set above that (min_dop implies parallel filtering, as its descript ? $"lifetime max_dop >= {minMaxDop} (applied in SQL before the top-{top} ranking; the page is the top-{top} of the parallel population)" : null; + var requestedStart = nowUtc.AddHours(-hours_back); var rows = await dataService.GetTopQueriesByCpuAsync(resolved.ServerId, hours_back, top, databaseNames: string.IsNullOrEmpty(database_name) ? null : new[] { database_name }, asOfUtc: windowEnd, minMaxDop: minMaxDop); if (rows.Count == 0) { @@ -67,19 +68,27 @@ min_dop when set above that (min_dop implies parallel filtering, as its descript ?? McpHelpers.Status("unavailable", "No query stats available for the specified time range."); } + /* #4231: what the window ACTUALLY reached, from the SAME relation (v_query_stats) the ranking read + above just used — a raw-only table with no rollup fallback, so a lowered retention_days or a + young install can cut a "Last 7 days" ask well short of 7 days with nothing on the page saying + so. One probe, shared with the Queries-tab grid (LocalDataService.GetQueryWindowFloorAsync). */ + var floor = await dataService.GetQueryWindowFloorAsync(QueryWindowRelation.QueryStats, resolved.ServerId, requestedStart, nowUtc); + var effectiveStart = floor ?? requestedStart; + var truncated = IsWindowTruncated(floor, requestedStart); + /* #2320: what fraction of the box's measured CPU the RETURNED rows explain — numerator is 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. One nowUtc backs the aggregate read AND the ratio math, and the two independent reads run concurrently (review catches; Darling has both by construction). */ - var cpuAggregateTask = dataService.GetCpuWindowAggregateAsync(resolved.ServerId, nowUtc.AddHours(-hours_back), nowUtc); + var cpuAggregateTask = dataService.GetCpuWindowAggregateAsync(resolved.ServerId, requestedStart, nowUtc); var propertiesTask = dataService.GetLatestServerPropertiesAsync(resolved.ServerId); await Task.WhenAll(cpuAggregateTask, propertiesTask); var cpuAggregate = await cpuAggregateTask; var properties = await propertiesTask; var attribution = CpuAttribution.Compute( rows.Sum(r => r.TotalCpuMs) / 1000.0, - nowUtc.AddHours(-hours_back), nowUtc, + requestedStart, nowUtc, cpuAggregate.SampleCount, cpuAggregate.FirstSample, cpuAggregate.LastSample, cpuAggregate.AvgSqlCpuPercent, properties?.CpuCount ?? 0); @@ -128,6 +137,15 @@ ratio is omitted rather than invented when a denominator piece is missing. One n { server = resolved.ServerName, hours_back, + /* #4231: what was served, beside what was asked for — query_stats is raw-only (no rollup + fallback), so a lowered retention_days or a young install can serve less than hours_back + with nothing else on the payload saying so. */ + effective_start = effectiveStart.ToString("o"), + effective_hours_back = Math.Round((nowUtc - effectiveStart).TotalHours, 1), + window_truncated = truncated, + truncation_note = truncated + ? "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, /* #3541 A13: the filter that shaped the population, stated on the payload; null when none. */ filter_applied = filterApplied, cpu_attribution = new @@ -146,7 +164,7 @@ ratio is omitted rather than invented when a denominator piece is missing. One n } } - [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. Reads the raw tier only, which on a store with a lowered retention_days or less history than the window can be shorter than hours_back: window_truncated is the store's retention floor, not a page cut, and effective_start / effective_hours_back say where the answer begins. <> 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( LocalDataService dataService, ServerManager serverManager, @@ -169,6 +187,7 @@ public static async Task GetTopProceduresByCpu( /* Same pre-read capture as the queries tool — the skew shrinks to call-entry overhead. */ var nowUtc = windowEnd; + var requestedStart = nowUtc.AddHours(-hours_back); var rows = await dataService.GetTopProceduresByCpuAsync(resolved.ServerId, hours_back, top, databaseNames: string.IsNullOrEmpty(database_name) ? null : new[] { database_name }, asOfUtc: windowEnd); if (rows.Count == 0) { @@ -178,16 +197,22 @@ public static async Task GetTopProceduresByCpu( "No procedure stats available. Delta-based collection requires at least two collection cycles (~30 minutes) to produce non-zero values."); } + /* #4231: what the window ACTUALLY reached, from the SAME relation (v_procedure_stats) the ranking + read above just used. One probe, shared with the Queries-tab grid. */ + var floor = await dataService.GetQueryWindowFloorAsync(QueryWindowRelation.ProcedureStats, resolved.ServerId, requestedStart, nowUtc); + var effectiveStart = floor ?? requestedStart; + var truncated = IsWindowTruncated(floor, requestedStart); + /* #2320: same attributed-CPU disclosure as the queries tool — one shared computation, one nowUtc backing aggregate and ratio, same concurrent independent reads. */ - var cpuAggregateTask = dataService.GetCpuWindowAggregateAsync(resolved.ServerId, nowUtc.AddHours(-hours_back), nowUtc); + var cpuAggregateTask = dataService.GetCpuWindowAggregateAsync(resolved.ServerId, requestedStart, nowUtc); var propertiesTask = dataService.GetLatestServerPropertiesAsync(resolved.ServerId); await Task.WhenAll(cpuAggregateTask, propertiesTask); var cpuAggregate = await cpuAggregateTask; var properties = await propertiesTask; var attribution = CpuAttribution.Compute( rows.Sum(r => r.TotalCpuMs) / 1000.0, - nowUtc.AddHours(-hours_back), nowUtc, + requestedStart, nowUtc, cpuAggregate.SampleCount, cpuAggregate.FirstSample, cpuAggregate.LastSample, cpuAggregate.AvgSqlCpuPercent, properties?.CpuCount ?? 0); @@ -221,6 +246,15 @@ public static async Task GetTopProceduresByCpu( { server = resolved.ServerName, hours_back, + /* #4231: what was served, beside what was asked for — procedure_stats is raw-only (no rollup + fallback), so a lowered retention_days or a young install can serve less than hours_back + with nothing else on the payload saying so. */ + effective_start = effectiveStart.ToString("o"), + effective_hours_back = Math.Round((nowUtc - effectiveStart).TotalHours, 1), + window_truncated = truncated, + truncation_note = truncated + ? "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, cpu_attribution = new { ranked_cpu_seconds = attribution.RankedCpuSeconds, @@ -237,7 +271,7 @@ public static async Task GetTopProceduresByCpu( } } - [McpServerTool(Name = "get_query_store_top"), Description("Cost-ranked top Query Store queries (heaviest first), not time-ordered. Requires Query Store enabled on target databases. Darling: window_truncated marks a window floor, not a page cut — no limit changes it — because raw retention can be shorter than asked; effective_start / effective_hours_back give the reach actually served. Lite: no such floor; the full requested window is always read. <> Gets expensive queries from Query Store (persistent, survives restarts). Best for: historical analysis, queries no longer in plan cache. Requires Query Store enabled on target databases. Supports database and module filtering. Rows are per Query Store execution outcome (execution_type: Regular, Aborted, Exception): a plan with aborted executions returns one row per outcome, each with its own counts and averages. The execution_type filter keeps one outcome, and module_name keeps one module: the exact, case-sensitive schema-qualified name the collector records (get_top_procedures_by_cpu's full_name; Adhoc for ad-hoc statements, Unknown for an object it could not resolve), applied after interval deduplication and before ranking. When a filter matches nothing but the same read without the filters has rows, the answer is empty (a measured zero), not a Query Store precondition.")] + [McpServerTool(Name = "get_query_store_top"), Description("Cost-ranked top Query Store queries (heaviest first), not time-ordered. Requires Query Store enabled on target databases. window_truncated marks a window floor, not a page cut — no limit changes it — because query_store_stats is raw-only (no rollup carries query_id/plan_id) and a lowered retention_days or a young install can hold less than the window asked for; effective_start / effective_hours_back give the reach actually served. Same disclosure as Darling's get_query_store_top. <> Gets expensive queries from Query Store (persistent, survives restarts). Best for: historical analysis, queries no longer in plan cache. Requires Query Store enabled on target databases. Supports database and module filtering. Rows are per Query Store execution outcome (execution_type: Regular, Aborted, Exception): a plan with aborted executions returns one row per outcome, each with its own counts and averages. The execution_type filter keeps one outcome, and module_name keeps one module: the exact, case-sensitive schema-qualified name the collector records (get_top_procedures_by_cpu's full_name; Adhoc for ad-hoc statements, Unknown for an object it could not resolve), applied after interval deduplication and before ranking. When a filter matches nothing but the same read without the filters has rows, the answer is empty (a measured zero), not a Query Store precondition; a module_name miss also carries the window read (effective_start, effective_hours_back, window_truncated) as hints." + McpHelpers.WindowTruncatedDescription)] public static async Task GetQueryStoreTop( LocalDataService dataService, ServerManager serverManager, @@ -270,12 +304,24 @@ and the empty answer under it would read as "no such executions". Downstream fil module_name = string.IsNullOrWhiteSpace(module_name) ? null : module_name; + var nowUtc = windowEnd; + var requestedStart = nowUtc.AddHours(-hours_back); var rows = await dataService.GetQueryStoreTopQueriesAsync( resolved.ServerId, hours_back, top, databaseNames: string.IsNullOrEmpty(database_name) ? null : new[] { database_name }, asOfUtc: windowEnd, executionType: execution_type, moduleName: module_name); + + /* #4231 (Lite's twin of Darling's #2364): what the window ACTUALLY reached. rows above are the top + N by COST, so their timestamps say nothing about how far back the read went. query_store_stats + has no rollup fallback (the only one that keeps query_id/plan_id would approach raw's own size), + so the honest move is to report what was served rather than echo what was asked for. Deliberately + unfiltered: the floor is a property of the tier, not narrowed by database/execution_type/module. */ + var floor = await dataService.GetQueryWindowFloorAsync(QueryWindowRelation.QueryStoreStats, resolved.ServerId, requestedStart, nowUtc); + var effectiveStart = floor ?? requestedStart; + var truncated = IsWindowTruncated(floor, requestedStart); + if (rows.Count == 0) { /* A filter that matched nothing is an answer, not a missing collection. Most queries never abort, @@ -288,7 +334,14 @@ window is a measured zero whenever the read without the filters has rows. */ && (await dataService.GetQueryStoreTopQueriesAsync(resolved.ServerId, hours_back, 1, databaseNames: string.IsNullOrEmpty(database_name) ? null : new[] { database_name }, asOfUtc: windowEnd)).Count > 0) return module_name is null ? McpHelpers.QueryStoreExecutionTypeEmpty(execution_type!, hours_back, database_name) - : McpHelpers.QueryStoreModuleEmpty(module_name, execution_type, hours_back, database_name); + /* The module miss hands back the window it read: "did not run" is a claim about that + window, and the raw tier may not reach the whole of the one asked for. */ + : McpHelpers.QueryStoreModuleEmpty(module_name, execution_type, hours_back, database_name, truncated, new + { + effective_start = effectiveStart.ToString("o"), + effective_hours_back = Math.Round((nowUtc - effectiveStart).TotalHours, 1), + window_truncated = truncated + }); return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "query_store") /* #2546: the sentence below GUESSES ("may not be enabled"), and it had to, because the @@ -327,6 +380,14 @@ the thing that cannot read it. */ { server = resolved.ServerName, hours_back, + /* #4231: what was served, beside what was asked for — hours_back alone is a request echoed + back as though it described the data. Same keys, same meaning as Darling's get_query_store_top. */ + effective_start = effectiveStart.ToString("o"), + effective_hours_back = Math.Round((nowUtc - effectiveStart).TotalHours, 1), + window_truncated = truncated, + truncation_note = truncated + ? "The window reaches further back than this server's raw query_store_stats retains (or this server has been monitored for less time than that), so the older part of it was not read. This tool reads the raw tier only: no rollup carries query_id or plan_id, and plan identity is what it exists to return." + : null, queries = result }, McpHelpers.JsonOptions); } @@ -856,6 +917,16 @@ private static TrendGrain Bucketed(int bucketMinutes, bool requested, int autoPo /// internal static readonly TimeSpan TruncationSlack = TimeSpan.FromMinutes(90); + /// + /// #4231: whether a probe found the store's raw + /// data starting LATER than the window asked for, by more than — the window + /// floor, not a page cut; no top/limit changes it. Shared so get_top_queries_by_cpu, + /// get_top_procedures_by_cpu, get_query_store_top and the Queries-tab grids that read the + /// same three tables all compute the same verdict from the same probe instead of drifting apart. + /// + internal static bool IsWindowTruncated(DateTime? floor, DateTime requestedStart) => + floor is DateTime f && f > requestedStart + TruncationSlack; + /// /// The disclosure block every Performance-Trends payload carries (#3541 A2), written in the same key order /// on both SKUs and on both branches. Lite has ONE tier — DuckDB keeps raw rows for the collector's whole diff --git a/Lite/Services/LocalDataService.QueryWindowFloor.cs b/Lite/Services/LocalDataService.QueryWindowFloor.cs new file mode 100644 index 000000000..c41566e5a --- /dev/null +++ b/Lite/Services/LocalDataService.QueryWindowFloor.cs @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Threading.Tasks; +using DuckDB.NET.Data; + +namespace PerformanceMonitorLite.Services; + +/// +/// The three RAW-ONLY relations #4231's window-floor disclosure covers: every Queries-tab grid and MCP tool +/// that reads one of these has no rollup underneath it to fall back on when the requested window reaches past +/// what the raw table actually retains. +/// +public enum QueryWindowRelation +{ + QueryStats, + ProcedureStats, + QueryStoreStats +} + +public partial class LocalDataService +{ + private static string QueryWindowRelationView(QueryWindowRelation relation) => relation switch + { + QueryWindowRelation.QueryStats => "v_query_stats", + QueryWindowRelation.ProcedureStats => "v_procedure_stats", + QueryWindowRelation.QueryStoreStats => "v_query_store_stats", + _ => throw new ArgumentOutOfRangeException(nameof(relation), relation, "unknown QueryWindowRelation") + }; + + /// + /// #4231: the oldest collection_time this server actually has inside the requested window, for + /// whichever of the three raw relations the caller names. ONE probe shared by every Queries-tab grid + /// (Lite/Controls/ServerTab.*) and every MCP tool that reads that relation (get_top_queries_by_cpu, + /// get_top_procedures_by_cpu, get_query_store_top), so there is one place that computes the + /// floor rather than three near-identical copies that can drift apart. Twin of Darling's + /// DarlingDataReader.GetQueryStoreWindowFloorAsync (#2364), generalized to Lite's three raw tables: + /// Lite's default 30-day retention_days is per-collector and user-settable (lower it and the raw + /// table is shorter than a "Last 7 days" ask), and a server added mid-window has less history than that + /// regardless of the setting — the same silent cut #2364 fixed on Darling's query_store_stats can + /// happen on any of Lite's three (#4231). + /// + /// Reads the v_ view, not the bare table: on a store with archived history that view is the + /// hot table UNION ALL the parquet archive (), + /// so the floor this returns is the true floor of everything the matching grid or tool read, archive + /// included — never just the hot table's. + /// + /// Bounded on both sides of the window. An unbounded MIN would scan back to the start of + /// retention to answer a question about the window's edge; the upper bound is what lets DuckDB's zone-map + /// statistics prune row groups in the hot table AND skip whole files in the parquet glob, the same way the + /// callers' own collection_time filters do (no separate index needed — see + /// DuckDbSchemaGenerator's idx_query_store_time comment neighbors for why the column stats + /// alone are enough here). Null when the window holds nothing at all, which the caller reports as "nothing + /// was read" rather than as a measured absence of activity. + /// + public async Task GetQueryWindowFloorAsync(QueryWindowRelation relation, int serverId, DateTime startUtc, DateTime endUtc) + { + var view = QueryWindowRelationView(relation); + using var _q = TimeQuery("GetQueryWindowFloorAsync", $"{view} MIN(collection_time) window floor"); + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + command.CommandText = $@" +SELECT MIN(collection_time) +FROM {view} +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3"; + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + command.Parameters.Add(new DuckDBParameter { Value = startUtc }); + command.Parameters.Add(new DuckDBParameter { Value = endUtc }); + var result = await command.ExecuteScalarAsync(); + return result is DateTime dt ? dt : null; + } +} diff --git a/PerformanceMonitor.Common/Mcp/McpHelpers.cs b/PerformanceMonitor.Common/Mcp/McpHelpers.cs index d063c30e9..c9a0bff11 100644 --- a/PerformanceMonitor.Common/Mcp/McpHelpers.cs +++ b/PerformanceMonitor.Common/Mcp/McpHelpers.cs @@ -232,8 +232,9 @@ public static string QueryStoreExecutionTypeEmpty(string executionType, int hour /// without the filters returns rows: Query Store is collecting and the window has rows, just none from that /// module, so it is a measured zero rather than the "may not be enabled" guess. Names an execution_type /// filter too when one rode along, since either can be why nothing matched. Shared so both SKUs say it in the - /// same words. Darling passes its window floor ( and the served window as - /// ), because the raw tier can stop short of the window asked for; Lite has no floor. + /// same words. Both SKUs pass their window floor ( and the served window as + /// ), because the raw tier can stop short of the window asked for (#2364 on Darling, + /// #4231 on Lite). /// public static string QueryStoreModuleEmpty( string moduleName, string? executionType, int hoursBack, string? databaseName, From 5aef1c268d5e4afbefbb2ae866a2846d6c68405c Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:33:35 -0400 Subject: [PATCH 2/8] Lite grids disclose a truncated query-stats window (#4231) The Top Queries, Top Procedures and Query Store grids (and their time-range slicers, which re-read the same grid over a narrower window) now show "Showing since " in the header when the shared GetQueryWindowFloorAsync probe finds the raw table's floor cutting the requested window short -- the same words and probe the three MCP tools added in the previous commit on this branch use, via one shared RefreshWindowTruncatedBannerAsync helper so the grid and the tool can never disagree. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- Lite/Controls/ServerTab.Refresh.cs | 39 ++++++++++++++++++++++++++++++ Lite/Controls/ServerTab.Slicers.cs | 3 +++ Lite/Controls/ServerTab.xaml | 35 +++++++++++++++++++++------ 3 files changed, 69 insertions(+), 8 deletions(-) diff --git a/Lite/Controls/ServerTab.Refresh.cs b/Lite/Controls/ServerTab.Refresh.cs index bdf344948..a310b3e0b 100644 --- a/Lite/Controls/ServerTab.Refresh.cs +++ b/Lite/Controls/ServerTab.Refresh.cs @@ -14,6 +14,7 @@ using System.Threading.Tasks; using System.Windows.Controls; using PerformanceMonitorLite.Helpers; +using PerformanceMonitorLite.Mcp; using PerformanceMonitorLite.Models; using PerformanceMonitorLite.Services; using PerformanceMonitor.Common; @@ -262,6 +263,7 @@ private async System.Threading.Tasks.Task RefreshQueriesAsync(int hoursBack, Dat var cEnd = toDate ?? DateTime.UtcNow; var cStart = fromDate ?? cEnd.AddHours(-hoursBack); await RefreshQueryStatsComparisonAsync(cStart, cEnd); + await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QueryStats, QueryStatsWindowTruncatedBanner, cStart, cEnd); } break; case 3: // Top Procedures by Duration @@ -273,6 +275,7 @@ private async System.Threading.Tasks.Task RefreshQueriesAsync(int hoursBack, Dat var cEnd = toDate ?? DateTime.UtcNow; var cStart = fromDate ?? cEnd.AddHours(-hoursBack); await RefreshProcStatsComparisonAsync(cStart, cEnd); + await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.ProcedureStats, ProcStatsWindowTruncatedBanner, cStart, cEnd); } break; case 4: // Query Store by Duration @@ -284,6 +287,7 @@ private async System.Threading.Tasks.Task RefreshQueriesAsync(int hoursBack, Dat var cEnd = toDate ?? DateTime.UtcNow; var cStart = fromDate ?? cEnd.AddHours(-hoursBack); await RefreshQueryStoreComparisonAsync(cStart, cEnd); + await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QueryStoreStats, QueryStoreWindowTruncatedBanner, cStart, cEnd); } break; case 5: // Plan Corrections @@ -336,6 +340,7 @@ await System.Threading.Tasks.Task.WhenAll( var cEnd = toDate ?? DateTime.UtcNow; var cStart = fromDate ?? cEnd.AddHours(-hoursBack); await RefreshQueryStatsComparisonAsync(cStart, cEnd); + await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QueryStats, QueryStatsWindowTruncatedBanner, cStart, cEnd); } _procStatsFilterMgr!.UpdateData(procStatsTask.Result); SetDefaultSortIfNone(ProcedureStatsGrid, "TotalElapsedMs", ListSortDirection.Descending); @@ -344,6 +349,7 @@ await System.Threading.Tasks.Task.WhenAll( var cEnd2 = toDate ?? DateTime.UtcNow; var cStart2 = fromDate ?? cEnd2.AddHours(-hoursBack); await RefreshProcStatsComparisonAsync(cStart2, cEnd2); + await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.ProcedureStats, ProcStatsWindowTruncatedBanner, cStart2, cEnd2); } _queryStoreFilterMgr!.UpdateData(queryStoreTask.Result); SetDefaultSortIfNone(QueryStoreGrid, "TotalDurationMs", ListSortDirection.Descending); @@ -352,6 +358,7 @@ await System.Threading.Tasks.Task.WhenAll( var cEnd3 = toDate ?? DateTime.UtcNow; var cStart3 = fromDate ?? cEnd3.AddHours(-hoursBack); await RefreshQueryStoreComparisonAsync(cStart3, cEnd3); + await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QueryStoreStats, QueryStoreWindowTruncatedBanner, cStart3, cEnd3); } _planCorrectionFilterMgr!.UpdateData(planCorrectionTask.Result); SetDefaultSortIfNone(PlanCorrectionGrid, "Score", ListSortDirection.Descending); @@ -368,6 +375,38 @@ await System.Threading.Tasks.Task.WhenAll( } } + /// + /// #4231: probes the shared window-floor helper () + /// for one of the three raw-only relations and updates that tab's "Showing since <time>" banner — + /// the SAME probe and the same truncation verdict () the + /// matching MCP tool uses, so the grid and the tool never disagree about whether a window was cut short. + /// Called from the sub-tab switch and full-refresh paths below AND from the three OnXSlicerChanged + /// handlers in ServerTab.Slicers.cs — a slicer drag re-reads the same grid over a narrower window, which + /// can itself start after the raw table's floor, so it needs the same disclosure. No try/catch here: every + /// caller already runs inside its own (ServerTabCapabilityPinTests pins that every OnXSlicerChanged keeps + /// its own try/catch; RefreshQueriesAsync has one around the whole sub-tab switch). + /// + private async System.Threading.Tasks.Task RefreshWindowTruncatedBannerAsync(QueryWindowRelation relation, TextBlock banner, DateTime start, DateTime end) + { + var floor = await Task.Run(() => _dataService.GetQueryWindowFloorAsync(relation, _serverId, start, end)); + var truncated = McpQueryTools.IsWindowTruncated(floor, start); + SetWindowTruncatedBanner(banner, truncated, floor ?? start); + } + + /// + /// #4231 Ruled comment: "the WPF ... grids show 'Showing since <time>' in the header when the window + /// is cut short" — same words Darling's twin uses. Formats with ServerTimeHelper.FormatServerTime, the way + /// QueryStatsComparisonBanner / ProcStatsComparisonBanner / QueryStoreComparisonBanner already format their + /// baseline range on these same tabs. internal (not private) so QueryWindowTruncationTests can pin the + /// truncated/not-truncated text without instantiating the UserControl (WPF objects still need an STA + /// thread to construct, which the test provides; the text itself is plain string formatting). + /// + internal static void SetWindowTruncatedBanner(TextBlock banner, bool truncated, DateTime effectiveStart) + { + banner.Visibility = truncated ? System.Windows.Visibility.Visible : System.Windows.Visibility.Collapsed; + banner.Text = truncated ? $"Showing since {ServerTimeHelper.FormatServerTime(effectiveStart)}" : string.Empty; + } + /// Tab 0 — Overview (Correlated Timeline Lanes) private async System.Threading.Tasks.Task RefreshOverviewAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) { diff --git a/Lite/Controls/ServerTab.Slicers.cs b/Lite/Controls/ServerTab.Slicers.cs index 9181a7bcf..347999926 100644 --- a/Lite/Controls/ServerTab.Slicers.cs +++ b/Lite/Controls/ServerTab.Slicers.cs @@ -173,6 +173,7 @@ private async void OnQueryStatsSlicerChanged(object? sender, Controls.SlicerRang var queryStats = await Task.Run(() => _dataService.GetTopQueriesByCpuAsync(_serverId, 0, 50, fromServer, toServer, UtcOffsetMinutes, SelectedDatabaseFilter)); _queryStatsFilterMgr!.UpdateData(queryStats); await RefreshQueryStatsComparisonAsync(fromServer, toServer); + await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QueryStats, QueryStatsWindowTruncatedBanner, fromServer, toServer); } catch (Exception ex) { @@ -224,6 +225,7 @@ private async void OnQueryStoreSlicerChanged(object? sender, Controls.SlicerRang var qsData = await Task.Run(() => _dataService.GetQueryStoreTopQueriesAsync(_serverId, 0, 50, fromServer, toServer, SelectedDatabaseFilter)); _queryStoreFilterMgr!.UpdateData(qsData); await RefreshQueryStoreComparisonAsync(fromServer, toServer); + await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QueryStoreStats, QueryStoreWindowTruncatedBanner, fromServer, toServer); } catch (Exception ex) { @@ -275,6 +277,7 @@ private async void OnProcStatsSlicerChanged(object? sender, Controls.SlicerRange var procStats = await Task.Run(() => _dataService.GetTopProceduresByCpuAsync(_serverId, 0, 50, fromServer, toServer, UtcOffsetMinutes, SelectedDatabaseFilter)); _procStatsFilterMgr!.UpdateData(procStats); await RefreshProcStatsComparisonAsync(fromServer, toServer); + await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.ProcedureStats, ProcStatsWindowTruncatedBanner, fromServer, toServer); } catch (Exception ex) { diff --git a/Lite/Controls/ServerTab.xaml b/Lite/Controls/ServerTab.xaml index 0d4428cc1..4308528f6 100644 --- a/Lite/Controls/ServerTab.xaml +++ b/Lite/Controls/ServerTab.xaml @@ -486,6 +486,11 @@ + + - + + + + + - + + + + + Date: Fri, 25 Sep 2026 08:33:42 -0400 Subject: [PATCH 3/8] Pin the shared window-floor probe and the grid banner text (#4231) A source pin (QueriesTabGridReads_RouteThroughSharedWindowFloorHelper) confirms every Queries-tab grid and slicer read of query_stats, procedure_stats and query_store_stats routes through the ONE shared GetQueryWindowFloorAsync probe and calls RefreshWindowTruncatedBannerAsync, never a hand-rolled second copy. Two more tests pin ServerTab.SetWindowTruncatedBanner's text and visibility for the truncated and not-truncated cases. Verified the source pin catches a dropped call site by temporarily removing one and reverting. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- Lite.Tests/QueryWindowTruncationTests.cs | 113 +++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/Lite.Tests/QueryWindowTruncationTests.cs b/Lite.Tests/QueryWindowTruncationTests.cs index 7c32e97b9..c3e043fd7 100644 --- a/Lite.Tests/QueryWindowTruncationTests.cs +++ b/Lite.Tests/QueryWindowTruncationTests.cs @@ -9,9 +9,13 @@ using System; using System.Diagnostics; using System.IO; +using System.Runtime.CompilerServices; using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; using System.Threading.Tasks; using DuckDB.NET.Data; +using PerformanceMonitorLite.Controls; using PerformanceMonitorLite.Database; using PerformanceMonitorLite.Mcp; using PerformanceMonitorLite.Models; @@ -289,4 +293,113 @@ public async Task FloorHelper_ReadsTheArchivedFloor_NotJustTheHotTable() Assert.True(Math.Abs((floor!.Value - archivedFloor).TotalMinutes) < 2, $"floor {floor:o} should be the ARCHIVED start {archivedFloor:o}, not the hot table's {hotStart:o}"); } + + /// + /// #4231 ruling: "the WPF Top Queries, Top Procedures and Query Store grids show 'Showing since <time>' + /// in the header when the window is cut short." Pins -- + /// the same text/visibility plumbing every one of the three grids' refresh paths and their matching + /// OnXSlicerChanged handler call -- directly, rather than through ServerTab's full UI (no InitializeComponent, + /// no server connection needed to pin the banner text). WPF objects need an STA thread to construct even + /// off-screen; same shape as MainWindowAccessKeyTests/ThemeColorOverrideTests' OnStaThread. + /// + [Fact] + public void SetWindowTruncatedBanner_Truncated_ShowsSinceEffectiveStart() + { + var effectiveStart = new DateTime(2026, 1, 15, 8, 30, 0, DateTimeKind.Unspecified); + + var (visibility, text) = OnStaThread(() => + { + var banner = new System.Windows.Controls.TextBlock(); + ServerTab.SetWindowTruncatedBanner(banner, truncated: true, effectiveStart); + return (banner.Visibility, banner.Text); + }); + + Assert.Equal(System.Windows.Visibility.Visible, visibility); + Assert.Equal($"Showing since {ServerTimeHelper.FormatServerTime(effectiveStart)}", text); + } + + /// #4231: a floor inside the slack (or no truncation at all) must hide the banner and clear stale text. + [Fact] + public void SetWindowTruncatedBanner_NotTruncated_HidesBanner() + { + var (visibility, text) = OnStaThread(() => + { + var banner = new System.Windows.Controls.TextBlock + { + Visibility = System.Windows.Visibility.Visible, + Text = "Showing since 2020-01-01 00:00:00" + }; + ServerTab.SetWindowTruncatedBanner(banner, truncated: false, DateTime.UtcNow); + return (banner.Visibility, banner.Text); + }); + + Assert.Equal(System.Windows.Visibility.Collapsed, visibility); + Assert.Equal(string.Empty, text); + } + + /// WPF objects require STA; same shape as MainWindowAccessKeyTests/ThemeColorOverrideTests. + private static T OnStaThread(Func body) + { + T result = default!; + Exception? error = null; + var thread = new Thread(() => + { + try { result = body(); } + catch (Exception ex) { error = ex; } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + + if (error is not null) + { + throw error; + } + + return result; + } + + /// + /// #4231 ruling 6's source pin: every Queries-tab grid read of the three raw-only relations routes through + /// the ONE shared probe -- never a second hand-rolled + /// copy -- and every grid-refresh call site (sub-tab switch, full refresh, and the matching slicer handler, + /// which re-reads the same grid over a narrower window) calls the shared banner helper. Text-scans SOURCE, + /// not a loaded assembly, matching ServerTabCapabilityPinTests' mechanism. + /// + [Fact] + public void QueriesTabGridReads_RouteThroughSharedWindowFloorHelper() + { + var refreshSource = File.ReadAllText(ControlsFile("ServerTab.Refresh.cs")); + var combined = refreshSource + "\n" + File.ReadAllText(ControlsFile("ServerTab.Slicers.cs")); + + // The raw probe itself must have exactly ONE call site -- the shared RefreshWindowTruncatedBannerAsync + // helper (ServerTab.Refresh.cs), which takes `relation` as a parameter rather than repeating the + // literal per table, so every grid/slicer read forwards through the same probe call. + var floorCalls = Regex.Matches(refreshSource, @"_dataService\.GetQueryWindowFloorAsync\(").Count; + Assert.True(floorCalls == 1, + $"expected exactly one _dataService.GetQueryWindowFloorAsync(...) call site in ServerTab.Refresh.cs " + + $"(found {floorCalls}) -- every Queries-tab grid/slicer read must route through the ONE shared " + + "RefreshWindowTruncatedBannerAsync helper, not a second copy of the probe (#4231)."); + + foreach (var relation in new[] { "QueryStats", "ProcedureStats", "QueryStoreStats" }) + { + var bannerCalls = Regex.Matches(combined, $@"RefreshWindowTruncatedBannerAsync\(\s*QueryWindowRelation\.{relation}\b").Count; + Assert.True(bannerCalls >= 3, + $"expected at least 3 RefreshWindowTruncatedBannerAsync(QueryWindowRelation.{relation}...) call " + + $"sites (sub-tab switch + full refresh + slicer handler), found {bannerCalls} -- a Queries-tab " + + "grid read of that relation is missing its window-truncated banner refresh (#4231)."); + } + + foreach (var file in Directory.EnumerateFiles(ControlsDir(), "ServerTab*.cs")) + { + Assert.False(File.ReadAllText(file).Contains("MIN(collection_time)", StringComparison.OrdinalIgnoreCase), + $"{Path.GetFileName(file)} hand-rolls a MIN(collection_time) query -- route it through " + + "LocalDataService.GetQueryWindowFloorAsync instead (#4231)."); + } + } + + private static string ControlsFile(string name) => Path.Combine(ControlsDir(), name); + + private static string ControlsDir([CallerFilePath] string thisFile = "") => + Path.GetFullPath(Path.Combine(Path.GetDirectoryName(thisFile)!, "..", "Lite", "Controls")); } From 2e8a740987dccc3d8afb18adc5886e6f52741d41 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:45:39 -0400 Subject: [PATCH 4/8] Fix Lite MCP tool-head budget regressions from the #4231 disclosure text (#4231) The earlier commits on this branch put the new raw-tier/window_truncated sentence in the SERVED HEAD (before <>) for get_top_procedures_by_cpu and get_query_store_top, pushing them over their tools/list budget pins and, for get_top_procedures_by_cpu, over the 620-char hard target. Both already carry the same fact via the shared McpHelpers.WindowTruncatedDescription appended in the guide tail, so the head text was redundant as well as oversized. - get_top_procedures_by_cpu: dropped the redundant head sentence, matching get_top_queries_by_cpu's existing (correct) pattern of leaving the disclosure to the tail. - get_query_store_top: trimmed the head sentence to the essential fact (raw-tier retention floor, no rollup) instead of restating what the tail already says. - Banked the resulting saving in McpToolsListBudget/McpQueryTools.txt (get_query_store_top's ceiling: 422 -> 390). - Lite.Tests/McpToolGuideHeads.CollectionLog.cs: the "Lite: no such floor" guardrail fact and its dedicated test were pinning the PRE-#4231 shape (Lite genuinely had no floor then); updated both to the current, true fact and renamed the test accordingly. - Lite.Tests/McpToolGuideHeads.Data.cs: CpuTimeExtremesTopic_RidesOnBothTopByCpuTools asserted the CPU-extremes topic ends each tool's tail; it no longer does, since WindowTruncatedDescription now trails it on both tools. Switched the assertion from EndsWith to Contains. Full Lite.Tests suite pending in the next commit. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- Lite.Tests/McpToolGuideHeads.CollectionLog.cs | 12 ++++++++---- Lite.Tests/McpToolGuideHeads.Data.cs | 9 +++++++-- Lite.Tests/McpToolsListBudget/McpQueryTools.txt | 2 +- Lite/Mcp/McpQueryTools.cs | 4 ++-- 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/Lite.Tests/McpToolGuideHeads.CollectionLog.cs b/Lite.Tests/McpToolGuideHeads.CollectionLog.cs index 2aa4f25e1..b1232ebb6 100644 --- a/Lite.Tests/McpToolGuideHeads.CollectionLog.cs +++ b/Lite.Tests/McpToolGuideHeads.CollectionLog.cs @@ -32,7 +32,7 @@ private static readonly (string Tool, string Fact)[] HeadFacts = ("get_collection_log", "an unknown value is refused, never silently empty"), ("get_query_store_top", "window_truncated"), ("get_query_store_top", "not a page cut"), - ("get_query_store_top", "Lite: no such floor"), + ("get_query_store_top", "raw-tier retention floor"), ]; [Fact] @@ -73,11 +73,15 @@ public void CollectionLogTail_DropsTheIssueReference_ButKeepsTheRule() Assert.Contains("this SKU never enables the deferred plan-XML or statement-text fetches", tail, StringComparison.Ordinal); } + /// + /// #4231: Lite gained the same raw-tier window floor Darling already had, so the head no longer splits + /// "Darling: has one. Lite: does not" -- it names the same disclosure both SKUs now carry. + /// [Fact] - public void QueryStoreTop_WindowFloorClause_IsScopedToDarlingInTheHead() + public void QueryStoreTop_HeadNamesTheSameDisclosureAsDarling() { var served = McpToolGuideTests.Served("get_query_store_top"); - Assert.Contains("Darling: window_truncated", served.Served, StringComparison.Ordinal); - Assert.Contains("Lite: no such floor", served.Served, StringComparison.Ordinal); + Assert.Contains("window_truncated", served.Served, StringComparison.Ordinal); + Assert.Contains("Same disclosure as Darling's get_query_store_top", served.Served, StringComparison.Ordinal); } } diff --git a/Lite.Tests/McpToolGuideHeads.Data.cs b/Lite.Tests/McpToolGuideHeads.Data.cs index 76a053ca8..558677c9b 100644 --- a/Lite.Tests/McpToolGuideHeads.Data.cs +++ b/Lite.Tests/McpToolGuideHeads.Data.cs @@ -64,11 +64,16 @@ public void EveryConvertedHead_CarriesItsGuardrailFact_AndThePointer() } } + /// + /// #4231: the topic no longer ends either tail -- McpHelpers.WindowTruncatedDescription now trails it on + /// both tools (both raw-tier reads gained the window-floor disclosure), so this checks presence, not + /// position. + /// [Fact] public void CpuTimeExtremesTopic_RidesOnBothTopByCpuTools() { - Assert.EndsWith(McpToolGuideTopics.CpuTimeExtremesAndAttribution, McpToolGuideTests.Served("get_top_procedures_by_cpu").Tail!, StringComparison.Ordinal); - Assert.EndsWith(McpToolGuideTopics.CpuTimeExtremesAndAttribution, McpToolGuideTests.Served("get_top_queries_by_cpu").Tail!, StringComparison.Ordinal); + Assert.Contains(McpToolGuideTopics.CpuTimeExtremesAndAttribution, McpToolGuideTests.Served("get_top_procedures_by_cpu").Tail!, StringComparison.Ordinal); + Assert.Contains(McpToolGuideTopics.CpuTimeExtremesAndAttribution, McpToolGuideTests.Served("get_top_queries_by_cpu").Tail!, StringComparison.Ordinal); } /// Lite's list_servers has no fleet/engine concept; its tail is just the original "use this first" diff --git a/Lite.Tests/McpToolsListBudget/McpQueryTools.txt b/Lite.Tests/McpToolsListBudget/McpQueryTools.txt index 803142216..2a83162f8 100644 --- a/Lite.Tests/McpToolsListBudget/McpQueryTools.txt +++ b/Lite.Tests/McpToolsListBudget/McpQueryTools.txt @@ -34,7 +34,7 @@ param get_query_store_regressions.hours_back 110 param get_query_store_regressions.limit 164 param get_query_store_regressions.server_name 28 -tool get_query_store_top 422 +tool get_query_store_top 390 param get_query_store_top.as_of 167 param get_query_store_top.database_name 30 param get_query_store_top.execution_type 72 diff --git a/Lite/Mcp/McpQueryTools.cs b/Lite/Mcp/McpQueryTools.cs index 39c8211b5..02cdcad92 100644 --- a/Lite/Mcp/McpQueryTools.cs +++ b/Lite/Mcp/McpQueryTools.cs @@ -164,7 +164,7 @@ ratio is omitted rather than invented when a denominator piece is missing. One n } } - [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. Reads the raw tier only, which on a store with a lowered retention_days or less history than the window can be shorter than hours_back: window_truncated is the store's retention floor, not a page cut, and effective_start / effective_hours_back say where the answer begins. <> 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. Reads the raw tier only, which on a store with a lowered retention_days or less history than the window can be shorter than hours_back. " + McpToolGuideTopics.CpuTimeExtremesAndAttribution + McpHelpers.WindowTruncatedDescription)] public static async Task GetTopProceduresByCpu( LocalDataService dataService, ServerManager serverManager, @@ -271,7 +271,7 @@ public static async Task GetTopProceduresByCpu( } } - [McpServerTool(Name = "get_query_store_top"), Description("Cost-ranked top Query Store queries (heaviest first), not time-ordered. Requires Query Store enabled on target databases. window_truncated marks a window floor, not a page cut — no limit changes it — because query_store_stats is raw-only (no rollup carries query_id/plan_id) and a lowered retention_days or a young install can hold less than the window asked for; effective_start / effective_hours_back give the reach actually served. Same disclosure as Darling's get_query_store_top. <> Gets expensive queries from Query Store (persistent, survives restarts). Best for: historical analysis, queries no longer in plan cache. Requires Query Store enabled on target databases. Supports database and module filtering. Rows are per Query Store execution outcome (execution_type: Regular, Aborted, Exception): a plan with aborted executions returns one row per outcome, each with its own counts and averages. The execution_type filter keeps one outcome, and module_name keeps one module: the exact, case-sensitive schema-qualified name the collector records (get_top_procedures_by_cpu's full_name; Adhoc for ad-hoc statements, Unknown for an object it could not resolve), applied after interval deduplication and before ranking. When a filter matches nothing but the same read without the filters has rows, the answer is empty (a measured zero), not a Query Store precondition; a module_name miss also carries the window read (effective_start, effective_hours_back, window_truncated) as hints." + McpHelpers.WindowTruncatedDescription)] + [McpServerTool(Name = "get_query_store_top"), Description("Cost-ranked top Query Store queries (heaviest first), not time-ordered. Requires Query Store enabled on target databases. window_truncated marks a raw-tier retention floor, not a page cut: query_store_stats has no rollup, so a lowered retention_days or a young install can hold less than the window asked for. Same disclosure as Darling's get_query_store_top. <> Gets expensive queries from Query Store (persistent, survives restarts). Best for: historical analysis, queries no longer in plan cache. Requires Query Store enabled on target databases. Supports database and module filtering. Rows are per Query Store execution outcome (execution_type: Regular, Aborted, Exception): a plan with aborted executions returns one row per outcome, each with its own counts and averages. The execution_type filter keeps one outcome, and module_name keeps one module: the exact, case-sensitive schema-qualified name the collector records (get_top_procedures_by_cpu's full_name; Adhoc for ad-hoc statements, Unknown for an object it could not resolve), applied after interval deduplication and before ranking. When a filter matches nothing but the same read without the filters has rows, the answer is empty (a measured zero), not a Query Store precondition; a module_name miss also carries the window read (effective_start, effective_hours_back, window_truncated) as hints." + McpHelpers.WindowTruncatedDescription)] public static async Task GetQueryStoreTop( LocalDataService dataService, ServerManager serverManager, From 3b8d9e12ef06f6a4d1cf9f9e87dd91dae464add0 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:55:53 -0400 Subject: [PATCH 5/8] Keep get_query_store_top's head byte-identical to Darling's, per coordinator (#4231) Darling.Tests' McpToolGuideTests.EverySharedToolName_CarriesTheMarkerOnBothSkus_OrNeither_WithByteIdenticalHeads requires get_query_store_top's served head to be byte-identical on both SKUs. Darling's own #4231 PR (#4278) is a separate, still-open lane that keeps its head as on dev, so this reverts Lite's head to match dev's Darling head exactly (including its now-stale "Lite: no such floor" line) and moves the corrected #4231 explanation to the tail instead, where get_tool_guide serves it -- the payload's own window_truncated / effective_start / effective_hours_back fields (and their tests) were never wrong, only this shared head sentence. Also updates Darling.Tests/McpPayloadContractCensusTests.cs's cross-SKU rosters (WindowFloorBlocks, WindowFloorTools, CutNoteKeys) for the three Lite tools that legitimately gained McpHelpers.WindowTruncatedDescription and truncation_note in this PR -- data-only roster additions, no Darling product code touched. Verified: Darling.Tests McpToolGuideTests, McpToolGuideHeadsDataTests, McpPayloadContractCensusTests all green (81/81); Lite.Tests McpToolsListBudgetTests, McpToolGuideTests, McpToolGuideHeadsDataTests, McpToolGuideHeadsCollectionLogTests, QueryWindowTruncationTests, ServerTabCapabilityPinTests all green (37/37). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- .../McpPayloadContractCensusTests.cs | 11 +++++++++-- Lite.Tests/McpToolGuideHeads.CollectionLog.cs | 18 ++++++++++++------ .../McpToolsListBudget/McpQueryTools.txt | 2 +- Lite/Mcp/McpQueryTools.cs | 2 +- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs index 3da195b13..8217b4199 100644 --- a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs +++ b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs @@ -1510,8 +1510,8 @@ public static readonly (string Key, string[] Files, string WhatWasCut)[] SecondB public static readonly (string Key, string[] Files, string WhatItExplains)[] CutNoteKeys = [ - ("truncation_note", ["DarlingMcpDataTools.cs", "DarlingMcpQueryStoreClutterTools.cs", "DarlingMcpTools.cs", "McpAnalysisTools.cs"], - "the prose beside the flag: on get_analysis_findings (both SKUs) beside truncated, the WindowCoveringLimit read cap observed off a cap + 1 fetch; on get_query_store_top and get_query_store_clutter beside window_truncated, the #2364 window floor — the store's raw retention did not reach the whole requested window (on the clutter view, for the two arms that read the raw tier)"), + ("truncation_note", ["DarlingMcpDataTools.cs", "DarlingMcpQueryStoreClutterTools.cs", "DarlingMcpTools.cs", "McpAnalysisTools.cs", "McpQueryTools.cs"], + "the prose beside the flag: on get_analysis_findings (both SKUs) beside truncated, the WindowCoveringLimit read cap observed off a cap + 1 fetch; on get_query_store_top and get_query_store_clutter beside window_truncated, the #2364 window floor — the store's raw retention did not reach the whole requested window (on the clutter view, for the two arms that read the raw tier); on Lite's get_top_queries_by_cpu / get_top_procedures_by_cpu / get_query_store_top (#4231), the same window-floor note, from LocalDataService.GetQueryWindowFloorAsync"), ]; public static readonly (string Key, string[] Files, string WhatWasCut)[] SourceSideCutKeys = @@ -1752,6 +1752,10 @@ read as hints so "no rows matched" is never read as a claim about the part the r ("DarlingMcpTrendTools.cs", "envelope", 1), ("DarlingMcpTrendTools.cs", "initializer", 1), ("McpQueryTools.cs", "envelope", 1), + /* #4231: Lite's own get_query_store_top initializer (mirroring DarlingMcpDataTools.cs's 2, but Lite's + get_query_store_top has no module_name-miss hint block of its own, so 1), plus get_top_queries_by_cpu + and get_top_procedures_by_cpu, newly given the same raw-tier disclosure. */ + ("McpQueryTools.cs", "initializer", 4), ]; public static readonly (string File, string Tool)[] WindowFloorTools = @@ -1765,7 +1769,10 @@ public static readonly (string File, string Tool)[] WindowFloorTools = ("McpQueryTools.cs", "get_procedure_duration_trend"), ("McpQueryTools.cs", "get_query_duration_trend"), ("McpQueryTools.cs", "get_query_store_duration_trend"), + ("McpQueryTools.cs", "get_query_store_top"), ("McpQueryTools.cs", "get_query_trend"), + ("McpQueryTools.cs", "get_top_procedures_by_cpu"), + ("McpQueryTools.cs", "get_top_queries_by_cpu"), ]; /// The reach key: the one neighbour only the window floor has. diff --git a/Lite.Tests/McpToolGuideHeads.CollectionLog.cs b/Lite.Tests/McpToolGuideHeads.CollectionLog.cs index b1232ebb6..2d85bf3e4 100644 --- a/Lite.Tests/McpToolGuideHeads.CollectionLog.cs +++ b/Lite.Tests/McpToolGuideHeads.CollectionLog.cs @@ -32,7 +32,7 @@ private static readonly (string Tool, string Fact)[] HeadFacts = ("get_collection_log", "an unknown value is refused, never silently empty"), ("get_query_store_top", "window_truncated"), ("get_query_store_top", "not a page cut"), - ("get_query_store_top", "raw-tier retention floor"), + ("get_query_store_top", "Lite: no such floor"), ]; [Fact] @@ -74,14 +74,20 @@ public void CollectionLogTail_DropsTheIssueReference_ButKeepsTheRule() } /// - /// #4231: Lite gained the same raw-tier window floor Darling already had, so the head no longer splits - /// "Darling: has one. Lite: does not" -- it names the same disclosure both SKUs now carry. + /// #4231: Lite gained the same raw-tier window floor Darling already had (LocalDataService. + /// GetQueryWindowFloorAsync), so the head's "Lite: no such floor" line is now stale prose. It survives here + /// UNCHANGED anyway: this head sentence is shared, byte-identical, cross-SKU text (Darling.Tests' + /// McpToolGuideTests lockstep pin), so it can only change in a coordinated PR that updates both SKUs' heads + /// together. The correction lives in the tail instead (right after <<GUIDE>>) and on the + /// payload's own window_truncated / effective_start / effective_hours_back, which this file's other tests + /// (and QueryWindowTruncationTests on the Lite side) already pin as truthful. /// [Fact] - public void QueryStoreTop_HeadNamesTheSameDisclosureAsDarling() + public void QueryStoreTop_WindowFloorClause_IsScopedToDarlingInTheHead() { var served = McpToolGuideTests.Served("get_query_store_top"); - Assert.Contains("window_truncated", served.Served, StringComparison.Ordinal); - Assert.Contains("Same disclosure as Darling's get_query_store_top", served.Served, StringComparison.Ordinal); + Assert.Contains("Darling: window_truncated", served.Served, StringComparison.Ordinal); + Assert.Contains("Lite: no such floor", served.Served, StringComparison.Ordinal); + Assert.Contains("that head sentence is shared, byte-identical, cross-SKU text", served.Tail, StringComparison.Ordinal); } } diff --git a/Lite.Tests/McpToolsListBudget/McpQueryTools.txt b/Lite.Tests/McpToolsListBudget/McpQueryTools.txt index 2a83162f8..803142216 100644 --- a/Lite.Tests/McpToolsListBudget/McpQueryTools.txt +++ b/Lite.Tests/McpToolsListBudget/McpQueryTools.txt @@ -34,7 +34,7 @@ param get_query_store_regressions.hours_back 110 param get_query_store_regressions.limit 164 param get_query_store_regressions.server_name 28 -tool get_query_store_top 390 +tool get_query_store_top 422 param get_query_store_top.as_of 167 param get_query_store_top.database_name 30 param get_query_store_top.execution_type 72 diff --git a/Lite/Mcp/McpQueryTools.cs b/Lite/Mcp/McpQueryTools.cs index 02cdcad92..ce8e05f65 100644 --- a/Lite/Mcp/McpQueryTools.cs +++ b/Lite/Mcp/McpQueryTools.cs @@ -271,7 +271,7 @@ public static async Task GetTopProceduresByCpu( } } - [McpServerTool(Name = "get_query_store_top"), Description("Cost-ranked top Query Store queries (heaviest first), not time-ordered. Requires Query Store enabled on target databases. window_truncated marks a raw-tier retention floor, not a page cut: query_store_stats has no rollup, so a lowered retention_days or a young install can hold less than the window asked for. Same disclosure as Darling's get_query_store_top. <> Gets expensive queries from Query Store (persistent, survives restarts). Best for: historical analysis, queries no longer in plan cache. Requires Query Store enabled on target databases. Supports database and module filtering. Rows are per Query Store execution outcome (execution_type: Regular, Aborted, Exception): a plan with aborted executions returns one row per outcome, each with its own counts and averages. The execution_type filter keeps one outcome, and module_name keeps one module: the exact, case-sensitive schema-qualified name the collector records (get_top_procedures_by_cpu's full_name; Adhoc for ad-hoc statements, Unknown for an object it could not resolve), applied after interval deduplication and before ranking. When a filter matches nothing but the same read without the filters has rows, the answer is empty (a measured zero), not a Query Store precondition; a module_name miss also carries the window read (effective_start, effective_hours_back, window_truncated) as hints." + McpHelpers.WindowTruncatedDescription)] + [McpServerTool(Name = "get_query_store_top"), Description("Cost-ranked top Query Store queries (heaviest first), not time-ordered. Requires Query Store enabled on target databases. Darling: window_truncated marks a window floor, not a page cut — no limit changes it — because raw retention can be shorter than asked; effective_start / effective_hours_back give the reach actually served. Lite: no such floor; the full requested window is always read. <> Gets expensive queries from Query Store (persistent, survives restarts). Best for: historical analysis, queries no longer in plan cache. Requires Query Store enabled on target databases. Supports database and module filtering. #4231 WIRE CHANGE: that head sentence is shared, byte-identical, cross-SKU text (Darling.Tests' McpToolGuideTests lockstep pin) and is stale for Lite as of this change -- Lite NOW has the same raw-tier window floor Darling does, via LocalDataService.GetQueryWindowFloorAsync; trust window_truncated / effective_start / effective_hours_back on the payload (and McpHelpers.WindowTruncatedDescription below), not the head's 'no such floor' line, until a coordinated PR updates both SKUs' heads together. Rows are per Query Store execution outcome (execution_type: Regular, Aborted, Exception): a plan with aborted executions returns one row per outcome, each with its own counts and averages. The execution_type filter keeps one outcome, and module_name keeps one module: the exact, case-sensitive schema-qualified name the collector records (get_top_procedures_by_cpu's full_name; Adhoc for ad-hoc statements, Unknown for an object it could not resolve), applied after interval deduplication and before ranking. When a filter matches nothing but the same read without the filters has rows, the answer is empty (a measured zero), not a Query Store precondition; a module_name miss also carries the window read (effective_start, effective_hours_back, window_truncated) as hints." + McpHelpers.WindowTruncatedDescription)] public static async Task GetQueryStoreTop( LocalDataService dataService, ServerManager serverManager, From 0ad7d3d9d9b4689e3ada365f96f273c020f4cda1 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:22:50 -0400 Subject: [PATCH 6/8] Give the window-truncated banner the same UTC window the grid read (#4279) The slicer handlers passed server-local fromServer/toServer (ServerTimeHelper.ToServerTime) to RefreshWindowTruncatedBannerAsync, and the custom-range refresh path passed server-local cStart/cEnd, while GetQueryWindowFloorAsync compares straight against UTC collection_time. On any server not on UTC the banner probed a window shifted by the server's offset. Slicers now pass e.StartUtc/e.EndUtc directly. Refresh.cs computes the banner's window through a new internal LocalDataService.GetQueriesTabWindowUtc helper -- the same GetTimeRange call GetTopQueriesByCpuAsync/GetTopProceduresByCpuAsync/GetQueryStoreTopQueriesAsync already use for their own window -- so the grid and its banner can never drift onto two different ranges again. The comparison calls on the same lines (RefreshQueryStatsComparisonAsync and its two twins) are deliberately untouched: GetComparisonRange() (ServerTab.Comparison.cs:59) is UTC for the default hoursBack window but server-local for a custom range, the same split as the bug just fixed, so giving the "current" side a UTC window while the baseline stays server-local would desync them instead of fixing them. That needs GetComparisonRange() itself rerouted, which is a separate design decision. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- Lite.Tests/QueryWindowTruncationTests.cs | 77 ++++++++++++++++++++++++ Lite/Controls/ServerTab.Refresh.cs | 43 ++++++++++--- Lite/Controls/ServerTab.Slicers.cs | 12 +++- Lite/Services/LocalDataService.cs | 21 +++++++ 4 files changed, 140 insertions(+), 13 deletions(-) diff --git a/Lite.Tests/QueryWindowTruncationTests.cs b/Lite.Tests/QueryWindowTruncationTests.cs index c3e043fd7..529b0c48a 100644 --- a/Lite.Tests/QueryWindowTruncationTests.cs +++ b/Lite.Tests/QueryWindowTruncationTests.cs @@ -398,6 +398,83 @@ public void QueriesTabGridReads_RouteThroughSharedWindowFloorHelper() } } + /// + /// #4279: collection_time is UTC and compares + /// straight against it, no offset conversion. Pins -- + /// the SAME GetTimeRange custom-range branch GetTopQueriesByCpuAsync/etc. use for the grid's + /// OWN window -- against a non-zero offset, so a caller that stops converting (or converts the wrong + /// direction) fails loudly rather than only on a server that happens to run UTC. + /// + [Fact] + public void GetQueriesTabWindowUtc_CustomRange_ConvertsServerLocalPickersBackToUtc() + { + const int utcOffsetMinutes = -240; // UTC-4: server-local clock reads 4 hours BEHIND UTC. + var fromDate = new DateTime(2026, 1, 15, 8, 0, 0, DateTimeKind.Unspecified); + var toDate = new DateTime(2026, 1, 15, 10, 0, 0, DateTimeKind.Unspecified); + + var (startUtc, endUtc) = LocalDataService.GetQueriesTabWindowUtc(24, fromDate, toDate, utcOffsetMinutes); + + // Server-local is 4 hours behind UTC, so converting back to UTC ADDS 4 hours. + Assert.Equal(fromDate.AddMinutes(240), startUtc); + Assert.Equal(toDate.AddMinutes(240), endUtc); + } + + /// + /// #4279: OnXSlicerChanged (ServerTab.Slicers.cs) now passes e.StartUtc/e.EndUtc to the + /// banner untouched, while the grid read beside it converts the SAME e.StartUtc/e.EndUtc to + /// server-local (ServerTimeHelper.ToServerTime: adds the offset) and then back to UTC + /// ('s custom-range branch: subtracts it again). This + /// pins that the round trip is a no-op, i.e. that the banner's un-converted UTC bounds equal what the grid + /// actually reads -- inlines ToServerTime's own AddMinutes formula rather than mutating the + /// process-global ServerTimeHelper.UtcOffsetMinutes, which parallel test classes also read. + /// + [Fact] + public void SlicerBannerWindow_MatchesTheGridsUtcWindow_ForANonUtcServer() + { + const int utcOffsetMinutes = -240; + var startUtc = new DateTime(2026, 1, 15, 8, 0, 0, DateTimeKind.Unspecified); + var endUtc = new DateTime(2026, 1, 15, 10, 0, 0, DateTimeKind.Unspecified); + + var fromServer = startUtc.AddMinutes(utcOffsetMinutes); // ServerTimeHelper.ToServerTime's formula + var toServer = endUtc.AddMinutes(utcOffsetMinutes); + + var (gridStartUtc, gridEndUtc) = LocalDataService.GetQueriesTabWindowUtc(24, fromServer, toServer, utcOffsetMinutes); + + Assert.Equal(startUtc, gridStartUtc); + Assert.Equal(endUtc, gridEndUtc); + } + + /// + /// #4279 revert-proof: text-scans SOURCE (matching 's + /// mechanism) so a future edit that quietly goes back to feeding the banner server-local + /// fromServer/toServer or cStart/cStart2/cStart3 fails a test even though those names still compile fine + /// (they are plain DateTimes either way). Confirmed by reverting ServerTab.Slicers.cs and + /// ServerTab.Refresh.cs to 3b8d9e12 (pre-fix): both assertions below failed before the fix. + /// + [Fact] + public void WindowTruncatedBannerCallSites_TakeUtcBounds_NotServerLocalOnes() + { + var slicersSource = File.ReadAllText(ControlsFile("ServerTab.Slicers.cs")); + var slicerBannerCallsOnUtc = Regex.Matches(slicersSource, + @"RefreshWindowTruncatedBannerAsync\(\s*QueryWindowRelation\.\w+,\s*\w+,\s*e\.StartUtc,\s*e\.EndUtc\)").Count; + Assert.True(slicerBannerCallsOnUtc == 3, + $"expected all 3 OnXSlicerChanged banner calls to pass e.StartUtc, e.EndUtc (found {slicerBannerCallsOnUtc}) " + + "-- fromServer/toServer are server-local and GetQueryWindowFloorAsync compares them straight against " + + "UTC collection_time (#4279)."); + Assert.False(Regex.IsMatch(slicersSource, @"RefreshWindowTruncatedBannerAsync\([^)]*fromServer,\s*toServer\)"), + "a slicer banner call still passes server-local fromServer/toServer (#4279)."); + + var refreshSource = File.ReadAllText(ControlsFile("ServerTab.Refresh.cs")); + var refreshBannerCallsOnHelperOutput = Regex.Matches(refreshSource, + @"RefreshWindowTruncatedBannerAsync\(\s*QueryWindowRelation\.\w+,\s*\w+,\s*bannerStart\d?,\s*bannerEnd\d?\)").Count; + Assert.True(refreshBannerCallsOnHelperOutput == 6, + $"expected all 6 ServerTab.Refresh.cs banner calls to pass a GetQueriesTabWindowUtc result " + + $"(bannerStart/bannerEnd) (found {refreshBannerCallsOnHelperOutput}) -- cStart/cStart2/cStart3 are " + + "server-local under a custom range and feed the (deliberately untouched -- see ServerTab.Comparison.cs) " + + "comparison calls on the same lines, not the banner (#4279)."); + Assert.Equal(6, Regex.Matches(refreshSource, @"LocalDataService\.GetQueriesTabWindowUtc\(").Count); + } + private static string ControlsFile(string name) => Path.Combine(ControlsDir(), name); private static string ControlsDir([CallerFilePath] string thisFile = "") => diff --git a/Lite/Controls/ServerTab.Refresh.cs b/Lite/Controls/ServerTab.Refresh.cs index a310b3e0b..3517ecec5 100644 --- a/Lite/Controls/ServerTab.Refresh.cs +++ b/Lite/Controls/ServerTab.Refresh.cs @@ -263,7 +263,14 @@ private async System.Threading.Tasks.Task RefreshQueriesAsync(int hoursBack, Dat var cEnd = toDate ?? DateTime.UtcNow; var cStart = fromDate ?? cEnd.AddHours(-hoursBack); await RefreshQueryStatsComparisonAsync(cStart, cEnd); - await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QueryStats, QueryStatsWindowTruncatedBanner, cStart, cEnd); + /* #4279: the banner probes UTC collection_time, so it takes the SAME UTC window + GetTopQueriesByCpuAsync just read (LocalDataService.GetQueriesTabWindowUtc), not + cStart/cEnd -- those are server-local under a custom range (GetCurrentWindow + converts the pickers into server time) and stay that way here on purpose, + because RefreshQueryStatsComparisonAsync's baseline (GetComparisonRange, + ServerTab.Comparison.cs) is server-local under a custom range too. */ + var (bannerStart, bannerEnd) = LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate, ServerTimeHelper.UtcOffsetMinutes); + await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QueryStats, QueryStatsWindowTruncatedBanner, bannerStart, bannerEnd); } break; case 3: // Top Procedures by Duration @@ -275,7 +282,9 @@ private async System.Threading.Tasks.Task RefreshQueriesAsync(int hoursBack, Dat var cEnd = toDate ?? DateTime.UtcNow; var cStart = fromDate ?? cEnd.AddHours(-hoursBack); await RefreshProcStatsComparisonAsync(cStart, cEnd); - await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.ProcedureStats, ProcStatsWindowTruncatedBanner, cStart, cEnd); + /* #4279: UTC probe, UTC bounds -- see the twin comment on the Top Queries case above. */ + var (bannerStart, bannerEnd) = LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate, ServerTimeHelper.UtcOffsetMinutes); + await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.ProcedureStats, ProcStatsWindowTruncatedBanner, bannerStart, bannerEnd); } break; case 4: // Query Store by Duration @@ -287,7 +296,9 @@ private async System.Threading.Tasks.Task RefreshQueriesAsync(int hoursBack, Dat var cEnd = toDate ?? DateTime.UtcNow; var cStart = fromDate ?? cEnd.AddHours(-hoursBack); await RefreshQueryStoreComparisonAsync(cStart, cEnd); - await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QueryStoreStats, QueryStoreWindowTruncatedBanner, cStart, cEnd); + /* #4279: UTC probe, UTC bounds -- see the twin comment on the Top Queries case above. */ + var (bannerStart, bannerEnd) = LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate, ServerTimeHelper.UtcOffsetMinutes); + await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QueryStoreStats, QueryStoreWindowTruncatedBanner, bannerStart, bannerEnd); } break; case 5: // Plan Corrections @@ -340,7 +351,9 @@ await System.Threading.Tasks.Task.WhenAll( var cEnd = toDate ?? DateTime.UtcNow; var cStart = fromDate ?? cEnd.AddHours(-hoursBack); await RefreshQueryStatsComparisonAsync(cStart, cEnd); - await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QueryStats, QueryStatsWindowTruncatedBanner, cStart, cEnd); + /* #4279: UTC probe, UTC bounds -- see the twin comment on the sub-tab-switch case above. */ + var (bannerStart, bannerEnd) = LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate, ServerTimeHelper.UtcOffsetMinutes); + await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QueryStats, QueryStatsWindowTruncatedBanner, bannerStart, bannerEnd); } _procStatsFilterMgr!.UpdateData(procStatsTask.Result); SetDefaultSortIfNone(ProcedureStatsGrid, "TotalElapsedMs", ListSortDirection.Descending); @@ -349,7 +362,9 @@ await System.Threading.Tasks.Task.WhenAll( var cEnd2 = toDate ?? DateTime.UtcNow; var cStart2 = fromDate ?? cEnd2.AddHours(-hoursBack); await RefreshProcStatsComparisonAsync(cStart2, cEnd2); - await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.ProcedureStats, ProcStatsWindowTruncatedBanner, cStart2, cEnd2); + /* #4279: UTC probe, UTC bounds -- see the twin comment on the sub-tab-switch case above. */ + var (bannerStart2, bannerEnd2) = LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate, ServerTimeHelper.UtcOffsetMinutes); + await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.ProcedureStats, ProcStatsWindowTruncatedBanner, bannerStart2, bannerEnd2); } _queryStoreFilterMgr!.UpdateData(queryStoreTask.Result); SetDefaultSortIfNone(QueryStoreGrid, "TotalDurationMs", ListSortDirection.Descending); @@ -358,7 +373,9 @@ await System.Threading.Tasks.Task.WhenAll( var cEnd3 = toDate ?? DateTime.UtcNow; var cStart3 = fromDate ?? cEnd3.AddHours(-hoursBack); await RefreshQueryStoreComparisonAsync(cStart3, cEnd3); - await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QueryStoreStats, QueryStoreWindowTruncatedBanner, cStart3, cEnd3); + /* #4279: UTC probe, UTC bounds -- see the twin comment on the sub-tab-switch case above. */ + var (bannerStart3, bannerEnd3) = LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate, ServerTimeHelper.UtcOffsetMinutes); + await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QueryStoreStats, QueryStoreWindowTruncatedBanner, bannerStart3, bannerEnd3); } _planCorrectionFilterMgr!.UpdateData(planCorrectionTask.Result); SetDefaultSortIfNone(PlanCorrectionGrid, "Score", ListSortDirection.Descending); @@ -385,12 +402,18 @@ await System.Threading.Tasks.Task.WhenAll( /// can itself start after the raw table's floor, so it needs the same disclosure. No try/catch here: every /// caller already runs inside its own (ServerTabCapabilityPinTests pins that every OnXSlicerChanged keeps /// its own try/catch; RefreshQueriesAsync has one around the whole sub-tab switch). + /// + /// #4279: / MUST be the same UTC window the + /// matching grid read (, or a slicer's own + /// SlicerRangeEventArgs.StartUtc/EndUtc) -- + /// compares them straight against UTC collection_time, with no offset conversion of its own. A + /// server-local pair here silently shifts the probed window by the server's UTC offset. /// - private async System.Threading.Tasks.Task RefreshWindowTruncatedBannerAsync(QueryWindowRelation relation, TextBlock banner, DateTime start, DateTime end) + private async System.Threading.Tasks.Task RefreshWindowTruncatedBannerAsync(QueryWindowRelation relation, TextBlock banner, DateTime startUtc, DateTime endUtc) { - var floor = await Task.Run(() => _dataService.GetQueryWindowFloorAsync(relation, _serverId, start, end)); - var truncated = McpQueryTools.IsWindowTruncated(floor, start); - SetWindowTruncatedBanner(banner, truncated, floor ?? start); + var floor = await Task.Run(() => _dataService.GetQueryWindowFloorAsync(relation, _serverId, startUtc, endUtc)); + var truncated = McpQueryTools.IsWindowTruncated(floor, startUtc); + SetWindowTruncatedBanner(banner, truncated, floor ?? startUtc); } /// diff --git a/Lite/Controls/ServerTab.Slicers.cs b/Lite/Controls/ServerTab.Slicers.cs index 347999926..ba78bfec1 100644 --- a/Lite/Controls/ServerTab.Slicers.cs +++ b/Lite/Controls/ServerTab.Slicers.cs @@ -173,7 +173,11 @@ private async void OnQueryStatsSlicerChanged(object? sender, Controls.SlicerRang var queryStats = await Task.Run(() => _dataService.GetTopQueriesByCpuAsync(_serverId, 0, 50, fromServer, toServer, UtcOffsetMinutes, SelectedDatabaseFilter)); _queryStatsFilterMgr!.UpdateData(queryStats); await RefreshQueryStatsComparisonAsync(fromServer, toServer); - await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QueryStats, QueryStatsWindowTruncatedBanner, fromServer, toServer); + /* #4279: the banner probes UTC collection_time directly (LocalDataService.GetQueryWindowFloorAsync), + so it takes e.StartUtc/e.EndUtc, not the server-local fromServer/toServer the grid read and the + comparison call above use (those convert back to UTC themselves, or -- for the comparison -- share + GetComparisonRange's own basis; see ServerTab.Comparison.cs). */ + await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QueryStats, QueryStatsWindowTruncatedBanner, e.StartUtc, e.EndUtc); } catch (Exception ex) { @@ -225,7 +229,8 @@ private async void OnQueryStoreSlicerChanged(object? sender, Controls.SlicerRang var qsData = await Task.Run(() => _dataService.GetQueryStoreTopQueriesAsync(_serverId, 0, 50, fromServer, toServer, SelectedDatabaseFilter)); _queryStoreFilterMgr!.UpdateData(qsData); await RefreshQueryStoreComparisonAsync(fromServer, toServer); - await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QueryStoreStats, QueryStoreWindowTruncatedBanner, fromServer, toServer); + /* #4279: UTC probe, UTC bounds -- see the twin comment in OnQueryStatsSlicerChanged above. */ + await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QueryStoreStats, QueryStoreWindowTruncatedBanner, e.StartUtc, e.EndUtc); } catch (Exception ex) { @@ -277,7 +282,8 @@ private async void OnProcStatsSlicerChanged(object? sender, Controls.SlicerRange var procStats = await Task.Run(() => _dataService.GetTopProceduresByCpuAsync(_serverId, 0, 50, fromServer, toServer, UtcOffsetMinutes, SelectedDatabaseFilter)); _procStatsFilterMgr!.UpdateData(procStats); await RefreshProcStatsComparisonAsync(fromServer, toServer); - await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.ProcedureStats, ProcStatsWindowTruncatedBanner, fromServer, toServer); + /* #4279: UTC probe, UTC bounds -- see the twin comment in OnQueryStatsSlicerChanged above. */ + await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.ProcedureStats, ProcStatsWindowTruncatedBanner, e.StartUtc, e.EndUtc); } catch (Exception ex) { diff --git a/Lite/Services/LocalDataService.cs b/Lite/Services/LocalDataService.cs index 760909ef8..b616bdebc 100644 --- a/Lite/Services/LocalDataService.cs +++ b/Lite/Services/LocalDataService.cs @@ -252,6 +252,27 @@ through that branch would silently shift the window by the monitored server's of return (anchor.AddHours(-hoursBack), anchor); } + /// + /// #4279: the exact UTC window GetTopQueriesByCpuAsync / GetTopProceduresByCpuAsync / + /// GetQueryStoreTopQueriesAsync read for the Queries tab's three grids -- the SAME + /// call those three make internally. internal (not private) so ServerTab.RefreshWindowTruncatedBannerAsync + /// (ServerTab.Refresh.cs) can probe this exact window instead of recomputing its own copy: before this + /// existed, the banner's custom-range window came from server-local cStart/cEnd + /// (toDate ?? DateTime.UtcNow / fromDate ?? ..., with fromDate/toDate already converted to + /// server time by ServerTab.GetCurrentWindow) while GetTopQueriesByCpuAsync's own window went through + /// GetTimeRange's custom-range branch and came out UTC -- the grid and its banner silently disagreed on + /// any server not on UTC (#4279). + /// + /// + /// The SAME offset the caller's ServerTab.GetCurrentWindow used to produce / + /// -- the selected tab's ServerTimeHelper.UtcOffsetMinutes, not + /// necessarily this tab's own. GetTimeRange's custom-range branch subtracts this same value back out, so + /// a mismatched offset here breaks the round trip the same way a mismatched one breaks it inside + /// GetTopQueriesByCpuAsync. + /// + internal static (DateTime startUtc, DateTime endUtc) GetQueriesTabWindowUtc(int hoursBack, DateTime? fromDate, DateTime? toDate, int utcOffsetMinutes) + => GetTimeRange(hoursBack, fromDate, toDate, asOfUtc: null, utcOffsetMinutes); + /// /// Gets the time range in server local time (for tables like cpu_utilization_stats.sample_time). /// From 4226552ea499174a53cca817059f40b27e7012a7 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:24:02 -0400 Subject: [PATCH 7/8] One shared window-floor head, byte-identical in Lite and Darling (#4231) get_query_store_top's head drops the "Darling: "/"Lite: no such floor" split for one shared sentence: window_truncated is a window floor, not a page cut, because stored history can be shorter than asked. Both get_top_queries_by_cpu and get_top_procedures_by_cpu gain the same sentence in their heads (both stay under the 620-char budget). Lite's CPU-tool tails now order McpHelpers.WindowTruncatedDescription before McpToolGuideTopics.CpuTimeExtremesAndAttribution, matching Darling, and all four now join with exactly one space at each seam. Updates the pin tests that expected the old split text: the census carve-out for the two CPU tools, both McpToolGuideHeads.CollectionLog twin tests (rewritten as one shared-head assertion), and new guardrail rows in both McpToolGuideHeads.Data files. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- .../McpPayloadContractCensusTests.cs | 19 ++------------- .../McpToolGuideHeads.CollectionLog.cs | 14 ++++++----- .../Darling.Tests/McpToolGuideHeads.Data.cs | 4 ++++ .../Mcp/DarlingMcpDataTools.cs | 6 ++--- Lite.Tests/McpToolGuideHeads.CollectionLog.cs | 23 ++++++++----------- Lite.Tests/McpToolGuideHeads.Data.cs | 16 +++++++------ Lite/Mcp/McpQueryTools.cs | 4 ++-- 7 files changed, 37 insertions(+), 49 deletions(-) diff --git a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs index d75bdcd3b..f177eaeb2 100644 --- a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs +++ b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs @@ -2010,23 +2010,8 @@ 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; - 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.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/McpToolGuideHeads.CollectionLog.cs b/Darling/Darling.Tests/McpToolGuideHeads.CollectionLog.cs index d9e0601a3..69272e0f2 100644 --- a/Darling/Darling.Tests/McpToolGuideHeads.CollectionLog.cs +++ b/Darling/Darling.Tests/McpToolGuideHeads.CollectionLog.cs @@ -35,7 +35,6 @@ private static readonly (string Tool, string Fact)[] HeadFacts = ("get_collection_log", "an unknown value is refused, never silently empty"), ("get_query_store_top", "window_truncated"), ("get_query_store_top", "not a page cut"), - ("get_query_store_top", "Lite: no such floor"), ]; [Fact] @@ -67,13 +66,16 @@ public void CollectionLogTail_CarriesTheThreeHeldBackParameterRemainders() Assert.Contains("status: THE FAILURE FILTER", tail, StringComparison.Ordinal); } - /// get_query_store_top's window-floor clause (#2364/#3653) is Darling-only; the head says so in one - /// clause rather than asserting it's true for Lite too (D6), and Lite's own tail never claims it. + /// #4231: get_query_store_top's window-floor clause (#2364/#3653) is now one shared, byte-identical + /// head across both SKUs -- neither the old "Darling: " qualifier nor the false "Lite: no such floor" line + /// survives. [Fact] - public void QueryStoreTop_WindowFloorClause_IsScopedToDarlingInTheHead() + public void QueryStoreTop_WindowFloorClause_IsSharedAcrossBothSkusInTheHead() { var served = McpToolGuideTests.Served("get_query_store_top"); - Assert.Contains("Darling: window_truncated", served.Served, StringComparison.Ordinal); - Assert.Contains("Lite: no such floor", served.Served, StringComparison.Ordinal); + Assert.Contains("window_truncated", served.Served, StringComparison.Ordinal); + Assert.Contains("not a page cut", served.Served, StringComparison.Ordinal); + Assert.DoesNotContain("Darling: window_truncated", served.Served, StringComparison.Ordinal); + Assert.DoesNotContain("Lite: no such floor", served.Served, StringComparison.Ordinal); } } diff --git a/Darling/Darling.Tests/McpToolGuideHeads.Data.cs b/Darling/Darling.Tests/McpToolGuideHeads.Data.cs index 659897c5d..2ac550630 100644 --- a/Darling/Darling.Tests/McpToolGuideHeads.Data.cs +++ b/Darling/Darling.Tests/McpToolGuideHeads.Data.cs @@ -43,8 +43,12 @@ private static readonly (string Tool, string Fact)[] HeadFacts = ("get_perfmon_stats", "LATEST IS A TIME: the newest snapshot, not a window"), ("get_server_properties", "LATEST IS A TIME: the newest snapshot, not a window"), ("get_top_procedures_by_cpu", "LIFETIME extremes, not windowed"), + ("get_top_procedures_by_cpu", "window_truncated"), + ("get_top_procedures_by_cpu", "not a page cut"), ("get_top_queries_by_cpu", "LIFETIME extremes, not windowed"), ("get_top_queries_by_cpu", "an empty page under it is the window's real answer, not a miss"), + ("get_top_queries_by_cpu", "window_truncated"), + ("get_top_queries_by_cpu", "not a page cut"), ("get_wait_stats", "Bounded by limit"), ("list_servers", "Darling has no live connection to monitored servers"), ("list_servers", "Lite's status IS a live connection check"), diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs index 077d6d055..7c24c9da0 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. " + McpHelpers.WindowTruncatedDescription + 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. window_truncated marks a window floor, not a page cut; effective_start / effective_hours_back give the reach actually served. <> 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. " + McpHelpers.WindowTruncatedDescription + 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. window_truncated marks a window floor, not a page cut; effective_start / effective_hours_back give the reach actually served. <> 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, @@ -803,7 +803,7 @@ same concurrent independent reads. */ /// private const int QueryTextPreviewLength = 400; - [McpServerTool(Name = "get_query_store_top"), Description("Cost-ranked top Query Store queries (heaviest first), not time-ordered. Requires Query Store enabled on target databases. Darling: window_truncated marks a window floor, not a page cut — no limit changes it — because raw retention can be shorter than asked; effective_start / effective_hours_back give the reach actually served. Lite: no such floor; the full requested window is always read. <> Gets expensive queries from Query Store (persistent, survives restarts). Best for: historical analysis, queries no longer in plan cache. Requires Query Store enabled on target databases. Supports database and module filtering. Reads the raw tier only (the corrected rollups carry no query_id or plan_id), which on a store with the rollups armed is dropped at 4 days. Rows are per Query Store execution outcome (execution_type: Regular, Aborted, Exception): a plan with aborted executions returns one row per outcome, each with its own counts and averages. The execution_type filter keeps one outcome, and module_name keeps one module: the exact, case-sensitive schema-qualified name the collector records (get_top_procedures_by_cpu's full_name; Adhoc for ad-hoc statements, Unknown for an object it could not resolve), applied after interval deduplication and before ranking. When a filter matches nothing but the same read without the filters has rows, the answer is empty (a measured zero), not a Query Store precondition; a module_name miss also carries the window read (effective_start, effective_hours_back, window_truncated) as hints. query_text is a 400-character preview by default (query_text_truncated marks a cut row); full_text=true returns each row's whole statement." + McpHelpers.WindowTruncatedDescription)] + [McpServerTool(Name = "get_query_store_top"), Description("Cost-ranked top Query Store queries (heaviest first), not time-ordered. Requires Query Store enabled on target databases. window_truncated marks a window floor, not a page cut — no limit changes it — because stored history can be shorter than asked; effective_start / effective_hours_back give the reach actually served. <> Gets expensive queries from Query Store (persistent, survives restarts). Best for: historical analysis, queries no longer in plan cache. Requires Query Store enabled on target databases. Supports database and module filtering. Reads the raw tier only (the corrected rollups carry no query_id or plan_id), which on a store with the rollups armed is dropped at 4 days. Rows are per Query Store execution outcome (execution_type: Regular, Aborted, Exception): a plan with aborted executions returns one row per outcome, each with its own counts and averages. The execution_type filter keeps one outcome, and module_name keeps one module: the exact, case-sensitive schema-qualified name the collector records (get_top_procedures_by_cpu's full_name; Adhoc for ad-hoc statements, Unknown for an object it could not resolve), applied after interval deduplication and before ranking. When a filter matches nothing but the same read without the filters has rows, the answer is empty (a measured zero), not a Query Store precondition; a module_name miss also carries the window read (effective_start, effective_hours_back, window_truncated) as hints. query_text is a 400-character preview by default (query_text_truncated marks a cut row); full_text=true returns each row's whole statement." + McpHelpers.WindowTruncatedDescription)] public static Task GetQueryStoreTop( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, diff --git a/Lite.Tests/McpToolGuideHeads.CollectionLog.cs b/Lite.Tests/McpToolGuideHeads.CollectionLog.cs index 2d85bf3e4..c56af2278 100644 --- a/Lite.Tests/McpToolGuideHeads.CollectionLog.cs +++ b/Lite.Tests/McpToolGuideHeads.CollectionLog.cs @@ -32,7 +32,6 @@ private static readonly (string Tool, string Fact)[] HeadFacts = ("get_collection_log", "an unknown value is refused, never silently empty"), ("get_query_store_top", "window_truncated"), ("get_query_store_top", "not a page cut"), - ("get_query_store_top", "Lite: no such floor"), ]; [Fact] @@ -73,21 +72,17 @@ public void CollectionLogTail_DropsTheIssueReference_ButKeepsTheRule() Assert.Contains("this SKU never enables the deferred plan-XML or statement-text fetches", tail, StringComparison.Ordinal); } - /// - /// #4231: Lite gained the same raw-tier window floor Darling already had (LocalDataService. - /// GetQueryWindowFloorAsync), so the head's "Lite: no such floor" line is now stale prose. It survives here - /// UNCHANGED anyway: this head sentence is shared, byte-identical, cross-SKU text (Darling.Tests' - /// McpToolGuideTests lockstep pin), so it can only change in a coordinated PR that updates both SKUs' heads - /// together. The correction lives in the tail instead (right after <<GUIDE>>) and on the - /// payload's own window_truncated / effective_start / effective_hours_back, which this file's other tests - /// (and QueryWindowTruncationTests on the Lite side) already pin as truthful. - /// + /// #4231: get_query_store_top's window-floor clause is now one shared, byte-identical head across + /// both SKUs -- neither the old "Darling: " qualifier nor the false "Lite: no such floor" line survives. + /// Darling's twin is Darling.Tests/McpToolGuideHeadsCollectionLogTests. + /// QueryStoreTop_WindowFloorClause_IsSharedAcrossBothSkusInTheHead. [Fact] - public void QueryStoreTop_WindowFloorClause_IsScopedToDarlingInTheHead() + public void QueryStoreTop_WindowFloorClause_IsSharedAcrossBothSkusInTheHead() { var served = McpToolGuideTests.Served("get_query_store_top"); - Assert.Contains("Darling: window_truncated", served.Served, StringComparison.Ordinal); - Assert.Contains("Lite: no such floor", served.Served, StringComparison.Ordinal); - Assert.Contains("that head sentence is shared, byte-identical, cross-SKU text", served.Tail, StringComparison.Ordinal); + Assert.Contains("window_truncated", served.Served, StringComparison.Ordinal); + Assert.Contains("not a page cut", served.Served, StringComparison.Ordinal); + Assert.DoesNotContain("Darling: window_truncated", served.Served, StringComparison.Ordinal); + Assert.DoesNotContain("Lite: no such floor", served.Served, StringComparison.Ordinal); } } diff --git a/Lite.Tests/McpToolGuideHeads.Data.cs b/Lite.Tests/McpToolGuideHeads.Data.cs index 558677c9b..98b882b69 100644 --- a/Lite.Tests/McpToolGuideHeads.Data.cs +++ b/Lite.Tests/McpToolGuideHeads.Data.cs @@ -41,7 +41,11 @@ private static readonly (string Tool, string Fact)[] HeadFacts = ("get_perfmon_stats", "LATEST IS A TIME: the newest snapshot, not a window"), ("get_server_properties", "LATEST IS A TIME: the newest snapshot, not a window"), ("get_top_procedures_by_cpu", "LIFETIME extremes, not windowed"), + ("get_top_procedures_by_cpu", "window_truncated"), + ("get_top_procedures_by_cpu", "not a page cut"), ("get_top_queries_by_cpu", "LIFETIME extremes, not windowed"), + ("get_top_queries_by_cpu", "window_truncated"), + ("get_top_queries_by_cpu", "not a page cut"), ("get_wait_stats", "Bounded by limit"), ("list_servers", "Lite's status IS a live connection check"), ]; @@ -64,16 +68,14 @@ public void EveryConvertedHead_CarriesItsGuardrailFact_AndThePointer() } } - /// - /// #4231: the topic no longer ends either tail -- McpHelpers.WindowTruncatedDescription now trails it on - /// both tools (both raw-tier reads gained the window-floor disclosure), so this checks presence, not - /// position. - /// + /// #4231: the shared cpu-extremes/attribution topic is the last thing appended to both tails again + /// (McpHelpers.WindowTruncatedDescription now sits before it, not after), so this is back to checking + /// position, matching Darling's twin. [Fact] public void CpuTimeExtremesTopic_RidesOnBothTopByCpuTools() { - Assert.Contains(McpToolGuideTopics.CpuTimeExtremesAndAttribution, McpToolGuideTests.Served("get_top_procedures_by_cpu").Tail!, StringComparison.Ordinal); - Assert.Contains(McpToolGuideTopics.CpuTimeExtremesAndAttribution, McpToolGuideTests.Served("get_top_queries_by_cpu").Tail!, StringComparison.Ordinal); + Assert.EndsWith(McpToolGuideTopics.CpuTimeExtremesAndAttribution, McpToolGuideTests.Served("get_top_procedures_by_cpu").Tail!, StringComparison.Ordinal); + Assert.EndsWith(McpToolGuideTopics.CpuTimeExtremesAndAttribution, McpToolGuideTests.Served("get_top_queries_by_cpu").Tail!, StringComparison.Ordinal); } /// Lite's list_servers has no fleet/engine concept; its tail is just the original "use this first" diff --git a/Lite/Mcp/McpQueryTools.cs b/Lite/Mcp/McpQueryTools.cs index 2a26d9323..88c5537f9 100644 --- a/Lite/Mcp/McpQueryTools.cs +++ b/Lite/Mcp/McpQueryTools.cs @@ -10,7 +10,7 @@ namespace PerformanceMonitorLite.Mcp; [McpServerToolType] public sealed class McpQueryTools { - [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 no stored text for the group). 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. Reads the raw tier only, which on a store with a lowered retention_days or less history than the window can be shorter than hours_back: window_truncated is the store's retention floor, not a page cut, and effective_start / effective_hours_back say where the answer begins. " + 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. window_truncated marks a window floor, not a page cut; effective_start / effective_hours_back give the reach actually served. <> 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 no stored text for the group). 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. Reads the raw tier only, which on a store with a lowered retention_days or less history than the window can be shorter than hours_back: window_truncated is the store's retention floor, not a page cut, and effective_start / effective_hours_back say where the answer begins." + McpHelpers.WindowTruncatedDescription + " " + McpToolGuideTopics.CpuTimeExtremesAndAttribution)] public static async Task GetTopQueriesByCpu( LocalDataService dataService, ServerManager serverManager, @@ -164,7 +164,7 @@ ratio is omitted rather than invented when a denominator piece is missing. One n } } - [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. Reads the raw tier only, which on a store with a lowered retention_days or less history than the window can be shorter than hours_back. " + 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. window_truncated marks a window floor, not a page cut; effective_start / effective_hours_back give the reach actually served. <> Shows execution counts, CPU/elapsed times, and I/O metrics. Delta-based: requires ~30 minutes after adding a new server before data appears. Reads the raw tier only, which on a store with a lowered retention_days or less history than the window can be shorter than hours_back." + McpHelpers.WindowTruncatedDescription + " " + McpToolGuideTopics.CpuTimeExtremesAndAttribution)] public static async Task GetTopProceduresByCpu( LocalDataService dataService, ServerManager serverManager, From f6e9f53a088602e918a92db9b81ebbb562159333 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:37:20 -0400 Subject: [PATCH 8/8] Sync #4231 head budgets: query_store_top shorter, both CPU tools grow (#4279) Lite's #4279 lands the shared window-floor head, so the twin exemption in Darling's budget no longer applies. Re-measured tools/list for both SKUs: get_query_store_top's head drops the old split for the shared sentence (422 -> 351), get_top_queries_by_cpu and get_top_procedures_by_cpu each gain that same sentence (474 -> 600, 406 -> 532). Net +181 bytes on both sides. Updated the three per-tool lines and each project's TotalCeilingBytes (174,373 -> 174,554 Darling; 92,041 -> 92,222 Lite), with a change-log comment recording the deltas. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ --- .../McpToolsListBudget/DarlingMcpDataTools.txt | 6 +++--- Darling/Darling.Tests/McpToolsListBudgetTests.cs | 6 +++++- Lite.Tests/McpToolsListBudget/McpQueryTools.txt | 6 +++--- Lite.Tests/McpToolsListBudgetTests.cs | 6 +++++- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDataTools.txt b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDataTools.txt index afe3afcc6..5bfb2e121 100644 --- a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDataTools.txt +++ b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDataTools.txt @@ -44,7 +44,7 @@ param get_perfmon_stats.counter_name 61 param get_perfmon_stats.instance_name 57 param get_perfmon_stats.server_name 28 -tool get_query_store_top 422 +tool get_query_store_top 351 param get_query_store_top.as_of 167 param get_query_store_top.database_name 30 param get_query_store_top.execution_type 72 @@ -63,14 +63,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 532 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 600 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 928413872..6a3fdc7df 100644 --- a/Darling/Darling.Tests/McpToolsListBudgetTests.cs +++ b/Darling/Darling.Tests/McpToolsListBudgetTests.cs @@ -171,7 +171,11 @@ shared 32 KB budget and is not a served description either. */ #4231 branched from); combined total unchanged from the merge base since #4231 added no head bytes. */ /* #4231 (merge after #4273): re-measured after merging origin/dev (dev now includes #4272+#4273); #4231 still adds no head bytes, so the total is dev's own. */ - private const int TotalCeilingBytes = 174_373; + /* #4279 (Lite half of #4231 ships): the exemption above no longer applies once Lite's own head carries the + same disclosure. get_query_store_top's head drops the old Darling/Lite split for the shared sentence + (422 -> 351, banking 71 bytes); get_top_queries_by_cpu and get_top_procedures_by_cpu each gain that same + sentence (474 -> 600 and 406 -> 532, +126 bytes apiece). Net +181, matching Lite's twin change exactly. */ + private const int TotalCeilingBytes = 174_554; private const int ConvertedHeadCap = 1_000; diff --git a/Lite.Tests/McpToolsListBudget/McpQueryTools.txt b/Lite.Tests/McpToolsListBudget/McpQueryTools.txt index 644e57e60..348d8538c 100644 --- a/Lite.Tests/McpToolsListBudget/McpQueryTools.txt +++ b/Lite.Tests/McpToolsListBudget/McpQueryTools.txt @@ -34,7 +34,7 @@ param get_query_store_regressions.hours_back 110 param get_query_store_regressions.limit 164 param get_query_store_regressions.server_name 28 -tool get_query_store_top 422 +tool get_query_store_top 351 param get_query_store_top.as_of 167 param get_query_store_top.database_name 30 param get_query_store_top.execution_type 72 @@ -51,14 +51,14 @@ param get_query_trend.hours_back 29 param get_query_trend.query_hash 72 param get_query_trend.server_name 28 -tool get_top_procedures_by_cpu 406 +tool get_top_procedures_by_cpu 532 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 600 param get_top_queries_by_cpu.as_of 167 param get_top_queries_by_cpu.database_name 30 param get_top_queries_by_cpu.hours_back 29 diff --git a/Lite.Tests/McpToolsListBudgetTests.cs b/Lite.Tests/McpToolsListBudgetTests.cs index a885b0ba7..650417c1d 100644 --- a/Lite.Tests/McpToolsListBudgetTests.cs +++ b/Lite.Tests/McpToolsListBudgetTests.cs @@ -133,7 +133,11 @@ combined total with blocking (#4267) changes on top. */ here. */ /* #4198 (qs-top, merge): re-measured after merging origin/dev (dev now includes #4261+#4258+#4265+#4267+#4264+#4266+#4268+#4272); combined total with qs-top (#4273) changes on top. */ - private const int TotalCeilingBytes = 92_041; + /* #4279 (Lite half of #4231): get_query_store_top's head drops the old Darling/Lite split for one shared + window-floor sentence (422 -> 351, banking 71 bytes); get_top_queries_by_cpu and get_top_procedures_by_cpu + each gain that same sentence (474 -> 600 and 406 -> 532, +126 bytes apiece), making all three heads + byte-identical to Darling's. Net +181, matching Darling's twin change exactly. */ + private const int TotalCeilingBytes = 92_222; private const int ConvertedHeadCap = 1_000; private const int ConvertedParameterCap = 200;