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
90 changes: 90 additions & 0 deletions Darling/Darling.Tests/McpToolGuideHeads.Trend.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
* 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.Collectors;
using PerformanceMonitor.Common;
using Xunit;

namespace Darling.Tests;

/// <summary>
/// #3898 D3 head pins for the "Trend" family: <c>get_query_store_duration_trend</c> and
/// <c>get_perfmon_trend</c>, converted out of <c>DarlingMcpTrendTools</c> (Darling) and their Lite twins.
/// Follows the pattern in <see cref="McpToolGuideHeadsHealthParserTests"/>.
/// </summary>
public sealed class McpToolGuideHeadsTrendTests
{
private static readonly string[] ConvertedTools =
[
"get_perfmon_trend",
"get_query_store_duration_trend",
];

/// <summary>The per-tool guardrail phrase each head must state.</summary>
private static readonly (string Tool, string Fact)[] HeadFacts =
[
("get_perfmon_trend", "No points never returns empty"),
("get_perfmon_trend", "never delta_value alone or when the interval is 0"),
("get_perfmon_trend", "classify by name (ends in /sec = rate)"),
("get_query_store_duration_trend", "not_collected: engine cannot run Query Store."),
("get_query_store_duration_trend", "unavailable: never sampled here."),
("get_query_store_duration_trend", "empty: quiet on Lite always; on Darling, empty can also be a rollup coverage gap"),
("get_query_store_duration_trend", "each interval counted once, at the hour it ran"),
("get_query_store_duration_trend", "window_truncated marks the retention floor, not a page cut"),
];

[Fact]
public void EveryConvertedHead_CarriesItsGuardrailFact_AndThePointer()
{
foreach (var tool in ConvertedTools)
{
var served = McpToolGuideTests.Served(tool);
Assert.NotNull(served.Tail);
Assert.EndsWith(McpToolGuide.GuidePointer, served.Served, StringComparison.Ordinal);
Assert.True(served.Served.Length <= 620, $"{tool}: served head {served.Served.Length} is over the 620 target");
Assert.All(served.ParameterDescriptionLengths, p => Assert.True(p.Length <= 200, $"{tool}.{p.Parameter}: {p.Length} > 200"));
}

foreach (var (tool, fact) in HeadFacts)
{
Assert.Contains(fact, McpToolGuideTests.Served(tool).Served, StringComparison.Ordinal);
}
}

/// <summary>get_perfmon_trend's miss vocabulary is not_collected/unavailable only: unlike its trend
/// siblings it never returns status empty (#4098-era D9 trap). The head must say so, and the tail (the
/// original prose, unchanged) must still carry the four counter_kind branches behind it.</summary>
[Fact]
public void PerfmonTrend_NeverEmpty_AndTailKeepsAllFourCounterKinds()
{
var served = McpToolGuideTests.Served("get_perfmon_trend");
Assert.Contains("No points never returns empty:", served.Served, StringComparison.Ordinal);
Assert.Contains("unavailable: no counter at all collected in the window.", served.Served, StringComparison.Ordinal);
Assert.StartsWith("Gets one performance counter over time in time buckets.", served.Tail!, StringComparison.Ordinal);
Assert.EndsWith(BaselineDiscontinuities.DescriptionSentence, served.Tail!, StringComparison.Ordinal);
foreach (var kind in new[] { "'gauge'", "'rate'", "'other'" })
{
Assert.Contains(kind, served.Tail!, StringComparison.Ordinal);
}
}

/// <summary>get_query_store_duration_trend's empty answer means different things on the two products
/// (Darling has a rollup tier that can leave a coverage gap; Lite has none, so its empty is always a
/// genuinely quiet window): the shared head states both in one clause per D6, and each product's own
/// tail (the original prose, unchanged) keeps its own routing detail.</summary>
[Fact]
public void QueryStoreDurationTrend_TailKeepsItsOwnRoutingDetail_DarlingSide()
{
var served = McpToolGuideTests.Served("get_query_store_duration_trend");
Assert.StartsWith("Gets a time-series of Query Store duration per second and executions per second over time, summed across every query.", served.Tail!, StringComparison.Ordinal);
Assert.Contains("A rollup point (an hourly bucket the corrected rollup has materialized) is rated over its bucket width", served.Tail!, StringComparison.Ordinal);
Assert.EndsWith(BaselineDiscontinuities.DescriptionSentence, served.Tail!, StringComparison.Ordinal);
Assert.Contains("window_truncated is true when the store did not hold the start of the window", served.Tail!, StringComparison.Ordinal);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ param get_memory_trend.bucket_minutes 55
param get_memory_trend.hours_back 29
param get_memory_trend.server_name 28

tool get_perfmon_trend 1057
tool get_perfmon_trend 615
param get_perfmon_trend.as_of 167
param get_perfmon_trend.bucket_minutes 55
param get_perfmon_trend.counter_name 50
Expand All @@ -31,7 +31,7 @@ param get_query_duration_trend.bucket_minutes 55
param get_query_duration_trend.hours_back 29
param get_query_duration_trend.server_name 28

tool get_query_store_duration_trend 1537
tool get_query_store_duration_trend 616
param get_query_store_duration_trend.as_of 167
param get_query_store_duration_trend.hours_back 29
param get_query_store_duration_trend.server_name 28
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ appears where snapshots exist with nothing granted. Lite's tool builds the same
}
}

