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