diff --git a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs index 3bca6142d..f177eaeb2 100644 --- a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs +++ b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs @@ -1512,8 +1512,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"), ("findings_truncated_note", ["DarlingMcpTools.cs", "McpAnalysisTools.cs"], "#4198: the prose beside findings_truncated — how many diagnostic chains were active in the window and that raising limit or narrowing hours_back would show more of them"), ]; @@ -1763,6 +1763,10 @@ disclosure over query_stats and procedure_stats. */ ("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 = @@ -1778,7 +1782,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. @@ -2003,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/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/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 2aa4f25e1..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,11 +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: 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("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 76a053ca8..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,6 +68,9 @@ public void EveryConvertedHead_CarriesItsGuardrailFact_AndThePointer() } } + /// #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() { 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; diff --git a/Lite.Tests/QueryWindowTruncationTests.cs b/Lite.Tests/QueryWindowTruncationTests.cs new file mode 100644 index 000000000..529b0c48a --- /dev/null +++ b/Lite.Tests/QueryWindowTruncationTests.cs @@ -0,0 +1,482 @@ +/* + * 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.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; +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}"); + } + + /// + /// #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)."); + } + } + + /// + /// #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 = "") => + Path.GetFullPath(Path.Combine(Path.GetDirectoryName(thisFile)!, "..", "Lite", "Controls")); +} diff --git a/Lite/Controls/ServerTab.Refresh.cs b/Lite/Controls/ServerTab.Refresh.cs index bdf344948..3517ecec5 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,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); + /* #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 @@ -273,6 +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); + /* #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 @@ -284,6 +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); + /* #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 @@ -336,6 +351,9 @@ await System.Threading.Tasks.Task.WhenAll( var cEnd = toDate ?? DateTime.UtcNow; var cStart = fromDate ?? cEnd.AddHours(-hoursBack); await RefreshQueryStatsComparisonAsync(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); @@ -344,6 +362,9 @@ await System.Threading.Tasks.Task.WhenAll( var cEnd2 = toDate ?? DateTime.UtcNow; var cStart2 = fromDate ?? cEnd2.AddHours(-hoursBack); await RefreshProcStatsComparisonAsync(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); @@ -352,6 +373,9 @@ await System.Threading.Tasks.Task.WhenAll( var cEnd3 = toDate ?? DateTime.UtcNow; var cStart3 = fromDate ?? cEnd3.AddHours(-hoursBack); await RefreshQueryStoreComparisonAsync(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); @@ -368,6 +392,44 @@ 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). + /// + /// #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 startUtc, DateTime endUtc) + { + var floor = await Task.Run(() => _dataService.GetQueryWindowFloorAsync(relation, _serverId, startUtc, endUtc)); + var truncated = McpQueryTools.IsWindowTruncated(floor, startUtc); + SetWindowTruncatedBanner(banner, truncated, floor ?? startUtc); + } + + /// + /// #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..ba78bfec1 100644 --- a/Lite/Controls/ServerTab.Slicers.cs +++ b/Lite/Controls/ServerTab.Slicers.cs @@ -173,6 +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); + /* #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) { @@ -224,6 +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); + /* #4279: UTC probe, UTC bounds -- see the twin comment in OnQueryStatsSlicerChanged above. */ + await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QueryStoreStats, QueryStoreWindowTruncatedBanner, e.StartUtc, e.EndUtc); } catch (Exception ex) { @@ -275,6 +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); + /* #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/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 @@ + + - + + + + + - + + + + + > 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. 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, @@ -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. 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, @@ -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, @@ -243,7 +277,7 @@ public static async Task GetTopProceduresByCpu( /// private const int QueryTextPreviewLength = 400; - [McpServerTool(Name = "get_query_store_top"), Description("Cost-ranked top Query Store queries (heaviest first), not time-ordered. Requires Query Store enabled on target databases. Darling: window_truncated marks a window floor, not a page cut — no limit changes it — because raw retention can be shorter than asked; effective_start / effective_hours_back give the reach actually served. Lite: no such floor; the full requested window is always read. <> Gets expensive queries from Query Store (persistent, survives restarts). Best for: historical analysis, queries no longer in plan cache. Requires Query Store enabled on target databases. Supports database and module filtering. Rows are per Query Store execution outcome (execution_type: Regular, Aborted, Exception): a plan with aborted executions returns one row per outcome, each with its own counts and averages. The execution_type filter keeps one outcome, and module_name keeps one module: the exact, case-sensitive schema-qualified name the collector records (get_top_procedures_by_cpu's full_name; Adhoc for ad-hoc statements, Unknown for an object it could not resolve), applied after interval deduplication and before ranking. When a filter matches nothing but the same read without the filters has rows, the answer is empty (a measured zero), not a Query Store precondition. query_text is a 400-character preview by default (query_text_truncated marks a cut row); full_text=true returns each row's whole statement.")] + [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. 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 async Task GetQueryStoreTop( LocalDataService dataService, ServerManager serverManager, @@ -277,12 +311,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, @@ -295,7 +341,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 @@ -335,6 +388,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); } @@ -888,6 +949,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/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). /// 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,