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
6 changes: 6 additions & 0 deletions Darling/Darling.Tests/DarlingMcpDataToolsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,12 @@ public void PerfmonSql_LatestSnapshot_ValueAndDelta()
Assert.Contains("cntr_value", sql, StringComparison.Ordinal);
Assert.Contains("delta_cntr_value", sql, StringComparison.Ordinal);
Assert.Contains("MAX(collection_time)", sql, StringComparison.Ordinal);

/* The interval a rate row's per_second divides by, selected LAST (ordinal 6, after cntr_type) because the
reader reads by ordinal. */
var interval = sql.IndexOf("sample_interval_seconds", StringComparison.Ordinal);
Assert.True(interval > sql.IndexOf("cntr_type", StringComparison.Ordinal), "sample_interval_seconds must follow cntr_type");
Assert.True(interval < sql.IndexOf("FROM v_perfmon_stats", StringComparison.Ordinal), "sample_interval_seconds must be a selected column");
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ param get_memory_clerks.server_name 28
tool get_memory_stats 480
param get_memory_stats.server_name 28

tool get_perfmon_stats 521
tool get_perfmon_stats 579
param get_perfmon_stats.counter_name 61
param get_perfmon_stats.instance_name 57
param get_perfmon_stats.server_name 28
Expand Down
4 changes: 3 additions & 1 deletion Darling/Darling.Tests/McpToolsListBudgetTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,9 @@ shared 32 KB budget and is not a served description either. */
// #4452 (merge): re-measured on the tree combining dev's #4442 get_read_latency addition with this
// branch's scheduler-issues tool description growth (+95). Constant set to the value
// McpToolsListBudgetTests itself measured on the merged tree, not the two deltas added by hand.
private const int TotalCeilingBytes = 176_476;
// get_perfmon_stats' head names the new per_second field in its rate clause (521 -> 579), byte-identical
// with Lite's twin. Constant set to the value McpToolsListBudgetTests itself measured on this tree.
private const int TotalCeilingBytes = 176_515;



Expand Down
12 changes: 8 additions & 4 deletions Darling/Darling.Tests/MeasurementContractCensusTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -387,16 +387,16 @@ public void EveryPerSecondAlias_IsAQuotient()
/* ---------------- rule 6, the C# half (the Lite half's regexes, verbatim) ---------------- */

private static readonly Regex IdentifierPayloadKey = new(
@"(?<![\w.])(?<key>\w+_per_sec(?:ond)?)\s*=(?![=>])", RegexOptions.Compiled);
@"(?<![\w.])(?<key>(?:\w+_)?per_sec(?:ond)?)\s*=(?![=>])", RegexOptions.Compiled);

private static readonly Regex QuotedPayloadKey = new(
@"\[\s*""(?<key>\w+_per_sec(?:ond)?)""\s*\]\s*=(?![=>])", RegexOptions.Compiled);
@"\[\s*""(?<key>(?:\w+_)?per_sec(?:ond)?)""\s*\]\s*=(?![=>])", RegexOptions.Compiled);

private static readonly Regex AddedPayloadKey = new(
@"\.(?:Try)?Add\(\s*""(?<key>\w+_per_sec(?:ond)?)""\s*,", RegexOptions.Compiled);
@"\.(?:Try)?Add\(\s*""(?<key>(?:\w+_)?per_sec(?:ond)?)""\s*,", RegexOptions.Compiled);

private static readonly Regex PerSecondKeyConstant = new(
@"\bconst\s+string\s+(?<name>\w+)\s*=\s*""(?<key>\w+_per_sec(?:ond)?)""\s*;", RegexOptions.Compiled);
@"\bconst\s+string\s+(?<name>\w+)\s*=\s*""(?<key>(?:\w+_)?per_sec(?:ond)?)""\s*;", RegexOptions.Compiled);

