diff --git a/Lite.Tests/OverviewBlockingLaneDataStartLiveTests.cs b/Lite.Tests/OverviewBlockingLaneDataStartLiveTests.cs new file mode 100644 index 0000000000..ce7ec2ac3f --- /dev/null +++ b/Lite.Tests/OverviewBlockingLaneDataStartLiveTests.cs @@ -0,0 +1,205 @@ +/* + * 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.IO; +using System.Threading; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using PerformanceMonitorLite.Controls; +using PerformanceMonitorLite.Database; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// +/// The Overview blocking chart's "Showing since" note (#4966) against a real store, through the same step the control runs +/// (): the notice, the quiet start, one series answering, a failed probe and a short window. +/// Anchors are fixed so the tests never straddle a day boundary. +/// +[Collection("server-time-helper")] +public sealed class OverviewBlockingLaneDataStartLiveTests : IDisposable +{ + private const int ServerId = 4966; + private const string ServerName = "OverviewBlockingServer"; + private static readonly DateTime End = new(2026, 9, 20, 12, 0, 0, DateTimeKind.Unspecified); + private readonly string _tempDir; + private readonly DuckDbInitializer _duckDb; + private long _nextId = 1; + + public OverviewBlockingLaneDataStartLiveTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "OverviewBlockingLane_" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(_tempDir); + _duckDb = new DuckDbInitializer(Path.Combine(_tempDir, "test.duckdb")); + } + + public void Dispose() + { + try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); } + catch { /* best-effort cleanup */ } + } + + private static string CollectorOf(QueryWindowRelation relation) => + relation == QueryWindowRelation.BlockedProcessReports ? "blocked_process_report" : "deadlocks"; + + private async Task SeedLogRunsAsync(QueryWindowRelation relation, DateTime firstUtc, DateTime lastUtc) + { + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + using var readLock = _duckDb.AcquireReadLock(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +INSERT INTO collection_log (log_id, server_id, server_name, collector_name, collection_time, duration_ms, status, rows_collected) +SELECT $1 + row_number() OVER (), $2, $3, $4, g.t, 12, 'SUCCESS', 0 +FROM generate_series($5::TIMESTAMP, $6::TIMESTAMP, INTERVAL 30 MINUTE) AS g(t)"; + cmd.Parameters.Add(new DuckDBParameter { Value = _nextId }); + cmd.Parameters.Add(new DuckDBParameter { Value = ServerId }); + cmd.Parameters.Add(new DuckDBParameter { Value = ServerName }); + cmd.Parameters.Add(new DuckDBParameter { Value = CollectorOf(relation) }); + cmd.Parameters.Add(new DuckDBParameter { Value = firstUtc }); + cmd.Parameters.Add(new DuckDBParameter { Value = lastUtc }); + _nextId += await cmd.ExecuteNonQueryAsync() + 1; + } + + private static string SinceText(DateTime instant) => + "Showing since " + PerformanceMonitor.Ui.DisplayZone.Format(instant, TimeZoneInfo.Utc, "yyyy-MM-dd HH:mm:ss"); + + private static TrendPoint Bar(DateTime at, int count = 1) => new() { Time = at, Count = count }; + + private async Task<(bool Visible, string Text)> NoteAsync( + DateTime startUtc, DateTime endUtc, IEnumerable blockingBars, IEnumerable deadlockBars, + Func>? floorOf = null) + { + var service = new LocalDataService(_duckDb); + floorOf ??= relation => service.GetQueryWindowFloorAsync(relation, ServerId, startUtc, endUtc); + /* The real note step, end to end, on an STA thread: the banner is a WPF object. No sync context there, so the blocking wait is safe. */ + return OnStaThread(() => + { + var banner = new System.Windows.Controls.TextBlock(); + LiteBlockingLaneDataStart.ShowAsync(banner, floorOf, startUtc, endUtc, blockingBars, deadlockBars, TimeZoneInfo.Utc).GetAwaiter().GetResult(); + return (banner.Visibility == System.Windows.Visibility.Visible, banner.Text); + }); + } + + /// Blocking was collected from 2 days back, deadlocks from 3: the one note names the later start, in the chart's zone. + [Fact] + public async Task Notice_NamesTheLaterOfTheTwoSeriesStarts() + { + await _duckDb.InitializeAsync(); + var blockingFrom = End.AddDays(-2); + await SeedLogRunsAsync(QueryWindowRelation.BlockedProcessReports, blockingFrom, End); + await SeedLogRunsAsync(QueryWindowRelation.Deadlocks, End.AddDays(-3), End); + + var (visible, text) = await NoteAsync(End.AddDays(-7), End, [], []); + + Assert.True(visible); + Assert.Equal(SinceText(blockingFrom), text); + } + + /// Both collectors ran across the whole range and the first bars come late: no note, and a zero bucket changes nothing. + [Fact] + public async Task QuietStart_BothCollectorsCoveredTheRange_ShowsNoNote() + { + await _duckDb.InitializeAsync(); + await SeedLogRunsAsync(QueryWindowRelation.BlockedProcessReports, End.AddDays(-9), End); + await SeedLogRunsAsync(QueryWindowRelation.Deadlocks, End.AddDays(-9), End); + + var (visible, text) = await NoteAsync( + End.AddDays(-7), End, [Bar(End.AddDays(-7).AddHours(5)), Bar(End.AddHours(-1))], [Bar(End.AddDays(-7), 0), Bar(End.AddHours(-2))]); + + Assert.False(visible); + Assert.Equal(string.Empty, text); + } + + /// One collector covers the range and the other began 2 days ago: the later start is named, not the covered one. + [Fact] + public async Task OneSeriesCovered_TheOtherStartedLate_NamesTheLateOne() + { + await _duckDb.InitializeAsync(); + var lateFrom = End.AddDays(-2); + await SeedLogRunsAsync(QueryWindowRelation.BlockedProcessReports, End.AddDays(-9), End); + await SeedLogRunsAsync(QueryWindowRelation.Deadlocks, lateFrom, End); + + var (visible, text) = await NoteAsync(End.AddDays(-7), End, [], []); + + Assert.True(visible); + Assert.Equal(SinceText(lateFrom), text); + } + + /// A probe that throws costs only its own series' start; the other still names the note. + [Fact] + public async Task AFailedProbe_CostsOnlyItsSeries() + { + await _duckDb.InitializeAsync(); + var from = End.AddDays(-2); + await SeedLogRunsAsync(QueryWindowRelation.Deadlocks, from, End); + var service = new LocalDataService(_duckDb); + + var (visible, text) = await NoteAsync(End.AddDays(-7), End, [], [], + relation => relation == QueryWindowRelation.BlockedProcessReports + ? Task.FromException(new InvalidOperationException("probe failed")) + : service.GetQueryWindowFloorAsync(relation, ServerId, End.AddDays(-7), End)); + + Assert.True(visible); + Assert.Equal(SinceText(from), text); + } + + /// A failed blocking probe with a late bar names no start; the covered deadlock series starts at the window start, so no note. + [Fact] + public async Task AFailedProbe_WithALateBar_AndACoveredOtherSeries_ShowsNoNote() + { + await _duckDb.InitializeAsync(); + await SeedLogRunsAsync(QueryWindowRelation.Deadlocks, End.AddDays(-9), End); + var service = new LocalDataService(_duckDb); + + var (visible, text) = await NoteAsync(End.AddDays(-7), End, [Bar(End.AddHours(-1))], [], + relation => relation == QueryWindowRelation.BlockedProcessReports + ? Task.FromException(new InvalidOperationException("probe failed")) + : service.GetQueryWindowFloorAsync(relation, ServerId, End.AddDays(-7), End)); + + Assert.False(visible); + Assert.Equal(string.Empty, text); + } + + /// A window of 90 minutes or less starts no probe, even on a store that would earn a note. + [Fact] + public async Task AShortWindow_StartsNoProbe_AndShowsNoNote() + { + await _duckDb.InitializeAsync(); + var probes = 0; + + var (visible, _) = await NoteAsync(End.AddMinutes(-90), End, [], [], relation => { probes++; return Task.FromResult(End); }); + + Assert.False(visible); + Assert.Equal(0, probes); + } + + private static T OnStaThread(Func body) + { + T result = default!; + Exception? error = null; + var thread = new Thread(() => + { + try { result = body(); } + catch (Exception ex) { error = ex; } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + + if (error is not null) + { + throw error; + } + + return result; + } +} diff --git a/Lite.Tests/OverviewBlockingLaneDataStartPinTests.cs b/Lite.Tests/OverviewBlockingLaneDataStartPinTests.cs new file mode 100644 index 0000000000..52896feaff --- /dev/null +++ b/Lite.Tests/OverviewBlockingLaneDataStartPinTests.cs @@ -0,0 +1,101 @@ +/* + * 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.IO; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// +/// How the Overview's blocking chart is wired to say where its data starts (#4966), read from the source because the control needs a +/// window to run. The choice is in OverviewBlockingLaneDataStartTests, the notice in OverviewBlockingLaneDataStartLiveTests. +/// +public sealed class OverviewBlockingLaneDataStartPinTests +{ + private static string Code() => Read("CorrelatedTimelineLanesControl.xaml.cs"); + + private static string Xaml() => Read("CorrelatedTimelineLanesControl.xaml"); + + private static string Step() => Read("LiteBlockingLaneDataStart.cs"); + + private static string Between(string source, string from, string to) + { + var start = source.IndexOf(from, StringComparison.Ordinal); + Assert.True(start >= 0, from + " was not found"); + var end = source.IndexOf(to, start, StringComparison.Ordinal); + Assert.True(end > start, to + " was not found after " + from); + return source[start..end]; + } + + [Fact] + public void RefreshAsync_RunsTheNoteAfterTheLanesAreDrawn_OutsideTheReadsWhenAll() + { + var refresh = Between(Code(), "public async Task RefreshAsync(", "private async Task ShowBlockingLaneDataStartAsync("); + + var whenAll = Between(refresh, "await Task.WhenAll(", ");"); + Assert.DoesNotContain("DataStart", whenAll, StringComparison.Ordinal); + + var drawn = refresh.IndexOf("UpdateBlockingLane(blockingData", StringComparison.Ordinal); + var ghost = refresh.IndexOf("AddGhostLine(BlockingChart", StringComparison.Ordinal); + var axes = refresh.IndexOf("SyncXAxes(hoursBack", StringComparison.Ordinal); + var note = refresh.IndexOf("await ShowBlockingLaneDataStartAsync(", StringComparison.Ordinal); + Assert.True(drawn > 0 && ghost > drawn && axes > ghost && note > axes, "the lanes, ghost lines and axes come before the note"); + Assert.Single(Regex.Matches(refresh, "ShowBlockingLaneDataStartAsync")); + } + + [Fact] + public void TheWindow_IsTheOneTheReadsTake_AndTheNoteIsFedFromBothSeriesBars() + { + var refresh = Between(Code(), "public async Task RefreshAsync(", "private async Task ShowBlockingLaneDataStartAsync("); + Assert.Contains("var (windowStartUtc, windowEndUtc) = LocalDataService.GetTimeRange(hoursBack, fromDate, toDate, asOfUtc: null);", refresh, StringComparison.Ordinal); + Assert.Contains("GetBlockingTrendAsync(_serverId, hoursBack, fromDate, toDate)", refresh, StringComparison.Ordinal); + Assert.Contains("GetDeadlockTrendAsync(_serverId, hoursBack, fromDate, toDate)", refresh, StringComparison.Ordinal); + + var call = Between(refresh, "await ShowBlockingLaneDataStartAsync(", ");"); + Assert.Contains("windowStartUtc, windowEndUtc", call, StringComparison.Ordinal); + Assert.Contains("blockingTask.Result", call, StringComparison.Ordinal); + Assert.Contains("deadlockTask.Result", call, StringComparison.Ordinal); + + var wrapper = Between(Code(), "private async Task ShowBlockingLaneDataStartAsync(", "private void UpdateBlockingLane("); + Assert.Contains("LiteBlockingLaneDataStart.ShowAsync(", wrapper, StringComparison.Ordinal); + Assert.Contains("BlockingLaneDataStartBanner", wrapper, StringComparison.Ordinal); + Assert.Contains("GetQueryWindowFloorAsync(relation, _serverId, startUtc, endUtc)", wrapper, StringComparison.Ordinal); + Assert.Contains("_displayZone()", wrapper, StringComparison.Ordinal); + } + + [Fact] + public void TheStep_ProbesBothRelations_ThroughTheShortWindowGuard_AndWordsTheBanner() + { + var step = Between(Step(), "internal static async Task ShowAsync(", "/// One series' start:"); + Assert.DoesNotContain("ProbeWindowFloorOrNullAsync", step, StringComparison.Ordinal); + Assert.True(step.IndexOf("McpQueryTools.CanWindowBeTruncated(startUtc, endUtc)", StringComparison.Ordinal) + < step.IndexOf("Probe(floorOf, QueryWindowRelation.BlockedProcessReports)", StringComparison.Ordinal)); + Assert.Contains("Probe(floorOf, QueryWindowRelation.Deadlocks)", step, StringComparison.Ordinal); + Assert.Contains("StartAsync(floorOf, startUtc, endUtc, blockingBars, deadlockBars)", step, StringComparison.Ordinal); + Assert.Contains("ChooseAsync(blockingProbe, deadlockProbe, blockingBars, deadlockBars)", step, StringComparison.Ordinal); + Assert.Contains("ServerTab.ApplyWindowFloorToBanner(banner, start, startUtc, zone)", step, StringComparison.Ordinal); + Assert.Contains("bar.Count > 0", Step(), StringComparison.Ordinal); + } + + [Fact] + public void TheBanner_IsDeclaredInAnAutoRowAboveTheChart_AndOnlyOnTheBlockingLane() + { + var chart = Between(Xaml(), "", " + + + + + - + diff --git a/Lite/Controls/CorrelatedTimelineLanesControl.xaml.cs b/Lite/Controls/CorrelatedTimelineLanesControl.xaml.cs index a65d08e2fd..2d1b797c67 100644 --- a/Lite/Controls/CorrelatedTimelineLanesControl.xaml.cs +++ b/Lite/Controls/CorrelatedTimelineLanesControl.xaml.cs @@ -196,6 +196,9 @@ public async Task RefreshAsync(int hoursBack, DateTime? fromDate, DateTime? toDa { _crosshairManager?.PrepareForRefresh(); + /* #4966: the UTC window the blocking and deadlock reads below take (their own GetTimeRange call), for the data-start note. */ + var (windowStartUtc, windowEndUtc) = LocalDataService.GetTimeRange(hoursBack, fromDate, toDate, asOfUtc: null); + var cpuTask = Task.Run(() => _dataService.GetCpuUtilizationAsync(_serverId, hoursBack, fromDate, toDate, frame: CpuTimeFrame.Utc)); var waitTask = Task.Run(() => _dataService.GetTotalWaitTrendAsync(_serverId, hoursBack, fromDate, toDate)); var blockingTask = Task.Run(() => _dataService.GetBlockingTrendAsync(_serverId, hoursBack, fromDate, toDate)); @@ -346,6 +349,14 @@ Each ghost row goes back onto the current axis by the same rule in reverse (Time the render set when the chart refreshes. */ _crosshairManager?.ReattachVLines(); SyncXAxes(hoursBack, fromDate, toDate); + + /* #4966: the blocking chart draws event counts, so an empty stretch reads as "nothing happened" and the chart says + where its data starts. The note comes last, after every lane and the ghost lines are drawn and synced, and on its + own: the probes are not part of the reads' WhenAll above, and a probe that fails costs the note, never the bars. */ + await ShowBlockingLaneDataStartAsync( + windowStartUtc, windowEndUtc, + blockingTask.IsCompletedSuccessfully ? blockingTask.Result : [], + deadlockTask.IsCompletedSuccessfully ? deadlockTask.Result : []); } finally { @@ -357,6 +368,21 @@ the render set when the chart refreshes. */ } } + /// + /// Raises or hides the blocking chart's "Showing since" note (#4966). The chart's two series, blocking and deadlocks, are + /// event counts: an empty stretch before a series starts reads as "nothing happened". A window of 90 minutes or less + /// starts no probe (). The text goes through the shared banner step in + /// the lanes' own clock (_displayZone, which is the tab's GetPickerZone), to the second. + /// + private async Task ShowBlockingLaneDataStartAsync( + DateTime startUtc, DateTime endUtc, IReadOnlyList blockingBars, IReadOnlyList deadlockBars) + { + await LiteBlockingLaneDataStart.ShowAsync( + BlockingLaneDataStartBanner, + relation => Task.Run(() => _dataService!.GetQueryWindowFloorAsync(relation, _serverId, startUtc, endUtc)), + startUtc, endUtc, blockingBars, deadlockBars, _displayZone()); + } + private void UpdateBlockingLane(List<(double Time, double Value)> blockingData, List<(double Time, double Value)> deadlockData, BaselineBucket? baseline = null) { diff --git a/Lite/Controls/LiteBlockingLaneDataStart.cs b/Lite/Controls/LiteBlockingLaneDataStart.cs new file mode 100644 index 0000000000..e03974dc82 --- /dev/null +++ b/Lite/Controls/LiteBlockingLaneDataStart.cs @@ -0,0 +1,129 @@ +/* + * 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 System.Windows.Controls; +using PerformanceMonitorLite.Mcp; +using PerformanceMonitorLite.Services; + +namespace PerformanceMonitorLite.Controls; + +/// +/// Where the Overview's blocking chart says its data starts (#4966). The chart draws two event-count series, blocking and +/// deadlocks, so an empty stretch before a series starts reads as "nothing happened". Each series starts at the EARLIER of its +/// coverage floor and its earliest bar drawn (a zero bucket is not a bar); the chart has one note, and it names the LATER of +/// the two starts so it is true of both series. A series with no start (no floor, no bar, or a probe that failed with no bar) +/// is left out; when neither has one the chart shows no note. The Darling viewer's twin is ViewerBlockingLaneDataStart. +/// Two rules: a probe that FAILED gives its series no start, whatever its bars (a failure is not evidence of a late start, so +/// the other series answers alone); a probe that ANSWERED null (no collector run in the window) with bars drawn names the first +/// bar, as the viewer does (ViewerEventDataStart.Of). Lite's own Blocking tab differs: it keeps the null there +/// (EarlierOfFloorAndRowShown). +/// +internal static class LiteBlockingLaneDataStart +{ + /// + /// The instant the chart's note names, or null when it names nothing. A probe that throws costs its series' start and + /// nothing else: it is logged and the other series answers alone. + /// + internal static async Task ChooseAsync( + Task blockingProbe, Task deadlockProbe, + IEnumerable blockingBars, IEnumerable deadlockBars) + { + ArgumentNullException.ThrowIfNull(blockingProbe); + ArgumentNullException.ThrowIfNull(deadlockProbe); + ArgumentNullException.ThrowIfNull(blockingBars); + ArgumentNullException.ThrowIfNull(deadlockBars); + + var blocking = await AnswerAsync(blockingProbe, "blocking"); + var deadlock = await AnswerAsync(deadlockProbe, "deadlocks"); + + return Later( + blocking.Failed ? null : SeriesStart(blocking.Floor, blockingBars), + deadlock.Failed ? null : SeriesStart(deadlock.Floor, deadlockBars)); + } + + /// + /// The note step: chooses the start the note names, then words the banner in + /// (). The banner is touched on the caller's thread only, after the start is chosen. + /// + internal static async Task ShowAsync( + TextBlock banner, Func> floorOf, DateTime startUtc, DateTime endUtc, + IEnumerable blockingBars, IEnumerable deadlockBars, TimeZoneInfo zone) + { + var start = await StartAsync(floorOf, startUtc, endUtc, blockingBars, deadlockBars); + ServerTab.ApplyWindowFloorToBanner(banner, start, startUtc, zone); + } + + /// + /// Probes BOTH relations over the window the chart's reads took (a window of 90 minutes or less starts no probe, and a probe + /// that throws costs only its series' start) and chooses the start. is + /// LocalDataService.GetQueryWindowFloorAsync for the relation. Touches no WPF object, so the live-store tests drive it. + /// + internal static async Task StartAsync( + Func> floorOf, DateTime startUtc, DateTime endUtc, + IEnumerable blockingBars, IEnumerable deadlockBars) + { + if (!McpQueryTools.CanWindowBeTruncated(startUtc, endUtc)) + { + return null; + } + + /* The shared floor-or-null wrapper is not used here: it turns a throw into null, and null here would read as "no collector run". */ + var blockingProbe = Probe(floorOf, QueryWindowRelation.BlockedProcessReports); + var deadlockProbe = Probe(floorOf, QueryWindowRelation.Deadlocks); + + return await ChooseAsync(blockingProbe, deadlockProbe, blockingBars, deadlockBars); + } + + /// One series' start: the earlier of its floor and its earliest bar with a count above zero; whichever exists; null when neither. + internal static DateTime? SeriesStart(DateTime? floor, IEnumerable bars) + { + DateTime? firstBar = null; + foreach (var bar in bars) + { + if (bar.Count > 0 && (firstBar is null || bar.Time < firstBar.Value)) + { + firstBar = bar.Time; + } + } + + return floor is DateTime f && firstBar is DateTime b ? (b < f ? b : f) : floor ?? firstBar; + } + + /// The later of two series' starts; the one that answered when only one did; null when neither did. + internal static DateTime? Later(DateTime? blocking, DateTime? deadlock) => + blocking.HasValue && deadlock.HasValue ? (blocking.Value >= deadlock.Value ? blocking : deadlock) : blocking ?? deadlock; + + private static Task Probe(Func> floorOf, QueryWindowRelation relation) + { + try + { + return floorOf(relation); + } + catch (Exception ex) + { + return Task.FromException(ex); + } + } + + private static async Task<(bool Failed, DateTime? Floor)> AnswerAsync(Task probe, string series) + { + try + { + return (false, await probe); + } + catch (Exception ex) + { + AppLogger.Warn("CorrelatedLanes", $"Overview blocking chart ({series}): the data-start probe failed, so that series names no start: {ex.Message}"); + return (true, null); + } + } +}