Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions Darling/Darling.Tests/McpToolGuideHeads.Latch.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// #3898 D3 head pins for <c>get_latch_stats</c> (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
/// <see cref="McpToolGuideHeadsHealthParserTests"/>. Lite's twin is <c>Lite.Tests/McpToolGuideHeadsLatchTests</c>.
/// </summary>
public sealed class McpToolGuideHeadsLatchTests
{
private static readonly string[] ConvertedTools = ["get_latch_stats"];

/// <summary>The per-tool guardrail phrases the shared head must state.</summary>
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);
}
}

/// <summary>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
/// <c>list_servers</c> keeps peer_fleets and engine_kind off its shared head.</summary>
[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);
}

/// <summary>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. <c>dropcheck.py</c> is the formal check; this pins the sentences most
/// likely to be paraphrased by a future edit.</summary>
[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);
}

/// <summary>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.</summary>
[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);
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. <<GUIDE>> 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<string> GetLatchStats(
NpgsqlDataSource postgres,
[Description("Server name or display name.")] string? server_name = null,
Expand Down
86 changes: 86 additions & 0 deletions Lite.Tests/McpToolGuideHeads.Latch.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// #3898 D3 head pins for <c>get_latch_stats</c>'s Lite twin (wave E, lane e4). Darling's twin is
/// <c>Darling.Tests/McpToolGuideHeadsLatchTests</c>, which also holds the cross-SKU lockstep pin.
/// </summary>
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);
}
}

/// <summary>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.</summary>
[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);
}

/// <summary>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.
/// <c>dropcheck.py</c> is the formal check; this pins the sentences most likely to be paraphrased by a
/// future edit.</summary>
[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);
}

/// <summary>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.</summary>
[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);
}
}
2 changes: 1 addition & 1 deletion Lite.Tests/McpToolsListBudget/McpLatchSpinlockTools.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion Lite/Mcp/McpLatchSpinlockTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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. <<GUIDE>> 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<string> GetLatchStats(
LocalDataService dataService,
ServerManager serverManager,
Expand Down
2 changes: 1 addition & 1 deletion deprecated/Dashboard/Mcp/McpAnalysisTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
],
Expand Down
2 changes: 1 addition & 1 deletion deprecated/Dashboard/Mcp/McpLatchSpinlockTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> GetLatchStats(
ServerManager serverManager,
DatabaseServiceRegistry registry,
Expand Down
Loading