[McpServerTool(Name = "get_perfmon_trend"), Description("Gets one performance counter over time in time buckets. Use get_perfmon_stats first to see available counter names. counter_kind (from the stored cntr_type) says what a point's number is: 'gauge' — value is the bucket's average reading and peak_value its highest, delta_value and sample_interval_seconds are null because a level has no delta; 'rate' — the per-second figure is delta_value divided by sample_interval_seconds, never delta_value alone, and never where sample_interval_seconds is 0 (no delta was knowable); 'other' — delta_value is the change of an average/fraction numerator, not a rate and not a level; null — the rows predate the stored type or the instances mix types, so classify by name (a name ending in /sec is a rate)." + BaselineDiscontinuities.DescriptionSentence)]
[McpServerTool(Name = "get_perfmon_trend"), Description("Gets one performance counter over time in buckets, ending at as_of. counter_kind says the unit: gauge - value is the bucket average, delta_value and sample_interval_seconds null (no delta for a level); rate - per-second is delta_value divided by sample_interval_seconds, never delta_value alone or when the interval is 0; other - delta_value is a non-rate change; null - classify by name (ends in /sec = rate). No points never returns empty: not_collected covers a gated engine, Page Life Expectancy, or an unknown counter name; unavailable: no counter at all collected in the window. <<GUIDE>> Gets one performance counter over time in time buckets. Use get_perfmon_stats first to see available counter names. counter_kind (from the stored cntr_type) says what a point's number is: 'gauge' — value is the bucket's average reading and peak_value its highest, delta_value and sample_interval_seconds are null because a level has no delta; 'rate' — the per-second figure is delta_value divided by sample_interval_seconds, never delta_value alone, and never where sample_interval_seconds is 0 (no delta was knowable); 'other' — delta_value is the change of an average/fraction numerator, not a rate and not a level; null — the rows predate the stored type or the instances mix types, so classify by name (a name ending in /sec is a rate)." + BaselineDiscontinuities.DescriptionSentence)]
public static Task<string> GetPerfmonTrend(
NpgsqlDataSource postgres,
[Description("The exact counter name, e.g. 'Batch Requests/sec'.")] string counter_name,
Expand Down Expand Up @@ -614,7 +614,7 @@ width rule on the hourly tier. */
}
}

