diff --git a/Darling/Darling.Tests/PerfmonCounterTypeRungTests.cs b/Darling/Darling.Tests/PerfmonCounterTypeRungTests.cs
index 2f0da8979e..c8d28c830a 100644
--- a/Darling/Darling.Tests/PerfmonCounterTypeRungTests.cs
+++ b/Darling/Darling.Tests/PerfmonCounterTypeRungTests.cs
@@ -212,9 +212,9 @@ public void TheProbeCarriesThisRungsSentinel_AndAFullyMigratedStoreMapsToTheLadd
///
/// 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.
///
[Fact]
public void EveryDarlingPerfmonRead_SelectsTheType_AndTheRowsKeepNullAsNull()
@@ -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);
diff --git a/Lite.Tests/LiteWaitPerfmonTrendBucketingTests.cs b/Lite.Tests/LiteWaitPerfmonTrendBucketingTests.cs
new file mode 100644
index 0000000000..ba891ebfa1
--- /dev/null
+++ b/Lite.Tests/LiteWaitPerfmonTrendBucketingTests.cs
@@ -0,0 +1,481 @@
+/*
+ * 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.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using DuckDB.NET.Data;
+using PerformanceMonitor.Common;
+using PerformanceMonitorLite.Database;
+using PerformanceMonitorLite.Services;
+using Xunit;
+
+namespace PerformanceMonitorLite.Tests;
+
+///
+/// #4234 (lane T3b, the Lite twin of PR #4304): the ruling's four required tests for
+/// / GetPerfmonTrendsByCountersAsync and their
+/// two picker reads. No live SQL Server or Postgres needed — DuckDB is the embedded store itself.
+/// Proven once by hand against the pre-#4234 text: reverting /
+/// PerfmonTrendsSql to the per-collection SELECT (no rated CTE, no time_bucket, no width
+/// parameter) fails 's source checks directly, and fails
+/// /
+/// its Perfmon twin (10,080 one-minute collections over 7 days is 10,080 rows per series, not ≤ 1,500).
+///
+public sealed class LiteTrendBucketWidthSqlTests
+{
+ [Fact]
+ public void WaitTrendsSql_CarriesABucketWidth()
+ {
+ /* server/start/end = $1-$3, 2 wait types = $4-$5, so the width is $6. */
+ var sql = LocalDataService.WaitTrendsSql(2);
+ Assert.Contains("time_bucket(to_minutes(CAST($6 AS INTEGER))", sql, StringComparison.Ordinal);
+ Assert.Contains(TrendBuckets.OriginSql, sql, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void PerfmonTrendsSql_CarriesABucketWidth()
+ {
+ var sql = LocalDataService.PerfmonTrendsSql(2);
+ Assert.Contains("time_bucket(to_minutes(CAST($6 AS INTEGER))", sql, StringComparison.Ordinal);
+ Assert.Contains(TrendBuckets.OriginSql, sql, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void WaitTrendsSql_And_PerfmonTrendsSql_ProjectFirstCollectionTimeAndCollectionCount()
+ {
+ foreach (var sql in new[] { LocalDataService.WaitTrendsSql(1), LocalDataService.PerfmonTrendsSql(1) })
+ {
+ Assert.Contains("MIN(collection_time) AS first_collection_time", sql, StringComparison.Ordinal);
+ Assert.Contains("COUNT(*) AS collection_count", sql, StringComparison.Ordinal);
+ }
+ }
+}
+
+///
+/// 's hit/miss rules, exercised directly — no store needed since the cache never
+/// touches DuckDB itself. Mirrors Darling's ViewerNameListCacheTests (#4234, PR #4304) so the two SKUs'
+/// picker caches are pinned the same way.
+///
+public sealed class LiteNameListCacheTests
+{
+ private static readonly TimeSpan Length = TimeSpan.FromHours(24);
+
+ [Fact]
+ public void TryGet_MissesInitially()
+ {
+ var cache = new LiteNameListCache();
+ var now = DateTime.UtcNow;
+ Assert.False(cache.TryGet(1, Length, now, now, out _));
+ }
+
+ [Fact]
+ public void TryGet_HitsWithinTtl_SameWindowEnd()
+ {
+ var cache = new LiteNameListCache();
+ var fetchedAt = new DateTime(2026, 3, 1, 12, 0, 0);
+ var end = new DateTime(2026, 3, 1, 11, 0, 0);
+ var names = new List { "CXPACKET" };
+ cache.Set(1, Length, end, names, fetchedAt);
+
+ /* The ruling's own pin: a second refresh inside 15 minutes runs no DISTINCT — this IS that refresh,
+ proven at the mechanism that prevents the query: TryGet returns the cached list. */
+ Assert.True(cache.TryGet(1, Length, end, fetchedAt.AddMinutes(14), out var cached));
+ Assert.Same(names, cached);
+ }
+
+ [Fact]
+ public void TryGet_MissesAtFifteenMinutes_WallClockExpiry()
+ {
+ var cache = new LiteNameListCache();
+ var fetchedAt = new DateTime(2026, 3, 1, 12, 0, 0);
+ var end = new DateTime(2026, 3, 1, 11, 0, 0);
+ cache.Set(1, Length, end, new List { "CXPACKET" }, fetchedAt);
+
+ /* One after 15 minutes DOES run the DISTINCT again — the ruling's other half of the same pin. */
+ Assert.False(cache.TryGet(1, Length, end, fetchedAt.AddMinutes(15), out _));
+ }
+
+ [Fact]
+ public void TryGet_Misses_WhenCachedWindowEndIsFarFromRequestedEnd_DespiteFreshFetch()
+ {
+ var cache = new LiteNameListCache();
+ var now = new DateTime(2026, 3, 1, 12, 0, 0);
+ cache.Set(1, TimeSpan.FromDays(7), now, new List { "CXPACKET" }, now);
+
+ var lastMonthsEnd = now.AddDays(-30);
+ Assert.False(cache.TryGet(1, TimeSpan.FromDays(7), lastMonthsEnd, now, out _));
+ }
+
+ [Fact]
+ public void TryGet_Hits_WhenRequestedEndIsSlightlyBeforeCachedEnd_TwoSidedCheck()
+ {
+ var cache = new LiteNameListCache();
+ var now = new DateTime(2026, 3, 1, 12, 0, 0);
+ cache.Set(1, Length, now, new List { "CXPACKET" }, now);
+
+ Assert.True(cache.TryGet(1, Length, now.AddMinutes(-5), now, out _));
+ }
+}
+
+///
+/// The ruling's remaining two tests (the 7-day row-budget cap, and point equality when the budget covers every
+/// collection) plus the pickers' end-to-end cache behavior, against a real embedded DuckDB.
+///
+/* GetWaitStatsTrendsByTypesAsync / GetPerfmonTrendsByCountersAsync's fromDate/toDate overload is server-local
+ (GetTimeRange converts it back to UTC through ServerTimeHelper.UtcOffsetMinutes, a process-wide mutable
+ static) — every fromDate/toDate this class passes is meant as an exact UTC instant, so the offset is pinned
+ to 0 for the class's lifetime. xUnit runs test classes in parallel, so this joins the collection the other
+ offset-touching classes use rather than racing them. */
+[Collection("server-time-helper")]
+public sealed class LiteTrendBucketingLiveTests : IClassFixture, IDisposable
+{
+ private const int ServerId = -4234300;
+ private const string ServerName = "trend-bucketing-e2e";
+
+ private readonly DuckDbInitializer _duckDb;
+ private readonly LocalDataService _dataService;
+ private readonly int _savedUtcOffsetMinutes;
+ private long _nextId = -1;
+ private DuckDBConnection? _seedConn;
+
+ public LiteTrendBucketingLiveTests(SharedDuckDbFixture fixture)
+ {
+ fixture.ResetData();
+ _duckDb = fixture.DuckDb;
+ _dataService = new LocalDataService(_duckDb);
+ _savedUtcOffsetMinutes = ServerTimeHelper.UtcOffsetMinutes;
+ ServerTimeHelper.UtcOffsetMinutes = 0;
+ }
+
+ public void Dispose()
+ {
+ ServerTimeHelper.UtcOffsetMinutes = _savedUtcOffsetMinutes;
+ _seedConn?.Dispose();
+ }
+
+ [Fact]
+ public async Task WaitTrend_SevenDayWindow_ReturnsAtMostBudgetTimesSeriesRows()
+ {
+ var end = new DateTime(2026, 3, 10, 0, 0, 0);
+ var start = end.AddDays(-7);
+ var waitTypes = new List { "CXPACKET", "WRITELOG" };
+
+ foreach (var waitType in waitTypes)
+ {
+ await BulkSeedWaitAsync(start, end, waitType);
+ }
+
+ var trends = await _dataService.GetWaitStatsTrendsByTypesAsync(ServerId, waitTypes, fromDate: start, toDate: end);
+
+ var totalRows = trends.Values.Sum(list => list.Count);
+ var budget = TrendBudget.Chart.AutoPoints * waitTypes.Count;
+ Assert.True(totalRows > 0 && totalRows <= budget, $"{totalRows} rows over a {waitTypes.Count}-series budget of {budget}");
+ }
+
+ [Fact]
+ public async Task PerfmonTrend_SevenDayWindow_ReturnsAtMostBudgetTimesSeriesRows()
+ {
+ var end = new DateTime(2026, 3, 10, 0, 0, 0);
+ var start = end.AddDays(-7);
+ var counters = new List { "Batch Requests/sec", "Page life expectancy" };
+
+ foreach (var counter in counters)
+ {
+ await BulkSeedPerfmonAsync(start, end, counter);
+ }
+
+ var trends = await _dataService.GetPerfmonTrendsByCountersAsync(ServerId, counters, fromDate: start, toDate: end);
+
+ var totalRows = trends.Values.Sum(list => list.Count);
+ var budget = TrendBudget.Chart.AutoPoints * counters.Count;
+ Assert.True(totalRows > 0 && totalRows <= budget, $"{totalRows} rows over a {counters.Count}-series budget of {budget}");
+ }
+
+ [Fact]
+ public async Task WaitTrend_BudgetCoversEveryCollection_ReturnsRawTimestampsAndValuesUnchanged()
+ {
+ /* Off the minute grid (:37 seconds) — on the pre-#4234 bucketed code every point would have been
+ floored to the time_bucket grid line, losing the seconds. */
+ var t1 = new DateTime(2026, 3, 10, 9, 0, 37);
+ var t2 = t1.AddMinutes(5);
+ var t3 = t2.AddMinutes(5);
+
+ await SeedWaitAsync(t1, "CXPACKET", deltaMs: 300, deltaSignal: 30, deltaTasks: 10, interval: 300);
+ await SeedWaitAsync(t2, "CXPACKET", deltaMs: 600, deltaSignal: 60, deltaTasks: 20, interval: 300);
+ await SeedWaitAsync(t3, "CXPACKET", deltaMs: 900, deltaSignal: 90, deltaTasks: 30, interval: 300);
+
+ var trends = await _dataService.GetWaitStatsTrendsByTypesAsync(
+ ServerId, new List { "CXPACKET" }, fromDate: t1.AddMinutes(-1), toDate: t3.AddMinutes(1));
+
+ var points = trends["CXPACKET"];
+ Assert.Equal(new[] { t1, t2, t3 }, points.Select(p => p.CollectionTime).ToArray());
+ Assert.Equal(1.0, points[0].WaitTimeMsPerSecond, precision: 6);
+ Assert.Equal(2.0, points[1].WaitTimeMsPerSecond, precision: 6);
+ Assert.Equal(3.0, points[2].WaitTimeMsPerSecond, precision: 6);
+ Assert.Equal(30.0, points[0].AvgMsPerWait, precision: 6);
+ Assert.Equal(30.0, points[1].AvgMsPerWait, precision: 6);
+ }
+
+ [Fact]
+ public async Task PerfmonTrend_BudgetCoversEveryCollection_ReturnsRawTimestampsAndValuesUnchanged()
+ {
+ var t1 = new DateTime(2026, 3, 10, 9, 0, 37);
+ var t2 = t1.AddMinutes(5);
+ var t3 = t2.AddMinutes(5);
+
+ await SeedPerfmonAsync(t1, "Batch Requests/sec", cntr: 1000, delta: 300, interval: 300);
+ await SeedPerfmonAsync(t2, "Batch Requests/sec", cntr: 1300, delta: 600, interval: 300);
+ await SeedPerfmonAsync(t3, "Batch Requests/sec", cntr: 1900, delta: 900, interval: 300);
+
+ var trends = await _dataService.GetPerfmonTrendsByCountersAsync(
+ ServerId, new List { "Batch Requests/sec" }, fromDate: t1.AddMinutes(-1), toDate: t3.AddMinutes(1));
+
+ var points = trends["Batch Requests/sec"];
+ Assert.Equal(new[] { t1, t2, t3 }, points.Select(p => p.CollectionTime).ToArray());
+ Assert.Equal(new long[] { 1000, 1300, 1900 }, points.Select(p => p.Value).ToArray());
+ Assert.Equal(new long?[] { 300, 600, 900 }, points.Select(p => p.DeltaValue).ToArray());
+ Assert.Equal(new long?[] { 300, 300, 300 }, points.Select(p => p.SampleIntervalSeconds).ToArray());
+ }
+
+ /// The real guard for the 12-17x bug (#2234):
+ /// pins the inner MAX(sample_interval_seconds) in source text, but no test before this one seeded
+ /// more than one instance row per collection — so a regression that turned that inner MAX into a SUM would
+ /// pass every other test in this file. Transactions/sec-shaped: 12 instance rows (the production median) on
+ /// the counter's ONE collection, each carrying the same delta and interval. A single-collection bucket's
+ /// DeltaValue/SampleIntervalSeconds must equal that one collection's already-correctly-aggregated values —
+ /// proven once by hand: reverting the inner MAX to SUM turns 300 into 3,600 here, a 12x-too-low rate.
+ ///
+ [Fact]
+ public async Task PerfmonTrend_MultiInstanceCollection_SingleCollectionBucket_TakesIntervalAsMaxNotSum()
+ {
+ var end = new DateTime(2026, 3, 12, 9, 5, 0);
+ var start = end.AddMinutes(-20);
+ var at = end.AddMinutes(-10);
+
+ await SeedPerfmonMultiInstanceAsync(at, "Transactions/sec", instanceCount: 12, deltaPerInstance: 100, intervalPerInstance: 300);
+
+ var trends = await _dataService.GetPerfmonTrendsByCountersAsync(
+ ServerId, new List { "Transactions/sec" }, fromDate: start, toDate: end);
+
+ var point = Assert.Single(trends["Transactions/sec"]);
+ Assert.Equal(1200L, point.DeltaValue);
+ Assert.Equal(300L, point.SampleIntervalSeconds);
+ }
+
+ /// The merged-bucket half: several collections — each already correctly aggregated across its own
+ /// 12 instance rows by the inner GROUP BY — land in the SAME bucket. The bucket's DeltaValue and
+ /// SampleIntervalSeconds are the SUM across those COLLECTIONS (the ruling's summed-deltas-over-summed-
+ /// intervals rate), never across instance rows, so the rate they imply stays 100 * 12 / 300 = 4/second no
+ /// matter how many collections merge — 3,600 / 900, not 3,600 / 10,800 (which a SUM over 36 instance rows'
+ /// worth of interval would give).
+ [Fact]
+ public async Task PerfmonTrend_MultiInstanceCollection_MergedBucket_SumsAcrossCollectionsAtFourPerSecond()
+ {
+ var end = new DateTime(2026, 3, 12, 9, 5, 0);
+ var start = end.AddMinutes(-20);
+ var bucketStart = end.AddMinutes(-15);
+
+ await SeedPerfmonMultiInstanceAsync(bucketStart.AddSeconds(5), "Transactions/sec", instanceCount: 12, deltaPerInstance: 100, intervalPerInstance: 300);
+ await SeedPerfmonMultiInstanceAsync(bucketStart.AddSeconds(25), "Transactions/sec", instanceCount: 12, deltaPerInstance: 100, intervalPerInstance: 300);
+ await SeedPerfmonMultiInstanceAsync(bucketStart.AddSeconds(45), "Transactions/sec", instanceCount: 12, deltaPerInstance: 100, intervalPerInstance: 300);
+
+ var trends = await _dataService.GetPerfmonTrendsByCountersAsync(
+ ServerId, new List { "Transactions/sec" }, fromDate: start, toDate: end);
+
+ var point = Assert.Single(trends["Transactions/sec"]);
+ Assert.Equal(3600L, point.DeltaValue);
+ Assert.Equal(900L, point.SampleIntervalSeconds);
+ Assert.Equal(4.0, (double)point.DeltaValue!.Value / point.SampleIntervalSeconds!.Value, precision: 6);
+ }
+
+ /// The ruling's picker test: a second call inside 15 minutes runs no DISTINCT (the store gains a
+ /// second wait type, but a within-TTL call still answers with the cached one-type list), and a call past
+ /// the TTL reruns it and picks up the change. Only the picker entry point takes nowUtc / caches at
+ /// all — itself stays uncached for MCP.
+ [Fact]
+ public async Task DistinctWaitTypesForPicker_SecondCallInsideTtl_SkipsTheReread_MissesPastTtl()
+ {
+ var end = new DateTime(2026, 3, 1, 12, 30, 0);
+ await SeedWaitAsync(end.AddMinutes(-5), "CXPACKET", deltaMs: 100, deltaSignal: 10, deltaTasks: 5, interval: 60);
+
+ var first = await _dataService.GetDistinctWaitTypesForPickerAsync(ServerId, hoursBack: 1, asOfUtc: end, nowUtc: end);
+ Assert.Equal(new[] { "CXPACKET" }, first);
+
+ await SeedWaitAsync(end.AddMinutes(-5), "WRITELOG", deltaMs: 50, deltaSignal: 5, deltaTasks: 2, interval: 60);
+
+ var withinTtl = await _dataService.GetDistinctWaitTypesForPickerAsync(ServerId, hoursBack: 1, asOfUtc: end, nowUtc: end.AddMinutes(14));
+ Assert.Equal(new[] { "CXPACKET" }, withinTtl);
+
+ var pastTtl = await _dataService.GetDistinctWaitTypesForPickerAsync(ServerId, hoursBack: 1, asOfUtc: end, nowUtc: end.AddMinutes(16));
+ Assert.Equal(2, pastTtl.Count);
+ }
+
+ /// The Perfmon picker's same two halves of the ruling's cache pin.
+ [Fact]
+ public async Task DistinctPerfmonCountersForPicker_SecondCallInsideTtl_SkipsTheReread_MissesPastTtl()
+ {
+ var end = new DateTime(2026, 3, 1, 12, 30, 0);
+ await SeedPerfmonAsync(end.AddMinutes(-5), "Batch Requests/sec", cntr: 1000, delta: 300, interval: 300);
+
+ var first = await _dataService.GetDistinctPerfmonCountersForPickerAsync(ServerId, hoursBack: 1, asOfUtc: end, nowUtc: end);
+ Assert.Equal(new[] { "Batch Requests/sec" }, first);
+
+ await SeedPerfmonAsync(end.AddMinutes(-5), "Page life expectancy", cntr: 500, delta: null, interval: null);
+
+ var withinTtl = await _dataService.GetDistinctPerfmonCountersForPickerAsync(ServerId, hoursBack: 1, asOfUtc: end, nowUtc: end.AddMinutes(14));
+ Assert.Equal(new[] { "Batch Requests/sec" }, withinTtl);
+
+ var pastTtl = await _dataService.GetDistinctPerfmonCountersForPickerAsync(ServerId, hoursBack: 1, asOfUtc: end, nowUtc: end.AddMinutes(16));
+ Assert.Equal(2, pastTtl.Count);
+ }
+
+ /// MCP's guard: the shared read never
+ /// caches, so two calls back to back — even inside the picker's 15-minute TTL — each re-run the DISTINCT and
+ /// see whatever is in the store at call time, not a memoized snapshot from the first call.
+ [Fact]
+ public async Task DistinctWaitTypes_SharedMethod_NeverCaches_SecondCallSeesNewlySeededType()
+ {
+ var end = new DateTime(2026, 3, 1, 12, 30, 0);
+ await SeedWaitAsync(end.AddMinutes(-5), "CXPACKET", deltaMs: 100, deltaSignal: 10, deltaTasks: 5, interval: 60);
+
+ var first = await _dataService.GetDistinctWaitTypesAsync(ServerId, hoursBack: 1, asOfUtc: end);
+ Assert.Equal(new[] { "CXPACKET" }, first);
+
+ await SeedWaitAsync(end.AddMinutes(-5), "WRITELOG", deltaMs: 50, deltaSignal: 5, deltaTasks: 2, interval: 60);
+
+ var second = await _dataService.GetDistinctWaitTypesAsync(ServerId, hoursBack: 1, asOfUtc: end);
+ Assert.Equal(2, second.Count);
+ }
+
+ /// The Perfmon twin: never caches.
+ [Fact]
+ public async Task DistinctPerfmonCounters_SharedMethod_NeverCaches_SecondCallSeesNewlySeededCounter()
+ {
+ var end = new DateTime(2026, 3, 1, 12, 30, 0);
+ await SeedPerfmonAsync(end.AddMinutes(-5), "Batch Requests/sec", cntr: 1000, delta: 300, interval: 300);
+
+ var first = await _dataService.GetDistinctPerfmonCountersAsync(ServerId, hoursBack: 1, asOfUtc: end);
+ Assert.Equal(new[] { "Batch Requests/sec" }, first);
+
+ await SeedPerfmonAsync(end.AddMinutes(-5), "Page life expectancy", cntr: 500, delta: null, interval: null);
+
+ var second = await _dataService.GetDistinctPerfmonCountersAsync(ServerId, hoursBack: 1, asOfUtc: end);
+ Assert.Equal(2, second.Count);
+ }
+
+ /* ---- seeding ---------------------------------------------------------------------------------------- */
+
+ private async Task SeedConnectionAsync()
+ {
+ if (_seedConn is null)
+ {
+ _seedConn = _duckDb.CreateConnection();
+ await _seedConn.OpenAsync();
+ }
+
+ return _seedConn;
+ }
+
+ private async Task SeedWaitAsync(DateTime at, string waitType, long deltaMs, long deltaSignal, long deltaTasks, int interval)
+ {
+ using var readLock = _duckDb.AcquireReadLock();
+ var conn = await SeedConnectionAsync();
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = @"INSERT INTO wait_stats
+ (collection_id, collection_time, server_id, server_name, wait_type,
+ waiting_tasks_count, wait_time_ms, signal_wait_time_ms,
+ delta_waiting_tasks, delta_wait_time_ms, delta_signal_wait_time_ms, sample_interval_seconds)
+ VALUES ($1, $2, $3, $4, $5, 0, 0, 0, $6, $7, $8, $9)";
+ foreach (var v in new object[] { _nextId--, at, ServerId, ServerName, waitType, deltaTasks, deltaMs, deltaSignal, interval })
+ {
+ cmd.Parameters.Add(new DuckDBParameter { Value = v });
+ }
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ private async Task SeedPerfmonAsync(DateTime at, string counterName, long cntr, long? delta, int? interval)
+ {
+ using var readLock = _duckDb.AcquireReadLock();
+ var conn = await SeedConnectionAsync();
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = @"INSERT INTO perfmon_stats
+ (collection_id, collection_time, server_id, server_name, object_name, counter_name, instance_name,
+ cntr_value, delta_cntr_value, sample_interval_seconds)
+ VALUES ($1, $2, $3, $4, 'SQLServer:Test', $5, '', $6, $7, $8)";
+ foreach (var v in new object[] { _nextId--, at, ServerId, ServerName, counterName, cntr, (object?)delta ?? DBNull.Value, (object?)interval ?? DBNull.Value })
+ {
+ cmd.Parameters.Add(new DuckDBParameter { Value = v });
+ }
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ /// Seeds ONE collection as instance rows — a counter whose DMV
+ /// shape is one row per database/file/whatever the instance is, all sharing one — so
+ /// the interval and delta aggregation runs over real instance rows instead of the single-row-per-collection
+ /// shape every other seed helper in this file uses.
+ private async Task SeedPerfmonMultiInstanceAsync(DateTime at, string counterName, int instanceCount, long deltaPerInstance, int intervalPerInstance)
+ {
+ using var readLock = _duckDb.AcquireReadLock();
+ var conn = await SeedConnectionAsync();
+ for (var i = 0; i < instanceCount; i++)
+ {
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = @"INSERT INTO perfmon_stats
+ (collection_id, collection_time, server_id, server_name, object_name, counter_name, instance_name,
+ cntr_value, delta_cntr_value, sample_interval_seconds)
+ VALUES ($1, $2, $3, $4, 'SQLServer:Databases', $5, $6, $7, $8, $9)";
+ foreach (var v in new object[] { _nextId--, at, ServerId, ServerName, counterName, "db" + i, deltaPerInstance, deltaPerInstance, intervalPerInstance })
+ {
+ cmd.Parameters.Add(new DuckDBParameter { Value = v });
+ }
+ await cmd.ExecuteNonQueryAsync();
+ }
+ }
+
+ /// One row a minute across the whole window, through generate_series in a single INSERT
+ /// (10,080 rows for a 7-day window) — the budget-cap tests' bulk seed. Rated throughout (a fixed 60-second
+ /// interval) so the read's own point count, not an unrated-collection drop, is what the assertion checks.
+ private async Task BulkSeedWaitAsync(DateTime start, DateTime end, string waitType)
+ {
+ using var readLock = _duckDb.AcquireReadLock();
+ var conn = await SeedConnectionAsync();
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = @"INSERT INTO wait_stats
+ (collection_id, collection_time, server_id, server_name, wait_type,
+ waiting_tasks_count, wait_time_ms, signal_wait_time_ms,
+ delta_waiting_tasks, delta_wait_time_ms, delta_signal_wait_time_ms, sample_interval_seconds)
+ SELECT $1 - ROW_NUMBER() OVER (ORDER BY t), t, $2, $3, $4, 0, 0, 0, 5, 100, 10, 60
+ FROM generate_series(CAST($5 AS TIMESTAMP), CAST($6 AS TIMESTAMP), INTERVAL 1 MINUTE) AS s(t)";
+ foreach (var v in new object[] { _nextId, ServerId, ServerName, waitType, start, end })
+ {
+ cmd.Parameters.Add(new DuckDBParameter { Value = v });
+ }
+ await cmd.ExecuteNonQueryAsync();
+ _nextId -= (long)(end - start).TotalMinutes + 2;
+ }
+
+ private async Task BulkSeedPerfmonAsync(DateTime start, DateTime end, string counterName)
+ {
+ using var readLock = _duckDb.AcquireReadLock();
+ var conn = await SeedConnectionAsync();
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = @"INSERT INTO perfmon_stats
+ (collection_id, collection_time, server_id, server_name, object_name, counter_name, instance_name,
+ cntr_value, delta_cntr_value, sample_interval_seconds)
+ SELECT $1 - ROW_NUMBER() OVER (ORDER BY t), t, $2, $3, 'SQLServer:Test', $4, '', 1000, 60, 60
+ FROM generate_series(CAST($5 AS TIMESTAMP), CAST($6 AS TIMESTAMP), INTERVAL 1 MINUTE) AS s(t)";
+ foreach (var v in new object[] { _nextId, ServerId, ServerName, counterName, start, end })
+ {
+ cmd.Parameters.Add(new DuckDBParameter { Value = v });
+ }
+ await cmd.ExecuteNonQueryAsync();
+ _nextId -= (long)(end - start).TotalMinutes + 2;
+ }
+}
diff --git a/Lite.Tests/PerfmonIntervalAggregationTests.cs b/Lite.Tests/PerfmonIntervalAggregationTests.cs
index 5342567818..bd051ef6d7 100644
--- a/Lite.Tests/PerfmonIntervalAggregationTests.cs
+++ b/Lite.Tests/PerfmonIntervalAggregationTests.cs
@@ -45,27 +45,41 @@ public void BothPerfmonTrendReads_TakeTheIntervalAsMax()
Assert.Equal(2, max);
}
- /// 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.
+ /// 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.
+ /// #4234: the batched trend's bucketing legitimately adds a SECOND, different SUM over
+ /// sample_interval_seconds — the outer GROUP BY counter_name, bucket_start summing each
+ /// COLLECTION's own already-MAX'd interval across the collections a bucket holds (the ruling's
+ /// summed-intervals rate), never raw instance rows. That SUM always carries this exact
+ /// FILTER (WHERE sample_interval_seconds > 0) right after it, so stripping just that
+ /// substring isolates exactly the original bug shape: a bare, unfiltered
+ /// SUM(sample_interval_seconds) grouped over instance rows.
[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);
}
/// 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.
+ /// report one instance's value as the whole counter.
+ /// #4234: delta_cntr_value is 3, not 2 — the batched trend's bucketing adds one more SUM,
+ /// the outer GROUP BY counter_name, bucket_start summing each collection's already-summed delta
+ /// across the bucket's collections (the ruling's summed-deltas rate), same additive column, one layer
+ /// higher. cntr_value stays 2: that outer layer AVERAGES (the ruling's gauge rule), so it adds no
+ /// third SUM.
[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);
}
diff --git a/Lite/Controls/ServerTab.Refresh.cs b/Lite/Controls/ServerTab.Refresh.cs
index 0f726210d0..475bb28f8b 100644
--- a/Lite/Controls/ServerTab.Refresh.cs
+++ b/Lite/Controls/ServerTab.Refresh.cs
@@ -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();
@@ -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();
diff --git a/Lite/Services/LiteNameListCache.cs b/Lite/Services/LiteNameListCache.cs
new file mode 100644
index 0000000000..e2c56e6d3b
--- /dev/null
+++ b/Lite/Services/LiteNameListCache.cs
@@ -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;
+
+///
+/// #4234: TTL memoization for the Wait Stats and Perfmon picker's name-list reads
+/// (,
+/// ), each a full-window DISTINCT
+/// that used to run on every 1-minute auto-refresh. MCP shares the same underlying reads
+/// (, )
+/// but calls them directly, uncached, so an MCP answer is never stale by — 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 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 ViewerNameListCache (#4234, PR #4304); this port keeps its rules so the two SKUs
+/// cache the same way.
+///
+/// The key is (server, length) alone, but a hit ALSO needs the cached window's END to still be
+/// recent. 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 to sit within 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 (Duration()) since a custom range can move the end
+/// either way, not just forward like a sliding preset's auto-refresh does.
+///
+/// Meant to be reused beside any other Lite picker that reads a name list over a window the same way
+/// (one instance per , matching that service's own per-tab lifetime).
+///
+internal sealed class LiteNameListCache
+{
+ /// How long a fetched list is trusted before the next call re-runs the DISTINCT (the #4234 ruling's
+ /// number).
+ public static readonly TimeSpan Ttl = TimeSpan.FromMinutes(15);
+
+ private readonly record struct Key(int ServerId, int WindowLengthMinutes);
+ private readonly record struct Entry(List Names, DateTime WindowEndUtc, DateTime FetchedAtUtc);
+
+ private readonly ConcurrentDictionary _entries = new();
+
+ /// True (with populated) when a list for this server and window length was
+ /// fetched within of AND that fetch's window end sits within
+ /// of — 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.
+ public bool TryGet(int serverId, TimeSpan windowLength, DateTime endUtc, DateTime nowUtc, out List 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();
+ return false;
+ }
+
+ /// Records a freshly fetched list for the window ending , stamped
+ /// .
+ public void Set(int serverId, TimeSpan windowLength, DateTime endUtc, List names, DateTime nowUtc) =>
+ _entries[KeyFor(serverId, windowLength)] = new Entry(names, endUtc, nowUtc);
+
+ /// 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.
+ private static Key KeyFor(int serverId, TimeSpan windowLength) =>
+ new(serverId, (int)Math.Round(windowLength.TotalMinutes));
+}
diff --git a/Lite/Services/LocalDataService.Perfmon.cs b/Lite/Services/LocalDataService.Perfmon.cs
index c62e5b9b55..8cdf14ef51 100644
--- a/Lite/Services/LocalDataService.Perfmon.cs
+++ b/Lite/Services/LocalDataService.Perfmon.cs
@@ -10,11 +10,15 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using DuckDB.NET.Data;
+using PerformanceMonitor.Common;
namespace PerformanceMonitorLite.Services;
public partial class LocalDataService
{
+ /// #4234: TTL memoization for — see .
+ private readonly LiteNameListCache _distinctPerfmonCountersCache = new();
+
///
/// Gets the latest perfmon counters for a server.
///
@@ -58,6 +62,9 @@ FROM v_perfmon_stats
///
/// Gets the distinct perfmon counter names for a server.
+ /// Uncached: MCP's perfmon-counter tool calls this directly so an MCP answer is never stale by
+ /// . The picker's memoized entry point is
+ /// .
///
public async Task> GetDistinctPerfmonCountersAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null)
{
@@ -84,6 +91,35 @@ FROM v_perfmon_stats
{
items.Add(reader.GetString(0));
}
+
+ return items;
+ }
+
+ ///
+ /// Picker-only entry point for : checks
+ /// first, falls back to the shared uncached read, then
+ /// memoizes it.
+ /// #4234: keyed on (server, window length) for , so the
+ /// full-window DISTINCT behind this runs at most once per 15 minutes rather than on every 1-minute
+ /// auto-refresh. Only ServerTab's picker goes through this cache — MCP shares the same underlying
+ /// read but calls directly so it never sees a cached answer.
+ /// is the cache's clock seam — null uses the wall clock; a test passes an
+ /// explicit time to fast-forward past the TTL without sleeping.
+ ///
+ public async Task> GetDistinctPerfmonCountersForPickerAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null, DateTime? nowUtc = null)
+ {
+ var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc, SelectedServerTabUtcOffsetMinutes);
+
+ var effectiveNow = nowUtc ?? DateTime.UtcNow;
+ var windowLength = endTime - startTime;
+ if (_distinctPerfmonCountersCache.TryGet(serverId, windowLength, endTime, effectiveNow, out var cached))
+ {
+ return cached;
+ }
+
+ var items = await GetDistinctPerfmonCountersAsync(serverId, hoursBack, fromDate, toDate, asOfUtc);
+
+ _distinctPerfmonCountersCache.Set(serverId, windowLength, endTime, items, effectiveNow);
return items;
}
@@ -136,13 +172,65 @@ GROUP BY collection_time
return items;
}
+ ///
+ /// The bucketed batched-trend statement text (#4234), pulled out of
+ /// so its shape (the bucket width in its own trailing parameter, after the dynamic counter_name IN (...)
+ /// list so that list's numbering does not shift) is checkable without a live DuckDB. Darling's twin is
+ /// ViewerDataService.PerfmonTrendsSql.
+ ///
+ internal static string PerfmonTrendsSql(int counterCount)
+ {
+ var nameParams = string.Join(", ", Enumerable.Range(0, counterCount).Select(i => "$" + (i + 4)));
+ var widthParam = "$" + (counterCount + 4);
+ return $@"
+SELECT
+ counter_name,
+ GREATEST(time_bucket(to_minutes(CAST({widthParam} AS INTEGER)), collection_time, {TrendBuckets.OriginSql}), $2) AS bucket_start,
+ CAST(ROUND(AVG(cntr_value)) AS BIGINT) AS cntr_value,
+ SUM(delta_cntr_value) FILTER (WHERE sample_interval_seconds > 0) AS delta_cntr_value,
+ SUM(sample_interval_seconds) FILTER (WHERE sample_interval_seconds > 0) AS sample_interval_seconds,
+ CASE WHEN MIN(cntr_type) = MAX(cntr_type) THEN MAX(cntr_type) END AS cntr_type,
+ MIN(collection_time) AS first_collection_time,
+ COUNT(*) AS collection_count
+FROM (
+ SELECT
+ counter_name,
+ collection_time,
+ SUM(cntr_value) AS cntr_value,
+ SUM(delta_cntr_value) AS delta_cntr_value,
+ MAX(sample_interval_seconds) AS sample_interval_seconds,
+ CASE WHEN MIN(cntr_type) = MAX(cntr_type) THEN MAX(cntr_type) END AS cntr_type
+ FROM v_perfmon_stats
+ WHERE server_id = $1
+ AND collection_time >= $2
+ AND collection_time <= $3
+ AND counter_name IN ({nameParams})
+ GROUP BY counter_name, collection_time
+) AS collections
+GROUP BY counter_name, 2
+ORDER BY counter_name, 2";
+ }
+
///
/// Batched sibling of : fetches the trend for ALL selected
/// counters in ONE query (replacing an N+1 query-per-counter loop), grouped by counter name.
+ /// #4234: wraps the unchanged per-collection SUM as a subquery and re-aggregates into buckets sized
+ /// to 's point budget PER SERIES (seriesCount always 1 into
+ /// , the ruling's own wording). 's
+ /// fields keep their per-collection MEANING: Value is the bucket's rounded GAUGE average (the
+ /// ruling's rule for a gauge); DeltaValue/SampleIntervalSeconds are summed only over rated
+ /// collections (a stored interval > 0), so DeltaValue / SampleIntervalSeconds downstream is
+ /// still the ruling's rate (summed deltas over summed intervals), and both are NULL together for a bucket
+ /// with no rated collection — the same "no delta" state an unrated per-collection row always carried;
+ /// CntrType keeps the per-collection MIN=MAX-agreement rule, now double-aggregated. When EVERY
+ /// bucket the whole call returned holds exactly one physical collection, each point is stamped at its
+ /// bucket's raw first_collection_time instead of the time_bucket grid line; a single merged
+ /// bucket anywhere (any counter) keeps bucket_start throughout. Darling's twin is
+ /// ViewerDataService.PerfmonTrendsSql / GetPerfmonTrendsByCountersAsync (#4234, PR #4304).
///
public async Task>> GetPerfmonTrendsByCountersAsync(int serverId, List counterNames, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null)
{
- using var _q = TimeQuery("GetPerfmonTrendsByCountersAsync", "v_perfmon_stats trends batched by counter");
+ using var _q = TimeQuery("GetPerfmonTrendsByCountersAsync", "v_perfmon_stats trends batched by counter, bucketed");
var result = new Dictionary>();
if (counterNames.Count == 0) return result;
@@ -150,46 +238,59 @@ public async Task>> GetPerfmonTrendsB
using var command = connection.CreateCommand();
var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc: null, SelectedServerTabUtcOffsetMinutes);
- var nameParams = string.Join(", ", counterNames.Select((_, i) => "$" + (i + 4)));
- command.CommandText = $@"
-SELECT
- counter_name,
- collection_time,
- SUM(cntr_value) AS cntr_value,
- SUM(delta_cntr_value) AS delta_cntr_value,
- MAX(sample_interval_seconds) AS sample_interval_seconds,
- CASE WHEN MIN(cntr_type) = MAX(cntr_type) THEN MAX(cntr_type) END AS cntr_type
-FROM v_perfmon_stats
-WHERE server_id = $1
-AND collection_time >= $2
-AND collection_time <= $3
-AND counter_name IN ({nameParams})
-GROUP BY counter_name, collection_time
-ORDER BY counter_name, collection_time";
+ var windowMinutes = Math.Max(1, (int)Math.Ceiling((endTime - startTime).TotalMinutes));
+ var bucketMinutes = TrendBuckets.AutoMinutes(windowMinutes, 1, TrendBudget.Chart.AutoPoints);
+
+ command.CommandText = PerfmonTrendsSql(counterNames.Count);
command.Parameters.Add(new DuckDBParameter { Value = serverId });
command.Parameters.Add(new DuckDBParameter { Value = startTime });
command.Parameters.Add(new DuckDBParameter { Value = endTime });
foreach (var cn in counterNames)
command.Parameters.Add(new DuckDBParameter { Value = cn });
+ command.Parameters.Add(new DuckDBParameter { Value = bucketMinutes });
+
+ var rows = new List<(string CounterName, DateTime BucketStart, DateTime FirstCollectionTime, long Value, long? DeltaValue, long? SampleIntervalSeconds, int? CntrType)>();
+ var everyBucketSingleton = true;
using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
- var cn = reader.GetString(0);
- if (!result.TryGetValue(cn, out var list))
+ if (reader.GetInt64(7) != 1)
+ {
+ everyBucketSingleton = false;
+ }
+
+ rows.Add((
+ reader.GetString(0),
+ reader.GetDateTime(1),
+ reader.GetDateTime(6),
+ /* ToInt64, not GetInt64/Convert.ToInt64: DuckDB's SUM over an INTEGER column (sample_interval_seconds)
+ promotes to HUGEINT, which the driver hands back as a boxed BigInteger that Convert.ToInt64 cannot
+ cast — the same reason GetPerfmonBucketsAsync's own FILTER-summed columns route through this helper. */
+ reader.IsDBNull(2) ? 0 : ToInt64(reader.GetValue(2)),
+ /* NULL stays NULL: a gauge's instance rows store no delta (v62), so the SUM is NULL, not 0. */
+ reader.IsDBNull(3) ? null : ToInt64(reader.GetValue(3)),
+ reader.IsDBNull(4) ? null : ToInt64(reader.GetValue(4)),
+ reader.IsDBNull(5) ? null : (int)ToInt64(reader.GetValue(5))));
+ }
+
+ foreach (var row in rows)
+ {
+ if (!result.TryGetValue(row.CounterName, out var list))
{
list = new List();
- result[cn] = list;
+ result[row.CounterName] = list;
}
+
list.Add(new PerfmonTrendPoint
{
- CollectionTime = reader.GetDateTime(1),
- Value = reader.IsDBNull(2) ? 0 : reader.GetInt64(2),
- DeltaValue = reader.IsDBNull(3) ? null : reader.GetInt64(3),
- SampleIntervalSeconds = reader.IsDBNull(4) ? null : Convert.ToInt64(reader.GetValue(4)),
- CntrType = reader.IsDBNull(5) ? null : Convert.ToInt32(reader.GetValue(5))
+ CollectionTime = everyBucketSingleton ? row.FirstCollectionTime : row.BucketStart,
+ Value = row.Value,
+ DeltaValue = row.DeltaValue,
+ SampleIntervalSeconds = row.SampleIntervalSeconds,
+ CntrType = row.CntrType
});
}
diff --git a/Lite/Services/LocalDataService.WaitStats.cs b/Lite/Services/LocalDataService.WaitStats.cs
index 43303f945c..b0e91d1443 100644
--- a/Lite/Services/LocalDataService.WaitStats.cs
+++ b/Lite/Services/LocalDataService.WaitStats.cs
@@ -18,6 +18,9 @@ namespace PerformanceMonitorLite.Services;
public partial class LocalDataService
{
+ /// #4234: TTL memoization for — see .
+ private readonly LiteNameListCache _distinctWaitTypesCache = new();
+
/* #1240: the wait-stats tab shares the collector's ignored-wait list and excludes those types at
DISPLAY time, so benign waits already in the DuckDB (collected before the filter was active) don't
surface in the tab/picker — copying the JSON only stops new collection, not existing rows. */
@@ -111,6 +114,9 @@ FROM v_wait_stats
///
/// Gets the distinct wait types that have been collected for a server.
+ /// Uncached: MCP's wait-type tools call this directly so an MCP answer is never stale by
+ /// . The picker's memoized entry point is
+ /// .
///
public async Task> GetDistinctWaitTypesAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null)
{
@@ -142,6 +148,34 @@ GROUP BY wait_type
{
items.Add(reader.GetString(0));
}
+
+ return items;
+ }
+
+ ///
+ /// Picker-only entry point for : checks
+ /// first, falls back to the shared uncached read, then memoizes it.
+ /// #4234: keyed on (server, window length) for , so the
+ /// full-window read behind this runs at most once per 15 minutes rather than on every 1-minute
+ /// auto-refresh. Only ServerTab's picker goes through this cache — MCP shares the same underlying
+ /// read but calls directly so it never sees a cached answer.
+ /// is the cache's clock seam — null uses the wall clock; a test passes an
+ /// explicit time to fast-forward past the TTL without sleeping.
+ ///
+ public async Task> GetDistinctWaitTypesForPickerAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null, DateTime? nowUtc = null)
+ {
+ var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc, SelectedServerTabUtcOffsetMinutes);
+
+ var effectiveNow = nowUtc ?? DateTime.UtcNow;
+ var windowLength = endTime - startTime;
+ if (_distinctWaitTypesCache.TryGet(serverId, windowLength, endTime, effectiveNow, out var cached))
+ {
+ return cached;
+ }
+
+ var items = await GetDistinctWaitTypesAsync(serverId, hoursBack, fromDate, toDate, asOfUtc);
+
+ _distinctWaitTypesCache.Set(serverId, windowLength, endTime, items, effectiveNow);
return items;
}
@@ -214,23 +248,16 @@ FROM raw
}
///
- /// Batched sibling of : fetches the per-second trend for
- /// ALL selected wait types in ONE query (replacing an N+1 query-per-type loop), grouped by type.
- /// The LAG window is partitioned by wait_type so each type's per-second rate is computed independently.
+ /// The bucketed batched-trend statement text (#4234), pulled out of
+ /// so its shape (the bucket width in its own trailing parameter, after the dynamic wait_type IN (...)
+ /// list so that list's $4.. numbering does not shift) is checkable without a live DuckDB. Darling's
+ /// twin is ViewerDataService.WaitTrendsSql.
///
- public async Task>> GetWaitStatsTrendsByTypesAsync(int serverId, List waitTypes, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null)
+ internal static string WaitTrendsSql(int waitTypeCount)
{
- using var _q = TimeQuery("GetWaitStatsTrendsByTypesAsync", "v_wait_stats trends batched by type");
- var result = new Dictionary>();
- if (waitTypes.Count == 0) return result;
-
- using var connection = await OpenConnectionAsync();
- using var command = connection.CreateCommand();
-
- var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc: null, SelectedServerTabUtcOffsetMinutes);
- var typeParams = string.Join(", ", waitTypes.Select((_, i) => "$" + (i + 4)));
-
- command.CommandText = $@"
+ var typeParams = string.Join(", ", Enumerable.Range(0, waitTypeCount).Select(i => "$" + (i + 4)));
+ var widthParam = "$" + (waitTypeCount + 4);
+ return $@"
WITH raw AS
(
SELECT
@@ -249,43 +276,114 @@ FROM v_wait_stats
AND collection_time >= $2
AND collection_time <= $3
AND wait_type IN ({typeParams})
+),
+rated AS
+(
+ SELECT
+ wait_type,
+ collection_time,
+ CASE WHEN interval_seconds > 0 THEN CAST(delta_wait_time_ms AS DOUBLE PRECISION) END AS rated_wait_ms,
+ CASE WHEN interval_seconds > 0 THEN CAST(delta_signal_wait_time_ms AS DOUBLE PRECISION) END AS rated_signal_ms,
+ CASE WHEN interval_seconds > 0 THEN interval_seconds END AS rated_seconds,
+ CASE WHEN interval_seconds > 0 THEN delta_waiting_tasks END AS rated_tasks
+ FROM raw
)
SELECT
wait_type,
- collection_time,
- CASE WHEN interval_seconds > 0 THEN CAST(delta_wait_time_ms AS DOUBLE PRECISION) / interval_seconds END AS wait_time_ms_per_second,
- CASE WHEN interval_seconds > 0 THEN CAST(delta_signal_wait_time_ms AS DOUBLE PRECISION) / interval_seconds END AS signal_wait_time_ms_per_second,
- CASE WHEN interval_seconds > 0 AND delta_waiting_tasks > 0 THEN CAST(delta_wait_time_ms AS DOUBLE PRECISION) / delta_waiting_tasks WHEN interval_seconds > 0 THEN 0 END AS avg_ms_per_wait
-FROM raw
-ORDER BY wait_type, collection_time";
+ GREATEST(time_bucket(to_minutes(CAST({widthParam} AS INTEGER)), collection_time, {TrendBuckets.OriginSql}), $2) AS bucket_start,
+ SUM(rated_wait_ms) / SUM(rated_seconds) AS wait_time_ms_per_second,
+ SUM(rated_signal_ms) / SUM(rated_seconds) AS signal_wait_time_ms_per_second,
+ CASE WHEN SUM(rated_tasks) > 0 THEN SUM(rated_wait_ms) / SUM(rated_tasks) ELSE 0 END AS avg_ms_per_wait,
+ MIN(collection_time) AS first_collection_time,
+ COUNT(*) AS collection_count
+FROM rated
+GROUP BY wait_type, 2
+HAVING COUNT(rated_seconds) > 0
+ORDER BY wait_type, 2";
+ }
+
+ ///
+ /// Batched sibling of : fetches the per-second trend for
+ /// ALL selected wait types in ONE query (replacing an N+1 query-per-type loop), grouped by type.
+ /// The LAG window is partitioned by wait_type so each type's per-second rate is computed independently.
+ /// #4234: buckets to 's point budget PER SERIES (the ruling's own
+ /// wording, not the MCP convention of dividing one shared budget across every line a call draws), so the
+ /// pin is rows <= budget * series count; seriesCount is therefore always 1 into
+ /// . A bucket's rate is its summed wait over its summed rated
+ /// seconds (time-weighted, never an average of per-collection rates), and a bucket with no rated
+ /// collection is dropped (HAVING), same as the per-collection read always dropped that collection.
+ /// When EVERY bucket the whole call returned holds exactly one physical collection, each point is stamped
+ /// at its bucket's first_collection_time (that one collection's own raw time) instead of
+ /// bucket_start, a time_bucket grid line; a single merged bucket anywhere (any wait type)
+ /// keeps bucket_start throughout. Darling's twin is ViewerDataService.WaitTrendsSql /
+ /// GetWaitStatsTrendsByTypesAsync (#4234, PR #4304).
+ ///
+ public async Task>> GetWaitStatsTrendsByTypesAsync(int serverId, List waitTypes, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null)
+ {
+ using var _q = TimeQuery("GetWaitStatsTrendsByTypesAsync", "v_wait_stats trends batched by type, bucketed");
+ var result = new Dictionary>();
+ if (waitTypes.Count == 0) return result;
+
+ using var connection = await OpenConnectionAsync();
+ using var command = connection.CreateCommand();
+
+ var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc: null, SelectedServerTabUtcOffsetMinutes);
+
+ var windowMinutes = Math.Max(1, (int)Math.Ceiling((endTime - startTime).TotalMinutes));
+ var bucketMinutes = TrendBuckets.AutoMinutes(windowMinutes, 1, TrendBudget.Chart.AutoPoints);
+
+ command.CommandText = WaitTrendsSql(waitTypes.Count);
command.Parameters.Add(new DuckDBParameter { Value = serverId });
command.Parameters.Add(new DuckDBParameter { Value = startTime });
command.Parameters.Add(new DuckDBParameter { Value = endTime });
foreach (var wt in waitTypes)
command.Parameters.Add(new DuckDBParameter { Value = wt });
+ command.Parameters.Add(new DuckDBParameter { Value = bucketMinutes });
+
+ var rows = new List<(string WaitType, DateTime BucketStart, DateTime FirstCollectionTime, double Rate, double Signal, double Avg)>();
+ var everyBucketSingleton = true;
using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
- /* A NULL rate is an unknowable interval (#3540): the row is dropped, not read as 0. */
+ /* A NULL rate is a bucket with no rated collection; HAVING already excludes it. */
if (reader.IsDBNull(2))
{
continue;
}
- var wt = reader.GetString(0);
- if (!result.TryGetValue(wt, out var list))
+ if (reader.GetInt64(6) != 1)
+ {
+ everyBucketSingleton = false;
+ }
+
+ rows.Add((
+ reader.GetString(0),
+ reader.GetDateTime(1),
+ reader.GetDateTime(5),
+ /* ToDouble, not GetDouble: a SUM over an INTEGER-typed column (e.g. rated_tasks, inside the
+ avg_ms_per_wait division) can come back as a boxed BigInteger DuckDB HUGEINT, which GetDouble
+ does not accept — see GetWaitBucketsAsync's own FILTER-summed columns for the same guard. */
+ ToDouble(reader.GetValue(2)),
+ reader.IsDBNull(3) ? 0 : ToDouble(reader.GetValue(3)),
+ reader.IsDBNull(4) ? 0 : ToDouble(reader.GetValue(4))));
+ }
+
+ foreach (var row in rows)
+ {
+ if (!result.TryGetValue(row.WaitType, out var list))
{
list = new List();
- result[wt] = list;
+ result[row.WaitType] = list;
}
+
list.Add(new WaitStatsTrendPoint
{
- CollectionTime = reader.GetDateTime(1),
- WaitTimeMsPerSecond = reader.GetDouble(2),
- SignalWaitTimeMsPerSecond = reader.IsDBNull(3) ? 0 : reader.GetDouble(3),
- AvgMsPerWait = reader.IsDBNull(4) ? 0 : reader.GetDouble(4)
+ CollectionTime = everyBucketSingleton ? row.FirstCollectionTime : row.BucketStart,
+ WaitTimeMsPerSecond = row.Rate,
+ SignalWaitTimeMsPerSecond = row.Signal,
+ AvgMsPerWait = row.Avg
});
}