diff --git a/Darling/Darling.Tests/McpToolGuideHeads.Latch.cs b/Darling/Darling.Tests/McpToolGuideHeads.Latch.cs new file mode 100644 index 0000000000..aedc40fb41 --- /dev/null +++ b/Darling/Darling.Tests/McpToolGuideHeads.Latch.cs @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using PerformanceMonitor.Common; +using Xunit; + +namespace Darling.Tests; + +/// +/// #3898 D3 head pins for get_latch_stats (wave E, lane e4). Darling ranks the TOP latch classes by a +/// window total; Lite pages the newest SNAPSHOT. That is a real behavior difference (D6), so the shared head +/// states both in one clause rather than picking one product's framing. Follows the pattern in +/// . Lite's twin is Lite.Tests/McpToolGuideHeadsLatchTests. +/// +public sealed class McpToolGuideHeadsLatchTests +{ + private static readonly string[] ConvertedTools = ["get_latch_stats"]; + + /// The per-tool guardrail phrases the shared head must state. + private static readonly string[] HeadFacts = + [ + "Darling sums waits over the whole hours_back window and returns the top classes by that total", + "Lite: LATEST IS A TIME, only the newest snapshot within hours_back, paged to limit, heaviest last-interval wait first", + "interval_seconds, both per-second rates and (on Darling) severity come from the LATEST interval only", + "they are null, never 0 or LOW", + "Zero rows: not_collected on a non-SQL Server target, else unavailable, never empty", + ]; + + [Fact] + public void GetLatchStatsHead_CarriesEveryGuardrailFact_AndThePointer() + { + var served = McpToolGuideTests.Served("get_latch_stats"); + Assert.NotNull(served.Tail); + Assert.EndsWith(McpToolGuide.GuidePointer, served.Served, StringComparison.Ordinal); + Assert.True(served.Served.Length <= 620, $"get_latch_stats: served head {served.Served.Length} is over the 620 target"); + Assert.All(served.ParameterDescriptionLengths, p => Assert.True(p.Length <= 200, $"get_latch_stats.{p.Parameter}: {p.Length} > 200")); + + foreach (var fact in HeadFacts) + { + Assert.Contains(fact, served.Served, StringComparison.Ordinal); + } + } + + /// D6: the shared head names neither product's exclusive field (Darling's severity_banded_from + /// block, Lite's truncated/captured_at paging) — those stay in each product's own tail, unchanged, the way + /// list_servers keeps peer_fleets and engine_kind off its shared head. + [Fact] + public void SharedHead_KeepsPerProductFieldsOffIt_AndEachTailKeepsItsOwn() + { + var served = McpToolGuideTests.Served("get_latch_stats"); + Assert.DoesNotContain("severity_banded_from", served.Served, StringComparison.Ordinal); + Assert.DoesNotContain("truncated", served.Served, StringComparison.Ordinal); + Assert.DoesNotContain("captured_at", served.Served, StringComparison.Ordinal); + Assert.Contains("severity_banded_from", served.Tail!, StringComparison.Ordinal); + } + + /// Nothing dropped from Darling's original: every sentence the pre-#3898 description carried + /// (the two-clocks rule, the LOW-severity-beside-a-large-total note, the restart null list) rides verbatim + /// in the tail get_tool_guide serves. dropcheck.py is the formal check; this pins the sentences most + /// likely to be paraphrased by a future edit. + [Fact] + public void DarlingTail_KeepsEveryOriginalSentence() + { + var tail = McpToolGuideTests.Served("get_latch_stats").Tail!; + Assert.Contains("Gets top latch contention by class.", tail, StringComparison.Ordinal); + Assert.Contains("Shows latch waits, wait time, and per-second rates.", tail, StringComparison.Ordinal); + Assert.Contains("TWO CLOCKS PER ROW, NAMED: total_delta_* SUM every collection in the window", tail, StringComparison.Ordinal); + Assert.Contains("A LOW severity beside a large window total is a class that was hot earlier in the window and is quiet now, not a contradiction.", tail, StringComparison.Ordinal); + Assert.Contains("never a quiet 0.00 or a LOW banded from a zero nobody measured; the window totals beside them still stand.", tail, StringComparison.Ordinal); + } + + /// The latch classes keep Microsoft's meaning (sys.dm_os_latch_stats): ACCESS_METHODS_DATASET_PARENT + /// synchronizes parallel operations, and FGCB_ADD_REMOVE covers file add, drop, grow and shrink. The old reading + /// of them as TempDB allocation contention must not come back, in the head or the tail. + [Fact] + public void LatchClasses_KeepMicrosoftsMeaning_NotATempDbReading() + { + var served = McpToolGuideTests.Served("get_latch_stats"); + var whole = served.Served + " " + served.Tail; + Assert.DoesNotContain("TempDB allocation contention", whole, StringComparison.Ordinal); + Assert.Contains("ACCESS_METHODS_DATASET_PARENT synchronizes child dataset access to the parent dataset during parallel operations, and FGCB_ADD_REMOVE synchronizes", served.Tail!, StringComparison.Ordinal); + } +} diff --git a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpLatchSpinlockTools.txt b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpLatchSpinlockTools.txt index 008190584e..5d9840a744 100644 --- a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpLatchSpinlockTools.txt +++ b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpLatchSpinlockTools.txt @@ -1,5 +1,5 @@ # DarlingMcpLatchSpinlockTools: tools/list budget for #3898. Ceilings only go down; see McpToolsListBudgetTests. One block per tool, blank line between blocks. -tool get_latch_stats 1027 +tool get_latch_stats 549 param get_latch_stats.as_of 167 param get_latch_stats.hours_back 37 param get_latch_stats.server_name 28 diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLatchSpinlockTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLatchSpinlockTools.cs index 86608b9e26..9d91931768 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLatchSpinlockTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLatchSpinlockTools.cs @@ -53,7 +53,7 @@ namespace PerformanceMonitor.Darling.Service.Mcp; [McpServerToolType] public sealed class DarlingMcpLatchSpinlockTools { - [McpServerTool(Name = "get_latch_stats"), Description("Gets top latch contention by class. Shows latch waits, wait time, and per-second rates. High LATCH_EX on ACCESS_METHODS_DATASET_PARENT or FGCB_ADD_REMOVE indicates TempDB allocation contention. TWO CLOCKS PER ROW, NAMED: total_delta_* SUM every collection in the window; severity, waits_per_second and wait_ms_per_second are banded/derived from the LATEST interval only - interval_seconds is the seconds that interval accrued over, and the severity_banded_from block names it in full (its delta wait, the same seconds, and the collection it ended at, which is also latest_collection_time). A LOW severity beside a large window total is a class that was hot earlier in the window and is quiet now, not a contradiction. When the latest interval was unknowable (a restart or first sample, stored as 0), interval_seconds, both per-second rates, severity and severity_banded_from.delta_wait_time_ms are all null - unknowable, never a quiet 0.00 or a LOW banded from a zero nobody measured; the window totals beside them still stand.")] + [McpServerTool(Name = "get_latch_stats"), Description("Latch contention by class. Darling sums waits over the whole hours_back window and returns the top classes by that total. Lite: LATEST IS A TIME, only the newest snapshot within hours_back, paged to limit, heaviest last-interval wait first. interval_seconds, both per-second rates and (on Darling) severity come from the LATEST interval only; on a restart or first sample (interval unknowable, stored 0) they are null, never 0 or LOW. Zero rows: not_collected on a non-SQL Server target, else unavailable, never empty. <> Gets top latch contention by class. Shows latch waits, wait time, and per-second rates. ACCESS_METHODS_DATASET_PARENT synchronizes child dataset access to the parent dataset during parallel operations, and FGCB_ADD_REMOVE synchronizes filegroup add, drop, grow and shrink file operations. TWO CLOCKS PER ROW, NAMED: total_delta_* SUM every collection in the window; severity, waits_per_second and wait_ms_per_second are banded/derived from the LATEST interval only - interval_seconds is the seconds that interval accrued over, and the severity_banded_from block names it in full (its delta wait, the same seconds, and the collection it ended at, which is also latest_collection_time). A LOW severity beside a large window total is a class that was hot earlier in the window and is quiet now, not a contradiction. When the latest interval was unknowable (a restart or first sample, stored as 0), interval_seconds, both per-second rates, severity and severity_banded_from.delta_wait_time_ms are all null - unknowable, never a quiet 0.00 or a LOW banded from a zero nobody measured; the window totals beside them still stand.")] public static async Task GetLatchStats( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, diff --git a/Lite.Tests/McpToolGuideHeads.Latch.cs b/Lite.Tests/McpToolGuideHeads.Latch.cs new file mode 100644 index 0000000000..9dc3280e53 --- /dev/null +++ b/Lite.Tests/McpToolGuideHeads.Latch.cs @@ -0,0 +1,86 @@ +/* + * 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 PerformanceMonitor.Common; +using Xunit; + +namespace Lite.Tests; + +/// +/// #3898 D3 head pins for get_latch_stats's Lite twin (wave E, lane e4). Darling's twin is +/// Darling.Tests/McpToolGuideHeadsLatchTests, which also holds the cross-SKU lockstep pin. +/// +public sealed class McpToolGuideHeadsLatchTests +{ + private static readonly string[] HeadFacts = + [ + "Darling sums waits over the whole hours_back window and returns the top classes by that total", + "Lite: LATEST IS A TIME, only the newest snapshot within hours_back, paged to limit, heaviest last-interval wait first", + "interval_seconds, both per-second rates and (on Darling) severity come from the LATEST interval only", + "they are null, never 0 or LOW", + "Zero rows: not_collected on a non-SQL Server target, else unavailable, never empty", + ]; + + [Fact] + public void GetLatchStatsHead_CarriesEveryGuardrailFact_AndThePointer() + { + var served = McpToolGuideTests.Served("get_latch_stats"); + Assert.NotNull(served.Tail); + Assert.EndsWith(McpToolGuide.GuidePointer, served.Served, StringComparison.Ordinal); + Assert.True(served.Served.Length <= 620, $"get_latch_stats: served head {served.Served.Length} is over the 620 target"); + Assert.All(served.ParameterDescriptionLengths, p => Assert.True(p.Length <= 200, $"get_latch_stats.{p.Parameter}: {p.Length} > 200")); + + foreach (var fact in HeadFacts) + { + Assert.Contains(fact, served.Served, StringComparison.Ordinal); + } + } + + /// D6: the shared head names neither product's exclusive field. Lite's own paging fields + /// (truncated, captured_at) stay off the shared head and live only in Lite's tail; Darling's severity + /// block never appears here at all. + [Fact] + public void SharedHead_KeepsPerProductFieldsOffIt_AndLiteTailKeepsItsOwn() + { + var served = McpToolGuideTests.Served("get_latch_stats"); + Assert.DoesNotContain("truncated", served.Served, StringComparison.Ordinal); + Assert.DoesNotContain("captured_at", served.Served, StringComparison.Ordinal); + Assert.DoesNotContain("severity_banded_from", served.Served, StringComparison.Ordinal); + Assert.Contains("truncated", served.Tail!, StringComparison.Ordinal); + Assert.Contains("captured_at", served.Tail!, StringComparison.Ordinal); + Assert.DoesNotContain("severity_banded_from", served.Tail!, StringComparison.Ordinal); + } + + /// Nothing dropped from Lite's original: the LATEST-IS-A-TIME sentence, the page-bounded-by-limit + /// rule and the restart null list all ride verbatim in the tail get_tool_guide serves. + /// dropcheck.py is the formal check; this pins the sentences most likely to be paraphrased by a + /// future edit. + [Fact] + public void LiteTail_KeepsEveryOriginalSentence() + { + var tail = McpToolGuideTests.Served("get_latch_stats").Tail!; + Assert.Contains("Gets the latest latch-contention snapshot by latch class", tail, StringComparison.Ordinal); + Assert.Contains("LATEST IS A TIME: this is the newest snapshot found within hours_back of as_of, not an aggregate over those hours", tail, StringComparison.Ordinal); + Assert.Contains("THE PAGE IS BOUNDED BY limit: latches_returned is how many latch classes you got, heaviest last-interval wait first", tail, StringComparison.Ordinal); + Assert.Contains("null, never 0, so a restart cannot read as a quiet latch; interval_seconds is also null, with the deltas standing, on a row collected before the interval was stored.", tail, StringComparison.Ordinal); + } + + /// The latch classes keep Microsoft's meaning (sys.dm_os_latch_stats): ACCESS_METHODS_DATASET_PARENT + /// synchronizes parallel operations, and every page latch reports under BUFFER. The old reading of them as + /// allocation contention "(often TempDB)" must not come back, in the head or the tail. + [Fact] + public void LatchClasses_KeepMicrosoftsMeaning_NotATempDbReading() + { + var served = McpToolGuideTests.Served("get_latch_stats"); + var whole = served.Served + " " + served.Tail; + Assert.DoesNotContain("TempDB allocation contention", whole, StringComparison.Ordinal); + Assert.DoesNotContain("(often TempDB)", whole, StringComparison.Ordinal); + Assert.Contains("ACCESS_METHODS_DATASET_PARENT synchronizes child dataset access to the parent dataset during parallel operations, and every page latch reports under the one BUFFER class", served.Tail!, StringComparison.Ordinal); + } +} diff --git a/Lite.Tests/McpToolsListBudget/McpLatchSpinlockTools.txt b/Lite.Tests/McpToolsListBudget/McpLatchSpinlockTools.txt index f30f90a480..99b5e019a9 100644 --- a/Lite.Tests/McpToolsListBudget/McpLatchSpinlockTools.txt +++ b/Lite.Tests/McpToolsListBudget/McpLatchSpinlockTools.txt @@ -1,5 +1,5 @@ # McpLatchSpinlockTools: tools/list budget for #3898. Ceilings only go down; see McpToolsListBudgetTests. One block per tool, blank line between blocks. -tool get_latch_stats 1372 +tool get_latch_stats 549 param get_latch_stats.as_of 167 param get_latch_stats.hours_back 63 param get_latch_stats.limit 194 diff --git a/Lite/Mcp/McpLatchSpinlockTools.cs b/Lite/Mcp/McpLatchSpinlockTools.cs index eeea6ea7dc..87aee95f30 100644 --- a/Lite/Mcp/McpLatchSpinlockTools.cs +++ b/Lite/Mcp/McpLatchSpinlockTools.cs @@ -45,7 +45,7 @@ public sealed class McpLatchSpinlockTools ? Math.Round((double)measured / seconds, 2) : null; - [McpServerTool(Name = "get_latch_stats"), Description("Gets the latest latch-contention snapshot by latch class: cumulative waiting requests and wait time (with the max single wait) plus the last collection interval's delta waits. High LATCH_EX on ACCESS_METHODS_DATASET_PARENT or a page-latch class indicates allocation/page contention (often TempDB). LATEST IS A TIME: this is the newest snapshot found within hours_back of as_of, not an aggregate over those hours - captured_at is the instant it was collected and age_seconds its distance from the window's end. THE PAGE IS BOUNDED BY limit: latches_returned is how many latch classes you got, heaviest last-interval wait first, and truncated says the snapshot held more than limit - a sum over the page is a sum over the page, not over the server. Raise limit when truncated is true. interval_seconds is the seconds the deltas accrued over, and waits_per_second / wait_ms_per_second are the deltas over it - the same keys Darling's twin derives from its latest interval. On a restart / first-sample row (the interval they would have accrued over was unknowable, stored as 0) delta_waiting_requests_count, delta_wait_time_ms, avg_wait_ms_per_request, interval_seconds and both per-second rates are all null - null, never 0, so a restart cannot read as a quiet latch; interval_seconds is also null, with the deltas standing, on a row collected before the interval was stored.")] + [McpServerTool(Name = "get_latch_stats"), Description("Latch contention by class. Darling sums waits over the whole hours_back window and returns the top classes by that total. Lite: LATEST IS A TIME, only the newest snapshot within hours_back, paged to limit, heaviest last-interval wait first. interval_seconds, both per-second rates and (on Darling) severity come from the LATEST interval only; on a restart or first sample (interval unknowable, stored 0) they are null, never 0 or LOW. Zero rows: not_collected on a non-SQL Server target, else unavailable, never empty. <> Gets the latest latch-contention snapshot by latch class: cumulative waiting requests and wait time (with the max single wait) plus the last collection interval's delta waits. ACCESS_METHODS_DATASET_PARENT synchronizes child dataset access to the parent dataset during parallel operations, and every page latch reports under the one BUFFER class, where contention can mean hot pages or slow I/O. LATEST IS A TIME: this is the newest snapshot found within hours_back of as_of, not an aggregate over those hours - captured_at is the instant it was collected and age_seconds its distance from the window's end. THE PAGE IS BOUNDED BY limit: latches_returned is how many latch classes you got, heaviest last-interval wait first, and truncated says the snapshot held more than limit - a sum over the page is a sum over the page, not over the server. Raise limit when truncated is true. interval_seconds is the seconds the deltas accrued over, and waits_per_second / wait_ms_per_second are the deltas over it - the same keys Darling's twin derives from its latest interval. On a restart / first-sample row (the interval they would have accrued over was unknowable, stored as 0) delta_waiting_requests_count, delta_wait_time_ms, avg_wait_ms_per_request, interval_seconds and both per-second rates are all null - null, never 0, so a restart cannot read as a quiet latch; interval_seconds is also null, with the deltas standing, on a row collected before the interval was stored.")] public static async Task GetLatchStats( LocalDataService dataService, ServerManager serverManager, diff --git a/deprecated/Dashboard/Mcp/McpAnalysisTools.cs b/deprecated/Dashboard/Mcp/McpAnalysisTools.cs index 8c60d18133..30dbd51f5f 100644 --- a/deprecated/Dashboard/Mcp/McpAnalysisTools.cs +++ b/deprecated/Dashboard/Mcp/McpAnalysisTools.cs @@ -622,7 +622,7 @@ internal static class ToolRecommendations ], ["LATCH_EX"] = [ - new("get_latch_stats", "Check latch contention by class (ACCESS_METHODS_DATASET_PARENT / FGCB_ADD_REMOVE point at TempDB allocation contention)"), + new("get_latch_stats", "Check latch contention by class"), new("get_tempdb_trend", "Check TempDB for allocation contention"), new("get_wait_trend", "Track the latch contention trend", new() { ["wait_type"] = "LATCH_EX" }) ], diff --git a/deprecated/Dashboard/Mcp/McpLatchSpinlockTools.cs b/deprecated/Dashboard/Mcp/McpLatchSpinlockTools.cs index 560d10f120..4fffe77c60 100644 --- a/deprecated/Dashboard/Mcp/McpLatchSpinlockTools.cs +++ b/deprecated/Dashboard/Mcp/McpLatchSpinlockTools.cs @@ -12,7 +12,7 @@ namespace PerformanceMonitorDashboard.Mcp; [McpServerToolType] public sealed class McpLatchSpinlockTools { - [McpServerTool(Name = "get_latch_stats"), Description("Gets top latch contention by class. Shows latch waits, wait time, and per-second rates. High LATCH_EX on ACCESS_METHODS_DATASET_PARENT or FGCB_ADD_REMOVE indicates TempDB allocation contention.")] + [McpServerTool(Name = "get_latch_stats"), Description("Gets top latch contention by class. Shows latch waits, wait time, and per-second rates. ACCESS_METHODS_DATASET_PARENT synchronizes child dataset access to the parent dataset during parallel operations, and FGCB_ADD_REMOVE synchronizes filegroup add, drop, grow and shrink file operations.")] public static async Task GetLatchStats( ServerManager serverManager, DatabaseServiceRegistry registry,