[McpServerTool(Name = "get_query_store_duration_trend"), Description("Gets a time-series of Query Store duration per second and executions per second over time, summed across every query. Where get_query_duration_trend reads the plan cache and loses everything an eviction or a restart takes with it, this reads Query Store, which persists per interval - so it is the series that survives a failover and the one to reach for when a regression is older than the cache. Each interval is counted once, at the hour the work ran. A rollup point (an hourly bucket the corrected rollup has materialized) is rated over its bucket width, so every rollup point is rated, the window's first bucket included; a raw point (a Query Store interval placed at its start, or a legacy row at its collection time) is rated over the gap since the PREVIOUS point, so a raw point that is first in the window - with no previous one to difference against - carries null rates: unknowable, never reported as 0 (unrated_points counts them, unrated_note says why)." + McpHelpers.WindowTruncatedDescription + BaselineDiscontinuities.DescriptionSentence)]
[McpServerTool(Name = "get_query_store_duration_trend"), Description("Gets a time-series of Query Store duration and executions per second, summed across every query, each interval counted once, at the hour it ran. not_collected: engine cannot run Query Store. unavailable: never sampled here. empty: quiet on Lite always; on Darling, empty can also be a rollup coverage gap (window predates the corrected rollup, run --backfill-rollups). A point with no earlier point to rate against has null rates, never 0 (unrated_points, unrated_note says why). window_truncated marks the retention floor, not a page cut; effective_start says where the answer begins. <<GUIDE>> Gets a time-series of Query Store duration per second and executions per second over time, summed across every query. Where get_query_duration_trend reads the plan cache and loses everything an eviction or a restart takes with it, this reads Query Store, which persists per interval - so it is the series that survives a failover and the one to reach for when a regression is older than the cache. Each interval is counted once, at the hour the work ran. A rollup point (an hourly bucket the corrected rollup has materialized) is rated over its bucket width, so every rollup point is rated, the window's first bucket included; a raw point (a Query Store interval placed at its start, or a legacy row at its collection time) is rated over the gap since the PREVIOUS point, so a raw point that is first in the window - with no previous one to difference against - carries null rates: unknowable, never reported as 0 (unrated_points counts them, unrated_note says why)." + McpHelpers.WindowTruncatedDescription + BaselineDiscontinuities.DescriptionSentence)]
public static async Task<string> GetQueryStoreDurationTrend(
NpgsqlDataSource postgres,
[Description("Server name or display name.")] string? server_name = null,
Expand Down
89 changes: 89 additions & 0 deletions Lite.Tests/McpToolGuideHeads.Trend.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 Lite.
*
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
*/

using System;
using PerformanceMonitor.Collectors;
using PerformanceMonitor.Common;
using Xunit;

namespace Lite.Tests;

/// <summary>
/// #3898 D3 head pins for the "Trend" family's Lite twins: <c>get_query_store_duration_trend</c> and
/// <c>get_perfmon_trend</c>. Darling's twin is <c>Darling.Tests/McpToolGuideHeadsTrendTests</c>, which also
/// holds the cross-SKU lockstep pin.
/// </summary>
public sealed class McpToolGuideHeadsTrendTests
{
private static readonly string[] ConvertedTools =
[
"get_perfmon_trend",
"get_query_store_duration_trend",
];

/// <summary>The per-tool guardrail phrase each head must state.</summary>
private static readonly (string Tool, string Fact)[] HeadFacts =
[
("get_perfmon_trend", "No points never returns empty"),
("get_perfmon_trend", "never delta_value alone or when the interval is 0"),
("get_perfmon_trend", "classify by name (ends in /sec = rate)"),
("get_query_store_duration_trend", "not_collected: engine cannot run Query Store."),
("get_query_store_duration_trend", "unavailable: never sampled here."),
("get_query_store_duration_trend", "empty: quiet on Lite always; on Darling, empty can also be a rollup coverage gap"),
("get_query_store_duration_trend", "each interval counted once, at the hour it ran"),
("get_query_store_duration_trend", "window_truncated marks the retention floor, not a page cut"),
];

[Fact]
public void EveryConvertedHead_CarriesItsGuardrailFact_AndThePointer()
{
foreach (var tool in ConvertedTools)
{
var served = McpToolGuideTests.Served(tool);
Assert.NotNull(served.Tail);
Assert.EndsWith(McpToolGuide.GuidePointer, served.Served, StringComparison.Ordinal);
Assert.True(served.Served.Length <= 620, $"{tool}: served head {served.Served.Length} is over the 620 target");
Assert.All(served.ParameterDescriptionLengths, p => Assert.True(p.Length <= 200, $"{tool}.{p.Parameter}: {p.Length} > 200"));
}

foreach (var (tool, fact) in HeadFacts)
{
Assert.Contains(fact, McpToolGuideTests.Served(tool).Served, StringComparison.Ordinal);
}
}

/// <summary>get_perfmon_trend's miss vocabulary is not_collected/unavailable only, byte-identical on both
/// products: it never returns status empty. The tail (the original prose, unchanged) still carries the
/// four counter_kind branches behind the head's terse summary.</summary>
[Fact]
public void PerfmonTrend_NeverEmpty_AndTailKeepsAllFourCounterKinds()
{
var served = McpToolGuideTests.Served("get_perfmon_trend");
Assert.Contains("No points never returns empty:", served.Served, StringComparison.Ordinal);
Assert.Contains("unavailable: no counter at all collected in the window.", served.Served, StringComparison.Ordinal);
Assert.StartsWith("Gets one performance counter over time in time buckets.", served.Tail!, StringComparison.Ordinal);
Assert.EndsWith(BaselineDiscontinuities.DescriptionSentence, served.Tail!, StringComparison.Ordinal);
foreach (var kind in new[] { "'gauge'", "'rate'", "'other'" })
{
Assert.Contains(kind, served.Tail!, StringComparison.Ordinal);
}
}

/// <summary>Lite has no rollup tier, so get_query_store_duration_trend's empty is always a genuinely
/// quiet window there; the shared head says so in the same clause that states Darling's rollup-coverage-gap
/// case (D6), and Lite's own tail (the original prose, unchanged) never mentions a rollup.</summary>
[Fact]
public void QueryStoreDurationTrend_TailKeepsItsOwnRoutingDetail_LiteSide()
{
var served = McpToolGuideTests.Served("get_query_store_duration_trend");
Assert.StartsWith("Gets a time-series of Query Store duration per second and executions per second over time, summed across every query.", served.Tail!, StringComparison.Ordinal);
Assert.Contains("Every point is a rate over the gap since the PREVIOUS point", served.Tail!, StringComparison.Ordinal);
Assert.DoesNotContain("rollup", served.Tail!, StringComparison.Ordinal);
Assert.EndsWith(BaselineDiscontinuities.DescriptionSentence, served.Tail!, StringComparison.Ordinal);
}
}
2 changes: 1 addition & 1 deletion Lite.Tests/McpToolsListBudget/McpPerfmonTools.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ param get_perfmon_stats.counter_name 61
param get_perfmon_stats.instance_name 57
param get_perfmon_stats.server_name 28

tool get_perfmon_trend 1057
tool get_perfmon_trend 615
param get_perfmon_trend.as_of 167
param get_perfmon_trend.bucket_minutes 55
param get_perfmon_trend.counter_name 50
Expand Down
2 changes: 1 addition & 1 deletion Lite.Tests/McpToolsListBudget/McpQueryTools.txt
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ param get_query_heatmap.limit 195
param get_query_heatmap.metric 123
param get_query_heatmap.server_name 28

tool get_query_store_duration_trend 1276
tool get_query_store_duration_trend 616
param get_query_store_duration_trend.as_of 167
param get_query_store_duration_trend.hours_back 29
param get_query_store_duration_trend.server_name 28
Expand Down
2 changes: 1 addition & 1 deletion Lite/Mcp/McpPerfmonTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ public static async Task<string> GetPerfmonStats(
}
}

