diff --git a/Darling/Darling.Tests/LitePerfmonWaitStatisticsArtifactSourceTests.cs b/Darling/Darling.Tests/LitePerfmonWaitStatisticsArtifactSourceTests.cs new file mode 100644 index 0000000000..f3680682cf --- /dev/null +++ b/Darling/Darling.Tests/LitePerfmonWaitStatisticsArtifactSourceTests.cs @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using Xunit; +using static Darling.Tests.RepoFile; + +namespace Darling.Tests; + +/// +/// Lite's twins of the #4476 one-sample Wait Statistics artifact exclusion, checked without a live DuckDB or a +/// project reference to Lite: a source-text scan of the two SQL-builder files, confirming both embed +/// 's own emitted fragments +/// (never a second copy of the 1,000,000 / 1,000 literals). Runs on macOS. The wiring itself is proven by +/// Lite.Tests' Windows-only fact plus a local DuckDB check recorded in the PR body — this pin only proves the +/// text is present, not that DuckDB accepts it. +/// +public sealed class LitePerfmonWaitStatisticsArtifactSourceTests +{ + [Fact] + public void PerfmonTrendsSql_SourceEmbedsTheArtifactPredicateAndTheSuffixMatch() + { + var text = ReadRepoFile("Lite", "Services", "LocalDataService.Perfmon.cs"); + + Assert.Contains("WaitStatisticsArtifact.ArtifactPredicateSql(", text, StringComparison.Ordinal); + Assert.Contains("lag(cntr_value) OVER w AS prev_value", text, StringComparison.Ordinal); + Assert.Contains("lead(cntr_value) OVER w AS next_value", text, StringComparison.Ordinal); + Assert.Contains("artifacts_set_aside", text, StringComparison.Ordinal); + Assert.Contains("FILTER (WHERE NOT is_artifact)", text, StringComparison.Ordinal); + } + + [Fact] + public void PerfmonBucketsSql_SourceEmbedsTheArtifactPredicateAndTheSuffixMatch() + { + var text = ReadRepoFile("Lite", "Services", "LocalDataService.TrendBuckets.cs"); + + Assert.Contains("WaitStatisticsArtifact.ArtifactPredicateSql(", text, StringComparison.Ordinal); + Assert.Contains("WaitStatisticsArtifact.ObjectNameSuffixMatchSql(", text, StringComparison.Ordinal); + Assert.Contains("artifacts_set_aside", text, StringComparison.Ordinal); + Assert.Contains("PerfmonBucketsResult", text, StringComparison.Ordinal); + Assert.Contains("FILTER (WHERE NOT is_artifact)", text, StringComparison.Ordinal); + } +} diff --git a/Lite.Tests/LitePerfmonWaitStatisticsArtifactLiveTests.cs b/Lite.Tests/LitePerfmonWaitStatisticsArtifactLiveTests.cs new file mode 100644 index 0000000000..3669dc48cd --- /dev/null +++ b/Lite.Tests/LitePerfmonWaitStatisticsArtifactLiveTests.cs @@ -0,0 +1,193 @@ +/* + * 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; + +/// +/// #4476's Lite twin of Darling's PerfmonWaitStatisticsArtifactViewerLiveTests / +/// PerfmonWaitStatisticsArtifactMcpLiveTests: runs the REAL Lite reads +/// ( and +/// ) against an embedded DuckDB planted with the same +/// field-shaped isolated single-sample SQLServer:Wait Statistics artifact those two classes use — one +/// server, four instances of Page latch waits, five collections one minute apart, one spiking instance +/// (Waits started per second reading 318, 400, 214,396,451, 1,021, 500) among three flat neighbours. +/// +[Collection("server-time-helper")] +public sealed class LitePerfmonWaitStatisticsArtifactLiveTests : IClassFixture, IDisposable +{ + private const int ServerId = -447644761; + private const string ServerName = "lite-perfmon-waitstats-artifact-e2e"; + private const string ObjectName = "SQLServer:Wait Statistics"; + private const string CounterName = "Page latch waits"; + + private readonly DuckDbInitializer _duckDb; + private readonly LocalDataService _dataService; + private readonly int _savedUtcOffsetMinutes; + private long _nextId = -1; + private DuckDBConnection? _seedConn; + + public LitePerfmonWaitStatisticsArtifactLiveTests(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(); + } + + /// Through (the chart read): + /// collection 3's per-collection sum with the spike set aside is 62 (2 + 10 + 50, the three normal + /// instances' values at that collection), exactly one artifact is reported, and an all-artifact + /// collection (a counter with ONLY the spiking instance planted) is skipped rather than published as a + /// fabricated 0. + [Fact] + public async Task GetPerfmonTrendsByCountersAsync_SetsAsideTheIsolatedSpike_AndDropsAnAllArtifactCollection() + { + var t0 = new DateTime(2026, 3, 15, 9, 0, 0); + var times = Enumerable.Range(0, 5).Select(i => t0.AddMinutes(i)).ToArray(); + + /* Waits started per second: the spiking instance. Collection 3 (index 2) reads the field-shaped + cumulative artifact; its neighbours are normal. */ + var startedPerSec = new long[] { 318, 400, 214_396_451, 1_021, 500 }; + /* Waits in progress: a normal, non-spiking instance under the same counter name. */ + var inProgress = new long[] { 2, 3, 2, 1, 2 }; + /* Average wait time (ms) and Cumulative wait time (ms) per second: two more instances, flat. */ + var avgWaitMs = new long[] { 10, 10, 10, 10, 10 }; + var cumulativeWaitMs = new long[] { 50, 50, 50, 50, 50 }; + + for (var i = 0; i < 5; i++) + { + await PlantAsync(times[i], CounterName, "Waits started per second", startedPerSec[i]); + await PlantAsync(times[i], CounterName, "Waits in progress", inProgress[i]); + await PlantAsync(times[i], CounterName, "Average wait time (ms)", avgWaitMs[i]); + await PlantAsync(times[i], CounterName, "Cumulative wait time (ms) per second", cumulativeWaitMs[i]); + } + + var startUtc = times[0].AddMinutes(-1); + var endUtc = times[^1].AddMinutes(1); + + var trends = await _dataService.GetPerfmonTrendsByCountersAsync( + ServerId, new List { CounterName }, fromDate: startUtc, toDate: endUtc); + + Assert.True(trends.TryGetValue(CounterName, out var points), "the counter did not plot at all"); + Assert.Equal(5, points!.Count); + + var point3 = points.Single(p => p.CollectionTime == times[2]); + /* Fact: the artifact-excluded sum for collection 3 is 2 (Waits in progress) + 10 (Average wait time) + + 50 (Cumulative wait time) = 62 — the 214,396,451 spike is set aside entirely. */ + Assert.Equal(62, point3.Value); + Assert.Equal(1, point3.ArtifactsSetAside); + foreach (var p in points.Where(p => p.CollectionTime != times[2])) + { + Assert.Equal(0, p.ArtifactsSetAside); + } + + /* Only the spiking instance planted under a SEPARATE counter name (no other instance in that + collection to survive the exclusion): collection 3 drops out entirely rather than publishing a + fabricated 0. */ + const string SoloCounterName = "Waits started per second"; + for (var i = 0; i < 5; i++) + { + await PlantAsync(times[i], SoloCounterName, "", startedPerSec[i]); + } + + var soloTrends = await _dataService.GetPerfmonTrendsByCountersAsync( + ServerId, new List { SoloCounterName }, fromDate: startUtc, toDate: endUtc); + + Assert.True(soloTrends.TryGetValue(SoloCounterName, out var soloPoints), "the solo counter did not plot at all"); + Assert.Equal(4, soloPoints!.Count); + Assert.DoesNotContain(soloPoints, p => p.CollectionTime == times[2]); + } + + /// Through (the get_perfmon_trend + /// MCP tool's read): the published peak stays under 10,000 (the spike never reaches it) and exactly one + /// artifact is reported across the window. + [Fact] + public async Task GetPerfmonBucketsAsync_KeepsTheSpikeOutOfThePublishedPeak_AndReportsOneArtifact() + { + var t0 = new DateTime(2026, 3, 15, 9, 0, 0); + var times = Enumerable.Range(0, 5).Select(i => t0.AddMinutes(i)).ToArray(); + + var startedPerSec = new long[] { 318, 400, 214_396_451, 1_021, 500 }; + + /* This counter is planted as a SOLO instance (no sibling instances under the same counter name), so + the artifact's own collection has nothing else to sum — every row in that bucket is set aside. */ + const string SoloCounterName = "Waits started per second"; + for (var i = 0; i < 5; i++) + { + await PlantAsync(times[i], SoloCounterName, "", startedPerSec[i]); + } + + /* Smallest bucket width (1 minute), asOfUtc past the last plant, hoursBack wide enough to cover the + whole 5-minute plant — one point per collection, so the artifact's own collection is its own + bucket rather than folded into a wider one. */ + var asOfUtc = times[^1].AddMinutes(1); + + var result = await _dataService.GetPerfmonBucketsAsync( + ServerId, SoloCounterName, hoursBack: 1, asOfUtc: asOfUtc, bucketMinutes: 1); + + /* Only 4 points, not 5: this counter has a single instance, so the artifact's own collection has + nothing else to sum — every row in that bucket was set aside, and GetPerfmonBucketsAsync drops a + bucket like that rather than publish a fabricated 0. */ + Assert.Equal(4, result.Points.Count); + + var maxValue = result.Points.Max(p => p.MaxValue); + Assert.True(maxValue < 10_000, $"expected the artifact excluded from the published max, got {maxValue}"); + + Assert.Equal(1, result.ArtifactsSetAside); + } + + /* ---- seeding ---------------------------------------------------------------------------------------- */ + + private async Task SeedConnectionAsync() + { + if (_seedConn is null) + { + _seedConn = _duckDb.CreateConnection(); + await _seedConn.OpenAsync(); + } + + return _seedConn; + } + + private async Task PlantAsync(DateTime at, string counterName, string instanceName, long value) + { + 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, cntr_type) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NULL, NULL, $9)"; + foreach (var v in new object[] + { + _nextId--, at, ServerId, ServerName, ObjectName, counterName, instanceName, value, + PerfmonCounterTypes.PerfCounterLargeRawCount + }) + { + cmd.Parameters.Add(new DuckDBParameter { Value = v }); + } + await cmd.ExecuteNonQueryAsync(); + } +} diff --git a/Lite/Controls/ServerTab.Pickers.cs b/Lite/Controls/ServerTab.Pickers.cs index 4ac7a65d97..5dd328bde4 100644 --- a/Lite/Controls/ServerTab.Pickers.cs +++ b/Lite/Controls/ServerTab.Pickers.cs @@ -622,6 +622,23 @@ read reports one only where the point's instance rows agree. Null on every point PerfmonChart.Plot.YLabel(DeltaSeriesShaping.YAxisLabel(plottedBases)); SetChartYLimitsWithLegendPadding(PerfmonChart, 0, globalMax > 0 ? globalMax : 100); ShowChartLegend(PerfmonChart); + + /* #4476: a chart-chrome title naming how many one-sample Wait Statistics spikes this window's + plotted counters set aside — null when none, so nothing is shown for the common case. Mirrors + the Darling viewer's ViewerServerTab.Perfmon.cs. */ + var artifactsSetAside = selected + .Where(s => trendsByCounter.ContainsKey(s.DisplayName)) + .SelectMany(s => trendsByCounter[s.DisplayName]) + .Sum(t => t.ArtifactsSetAside); + var caption = WaitStatisticsArtifact.ChartCaption(artifactsSetAside); + if (caption != null) + { + PerfmonChart.Plot.Title(caption); + PerfmonChart.Plot.Axes.Title.Label.ForeColor = PerfmonChart.Plot.Axes.Bottom.TickLabelStyle.ForeColor; + PerfmonChart.Plot.Axes.Title.Label.FontSize = 11; + PerfmonChart.Plot.Axes.Title.Label.Bold = false; + } + PerfmonChart.Refresh(); } catch diff --git a/Lite/Mcp/McpPerfmonTools.cs b/Lite/Mcp/McpPerfmonTools.cs index a7e127d3e7..56bfa02763 100644 --- a/Lite/Mcp/McpPerfmonTools.cs +++ b/Lite/Mcp/McpPerfmonTools.cs @@ -86,7 +86,8 @@ public static async Task GetPerfmonTrend( var bucketError = TrendBuckets.Resolve(hours_back, bucket_minutes, 1, budget, out var bucketMinutes); if (bucketError != null) return bucketError; - var points = await dataService.GetPerfmonBucketsAsync(resolved.ServerId, counter_name, hours_back, windowEnd, bucketMinutes); + var bucketsResult = await dataService.GetPerfmonBucketsAsync(resolved.ServerId, counter_name, hours_back, windowEnd, bucketMinutes); + var points = bucketsResult.Points; if (points.Count == 0) { /* The engine question comes BEFORE the distinct-counter probe, not after it. Both are on @@ -140,7 +141,7 @@ the collected names so the caller can correct it. */ return TrendPayloads.PerfmonTrend( resolved.ServerName, counter_name, hours_back, points, bucketMinutes, bucket_minutes is not null, - budget.AutoPoints, BaselineDiscontinuities.ToPayload(discontinuities)); + budget.AutoPoints, BaselineDiscontinuities.ToPayload(discontinuities), bucketsResult.ArtifactsSetAside); } catch (Exception ex) { diff --git a/Lite/Services/LocalDataService.Perfmon.cs b/Lite/Services/LocalDataService.Perfmon.cs index 8cdf14ef51..89a10d517b 100644 --- a/Lite/Services/LocalDataService.Perfmon.cs +++ b/Lite/Services/LocalDataService.Perfmon.cs @@ -177,11 +177,30 @@ GROUP BY collection_time /// 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. + /// #4476: a raw-row layer sits under the per-collection SUM, carrying lag/lead of + /// cntr_value per instance (PARTITION BY object_name, counter_name, instance_name ORDER BY + /// collection_time — this read covers several counter names in one query, so counter_name joins + /// the window) so can tell an isolated + /// single-sample artifact (a SQLServer:Wait Statistics gauge instance whose one collection reads its + /// lifetime cumulative count, #4476) apart from a real value BEFORE the instances are summed into a + /// per-collection point, the same shape as Darling's ViewerDataService.PerfmonTrendsSql. Every + /// artifact instance-row is excluded from the per-collection cntr_value/delta_cntr_value SUMs + /// via FILTER (WHERE NOT is_artifact); the per-collection layer counts how many of its rows were set + /// aside (COUNT(*) FILTER (WHERE is_artifact)), and the outer bucket layer SUMs that count into + /// artifacts_set_aside, appended LAST so ordinals 0-7 do not move. A bucket whose every row was an + /// artifact reads cntr_value IS NULL with a positive artifacts_set_aside — + /// drops that point rather than turning it into a + /// fabricated 0. is only DuckDB's right() + /// and a string comparison, and the predicate's GREATEST/COALESCE/FILTER/window + /// lag/lead all exist in DuckDB unchanged from the Postgres text, so no dialect parameter is + /// needed. /// internal static string PerfmonTrendsSql(int counterCount) { var nameParams = string.Join(", ", Enumerable.Range(0, counterCount).Select(i => "$" + (i + 4))); var widthParam = "$" + (counterCount + 4); + var isArtifact = "COALESCE((" + WaitStatisticsArtifact.ArtifactPredicateSql( + "cntr_type", "object_name", "prev_value", "cntr_value", "next_value") + "), false)"; return $@" SELECT counter_name, @@ -191,20 +210,45 @@ internal static string PerfmonTrendsSql(int counterCount) 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 + COUNT(*) AS collection_count, + SUM(artifacts) AS artifacts_set_aside 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}) + CAST(SUM(cntr_value) FILTER (WHERE NOT is_artifact) AS BIGINT) AS cntr_value, + CAST(SUM(delta_cntr_value) FILTER (WHERE NOT is_artifact) AS BIGINT) AS delta_cntr_value, + CAST(MAX(sample_interval_seconds) AS BIGINT) AS sample_interval_seconds, + CASE WHEN MIN(cntr_type) = MAX(cntr_type) THEN MAX(cntr_type) END AS cntr_type, + COUNT(*) FILTER (WHERE is_artifact) AS artifacts + FROM ( + SELECT + counter_name, + collection_time, + cntr_value, + delta_cntr_value, + sample_interval_seconds, + cntr_type, + {isArtifact} AS is_artifact + FROM ( + SELECT + counter_name, + collection_time, + object_name, + cntr_value, + delta_cntr_value, + sample_interval_seconds, + cntr_type, + lag(cntr_value) OVER w AS prev_value, + lead(cntr_value) OVER w AS next_value + FROM v_perfmon_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND counter_name IN ({nameParams}) + WINDOW w AS (PARTITION BY object_name, counter_name, instance_name ORDER BY collection_time) + ) AS raw + ) AS flagged GROUP BY counter_name, collection_time ) AS collections GROUP BY counter_name, 2 @@ -251,7 +295,7 @@ public async Task>> GetPerfmonTrendsB 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 rows = new List<(string CounterName, DateTime BucketStart, DateTime FirstCollectionTime, long? Value, long? DeltaValue, long? SampleIntervalSeconds, int? CntrType, long ArtifactsSetAside)>(); var everyBucketSingleton = true; using var reader = await command.ExecuteReaderAsync(); @@ -266,18 +310,30 @@ public async Task>> GetPerfmonTrendsB reader.GetString(0), reader.GetDateTime(1), reader.GetDateTime(6), - /* ToInt64, not GetInt64/Convert.ToInt64: DuckDB's SUM over an INTEGER column (sample_interval_seconds) + /* NULL stays NULL here: a bucket whose every row was an artifact (#4476) is dropped below rather + than turned into a fabricated 0 — that decision needs to see the NULL. 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)), + reader.IsDBNull(2) ? null : 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)))); + reader.IsDBNull(5) ? null : (int)ToInt64(reader.GetValue(5)), + reader.IsDBNull(8) ? 0 : ToInt64(reader.GetValue(8)))); } foreach (var row in rows) { + /* #4476: every instance summed into this bucket was set aside as an isolated single-sample + artifact — there is no real value to plot, so the point is skipped rather than read as 0 + (the pre-#4476 IsDBNull(2) ? 0 : ... coercion this replaces). Every other NULL keeps that + coercion, since a NULL cntr_value with no artifacts set aside is not this case. */ + if (row.Value is null && row.ArtifactsSetAside > 0) + { + continue; + } + if (!result.TryGetValue(row.CounterName, out var list)) { list = new List(); @@ -287,9 +343,10 @@ public async Task>> GetPerfmonTrendsB list.Add(new PerfmonTrendPoint { CollectionTime = everyBucketSingleton ? row.FirstCollectionTime : row.BucketStart, - Value = row.Value, + Value = row.Value ?? 0, DeltaValue = row.DeltaValue, SampleIntervalSeconds = row.SampleIntervalSeconds, + ArtifactsSetAside = row.ArtifactsSetAside, CntrType = row.CntrType }); } @@ -339,10 +396,19 @@ public class PerfmonTrendPoint /// zero delta readable: the collector reports 0 in exactly the cases where no delta was knowable /// (first sighting, counter reset, gap past the policy), so (0, 0) is "unknown" while (0, n) is /// "genuinely idle" (#2234). MAX, never SUM, across a counter's instance rows — it is one measured - /// sweep gap repeated per instance, and Transactions/sec carries a median of 12 of them. Nullable + /// sweep gap repeated per instance, and Transactions/sec carries a median of 12 of them. + /// + /// #4476: follows, defaulted 0 for the unbucketed single-counter read. + /// Nullable /// since #3653 A7 so the third state survives the read (#3540): null is a row that never stored /// an interval, which the reader used to coerce to the 0 marker. The perfmon chart now plots THROUGH /// this field via the shared DeltaSeriesShaping; get_perfmon_trend hands it to the caller /// as it always did (a NULL row, which perfmon_stats has never written, would now publish null). public long? SampleIntervalSeconds { get; set; } + + /// How many instance rows this point's per-collection SUM excluded as an isolated single-sample + /// Wait Statistics artifact (#4476) — 0 for a counter that never carries the object, or a call from + /// (the per-collection read the artifact exclusion has not reached). + /// 's bucketed SUM is where this is nonzero. + public long ArtifactsSetAside { get; set; } } diff --git a/Lite/Services/LocalDataService.TrendBuckets.cs b/Lite/Services/LocalDataService.TrendBuckets.cs index de2fafda15..ca4334b978 100644 --- a/Lite/Services/LocalDataService.TrendBuckets.cs +++ b/Lite/Services/LocalDataService.TrendBuckets.cs @@ -713,28 +713,90 @@ GROUP BY 1 /// largest and last value, and the deltas summed per interval class — rated, unknowable 0, unrecorded NULL — with /// the seconds the rated ones accrued over. The rate's peak is the busiest single rated collection. Darling's twin /// is DarlingTrendReader.PerfmonTrendBucketedSql. + /// #4476: a raw-row layer sits under the per-collection SUM, carrying lag/lead of + /// cntr_value per instance (PARTITION BY object_name, instance_name ORDER BY collection_time — + /// this read is already scoped to one counter name by , so + /// counter_name does not need to join the window) so + /// can tell an isolated single-sample artifact (a + /// SQLServer:Wait Statistics gauge instance whose one collection reads its lifetime cumulative count, + /// #4476) apart from a real value BEFORE the instances are summed into a per-collection point. Every artifact + /// instance-row is excluded from the cntr_value/delta_cntr_value SUMs via FILTER (WHERE NOT + /// is_artifact); the per-collection layer counts how many of its rows were set aside, and the outer + /// bucket layer SUMs that count into artifacts_set_aside. A bucket every one of whose instance rows + /// was set aside reads cntr_value IS NULL with a positive artifacts_set_aside — the returned + /// drops that point from rather + /// than manufacturing a 0, but keeps its count in so a + /// dropped bucket does not silently drop its own count too — the same shape as Darling's + /// DarlingTrendReader.GetPerfmonBucketsAsync. /// - internal async Task> GetPerfmonBucketsAsync(int serverId, string counterName, int hoursBack, DateTime asOfUtc, int bucketMinutes) + internal async Task GetPerfmonBucketsAsync(int serverId, string counterName, int hoursBack, DateTime asOfUtc, int bucketMinutes) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); var (startTime, endTime) = GetTimeRange(hoursBack, null, null, asOfUtc, utcOffsetMinutes: 0); + var isArtifact = "COALESCE((" + WaitStatisticsArtifact.ArtifactPredicateSql( + "cntr_type", "object_name", "prev_value", "cntr_value", "next_value") + "), false)"; + var isWaitStatistics = WaitStatisticsArtifact.ObjectNameSuffixMatchSql("object_name"); + command.CommandText = $@" -WITH collections AS +WITH flagged AS +( + SELECT + collection_time, + cntr_value, + delta_cntr_value, + sample_interval_seconds, + cntr_type, + {isArtifact} AS is_artifact + FROM ( + SELECT + collection_time, + object_name, + cntr_value, + delta_cntr_value, + sample_interval_seconds, + cntr_type, + lag(cntr_value) OVER w AS prev_value, + lead(cntr_value) OVER w AS next_value + FROM v_perfmon_stats + WHERE server_id = $1 + AND counter_name = $2 + AND collection_time >= $3 + AND collection_time <= $4 + AND {isWaitStatistics} + WINDOW w AS (PARTITION BY object_name, instance_name ORDER BY collection_time) + + UNION ALL + + SELECT + collection_time, + object_name, + cntr_value, + delta_cntr_value, + sample_interval_seconds, + cntr_type, + NULL AS prev_value, + NULL AS next_value + FROM v_perfmon_stats + WHERE server_id = $1 + AND counter_name = $2 + AND collection_time >= $3 + AND collection_time <= $4 + AND NOT ({isWaitStatistics}) + ) AS raw +), +collections AS ( SELECT collection_time, - SUM(cntr_value) AS cntr_value, - SUM(delta_cntr_value) AS delta_cntr_value, + SUM(cntr_value) FILTER (WHERE NOT is_artifact) AS cntr_value, + SUM(delta_cntr_value) FILTER (WHERE NOT is_artifact) 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 counter_name = $2 - AND collection_time >= $3 - AND collection_time <= $4 + CASE WHEN MIN(cntr_type) = MAX(cntr_type) THEN MAX(cntr_type) END AS cntr_type, + COUNT(*) FILTER (WHERE is_artifact) AS artifacts + FROM flagged GROUP BY collection_time ) SELECT @@ -748,7 +810,8 @@ GROUP BY collection_time SUM(delta_cntr_value) FILTER (WHERE sample_interval_seconds = 0) AS unknowable_delta, SUM(delta_cntr_value) FILTER (WHERE sample_interval_seconds IS NULL) AS unrecorded_delta, MAX(CAST(delta_cntr_value AS DOUBLE PRECISION) / NULLIF(sample_interval_seconds, 0)) AS peak_per_second, - CASE WHEN MIN(cntr_type) = MAX(cntr_type) THEN MAX(cntr_type) END AS cntr_type + CASE WHEN MIN(cntr_type) = MAX(cntr_type) THEN MAX(cntr_type) END AS cntr_type, + SUM(artifacts) AS artifacts_set_aside FROM collections GROUP BY 1 ORDER BY 1"; @@ -760,9 +823,20 @@ GROUP BY 1 command.Parameters.Add(new DuckDBParameter { Value = bucketMinutes }); var items = new List(); + long artifactsSetAsideTotal = 0; using var reader = await command.ExecuteReaderAsync(); while (await reader.ReadAsync()) { + var artifactsSetAside = reader.IsDBNull(11) ? 0 : ToInt64(reader.GetValue(11)); + artifactsSetAsideTotal += artifactsSetAside; + if (reader.IsDBNull(1) && artifactsSetAside > 0) + { + /* Every row in this bucket was set aside: no genuine reading survived to average, so this + is not a bucket with nothing collected — skip it rather than manufacture a 0. Its count is + already folded into artifactsSetAsideTotal above. */ + continue; + } + items.Add(new PerfmonBucketPoint( reader.GetDateTime(0), reader.IsDBNull(1) ? 0 : ToDouble(reader.GetValue(1)), @@ -774,9 +848,17 @@ GROUP BY 1 reader.IsDBNull(7) ? null : ToInt64(reader.GetValue(7)), reader.IsDBNull(8) ? null : ToInt64(reader.GetValue(8)), reader.IsDBNull(9) ? null : ToDouble(reader.GetValue(9)), - reader.IsDBNull(10) ? null : (int)ToInt64(reader.GetValue(10)))); + reader.IsDBNull(10) ? null : (int)ToInt64(reader.GetValue(10)), + artifactsSetAside)); } - return items; + return new PerfmonBucketsResult(items, artifactsSetAsideTotal); } } + +/// Return shape of (#4476): the published points, +/// plus the TOTAL artifacts set aside across the whole window — a separate figure because a bucket every one +/// of whose rows was an artifact is dropped from , so summing +/// back out of the published points would silently lose +/// that bucket's own count. Mirrors Darling's DarlingTrendReader.PerfmonBucketsResult. +internal sealed record PerfmonBucketsResult(List Points, long ArtifactsSetAside);