diff --git a/Darling/Darling.Tests/ViewerCpuSchedulerTests.cs b/Darling/Darling.Tests/ViewerCpuSchedulerTests.cs index 0ecb87195c..2c72d614cf 100644 --- a/Darling/Darling.Tests/ViewerCpuSchedulerTests.cs +++ b/Darling/Darling.Tests/ViewerCpuSchedulerTests.cs @@ -119,6 +119,17 @@ public void CpuSchedulerView_IsPinnedInTheAuthoritativeViewList() /// public sealed class ViewerCpuSchedulerProjectionTests { + [Fact] + public void TheGridRows_AllCarryTheSnapshotsCollectionTime_AndANullSnapshotHasNone() + { + var snapshot = Snapshot(); + var rows = CpuSchedulerGridRow.Build(snapshot); + Assert.NotEmpty(rows); + Assert.All(rows, r => Assert.Equal(snapshot.CollectionTime, r.CollectionTime)); + Assert.Equal(CpuSchedulerMetrics.BuildMetrics(snapshot).Select(m => m.Metric), rows.Select(r => r.Metric)); + Assert.Empty(CpuSchedulerGridRow.Build(null)); + } + private static CpuSchedulerSnapshot Snapshot( bool workerExhaustion = false, bool runnableWarn = false, bool blockedWarn = false, bool queuedWarn = false, bool memPressure = false, bool offlineWarn = false, diff --git a/Darling/Darling.Tests/ViewerGridTimeColumnSortMemberTests.cs b/Darling/Darling.Tests/ViewerGridTimeColumnSortMemberTests.cs index b5e560d3a4..d41f17e574 100644 --- a/Darling/Darling.Tests/ViewerGridTimeColumnSortMemberTests.cs +++ b/Darling/Darling.Tests/ViewerGridTimeColumnSortMemberTests.cs @@ -132,6 +132,12 @@ public sealed class ViewerGridTimeColumnSortMemberTests { "ViewerServerTab.xaml", "ProcedureStatsGrid", "LastExecutionTimeLocal", Name(typeof(ViewerProcedureStatsRow)), "LastExecutionTime" }, { "ViewerServerTab.xaml", "ProcedureStatsGrid", "CachedTimeFormatted", Name(typeof(ViewerProcedureStatsRow)), "CachedTime" }, { "ViewerServerTab.xaml", "RunningJobsGrid", "StartTimeLocal", Name(typeof(RunningJobRow)), "StartTime" }, + + /* The last "Collected" column of the four newest-snapshot grids (#4966): naive-UTC CollectionTime, shown in the display zone. */ + { "ViewerServerTab.xaml", "RunningJobsGrid", "CollectionTimeLocal", Name(typeof(RunningJobRow)), "CollectionTime" }, + { "ViewerServerTab.xaml", "CpuSchedulerGrid", "CollectionTimeLocal", Name(typeof(CpuSchedulerGridRow)), "CollectionTime" }, + { "ViewerServerTab.xaml", "LatchStatsGrid", "CollectionTimeLocal", Name(typeof(LatchStatsSnapshotRow)), "CollectionTime" }, + { "ViewerServerTab.xaml", "SpinlockStatsGrid", "CollectionTimeLocal", Name(typeof(SpinlockStatsSnapshotRow)), "CollectionTime" }, { "ProcedureHistoryWindow.xaml", "HistoryDataGrid", "LastExecutionTimeLocal", Name(typeof(ViewerProcedureStatsHistoryRow)), "LastExecutionTime" }, { "ProcedureHistoryWindow.xaml", "HistoryDataGrid", "CachedTimeLocal", Name(typeof(ViewerProcedureStatsHistoryRow)), "CachedTime" }, { "QueryStatsHistoryWindow.xaml", "HistoryDataGrid", "LastExecutionTimeLocal", Name(typeof(ViewerQueryStatsHistoryRow)), "LastExecutionTime" }, diff --git a/Darling/Darling.Tests/ViewerLatchSpinlockTests.cs b/Darling/Darling.Tests/ViewerLatchSpinlockTests.cs index 16c04b20c3..6a869c4981 100644 --- a/Darling/Darling.Tests/ViewerLatchSpinlockTests.cs +++ b/Darling/Darling.Tests/ViewerLatchSpinlockTests.cs @@ -101,21 +101,21 @@ public void SnapshotSql_CarriesTheStoredInterval_SelectsDeltasAsStored(string wh public void SnapshotRows_NullTheDeltasOnTheMarker_AndSpellTheInterval() { var marker = new LatchStatsSnapshotRow("BUFFER", 0, 0, 0, - DeltaSeriesShaping.ReadableDelta(0, 0), DeltaSeriesShaping.ReadableDelta(0, 0), 0); + DeltaSeriesShaping.ReadableDelta(0, 0), DeltaSeriesShaping.ReadableDelta(0, 0), 0, DateTime.UnixEpoch); Assert.True(marker.IsUnknowable); Assert.Null(marker.DeltaWaitTimeMs); Assert.Null(marker.DeltaWaitingRequestsCount); Assert.Equal("restart / first sample", marker.IntervalDisplay); var measured = new SpinlockStatsSnapshotRow("LOCK_HASH", 900, 0, 0, 0, 0, - DeltaSeriesShaping.ReadableDelta(400, 120), DeltaSeriesShaping.ReadableDelta(0, 120), 120); + DeltaSeriesShaping.ReadableDelta(400, 120), DeltaSeriesShaping.ReadableDelta(0, 120), 120, DateTime.UnixEpoch); Assert.False(measured.IsUnknowable); Assert.Equal(400L, measured.DeltaCollisions); Assert.Equal(0L, measured.DeltaSpins); // a measured zero stays a zero Assert.Equal("120", measured.IntervalDisplay); var preV127 = new LatchStatsSnapshotRow("LOG_MANAGER", 0, 0, 0, - DeltaSeriesShaping.ReadableDelta(100, null), DeltaSeriesShaping.ReadableDelta(4000, null), null); + DeltaSeriesShaping.ReadableDelta(100, null), DeltaSeriesShaping.ReadableDelta(4000, null), null, DateTime.UnixEpoch); Assert.False(preV127.IsUnknowable); Assert.Equal(4000L, preV127.DeltaWaitTimeMs); Assert.Equal("not stored", preV127.IntervalDisplay); diff --git a/Darling/Darling.Tests/ViewerSnapshotCollectedColumnTests.cs b/Darling/Darling.Tests/ViewerSnapshotCollectedColumnTests.cs new file mode 100644 index 0000000000..141585b18e --- /dev/null +++ b/Darling/Darling.Tests/ViewerSnapshotCollectedColumnTests.cs @@ -0,0 +1,85 @@ +/* + * 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 System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; +using PerformanceMonitor.Darling.Viewer; +using PerformanceMonitor.Ui; +using Xunit; +using static Darling.Tests.RepoFile; + +namespace Darling.Tests; + +/// The CPU Scheduler, Latch, Spinlock and Running Jobs grids end in a "Collected" column that says when their snapshot +/// was collected (#4966): the display-zone time to the second, sorted on the stored DateTime, as the viewer's other Collected columns. +[Collection("viewer-time-statics")] +public sealed class ViewerSnapshotCollectedColumnTests +{ + private static string Xaml() => + ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerServerTab.xaml").ReplaceLineEndings("\n"); + + private static string ViewerFile(string file) => + ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", file).ReplaceLineEndings("\n"); + + [Theory] + [InlineData("CpuSchedulerGrid")] + [InlineData("LatchStatsGrid")] + [InlineData("SpinlockStatsGrid")] + [InlineData("RunningJobsGrid")] + public void TheGrid_EndsInACollectedColumn_BoundToTheDisplayTime_SortedOnTheStoredDateTime(string grid) + { + var xaml = Xaml(); + var start = Regex.Match(xaml, @"]*?x:Name=""" + grid + @"""").Index; + var end = xaml.IndexOf("", start, StringComparison.Ordinal); + var last = Regex.Matches(xaml[start..end], @"""])*>").Last().Value; + Assert.Contains("Header=\"Collected\"", last, StringComparison.Ordinal); + Assert.Contains("Binding=\"{Binding CollectionTimeLocal}\"", last, StringComparison.Ordinal); + Assert.Contains("SortMemberPath=\"CollectionTime\"", last, StringComparison.Ordinal); + } + + [Fact] + public void TheOldSnapshotLabel_IsGone() + { + Assert.DoesNotContain("SnapshotTime\"", Xaml(), StringComparison.Ordinal); + Assert.DoesNotContain("Snapshot at", Xaml(), StringComparison.Ordinal); + } + + [Fact] + public void TheCpuSchedulerLoad_WrapsItsRowsWithTheSnapshotsCollectionTime() + { + var source = ViewerFile("ViewerServerTab.CpuScheduler.cs"); + Assert.Contains("CpuSchedulerGrid.ItemsSource = CpuSchedulerGridRow.Build(snapshotTask.Result);", source, StringComparison.Ordinal); + } + + [Fact] + public void TheDisplayText_IsTheDisplayZonesWallTime_ToTheSecond() + { + var saved = CultureInfo.CurrentCulture; + var savedMode = ViewerTimeHelper.CurrentDisplayMode; + try + { + CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; + ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.UTC; + var at = new DateTime(2026, 10, 3, 14, 5, 9, DateTimeKind.Unspecified); + // A literal, so the format string itself is pinned. + var expected = "2026-10-03 14:05:09"; + Assert.Equal(expected, HistoryTime.CollectionLocal(at)); + Assert.Equal(expected, new RunningJobRow { CollectionTime = at }.CollectionTimeLocal); + Assert.Equal(expected, new LatchStatsSnapshotRow("L", 0, 0, 0, null, null, null, at).CollectionTimeLocal); + Assert.Equal(expected, new SpinlockStatsSnapshotRow("S", 0, 0, 0, 0, 0, null, null, null, at).CollectionTimeLocal); + Assert.Equal(expected, new CpuSchedulerGridRow { CollectionTime = at }.CollectionTimeLocal); + } + finally + { + ViewerTimeHelper.CurrentDisplayMode = savedMode; + CultureInfo.CurrentCulture = saved; + } + } +} diff --git a/Darling/Darling.Tests/ViewerSnapshotTimeLiveTests.cs b/Darling/Darling.Tests/ViewerSnapshotTimeLiveTests.cs new file mode 100644 index 0000000000..aa4430a101 --- /dev/null +++ b/Darling/Darling.Tests/ViewerSnapshotTimeLiveTests.cs @@ -0,0 +1,65 @@ +/* + * 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 System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// The latch and spinlock snapshot reads against a real store return the collection time of the snapshot they rendered (#4966). +/* #1776 own-store: deliberately NOT [Collection("live-postgres")]. Every test here reaches DARLING_TEST_PG only to CREATE + and DROP its own scratch database. */ +[Collection("viewer-time-statics")] +public sealed class ViewerSnapshotTimeLiveTests +{ + private const int ServerId = -496660; + + [Fact] + public async Task TheLatchAndSpinlockSnapshots_CarryTheNewestCollectionTime_AgainstDevPostgres() + { + var ct = TestContext.Current.CancellationToken; + await using var scratch = await QueryGridSeed.OpenScratchAsync(ct); + var end = QueryGridSeed.NowToTheMinute(); + var older = end.AddHours(-3); + var newest = end.AddHours(-1); + await using (var connection = new NpgsqlConnection(scratch.ConnectionString)) + { + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + await EventGridSeed.AddServerAsync(connection, ServerId, "snapshot-time", end.AddDays(-2), "latch_stats", end, clockOffsetMinutes: null, ct); + long id = 1; + foreach (var at in new[] { older, newest }) + { + await ExecAsync(connection, $"INSERT INTO latch_stats (collection_id, collection_time, server_id, server_name, latch_class, waiting_requests_count, wait_time_ms, max_wait_time_ms, delta_waiting_requests_count, delta_wait_time_ms, delta_max_wait_time_ms, sample_interval_seconds) VALUES ({id++}, @t, {ServerId}, 'snapshot-time', 'BUFFER', 10, 20, 5, 1, 2, 1, 60)", at, ct); + await ExecAsync(connection, $"INSERT INTO spinlock_stats (collection_id, collection_time, server_id, server_name, spinlock_name, collisions, spins, spins_per_collision, sleep_time, backoffs, delta_collisions, delta_spins, delta_sleep_time, delta_backoffs, sample_interval_seconds) VALUES ({id++}, @t, {ServerId}, 'snapshot-time', 'LOCK_HASH', 10, 20, 2, 0, 0, 1, 2, 0, 0, 60)", at, ct); + } + } + + var viewer = new ViewerDataService(scratch.ConnectionString); + var latch = await viewer.GetLatchStatsSnapshotAsync(ServerId, end.AddDays(-7), end, ct); + var spinlock = await viewer.GetSpinlockStatsSnapshotAsync(ServerId, end.AddDays(-7), end, ct); + + Assert.Single(latch); + Assert.Equal(newest, latch[0].CollectionTime); + Assert.Single(spinlock); + Assert.Equal(newest, spinlock[0].CollectionTime); + Assert.Empty(await viewer.GetLatchStatsSnapshotAsync(ServerId + 1, end.AddDays(-7), end, ct)); + } + + private static async Task ExecAsync(NpgsqlConnection connection, string sql, DateTime at, CancellationToken ct) + { + await using var command = new NpgsqlCommand(sql, connection); + command.Parameters.AddWithValue("t", DateTime.SpecifyKind(at, DateTimeKind.Unspecified)); + await command.ExecuteNonQueryAsync(ct); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CpuScheduler.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CpuScheduler.cs index 057206438e..723378e782 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CpuScheduler.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CpuScheduler.cs @@ -255,3 +255,36 @@ public async Task> GetCpuSchedulerTrendAsync( OfflineCpuWarning: !reader.IsDBNull(26) && reader.GetBoolean(26)); } } + +/// +/// One row of the CPU Scheduler latest-snapshot grid (#4966): the shared metric row plus when the snapshot it reads was collected. +/// The shared leaves the time out on purpose (each app keeps its own time handling), so the +/// viewer adds it here: every row of one snapshot carries that snapshot's naive-UTC , worded in the +/// display zone to the second as ; the column sorts by . +/// +public sealed class CpuSchedulerGridRow +{ + public string Metric { get; init; } = ""; + public string Value { get; init; } = ""; + public bool IsWarning { get; init; } + public DateTime CollectionTime { get; init; } + + public string CollectionTimeLocal => HistoryTime.CollectionLocal(CollectionTime); + + /// The grid's rows for the snapshot; no rows (and so no time) for a window that holds no snapshot. + public static List Build(CpuSchedulerSnapshot? snapshot) + { + var rows = new List(); + if (snapshot is null) + { + return rows; + } + + foreach (var metric in CpuSchedulerMetrics.BuildMetrics(snapshot)) + { + rows.Add(new CpuSchedulerGridRow { Metric = metric.Metric, Value = metric.Value, IsWarning = metric.IsWarning, CollectionTime = snapshot.CollectionTime }); + } + + return rows; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.LatchSpinlock.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.LatchSpinlock.cs index 71ca2f50c7..8896ca6a6f 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.LatchSpinlock.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.LatchSpinlock.cs @@ -12,6 +12,7 @@ using System.Threading.Tasks; using Npgsql; using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Storage; namespace PerformanceMonitor.Darling.Viewer; @@ -36,12 +37,16 @@ public sealed record LatchStatsSnapshotRow( long MaxWaitTimeMs, long? DeltaWaitingRequestsCount, long? DeltaWaitTimeMs, - int? SampleIntervalSeconds) + int? SampleIntervalSeconds, + DateTime CollectionTime) { /// True when the row's deltas are the unknowable marker — a stored interval of exactly 0 (the /// FileIoStatsRow / resource-semaphore idiom). public bool IsUnknowable => SampleIntervalSeconds == 0; + /// (naive UTC) in the display zone, to the second; the "Collected" column sorts by . + public string CollectionTimeLocal => HistoryTime.CollectionLocal(CollectionTime); + /// The grid's Interval (sec) cell — see . public string IntervalDisplay => DeltaSeriesShaping.IntervalDisplay(SampleIntervalSeconds); } @@ -63,11 +68,15 @@ public sealed record SpinlockStatsSnapshotRow( long Backoffs, long? DeltaCollisions, long? DeltaSpins, - int? SampleIntervalSeconds) + int? SampleIntervalSeconds, + DateTime CollectionTime) { /// True when the row's deltas are the unknowable marker — a stored interval of exactly 0. public bool IsUnknowable => SampleIntervalSeconds == 0; + /// (naive UTC) in the display zone, to the second; the "Collected" column sorts by . + public string CollectionTimeLocal => HistoryTime.CollectionLocal(CollectionTime); + /// The grid's Interval (sec) cell — see . public string IntervalDisplay => DeltaSeriesShaping.IntervalDisplay(SampleIntervalSeconds); } @@ -144,7 +153,8 @@ FROM v_latch_stats max_wait_time_ms, delta_waiting_requests_count, delta_wait_time_ms, - sample_interval_seconds + sample_interval_seconds, + collection_time FROM v_latch_stats WHERE server_id = $1 AND collection_time = (SELECT mx FROM latest) @@ -220,7 +230,8 @@ FROM v_spinlock_stats backoffs, delta_collisions, delta_spins, - sample_interval_seconds + sample_interval_seconds, + collection_time FROM v_spinlock_stats WHERE server_id = $1 AND collection_time = (SELECT mx FROM latest) @@ -278,7 +289,8 @@ deltas stand. The interval column is integer in the store (V127), read as such. reader.IsDBNull(3) ? 0 : reader.GetInt64(3), DeltaSeriesShaping.ReadableDelta(reader.IsDBNull(4) ? 0 : reader.GetInt64(4), interval), DeltaSeriesShaping.ReadableDelta(reader.IsDBNull(5) ? 0 : reader.GetInt64(5), interval), - interval)); + interval, + reader.GetDateTime(7))); } return result; @@ -334,9 +346,21 @@ public async Task> GetSpinlockStatsSnapshotAsync( reader.IsDBNull(5) ? 0 : reader.GetInt64(5), DeltaSeriesShaping.ReadableDelta(reader.IsDBNull(6) ? 0 : reader.GetInt64(6), interval), DeltaSeriesShaping.ReadableDelta(reader.IsDBNull(7) ? 0 : reader.GetInt64(7), interval), - interval)); + interval, + reader.GetDateTime(9))); } return result; } + + /// Where this server's latch_stats coverage starts for the window (the shared probe), so the trend chart can say so + /// when an empty stretch would otherwise draw as a flat zero (#4966). + public Task GetLatchStatsDataStartAsync(int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) => + DataWindowFloor.GetForServerAsync(_dataSource, DataWindowFloor.Source.ForCollectorTable("latch_stats"), serverId, startUtc, endUtc, + ViewerCommandDeadlines.CurrentInteractiveReadSeconds, cancellationToken); + + /// The spinlock_stats twin of . + public Task GetSpinlockStatsDataStartAsync(int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) => + DataWindowFloor.GetForServerAsync(_dataSource, DataWindowFloor.Source.ForCollectorTable("spinlock_stats"), serverId, startUtc, endUtc, + ViewerCommandDeadlines.CurrentInteractiveReadSeconds, cancellationToken); } diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.RunningJobs.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.RunningJobs.cs index e8951d4834..270ce13e56 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.RunningJobs.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.RunningJobs.cs @@ -204,6 +204,9 @@ public class RunningJobRow public string StartTimeLocal => ViewerDataService.FormatServerClock(StartTime); + /// (naive UTC) in the display zone, to the second; the "Collected" column sorts by . + public string CollectionTimeLocal => HistoryTime.CollectionLocal(CollectionTime); + public string CurrentDurationFormatted => FormatDuration(CurrentDurationSeconds); public string AvgDurationFormatted => FormatDuration(AvgDurationSeconds); public string P95DurationFormatted => FormatDuration(P95DurationSeconds); diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CpuScheduler.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CpuScheduler.cs index 3812c27a77..70d15e3620 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CpuScheduler.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CpuScheduler.cs @@ -57,7 +57,7 @@ private async Task LoadCpuSchedulerAsync() await Task.WhenAll(trendTask, snapshotTask); RenderCpuSchedulerChart(trendTask.Result); - CpuSchedulerGrid.ItemsSource = CpuSchedulerMetrics.BuildMetrics(snapshotTask.Result); + CpuSchedulerGrid.ItemsSource = CpuSchedulerGridRow.Build(snapshotTask.Result); var gap = CpuSchedulerGapNote(_server.ServerName, _server.EngineEdition, _server.EngineKind); CpuSchedulerNoDataMessage.Text = gap ?? ""; diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.LatchSpinlock.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.LatchSpinlock.cs index a8f479d4b6..5e49609259 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.LatchSpinlock.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.LatchSpinlock.cs @@ -62,6 +62,7 @@ private async Task LoadLatchSpinlockAsync() { var (startUtc, endUtc) = GetWindowUtc(); + /* Four reads in flight for the join below; the two data-start probes run after it under their own scope (#4966). */ using var readFanOut = ViewerReadFanOut.Of(4); var latchTrendTask = _dataService.GetLatchStatsTrendAsync(_server.ServerId, startUtc, endUtc); @@ -71,10 +72,23 @@ private async Task LoadLatchSpinlockAsync() await Task.WhenAll(latchTrendTask, latchSnapshotTask, spinlockTrendTask, spinlockSnapshotTask); + /* The join is over: free its width before the probes read again, so they are priced against two reads, not four finished ones. */ + readFanOut.Release(); + RenderLatchStatsChart(latchTrendTask.Result); LatchStatsGrid.ItemsSource = latchSnapshotTask.Result; RenderSpinlockStatsChart(spinlockTrendTask.Result); SpinlockStatsGrid.ItemsSource = spinlockSnapshotTask.Result; + + /* #4966: each chart draws a flat zero over an empty stretch, so each says where its collector's coverage starts. The probes stay OUT + of the join above: a probe that throws costs the chart's note (DataStartOrNullAsync catches it), never the rows. */ + using var probeFanOut = ViewerReadFanOut.Of(2); + var latchDataStartTask = _dataService.GetLatchStatsDataStartAsync(_server.ServerId, startUtc, endUtc); + var spinlockDataStartTask = _dataService.GetSpinlockStatsDataStartAsync(_server.ServerId, startUtc, endUtc); + /* Both probes are awaited on one line, each through the catching helper: neither is left unobserved while the other is awaited. */ + var (latchDataStart, spinlockDataStart) = (await DataStartOrNullAsync(latchDataStartTask, "Latch Stats"), await DataStartOrNullAsync(spinlockDataStartTask, "Spinlock Stats")); + UpdateTruncationBanner(LatchStatsTruncationBanner, latchDataStart, startUtc); + UpdateTruncationBanner(SpinlockStatsTruncationBanner, spinlockDataStart, startUtc); } private void RenderLatchStatsChart(List data) diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml index 4b49fb7bfe..1b61ac4a67 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml @@ -2153,6 +2153,7 @@ + @@ -2894,6 +2895,7 @@