[McpServerTool(Name = "get_perfmon_trend"), Description("Gets one performance counter over time in time buckets. Use get_perfmon_stats first to see available counter names. counter_kind (from the stored cntr_type) says what a point's number is: 'gauge' — value is the bucket's average reading and peak_value its highest, delta_value and sample_interval_seconds are null because a level has no delta; 'rate' — the per-second figure is delta_value divided by sample_interval_seconds, never delta_value alone, and never where sample_interval_seconds is 0 (no delta was knowable); 'other' — delta_value is the change of an average/fraction numerator, not a rate and not a level; null — the rows predate the stored type or the instances mix types, so classify by name (a name ending in /sec is a rate)." + BaselineDiscontinuities.DescriptionSentence)]
[McpServerTool(Name = "get_perfmon_trend"), Description("Gets one performance counter over time in buckets, ending at as_of. counter_kind says the unit: gauge - value is the bucket average, delta_value and sample_interval_seconds null (no delta for a level); rate - per-second is delta_value divided by sample_interval_seconds, never delta_value alone or when the interval is 0; other - delta_value is a non-rate change; null - classify by name (ends in /sec = rate). No points never returns empty: not_collected covers a gated engine, Page Life Expectancy, or an unknown counter name; unavailable: no counter at all collected in the window. <<GUIDE>> Gets one performance counter over time in time buckets. Use get_perfmon_stats first to see available counter names. counter_kind (from the stored cntr_type) says what a point's number is: 'gauge' — value is the bucket's average reading and peak_value its highest, delta_value and sample_interval_seconds are null because a level has no delta; 'rate' — the per-second figure is delta_value divided by sample_interval_seconds, never delta_value alone, and never where sample_interval_seconds is 0 (no delta was knowable); 'other' — delta_value is the change of an average/fraction numerator, not a rate and not a level; null — the rows predate the stored type or the instances mix types, so classify by name (a name ending in /sec is a rate)." + BaselineDiscontinuities.DescriptionSentence)]
public static async Task<string> GetPerfmonTrend(
LocalDataService dataService,
ServerManager serverManager,
Expand Down
Loading
Loading