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 }); }