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
10 changes: 6 additions & 4 deletions Darling/Darling.Tests/PerfmonCounterTypeRungTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -212,9 +212,9 @@ 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 Lite's two, 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 manufactured
/// in their place would be #3642's fabricated zero on a level that has no delta.
/// 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
/// manufactured in their place would be #3642's fabricated zero on a level that has no delta.
/// </summary>
[Fact]
public void EveryDarlingPerfmonRead_SelectsTheType_AndTheRowsKeepNullAsNull()
Expand All @@ -225,7 +225,9 @@ public void EveryDarlingPerfmonRead_SelectsTheType_AndTheRowsKeepNullAsNull()
Assert.Contains(AgreedTypeExpression, viewerTrend, StringComparison.Ordinal);

var lite = RepoFile.ReadRepoFile("Lite", "Services", "LocalDataService.Perfmon.cs");
Assert.Equal(2, Regex.Matches(lite, Regex.Escape(AgreedTypeExpression)).Count);
/* Lite's single-counter read holds it once; its bucketed batched read (#4234) holds it twice, per collection
inside and per bucket outside, as both Darling twins do. */
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);
Expand Down
481 changes: 481 additions & 0 deletions Lite.Tests/LiteWaitPerfmonTrendBucketingTests.cs

Large diffs are not rendered by default.

28 changes: 21 additions & 7 deletions Lite.Tests/PerfmonIntervalAggregationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,27 +45,41 @@ public void BothPerfmonTrendReads_TakeTheIntervalAsMax()
Assert.Equal(2, max);
}

/// <summary>The failure mode itself: a summed interval anywhere in this read path is the 12-17x
/// denominator inflation, and it is silent — the column is populated, the query succeeds, and only
/// the derived rate is wrong.</summary>
/// <summary>The failure mode itself: a summed interval across a collection's INSTANCE rows is the
/// 12-17x denominator inflation, and it is silent — the column is populated, the query succeeds, and
/// only the derived rate is wrong.
/// <para>#4234: the batched trend's bucketing legitimately adds a SECOND, different SUM over
/// <c>sample_interval_seconds</c> — the outer GROUP BY <c>counter_name, bucket_start</c> summing each
/// COLLECTION's own already-<c>MAX</c>'d interval across the collections a bucket holds (the ruling's
/// summed-intervals rate), never raw instance rows. That SUM always carries this exact
/// <c>FILTER (WHERE sample_interval_seconds &gt; 0)</c> right after it, so stripping just that
/// substring isolates exactly the original bug shape: a bare, unfiltered
/// <c>SUM(sample_interval_seconds)</c> grouped over instance rows.</para></summary>
[Fact]
public void NoPerfmonTrendRead_SumsTheInterval()
public void NoPerfmonTrendRead_SumsTheIntervalAcrossInstanceRows()
{
var source = ParitySource.ReadFile(ReadPath);

Assert.DoesNotContain("SUM(sample_interval_seconds)", source, StringComparison.Ordinal);
var withoutTheBucketedSum = source.Replace(
"SUM(sample_interval_seconds) FILTER (WHERE sample_interval_seconds > 0)", string.Empty);
Assert.DoesNotContain("SUM(sample_interval_seconds)", withoutTheBucketedSum, StringComparison.Ordinal);
}