private static readonly Regex ConstantIndexerKey = new(
@"\[\s*(?:\w+\s*\.\s*)*(?<name>\w+)\s*\]\s*=(?![=>])", RegexOptions.Compiled);
Expand Down Expand Up @@ -552,6 +552,7 @@ internal static class Keys { public const string ConstPerSecKey = "constant_per_
["assumed_per_sec"] = row.DeltaValue / 60.0,
[Keys.ConstPerSecKey] = ops / observedSeconds,
["unknown_per_sec"] = something,
["per_second"] = PerSecond(row.DeltaValue, row.SampleIntervalSeconds),
};
stamped.Add("added_per_sec", total / elapsed.TotalSeconds);
var page = new
Expand All @@ -564,6 +565,7 @@ internal static class Keys { public const string ConstPerSecKey = "constant_per_
halved_per_second = total / count,
renamed_per_second = p.Value,
mapped_per_second = "a_table",
per_sec = row.DeltaValue,
compared = ms_per_sec == 0,
};
""";
Expand Down Expand Up @@ -591,6 +593,7 @@ internal static class Keys { public const string ConstPerSecKey = "constant_per_
("assumed_per_sec", "OFFENDER"),
("constant_per_sec", Quotient),
("unknown_per_sec", UnderAnotherName),
("per_second", RateHelper),
("added_per_sec", Quotient),
("divided_per_second", Quotient),
("helped_per_second", RateHelper),
Expand All @@ -600,6 +603,7 @@ internal static class Keys { public const string ConstPerSecKey = "constant_per_
("halved_per_second", "OFFENDER"),
("renamed_per_second", UnderAnotherName),
("mapped_per_second", MapEntry),
("per_sec", "OFFENDER"),
},
verdicts);

Expand Down
14 changes: 11 additions & 3 deletions Darling/Darling.Tests/PerfmonCounterTypeRungTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,8 @@ public void TheProbeCarriesThisRungsSentinel_AndAFullyMigratedStoreMapsToTheLadd
/// <summary>
/// Every Darling perfmon read selects the type: the two trend reads (MCP and viewer) with the one agreed-type
/// expression, byte-identical to each other and to every copy in Lite's trend reads, and the latest-snapshot read
/// with the row's own. The row types keep NULL as null — a gauge row stores no delta and no interval, and a 0
/// with the row's own, and after it the interval a rate row's per-second figure divides by, in the same order in
/// Lite's twin. The row types keep NULL as null — a gauge row stores no delta and no interval, and a 0
/// manufactured in their place would be #3642's fabricated zero on a level that has no delta.
/// </summary>
[Fact]
Expand All @@ -230,8 +231,8 @@ public void EveryDarlingPerfmonRead_SelectsTheType_AndTheRowsKeepNullAsNull()
Assert.Equal(3, Regex.Matches(lite, Regex.Escape(AgreedTypeExpression)).Count);

var latest = DarlingDataReader.LatestPerfmonStatsSql.Replace("\r\n", "\n", StringComparison.Ordinal);
Assert.Contains("collection_time,\n cntr_type\nFROM v_perfmon_stats", latest, StringComparison.Ordinal);
Assert.Contains("collection_time,\n cntr_type\nFROM v_perfmon_stats", lite.Replace("\r\n", "\n", StringComparison.Ordinal), StringComparison.Ordinal);
Assert.Contains("collection_time,\n cntr_type,\n sample_interval_seconds\nFROM v_perfmon_stats", latest, StringComparison.Ordinal);
Assert.Contains("collection_time,\n cntr_type,\n sample_interval_seconds\nFROM v_perfmon_stats", lite.Replace("\r\n", "\n", StringComparison.Ordinal), StringComparison.Ordinal);

/* #4476: both the MCP and the Viewer twins' per-collection SUMs exclude a Wait Statistics isolated
single-sample artifact — FILTER (WHERE NOT is_artifact) — while the MAX interval and the MIN=MAX
Expand Down Expand Up @@ -456,6 +457,7 @@ alignment against TrendBuckets.OriginSql. */
Assert.All(points, p => Assert.Equal(JsonValueKind.Null, p.GetProperty("delta_value").ValueKind));
Assert.All(points, p => Assert.Equal(JsonValueKind.Null, p.GetProperty("sample_interval_seconds").ValueKind));
Assert.Equal(8_000_000, points[1].GetProperty("value").GetInt64());
Assert.All(points, p => Assert.False(p.TryGetProperty("per_second", out _)));
}

using (var trend = JsonDocument.Parse(await DarlingMcpTrendTools.GetPerfmonTrend(postgres, "Batch Requests/sec", ServerName, bucket_minutes: 1)))
Expand All @@ -465,6 +467,9 @@ alignment against TrendBuckets.OriginSql. */
Assert.Equal(0, points[0].GetProperty("sample_interval_seconds").GetInt64());
Assert.Equal(900, points[1].GetProperty("delta_value").GetInt64());
Assert.Equal(300, points[1].GetProperty("sample_interval_seconds").GetInt64());
/* The rate a reader means: the first point's delta was not knowable, the second's is 900 over 300 s. */
Assert.Equal(JsonValueKind.Null, points[0].GetProperty("per_second").ValueKind);
Assert.Equal(3.0, points[1].GetProperty("per_second").GetDouble(), precision: 6);
}

using (var trend = JsonDocument.Parse(await DarlingMcpTrendTools.GetPerfmonTrend(postgres, "Lock waits", ServerName, bucket_minutes: 1)))
Expand All @@ -480,10 +485,13 @@ alignment against TrendBuckets.OriginSql. */
var started = counters.Single(c => c.GetProperty("instance_name").GetString() == "Waits started per second");
Assert.Equal("rate", started.GetProperty("counter_kind").GetString());
Assert.Equal(70, started.GetProperty("delta_value").GetInt64());
/* 70 over the 300 s since the previous collection, which the latest read now selects. */
Assert.Equal(0.2333, started.GetProperty("per_second").GetDouble(), precision: 6);
var inProgress = counters.Single(c => c.GetProperty("instance_name").GetString() == "Waits in progress");
Assert.Equal("gauge", inProgress.GetProperty("counter_kind").GetString());
Assert.Equal(JsonValueKind.Null, inProgress.GetProperty("delta_value").ValueKind);
Assert.Equal(3, inProgress.GetProperty("value").GetInt64());
Assert.False(inProgress.TryGetProperty("per_second", out _));
}

bodySucceeded = true;
Expand Down
120 changes: 120 additions & 0 deletions Darling/Darling.Tests/PerfmonPerSecondPayloadTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/*
* 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 System.Linq;
using System.Text.Json;
using PerformanceMonitor.Common;
using PerformanceMonitor.Darling.Service.Mcp;
using Xunit;

namespace Darling.Tests;

/// <summary>
/// Darling's <c>get_perfmon_stats</c> row, built without a store: the reader's row goes through
/// <see cref="DarlingMcpDataTools.PerfmonRowPayload"/> exactly as the tool sends it. A rate counter's <c>value</c> is
/// its running total, so the row also carries <c>per_second</c>, its delta over the seconds since the previous
/// collection, by the rule both desktop charts plot with. Lite's tool builds the same row through the same shared
/// builder, and <c>PerfmonCounterTypeReadTests</c> in Lite.Tests checks it end to end. The store half (the reader
/// selecting the interval) is <c>DarlingMcpDataToolsSurfaceAndSqlTests.PerfmonSql_LatestSnapshot_ValueAndDelta</c>
/// here and <c>PerfmonCounterTypeLivePostgresTests</c> against a real store.
/// </summary>
public sealed class PerfmonPerSecondPayloadTests
{
private static JsonElement Payload(string counter, long value, long? delta, int? type, int? interval, string instance = "") =>
JsonDocument.Parse(JsonSerializer.Serialize(
DarlingMcpDataTools.PerfmonRowPayload(new DarlingDataReader.PerfmonRow(counter, instance, value, delta, type, interval)),
McpHelpers.JsonOptions)).RootElement;

[Fact]
public void ARateRow_CarriesItsDeltaOverTheStoredInterval_BesideTheRunningTotal()
{
var row = Payload("Batch Requests/sec", 11_641, 66, PerfmonCounterTypes.PerfCounterBulkCount, 300);

Assert.Equal(0.22, row.GetProperty("per_second").GetDouble(), precision: 10);
Assert.Equal(11_641, row.GetProperty("value").GetInt64());
Assert.Equal(66, row.GetProperty("delta_value").GetInt64());
Assert.Equal("rate", row.GetProperty("counter_kind").GetString());

/* The keys the tool published before stay where they were; per_second is added after them. */
Assert.Equal(
new[] { "counter_name", "instance_name", "value", "delta_value", "cntr_type", "counter_kind", "per_second" },
row.EnumerateObject().Select(p => p.Name).ToArray());
}

/// <summary>An interval of 0 is the calculator's "no delta was knowable" (a first collection, a counter reset,
/// a restart): the rate is unknown, which is null, not 0.</summary>
[Fact]
public void ARateRowWithNoKnowableDelta_SaysNull_NotZero()
{
var row = Payload("SQL Compilations/sec", 4_000, 0, PerfmonCounterTypes.PerfCounterBulkCount, 0);

Assert.Equal(JsonValueKind.Null, row.GetProperty("per_second").ValueKind);
}

/// <summary>A gauge's value is its reading, and an average's numerator is not a rate: neither row has the key,
/// so a caller never takes its absence of a rate for an unknown one.</summary>
[Fact]
public void AGaugeRow_AndAnAverageRow_HaveNoPerSecondKey()
{
var gauge = Payload("Total Server Memory (KB)", 8_000_000, null, PerfmonCounterTypes.PerfCounterLargeRawCount, null);
Assert.False(gauge.TryGetProperty("per_second", out _));
Assert.Equal(8_000_000, gauge.GetProperty("value").GetInt64());
Assert.Equal(JsonValueKind.Null, gauge.GetProperty("delta_value").ValueKind);

var average = Payload("Lock waits", 5_000, 40, PerfmonCounterTypes.PerfAverageBulk, 300, "Average wait time (ms)");
Assert.False(average.TryGetProperty("per_second", out _));
}

/// <summary>A row written before the type was stored is rated the way the charts rate it: by a name that says
/// <c>/sec</c>.</summary>
[Fact]
public void ARowWithNoStoredType_IsRatedByItsName()
{
Assert.Equal(0.2, Payload("Legacy Transactions/sec", 900, 60, null, 300).GetProperty("per_second").GetDouble(), precision: 10);
Assert.False(Payload("Legacy Counter", 120, 20, null, 300).TryGetProperty("per_second", out _));
}

/// <summary>A counter that seldom fires keeps its rate and its running total side by side. One deadlock in 300 s is
/// 0.0033 a second; one in the longest interval the calculator rates (3,600 s) keeps two significant digits, where
/// four decimals made it 0.0003. The rule itself, <c>TrendPayloads.RoundRate</c>, is pinned value by value in
/// Lite.Tests' <c>PerfmonPerSecondRoundingTests</c>.</summary>
[Fact]
public void ARateThatSeldomFires_KeepsItsRate_BesideItsTotal()
{
var fiveMinutes = Payload("Number of Deadlocks/sec", 37, 1, PerfmonCounterTypes.PerfCounterBulkCount, 300);
Assert.Equal(0.0033, fiveMinutes.GetProperty("per_second").GetDouble(), precision: 12);
Assert.Equal(37, fiveMinutes.GetProperty("value").GetInt64());

var anHour = Payload("Number of Deadlocks/sec", 37, 1, PerfmonCounterTypes.PerfCounterBulkCount, 3600);
Assert.Equal(0.00028, anHour.GetProperty("per_second").GetDouble(), precision: 12);
Assert.Equal(37, anHour.GetProperty("value").GetInt64());
}

/// <summary>Darling's <c>get_perfmon_trend</c> builds its points through the shared <c>TrendPayloads.PerfmonTrend</c>.
/// The largest bucket a caller can ask for is a day, 86,400 s, and one deadlock in it is a rate that four decimals
/// erase. It publishes as the rate it is, and the busiest collection is rounded the same way.</summary>
[Fact]
public void ADayWideTrendBucket_WithOneCount_PublishesItsRate_NotZero()
{
var seconds = TrendBuckets.MaxBucketMinutes * 60L;
var bucket = new PerfmonBucketPoint(
new DateTime(2026, 9, 1, 0, 0, 0, DateTimeKind.Unspecified), AvgValue: 0, MaxValue: 0, LastValue: 37,
RatedDelta: 1, RatedSeconds: seconds, UnknowableCollections: 0, UnknowableDelta: null, UnrecordedDelta: null,
PeakPerSecond: 1.0 / 3600, CntrType: PerfmonCounterTypes.PerfCounterBulkCount);

using var doc = JsonDocument.Parse(TrendPayloads.PerfmonTrend(
"SRV1", "Number of Deadlocks/sec", 168, new[] { bucket }, TrendBuckets.MaxBucketMinutes, requested: true,
autoBudget: TrendBuckets.McpPointBudget, discontinuities: Array.Empty<object>()));
var point = doc.RootElement.GetProperty("trend")[0];

Assert.Equal(0.000012, point.GetProperty("per_second").GetDouble(), precision: 12);
Assert.Equal(0.00028, point.GetProperty("peak_per_second").GetDouble(), precision: 12);
Assert.Equal(1, point.GetProperty("delta_value").GetInt64());
}
}
Loading
Loading