Skip to content
Merged
11 changes: 11 additions & 0 deletions Darling/Darling.Tests/ViewerCpuSchedulerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,17 @@ public void CpuSchedulerView_IsPinnedInTheAuthoritativeViewList()
/// </summary>
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,
Expand Down
6 changes: 6 additions & 0 deletions Darling/Darling.Tests/ViewerGridTimeColumnSortMemberTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
6 changes: 3 additions & 3 deletions Darling/Darling.Tests/ViewerLatchSpinlockTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
85 changes: 85 additions & 0 deletions Darling/Darling.Tests/ViewerSnapshotCollectedColumnTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>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.</summary>
[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, @"<DataGrid\b[^>]*?x:Name=""" + grid + @"""").Index;
var end = xaml.IndexOf("</DataGrid.Columns>", start, StringComparison.Ordinal);
var last = Regex.Matches(xaml[start..end], @"<DataGridTextColumn\b(?:""[^""]*""|[^>""])*>").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;
}
}
}
65 changes: 65 additions & 0 deletions Darling/Darling.Tests/ViewerSnapshotTimeLiveTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>The latch and spinlock snapshot reads against a real store return the collection time of the snapshot they rendered (#4966).</summary>
/* #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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,36 @@ public async Task<List<CpuSchedulerTrendPoint>> GetCpuSchedulerTrendAsync(
OfflineCpuWarning: !reader.IsDBNull(26) && reader.GetBoolean(26));
}
}

/// <summary>
/// One row of the CPU Scheduler latest-snapshot grid (#4966): the shared metric row plus when the snapshot it reads was collected.
/// The shared <see cref="CpuSchedulerMetricRow"/> 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 <see cref="CollectionTime"/>, worded in the
/// display zone to the second as <see cref="CollectionTimeLocal"/>; the column sorts by <see cref="CollectionTime"/>.
/// </summary>
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);

/// <summary>The grid's rows for the snapshot; no rows (and so no time) for a window that holds no snapshot.</summary>
public static List<CpuSchedulerGridRow> Build(CpuSchedulerSnapshot? snapshot)
{
var rows = new List<CpuSchedulerGridRow>();
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using System.Threading.Tasks;
using Npgsql;
using PerformanceMonitor.Common;
using PerformanceMonitor.Darling.Storage;

namespace PerformanceMonitor.Darling.Viewer;

Expand All @@ -36,12 +37,16 @@ public sealed record LatchStatsSnapshotRow(
long MaxWaitTimeMs,
long? DeltaWaitingRequestsCount,
long? DeltaWaitTimeMs,
int? SampleIntervalSeconds)
int? SampleIntervalSeconds,
DateTime CollectionTime)
{
/// <summary>True when the row's deltas are the unknowable marker — a stored interval of exactly 0 (the
/// <c>FileIoStatsRow</c> / resource-semaphore idiom).</summary>
public bool IsUnknowable => SampleIntervalSeconds == 0;

/// <summary><see cref="CollectionTime"/> (naive UTC) in the display zone, to the second; the "Collected" column sorts by <see cref="CollectionTime"/>.</summary>
public string CollectionTimeLocal => HistoryTime.CollectionLocal(CollectionTime);

/// <summary>The grid's Interval (sec) cell — see <see cref="DeltaSeriesShaping.IntervalDisplay"/>.</summary>
public string IntervalDisplay => DeltaSeriesShaping.IntervalDisplay(SampleIntervalSeconds);
}
Expand All @@ -63,11 +68,15 @@ public sealed record SpinlockStatsSnapshotRow(
long Backoffs,
long? DeltaCollisions,
long? DeltaSpins,
int? SampleIntervalSeconds)
int? SampleIntervalSeconds,
DateTime CollectionTime)
{
/// <summary>True when the row's deltas are the unknowable marker — a stored interval of exactly 0.</summary>
public bool IsUnknowable => SampleIntervalSeconds == 0;

/// <summary><see cref="CollectionTime"/> (naive UTC) in the display zone, to the second; the "Collected" column sorts by <see cref="CollectionTime"/>.</summary>
public string CollectionTimeLocal => HistoryTime.CollectionLocal(CollectionTime);

/// <summary>The grid's Interval (sec) cell — see <see cref="DeltaSeriesShaping.IntervalDisplay"/>.</summary>
public string IntervalDisplay => DeltaSeriesShaping.IntervalDisplay(SampleIntervalSeconds);
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -334,9 +346,21 @@ public async Task<List<SpinlockStatsSnapshotRow>> 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;
}

/// <summary>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).</summary>
public Task<DateTime?> GetLatchStatsDataStartAsync(int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) =>
DataWindowFloor.GetForServerAsync(_dataSource, DataWindowFloor.Source.ForCollectorTable("latch_stats"), serverId, startUtc, endUtc,
ViewerCommandDeadlines.CurrentInteractiveReadSeconds, cancellationToken);

/// <summary>The spinlock_stats twin of <see cref="GetLatchStatsDataStartAsync"/>.</summary>
public Task<DateTime?> GetSpinlockStatsDataStartAsync(int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) =>
DataWindowFloor.GetForServerAsync(_dataSource, DataWindowFloor.Source.ForCollectorTable("spinlock_stats"), serverId, startUtc, endUtc,
ViewerCommandDeadlines.CurrentInteractiveReadSeconds, cancellationToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,9 @@ public class RunningJobRow

public string StartTimeLocal => ViewerDataService.FormatServerClock(StartTime);

/// <summary><see cref="CollectionTime"/> (naive UTC) in the display zone, to the second; the "Collected" column sorts by <see cref="CollectionTime"/>.</summary>
public string CollectionTimeLocal => HistoryTime.CollectionLocal(CollectionTime);

public string CurrentDurationFormatted => FormatDuration(CurrentDurationSeconds);
public string AvgDurationFormatted => FormatDuration(AvgDurationSeconds);
public string P95DurationFormatted => FormatDuration(P95DurationSeconds);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "";
Expand Down
Loading
Loading