/// <summary>Guards the fix from being over-applied: the two columns that ARE additive must stay sums,
/// in both reads. A well-meant "make it consistent" edit that turned these into MAX would silently
/// report one instance's value as the whole counter.</summary>
/// report one instance's value as the whole counter.
/// <para>#4234: <c>delta_cntr_value</c> is 3, not 2 — the batched trend's bucketing adds one more SUM,
/// the outer GROUP BY <c>counter_name, bucket_start</c> summing each collection's already-summed delta
/// across the bucket's collections (the ruling's summed-deltas rate), same additive column, one layer
/// higher. <c>cntr_value</c> stays 2: that outer layer AVERAGES (the ruling's gauge rule), so it adds no
/// third SUM.</para></summary>
[Fact]
public void TheAdditiveColumnsStaySummed()
{
var source = ParitySource.ReadFile(ReadPath);

Assert.Equal(2, CountOccurrences(source, "SUM(cntr_value)"));
Assert.Equal(2, CountOccurrences(source, "SUM(delta_cntr_value)"));
Assert.Equal(3, CountOccurrences(source, "SUM(delta_cntr_value)"));
Assert.DoesNotContain("MAX(cntr_value)", source, StringComparison.Ordinal);
Assert.DoesNotContain("MAX(delta_cntr_value)", source, StringComparison.Ordinal);
}
Expand Down
4 changes: 2 additions & 2 deletions Lite/Controls/ServerTab.Refresh.cs
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ private async System.Threading.Tasks.Task RefreshWaitStatsAsync(int hoursBack, D
{
try
{
var waitTypesTask = Task.Run(() => _dataService.GetDistinctWaitTypesAsync(_serverId, hoursBack, fromDate, toDate));
var waitTypesTask = Task.Run(() => _dataService.GetDistinctWaitTypesForPickerAsync(_serverId, hoursBack, fromDate, toDate));
await waitTypesTask;
PopulateWaitTypePicker(waitTypesTask.Result);
await UpdateWaitStatsChartFromPickerAsync();
Expand Down Expand Up @@ -749,7 +749,7 @@ private async System.Threading.Tasks.Task RefreshPerfmonAsync(int hoursBack, Dat
{
try
{
var perfmonCountersTask = Task.Run(() => _dataService.GetDistinctPerfmonCountersAsync(_serverId, hoursBack, fromDate, toDate));
var perfmonCountersTask = Task.Run(() => _dataService.GetDistinctPerfmonCountersForPickerAsync(_serverId, hoursBack, fromDate, toDate));
await perfmonCountersTask;
PopulatePerfmonPicker(perfmonCountersTask.Result);
await UpdatePerfmonChartFromPickerAsync();
Expand Down
80 changes: 80 additions & 0 deletions Lite/Services/LiteNameListCache.cs
Original file line number Diff line number Diff line change
@@ -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.Collections.Concurrent;
using System.Collections.Generic;

namespace PerformanceMonitorLite.Services;

/// <summary>
/// #4234: TTL memoization for the Wait Stats and Perfmon picker's name-list reads
/// (<see cref="LocalDataService.GetDistinctWaitTypesForPickerAsync"/>,
/// <see cref="LocalDataService.GetDistinctPerfmonCountersForPickerAsync"/>), each a full-window <c>DISTINCT</c>
/// that used to run on every 1-minute auto-refresh. MCP shares the same underlying reads
/// (<see cref="LocalDataService.GetDistinctWaitTypesAsync"/>, <see cref="LocalDataService.GetDistinctPerfmonCountersAsync"/>)
/// but calls them directly, uncached, so an MCP answer is never stale by <see cref="Ttl"/> — only the picker
/// trades that freshness for fewer full-window scans. A server's set of collected names barely moves inside
/// its own window, so this caches the list per (server, window length) for <see cref="Ttl"/> and lets a
/// refresh inside that window reuse it — window LENGTH, not bounds, because a sliding preset's start/end both
/// move every tick while the length stays constant, and keying on the moving bounds would never hit. The
/// Darling twin is <c>ViewerNameListCache</c> (#4234, PR #4304); this port keeps its rules so the two SKUs
/// cache the same way.
///
/// <para><b>The key is (server, length) alone, but a hit ALSO needs the cached window's END to still be
/// recent.</b> Keying on length alone would let a stale entry answer for a window it never fetched: a user
/// viewing this week's 7-day range, then switching to a custom 7-day range from last month, would get THIS
/// WEEK's list under last month's window, because both windows share one length-keyed slot. A hit therefore
/// also requires the cached <see cref="Entry.WindowEndUtc"/> to sit within <see cref="Ttl"/> of the requested
/// end — the same freshness distance the fetch timestamp already checks, applied to the window itself rather
/// than the wall clock, and checked BOTH directions (<c>Duration()</c>) since a custom range can move the end
/// either way, not just forward like a sliding preset's auto-refresh does.</para>
///
/// <para>Meant to be reused beside any other Lite picker that reads a name list over a window the same way
/// (one instance per <see cref="LocalDataService"/>, matching that service's own per-tab lifetime).</para>
/// </summary>
internal sealed class LiteNameListCache
{
/// <summary>How long a fetched list is trusted before the next call re-runs the DISTINCT (the #4234 ruling's
/// number).</summary>
public static readonly TimeSpan Ttl = TimeSpan.FromMinutes(15);

private readonly record struct Key(int ServerId, int WindowLengthMinutes);
private readonly record struct Entry(List<string> Names, DateTime WindowEndUtc, DateTime FetchedAtUtc);

private readonly ConcurrentDictionary<Key, Entry> _entries = new();

/// <summary>True (with <paramref name="names"/> populated) when a list for this server and window length was
/// fetched within <see cref="Ttl"/> of <paramref name="nowUtc"/> AND that fetch's window end sits within
/// <see cref="Ttl"/> of <paramref name="endUtc"/> — two independent freshness checks, because a same-length
/// window from a different point in time is a different window, not a refresh of this one.</summary>
public bool TryGet(int serverId, TimeSpan windowLength, DateTime endUtc, DateTime nowUtc, out List<string> names)
{
if (_entries.TryGetValue(KeyFor(serverId, windowLength), out var entry)
&& nowUtc - entry.FetchedAtUtc < Ttl
&& (endUtc - entry.WindowEndUtc).Duration() < Ttl)
{
names = entry.Names;
return true;
}

names = new List<string>();
return false;
}

/// <summary>Records a freshly fetched list for the window ending <paramref name="endUtc"/>, stamped
/// <paramref name="nowUtc"/>.</summary>
public void Set(int serverId, TimeSpan windowLength, DateTime endUtc, List<string> names, DateTime nowUtc) =>
_entries[KeyFor(serverId, windowLength)] = new Entry(names, endUtc, nowUtc);

/// <summary>Rounded to the whole minute: a preset window's length is already a whole number of hours and a
/// custom range is quarter-hour granular, so rounding only absorbs floating-point noise in the subtraction,
/// never two genuinely different windows into one key.</summary>
private static Key KeyFor(int serverId, TimeSpan windowLength) =>
new(serverId, (int)Math.Round(windowLength.TotalMinutes));
}
Loading
Loading