From 40e901543d57ab84d4037e93e8b2bde8658da8f4 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:50:00 -0400 Subject: [PATCH 1/2] Bucket Lite's Overview CPU, wait and memory lane reads (#4234) GetCpuUtilizationAsync, GetTotalWaitTrendAsync and GetMemoryTrendAsync fed the Overview correlated lanes (and, for CPU/memory, their own dedicated tab charts) one row per collection, so a multi-day window shipped thousands of points to a WPF chart. Each now buckets in DuckDB to TrendBuckets.AutoMinutes against TrendBudget.Chart, the same ladder and origin the MCP trend family already uses, with the SQL text pulled into an internal static member so a source test can check it without a live DuckDB. A bucket whose collections are all a single physical row stamps at that row's own clock instead of the bucket grid line, so a window narrow enough to never merge two collections renders exactly as the old per-collection read did. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- Lite/Services/LocalDataService.Cpu.cs | 84 ++++++++++--- Lite/Services/LocalDataService.Memory.cs | 88 +++++++++++--- .../Services/LocalDataService.TrendBuckets.cs | 7 ++ Lite/Services/LocalDataService.WaitStats.cs | 115 +++++++++++++----- 4 files changed, 229 insertions(+), 65 deletions(-) diff --git a/Lite/Services/LocalDataService.Cpu.cs b/Lite/Services/LocalDataService.Cpu.cs index 6f49cd412e..a699e46733 100644 --- a/Lite/Services/LocalDataService.Cpu.cs +++ b/Lite/Services/LocalDataService.Cpu.cs @@ -10,6 +10,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using DuckDB.NET.Data; +using PerformanceMonitor.Common; namespace PerformanceMonitorLite.Services; @@ -59,40 +60,89 @@ the pre-rung fallback arm can re-derive the local comparison inside the predicat var startUtc = startTime.AddMinutes(-offset); var endUtc = endTime.AddMinutes(-offset); - /* v63 (#3653 item 13): prefer the stored UTC instant for the window; a pre-rung row (NULL twin) falls - back to sample_time - offset, which against UTC bounds is exactly the old sample_time-against-local - bounds. The PROJECTION stays sample_time: the chart plots the server's own frame. */ - command.CommandText = @" -SELECT - sample_time, - sqlserver_cpu_utilization, - other_process_cpu_utilization -FROM v_cpu_utilization_stats -WHERE server_id = $1 -AND COALESCE(sample_time_utc, sample_time - to_minutes($4)) >= $2 -AND COALESCE(sample_time_utc, sample_time - to_minutes($4)) <= $3 -ORDER BY sample_time"; + /* #4234: bucketed to TrendBudget.Chart's point budget so the Overview lane and this same read's CPU + tab chart stop shipping one point per collection over a multi-day window. seriesCount is always 1 — + SqlServerCpu/OtherProcessCpu ride the SAME row/bucket, not separate series. */ + var windowMinutes = Math.Max(1, (int)Math.Ceiling((endTime - startTime).TotalMinutes)); + var bucketMinutes = TrendBuckets.AutoMinutes(windowMinutes, 1, TrendBudget.Chart.AutoPoints); + + command.CommandText = CpuUtilizationTrendSql; command.Parameters.Add(new DuckDBParameter { Value = serverId }); command.Parameters.Add(new DuckDBParameter { Value = startUtc }); command.Parameters.Add(new DuckDBParameter { Value = endUtc }); command.Parameters.Add(new DuckDBParameter { Value = (long)offset }); + command.Parameters.Add(new DuckDBParameter { Value = bucketMinutes }); + command.Parameters.Add(new DuckDBParameter { Value = startTime }); + + var rows = new List<(DateTime BucketStart, int SqlCpu, int OtherCpu, DateTime FirstSampleTime, long SampleCount)>(); + var everyBucketSingleton = true; - var items = new List(); using var reader = await command.ExecuteReaderAsync(); while (await reader.ReadAsync()) + { + var sampleCount = reader.GetInt64(4); + if (sampleCount != 1) + { + everyBucketSingleton = false; + } + + rows.Add(( + reader.GetDateTime(0), + reader.GetInt32(1), + reader.GetInt32(2), + reader.GetDateTime(3), + sampleCount)); + } + + /* Every bucket in this call held exactly one physical sample: stamp each point at that sample's OWN + clock instead of the bucket grid line, so a window narrow enough to never merge two collections + renders byte-identical to the pre-#4234 per-collection read. */ + var items = new List(rows.Count); + foreach (var row in rows) { items.Add(new CpuUtilizationRow { - SampleTime = reader.GetDateTime(0), - SqlServerCpu = reader.IsDBNull(1) ? 0 : reader.GetInt32(1), - OtherProcessCpu = reader.IsDBNull(2) ? 0 : reader.GetInt32(2) + SampleTime = everyBucketSingleton ? row.FirstSampleTime : row.BucketStart, + SqlServerCpu = row.SqlCpu, + OtherProcessCpu = row.OtherCpu }); } return items; } + /// + /// The bucketed CPU trend statement text (#4234), pulled out of so its + /// shape is checkable without a live DuckDB. $1 server_id, $2/$3 the UTC window, $4 the offset minutes (the + /// pre-v63 sample_time_utc fallback), $5 the bucket width in minutes, $6 the server-local window + /// start, which clamps time_bucket's grid line so the first bucket never renders earlier than the + /// window the caller asked for. A NULL reading counts as 0 in the average, exactly as the per-collection + /// read always counted it in C#; is the same origin every bucketed + /// trend in this app aligns to, so a width that does not divide a day still bins consistently. + /// + internal static string CpuUtilizationTrendSql => $@" +WITH raw AS +( + SELECT + sample_time, + sqlserver_cpu_utilization, + other_process_cpu_utilization + FROM v_cpu_utilization_stats + WHERE server_id = $1 + AND COALESCE(sample_time_utc, sample_time - to_minutes($4)) >= $2 + AND COALESCE(sample_time_utc, sample_time - to_minutes($4)) <= $3 +) +SELECT + GREATEST(time_bucket(to_minutes(CAST($5 AS INTEGER)), sample_time, {TrendBuckets.OriginSql}), $6) AS bucket_start, + CAST(ROUND(AVG(COALESCE(sqlserver_cpu_utilization, 0))) AS INTEGER) AS sql_server_cpu, + CAST(ROUND(AVG(COALESCE(other_process_cpu_utilization, 0))) AS INTEGER) AS other_process_cpu, + MIN(sample_time) AS first_sample_time, + COUNT(*) AS sample_count +FROM raw +GROUP BY 1 +ORDER BY 1"; + /// /// The attributed-CPU denominator's pieces (#2320): sample count, coverage bounds, and average SQL /// CPU% over the window. Windowed on collection_time (UTC) — the SAME bounds the top-queries and diff --git a/Lite/Services/LocalDataService.Memory.cs b/Lite/Services/LocalDataService.Memory.cs index a0741c4eac..8de7de249f 100644 --- a/Lite/Services/LocalDataService.Memory.cs +++ b/Lite/Services/LocalDataService.Memory.cs @@ -10,6 +10,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using DuckDB.NET.Data; +using PerformanceMonitor.Common; namespace PerformanceMonitorLite.Services; @@ -74,40 +75,91 @@ public async Task> GetMemoryTrendAsync(int serverId, int var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc, SelectedServerTabUtcOffsetMinutes); - command.CommandText = @" -SELECT - collection_time, - total_server_memory_mb, - target_server_memory_mb, - buffer_pool_mb, - plan_cache_mb -FROM v_memory_stats -WHERE server_id = $1 -AND collection_time >= $2 -AND collection_time <= $3 -ORDER BY collection_time"; + /* #4234: bucketed to TrendBudget.Chart's point budget — this read feeds the Overview lane (buffer pool + only) and the Memory tab's own chart (all four gauges), so every caller wants the same bucketing. + seriesCount is always 1: the four gauges ride the same row/bucket, not separate series. */ + var windowMinutes = Math.Max(1, (int)Math.Ceiling((endTime - startTime).TotalMinutes)); + var bucketMinutes = TrendBuckets.AutoMinutes(windowMinutes, 1, TrendBudget.Chart.AutoPoints); + + command.CommandText = MemoryTrendSql; command.Parameters.Add(new DuckDBParameter { Value = serverId }); command.Parameters.Add(new DuckDBParameter { Value = startTime }); command.Parameters.Add(new DuckDBParameter { Value = endTime }); + command.Parameters.Add(new DuckDBParameter { Value = bucketMinutes }); + + var rows = new List<(DateTime BucketStart, double Total, double Target, double Buffer, double Plan, DateTime FirstCollectionTime, long CollectionCount)>(); + var everyBucketSingleton = true; - var items = new List(); using var reader = await command.ExecuteReaderAsync(); while (await reader.ReadAsync()) + { + var collectionCount = reader.GetInt64(6); + if (collectionCount != 1) + { + everyBucketSingleton = false; + } + + rows.Add(( + reader.GetDateTime(0), + ToDouble(reader.GetValue(1)), + ToDouble(reader.GetValue(2)), + ToDouble(reader.GetValue(3)), + ToDouble(reader.GetValue(4)), + reader.GetDateTime(5), + collectionCount)); + } + + /* Every bucket held exactly one collection: stamp at that collection's own clock, byte-identical to + the pre-#4234 per-collection read (see GetCpuUtilizationAsync for the same rule). */ + var items = new List(rows.Count); + foreach (var row in rows) { items.Add(new MemoryTrendPoint { - CollectionTime = reader.GetDateTime(0), - TotalServerMemoryMb = reader.IsDBNull(1) ? 0 : ToDouble(reader.GetValue(1)), - TargetServerMemoryMb = reader.IsDBNull(2) ? 0 : ToDouble(reader.GetValue(2)), - BufferPoolMb = reader.IsDBNull(3) ? 0 : ToDouble(reader.GetValue(3)), - PlanCacheMb = reader.IsDBNull(4) ? 0 : ToDouble(reader.GetValue(4)) + CollectionTime = everyBucketSingleton ? row.FirstCollectionTime : row.BucketStart, + TotalServerMemoryMb = row.Total, + TargetServerMemoryMb = row.Target, + BufferPoolMb = row.Buffer, + PlanCacheMb = row.Plan }); } return items; } + /// + /// The bucketed memory trend statement text (#4234), pulled out of so its + /// shape is checkable without a live DuckDB. $1 server_id, $2/$3 the UTC window (also the GREATEST clamp so + /// the first bucket never renders earlier than the window), $4 the bucket width in minutes. A NULL gauge + /// counts as 0 in the average, exactly as the per-collection read always counted it in C#. + /// + internal static string MemoryTrendSql => $@" +WITH raw AS +( + SELECT + collection_time, + total_server_memory_mb, + target_server_memory_mb, + buffer_pool_mb, + plan_cache_mb + FROM v_memory_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 +) +SELECT + GREATEST(time_bucket(to_minutes(CAST($4 AS INTEGER)), collection_time, {TrendBuckets.OriginSql}), $2) AS bucket_start, + AVG(COALESCE(total_server_memory_mb, 0)) AS total_server_memory_mb, + AVG(COALESCE(target_server_memory_mb, 0)) AS target_server_memory_mb, + AVG(COALESCE(buffer_pool_mb, 0)) AS buffer_pool_mb, + AVG(COALESCE(plan_cache_mb, 0)) AS plan_cache_mb, + MIN(collection_time) AS first_collection_time, + COUNT(*) AS collection_count +FROM raw +GROUP BY 1 +ORDER BY 1"; + /// /// Whether this server has EVER recorded a memory sample, ignoring any window. /// Lets an empty memory trend say WHICH kind of nothing it found. "No memory trend data" is true diff --git a/Lite/Services/LocalDataService.TrendBuckets.cs b/Lite/Services/LocalDataService.TrendBuckets.cs index b488da3cbe..de2fafda15 100644 --- a/Lite/Services/LocalDataService.TrendBuckets.cs +++ b/Lite/Services/LocalDataService.TrendBuckets.cs @@ -24,6 +24,13 @@ namespace PerformanceMonitorLite.Services; * buckets of $N minutes, so an MCP answer stays near TrendBuckets.McpPointBudget points instead of one point per * collection per series: a day of file I/O was 12,500 rows and 1.4 MB. * + * #4234 later bucketed three of those SAME per-collection reads in place for their own chart callers + * (GetCpuUtilizationAsync, GetTotalWaitTrendAsync — the all-types aggregate, not GetWaitStatsTrendAsync above — + * and GetMemoryTrendAsync, all three called ONLY by Overview's correlated lanes and their dedicated tab chart, + * never by an MCP tool), at TrendBudget.Chart's wider point budget rather than McpPointBudget. The MCP-bucketed + * reads below stay separate functions with their own SQL, because get_cpu_utilization and friends bucket to a + * narrower budget than a chart would want. + * * Darling's twins are DarlingTrendReader (file I/O, the duration pair via DurationTrendRouting's bucketed * builders) and DarlingBlockingTrendReader (lock waits); the SQL differs only in dialect — DuckDB's time_bucket * where PostgreSQL has date_bin, both on TrendBuckets.OriginSql so a width that does not divide a day still diff --git a/Lite/Services/LocalDataService.WaitStats.cs b/Lite/Services/LocalDataService.WaitStats.cs index 631954471d..43303f945c 100644 --- a/Lite/Services/LocalDataService.WaitStats.cs +++ b/Lite/Services/LocalDataService.WaitStats.cs @@ -11,6 +11,7 @@ using System.Threading.Tasks; using DuckDB.NET.Data; using PerformanceMonitor.Alerting; +using PerformanceMonitor.Common; using PerformanceMonitor.Ui; namespace PerformanceMonitorLite.Services; @@ -297,14 +298,80 @@ FROM raw /// public async Task> GetTotalWaitTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) { - using var _q = TimeQuery("GetTotalWaitTrendAsync", "wait_stats total trend"); + using var _q = TimeQuery("GetTotalWaitTrendAsync", "wait_stats total trend, bucketed"); using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc: null, SelectedServerTabUtcOffsetMinutes); + /* #4234: bucketed to TrendBudget.Chart's point budget — this is the Overview lane's single aggregated + wait line. seriesCount is always 1. */ + var windowMinutes = Math.Max(1, (int)Math.Ceiling((endTime - startTime).TotalMinutes)); + var bucketMinutes = TrendBuckets.AutoMinutes(windowMinutes, 1, TrendBudget.Chart.AutoPoints); + var exclude = IgnoredWaitTypes.BuildExclusionClause(_ignoredWaitTypes.Value); - command.CommandText = $@" + command.CommandText = TotalWaitTrendSql(exclude); + + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + command.Parameters.Add(new DuckDBParameter { Value = startTime }); + command.Parameters.Add(new DuckDBParameter { Value = endTime }); + command.Parameters.Add(new DuckDBParameter { Value = bucketMinutes }); + + var rows = new List<(DateTime BucketStart, double Rate, DateTime FirstCollectionTime, long CollectionCount)>(); + var everyBucketSingleton = true; + + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + /* A NULL rate is a bucket with no rated collection; HAVING already excludes it, so this can only + fire if that guard is ever loosened — kept as the same defensive drop the per-collection read + always applied (#3540). */ + if (reader.IsDBNull(1)) + { + continue; + } + + var collectionCount = reader.GetInt64(3); + if (collectionCount != 1) + { + everyBucketSingleton = false; + } + + rows.Add(( + reader.GetDateTime(0), + ToDouble(reader.GetValue(1)), + reader.GetDateTime(2), + collectionCount)); + } + + /* Every bucket held exactly one rated collection: stamp at that collection's own clock, byte-identical + to the pre-#4234 per-collection read (see GetCpuUtilizationAsync for the same rule). */ + var items = new List(rows.Count); + foreach (var row in rows) + { + items.Add(new WaitStatsTrendPoint + { + CollectionTime = everyBucketSingleton ? row.FirstCollectionTime : row.BucketStart, + WaitTimeMsPerSecond = row.Rate + }); + } + + return items; + } + + /// + /// The bucketed total-wait trend statement text (#4234), pulled out of + /// so its shape is checkable without a live DuckDB. $1 server_id, $2/$3 the UTC window (also the GREATEST + /// clamp), $4 the bucket width in minutes. is the caller's own + /// ignored_wait_types.json exclusion clause (), + /// safe-identifier literals rather than a parameter, exactly as the per-collection read always inlined it. + /// A collection's rate is undefined (#3540) when EVERY wait type it reported that pass stored a 0 + /// interval (a restart) — rated nulls that collection's contribution rather than reading it as 0 — + /// and a bucket's rate is its SUMMED rated wait over its SUMMED rated seconds (time-weighted, never an + /// average of per-collection rates); a bucket with no rated collection is dropped by HAVING, the + /// same as the per-collection read always dropped that one collection. + /// + internal static string TotalWaitTrendSql(string exclude) => $@" WITH per_collection AS ( SELECT @@ -325,36 +392,24 @@ FROM v_wait_stats AND collection_time <= $3 {exclude} GROUP BY collection_time +), +rated AS +( + SELECT + collection_time, + CASE WHEN interval_seconds > 0 THEN CAST(total_delta_ms AS DOUBLE PRECISION) END AS rated_ms, + CASE WHEN interval_seconds > 0 THEN interval_seconds END AS rated_seconds + FROM per_collection ) SELECT - collection_time, - CASE WHEN interval_seconds > 0 THEN CAST(total_delta_ms AS DOUBLE PRECISION) / interval_seconds END AS wait_time_ms_per_second -FROM per_collection -ORDER BY collection_time"; - - command.Parameters.Add(new DuckDBParameter { Value = serverId }); - command.Parameters.Add(new DuckDBParameter { Value = startTime }); - command.Parameters.Add(new DuckDBParameter { Value = endTime }); - - var items = new List(); - 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. */ - if (reader.IsDBNull(1)) - { - continue; - } - - items.Add(new WaitStatsTrendPoint - { - CollectionTime = reader.GetDateTime(0), - WaitTimeMsPerSecond = reader.GetDouble(1) - }); - } - - return items; - } + GREATEST(time_bucket(to_minutes(CAST($4 AS INTEGER)), collection_time, {TrendBuckets.OriginSql}), $2) AS bucket_start, + SUM(rated_ms) / SUM(rated_seconds) AS wait_time_ms_per_second, + MIN(collection_time) AS first_collection_time, + COUNT(*) AS collection_count +FROM rated +GROUP BY 1 +HAVING COUNT(rated_seconds) > 0 +ORDER BY 1"; /// /// Accumulated poison wait per wait type over the alert's window (#3539 A4) — the DuckDB twin of From 0de3beb712a92d3c945810d5192a573002407266 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:56:51 -0400 Subject: [PATCH 2/2] Pin the Overview lane bucketing behind a live DuckDB (#4234) Four claims against the three reads bucketed in the prior commit: each statement's text carries a bucket width; a 7-day window (2,016 raw collections per lane) stays within TrendBudget.Chart's 1,500-point cap, which the raw row count alone would have missed; a window narrow enough that every bucket holds exactly one physical collection renders byte-identical to the pre-#4234 per-collection read; and the wait lane still drops an isolated unrated collection (#3540) rather than rendering it as 0. Proved the budget claim fails pre-bucketing (2,005 points, over the 1,500 cap) and the source-check claim fails to compile pre-bucketing, by building against the parent commit. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- Lite.Tests/OverviewLaneBucketingTests.cs | 280 +++++++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 Lite.Tests/OverviewLaneBucketingTests.cs diff --git a/Lite.Tests/OverviewLaneBucketingTests.cs b/Lite.Tests/OverviewLaneBucketingTests.cs new file mode 100644 index 0000000000..944c4b15ea --- /dev/null +++ b/Lite.Tests/OverviewLaneBucketingTests.cs @@ -0,0 +1,280 @@ +/* + * 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.Linq; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using PerformanceMonitor.Common; +using PerformanceMonitorLite.Database; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// +/// #4234: the three Overview correlated-lane reads — , +/// , — +/// now bucket in DuckDB to 's point budget instead of shipping one row per +/// collection. Four claims, proved end to end against a real DuckDB through : +/// each statement carries a bucket width; a 7-day window stays within budget where the raw row count alone +/// would not; a window narrow enough that every bucket holds exactly one physical collection renders +/// byte-identical to the pre-#4234 per-collection read; and the wait lane still drops an isolated unrated +/// collection rather than rendering it as a 0. +/// +public sealed class OverviewLaneBucketingTests : IClassFixture, IDisposable +{ + private const int ServerId = -4234; + private const string ServerName = "overview-lane-bucket-e2e"; + + private readonly DuckDbInitializer _duckDb; + private readonly LocalDataService _dataService; + private long _nextId = -1; + private DuckDBConnection? _seedConn; + + public OverviewLaneBucketingTests(SharedDuckDbFixture fixture) + { + fixture.ResetData(); + _duckDb = fixture.DuckDb; + _dataService = new LocalDataService(_duckDb); + } + + public void Dispose() => _seedConn?.Dispose(); + + /// Source check (no live DuckDB needed): each bucketed statement's text names time_bucket, + /// so a future edit that quietly reverts one read to per-collection SQL fails here first. + [Fact] + public void BucketedTrendSql_CarriesABucketWidth() + { + Assert.Contains("time_bucket", LocalDataService.CpuUtilizationTrendSql); + Assert.Contains("time_bucket", LocalDataService.MemoryTrendSql); + Assert.Contains("time_bucket", LocalDataService.TotalWaitTrendSql(string.Empty)); + } + + /// + /// 2,016 collections (5-minute cadence over 7 days) per lane — more than + /// (1,500), so an unbucketed read would have blown the budget. All three lanes stay at or under it. + /// + [Fact] + public async Task SevenDayWindow_StaysWithinChartBudget_ForAllThreeLanes() + { + var windowStart = Truncate(DateTime.UtcNow).AddDays(-7).AddHours(1); + const int days = 7; + const int cadenceMinutes = 5; + const int perDay = 1440 / cadenceMinutes; + Assert.True(days * perDay > TrendBuckets.ChartPointBudget, "the fixture must seed more raw rows than the budget to prove bucketing matters"); + + await BulkSeedCpuAsync(windowStart, days, perDay, cadenceMinutes); + await BulkSeedMemoryAsync(windowStart, days, perDay, cadenceMinutes); + await BulkSeedWaitAsync(windowStart, days, perDay, cadenceMinutes); + + var cpu = await _dataService.GetCpuUtilizationAsync(ServerId, hoursBack: 24 * 7, utcOffsetMinutes: 0); + var memory = await _dataService.GetMemoryTrendAsync(ServerId, hoursBack: 24 * 7); + var wait = await _dataService.GetTotalWaitTrendAsync(ServerId, hoursBack: 24 * 7); + + Assert.True(cpu.Count > 0 && cpu.Count <= TrendBudget.Chart.AutoPoints, $"CPU returned {cpu.Count} points"); + Assert.True(memory.Count > 0 && memory.Count <= TrendBudget.Chart.AutoPoints, $"Memory returned {memory.Count} points"); + Assert.True(wait.Count > 0 && wait.Count <= TrendBudget.Chart.AutoPoints, $"Wait returned {wait.Count} points"); + } + + /// + /// Three collections five minutes apart, seeded at :37 seconds past the minute (off the bucket grid) so + /// the fixture cannot pass by accident. A 3-hour window auto-sizes to a 1-minute bucket + /// (TrendBuckets.AutoMinutes's narrowest rung), and a 5-minute gap never shares a 1-minute bucket, + /// so every bucket holds exactly one physical collection and each lane must stamp its point at that + /// collection's OWN clock and value — not the bucket grid line — reproducing the pre-#4234 per-collection + /// read exactly. + /// + [Fact] + public async Task SingletonBuckets_MatchThePerCollectionReadExactly_ForAllThreeLanes() + { + var anchor = DateTime.UtcNow.AddHours(-2); + var t1 = new DateTime(anchor.Year, anchor.Month, anchor.Day, anchor.Hour, anchor.Minute, 37, DateTimeKind.Unspecified); + var t2 = t1.AddMinutes(5); + var t3 = t2.AddMinutes(5); + + await SeedCpuAsync(t1, sqlCpu: 40, otherCpu: 5); + await SeedCpuAsync(t2, sqlCpu: 55, otherCpu: 6); + await SeedCpuAsync(t3, sqlCpu: 70, otherCpu: 7); + + await SeedMemoryAsync(t1, total: 40000, target: 39000, buffer: 30000, plan: 4000); + await SeedMemoryAsync(t2, total: 41000, target: 39000, buffer: 31000, plan: 4100); + await SeedMemoryAsync(t3, total: 42000, target: 39000, buffer: 32000, plan: 4200); + + await SeedWaitAsync(t1, "CXPACKET", deltaMs: 3000, deltaSignal: 300, deltaTasks: 10, interval: 300); + await SeedWaitAsync(t2, "CXPACKET", deltaMs: 1500, deltaSignal: 150, deltaTasks: 5, interval: 300); + await SeedWaitAsync(t3, "CXPACKET", deltaMs: 6000, deltaSignal: 600, deltaTasks: 20, interval: 300); + + var cpu = await _dataService.GetCpuUtilizationAsync(ServerId, hoursBack: 3, utcOffsetMinutes: 0); + Assert.Equal(new[] { t1, t2, t3 }, cpu.Select(r => r.SampleTime).ToArray()); + Assert.Equal(new[] { 40, 55, 70 }, cpu.Select(r => r.SqlServerCpu).ToArray()); + Assert.Equal(new[] { 5, 6, 7 }, cpu.Select(r => r.OtherProcessCpu).ToArray()); + + var memory = await _dataService.GetMemoryTrendAsync(ServerId, hoursBack: 3); + Assert.Equal(new[] { t1, t2, t3 }, memory.Select(r => r.CollectionTime).ToArray()); + Assert.Equal(new[] { 40000.0, 41000.0, 42000.0 }, memory.Select(r => r.TotalServerMemoryMb).ToArray()); + Assert.Equal(new[] { 30000.0, 31000.0, 32000.0 }, memory.Select(r => r.BufferPoolMb).ToArray()); + + var wait = await _dataService.GetTotalWaitTrendAsync(ServerId, hoursBack: 3); + Assert.Equal(new[] { t1, t2, t3 }, wait.Select(r => r.CollectionTime).ToArray()); + /* 3000/300=10.0, 1500/300=5.0, 6000/300=20.0 */ + Assert.Equal(new[] { 10.0, 5.0, 20.0 }, wait.Select(r => r.WaitTimeMsPerSecond).ToArray()); + } + + /// + /// #3540 still holds post-bucketing: a collection where every wait type stored a 0 interval (a restart) is + /// unrated, and when it sits alone in its own bucket (isolated by 5 minutes from its neighbors, same as the + /// singleton test above) the bucket has no rated row at all — HAVING COUNT(rated_seconds) > 0 drops + /// it, so it stays ABSENT rather than rendering as a 0.00 ms/sec point. + /// + [Fact] + public async Task TotalWaitTrend_Bucketed_DropsAnIsolatedUnratedCollection() + { + var anchor = DateTime.UtcNow.AddHours(-2); + var t1 = new DateTime(anchor.Year, anchor.Month, anchor.Day, anchor.Hour, anchor.Minute, 37, DateTimeKind.Unspecified); + var t2 = t1.AddMinutes(5); + var t3 = t2.AddMinutes(5); + + await SeedWaitAsync(t1, "CXPACKET", deltaMs: 3000, deltaSignal: 300, deltaTasks: 10, interval: 300); + await SeedWaitAsync(t2, "CXPACKET", deltaMs: 0, deltaSignal: 0, deltaTasks: 0, interval: 0); + await SeedWaitAsync(t3, "CXPACKET", deltaMs: 1500, deltaSignal: 150, deltaTasks: 5, interval: 300); + + var points = await _dataService.GetTotalWaitTrendAsync(ServerId, hoursBack: 3); + + Assert.Equal(new[] { t1, t3 }, points.Select(p => p.CollectionTime).ToArray()); + Assert.Equal(10.0, points[0].WaitTimeMsPerSecond, precision: 6); + Assert.Equal(5.0, points[1].WaitTimeMsPerSecond, precision: 6); + } + + private static DateTime Truncate(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond)), DateTimeKind.Unspecified); + + private async Task SeedConnectionAsync() + { + if (_seedConn is null) + { + _seedConn = _duckDb.CreateConnection(); + await _seedConn.OpenAsync(); + } + + return _seedConn; + } + + private async Task SeedCpuAsync(DateTime sampleTime, int sqlCpu, int otherCpu) + { + using var readLock = _duckDb.AcquireReadLock(); + var conn = await SeedConnectionAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = @"INSERT INTO cpu_utilization_stats + (collection_id, collection_time, server_id, server_name, sample_time, + sqlserver_cpu_utilization, other_process_cpu_utilization) + VALUES ($1, $2, $3, $4, $5, $6, $7)"; + foreach (var v in new object[] { _nextId--, DateTime.UtcNow, ServerId, ServerName, sampleTime, sqlCpu, otherCpu }) + { + cmd.Parameters.Add(new DuckDBParameter { Value = v }); + } + + await cmd.ExecuteNonQueryAsync(); + } + + private async Task SeedMemoryAsync(DateTime collectionTime, double total, double target, double buffer, double plan) + { + using var readLock = _duckDb.AcquireReadLock(); + var conn = await SeedConnectionAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = @"INSERT INTO memory_stats + (collection_id, collection_time, server_id, server_name, + total_physical_memory_mb, available_physical_memory_mb, + target_server_memory_mb, total_server_memory_mb, buffer_pool_mb, plan_cache_mb) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)"; + foreach (var v in new object[] { _nextId--, collectionTime, ServerId, ServerName, 65536.0, 8192.0, target, total, buffer, plan }) + { + cmd.Parameters.Add(new DuckDBParameter { Value = v }); + } + + await cmd.ExecuteNonQueryAsync(); + } + + private static object IntervalValue(int? interval) => interval.HasValue ? interval.Value : DBNull.Value; + + 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, IntervalValue(interval) }) + { + cmd.Parameters.Add(new DuckDBParameter { Value = v }); + } + + await cmd.ExecuteNonQueryAsync(); + } + + /// Bulk-inserts * CPU rows in ONE statement + /// (a C# loop of thousands of round trips would dominate the test's time) at + /// apart, starting at . + private async Task BulkSeedCpuAsync(DateTime windowStart, int days, int perDay, int cadenceMinutes) + { + var conn = await SeedConnectionAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = $@" +INSERT INTO cpu_utilization_stats + (collection_id, collection_time, server_id, server_name, sample_time, + sqlserver_cpu_utilization, other_process_cpu_utilization) +SELECT -900000000 - (d * {perDay} + i), + TIMESTAMP '{windowStart:yyyy-MM-dd HH:mm:ss}' + (d * INTERVAL 1 DAY) + (i * INTERVAL {cadenceMinutes} MINUTE), + {ServerId}, '{ServerName}', + TIMESTAMP '{windowStart:yyyy-MM-dd HH:mm:ss}' + (d * INTERVAL 1 DAY) + (i * INTERVAL {cadenceMinutes} MINUTE), + 50, 10 +FROM generate_series(0, {days - 1}) AS t1(d) +CROSS JOIN generate_series(0, {perDay - 1}) AS t2(i)"; + await cmd.ExecuteNonQueryAsync(); + } + + private async Task BulkSeedMemoryAsync(DateTime windowStart, int days, int perDay, int cadenceMinutes) + { + var conn = await SeedConnectionAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = $@" +INSERT INTO memory_stats + (collection_id, collection_time, server_id, server_name, + total_physical_memory_mb, available_physical_memory_mb, + target_server_memory_mb, total_server_memory_mb, buffer_pool_mb, plan_cache_mb) +SELECT -800000000 - (d * {perDay} + i), + TIMESTAMP '{windowStart:yyyy-MM-dd HH:mm:ss}' + (d * INTERVAL 1 DAY) + (i * INTERVAL {cadenceMinutes} MINUTE), + {ServerId}, '{ServerName}', + 65536.0, 8192.0, 39000.0, 40000.0, 30000.0, 4000.0 +FROM generate_series(0, {days - 1}) AS t1(d) +CROSS JOIN generate_series(0, {perDay - 1}) AS t2(i)"; + await cmd.ExecuteNonQueryAsync(); + } + + private async Task BulkSeedWaitAsync(DateTime windowStart, int days, int perDay, int cadenceMinutes) + { + 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 -700000000 - (d * {perDay} + i), + TIMESTAMP '{windowStart:yyyy-MM-dd HH:mm:ss}' + (d * INTERVAL 1 DAY) + (i * INTERVAL {cadenceMinutes} MINUTE), + {ServerId}, '{ServerName}', 'CXPACKET', + 0, 0, 0, + 10, 3000, 300, {cadenceMinutes * 60} +FROM generate_series(0, {days - 1}) AS t1(d) +CROSS JOIN generate_series(0, {perDay - 1}) AS t2(i)"; + await cmd.ExecuteNonQueryAsync(); + } +}