From 634641f97188e16c418471b9cc98cc110417321c Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:32:09 -0400 Subject: [PATCH 1/9] Data-start notice: window-floor relations and tests for the Collection Log, System Events, Config Changes and Long Queries grids (#4966) The probe gains seven relations, each with its view and the collector whose runs show the store covered a quiet start, and a time column map for the three config snapshots, which are stamped capture_time. The tests pin, per surface, a notice for rows that start inside the range and no notice for a quiet start, plus the banner wiring and the surfaces that need none. --- Lite.Tests/DataStartBannerSurfaceTests.cs | 355 ++++++++++++++++++ Lite.Tests/DataStartBannerTests.cs | 14 +- .../LocalDataService.QueryWindowFloor.cs | 62 ++- 3 files changed, 417 insertions(+), 14 deletions(-) create mode 100644 Lite.Tests/DataStartBannerSurfaceTests.cs diff --git a/Lite.Tests/DataStartBannerSurfaceTests.cs b/Lite.Tests/DataStartBannerSurfaceTests.cs new file mode 100644 index 0000000000..891a35019b --- /dev/null +++ b/Lite.Tests/DataStartBannerSurfaceTests.cs @@ -0,0 +1,355 @@ +/* + * 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.Globalization; +using System.IO; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using PerformanceMonitorLite.Controls; +using PerformanceMonitorLite.Database; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// +/// #4966: the "Showing since" notice on the Collection Log, the nine System Events grids that read stored events +/// (Scheduler Issues, Severe Errors, Memory Conditions, Memory Broker, Memory Node OOM, Significant Waits, CPU Tasks, +/// I/O Issues and Default Trace), the three Config Changes grids and Long Queries. Each one reads its rows over the +/// toolbar's window, so a window that starts before the store's coverage drew a shorter range with no notice. The +/// notice comes from the ONE shared probe () and the ONE banner +/// step () the Queries tab, Active Queries and Current Waits use, and +/// each surface has the two tests the issue asks for: rows that start inside the range read the notice back, and a +/// quiet start (the collector covered the whole range, the first row comes late) shows none. +/// +/// The surfaces with no notice are pinned too (): +/// the Health Summary is a fixed seven-day aggregate that does not read the toolbar's range, and the Duration Trends, +/// Corruption Events and Contention Events charts pin their time axis to the asked range, so an empty span is already +/// drawn as one. +/// +[Collection("server-time-helper")] +public sealed class DataStartBannerSurfaceTests : IDisposable +{ + private const int ServerId = 4343; + private const string ServerName = "SurfaceBannerServer"; + private readonly string _tempDir; + private readonly DuckDbInitializer _duckDb; + private long _nextId = 1; + + public DataStartBannerSurfaceTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "SurfaceBanner_" + 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 */ } + } + + /// One relation per surface that gets a notice. The eight system_health grids share a relation, as they + /// share a collector and a table; the wiring test below pins each grid's own banner and call. + public static TheoryData NoticeRelations => new() + { + QueryWindowRelation.CollectionLog, + QueryWindowRelation.SystemHealthEvents, + QueryWindowRelation.DefaultTraceEvents, + QueryWindowRelation.ServerConfig, + QueryWindowRelation.DatabaseConfig, + QueryWindowRelation.TraceFlags, + QueryWindowRelation.LongQueryCompletions + }; + + private static DateTime Naive(DateTime instant) => DateTime.SpecifyKind(instant, DateTimeKind.Unspecified); + + private static string Literal(DateTime instant) => + "TIMESTAMP '" + Naive(instant).ToString("yyyy-MM-dd HH:mm:ss.ffffff", CultureInfo.InvariantCulture) + "'"; + + /// + /// One row in the relation's table with its time column at . Every other column the table + /// requires (NOT NULL without a default) gets a placeholder of its type, found from the table itself, so the test + /// does not repeat any collector's column list. + /// + private async Task SeedRowAsync(QueryWindowRelation relation, DateTime at) + { + var table = LocalDataService.QueryWindowRelationView(relation)[2..]; + var timeColumn = LocalDataService.QueryWindowRelationTimeColumn(relation); + + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + using var readLock = _duckDb.AcquireReadLock(); + + var names = new List(); + var values = new List(); + using (var info = connection.CreateCommand()) + { + info.CommandText = $"SELECT name, type, \"notnull\", pk, dflt_value IS NOT NULL FROM pragma_table_info('{table}') ORDER BY cid"; + using var reader = await info.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var name = reader.GetString(0); + var type = reader.GetString(1); + var pk = reader.GetBoolean(3); + if (!(reader.GetBoolean(2) || pk) || reader.GetBoolean(4)) + { + continue; + } + + names.Add(name); + values.Add(PlaceholderFor(name, type, pk, timeColumn, at)); + } + } + + using var insert = connection.CreateCommand(); + insert.CommandText = $"INSERT INTO {table} ({string.Join(", ", names)}) VALUES ({string.Join(", ", values)})"; + await insert.ExecuteNonQueryAsync(); + } + + private string PlaceholderFor(string name, string type, bool pk, string timeColumn, DateTime at) + { + if (name == timeColumn) return Literal(at); + if (name == "server_id") return ServerId.ToString(); + if (name == "server_name") return $"'{ServerName}'"; + if (pk) return (_nextId++).ToString(); + + var upper = type.ToUpperInvariant(); + if (upper.StartsWith("VARCHAR", StringComparison.Ordinal)) return "'x'"; + if (upper.StartsWith("TIMESTAMP", StringComparison.Ordinal)) return Literal(at); + if (upper == "BOOLEAN") return "false"; + if (upper.Contains("INT", StringComparison.Ordinal) || upper.StartsWith("DECIMAL", StringComparison.Ordinal) + || upper is "DOUBLE" or "FLOAT" or "REAL") + { + return $"CAST(1 AS {type})"; + } + + return $"CAST('x' AS {type})"; + } + + /// + /// The collector's runs in collection_log, every minutes from + /// to , whether or not they stored a row. + /// + private async Task SeedLogRunsAsync(string collector, DateTime firstUtc, DateTime lastUtc, int everyMinutes) + { + 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 {_nextId} + row_number() OVER (), {ServerId}, '{ServerName}', '{collector}', g.t, 12, 'SUCCESS', 0 +FROM generate_series({Literal(firstUtc)}, {Literal(lastUtc)}, INTERVAL {everyMinutes} MINUTE) AS g(t)"; + _nextId += await cmd.ExecuteNonQueryAsync() + 1; + } + + /// The probe, then the banner step the surface runs on the result: (visible, text). + private async Task<(bool Visible, string Text)> BannerForAsync(QueryWindowRelation relation, DateTime startUtc, DateTime endUtc) + { + var floor = await new LocalDataService(_duckDb).GetQueryWindowFloorAsync(relation, ServerId, startUtc, endUtc); + return OnStaThread(() => + { + var banner = new System.Windows.Controls.TextBlock(); + ServerTab.ApplyWindowFloorToBanner(banner, floor, startUtc, TimeZoneInfo.Utc); + return (banner.Visibility == System.Windows.Visibility.Visible, banner.Text); + }); + } + + /// + /// A range that starts before the first stored row, and before the collector's first run, gets the notice, worded + /// at that first row or run. Covers the config snapshots too, whose time column is capture_time. + /// + [Theory] + [MemberData(nameof(NoticeRelations))] + public async Task RowsStartInsideTheRange_ShowTheNotice(QueryWindowRelation relation) + { + await _duckDb.InitializeAsync(); + var end = DateTime.UtcNow; + var first = end.AddDays(-2); + await SeedRowAsync(relation, first); + await SeedRowAsync(relation, end.AddHours(-1)); + if (LocalDataService.QueryWindowRelationCollector(relation) is { } collector) + { + await SeedLogRunsAsync(collector, first, end, 60); + } + + var (visible, text) = await BannerForAsync(relation, end.AddDays(-7), end); + + Assert.True(visible); + Assert.StartsWith("Showing since ", text, StringComparison.Ordinal); + Assert.Contains(Naive(first).ToString("yyyy-MM-dd HH:"), text, StringComparison.Ordinal); + } + + /// + /// A quiet start is no reason for a notice. The store covered the whole range, but the first row inside it comes + /// three days in: for an event or snapshot table the collector ran for a month and stored nothing near the start, + /// and for the run log itself the oldest row is a month old. + /// + [Theory] + [MemberData(nameof(NoticeRelations))] + public async Task QuietStart_TheStoreCoveredTheRange_ShowsNoNotice(QueryWindowRelation relation) + { + await _duckDb.InitializeAsync(); + var end = DateTime.UtcNow; + if (LocalDataService.QueryWindowRelationCollector(relation) is { } collector) + { + await SeedLogRunsAsync(collector, end.AddDays(-30), end, 180); + } + else + { + await SeedRowAsync(relation, end.AddDays(-30)); + } + + await SeedRowAsync(relation, end.AddDays(-3)); + await SeedRowAsync(relation, end.AddHours(-1)); + + var (visible, text) = await BannerForAsync(relation, end.AddDays(-7), end); + + Assert.False(visible); + Assert.Equal(string.Empty, text); + } + + /// + /// Each relation reads the view its grid reads, by the column that table is purged on, and takes its coverage from + /// the collector whose runs the store logs under that name. The Collection Log is the run log, so it keeps the + /// row-only probe. + /// + [Theory] + [InlineData(QueryWindowRelation.CollectionLog, "v_collection_log", null, "collection_time")] + [InlineData(QueryWindowRelation.SystemHealthEvents, "v_system_health_events", "system_health_events", "collection_time")] + [InlineData(QueryWindowRelation.DefaultTraceEvents, "v_default_trace_events", "default_trace_events", "collection_time")] + [InlineData(QueryWindowRelation.ServerConfig, "v_server_config", "server_config", "capture_time")] + [InlineData(QueryWindowRelation.DatabaseConfig, "v_database_config", "database_config", "capture_time")] + [InlineData(QueryWindowRelation.TraceFlags, "v_trace_flags", "trace_flags", "capture_time")] + [InlineData(QueryWindowRelation.LongQueryCompletions, "v_long_query_completions", "long_query_completions", "collection_time")] + public void Relations_NameTheViewCollectorAndTimeColumnTheirGridReads( + QueryWindowRelation relation, string view, string? collector, string timeColumn) + { + Assert.Equal(view, LocalDataService.QueryWindowRelationView(relation)); + Assert.Equal(collector, LocalDataService.QueryWindowRelationCollector(relation)); + Assert.Equal(timeColumn, LocalDataService.QueryWindowRelationTimeColumn(relation)); + } + + /// + /// The wiring: each surface has its banner TextBlock, and the one method that reads it refreshes the banner after + /// the rows are bound, through the shared helper and over the UTC window the read took. Every read path of these + /// surfaces goes through that method: the sub-tab switch and the toolbar's range change reach the System Events, + /// Config Changes and Long Queries loaders through RefreshVisibleTabAsync, and a loader's own Refresh button + /// calls the same refresh. Comments are stripped first, so a sentence that names the call cannot satisfy the pin. + /// + [Theory] + [InlineData("private async System.Threading.Tasks.Task RefreshCollectionHealthAsync(", "ServerTab.Refresh.cs", "CollectionLog", "CollectionLogWindowTruncatedBanner")] + [InlineData("private async System.Threading.Tasks.Task LoadSchedulerIssuesAsync(", "ServerTab.SystemEvents.cs", "SystemHealthEvents", "SchedulerIssuesWindowTruncatedBanner")] + [InlineData("private async System.Threading.Tasks.Task LoadSevereErrorsAsync(", "ServerTab.SystemEvents.cs", "SystemHealthEvents", "SevereErrorsWindowTruncatedBanner")] + [InlineData("private async System.Threading.Tasks.Task LoadMemoryConditionsAsync(", "ServerTab.SystemEvents.cs", "SystemHealthEvents", "MemoryConditionsWindowTruncatedBanner")] + [InlineData("private async System.Threading.Tasks.Task LoadMemoryBrokerAsync(", "ServerTab.SystemEvents.cs", "SystemHealthEvents", "MemoryBrokerWindowTruncatedBanner")] + [InlineData("private async System.Threading.Tasks.Task LoadMemoryNodeOomAsync(", "ServerTab.SystemEvents.cs", "SystemHealthEvents", "MemoryNodeOomWindowTruncatedBanner")] + [InlineData("private async System.Threading.Tasks.Task LoadSignificantWaitsAsync(", "ServerTab.SystemEvents.cs", "SystemHealthEvents", "SignificantWaitsWindowTruncatedBanner")] + [InlineData("private async System.Threading.Tasks.Task LoadCpuTasksAsync(", "ServerTab.SystemEvents.cs", "SystemHealthEvents", "CpuTasksWindowTruncatedBanner")] + [InlineData("private async System.Threading.Tasks.Task LoadIoIssuesAsync(", "ServerTab.SystemEvents.cs", "SystemHealthEvents", "IoIssuesWindowTruncatedBanner")] + [InlineData("private async System.Threading.Tasks.Task LoadDefaultTraceEventsAsync(", "ServerTab.SystemEvents.cs", "DefaultTraceEvents", "DefaultTraceWindowTruncatedBanner")] + [InlineData("private async System.Threading.Tasks.Task LoadServerConfigChangesAsync(", "ServerTab.ConfigChanges.cs", "ServerConfig", "ServerConfigChangesWindowTruncatedBanner")] + [InlineData("private async System.Threading.Tasks.Task LoadDatabaseConfigChangesAsync(", "ServerTab.ConfigChanges.cs", "DatabaseConfig", "DatabaseConfigChangesWindowTruncatedBanner")] + [InlineData("private async System.Threading.Tasks.Task LoadTraceFlagChangesAsync(", "ServerTab.ConfigChanges.cs", "TraceFlags", "TraceFlagChangesWindowTruncatedBanner")] + [InlineData("private async Task RefreshLongQueriesAsync(", "ServerTab.LongQueries.cs", "LongQueryCompletions", "LongQueriesWindowTruncatedBanner")] + public void EverySurface_HasABanner_RefreshedAfterTheRowsAreBound(string signature, string file, string relation, string banner) + { + var xaml = File.ReadAllText(ControlsFile("ServerTab.xaml")); + Assert.Contains($"x:Name=\"{banner}\"", xaml, StringComparison.Ordinal); + + var code = StripComments(File.ReadAllText(ControlsFile(file)).Replace("\r\n", "\n")); + var start = code.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, $"{signature} is not in {file}"); + var next = Regex.Match(code[(start + signature.Length)..], @"\n (private|internal|public) "); + var body = next.Success ? code.Substring(start, signature.Length + next.Index) : code[start..]; + + var call = $"RefreshStoredWindowBannerAsync(QueryWindowRelation.{relation}, {banner}, hoursBack, fromDate, toDate)"; + Assert.Contains(call, body, StringComparison.Ordinal); + var bind = body.IndexOf("UpdateData(", StringComparison.Ordinal); + Assert.True(bind >= 0 && bind < body.IndexOf(call, StringComparison.Ordinal), + $"{banner} must be refreshed after the grid's rows are bound"); + } + + /// + /// The one helper every group surface calls: it takes the window the grids' reads take + /// (, the same GetTimeRange call) and hands it to the + /// shared banner step, which hides the banner when the probe throws instead of unwinding the refresh. + /// + [Fact] + public void TheBannerHelper_ProbesTheWindowTheGridsReadThroughTheSharedStep() + { + var code = StripComments(File.ReadAllText(ControlsFile("ServerTab.SystemEvents.cs")).Replace("\r\n", "\n")); + var start = code.IndexOf("RefreshStoredWindowBannerAsync(QueryWindowRelation relation,", StringComparison.Ordinal); + Assert.True(start >= 0); + var body = code[start..Math.Min(code.Length, start + 600)]; + Assert.Contains("LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate)", body, StringComparison.Ordinal); + Assert.Contains("RefreshWindowTruncatedBannerAsync(relation, banner, startUtc, endUtc)", body, StringComparison.Ordinal); + } + + /// + /// The surfaces that need no notice, and the shape that makes it true. The Health Summary reads a fixed seven + /// days per collector, with no range parameter, so a picked range never starts before its data. The Duration + /// Trends, Corruption Events and Contention Events charts set their X axis to the asked range, so a span with no + /// data is drawn as the empty span it is. If one of them starts reading the toolbar's range as a grid, or stops + /// pinning its axis, it needs a notice and this test says so. + /// + [Fact] + public void SurfacesWithoutANotice_KeepTheShapeThatNeedsNone() + { + var health = File.ReadAllText(RepoFile("Lite", "Services", "LocalDataService.CollectionHealth.cs")); + Assert.Contains("public async Task> GetCollectionHealthAsync(int serverId)", health, StringComparison.Ordinal); + Assert.Contains("GetCollectionHealthAsync(_serverId)", File.ReadAllText(ControlsFile("ServerTab.Refresh.cs")), StringComparison.Ordinal); + + var durationChart = Regex.Match(File.ReadAllText(ControlsFile("ServerTab.Charts.cs")).Replace("\r\n", "\n"), + @"private void UpdateCollectorDurationChart\(.*?\n \}\n", RegexOptions.Singleline); + Assert.True(durationChart.Success); + Assert.Contains("SetLimitsX(xMin, xMax)", durationChart.Value, StringComparison.Ordinal); + + var systemCharts = File.ReadAllText(ControlsFile("ServerTab.SystemHealthCharts.cs")); + Assert.Contains("ChartPalette.CyclingColor(3), xMin, xMax)", systemCharts, StringComparison.Ordinal); + Assert.Contains("RenderSickSpinlocksChart(SickSpinlocksChart, _sickSpinlocksHover, data, xMin, xMax)", systemCharts, StringComparison.Ordinal); + Assert.Contains("RenderCpuComparisonChart(CpuComparisonChart, _cpuComparisonHover, data, xMin, xMax)", systemCharts, StringComparison.Ordinal); + Assert.Equal(3, Regex.Matches(File.ReadAllText(RepoFile("PerformanceMonitor.Ui", "SystemHealthChartRenderer.cs")), + Regex.Escape("chart.Plot.Axes.SetLimitsX(xMin, xMax)")).Count); + } + + private static string StripComments(string lfSource) => + Regex.Replace(Regex.Replace(lfSource, @"/\*.*?\*/", string.Empty, RegexOptions.Singleline), @"//[^\n]*", string.Empty); + + /// WPF objects require STA; same shape as DataStartBannerTests. + 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; + } + + private static string ControlsFile(string name) => RepoFile("Lite", "Controls", name); + + private static string RepoFile(string folder, string subFolderOrFile, string? file = null, [CallerFilePath] string thisFile = "") => + Path.GetFullPath(Path.Combine(Path.GetDirectoryName(thisFile)!, "..", folder, subFolderOrFile, file ?? string.Empty)); +} diff --git a/Lite.Tests/DataStartBannerTests.cs b/Lite.Tests/DataStartBannerTests.cs index f94ebe7d7b..73b1679c3e 100644 --- a/Lite.Tests/DataStartBannerTests.cs +++ b/Lite.Tests/DataStartBannerTests.cs @@ -125,11 +125,14 @@ INSERT INTO collection_log (log_id, server_id, server_name, collector_name, coll /// /// The relation list is a closed enum, so no view name ever comes from the caller. Each member must name a real - /// archive view (v_ plus a table in the archivable set) whose time column is collection_time, with - /// a server_id column, which is what the probe's SQL assumes. + /// archive view (v_ plus a table in the archivable set) whose time column is the one + /// names for it (collection_time, or + /// capture_time on the three config snapshots, #4966), with a server_id column, which is what the + /// probe's SQL assumes. The archive purges each table by that same column, so the probe's window and the + /// retention edge are measured on one clock. /// [Fact] - public async Task EveryRelation_NamesARealArchiveView_WithServerIdAndCollectionTime() + public async Task EveryRelation_NamesARealArchiveView_WithServerIdAndItsTimeColumn() { await _duckDb.InitializeAsync(); foreach (var relation in Enum.GetValues()) @@ -137,14 +140,15 @@ public async Task EveryRelation_NamesARealArchiveView_WithServerIdAndCollectionT var view = LocalDataService.QueryWindowRelationView(relation); Assert.StartsWith("v_", view, StringComparison.Ordinal); var table = view[2..]; + var timeColumn = LocalDataService.QueryWindowRelationTimeColumn(relation); Assert.Contains(table, DuckDbInitializer.ArchivableTables); - Assert.Contains(ArchiveService.ArchivableTables, t => t.Table == table && t.TimeColumn == "collection_time"); + Assert.Contains(ArchiveService.ArchivableTables, t => t.Table == table && t.TimeColumn == timeColumn); using var connection = _duckDb.CreateConnection(); await connection.OpenAsync(); using var readLock = _duckDb.AcquireReadLock(); using var cmd = connection.CreateCommand(); - cmd.CommandText = $"SELECT COUNT(*) FROM information_schema.columns WHERE table_name = '{view}' AND column_name IN ('server_id', 'collection_time')"; + cmd.CommandText = $"SELECT COUNT(*) FROM information_schema.columns WHERE table_name = '{view}' AND column_name IN ('server_id', '{timeColumn}')"; Assert.Equal(2L, Convert.ToInt64(await cmd.ExecuteScalarAsync())); } } diff --git a/Lite/Services/LocalDataService.QueryWindowFloor.cs b/Lite/Services/LocalDataService.QueryWindowFloor.cs index 6b6df7d39a..d19f9565f8 100644 --- a/Lite/Services/LocalDataService.QueryWindowFloor.cs +++ b/Lite/Services/LocalDataService.QueryWindowFloor.cs @@ -14,7 +14,8 @@ namespace PerformanceMonitorLite.Services; /// /// The relations a window-floor probe () may read. Each is a -/// closed member that maps to ONE v_ view with server_id and collection_time columns, so no +/// closed member that maps to ONE v_ view with server_id and a time column (collection_time, or +/// capture_time on the three config snapshots, ), so no /// caller hands a view name, and nothing a user typed, into the probe's SQL. #4231 began with the three RAW-ONLY /// relations (, , ): every /// Queries-tab grid and MCP tool that reads one has no rollup underneath it to fall back on when the requested @@ -29,6 +30,16 @@ public enum QueryWindowRelation ProcedureStats, QueryStoreStats, QuerySnapshots, + + /* Group C of #4966: Collection Health, System Events, Config Changes and Long Queries. */ + CollectionLog, + SystemHealthEvents, + DefaultTraceEvents, + ServerConfig, + DatabaseConfig, + TraceFlags, + LongQueryCompletions, + WaitingTasks } @@ -40,23 +51,55 @@ public partial class LocalDataService QueryWindowRelation.ProcedureStats => "v_procedure_stats", QueryWindowRelation.QueryStoreStats => "v_query_store_stats", QueryWindowRelation.QuerySnapshots => "v_query_snapshots", + /* Group C of #4966: Collection Health, System Events, Config Changes and Long Queries. */ + QueryWindowRelation.CollectionLog => "v_collection_log", + QueryWindowRelation.SystemHealthEvents => "v_system_health_events", + QueryWindowRelation.DefaultTraceEvents => "v_default_trace_events", + QueryWindowRelation.ServerConfig => "v_server_config", + QueryWindowRelation.DatabaseConfig => "v_database_config", + QueryWindowRelation.TraceFlags => "v_trace_flags", + QueryWindowRelation.LongQueryCompletions => "v_long_query_completions", QueryWindowRelation.WaitingTasks => "v_waiting_tasks", _ => throw new ArgumentOutOfRangeException(nameof(relation), relation, "unknown QueryWindowRelation") }; /// /// The collector whose runs collection_log records for a relation the probe reads by coverage - /// (, ), or null for - /// the three Queries-tab relations, which keep the row-only probe. A closed map, so nothing a caller passes - /// reaches the probe's SQL. + /// (, , and #4966's + /// System Events, Config Changes and Long Queries relations), or null for the three Queries-tab relations and + /// , which keep the row-only probe. A closed map, so nothing a + /// caller passes reaches the probe's SQL. /// internal static string? QueryWindowRelationCollector(QueryWindowRelation relation) => relation switch { QueryWindowRelation.QuerySnapshots => "query_snapshots", QueryWindowRelation.WaitingTasks => "waiting_tasks", + /* Group C of #4966. Every event and snapshot table below holds a row only when something happened or changed + (system_health and default trace events, long-query completions, and the config snapshots, which the + collectors take at load), so a collector's runs are what show the store covered a quiet start. + CollectionLog is the run log itself, one dense row per run, so it keeps the row-only probe. */ + QueryWindowRelation.SystemHealthEvents => "system_health_events", + QueryWindowRelation.DefaultTraceEvents => "default_trace_events", + QueryWindowRelation.ServerConfig => "server_config", + QueryWindowRelation.DatabaseConfig => "database_config", + QueryWindowRelation.TraceFlags => "trace_flags", + QueryWindowRelation.LongQueryCompletions => "long_query_completions", _ => null }; + /// + /// The time column of a relation's view that the probe compares against the window (#4966): collection_time + /// for every relation except the three config snapshots, whose collectors stamp capture_time instead + /// (ICollectorSchemaInfo.PrefixTimeColumnName, the column the archive purges by too). A closed map, so nothing a + /// caller passes reaches the probe's SQL. The collector's own runs in v_collection_log always read + /// collection_time. + /// + internal static string QueryWindowRelationTimeColumn(QueryWindowRelation relation) => relation switch + { + QueryWindowRelation.ServerConfig or QueryWindowRelation.DatabaseConfig or QueryWindowRelation.TraceFlags => "capture_time", + _ => "collection_time" + }; + /// /// Where this server's data starts for the requested window, as far as any caller needs to know it. NULL when /// the server holds no row inside [, ] at all (nothing was @@ -115,6 +158,7 @@ public partial class LocalDataService using var connection = await OpenConnectionAsync(); var collector = QueryWindowRelationCollector(relation); + var timeColumn = QueryWindowRelationTimeColumn(relation); DateTime? firstInWindow; using (var windowCommand = connection.CreateCommand()) { @@ -122,14 +166,14 @@ public partial class LocalDataService LEAST skips a NULL, so either one alone answers. */ windowCommand.CommandText = collector is null ? $@" -SELECT MIN(collection_time) +SELECT MIN({timeColumn}) FROM {view} WHERE server_id = $1 -AND collection_time >= $2 -AND collection_time <= $3" +AND {timeColumn} >= $2 +AND {timeColumn} <= $3" : $@" SELECT LEAST( - (SELECT MIN(collection_time) FROM {view} WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3), + (SELECT MIN({timeColumn}) FROM {view} WHERE server_id = $1 AND {timeColumn} >= $2 AND {timeColumn} <= $3), (SELECT MIN(collection_time) FROM v_collection_log WHERE server_id = $1 AND collector_name = '{collector}' AND collection_time >= $2 AND collection_time <= $3))"; windowCommand.Parameters.Add(new DuckDBParameter { Value = serverId }); windowCommand.Parameters.Add(new DuckDBParameter { Value = startUtc }); @@ -148,7 +192,7 @@ SELECT LEAST( SELECT 1 FROM {view} WHERE server_id = $1 -AND collection_time < $2 +AND {timeColumn} < $2 LIMIT 1"; olderCommand.Parameters.Add(new DuckDBParameter { Value = serverId }); olderCommand.Parameters.Add(new DuckDBParameter { Value = startUtc }); From 436860f8d73f1857be7429bb43ca47e4deab6624 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:34:12 -0400 Subject: [PATCH 2/9] Collection Log, System Events, Config Changes and Long Queries say where the data starts when the range reaches before it (#4966) Each of the 14 grids that read stored rows over the toolbar's range now has a Showing since banner beside it, refreshed after the rows are bound from the one method its read runs through, so the sub-tab switch, a range change and the Refresh button all reach it. The Health Summary (a fixed seven-day aggregate) and the Duration Trends, Corruption Events and Contention Events charts (axis pinned to the range) need none. --- Lite/Controls/ServerTab.ConfigChanges.cs | 3 +++ Lite/Controls/ServerTab.LongQueries.cs | 1 + Lite/Controls/ServerTab.Refresh.cs | 1 + Lite/Controls/ServerTab.SystemEvents.cs | 24 ++++++++++++++++++++++++ Lite/Controls/ServerTab.xaml | 23 +++++++++++++++++++++-- 5 files changed, 50 insertions(+), 2 deletions(-) diff --git a/Lite/Controls/ServerTab.ConfigChanges.cs b/Lite/Controls/ServerTab.ConfigChanges.cs index 883b5fe317..2c647c0006 100644 --- a/Lite/Controls/ServerTab.ConfigChanges.cs +++ b/Lite/Controls/ServerTab.ConfigChanges.cs @@ -67,6 +67,7 @@ private async System.Threading.Tasks.Task LoadServerConfigChangesAsync(int hours _serverConfigChangesFilterMgr!.UpdateData(data); ShowEngineGap(ServerConfigChangesNoDataMessage, "server_config", data.Count, keepsOwnEmptyText: true); ServerConfigChangesCountIndicator.Text = data.Count > 0 ? $"{data.Count} change(s)" : ""; + await RefreshStoredWindowBannerAsync(QueryWindowRelation.ServerConfig, ServerConfigChangesWindowTruncatedBanner, hoursBack, fromDate, toDate); } private async System.Threading.Tasks.Task LoadDatabaseConfigChangesAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) @@ -75,6 +76,7 @@ private async System.Threading.Tasks.Task LoadDatabaseConfigChangesAsync(int hou _databaseConfigChangesFilterMgr!.UpdateData(data); DatabaseConfigChangesNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; DatabaseConfigChangesCountIndicator.Text = data.Count > 0 ? $"{data.Count} change(s)" : ""; + await RefreshStoredWindowBannerAsync(QueryWindowRelation.DatabaseConfig, DatabaseConfigChangesWindowTruncatedBanner, hoursBack, fromDate, toDate); } private async System.Threading.Tasks.Task LoadTraceFlagChangesAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) @@ -83,6 +85,7 @@ private async System.Threading.Tasks.Task LoadTraceFlagChangesAsync(int hoursBac _traceFlagChangesFilterMgr!.UpdateData(data); ShowEngineGap(TraceFlagChangesNoDataMessage, "trace_flags", data.Count, keepsOwnEmptyText: true); TraceFlagChangesCountIndicator.Text = data.Count > 0 ? $"{data.Count} change(s)" : ""; + await RefreshStoredWindowBannerAsync(QueryWindowRelation.TraceFlags, TraceFlagChangesWindowTruncatedBanner, hoursBack, fromDate, toDate); } /// The per-sub-tab Refresh button reloads the active Configuration Changes sub-tab over the diff --git a/Lite/Controls/ServerTab.LongQueries.cs b/Lite/Controls/ServerTab.LongQueries.cs index c5bb8a1edf..73521d730a 100644 --- a/Lite/Controls/ServerTab.LongQueries.cs +++ b/Lite/Controls/ServerTab.LongQueries.cs @@ -51,6 +51,7 @@ private async Task RefreshLongQueriesAsync(int hoursBack, DateTime? fromDate, Da /* View-only DESCENDING-by-duration sort — never flips the reader's chronological ORDER BY. */ SetDefaultSortIfNone(LongQueryCompletionsGrid, "DurationMicroseconds", ListSortDirection.Descending); + await RefreshStoredWindowBannerAsync(QueryWindowRelation.LongQueryCompletions, LongQueriesWindowTruncatedBanner, hoursBack, fromDate, toDate); } catch (Exception ex) { diff --git a/Lite/Controls/ServerTab.Refresh.cs b/Lite/Controls/ServerTab.Refresh.cs index 1c5cc4dcd2..b73237a0a4 100644 --- a/Lite/Controls/ServerTab.Refresh.cs +++ b/Lite/Controls/ServerTab.Refresh.cs @@ -973,6 +973,7 @@ and the stamped one also holds when the display mode flips or another tab is sel _collectionHealthFilterMgr!.UpdateData(collectionHealthTask.Result); _collectionLogFilterMgr!.UpdateData(collectionLogTask.Result); UpdateCollectorDurationChart(collectionLogTask.Result, hoursBack, fromDate, toDate); + await RefreshStoredWindowBannerAsync(QueryWindowRelation.CollectionLog, CollectionLogWindowTruncatedBanner, hoursBack, fromDate, toDate); } catch (Exception ex) { diff --git a/Lite/Controls/ServerTab.SystemEvents.cs b/Lite/Controls/ServerTab.SystemEvents.cs index 6692539c64..76f7c06cfe 100644 --- a/Lite/Controls/ServerTab.SystemEvents.cs +++ b/Lite/Controls/ServerTab.SystemEvents.cs @@ -106,6 +106,7 @@ private async System.Threading.Tasks.Task LoadSchedulerIssuesAsync(int hoursBack _seSchedulerFilterMgr!.UpdateData(data); ShowSystemHealthEmptyState(SchedulerIssuesNoDataMessage, data.Count); SchedulerIssuesCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : ""; + await RefreshStoredWindowBannerAsync(QueryWindowRelation.SystemHealthEvents, SchedulerIssuesWindowTruncatedBanner, hoursBack, fromDate, toDate); } private async System.Threading.Tasks.Task LoadSevereErrorsAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) @@ -114,6 +115,7 @@ private async System.Threading.Tasks.Task LoadSevereErrorsAsync(int hoursBack, D _seSevereErrorFilterMgr!.UpdateData(data); ShowSystemHealthEmptyState(SevereErrorsNoDataMessage, data.Count); SevereErrorsCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : ""; + await RefreshStoredWindowBannerAsync(QueryWindowRelation.SystemHealthEvents, SevereErrorsWindowTruncatedBanner, hoursBack, fromDate, toDate); } private async System.Threading.Tasks.Task LoadMemoryConditionsAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) @@ -122,6 +124,7 @@ private async System.Threading.Tasks.Task LoadMemoryConditionsAsync(int hoursBac _seMemoryConditionsFilterMgr!.UpdateData(data); ShowSystemHealthEmptyState(MemoryConditionsNoDataMessage, data.Count); MemoryConditionsCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : ""; + await RefreshStoredWindowBannerAsync(QueryWindowRelation.SystemHealthEvents, MemoryConditionsWindowTruncatedBanner, hoursBack, fromDate, toDate); } private async System.Threading.Tasks.Task LoadMemoryBrokerAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) @@ -130,6 +133,7 @@ private async System.Threading.Tasks.Task LoadMemoryBrokerAsync(int hoursBack, D _seMemoryBrokerFilterMgr!.UpdateData(data); ShowSystemHealthEmptyState(MemoryBrokerNoDataMessage, data.Count); MemoryBrokerCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : ""; + await RefreshStoredWindowBannerAsync(QueryWindowRelation.SystemHealthEvents, MemoryBrokerWindowTruncatedBanner, hoursBack, fromDate, toDate); } private async System.Threading.Tasks.Task LoadMemoryNodeOomAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) @@ -138,6 +142,7 @@ private async System.Threading.Tasks.Task LoadMemoryNodeOomAsync(int hoursBack, _seMemoryNodeOomFilterMgr!.UpdateData(data); ShowSystemHealthEmptyState(MemoryNodeOomNoDataMessage, data.Count); MemoryNodeOomCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : ""; + await RefreshStoredWindowBannerAsync(QueryWindowRelation.SystemHealthEvents, MemoryNodeOomWindowTruncatedBanner, hoursBack, fromDate, toDate); } private async System.Threading.Tasks.Task LoadSignificantWaitsAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) @@ -146,6 +151,7 @@ private async System.Threading.Tasks.Task LoadSignificantWaitsAsync(int hoursBac _seSignificantWaitsFilterMgr!.UpdateData(data); ShowSystemHealthEmptyState(SignificantWaitsNoDataMessage, data.Count); SignificantWaitsCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : ""; + await RefreshStoredWindowBannerAsync(QueryWindowRelation.SystemHealthEvents, SignificantWaitsWindowTruncatedBanner, hoursBack, fromDate, toDate); } private async System.Threading.Tasks.Task LoadCpuTasksAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) @@ -154,6 +160,7 @@ private async System.Threading.Tasks.Task LoadCpuTasksAsync(int hoursBack, DateT _seCpuTasksFilterMgr!.UpdateData(data); ShowSystemHealthEmptyState(CpuTasksNoDataMessage, data.Count); CpuTasksCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : ""; + await RefreshStoredWindowBannerAsync(QueryWindowRelation.SystemHealthEvents, CpuTasksWindowTruncatedBanner, hoursBack, fromDate, toDate); } private async System.Threading.Tasks.Task LoadIoIssuesAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) @@ -162,6 +169,7 @@ private async System.Threading.Tasks.Task LoadIoIssuesAsync(int hoursBack, DateT _seIoIssuesFilterMgr!.UpdateData(data); ShowSystemHealthEmptyState(IoIssuesNoDataMessage, data.Count); IoIssuesCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : ""; + await RefreshStoredWindowBannerAsync(QueryWindowRelation.SystemHealthEvents, IoIssuesWindowTruncatedBanner, hoursBack, fromDate, toDate); } private async System.Threading.Tasks.Task LoadDefaultTraceEventsAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) @@ -172,6 +180,7 @@ private async System.Threading.Tasks.Task LoadDefaultTraceEventsAsync(int hoursB DefaultTraceNoDataMessage.Text = gap; DefaultTraceNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; DefaultTraceCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : ""; + await RefreshStoredWindowBannerAsync(QueryWindowRelation.DefaultTraceEvents, DefaultTraceWindowTruncatedBanner, hoursBack, fromDate, toDate); } /// @@ -199,6 +208,21 @@ private async System.Threading.Tasks.Task LoadDefaultTraceEventsAsync(int hoursB internal static string? SystemHealthGapNote(string serverName, bool isAzureSqlDatabase) => EngineGapNote(serverName, isAzureSqlDatabase, "system_health_events"); + /// + /// #4966: the "Showing since" banner of one grid that reads stored rows over the toolbar's window (the Collection Log, + /// the System Events and Default Trace grids, the Config Changes grids and Long Queries). It probes the UTC window the + /// grid's read took, the same pair + /// (), through , so a + /// probe that throws hides the banner and the refresh goes on. Each surface calls it after its rows are bound, from the one + /// method its read runs through, so every read path of the surface (the sub-tab switch, a range change and the Refresh + /// button) refreshes it. + /// + private System.Threading.Tasks.Task RefreshStoredWindowBannerAsync(QueryWindowRelation relation, TextBlock banner, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + var (startUtc, endUtc) = LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate); + return RefreshWindowTruncatedBannerAsync(relation, banner, startUtc, endUtc); + } + /// A system_health grid's empty state: shown when the window has no rows. On an Azure SQL Database it says /// that the collector does not run there, in place of the grid's "no events in this window" text. private void ShowSystemHealthEmptyState(TextBlock message, int rowCount) diff --git a/Lite/Controls/ServerTab.xaml b/Lite/Controls/ServerTab.xaml index 5c3a682237..d17443ef6e 100644 --- a/Lite/Controls/ServerTab.xaml +++ b/Lite/Controls/ServerTab.xaml @@ -2626,6 +2626,7 @@ [Fact] public async Task EveryRelation_NamesARealArchiveView_WithServerIdAndItsTimeColumn() @@ -141,15 +143,18 @@ public async Task EveryRelation_NamesARealArchiveView_WithServerIdAndItsTimeColu Assert.StartsWith("v_", view, StringComparison.Ordinal); var table = view[2..]; var timeColumn = LocalDataService.QueryWindowRelationTimeColumn(relation); + var purgeColumn = relation is QueryWindowRelation.SystemHealthEvents or QueryWindowRelation.DefaultTraceEvents + ? "collection_time" + : timeColumn; Assert.Contains(table, DuckDbInitializer.ArchivableTables); - Assert.Contains(ArchiveService.ArchivableTables, t => t.Table == table && t.TimeColumn == timeColumn); + Assert.Contains(ArchiveService.ArchivableTables, t => t.Table == table && t.TimeColumn == purgeColumn); using var connection = _duckDb.CreateConnection(); await connection.OpenAsync(); using var readLock = _duckDb.AcquireReadLock(); using var cmd = connection.CreateCommand(); - cmd.CommandText = $"SELECT COUNT(*) FROM information_schema.columns WHERE table_name = '{view}' AND column_name IN ('server_id', '{timeColumn}')"; - Assert.Equal(2L, Convert.ToInt64(await cmd.ExecuteScalarAsync())); + cmd.CommandText = $"SELECT COUNT(*) FROM information_schema.columns WHERE table_name = '{view}' AND column_name IN ('server_id', '{timeColumn}', '{purgeColumn}')"; + Assert.Equal(purgeColumn == timeColumn ? 2L : 3L, Convert.ToInt64(await cmd.ExecuteScalarAsync())); } } diff --git a/Lite.Tests/StoredEventCopiesSweepTests.cs b/Lite.Tests/StoredEventCopiesSweepTests.cs index 34961f691b..3f17c3ea83 100644 --- a/Lite.Tests/StoredEventCopiesSweepTests.cs +++ b/Lite.Tests/StoredEventCopiesSweepTests.cs @@ -49,6 +49,13 @@ counts and buckets count COUNT(DISTINCT StoredEventCopies.DeadlockIdentityTuple) /* The two last-capture reads, MAX(collection_time) with no time window: a batch that stored only copies of events already held still read the session, so its collection_time is a true capture. */ [("LocalDataService.SystemEvents.cs", "v_system_health_events")] = 2, + + /* The window-floor probe (#4966): its QueryWindowRelationView map names the views, and the probe asks only for the + earliest time inside a window and whether any row sits before its start (LIMIT 1). A copy of an event is stored + later than its first copy and keeps the first copy's event_time, so it cannot move the earliest time or add an + older row. The probe returns no rows, so there is nothing to hide. */ + [("LocalDataService.QueryWindowFloor.cs", "v_system_health_events")] = 1, + [("LocalDataService.QueryWindowFloor.cs", "v_long_query_completions")] = 1, }; [Fact] diff --git a/Lite/Services/LocalDataService.QueryWindowFloor.cs b/Lite/Services/LocalDataService.QueryWindowFloor.cs index d19f9565f8..e8fb3c9e7f 100644 --- a/Lite/Services/LocalDataService.QueryWindowFloor.cs +++ b/Lite/Services/LocalDataService.QueryWindowFloor.cs @@ -14,8 +14,9 @@ namespace PerformanceMonitorLite.Services; /// /// The relations a window-floor probe () may read. Each is a -/// closed member that maps to ONE v_ view with server_id and a time column (collection_time, or -/// capture_time on the three config snapshots, ), so no +/// closed member that maps to ONE v_ view with server_id and a time column (collection_time, +/// capture_time on the three config snapshots, or event_time on the two event tables: the column the +/// surface's grid filters on, ), so no /// caller hands a view name, and nothing a user typed, into the probe's SQL. #4231 began with the three RAW-ONLY /// relations (, , ): every /// Queries-tab grid and MCP tool that reads one has no rollup underneath it to fall back on when the requested @@ -88,15 +89,23 @@ public partial class LocalDataService }; /// - /// The time column of a relation's view that the probe compares against the window (#4966): collection_time - /// for every relation except the three config snapshots, whose collectors stamp capture_time instead - /// (ICollectorSchemaInfo.PrefixTimeColumnName, the column the archive purges by too). A closed map, so nothing a - /// caller passes reaches the probe's SQL. The collector's own runs in v_collection_log always read - /// collection_time. + /// The time column of a relation's view that the probe compares against the window (#4966): the column the surface's + /// grid filters on, so a banner never names a time later than the earliest row its grid shows. That is + /// collection_time for most relations, capture_time for the three config snapshots (their collectors stamp + /// it instead, ICollectorSchemaInfo.PrefixTimeColumnName, and the archive purges by it), and event_time + /// for the two event relations (#4989): the System Events grids that read system_health events and the Default Trace + /// grid filter on the event's own time, not on the time a run stored it. A server's first run stores the history the + /// server already holds, every row stamped with that run's collection_time while its event_time goes back + /// days, and the probe reading collection_time there named the run above rows from before it. The archive still + /// purges those two tables by collection_time. The Default Trace's event_time is the monitored server's + /// wall clock as stored (its grid converts each row through the server's clock), so on a server whose clock is not UTC + /// the probe compares that wall time with the UTC window as it is. A closed map, so nothing a caller passes reaches the + /// probe's SQL. The collector's own runs in v_collection_log always read collection_time. /// internal static string QueryWindowRelationTimeColumn(QueryWindowRelation relation) => relation switch { QueryWindowRelation.ServerConfig or QueryWindowRelation.DatabaseConfig or QueryWindowRelation.TraceFlags => "capture_time", + QueryWindowRelation.SystemHealthEvents or QueryWindowRelation.DefaultTraceEvents => "event_time", _ => "collection_time" }; From 6309c57f15bfa8a0627c2d80569a8a923cd08425 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:28:00 -0400 Subject: [PATCH 4/9] Lite: the Default Trace banner reads the server's wall-clock event times through the server's clock (#4966) The Default Trace's event_time holds the monitored server's local wall-clock time. Its grid converts each row to UTC through the server's clock, with the offset in force at the row's own date. The data-start probe compared that column with the UTC window as it was and returned the result as if it were UTC, so on a server whose clock is not UTC both the banner's time and the before-or-after-the-range test were off by the offset. For that relation the probe now takes an optional ServerClock (falling back to ServerTimeHelper.ActiveServerClock, as the grid does), reads the candidate rows over the grid's pre-filter window (server-local bounds, an hour wider on each side), converts each row to UTC, and uses the converted times for the earliest-row and older-row checks, so the answer is exact across a daylight-saving change. The collector's runs in v_collection_log are UTC, so the first run and the first row are compared in C#, not in one LEAST. Every UTC relation, system_health's included (the XE timestamp), keeps the single-query path. Tests: on a clock 5 hours behind UTC the banner names the oldest event in UTC; on a clock 5 hours ahead an event before the start in UTC shows no banner; the mirror case shows the banner; four US Eastern rows across the spring-forward and fall-back changes give the exact UTC time and equal the oldest row the grid shows; the probe with no clock uses the active one. --- Lite.Tests/DataStartBannerSurfaceTests.cs | 235 +++++++++++++++++- .../LocalDataService.QueryWindowFloor.cs | 160 +++++++++++- 2 files changed, 386 insertions(+), 9 deletions(-) diff --git a/Lite.Tests/DataStartBannerSurfaceTests.cs b/Lite.Tests/DataStartBannerSurfaceTests.cs index b99b81f674..4de79a565c 100644 --- a/Lite.Tests/DataStartBannerSurfaceTests.cs +++ b/Lite.Tests/DataStartBannerSurfaceTests.cs @@ -10,10 +10,12 @@ using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Linq; using System.Runtime.CompilerServices; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using PerformanceMonitor.Analysis.Baselines; using PerformanceMonitorLite.Controls; using PerformanceMonitorLite.Database; using PerformanceMonitorLite.Services; @@ -168,10 +170,12 @@ INSERT INTO collection_log (log_id, server_id, server_name, collector_name, coll _nextId += await cmd.ExecuteNonQueryAsync() + 1; } - /// The probe, then the banner step the surface runs on the result: (visible, text). - private async Task<(bool Visible, string Text)> BannerForAsync(QueryWindowRelation relation, DateTime startUtc, DateTime endUtc) + /// The probe, then the banner step the surface runs on the result: (visible, text). The probe reads the Default + /// Trace through , the monitored server's clock (#4989). A test that names none is a UTC server's, + /// whatever clock the process holds as the active one. + private async Task<(bool Visible, string Text)> BannerForAsync(QueryWindowRelation relation, DateTime startUtc, DateTime endUtc, ServerClock? clock = null) { - var floor = await new LocalDataService(_duckDb).GetQueryWindowFloorAsync(relation, ServerId, startUtc, endUtc); + var floor = await new LocalDataService(_duckDb).GetQueryWindowFloorAsync(relation, ServerId, startUtc, endUtc, clock ?? ServerClock.Utc); return OnStaThread(() => { var banner = new System.Windows.Controls.TextBlock(); @@ -313,6 +317,231 @@ public async Task EventRelations_NoStoredHistory_NamesTheFirstRun(QueryWindowRel Assert.Contains(Naive(firstRun).ToString("yyyy-MM-dd HH:", CultureInfo.InvariantCulture), text, StringComparison.Ordinal); } + private static DateTime NowUtcSeconds() + { + var now = DateTime.UtcNow; + return new DateTime(now.Ticks - now.Ticks % TimeSpan.TicksPerSecond, DateTimeKind.Unspecified); + } + + /// + /// One Default Trace row as the collector stores it: event_time is the monitored SERVER's wall clock + /// () and collection_time the UTC time of the run that stored it (#4989). The event + /// is a 'Server Memory Change', one the grid's significance gate keeps, so a test can hold the probe's answer against the + /// rows the grid shows. + /// + private async Task SeedDefaultTraceRowAsync(DateTime serverLocal, DateTime collectedAtUtc) + { + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + using var readLock = _duckDb.AcquireReadLock(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = $@" +INSERT INTO default_trace_events + (default_trace_event_id, collection_time, server_id, server_name, event_time, event_name, + database_name, duration_us, integer_data, severity, error_number, text_data) +VALUES ({_nextId++}, {Literal(collectedAtUtc)}, {ServerId}, '{ServerName}', {Literal(serverLocal)}, 'Server Memory Change', + NULL, NULL, NULL, NULL, NULL, 'x')"; + await cmd.ExecuteNonQueryAsync(); + } + + /// + /// A server's first run (T0, ) on a server whose clock is : it + /// stores the history the server already holds, each row stamped with the run's UTC collection_time and an + /// event_time that is the server's WALL CLOCK at the event (, run through the + /// clock). The collector's runs are then logged hourly to , and the newest run stores an event as + /// it happens. + /// + private async Task SeedDefaultTraceFirstRunAsync(ServerClock clock, DateTime firstRunUtc, DateTime end, params DateTime[] historyEventsUtc) + { + foreach (var eventUtc in historyEventsUtc) + { + await SeedDefaultTraceRowAsync(clock.ToServerLocal(eventUtc), firstRunUtc); + } + + await SeedDefaultTraceRowAsync(clock.ToServerLocal(end.AddHours(-1)), end.AddHours(-1)); + await SeedLogRunsAsync(LocalDataService.QueryWindowRelationCollector(QueryWindowRelation.DefaultTraceEvents)!, firstRunUtc, end, 60); + } + + /// + /// #4989: the Default Trace's event_time is the server's wall clock, so the probe converts it through the server's + /// clock as the grid does. On a server 5 hours behind UTC the first run (T0) stores history whose oldest event (H) is + /// inside the range (S < H < T0), and the banner names H in UTC: not H less 5 hours, the stored wall clock read as + /// if it were UTC, and not T0. RED before the fix: the probe compared the wall clock with the UTC window as it was and + /// worded the banner at H shifted by 5 hours. + /// + [Fact] + public async Task DefaultTrace_ServerBehindUtc_HistoryBeforeTheFirstRun_NamesTheOldestEventInUtc() + { + await _duckDb.InitializeAsync(); + var clock = ServerClock.FixedOffset(-300); + var end = DateTime.UtcNow; + var firstRun = end.AddDays(-2); + var oldest = end.AddDays(-4); + await SeedDefaultTraceFirstRunAsync(clock, firstRun, end, oldest, end.AddDays(-3)); + + var (visible, text) = await BannerForAsync(QueryWindowRelation.DefaultTraceEvents, end.AddDays(-7), end, clock); + + Assert.True(visible); + Assert.Contains(Naive(oldest).ToString("yyyy-MM-dd HH:", CultureInfo.InvariantCulture), text, StringComparison.Ordinal); + Assert.DoesNotContain(Naive(oldest.AddHours(-5)).ToString("yyyy-MM-dd HH:", CultureInfo.InvariantCulture), text, StringComparison.Ordinal); + Assert.DoesNotContain(Naive(firstRun).ToString("yyyy-MM-dd HH:", CultureInfo.InvariantCulture), text, StringComparison.Ordinal); + } + + /// + /// The oldest event (H) is before the range's start (S) once both are in UTC, so the grid's range holds the history from + /// S on and there is no banner. On a server 5 hours AHEAD of UTC its stored wall clock (H plus 5 hours) is after S, so a + /// probe that read the wall clock as UTC found no older row and said the data starts late. (A server behind UTC cannot + /// show this: its wall clock reads earlier than the instant, never later.) H sits half an hour before S, inside the + /// pre-filter's margin the probe converts row by row, and 72 hours before it, below the margin, where one query answers. + /// + [Theory] + [InlineData(0.5)] + [InlineData(72.0)] + public async Task DefaultTrace_ServerAheadOfUtc_OldestEventBeforeTheStartInUtc_ShowsNoNotice(double hoursBeforeStart) + { + await _duckDb.InitializeAsync(); + var clock = ServerClock.FixedOffset(300); + var end = DateTime.UtcNow; + var start = end.AddDays(-7); + await SeedDefaultTraceFirstRunAsync(clock, end.AddDays(-2), end, start.AddHours(-hoursBeforeStart), end.AddDays(-3)); + + var (visible, text) = await BannerForAsync(QueryWindowRelation.DefaultTraceEvents, start, end, clock); + + Assert.False(visible); + Assert.Equal(string.Empty, text); + } + + /// + /// The other direction on a server 5 hours behind UTC: the oldest event (H) is 3 hours after S in UTC, so the data does + /// start late and the banner names H, though its stored wall clock reads as 2 hours BEFORE S. A probe that read the wall + /// clock as UTC took that for an older row, and hid the banner. + /// + [Fact] + public async Task DefaultTrace_ServerBehindUtc_OldestEventJustAfterTheStartInUtc_ShowsTheNotice() + { + await _duckDb.InitializeAsync(); + var clock = ServerClock.FixedOffset(-300); + var end = DateTime.UtcNow; + var start = end.AddDays(-7); + var oldest = start.AddHours(3); + await SeedDefaultTraceFirstRunAsync(clock, end.AddDays(-2), end, oldest); + + var (visible, text) = await BannerForAsync(QueryWindowRelation.DefaultTraceEvents, start, end, clock); + + Assert.True(visible); + Assert.Contains(Naive(oldest).ToString("yyyy-MM-dd HH:", CultureInfo.InvariantCulture), text, StringComparison.Ordinal); + } + + /// + /// The collector's runs in v_collection_log are UTC and take no part in the clock: on a server 5 hours behind UTC + /// whose first run found no history, the floor is that run as logged, not the run shifted by the clock. + /// + [Fact] + public async Task DefaultTrace_ServerBehindUtc_NoStoredHistory_NamesTheFirstRunInUtc() + { + await _duckDb.InitializeAsync(); + var clock = ServerClock.FixedOffset(-300); + var end = DateTime.UtcNow; + var firstRun = end.AddDays(-2); + await SeedDefaultTraceFirstRunAsync(clock, firstRun, end); + + var (visible, text) = await BannerForAsync(QueryWindowRelation.DefaultTraceEvents, end.AddDays(-7), end, clock); + + Assert.True(visible); + Assert.Contains(Naive(firstRun).ToString("yyyy-MM-dd HH:", CultureInfo.InvariantCulture), text, StringComparison.Ordinal); + } + + /// + /// A probe that names no clock reads the one the grid falls back to, , + /// the Default Trace grid's own default (it passes none), so the banner and the rows it sits over agree. + /// + [Fact] + public async Task DefaultTrace_WithoutAClock_UsesTheActiveServerClockLikeTheGrid() + { + await _duckDb.InitializeAsync(); + var clock = ServerClock.FixedOffset(-300); + var end = NowUtcSeconds(); + var start = end.AddDays(-7); + var oldest = end.AddDays(-4); + await SeedDefaultTraceFirstRunAsync(clock, end.AddDays(-2), end, oldest); + + var previous = ServerTimeHelper.ActiveServerClock; + try + { + ServerTimeHelper.ActiveServerClock = clock; + var service = new LocalDataService(_duckDb); + var floor = await service.GetQueryWindowFloorAsync(QueryWindowRelation.DefaultTraceEvents, ServerId, start, end); + var gridRows = await service.GetDefaultTraceEventsAsync(ServerId, fromDate: start, toDate: end); + + Assert.Equal(oldest, floor); + Assert.Equal(floor, gridRows.Min(r => r.EventTimeUtc)); + } + finally + { + ServerTimeHelper.ActiveServerClock = previous; + } + } + + /// + /// Across a daylight-saving change the probe takes each row's offset from the row's OWN date, as the grid does (#4766), + /// and the floor is exactly the oldest row the grid shows. US Eastern, 2026: the spring-forward is 8 March (02:00 EST to + /// 03:00 EDT, 07:00 UTC) and the fall-back is 1 November (02:00 EDT to 01:00 EST, 06:00 UTC). The range straddles the + /// change, and the oldest row is on each side of it in turn, so a probe that applied one offset to the whole range, + /// the one at its start or the one at its end, is an hour wrong on one of the four. + /// + [Theory] + [InlineData("2026-03-07 00:00:00", "2026-03-10 00:00:00", "2026-03-08 01:30:00", "2026-03-08 06:30:00")] + [InlineData("2026-03-07 00:00:00", "2026-03-10 00:00:00", "2026-03-08 03:30:00", "2026-03-08 07:30:00")] + [InlineData("2026-10-30 00:00:00", "2026-11-03 00:00:00", "2026-11-01 00:30:00", "2026-11-01 04:30:00")] + [InlineData("2026-10-30 00:00:00", "2026-11-03 00:00:00", "2026-11-01 02:30:00", "2026-11-01 07:30:00")] + public async Task DefaultTrace_RangeAcrossADaylightSavingChange_UsesTheOffsetOfTheRowsOwnDate( + string startUtc, string endUtc, string oldestServerLocal, string expectedFloorUtc) + { + await _duckDb.InitializeAsync(); + var clock = ServerClock.Resolve("Eastern Standard Time", -300); + var start = DateTime.Parse(startUtc, CultureInfo.InvariantCulture); + var end = DateTime.Parse(endUtc, CultureInfo.InvariantCulture); + var oldest = DateTime.Parse(oldestServerLocal, CultureInfo.InvariantCulture); + await SeedDefaultTraceRowAsync(oldest, end.AddHours(-1)); + await SeedDefaultTraceRowAsync(oldest.AddHours(30), end.AddHours(-1)); + + var service = new LocalDataService(_duckDb); + var floor = await service.GetQueryWindowFloorAsync(QueryWindowRelation.DefaultTraceEvents, ServerId, start, end, clock); + var gridRows = await service.GetDefaultTraceEventsAsync(ServerId, fromDate: start, toDate: end, serverClock: clock); + + Assert.Equal(DateTime.Parse(expectedFloorUtc, CultureInfo.InvariantCulture), floor); + Assert.Equal(floor, gridRows.Min(r => r.EventTimeUtc)); + } + + /// + /// The system_health event_time is the XE @timestamp, UTC, so a server clock never shifts it: the same + /// history as the Default Trace case, on a server 5 hours behind UTC, names the oldest event as stored. + /// + [Fact] + public async Task SystemHealth_UtcEventTime_IsNotShiftedByTheServerClock() + { + await _duckDb.InitializeAsync(); + var relation = QueryWindowRelation.SystemHealthEvents; + var end = DateTime.UtcNow; + var oldest = end.AddDays(-4); + await SeedFirstRunAsync(relation, end.AddDays(-2), end, oldest, end.AddDays(-3)); + + var (visible, text) = await BannerForAsync(relation, end.AddDays(-7), end, ServerClock.FixedOffset(-300)); + + Assert.True(visible); + Assert.Contains(Naive(oldest).ToString("yyyy-MM-dd HH:", CultureInfo.InvariantCulture), text, StringComparison.Ordinal); + } + + /// Only the Default Trace holds the server's wall clock (#4989); every other relation's time column is UTC. + [Fact] + public void OnlyTheDefaultTrace_HoldsServerLocalTime() + { + foreach (var relation in Enum.GetValues()) + { + Assert.Equal(relation == QueryWindowRelation.DefaultTraceEvents, LocalDataService.QueryWindowRelationTimeIsServerLocal(relation)); + } + } + /// /// Each relation reads the view its grid reads, by the column that grid filters on (#4989: event_time for the /// two event relations, collection_time or capture_time for the rest), and takes its coverage from diff --git a/Lite/Services/LocalDataService.QueryWindowFloor.cs b/Lite/Services/LocalDataService.QueryWindowFloor.cs index e8fb3c9e7f..58a405cf2e 100644 --- a/Lite/Services/LocalDataService.QueryWindowFloor.cs +++ b/Lite/Services/LocalDataService.QueryWindowFloor.cs @@ -9,6 +9,8 @@ using System; using System.Threading.Tasks; using DuckDB.NET.Data; +using PerformanceMonitor.Analysis.Baselines; +using PerformanceMonitorLite.Database; namespace PerformanceMonitorLite.Services; @@ -97,10 +99,13 @@ public partial class LocalDataService /// grid filter on the event's own time, not on the time a run stored it. A server's first run stores the history the /// server already holds, every row stamped with that run's collection_time while its event_time goes back /// days, and the probe reading collection_time there named the run above rows from before it. The archive still - /// purges those two tables by collection_time. The Default Trace's event_time is the monitored server's - /// wall clock as stored (its grid converts each row through the server's clock), so on a server whose clock is not UTC - /// the probe compares that wall time with the UTC window as it is. A closed map, so nothing a caller passes reaches the - /// probe's SQL. The collector's own runs in v_collection_log always read collection_time. + /// purges those two tables by collection_time. The system_health event_time is the XE @timestamp, + /// UTC, like every other column here. The Default Trace's event_time is the exception: it is the monitored server's + /// wall clock as stored (its grid converts each row through the server's clock), so the probe does the same + /// (, #4989): it reads that relation's rows over the grid's padded + /// server-local window, converts each one to UTC through the clock, and compares the converted times with the UTC + /// window. A closed map, so nothing a caller passes reaches the probe's SQL. The collector's own runs in + /// v_collection_log always read collection_time, which is UTC. /// internal static string QueryWindowRelationTimeColumn(QueryWindowRelation relation) => relation switch { @@ -109,6 +114,15 @@ public partial class LocalDataService _ => "collection_time" }; + /// + /// Whether the relation's time column () holds the monitored server's LOCAL + /// wall-clock time instead of UTC (#4989). Only the Default Trace does: ft.StartTime is stored as the trace wrote + /// it, and converts each row to UTC through the server's clock. Every other + /// relation's column is UTC, system_health's included (the XE @timestamp), and keeps the probe's single-query path. + /// + internal static bool QueryWindowRelationTimeIsServerLocal(QueryWindowRelation relation) => + relation == QueryWindowRelation.DefaultTraceEvents; + /// /// Where this server's data starts for the requested window, as far as any caller needs to know it. NULL when /// the server holds no row inside [, ] at all (nothing was @@ -159,8 +173,18 @@ public partial class LocalDataService /// an old row or the start, either of which would read as the window having been served) or when its first row /// is already at the start. Only when that first row sits after the start does a second query ask whether the /// server holds ANY row before the start, LIMIT 1, which stops at the first hit. + /// + /// A time column in the server's wall clock (the Default Trace, #4989). The Default Trace's event_time + /// is the monitored server's local time as stored, and its grid () converts each + /// row to UTC through the server's clock, so the probe does too, through : the server's OWN + /// clock (#4766), or when it is null, which is what that grid + /// falls back to. For that relation the two steps above read the candidate rows in the grid's padded server-local window, + /// convert each to UTC with the offset at the row's own date (so the answer is exact across a daylight-saving change), and + /// take the earlier of the first row and the collector's first run, which is UTC, in C# + /// (GetServerLocalQueryWindowFloorAsync). Every UTC relation, system_health's included (the XE @timestamp), + /// keeps the single-query path and ignores the clock. /// - public async Task GetQueryWindowFloorAsync(QueryWindowRelation relation, int serverId, DateTime startUtc, DateTime endUtc) + public async Task GetQueryWindowFloorAsync(QueryWindowRelation relation, int serverId, DateTime startUtc, DateTime endUtc, ServerClock? serverClock = null) { var view = QueryWindowRelationView(relation); using var _q = TimeQuery("GetQueryWindowFloorAsync", $"{view} window floor"); @@ -168,6 +192,15 @@ public partial class LocalDataService var collector = QueryWindowRelationCollector(relation); var timeColumn = QueryWindowRelationTimeColumn(relation); + + /* #4989: a time column that holds the server's wall clock (the Default Trace) goes through the clock, the way the + grid reads it. Every UTC relation keeps the single-query path below. */ + if (QueryWindowRelationTimeIsServerLocal(relation)) + { + return await GetServerLocalQueryWindowFloorAsync( + connection, view, timeColumn, collector, serverId, startUtc, endUtc, serverClock ?? ServerTimeHelper.ActiveServerClock); + } + DateTime? firstInWindow; using (var windowCommand = connection.CreateCommand()) { @@ -217,6 +250,13 @@ SELECT 1 } /* No older row: an older run of the collector still proves the window is covered. */ + return await HasCollectorRunBeforeAsync(connection, collector, serverId, startUtc) ? startUtc : firstRow; + } + + /// Whether the collector's runs in v_collection_log (collection_time, UTC) include one before + /// . + private static async Task HasCollectorRunBeforeAsync(LockedConnection connection, string collector, int serverId, DateTime startUtc) + { using var olderRunCommand = connection.CreateCommand(); olderRunCommand.CommandText = $@" SELECT 1 @@ -227,6 +267,114 @@ AND collection_time < $2 LIMIT 1"; olderRunCommand.Parameters.Add(new DuckDBParameter { Value = serverId }); olderRunCommand.Parameters.Add(new DuckDBParameter { Value = startUtc }); - return await olderRunCommand.ExecuteScalarAsync() is null ? firstRow : startUtc; + return await olderRunCommand.ExecuteScalarAsync() is not null; + } + + /// + /// for a relation whose time column is the monitored server's wall clock (#4989: + /// the Default Trace's event_time). It answers what the UTC path answers, from the rows the grid shows + /// (), so a banner never names a time later than the earliest row its grid shows. + /// + /// The candidate rows are read over the grid's pre-filter: the server-local bounds of the UTC window, an hour wider + /// on each side, which covers a daylight-saving change inside the span. Each row is converted to UTC through + /// , which takes the offset in force at the row's own date (#4766), and the converted times give + /// the earliest row inside [, ] and whether a row sits before the + /// start. The scan reads the one column over a span the grid reads whole. + /// The collector's runs in v_collection_log are UTC, so they are not converted: the earlier of the first run + /// and the first row is taken here, in C#, not by one SQL LEAST over a wall-clock column and a UTC one. + /// When that first time is after the start, the server holds an older row if the scan found one that converts to a + /// time before the start, or if any row sits below the pre-filter's lower bound (more than an hour before the start at + /// any offset the clock holds, so it needs no conversion). Then an older run of the collector counts, as on the UTC path. + /// + /// + private static async Task GetServerLocalQueryWindowFloorAsync( + LockedConnection connection, string view, string timeColumn, string? collector, int serverId, + DateTime startUtc, DateTime endUtc, ServerClock clock) + { + /* The grid's pre-filter, bound for bound (GetDefaultTraceEventsAsync). */ + var padStart = clock.ToServerLocal(startUtc).AddHours(-1); + var padEnd = clock.ToServerLocal(endUtc).AddHours(1); + + DateTime? firstRow = null; + var olderRowInPad = false; + using (var rowsCommand = connection.CreateCommand()) + { + rowsCommand.CommandText = $@" +SELECT {timeColumn} +FROM {view} +WHERE server_id = $1 +AND {timeColumn} >= $2 +AND {timeColumn} <= $3"; + rowsCommand.Parameters.Add(new DuckDBParameter { Value = serverId }); + rowsCommand.Parameters.Add(new DuckDBParameter { Value = padStart }); + rowsCommand.Parameters.Add(new DuckDBParameter { Value = padEnd }); + using var reader = await rowsCommand.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var eventUtc = clock.ToUtc(reader.GetDateTime(0)); + if (eventUtc < startUtc) + { + olderRowInPad = true; + } + else if (eventUtc <= endUtc && (firstRow is null || eventUtc < firstRow)) + { + firstRow = eventUtc; + } + } + } + + DateTime? firstRun = null; + if (collector is not null) + { + using var runCommand = connection.CreateCommand(); + runCommand.CommandText = $@" +SELECT MIN(collection_time) +FROM v_collection_log +WHERE server_id = $1 +AND collector_name = '{collector}' +AND collection_time >= $2 +AND collection_time <= $3"; + runCommand.Parameters.Add(new DuckDBParameter { Value = serverId }); + runCommand.Parameters.Add(new DuckDBParameter { Value = startUtc }); + runCommand.Parameters.Add(new DuckDBParameter { Value = endUtc }); + firstRun = await runCommand.ExecuteScalarAsync() is DateTime run ? run : null; + } + + var first = firstRow; + if (firstRun is DateTime firstRunUtc && (first is null || firstRunUtc < first)) + { + first = firstRunUtc; + } + + /* As the UTC path: nothing in the window answers NULL, and a first time at the start is the answer. */ + if (first is not DateTime firstInWindow || firstInWindow <= startUtc) + { + return first; + } + + if (olderRowInPad) + { + return startUtc; + } + + using (var olderCommand = connection.CreateCommand()) + { + olderCommand.CommandText = $@" +SELECT 1 +FROM {view} +WHERE server_id = $1 +AND {timeColumn} < $2 +LIMIT 1"; + olderCommand.Parameters.Add(new DuckDBParameter { Value = serverId }); + olderCommand.Parameters.Add(new DuckDBParameter { Value = padStart }); + if (await olderCommand.ExecuteScalarAsync() is not null) + { + return startUtc; + } + } + + return collector is not null && await HasCollectorRunBeforeAsync(connection, collector, serverId, startUtc) + ? startUtc + : firstInWindow; } } From 7ed4be8259e211f0390749e9143f914add8b712f Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 3 Oct 2026 03:44:48 -0400 Subject: [PATCH 5/9] Lite: the Long Queries and Collection Log notices name the oldest row their capped read returned (#4966) --- Lite.Tests/DataStartBannerSurfaceTests.cs | 347 +++++++++++++++++- Lite/Controls/ServerTab.LongQueries.cs | 17 +- Lite/Controls/ServerTab.Refresh.cs | 13 +- Lite/Controls/ServerTab.SystemEvents.cs | 7 +- .../LocalDataService.CollectionHealth.cs | 10 +- Lite/Services/LocalDataService.LongQueries.cs | 12 +- 6 files changed, 392 insertions(+), 14 deletions(-) diff --git a/Lite.Tests/DataStartBannerSurfaceTests.cs b/Lite.Tests/DataStartBannerSurfaceTests.cs index 4de79a565c..976058b020 100644 --- a/Lite.Tests/DataStartBannerSurfaceTests.cs +++ b/Lite.Tests/DataStartBannerSurfaceTests.cs @@ -170,6 +170,75 @@ INSERT INTO collection_log (log_id, server_id, server_name, collector_name, coll _nextId += await cmd.ExecuteNonQueryAsync() + 1; } + /// + /// A long-query completion every seconds from to + /// (the event's own time, event_time), each stored by a run + /// seconds later (collection_time). The two clocks differ on purpose: the + /// grid windows on the run's time and orders and caps on the event's, so a notice worded from the wrong one is + /// told apart from the right one. + /// + private async Task SeedLongQueriesEverySecondsAsync(DateTime firstEventUtc, DateTime lastEventUtc, int everySeconds) + { + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + using var readLock = _duckDb.AcquireReadLock(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = $@" +INSERT INTO long_query_completions (long_query_completion_id, collection_time, server_id, server_name, event_time, event_type, database_name, statement_text, duration_microseconds) +SELECT {_nextId} + row_number() OVER (), g.t + INTERVAL {CollectedAfterSeconds} SECOND, {ServerId}, '{ServerName}', g.t, 'rpc_completed', 'Db', + 'SELECT ' || CAST(row_number() OVER () AS VARCHAR), 5000000 +FROM generate_series({Literal(firstEventUtc)}, {Literal(lastEventUtc)}, INTERVAL {everySeconds} SECOND) AS g(t)"; + _nextId += await cmd.ExecuteNonQueryAsync() + 1; + } + + /// How long after a completion the run that stored it stamps its collection_time. + private const int CollectedAfterSeconds = 90; + + private static string Since(DateTime instant) => + "Showing since " + PerformanceMonitor.Ui.DisplayZone.Format(Naive(instant), TimeZoneInfo.Utc, "yyyy-MM-dd HH:mm:ss"); + + /// + /// A capped grid's read at its cap, then the capped-grid banner step itself (, + /// the decision inside the tab's RefreshCappedGridBannerAsync): (visible, text, oldest row the grid returned, + /// rows it returned, whether the step handed the banner to the probing step). The probing step stands in for the shared + /// RefreshWindowTruncatedBannerAsync: it words the banner from the real probe's answer, which is awaited BEFORE the + /// STA block, because a continuation after an await runs on another thread and a WPF banner can only be written by the + /// thread that made it. + /// + private async Task<(bool Visible, string Text, DateTime Oldest, int Rows, bool Probed)> CappedBannerOverTheRealReadAsync( + Func>> read, QueryWindowRelation relation, int rowCap, Func rowTimeUtc, + DateTime startUtc, DateTime endUtc) + { + var service = new LocalDataService(_duckDb); + var rows = await read(service); + var probedFloor = await service.GetQueryWindowFloorAsync(relation, ServerId, startUtc, endUtc, ServerClock.Utc); + var (visible, text, probed) = OnStaThread(() => + { + var banner = new System.Windows.Controls.TextBlock(); + var probeStepRan = false; + ServerTab.CappedGridBannerAsync(rows, rowCap, rowTimeUtc, + oldestRowShown => ServerTab.ApplyCappedWindowFloorToBanner(banner, oldestRowShown, startUtc, TimeZoneInfo.Utc), + () => + { + probeStepRan = true; + ServerTab.ApplyWindowFloorToBanner(banner, probedFloor, startUtc, TimeZoneInfo.Utc); + return Task.CompletedTask; + }).GetAwaiter().GetResult(); + return (banner.Visibility == System.Windows.Visibility.Visible, banner.Text, probeStepRan); + }); + return (visible, text, rows.Count == 0 ? default : rows.Min(rowTimeUtc), rows.Count, probed); + } + + private Task<(bool Visible, string Text, DateTime Oldest, int Rows, bool Probed)> CappedLongQueriesBannerAsync(DateTime startUtc, DateTime endUtc) => + CappedBannerOverTheRealReadAsync( + service => service.GetRecentLongQueryCompletionsAsync(ServerId, fromDate: startUtc, toDate: endUtc), + QueryWindowRelation.LongQueryCompletions, LocalDataService.LongQueryGridCap, ServerTab.LongQueryRowTimeUtc, startUtc, endUtc); + + private Task<(bool Visible, string Text, DateTime Oldest, int Rows, bool Probed)> CappedCollectionLogBannerAsync(DateTime startUtc, DateTime endUtc) => + CappedBannerOverTheRealReadAsync( + service => service.GetRecentCollectionLogAsync(ServerId, fromDate: startUtc, toDate: endUtc), + QueryWindowRelation.CollectionLog, LocalDataService.CollectionLogGridCap, row => row.CollectionTime, startUtc, endUtc); + /// The probe, then the banner step the surface runs on the result: (visible, text). The probe reads the Default /// Trace through , the monitored server's clock (#4989). A test that names none is a UTC server's, /// whatever clock the process holds as the active one. @@ -569,10 +638,11 @@ public void Relations_NameTheViewCollectorAndTimeColumnTheirGridReads( /// the rows are bound, through the shared helper and over the UTC window the read took. Every read path of these /// surfaces goes through that method: the sub-tab switch and the toolbar's range change reach the System Events, /// Config Changes and Long Queries loaders through RefreshVisibleTabAsync, and a loader's own Refresh button - /// calls the same refresh. Comments are stripped first, so a sentence that names the call cannot satisfy the pin. + /// calls the same refresh. Comments are stripped first, so a sentence that names the call cannot satisfy the pin. The + /// two grids that read a capped page, the Collection Log and Long Queries, are pinned by + /// instead (#4989). /// [Theory] - [InlineData("private async System.Threading.Tasks.Task RefreshCollectionHealthAsync(", "ServerTab.Refresh.cs", "CollectionLog", "CollectionLogWindowTruncatedBanner")] [InlineData("private async System.Threading.Tasks.Task LoadSchedulerIssuesAsync(", "ServerTab.SystemEvents.cs", "SystemHealthEvents", "SchedulerIssuesWindowTruncatedBanner")] [InlineData("private async System.Threading.Tasks.Task LoadSevereErrorsAsync(", "ServerTab.SystemEvents.cs", "SystemHealthEvents", "SevereErrorsWindowTruncatedBanner")] [InlineData("private async System.Threading.Tasks.Task LoadMemoryConditionsAsync(", "ServerTab.SystemEvents.cs", "SystemHealthEvents", "MemoryConditionsWindowTruncatedBanner")] @@ -585,7 +655,6 @@ public void Relations_NameTheViewCollectorAndTimeColumnTheirGridReads( [InlineData("private async System.Threading.Tasks.Task LoadServerConfigChangesAsync(", "ServerTab.ConfigChanges.cs", "ServerConfig", "ServerConfigChangesWindowTruncatedBanner")] [InlineData("private async System.Threading.Tasks.Task LoadDatabaseConfigChangesAsync(", "ServerTab.ConfigChanges.cs", "DatabaseConfig", "DatabaseConfigChangesWindowTruncatedBanner")] [InlineData("private async System.Threading.Tasks.Task LoadTraceFlagChangesAsync(", "ServerTab.ConfigChanges.cs", "TraceFlags", "TraceFlagChangesWindowTruncatedBanner")] - [InlineData("private async Task RefreshLongQueriesAsync(", "ServerTab.LongQueries.cs", "LongQueryCompletions", "LongQueriesWindowTruncatedBanner")] public void EverySurface_HasABanner_RefreshedAfterTheRowsAreBound(string signature, string file, string relation, string banner) { var xaml = File.ReadAllText(ControlsFile("ServerTab.xaml")); @@ -620,6 +689,278 @@ public void TheBannerHelper_ProbesTheWindowTheGridsReadThroughTheSharedStep() Assert.Contains("RefreshWindowTruncatedBannerAsync(relation, banner, startUtc, endUtc)", body, StringComparison.Ordinal); } + /// + /// #4989: the two grids that read a capped page, the Collection Log (the newest + /// runs) and Long Queries (the newest + /// completions), refresh their notice through the cap-aware step + /// (RefreshCappedGridBannerAsync), not through the probe-only one: after the rows are bound, over the UTC window + /// the read took, handing the step the rows the grid shows, the constant that is the read's cap and the time each + /// row is capped on. Comments are stripped first. A grid that goes back to the probe-only step would again say + /// nothing about the rows its cap dropped, on a range the store covers. + /// + [Theory] + [InlineData("private async System.Threading.Tasks.Task RefreshCollectionHealthAsync(", "ServerTab.Refresh.cs", "CollectionLog", "CollectionLogWindowTruncatedBanner", + "collectionLogTask.Result, LocalDataService.CollectionLogGridCap, row => row.CollectionTime")] + [InlineData("private async Task RefreshLongQueriesAsync(", "ServerTab.LongQueries.cs", "LongQueryCompletions", "LongQueriesWindowTruncatedBanner", + "task.Result, LocalDataService.LongQueryGridCap, LongQueryRowTimeUtc")] + public void CappedSurfaces_RefreshTheirBanner_ThroughTheCapAwareStep_AfterTheRowsAreBound( + string signature, string file, string relation, string banner, string rowsCapAndTime) + { + var xaml = File.ReadAllText(ControlsFile("ServerTab.xaml")); + Assert.Contains($"x:Name=\"{banner}\"", xaml, StringComparison.Ordinal); + + var code = StripComments(File.ReadAllText(ControlsFile(file)).Replace("\r\n", "\n")); + var start = code.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, $"{signature} is not in {file}"); + var next = Regex.Match(code[(start + signature.Length)..], @"\n (private|internal|public) "); + var body = next.Success ? code.Substring(start, signature.Length + next.Index) : code[start..]; + + var window = "var (windowStart, windowEnd) = LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate);"; + var call = $"RefreshCappedGridBannerAsync(QueryWindowRelation.{relation}, {banner}, windowStart, windowEnd, {rowsCapAndTime})"; + Assert.Contains(window, body, StringComparison.Ordinal); + Assert.Contains(call, body, StringComparison.Ordinal); + Assert.DoesNotContain("RefreshStoredWindowBannerAsync(", body, StringComparison.Ordinal); + var bind = body.IndexOf("UpdateData(", StringComparison.Ordinal); + Assert.True(bind >= 0 && bind < body.IndexOf(window, StringComparison.Ordinal) && body.IndexOf(window, StringComparison.Ordinal) < body.IndexOf(call, StringComparison.Ordinal), + $"{banner} must be refreshed after the grid's rows are bound, over the read's window"); + } + + /// + /// The read's LIMIT and the notice's cap are ONE value. The Long Queries read is written with + /// and not with a literal, so a read seeded past the cap comes back with + /// exactly that many rows, and the tab hands the same constant to the cap-aware step + /// (). The Collection Log's + /// default maxRows is the same way. The MCP read that ranks by + /// duration, GetSlowestLongQueryCompletionsAsync, caps nothing by time and keeps its own limit. + /// + [Fact] + public async Task TheGridReads_LimitIsTheSameConstantTheBannerCaps() + { + await _duckDb.InitializeAsync(); + var end = DateTime.UtcNow; + await SeedLongQueriesEverySecondsAsync(end.AddMinutes(-LocalDataService.LongQueryGridCap - 40), end.AddMinutes(-2), 60); + await SeedLogRunsAsync("wait_stats", end.AddMinutes(-LocalDataService.CollectionLogGridCap - 40), end, 1); + + var service = new LocalDataService(_duckDb); + Assert.Equal(LocalDataService.LongQueryGridCap, (await service.GetRecentLongQueryCompletionsAsync(ServerId, fromDate: end.AddDays(-1), toDate: end)).Count); + Assert.Equal(LocalDataService.CollectionLogGridCap, (await service.GetRecentCollectionLogAsync(ServerId, fromDate: end.AddDays(-1), toDate: end)).Count); + + var longQueries = File.ReadAllText(RepoFile("Lite", "Services", "LocalDataService.LongQueries.cs")).Replace("\r\n", "\n"); + var grid = longQueries[longQueries.IndexOf("GetRecentLongQueryCompletionsAsync(int serverId", StringComparison.Ordinal)..longQueries.IndexOf("GetSlowestLongQueryCompletionsAsync(int serverId", StringComparison.Ordinal)]; + Assert.Contains("LIMIT {LongQueryGridCap}", grid, StringComparison.Ordinal); + Assert.DoesNotMatch(@"LIMIT \d", grid); + } + + /// + /// A Long Queries read that fills its cap names its OLDEST returned completion, worded at its own time + /// (event_time, 90 seconds before the run that stored it stamped the row), even over a range the store covers: + /// the collector's runs reach 30 days back, so the probe alone shows nothing for a 7-day range, but the grid shows only + /// the newest 200 of the 3 days of completions it holds, the oldest about 16.6 hours back. The probing step is not asked. + /// + [Fact] + public async Task LongQueries_AReadThatFillsItsCap_NamesItsOldestRow_AlsoOverARangeTheStoreCovers() + { + await _duckDb.InitializeAsync(); + var end = DateTime.UtcNow; + await SeedLogRunsAsync("long_query_completions", end.AddDays(-30), end, 360); + await SeedLongQueriesEverySecondsAsync(end.AddDays(-3), end.AddMinutes(-2), 5 * 60); + + Assert.False((await BannerForAsync(QueryWindowRelation.LongQueryCompletions, end.AddDays(-7), end)).Visible); + + var (visible, text, oldest, rows, probed) = await CappedLongQueriesBannerAsync(end.AddDays(-7), end); + + Assert.Equal(LocalDataService.LongQueryGridCap, rows); + Assert.InRange((end - oldest).TotalHours, 16, 17); + Assert.True(visible); + Assert.Equal(Since(oldest), text); + Assert.False(probed); + } + + /// + /// The notice names the completion's own time, not the time its run stored it: the oldest row's + /// collection_time is 90 seconds later, and a notice worded from it would name a time after the oldest row shown. + /// + [Fact] + public async Task LongQueries_TheCappedNotice_IsWordedAtTheEventTime_NotTheTimeTheRunStoredIt() + { + await _duckDb.InitializeAsync(); + var end = DateTime.UtcNow; + await SeedLongQueriesEverySecondsAsync(end.AddHours(-30), end.AddMinutes(-2), 5 * 60); + + var rows = await new LocalDataService(_duckDb).GetRecentLongQueryCompletionsAsync(ServerId, fromDate: end.AddDays(-7), toDate: end); + var (visible, text, oldest, _, _) = await CappedLongQueriesBannerAsync(end.AddDays(-7), end); + + Assert.True(visible); + Assert.Equal(Since(oldest), text); + Assert.Equal(oldest, rows.Min(row => row.EventTime)); + Assert.NotEqual(Since(rows.Min(row => row.CollectionTime)), text); + } + + /// + /// The row's time is its event time, and a completion with no event time falls back to the time its run was collected. + /// + [Fact] + public void LongQueryRowTimeUtc_IsTheEventTime_AndFallsBackToTheCollectionTime() + { + var collected = new DateTime(2026, 6, 3, 10, 2, 0); + var eventTime = new DateTime(2026, 6, 3, 10, 0, 30); + + Assert.Equal(eventTime, ServerTab.LongQueryRowTimeUtc(new LongQueryCompletionRow { CollectionTime = collected, EventTime = eventTime })); + Assert.Equal(collected, ServerTab.LongQueryRowTimeUtc(new LongQueryCompletionRow { CollectionTime = collected, EventTime = null })); + } + + /// + /// A read under its cap holds everything the store has in the range, so the coverage notice stands as before: a server + /// whose trace was added 48 hours ago says it shows since then, and the probing step is the one that words it. + /// + [Fact] + public async Task LongQueries_AReadUnderItsCap_KeepsTheCoverageNotice() + { + await _duckDb.InitializeAsync(); + var end = DateTime.UtcNow; + var added = end.AddHours(-48); + await SeedLogRunsAsync("long_query_completions", added, end, 5); + await SeedLongQueriesEverySecondsAsync(end.AddMinutes(-2 - 5 * 149), end.AddMinutes(-2), 5 * 60); + + var (visible, text, _, rows, probed) = await CappedLongQueriesBannerAsync(end.AddDays(-7), end); + + Assert.Equal(150, rows); + Assert.True(probed); + Assert.True(visible); + Assert.Equal(Since(added), text); + } + + /// A range the store covers, read under the cap, shows no notice: the probing step answers the range's start. + [Fact] + public async Task LongQueries_ACoveredRangeUnderTheCap_ShowsNoNotice() + { + await _duckDb.InitializeAsync(); + var end = DateTime.UtcNow; + await SeedLogRunsAsync("long_query_completions", end.AddDays(-30), end, 360); + await SeedLongQueriesEverySecondsAsync(end.AddHours(-5), end.AddHours(-4), 20 * 60); + + var (visible, text, _, rows, probed) = await CappedLongQueriesBannerAsync(end.AddDays(-7), end); + + Assert.InRange(rows, 1, LocalDataService.LongQueryGridCap - 1); + Assert.True(probed); + Assert.False(visible); + Assert.Equal(string.Empty, text); + } + + /// + /// A capped Long Queries read on a range of ONE HOUR whose oldest completion is later than the start shows its notice, at + /// that completion: a completion every 10 seconds fills the 200-row cap in about 33 minutes, so the grid starts about 25 + /// minutes after the range does. The capped verdict gets no slack (the 90-minute slack is the coverage probe's, and + /// the probe is not asked). + /// + [Fact] + public async Task LongQueries_AOneHourRange_WhoseGridHitsItsCap_ShowsTheNoticeAtItsOldestRow_WithNoSlack() + { + await _duckDb.InitializeAsync(); + var end = DateTime.UtcNow; + await SeedLongQueriesEverySecondsAsync(end.AddMinutes(-40), end.AddMinutes(-2), 10); + + /* What the probe-only step said of this read: nothing, the first row is inside the slack. */ + Assert.False((await BannerForAsync(QueryWindowRelation.LongQueryCompletions, end.AddHours(-1), end)).Visible); + + var (visible, text, oldest, rows, probed) = await CappedLongQueriesBannerAsync(end.AddHours(-1), end); + + Assert.Equal(LocalDataService.LongQueryGridCap, rows); + Assert.InRange((end - oldest).TotalMinutes, 34, 36); + Assert.True(visible); + Assert.Equal(Since(oldest), text); + Assert.False(probed); + } + + /// + /// A range of 90 minutes or less makes no probe call for Long Queries, which gets its coverage notice from the shared + /// probing step (): such a window can never get one. A range past the + /// slack asks the probe once. + /// + [Theory] + [InlineData(60, 0)] + [InlineData(90, 0)] + [InlineData(91, 1)] + [InlineData(1440, 1)] + public async Task LongQueries_ARangeNoLongerThanTheSlack_MakesNoProbeCall(int rangeMinutes, int expectedProbeCalls) + { + await _duckDb.InitializeAsync(); + var end = DateTime.UtcNow; + await SeedLogRunsAsync("long_query_completions", end.AddDays(-2), end, 5); + var service = new LocalDataService(_duckDb); + var start = end.AddMinutes(-rangeMinutes); + var probeCalls = 0; + + var floor = await ServerTab.ProbeWindowFloorOrNullAsync( + () => + { + probeCalls++; + return service.GetQueryWindowFloorAsync(QueryWindowRelation.LongQueryCompletions, ServerId, start, end, ServerClock.Utc); + }, + "Long Queries", start, end); + + Assert.Equal(expectedProbeCalls, probeCalls); + Assert.Equal(expectedProbeCalls == 0, floor is null); + } + + /// + /// The Collection Log read fills its cap on a dense log (a run every minute for 12 hours is 720 rows, the cap 500), so + /// the notice names the oldest run the grid returned, over a range the store covers (an older run sits 20 days back, + /// so the probe alone shows nothing), and the probing step is not asked. + /// + [Fact] + public async Task CollectionLog_AReadThatFillsItsCap_NamesItsOldestRow_AlsoOverARangeTheStoreCovers() + { + await _duckDb.InitializeAsync(); + var end = DateTime.UtcNow; + await SeedLogRunsAsync("wait_stats", end.AddDays(-20), end.AddDays(-20), 1); + await SeedLogRunsAsync("wait_stats", end.AddHours(-12), end, 1); + + Assert.False((await BannerForAsync(QueryWindowRelation.CollectionLog, end.AddDays(-7), end)).Visible); + + var (visible, text, oldest, rows, probed) = await CappedCollectionLogBannerAsync(end.AddDays(-7), end); + + Assert.Equal(LocalDataService.CollectionLogGridCap, rows); + Assert.InRange((end - oldest).TotalMinutes, 498, 501); + Assert.True(visible); + Assert.Equal(Since(oldest), text); + Assert.False(probed); + } + + /// A Collection Log read under its cap keeps the coverage notice, worded at the first run in the range. + [Fact] + public async Task CollectionLog_AReadUnderItsCap_KeepsTheCoverageNotice() + { + await _duckDb.InitializeAsync(); + var end = DateTime.UtcNow; + var added = end.AddHours(-3); + await SeedLogRunsAsync("wait_stats", added, end, 5); + + var (visible, text, _, rows, probed) = await CappedCollectionLogBannerAsync(end.AddDays(-7), end); + + Assert.InRange(rows, 1, LocalDataService.CollectionLogGridCap - 1); + Assert.True(probed); + Assert.True(visible); + Assert.Equal(Since(added), text); + } + + /// + /// The other twelve banners (the eight system_health grids, Default Trace and the three Config Changes grids) read + /// their whole window: none of their reads carries a row cap, so the probe alone words their notice. A LIMIT + /// added to one of these reads would need the cap-aware step the Collection Log and Long Queries use, and fails here + /// until it has it. + /// + [Theory] + [InlineData("LocalDataService.SystemEvents.cs")] + [InlineData("LocalDataService.ConfigChanges.cs")] + public void TheUncappedSurfaces_ReadNoRowCap(string file) + { + var code = StripComments(File.ReadAllText(RepoFile("Lite", "Services", file)).Replace("\r\n", "\n")); + + Assert.DoesNotMatch(@"\bLIMIT\b", code); + Assert.DoesNotMatch(@"\bTOP\s*\(?\s*\d", code); + } + /// /// The surfaces that need no notice, and the shape that makes it true. The Health Summary reads a fixed seven /// days per collector, with no range parameter, so a picked range never starts before its data. The Duration diff --git a/Lite/Controls/ServerTab.LongQueries.cs b/Lite/Controls/ServerTab.LongQueries.cs index 73521d730a..746384d42f 100644 --- a/Lite/Controls/ServerTab.LongQueries.cs +++ b/Lite/Controls/ServerTab.LongQueries.cs @@ -28,12 +28,26 @@ internal static string LongQueriesDisabledText(bool isAzureSqlDatabase) => + "the 'long_query_completions' Enabled box. " + LongQueryCompletionsCollector.SessionScopeSentence(isAzureSqlDatabase); + /// + /// The instant the Long Queries grid's "Showing since" notice compares (#4989): the completion's own time, + /// event_time, the XE @timestamp, which is UTC (the grid shows it through the server's clock, and the + /// read orders and caps on it), and not the time a run stored the row. A completion without an event time falls back to + /// the time its run was collected, which is UTC too. The Default Trace is the one grid whose time is the server's wall + /// clock (); this one needs no conversion. + /// + internal static DateTime LongQueryRowTimeUtc(LongQueryCompletionRow row) => row.EventTime ?? row.CollectionTime; + /// /// Tab 20 — Long Queries (#1496): completed long-running queries (rpc/batch over the duration /// threshold) plus attentions (cancels/timeouts) from the opt-in XE session. Because the collector /// is default OFF, an explicit empty-state banner is shown while it is disabled, pointing the /// operator at the exact switch (Settings → Collector Schedules → Edit → the Enabled box). The /// enabled state is read live each refresh so toggling it updates the banner without reopening. + /// + /// The grid reads only the newest completions, so its "Showing + /// since" notice goes through the cap-aware step (#4989): a read that hit the cap is worded from the oldest completion + /// it returned, even where the store covers the range, and with no slack (the notice shows whenever that completion is + /// later than the range's start, on a range of an hour too). A read under the cap keeps the coverage notice. /// private async Task RefreshLongQueriesAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) { @@ -51,7 +65,8 @@ private async Task RefreshLongQueriesAsync(int hoursBack, DateTime? fromDate, Da /* View-only DESCENDING-by-duration sort — never flips the reader's chronological ORDER BY. */ SetDefaultSortIfNone(LongQueryCompletionsGrid, "DurationMicroseconds", ListSortDirection.Descending); - await RefreshStoredWindowBannerAsync(QueryWindowRelation.LongQueryCompletions, LongQueriesWindowTruncatedBanner, hoursBack, fromDate, toDate); + var (windowStart, windowEnd) = LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate); + await RefreshCappedGridBannerAsync(QueryWindowRelation.LongQueryCompletions, LongQueriesWindowTruncatedBanner, windowStart, windowEnd, task.Result, LocalDataService.LongQueryGridCap, LongQueryRowTimeUtc); } catch (Exception ex) { diff --git a/Lite/Controls/ServerTab.Refresh.cs b/Lite/Controls/ServerTab.Refresh.cs index 020dcba761..1b9799c598 100644 --- a/Lite/Controls/ServerTab.Refresh.cs +++ b/Lite/Controls/ServerTab.Refresh.cs @@ -501,7 +501,10 @@ await System.Threading.Tasks.Task.WhenAll( /// matching MCP tool uses, so the grid and the tool never disagree about whether a window was cut short. /// Every surface that carries the banner reaches it here: the three Queries grids, Active Queries, Current Waits, /// the Query Heatmap, Memory Pressure Events and Plan Corrections (the last one through - /// , when its read is under its cap). Called from the sub-tab switch + /// , when its read is under its cap), and, over the toolbar's window, the + /// System Events, Default Trace and Config Changes grids (through RefreshStoredWindowBannerAsync) and the two + /// grids that read a capped page, the Collection Log and Long Queries (through the cap-aware step, like Plan + /// Corrections). Called from the sub-tab switch /// and full-refresh paths below, from the slicer handlers in ServerTab.Slicers.cs, from the Active Queries /// drill-downs in ServerTab.DrillDown.cs and from the helpers in ServerTab.QueriesDataStart.cs — a slicer drag /// re-reads the same grid over a narrower window, which @@ -568,8 +571,8 @@ private async System.Threading.Tasks.Task RefreshWindowTruncatedBannerAsync(Quer /// than the window's start), while a probed floor keeps 's 90-minute slack, /// which absorbs a first collection that lands a little after the window starts. The ONE /// decision for every capped grid: calls it, and the grids that read - /// the newest N rows (Plan Corrections now; Long Queries, Blocked Process Reports and Deadlocks as they adopt it) - /// pass their rows, their cap and the column their rows are windowed on. + /// the newest N rows (Plan Corrections, the Collection Log and Long Queries now; Blocked Process Reports and + /// Deadlocks as they adopt it) pass their rows, their cap and the time their rows are shown and capped on. /// internal static DateTime? CapAwareWindowFloor(DateTime? probedFloor, IReadOnlyCollection rows, int rowCap, Func rowTimeUtc) => rowCap > 0 && rows.Count >= rowCap ? rows.Min(rowTimeUtc) : probedFloor; @@ -1076,7 +1079,9 @@ and the stamped one also holds when the display mode flips or another tab is sel _collectionHealthFilterMgr!.UpdateData(collectionHealthTask.Result); _collectionLogFilterMgr!.UpdateData(collectionLogTask.Result); UpdateCollectorDurationChart(collectionLogTask.Result, hoursBack, fromDate, toDate); - await RefreshStoredWindowBannerAsync(QueryWindowRelation.CollectionLog, CollectionLogWindowTruncatedBanner, hoursBack, fromDate, toDate); + /* #4989: the grid reads only the newest CollectionLogGridCap runs, so its notice goes through the cap-aware step. */ + var (windowStart, windowEnd) = LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate); + await RefreshCappedGridBannerAsync(QueryWindowRelation.CollectionLog, CollectionLogWindowTruncatedBanner, windowStart, windowEnd, collectionLogTask.Result, LocalDataService.CollectionLogGridCap, row => row.CollectionTime); } catch (Exception ex) { diff --git a/Lite/Controls/ServerTab.SystemEvents.cs b/Lite/Controls/ServerTab.SystemEvents.cs index 76f7c06cfe..5e7792e3d7 100644 --- a/Lite/Controls/ServerTab.SystemEvents.cs +++ b/Lite/Controls/ServerTab.SystemEvents.cs @@ -209,9 +209,10 @@ private async System.Threading.Tasks.Task LoadDefaultTraceEventsAsync(int hoursB EngineGapNote(serverName, isAzureSqlDatabase, "system_health_events"); /// - /// #4966: the "Showing since" banner of one grid that reads stored rows over the toolbar's window (the Collection Log, - /// the System Events and Default Trace grids, the Config Changes grids and Long Queries). It probes the UTC window the - /// grid's read took, the same pair + /// #4966: the "Showing since" banner of one grid that reads stored rows over the toolbar's window (the System Events and + /// Default Trace grids and the Config Changes grids: none of them caps its read; the Collection Log and Long Queries + /// read a capped page and go through over the same window, #4989). It probes + /// the UTC window the grid's read took, the same pair /// (), through , so a /// probe that throws hides the banner and the refresh goes on. Each surface calls it after its rows are bound, from the one /// method its read runs through, so every read path of the surface (the sub-tab switch, a range change and the Refresh diff --git a/Lite/Services/LocalDataService.CollectionHealth.cs b/Lite/Services/LocalDataService.CollectionHealth.cs index 67bd05c16e..3c47436fdf 100644 --- a/Lite/Services/LocalDataService.CollectionHealth.cs +++ b/Lite/Services/LocalDataService.CollectionHealth.cs @@ -430,6 +430,14 @@ FROM v_collection_log return await command.ExecuteScalarAsync() is not null and not DBNull; } + /// + /// The Collection Log grid's row cap (#4989): the default maxRows of , + /// so the grid reads the newest this many runs of the window. The grid's "Showing since" notice reads it too, as the + /// cap that says the grid dropped older runs (ServerTab.RefreshCappedGridBannerAsync), so the read's + /// LIMIT and the notice's cap are the one value. + /// + public const int CollectionLogGridCap = 500; + /// /// Gets recent collection log entries for a server, most recent first, bounded to the tab's /// settable window. A preset ends "now" ( from now); a custom range @@ -458,7 +466,7 @@ FROM v_collection_log /// /// The desktop Collection Log tab passes neither and is unaffected: no filter, newest first. /// - public async Task> GetRecentCollectionLogAsync(int serverId, int hoursBack = 4, DateTime? fromDate = null, DateTime? toDate = null, int maxRows = 500, DateTime? asOfUtc = null, string? collectorName = null, double? minDurationMs = null, string? status = null) + public async Task> GetRecentCollectionLogAsync(int serverId, int hoursBack = 4, DateTime? fromDate = null, DateTime? toDate = null, int maxRows = CollectionLogGridCap, DateTime? asOfUtc = null, string? collectorName = null, double? minDurationMs = null, string? status = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); diff --git a/Lite/Services/LocalDataService.LongQueries.cs b/Lite/Services/LocalDataService.LongQueries.cs index ed60eb0807..39a774dbb6 100644 --- a/Lite/Services/LocalDataService.LongQueries.cs +++ b/Lite/Services/LocalDataService.LongQueries.cs @@ -17,6 +17,14 @@ namespace PerformanceMonitorLite.Services; public partial class LocalDataService { + /// + /// The Long Queries grid's row cap (#4989): returns the newest this many + /// completions of the window, by event_time. It is the one value behind the read's LIMIT and behind the + /// grid's "Showing since" notice, which names the oldest completion the grid shows once a read comes back at the cap + /// (ServerTab.RefreshCappedGridBannerAsync), so the two cannot drift apart. + /// + public const int LongQueryGridCap = 200; + /// /// Recent long-running query completions (#1496) from v_long_query_completions (the archive /// union view), for the Long Queries grid. Reads the most recent completions in the window; the grid @@ -38,9 +46,9 @@ public async Task> GetRecentLongQueryCompletionsAsy var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues); command.CommandText = LongQueryCompletionsSelect + @" -FROM " + StoredEventCopies.LongQueryCompletions("server_id = $1 AND collection_time <= $3" + dbClause, collectedFrom: "$2") + @" AS ev +FROM " + StoredEventCopies.LongQueryCompletions("server_id = $1 AND collection_time <= $3" + dbClause, collectedFrom: "$2") + $@" AS ev ORDER BY event_time DESC -LIMIT 200"; +LIMIT {LongQueryGridCap}"; command.Parameters.Add(new DuckDBParameter { Value = serverId }); command.Parameters.Add(new DuckDBParameter { Value = startTime }); From 261356ae825662fb7dd004f30655b54eee72cb29 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 3 Oct 2026 03:49:15 -0400 Subject: [PATCH 6/9] Lite tests: the Refresh window census counts the Collection Log's cap-aware notice (#4966) --- Lite.Tests/QueryWindowTruncationTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Lite.Tests/QueryWindowTruncationTests.cs b/Lite.Tests/QueryWindowTruncationTests.cs index f191cb5392..b65e12cdfb 100644 --- a/Lite.Tests/QueryWindowTruncationTests.cs +++ b/Lite.Tests/QueryWindowTruncationTests.cs @@ -699,7 +699,9 @@ each take their banner window from GetQueriesTabWindowUtc too (see DataStartBann $"expected all 10 ServerTab.Refresh.cs banner calls to pass a GetQueriesTabWindowUtc result " + $"(windowStart/windowEnd) (found {refreshBannerCallsOnHelperOutput}) -- a server-local cStart must " + "not feed the banner (#4279/#4284)."); - Assert.Equal(10, Regex.Matches(refreshSource, @"LocalDataService\.GetQueriesTabWindowUtc\(").Count); + /* 10 -> 11: the Collection Log's cap-aware notice (RefreshCappedGridBannerAsync, #4989) takes its window from + GetQueriesTabWindowUtc too; it is not one of the 10 RefreshWindowTruncatedBannerAsync calls counted above. */ + Assert.Equal(11, Regex.Matches(refreshSource, @"LocalDataService\.GetQueriesTabWindowUtc\(").Count); } private static string ControlsFile(string name) => Path.Combine(ControlsDir(), name); From f7924adf36f08dfc4ef8633bfe8dcb4c41e1c5bb Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 3 Oct 2026 04:16:54 -0400 Subject: [PATCH 7/9] Census: the Long Queries grid read leaves the literal-LIMIT roster, its cap being a named constant (#4989) GetRecentLongQueryCompletionsAsync now ends its statement with LIMIT {LongQueryGridCap}, the one constant behind the read and behind the grid's "Showing since" notice, so no literal LIMIT ends it. The terminal literal-LIMIT roster in McpPayloadContractCensusTests drops its entry for that read, and its summary now counts six remaining reads: five viewer-only reads and the plan cache ceiling. The Collection Log grid's cap was already a bound parameter, so it was never in the roster. --- Darling/Darling.Tests/McpPayloadContractCensusTests.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs index 29bf5cc058..b627e45388 100644 --- a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs +++ b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs @@ -2248,11 +2248,14 @@ public void TheDailySummary_PublishesOneBandSpelling_OnBothSkus() /// /// Every literal LIMIT n (n > 1) that ENDS a reader statement on either SKU — the shape - /// #3541 A3 and #3659 found behind tools that advertised limit. Seven remain, all in Lite's - /// service layer, none behind a tool that takes a limit: six are viewer-only reads and one + /// #3541 A3 and #3659 found behind tools that advertised limit. Six remain, all in Lite's + /// service layer, none behind a tool that takes a limit: five are viewer-only reads and one /// (GetPlanCacheSnapshotAsync, behind get_plan_cache_bloat, which takes no cap) is a /// ceiling of 30 over a population of a dozen cache types. LIMIT 1 is the latest-row idiom and is - /// not a page. Pinned so a new terminal literal has to say what it is, and so the Lite twin of + /// not a page. The Long Queries grid's read (GetRecentLongQueryCompletionsAsync) left the roster in + /// #4989: its cap is the named constant LongQueryGridCap, written into the statement as + /// LIMIT {LongQueryGridCap}, so no literal ends it, and the grid's "Showing since" notice reads the same + /// constant. Pinned so a new terminal literal has to say what it is, and so the Lite twin of /// has a /// population to shrink. /// @@ -2260,7 +2263,6 @@ public static readonly (string File, string Member, int Limit)[] StatementTermin [ ("LocalDataService.Blocking.cs", "GetBlockingPairRowsAsync", 5000), ("LocalDataService.FinOps.Recommendations.cs", "GetRecommendationsAsync", 10), - ("LocalDataService.LongQueries.cs", "GetRecentLongQueryCompletionsAsync", 200), ("LocalDataService.PlanCache.cs", "GetPlanCacheSnapshotAsync", 30), ("LocalDataService.RunningJobs.cs", "GetAnomalousJobsAsync", 5), ("LocalDataService.WaitStats.cs", "GetAllQuerySnapshotsInRangeAsync", 2000), From 8d786686324f6cf74484722c172f5a10d3aee512 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 3 Oct 2026 05:19:38 -0400 Subject: [PATCH 8/9] Lite: the Duration Trends chart reads its own buckets over the whole range, Long Queries names the oldest completion shown, and first-run history depth is stated correctly (#4989) The Duration Trends chart was handed the Collection Log grid's page (the newest 500 runs) while its X axis is pinned to the whole range, so a long range drew only the newest slice. It now reads per-collector buckets over the whole range (GetCollectorDurationTrendAsync, at the width the other trend charts use), draws each bucket's slowest run and names the average and the run count in the hover. Long Queries under its cap now names the earlier of the coverage start and the oldest completion shown, as the Darling viewer does, since a first run stores completions from before itself and the grid shows their event times. The doc comment on the probe's time column said a first run's event history goes back days; that holds for the Default Trace only, system_health and Long Queries reach about 10 minutes back. --- .../DataStartBannerProbeFailureTests.cs | 2 +- Lite.Tests/DataStartBannerQueriesTabTests.cs | 2 +- Lite.Tests/DataStartBannerSurfaceTests.cs | 207 +++++++++++++++++- Lite.Tests/DisplayZoneTextFrameTests.cs | 4 +- Lite/Controls/ServerTab.Charts.cs | 94 +++++--- Lite/Controls/ServerTab.LongQueries.cs | 6 +- Lite/Controls/ServerTab.Refresh.cs | 38 +++- .../LocalDataService.CollectionHealth.cs | 75 +++++++ .../LocalDataService.QueryWindowFloor.cs | 13 +- PerformanceMonitor.Ui/ChartHoverHelper.cs | 37 +++- 10 files changed, 422 insertions(+), 56 deletions(-) diff --git a/Lite.Tests/DataStartBannerProbeFailureTests.cs b/Lite.Tests/DataStartBannerProbeFailureTests.cs index 0d44da3cb3..a8fffe84fc 100644 --- a/Lite.Tests/DataStartBannerProbeFailureTests.cs +++ b/Lite.Tests/DataStartBannerProbeFailureTests.cs @@ -162,7 +162,7 @@ public void TheSharedBannerRefresh_GoesThroughTheGuardedProbe() var source = File.ReadAllText(ControlsFile("ServerTab.Refresh.cs")); Assert.Single(Regex.Matches(source, - @"private async System\.Threading\.Tasks\.Task RefreshWindowTruncatedBannerAsync\([^)]*\)\s*\{\s*var floor = await ProbeWindowFloorOrNullAsync\(\s*\(\) => Task\.Run\(\(\) => _dataService\.GetQueryWindowFloorAsync\(relation, _serverId, startUtc, endUtc\)\),\s*\$""[^""]*"",\s*startUtc,\s*endUtc\);\s*ApplyWindowFloorToBanner\(banner, floor, startUtc, GetPickerZone\(\)\);")); + @"private async System\.Threading\.Tasks\.Task RefreshWindowTruncatedBannerAsync\([^)]*\)\s*\{\s*var floor = await ProbeWindowFloorOrNullAsync\(\s*\(\) => Task\.Run\(\(\) => _dataService\.GetQueryWindowFloorAsync\(relation, _serverId, startUtc, endUtc\)\),\s*\$""[^""]*"",\s*startUtc,\s*endUtc\);\s*ApplyWindowFloorToBanner\(banner, EarlierOfFloorAndRowShown\(floor, earliestRowShownUtc\), startUtc, GetPickerZone\(\)\);")); } /// WPF objects require STA, and a probe that has already completed keeps the continuation on this thread. diff --git a/Lite.Tests/DataStartBannerQueriesTabTests.cs b/Lite.Tests/DataStartBannerQueriesTabTests.cs index 1c4dbceae4..fda4f4c9ca 100644 --- a/Lite.Tests/DataStartBannerQueriesTabTests.cs +++ b/Lite.Tests/DataStartBannerQueriesTabTests.cs @@ -316,7 +316,7 @@ public void CappedGridBannerStep_AReadUnderItsCap_FallsThroughToTheProbingStep() public void RefreshCappedGridBannerAsync_HandsTheDecisionThePickerZone_AndTheSharedProbingStep() { Assert.Matches( - @"private System\.Threading\.Tasks\.Task RefreshCappedGridBannerAsync\(\s*QueryWindowRelation relation, TextBlock banner, DateTime startUtc, DateTime endUtc,\s*IReadOnlyCollection rows, int rowCap, Func rowTimeUtc\) =>\s*CappedGridBannerAsync\(rows, rowCap, rowTimeUtc,\s*oldestRowShown => ApplyCappedWindowFloorToBanner\(banner, oldestRowShown, startUtc, GetPickerZone\(\)\),\s*\(\) => RefreshWindowTruncatedBannerAsync\(relation, banner, startUtc, endUtc\)\);", + @"private System\.Threading\.Tasks\.Task RefreshCappedGridBannerAsync\(\s*QueryWindowRelation relation, TextBlock banner, DateTime startUtc, DateTime endUtc,\s*IReadOnlyCollection rows, int rowCap, Func rowTimeUtc\) =>\s*CappedGridBannerAsync\(rows, rowCap, rowTimeUtc,\s*oldestRowShown => ApplyCappedWindowFloorToBanner\(banner, oldestRowShown, startUtc, GetPickerZone\(\)\),\s*\(\) => RefreshWindowTruncatedBannerAsync\(relation, banner, startUtc, endUtc, EarliestRowShown\(rows, rowTimeUtc\)\)\);", CodeOf("ServerTab.Refresh.cs")); } diff --git a/Lite.Tests/DataStartBannerSurfaceTests.cs b/Lite.Tests/DataStartBannerSurfaceTests.cs index 976058b020..e65de19311 100644 --- a/Lite.Tests/DataStartBannerSurfaceTests.cs +++ b/Lite.Tests/DataStartBannerSurfaceTests.cs @@ -34,9 +34,9 @@ namespace PerformanceMonitorLite.Tests; /// quiet start (the collector covered the whole range, the first row comes late) shows none. /// /// The surfaces with no notice are pinned too (): -/// the Health Summary is a fixed seven-day aggregate that does not read the toolbar's range, and the Duration Trends, -/// Corruption Events and Contention Events charts pin their time axis to the asked range, so an empty span is already -/// drawn as one. +/// the Health Summary is a fixed seven-day aggregate that does not read the toolbar's range, the Corruption Events and +/// Contention Events charts pin their time axis to the asked range, so an empty span is already drawn as one, and the +/// Duration Trends chart reads its own full-range buckets, with its axis pinned the same way. /// [Collection("server-time-helper")] public sealed class DataStartBannerSurfaceTests : IDisposable @@ -326,7 +326,9 @@ private async Task SeedFirstRunAsync(QueryWindowRelation relation, DateTime firs /// /// #4989: a server's first run stores the server's event history, every row stamped with that run's - /// collection_time (T0) while its event_time goes back days. The grids filter on event_time, so a + /// collection_time (T0) while its event_time is older: days for the Default Trace, whose first run stores the + /// history its trace files hold, and about 10 minutes for system_health, whose first run reads back + /// CollectorContext.EventFallbackWindow. The tests seed the history at whatever depth they test. The grids filter on event_time, so a /// range that starts before the oldest event (S < H < T0) shows rows from H, and the banner names H, never T0: a /// banner never names a time later than the earliest row its grid shows. RED before the fix: the probe measured /// collection_time, found only T0, and said "Showing since T0" above rows from before T0. @@ -963,10 +965,13 @@ public void TheUncappedSurfaces_ReadNoRowCap(string file) /// /// The surfaces that need no notice, and the shape that makes it true. The Health Summary reads a fixed seven - /// days per collector, with no range parameter, so a picked range never starts before its data. The Duration - /// Trends, Corruption Events and Contention Events charts set their X axis to the asked range, so a span with no - /// data is drawn as the empty span it is. If one of them starts reading the toolbar's range as a grid, or stops - /// pinning its axis, it needs a notice and this test says so. + /// days per collector, with no range parameter, so a picked range never starts before its data. The Corruption Events + /// and Contention Events charts set their X axis to the asked range, so a span with no data is drawn as the empty + /// span it is. The Duration Trends chart needs none for its own reason (#4989): it reads its own full-range buckets + /// (, not the Collection Log grid's capped page) with its + /// X axis pinned to the asked range, so what it draws is the range's data and the empty span is the span with none; the + /// DurationTrends_* tests below pin that. If one of the others starts reading the toolbar's range as a grid, or + /// stops pinning its axis, it needs a notice and this test says so. /// [Fact] public void SurfacesWithoutANotice_KeepTheShapeThatNeedsNone() @@ -975,10 +980,8 @@ public void SurfacesWithoutANotice_KeepTheShapeThatNeedsNone() Assert.Contains("public async Task> GetCollectionHealthAsync(int serverId)", health, StringComparison.Ordinal); Assert.Contains("GetCollectionHealthAsync(_serverId)", File.ReadAllText(ControlsFile("ServerTab.Refresh.cs")), StringComparison.Ordinal); - var durationChart = Regex.Match(File.ReadAllText(ControlsFile("ServerTab.Charts.cs")).Replace("\r\n", "\n"), - @"private void UpdateCollectorDurationChart\(.*?\n \}\n", RegexOptions.Singleline); - Assert.True(durationChart.Success); - Assert.Contains("SetLimitsX(xMin, xMax)", durationChart.Value, StringComparison.Ordinal); + /* The Duration Trends chart is no longer pinned here: it reads its own buckets over the whole range, and the tests + below (DurationTrends_*) say what it draws. */ var systemCharts = File.ReadAllText(ControlsFile("ServerTab.SystemHealthCharts.cs")); Assert.Contains("ChartPalette.CyclingColor(3), xMin, xMax)", systemCharts, StringComparison.Ordinal); @@ -988,6 +991,186 @@ public void SurfacesWithoutANotice_KeepTheShapeThatNeedsNone() Regex.Escape("chart.Plot.Axes.SetLimitsX(xMin, xMax)")).Count); } + /// What a chart draws, as (first X, last X) of each plotted line: the series drawn on a real chart. + private static List<(double First, double Last)> DrawnSpans(IReadOnlyList series) => + OnStaThread(() => + { + var chart = new ScottPlot.WPF.WpfPlot(); + ServerTab.PlotCollectorDurationSeries(chart, null, series); + return chart.Plot.PlottableList.OfType() + .Select(scatter => scatter.Data.GetScatterPoints().Select(p => p.X).ToList()) + .Select(xs => (xs.Min(), xs.Max())) + .ToList(); + }); + + /// + /// #4989: a 24-hour range holding more runs than the Collection Log grid's page (the newest 500) draws points across + /// the WHOLE range. The chart used to be handed that page, so at three runs a minute it drew the newest 2 hours 47 + /// minutes of a day (and at twenty a minute, 25 minutes) and left the rest of its pinned axis empty though the store + /// held those runs. The first assertions say the page is the sliver it is; the rest, that each collector's line starts + /// at the range's start and ends at its end. + /// + [Fact] + public async Task DurationTrends_ADayHoldingMoreRunsThanTheGridsPage_DrawsPointsAcrossTheWholeRange() + { + await _duckDb.InitializeAsync(); + var end = DateTime.UtcNow; + var start = end.AddHours(-24); + foreach (var collector in new[] { "wait_stats", "cpu_utilization_stats", "memory_stats" }) + { + await SeedLogRunsAsync(collector, start, end, 1); + } + + var service = new LocalDataService(_duckDb); + var page = await service.GetRecentCollectionLogAsync(ServerId, fromDate: start, toDate: end); + Assert.Equal(LocalDataService.CollectionLogGridCap, page.Count); + Assert.True(page.Max(r => r.CollectionTime) - page.Min(r => r.CollectionTime) < TimeSpan.FromHours(4), + "the grid's page is the newest runs only"); + + var spans = DrawnSpans(ServerTab.BuildCollectorDurationSeries(await service.GetCollectorDurationTrendAsync(ServerId, fromDate: start, toDate: end))); + + Assert.Equal(3, spans.Count); + foreach (var (first, last) in spans) + { + Assert.True(first <= start.AddMinutes(2).ToOADate(), $"a line starts {DateTime.FromOADate(first):O}, after the range's start {start:O}"); + Assert.True(last >= end.AddMinutes(-2).ToOADate(), $"a line ends {DateTime.FromOADate(last):O}, before the range's end {end:O}"); + } + } + + /// + /// #4989: the chart draws each bucket's slowest run, so one slow run still shows as it did when every run was a point, + /// and the hover says what stands behind the point: how many runs it is the slowest of and their average. A 7-day range + /// of one run a minute is bucketed (far fewer points than runs, and every run counted once), and the one 9-second run + /// in it is the height of its bucket's point. + /// + [Fact] + public async Task DurationTrends_ASevenDayRange_DrawsEachBucketsSlowestRun_AndNamesTheAverageAndCountInTheHover() + { + await _duckDb.InitializeAsync(); + var end = DateTime.UtcNow; + var start = end.AddDays(-7); + await SeedLogRunsAsync("wait_stats", start, end, 1); + using (var connection = _duckDb.CreateConnection()) + { + await connection.OpenAsync(); + using var readLock = _duckDb.AcquireReadLock(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = $@" +UPDATE collection_log SET duration_ms = 9000 +WHERE collector_name = 'wait_stats' AND collection_time = (SELECT MIN(collection_time) FROM collection_log WHERE collection_time >= {Literal(start.AddDays(3))})"; + await cmd.ExecuteNonQueryAsync(); + } + + var buckets = await new LocalDataService(_duckDb).GetCollectorDurationTrendAsync(ServerId, fromDate: start, toDate: end); + + Assert.True(buckets.Count < 2000, $"{buckets.Count} points for 10,081 runs: the read buckets"); + Assert.Equal(10081, buckets.Sum(b => b.RunCount)); + Assert.True(buckets.All(b => b.BucketStart >= start.AddTicks(-10)), "no bucket starts before the range"); + var slow = Assert.Single(buckets, b => b.MaxDurationMs == 9000); + Assert.True(slow.RunCount >= 2); + Assert.Equal((9000 + 12.0 * (slow.RunCount - 1)) / slow.RunCount, slow.AverageDurationMs, 6); + + var series = Assert.Single(ServerTab.BuildCollectorDurationSeries(buckets)); + var at = Array.IndexOf(series.MaxMs, 9000d); + Assert.True(at >= 0, "the slow run is a point's height"); + var culture = System.Globalization.CultureInfo.CurrentCulture; + var average = slow.AverageDurationMs == Math.Floor(slow.AverageDurationMs) ? slow.AverageDurationMs.ToString("N0", culture) : slow.AverageDurationMs.ToString("N1", culture); + Assert.Equal($"Slowest of {slow.RunCount.ToString("N0", culture)} runs; average {average} ms", series.Details[at]); + + Assert.Equal("wait_stats\n9,000 ms\n10:00:00\nSlowest of 10 runs; average 910.8 ms", + PerformanceMonitor.Ui.ChartHoverHelper.HoverText("wait_stats", "9,000", "ms", "10:00:00", "Slowest of 10 runs; average 910.8 ms")); + Assert.Equal("wait_stats\n9,000 ms\n10:00:00", PerformanceMonitor.Ui.ChartHoverHelper.HoverText("wait_stats", "9,000", "ms", "10:00:00")); + } + + /// + /// #4989: the Refresh hands the chart its own read (GetCollectorDurationTrendAsync), started beside the grid's + /// read and awaited with it, and never the grid's page. + /// + [Fact] + public void DurationTrends_TheChartIsFedItsOwnRead_StartedBesideTheGridsRead() + { + var code = StripComments(File.ReadAllText(ControlsFile("ServerTab.Refresh.cs")).Replace("\r\n", "\n")); + var start = code.IndexOf("private async System.Threading.Tasks.Task RefreshCollectionHealthAsync(", StringComparison.Ordinal); + Assert.True(start >= 0); + var body = code[start..code.IndexOf("private static async Task> SafeQueryAsync", start, StringComparison.Ordinal)]; + + Assert.Contains("_dataService.GetCollectorDurationTrendAsync(_serverId, hoursBack, fromDate, toDate)", body, StringComparison.Ordinal); + Assert.Contains("Task.WhenAll(collectionHealthTask, collectionLogTask, collectorDurationTask)", body, StringComparison.Ordinal); + Assert.Contains("UpdateCollectorDurationChart(collectorDurationTask.Result, hoursBack, fromDate, toDate)", body, StringComparison.Ordinal); + Assert.DoesNotContain("UpdateCollectorDurationChart(collectionLogTask", body, StringComparison.Ordinal); + } + + private async Task SeedLongQueryAsync(DateTime eventUtc, DateTime collectedUtc) + { + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + using var readLock = _duckDb.AcquireReadLock(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = $@" +INSERT INTO long_query_completions (long_query_completion_id, collection_time, server_id, server_name, event_time, event_type, database_name, statement_text, duration_microseconds) +VALUES ({_nextId++}, {Literal(collectedUtc)}, {ServerId}, '{ServerName}', {Literal(eventUtc)}, 'rpc_completed', 'Db', 'SELECT 1', 5000000)"; + await cmd.ExecuteNonQueryAsync(); + } + + /// The Long Queries notice for a read UNDER its cap: the real read, the real probe, then the tab's own rule for + /// the floor ( over , the pair + /// RefreshCappedGridBannerAsync hands the shared step) and its banner step: (visible, text, rows shown). + private async Task<(bool Visible, string Text, int Rows)> UnderCapLongQueriesBannerAsync(DateTime startUtc, DateTime endUtc) + { + var service = new LocalDataService(_duckDb); + var rows = await service.GetRecentLongQueryCompletionsAsync(ServerId, fromDate: startUtc, toDate: endUtc); + var probed = await service.GetQueryWindowFloorAsync(QueryWindowRelation.LongQueryCompletions, ServerId, startUtc, endUtc, ServerClock.Utc); + var floor = ServerTab.EarlierOfFloorAndRowShown(probed, ServerTab.EarliestRowShown(rows, ServerTab.LongQueryRowTimeUtc)); + var (visible, text) = OnStaThread(() => + { + var banner = new System.Windows.Controls.TextBlock(); + ServerTab.ApplyWindowFloorToBanner(banner, floor, startUtc, TimeZoneInfo.Utc); + return (banner.Visibility == System.Windows.Visibility.Visible, banner.Text); + }); + return (visible, text, rows.Count); + } + + /// + /// #4989: a server's first run (T0, 48 hours ago) stores a completion from 8 minutes before itself (a first run reads + /// back ), and the grid shows it at its + /// event time. Under the cap, on a 7-day range, the notice names that completion's time, T0 less 8 minutes, and not the + /// run's T0. + /// + [Fact] + public async Task LongQueries_UnderTheCap_NameTheOldestCompletionShown_WhenItPredatesTheFirstRun() + { + await _duckDb.InitializeAsync(); + var end = DateTime.UtcNow; + var firstRun = new DateTime(end.AddHours(-48).Ticks / TimeSpan.TicksPerSecond * TimeSpan.TicksPerSecond, DateTimeKind.Utc); + await SeedLogRunsAsync("long_query_completions", firstRun, end, 60); + await SeedLongQueryAsync(firstRun.AddMinutes(-8), firstRun); + await SeedLongQueryAsync(firstRun.AddHours(2), firstRun.AddHours(2).AddSeconds(CollectedAfterSeconds)); + + var (visible, text, rows) = await UnderCapLongQueriesBannerAsync(end.AddDays(-7), end); + + Assert.InRange(rows, 1, LocalDataService.LongQueryGridCap - 1); + Assert.True(visible); + Assert.Equal(Since(firstRun.AddMinutes(-8)), text); + } + + /// + /// #4989: with no completion older than the first run, the notice still names the run, as before. + /// + [Fact] + public async Task LongQueries_UnderTheCap_StillNameTheFirstRun_WhenNoCompletionShownIsOlderThanIt() + { + await _duckDb.InitializeAsync(); + var end = DateTime.UtcNow; + var firstRun = new DateTime(end.AddHours(-48).Ticks / TimeSpan.TicksPerSecond * TimeSpan.TicksPerSecond, DateTimeKind.Utc); + await SeedLogRunsAsync("long_query_completions", firstRun, end, 60); + await SeedLongQueryAsync(firstRun.AddMinutes(30), firstRun.AddMinutes(30).AddSeconds(CollectedAfterSeconds)); + + var (visible, text, _) = await UnderCapLongQueriesBannerAsync(end.AddDays(-7), end); + + Assert.True(visible); + Assert.Equal(Since(firstRun), text); + } + private static string StripComments(string lfSource) => Regex.Replace(Regex.Replace(lfSource, @"/\*.*?\*/", string.Empty, RegexOptions.Singleline), @"//[^\n]*", string.Empty); diff --git a/Lite.Tests/DisplayZoneTextFrameTests.cs b/Lite.Tests/DisplayZoneTextFrameTests.cs index 67f2876de5..3051feec9a 100644 --- a/Lite.Tests/DisplayZoneTextFrameTests.cs +++ b/Lite.Tests/DisplayZoneTextFrameTests.cs @@ -225,7 +225,9 @@ public void TheRefreshHeaderAndTheTruncationBanner_UseTheTabsOwnClockAndZone() Assert.Contains( "ServerTimeHelper.GetTimezoneLabel(ServerTimeHelper.CurrentDisplayMode, _serverClock, refreshedUtc)", refresh, StringComparison.Ordinal); Assert.Contains("DisplayZone.ToDisplay(refreshedUtc, GetPickerZone())", refresh, StringComparison.Ordinal); - Assert.Contains("ApplyWindowFloorToBanner(banner, floor, startUtc, GetPickerZone());", refresh, StringComparison.Ordinal); + /* #4989: the shared step words the earlier of the probe's floor and the oldest row a capped grid shows; the zone + it is worded in is still the tab's own picker zone. */ + Assert.Contains("ApplyWindowFloorToBanner(banner, EarlierOfFloorAndRowShown(floor, earliestRowShownUtc), startUtc, GetPickerZone());", refresh, StringComparison.Ordinal); Assert.Contains( "internal static bool ApplyWindowFloorToBanner(TextBlock banner, DateTime? floor, DateTime startUtc, TimeZoneInfo zone)", refresh, StringComparison.Ordinal); diff --git a/Lite/Controls/ServerTab.Charts.cs b/Lite/Controls/ServerTab.Charts.cs index dd7d5788b7..e23299f82e 100644 --- a/Lite/Controls/ServerTab.Charts.cs +++ b/Lite/Controls/ServerTab.Charts.cs @@ -1574,7 +1574,72 @@ is this tab. */ /* ========== Collection Health ========== */ - private void UpdateCollectorDurationChart(List data, int hoursBack, DateTime? fromDate, DateTime? toDate) + /// + /// One collector's line on the Duration Trends chart (#4989): the X of each point (a UTC instant as an OADate, the + /// bucket's start), the Y it draws (the bucket's slowest run, in ms) and the hover line that says what stands behind + /// the point (), all in the same order. + /// + internal sealed record CollectorDurationSeries(string Collector, double[] Xs, double[] MaxMs, string[] Details); + + /// + /// The hover line of one Duration Trends point (#4989): the point draws its bucket's slowest run, so the line says how + /// many runs it is the slowest of and what the average run took. + /// + internal static string CollectorDurationDetail(CollectorDurationBucket bucket) + { + static string Ms(double value) => value == Math.Floor(value) + ? value.ToString("N0", System.Globalization.CultureInfo.CurrentCulture) + : value.ToString("N1", System.Globalization.CultureInfo.CurrentCulture); + + return $"Slowest of {bucket.RunCount.ToString("N0", System.Globalization.CultureInfo.CurrentCulture)} {(bucket.RunCount == 1 ? "run" : "runs")}; average {Ms(bucket.AverageDurationMs)} ms"; + } + + /// + /// What the Duration Trends chart draws from its own read (, + /// #4989): one series per collector, in collector order, each point a bucket at its start and the bucket's slowest + /// run as its height. A collector with fewer than two buckets draws no line, as before. internal static so the + /// tests see exactly what is drawn without building the UserControl. + /// + internal static List BuildCollectorDurationSeries(IEnumerable buckets) => + buckets + .GroupBy(b => b.CollectorName) + .OrderBy(g => g.Key) + .Select(g => g.OrderBy(b => b.BucketStart).ToList()) + .Where(points => points.Count >= 2) + .Select(points => new CollectorDurationSeries( + points[0].CollectorName, + points.Select(b => b.BucketStart.ToOADate()).ToArray(), + points.Select(b => b.MaxDurationMs).ToArray(), + points.Select(CollectorDurationDetail).ToArray())) + .ToList(); + + /// + /// Draws the series on (the lines only: the axes, the legend and the refresh stay with the + /// caller) and registers each with when there is one, with its per-point detail lines. + /// internal static so the tests drive it on a real chart. + /// + internal static void PlotCollectorDurationSeries(ScottPlot.WPF.WpfPlot chart, ChartHoverHelper? hover, IReadOnlyList series) + { + int colorIdx = 0; + foreach (var line in series) + { + var scatter = chart.Plot.Add.TimeSeries(line.Xs, line.MaxMs); + scatter.LegendText = line.Collector; + scatter.Color = ScottPlot.Color.FromHex(SeriesColors[colorIdx % SeriesColors.Length]); + scatter.LineWidth = 2; + scatter.MarkerSize = 0; + hover?.Add(scatter, line.Collector, line.Details); + colorIdx++; + } + } + + /// + /// The Duration Trends chart (#4989): each collector's slowest run per time bucket over the WHOLE asked range, read + /// beside the Collection Log grid (), not the grid's page of + /// the newest runs it used to be handed. The X axis is pinned to the + /// asked range, so a range the store does not cover from its start still draws the empty span it is. + /// + private void UpdateCollectorDurationChart(List data, int hoursBack, DateTime? fromDate, DateTime? toDate) { ClearChart(CollectorDurationChart); ApplyTheme(CollectorDurationChart); @@ -1595,31 +1660,10 @@ which reads as symmetric dead space. This is the one chart the window-pin campai return; } - /* Group by collector, plot each as a separate series */ - var groups = data - .Where(d => d.DurationMs.HasValue && d.Status == "SUCCESS") - .GroupBy(d => d.CollectorName) - .OrderBy(g => g.Key) - .ToList(); - + /* One series per collector, each point a bucket's slowest successful run (the read keeps only the runs the chart + has always drawn: SUCCESS with a duration). */ _collectorDurationHover?.Clear(); - int colorIdx = 0; - foreach (var group in groups) - { - var points = group.OrderBy(d => d.CollectionTime).ToList(); - if (points.Count < 2) continue; - - var times = points.Select(d => d.CollectionTime.ToOADate()).ToArray(); - var durations = points.Select(d => (double)d.DurationMs!.Value).ToArray(); - - var scatter = CollectorDurationChart.Plot.Add.TimeSeries(times, durations); - scatter.LegendText = group.Key; - scatter.Color = ScottPlot.Color.FromHex(SeriesColors[colorIdx % SeriesColors.Length]); - scatter.LineWidth = 2; - scatter.MarkerSize = 0; - _collectorDurationHover?.Add(scatter, group.Key); - colorIdx++; - } + PlotCollectorDurationSeries(CollectorDurationChart, _collectorDurationHover, BuildCollectorDurationSeries(data)); CollectorDurationChart.Plot.Axes.DateTimeTicksBottomUtc(GetPickerZone); ReapplyAxisColors(CollectorDurationChart); diff --git a/Lite/Controls/ServerTab.LongQueries.cs b/Lite/Controls/ServerTab.LongQueries.cs index 746384d42f..3b6b039e9d 100644 --- a/Lite/Controls/ServerTab.LongQueries.cs +++ b/Lite/Controls/ServerTab.LongQueries.cs @@ -47,7 +47,11 @@ internal static string LongQueriesDisabledText(bool isAzureSqlDatabase) => /// The grid reads only the newest completions, so its "Showing /// since" notice goes through the cap-aware step (#4989): a read that hit the cap is worded from the oldest completion /// it returned, even where the store covers the range, and with no slack (the notice shows whenever that completion is - /// later than the range's start, on a range of an hour too). A read under the cap keeps the coverage notice. + /// later than the range's start, on a range of an hour too). A read under the cap keeps the coverage notice, unless a + /// completion it shows has an earlier event time: a server's first run stores completions up to + /// older than itself, and the grid shows each at its event time + /// (), so the notice then names the oldest completion shown + /// (), the same rule the Darling viewer's event grids follow. /// private async Task RefreshLongQueriesAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) { diff --git a/Lite/Controls/ServerTab.Refresh.cs b/Lite/Controls/ServerTab.Refresh.cs index 1b9799c598..5dffe842d1 100644 --- a/Lite/Controls/ServerTab.Refresh.cs +++ b/Lite/Controls/ServerTab.Refresh.cs @@ -522,15 +522,37 @@ await System.Threading.Tasks.Task.WhenAll( /// #4966: a window no longer than the 90-minute slack () can /// never get a coverage note, so the probe is not called for it: the banner is hidden and its text cleared, as for /// any window the probe finds nothing to report on. + /// + /// #4989: a capped grid under its cap hands , the oldest of its rows by the + /// time each is shown on. The notice then names the earlier of the probe's floor and that row + /// (): the Long Queries grid shows each completion at its event time, which on a + /// server's first run can be up to before the run that stored it, + /// and the probe measures the run's time. Every other caller leaves it out and gets the probe's floor as before. /// - private async System.Threading.Tasks.Task RefreshWindowTruncatedBannerAsync(QueryWindowRelation relation, TextBlock banner, DateTime startUtc, DateTime endUtc) + private async System.Threading.Tasks.Task RefreshWindowTruncatedBannerAsync(QueryWindowRelation relation, TextBlock banner, DateTime startUtc, DateTime endUtc, DateTime? earliestRowShownUtc = null) { var floor = await ProbeWindowFloorOrNullAsync( () => Task.Run(() => _dataService.GetQueryWindowFloorAsync(relation, _serverId, startUtc, endUtc)), $"[{_server.DisplayName}] {relation}", startUtc, endUtc); - ApplyWindowFloorToBanner(banner, floor, startUtc, GetPickerZone()); + ApplyWindowFloorToBanner(banner, EarlierOfFloorAndRowShown(floor, earliestRowShownUtc), startUtc, GetPickerZone()); } + /// + /// #4989: the floor a capped grid's notice is worded from when its read stayed UNDER its cap: the probe's floor + /// (), or the oldest row the grid shows () when that is + /// earlier, so a notice never names a time later than a row on the screen. The same rule as the Darling viewer's event + /// grids (ViewerEventDataStart.Of). A null floor stays null: the probe found nothing to report on, its failure + /// hides the banner, and a window no longer than the slack never gets one, and a row shown does not change any of those. + /// For a grid whose rows are shown on the probe's own column (the Collection Log and Plan Corrections) the probe's floor is + /// never later than its oldest row, so this returns the floor unchanged. + /// + internal static DateTime? EarlierOfFloorAndRowShown(DateTime? probedFloor, DateTime? earliestRowShownUtc) => + probedFloor is DateTime floor && earliestRowShownUtc is DateTime shown && shown < floor ? shown : probedFloor; + + /// The oldest of a capped grid's by , or null when it shows none. + internal static DateTime? EarliestRowShown(IReadOnlyCollection rows, Func rowTimeUtc) => + rows.Count == 0 ? null : rows.Min(rowTimeUtc); + /// /// The answer of a window-floor probe, or null when the probe throws, or when the window is no longer than the /// slack (): such a window can never get a coverage note, so the @@ -583,7 +605,8 @@ private async System.Threading.Tasks.Task RefreshWindowTruncatedBannerAsync(Quer /// no probe, and no slack: its reach is what the grid shows, whatever the store holds, and the banner shows whenever /// that oldest row is later than the window's start (), even on a range /// of an hour. Below its cap it is the shared step, - /// , with its probe, as for every other grid. The decision itself is + /// , with its probe, as for every other grid, handed the oldest row the + /// grid shows so that the notice never names a time later than it (, #4989). The decision itself is /// , which this hands the two ways out: the banner worded in the tab's picker /// zone from the oldest row, and that shared step. /// @@ -592,7 +615,7 @@ private System.Threading.Tasks.Task RefreshCappedGridBannerAsync( IReadOnlyCollection rows, int rowCap, Func rowTimeUtc) => CappedGridBannerAsync(rows, rowCap, rowTimeUtc, oldestRowShown => ApplyCappedWindowFloorToBanner(banner, oldestRowShown, startUtc, GetPickerZone()), - () => RefreshWindowTruncatedBannerAsync(relation, banner, startUtc, endUtc)); + () => RefreshWindowTruncatedBannerAsync(relation, banner, startUtc, endUtc, EarliestRowShown(rows, rowTimeUtc))); /// /// #4966: the decision inside . A read that reached its cap @@ -1066,8 +1089,11 @@ private async System.Threading.Tasks.Task RefreshCollectionHealthAsync(int hours { var collectionHealthTask = Helpers.MethodProfiler.TimeAsync("CollectionHealth.Health", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetCollectionHealthAsync(_serverId)))); var collectionLogTask = Helpers.MethodProfiler.TimeAsync("CollectionHealth.Log", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetRecentCollectionLogAsync(_serverId, hoursBack, fromDate, toDate)))); + /* #4989: the Duration Trends chart reads its own buckets over the whole range, beside the grid's read. The grid's + page is the newest CollectionLogGridCap runs, a sliver of a long range, so the chart is not fed from it. */ + var collectorDurationTask = Helpers.MethodProfiler.TimeAsync("CollectionHealth.DurationTrends", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetCollectorDurationTrendAsync(_serverId, hoursBack, fromDate, toDate)))); - await System.Threading.Tasks.Task.WhenAll(collectionHealthTask, collectionLogTask); + await System.Threading.Tasks.Task.WhenAll(collectionHealthTask, collectionLogTask, collectorDurationTask); /* #4766: every row reads its time on THIS tab's server clock, not on whichever clock is active when the grid renders. Both grids sit in this server's own tab, so the two are the same while the tab is showing, @@ -1078,7 +1104,7 @@ and the stamped one also holds when the display mode flips or another tab is sel _collectionHealthFilterMgr!.UpdateData(collectionHealthTask.Result); _collectionLogFilterMgr!.UpdateData(collectionLogTask.Result); - UpdateCollectorDurationChart(collectionLogTask.Result, hoursBack, fromDate, toDate); + UpdateCollectorDurationChart(collectorDurationTask.Result, hoursBack, fromDate, toDate); /* #4989: the grid reads only the newest CollectionLogGridCap runs, so its notice goes through the cap-aware step. */ var (windowStart, windowEnd) = LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate); await RefreshCappedGridBannerAsync(QueryWindowRelation.CollectionLog, CollectionLogWindowTruncatedBanner, windowStart, windowEnd, collectionLogTask.Result, LocalDataService.CollectionLogGridCap, row => row.CollectionTime); diff --git a/Lite/Services/LocalDataService.CollectionHealth.cs b/Lite/Services/LocalDataService.CollectionHealth.cs index 3c47436fdf..69708068b0 100644 --- a/Lite/Services/LocalDataService.CollectionHealth.cs +++ b/Lite/Services/LocalDataService.CollectionHealth.cs @@ -530,6 +530,66 @@ FROM v_collection_log return items; } + /// + /// The Duration Trends chart's own read (#4989): per collector and time bucket over the WHOLE asked range, the slowest + /// run, the average run and the run count, over the successful runs that carry a duration (the runs the chart has always + /// drawn). The chart used to be handed the Collection Log grid's page, the newest + /// runs, while its X axis is pinned to the asked range: at about twenty runs a minute that page holds the newest + /// twenty-five minutes, so "Last 24 hours" drew the newest 25 minutes and left 23 hours and 35 minutes of axis empty + /// though the store holds those runs. This read has no row cap and answers at the width the other desktop trend charts + /// use for the range (, one series' share of , as the + /// Performance Trends charts size their buckets), so the number of points is bounded by the range, not by the runs. + /// + /// Each bucket's start is clamped to the window's start (GREATEST, as the bucketed duration-trend reads + /// do), so an unaligned window's first, partial bucket does not draw before it. The chart draws each bucket's MAXIMUM, + /// so a slow run still shows as it did when every run was a point; the average and the count ride along for its hover. + /// The window is the grid's own (), so the chart and the grid beside it describe one span. + /// + public async Task> GetCollectorDurationTrendAsync(int serverId, int hoursBack = 4, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) + { + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); + + command.CommandText = $@" +SELECT + collector_name, + GREATEST(time_bucket(to_minutes(CAST($4 AS INTEGER)), collection_time, {TrendBuckets.OriginSql}), $2) AS bucket_start, + MAX(duration_ms) AS max_duration_ms, + AVG(duration_ms) AS average_duration_ms, + COUNT(*) AS run_count +FROM v_collection_log +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3 +AND status = 'SUCCESS' +AND duration_ms IS NOT NULL +GROUP BY 1, 2 +ORDER BY 1, 2"; + + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + command.Parameters.Add(new DuckDBParameter { Value = startTime }); + command.Parameters.Add(new DuckDBParameter { Value = endTime }); + command.Parameters.Add(new DuckDBParameter { Value = AutoChartBucketMinutes(startTime, endTime) }); + + var items = new List(); + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new CollectorDurationBucket + { + CollectorName = reader.GetString(0), + BucketStart = reader.GetDateTime(1), + MaxDurationMs = Convert.ToDouble(reader.GetValue(2)), + AverageDurationMs = Convert.ToDouble(reader.GetValue(3)), + RunCount = Convert.ToInt64(reader.GetValue(4)) + }); + } + + return items; + } + /// /// Whether ANY of the given (enabled) servers has EVER recorded a collector run — the fleet-wide twin of /// (#4199), used the same way: to tell a genuinely quiet fleet-wide @@ -728,6 +788,21 @@ public class CollectionLogRow public string DuckDbDurationFormatted => DuckDbDurationMs.HasValue ? $"{DuckDbDurationMs.Value} ms" : ""; } +/// +/// One point of the Duration Trends chart (#4989): a collector's successful runs inside one time bucket of the asked +/// range (). The chart draws ; the +/// hover also names and . is a UTC +/// instant, clamped to the start of the range. +/// +public class CollectorDurationBucket +{ + public string CollectorName { get; set; } = ""; + public DateTime BucketStart { get; set; } + public double MaxDurationMs { get; set; } + public double AverageDurationMs { get; set; } + public long RunCount { get; set; } +} + /// /// One Collection Health grid row — a collector's 7-day roll-up with its health band. /// delegates to the shared in diff --git a/Lite/Services/LocalDataService.QueryWindowFloor.cs b/Lite/Services/LocalDataService.QueryWindowFloor.cs index d5a07b81d8..fdca2e8854 100644 --- a/Lite/Services/LocalDataService.QueryWindowFloor.cs +++ b/Lite/Services/LocalDataService.QueryWindowFloor.cs @@ -108,10 +108,15 @@ public partial class LocalDataService /// collection_time for most relations, capture_time for the three config snapshots (their collectors stamp /// it instead, ICollectorSchemaInfo.PrefixTimeColumnName, and the archive purges by it), and event_time /// for the two event relations (#4989): the System Events grids that read system_health events and the Default Trace - /// grid filter on the event's own time, not on the time a run stored it. A server's first run stores the history the - /// server already holds, every row stamped with that run's collection_time while its event_time goes back - /// days, and the probe reading collection_time there named the run above rows from before it. The archive still - /// purges those two tables by collection_time. The system_health event_time is the XE @timestamp, + /// grid filter on the event's own time, not on the time a run stored it. A server's first run can store events from + /// before itself, every row stamped with that run's collection_time while its event_time is older, and the + /// probe reading collection_time there named the run above rows from before it. How far back depends on the + /// collector: the Default Trace's first run stores the history the trace files already hold, which can go back days, + /// while a first run of the system_health collector (and of Long Queries) reads back only + /// , 10 minutes. Long Queries stays on + /// collection_time, the column its grid filters on; the grid's notice also takes the oldest completion it shows + /// into account (ServerTab.EarlierOfFloorAndRowShown). The archive still purges those two tables by + /// collection_time. The system_health event_time is the XE @timestamp, /// UTC, like every other column here. The Default Trace's event_time is the exception: it is the monitored server's /// wall clock as stored (its grid converts each row through the server's clock), so the probe does the same /// (, #4989): it reads that relation's rows over the grid's padded diff --git a/PerformanceMonitor.Ui/ChartHoverHelper.cs b/PerformanceMonitor.Ui/ChartHoverHelper.cs index 61900c5993..e8eff52245 100644 --- a/PerformanceMonitor.Ui/ChartHoverHelper.cs +++ b/PerformanceMonitor.Ui/ChartHoverHelper.cs @@ -26,10 +26,12 @@ namespace PerformanceMonitor.Ui; internal sealed class ChartHoverHelper { /// A registered series: the scatter, its full (untruncated) label, and the unmutated - /// identity color captured at registration (see ). + /// identity color captured at registration (see ). is an optional + /// extra hover line per point, by the point's index in the scatter (see ). internal readonly record struct SeriesEntry( ScottPlot.Plottables.Scatter Scatter, string Label, ScottPlot.Color Identity, - ScottPlot.Color OrigLineColor, float OrigLineWidth, float OrigMarkerSize, bool OrigFillY); + ScottPlot.Color OrigLineColor, float OrigLineWidth, float OrigMarkerSize, bool OrigFillY, + IReadOnlyList? PointDetails = null); private readonly ScottPlot.WPF.WpfPlot _chart; private readonly List _series = new(); @@ -180,7 +182,14 @@ public void Clear() PlainTimeAt = null; } - public void Add(ScottPlot.Plottables.Scatter scatter, string label) => + /// The series to hover. + /// The series' name, the popup's first line. + /// + /// Optional, one entry per point of in the order it was built (#4989): a fourth popup line + /// for the nearest point, for a series whose point stands for more than its value (a bucket's maximum drawn over the + /// runs behind it). Left out, the popup is the three lines it always was. + /// + public void Add(ScottPlot.Plottables.Scatter scatter, string label, IReadOnlyList? pointDetails = null) => /* Capture the IDENTITY color from the marker fill, NOT scatter.Color: Add runs after ChartStyle.StyleScatter, which has already mutated the line color to identity.WithAlpha(215) but never touches the marker fill — so MarkerStyle.FillColor still holds the pure identity @@ -189,7 +198,7 @@ but never touches the marker fill — so MarkerStyle.FillColor still holds the p no fill, and never call StyleScatter) as well as the StyleScatter'd fill charts. */ _series.Add(new SeriesEntry( scatter, label, scatter.MarkerStyle.FillColor, - scatter.LineColor, scatter.LineWidth, scatter.MarkerSize, scatter.FillY)); + scatter.LineColor, scatter.LineWidth, scatter.MarkerSize, scatter.FillY, pointDetails)); public void Add(ScottPlot.Plottables.BarPlot barPlot, string label) => _barPlots.Add((barPlot, label)); @@ -292,6 +301,7 @@ as tiebreaker. This makes tooltips appear reliably when hovering at double bestYDistance = double.MaxValue; ScottPlot.DataPoint bestPoint = default; string bestLabel = ""; + string? bestDetail = null; bool found = false; foreach (var entry in _series) @@ -311,11 +321,20 @@ pick the series closest in Y (nearest line to cursor). */ bestYDistance = dy; bestPoint = nearest; bestLabel = entry.Label; + bestDetail = entry.PointDetails is { } details && nearest.Index >= 0 && nearest.Index < details.Count + ? details[nearest.Index] + : null; found = true; } } + /* A bar that beats the series' nearest point (it lowers the Y distance) carries no detail line. */ + var distanceBeforeBars = bestYDistance; FindNearestBar(pixel, ref bestYDistance, ref bestPoint, ref bestLabel, ref found); + if (bestYDistance < distanceBeforeBars) + { + bestDetail = null; + } if (found) { @@ -324,7 +343,7 @@ pick the series closest in Y (nearest line to cursor). */ string valueFormatted = (bestPoint.Y == Math.Floor(bestPoint.Y)) ? bestPoint.Y.ToString("N0") : bestPoint.Y.ToString("N1"); - _text.Text = $"{bestLabel}\n{valueFormatted} {_unit}\n{time}"; + _text.Text = HoverText(bestLabel, valueFormatted, _unit, time, bestDetail); _popup.HorizontalOffset = pos.X + 15; _popup.VerticalOffset = pos.Y + 15; /* Updating the offsets above moves an already-open popup, so only toggle IsOpen @@ -357,6 +376,14 @@ private void OnMouseLeave(object sender, MouseEventArgs e) _popup.IsOpen = false; } + /// + /// The popup's text: the series' , the value with its , the time and, + /// when the nearest point has one (), + /// its line (#4989). Without a detail it is the three lines it always was. + /// + internal static string HoverText(string label, string valueFormatted, string unit, string time, string? detail = null) => + detail is null ? $"{label}\n{valueFormatted} {unit}\n{time}" : $"{label}\n{valueFormatted} {unit}\n{time}\n{detail}"; + /// /// The time line of a hover for the plotted X . With no zone, X is the app's /// server-time value and the line is the display-mode conversion of it, as it has always been. With a zone, X From 943561790b41aec409d438c531313ed52baf0e27 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:03:23 -0400 Subject: [PATCH 9/9] Lite: the Duration Trends hover shows each bucket's own runs after a gap, its Y axis names the slowest run, and an empty range clears the hover (#4989) The hover took its extra line from the point's position in the scatter, but the line breaks at every collection gap by inserting a point of its own. After the first break every bucket showed the NEXT bucket's runs and average, and the last bucket showed none. The shared hover helper now keys the line by the X the point is drawn at, and Lite builds the keys from the same X values the scatter is built from, so a bucket finds its own line and the point a break inserts finds none. The Y axis said "Duration (ms)" for a chart that draws each bucket's slowest run; it now says "Slowest run per bucket (ms)". The empty-range path returned before the hover was cleared, so a tooltip from the previous range could stay on an empty chart; the hover is cleared before that return. --- Lite.Tests/CollectorDurationHoverTests.cs | 245 ++++++++++++++++++++++ Lite/Controls/ServerTab.Charts.cs | 29 ++- PerformanceMonitor.Ui/ChartHoverHelper.cs | 129 +++++++----- 3 files changed, 343 insertions(+), 60 deletions(-) create mode 100644 Lite.Tests/CollectorDurationHoverTests.cs diff --git a/Lite.Tests/CollectorDurationHoverTests.cs b/Lite.Tests/CollectorDurationHoverTests.cs new file mode 100644 index 0000000000..d95c873e7d --- /dev/null +++ b/Lite.Tests/CollectorDurationHoverTests.cs @@ -0,0 +1,245 @@ +/* + * 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; +using Darling.Tests; +using PerformanceMonitor.Ui; +using PerformanceMonitorLite.Controls; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// +/// #4989: what the Duration Trends hover says over a line with a collection gap in it, and what the chart does with its +/// hover and its Y axis label when it is redrawn. The line breaks at every gap by inserting a point of its own (a NaN +/// between the two buckets it separates), so a hover line counted by the point's position in the scatter falls one bucket +/// behind after each break: it said the NEXT bucket's runs for every bucket after the first break and nothing for the +/// last. The hover lines are keyed by the X each bucket is drawn at, so a bucket finds its own and the inserted point finds +/// none. Each test draws the series on a real chart, renders it so the chart knows its pixels, and asks the hover what it +/// says with the mouse over a bucket's point. +/// +public class CollectorDurationHoverTests +{ + private static readonly DateTime Start = new(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc); + + /// + /// One collector's twenty buckets, ten minutes apart, with a seventy-minute hole after the fifth: the line breaks there + /// once. Bucket k holds k + 1 runs that averaged 50 + k ms, and its slowest took 100 + 3k ms. + /// + private static List TwentyBucketsWithOneBreak() => + Enumerable.Range(0, 20).Select(k => new CollectorDurationBucket + { + CollectorName = "wait_stats", + BucketStart = Start.AddMinutes(10 * k + (k >= 5 ? 60 : 0)), + MaxDurationMs = 100 + 3 * k, + AverageDurationMs = 50 + k, + RunCount = k + 1, + }).ToList(); + + /// The hover text over each of the line's twenty points in turn (the mouse exactly on the point), in bucket order. + private static List HoverOverEachBucket(List buckets) => + OnStaThread(() => + { + var chart = new ScottPlot.WPF.WpfPlot(); + var hover = new ChartHoverHelper(chart, "ms", displayZone: () => TimeZoneInfo.Utc); + var line = Assert.Single(ServerTab.BuildCollectorDurationSeries(buckets)); + ServerTab.PlotCollectorDurationSeries(chart, hover, new[] { line }); + chart.Plot.Axes.AutoScale(); + using var render = chart.Plot.GetImage(1000, 400); + + return line.Xs + .Select((x, i) => hover.HoverTextAt(chart.Plot.GetPixel(new ScottPlot.Coordinates(x, line.MaxMs[i])))) + .ToList(); + }); + + /// The popup's fourth line, or null when it is the three lines it is without one. + private static string? DetailOf(string? hover) + { + Assert.NotNull(hover); + var lines = hover!.Split('\n'); + return lines.Length == 4 ? lines[3] : null; + } + + /// + /// The observed case: twenty buckets and one break. The hover on bucket 10 said "Slowest of 12 runs; average 61 ms" (bucket + /// 11's), because the break pushed it one point along the scatter, and the last bucket said nothing, because its place + /// ran off the end of the lines. Each bucket says its own. + /// + [Fact] + public void ABucketAfterABreak_ShowsItsOwnRunsAndAverage_NotTheNextBucketsAndTheLastOneShowsItsOwn() + { + var text = HoverOverEachBucket(TwentyBucketsWithOneBreak()); + + Assert.Equal(20, text.Count); + Assert.Equal("Slowest of 11 runs; average 60 ms", DetailOf(text[10])); + Assert.Equal("Slowest of 20 runs; average 69 ms", DetailOf(text[19])); + for (var k = 0; k < text.Count; k++) + { + Assert.Equal($"Slowest of {k + 1} {(k == 0 ? "run" : "runs")}; average {50 + k} ms", DetailOf(text[k])); + } + } + + /// + /// The hover lines are keyed by the X each bucket is drawn at, and the point the line inserts at the break is none of + /// them: every real point of the drawn scatter finds its own bucket's line, and the NaN point between the two buckets the + /// break separates finds none. + /// + [Fact] + public void TheLinesAreKeyedByX_SoEveryDrawnBucketFindsItsOwn_AndThePointTheBreakInsertsFindsNone() + { + var (line, drawn) = OnStaThread(() => + { + var chart = new ScottPlot.WPF.WpfPlot(); + var series = Assert.Single(ServerTab.BuildCollectorDurationSeries(TwentyBucketsWithOneBreak())); + ServerTab.PlotCollectorDurationSeries(chart, null, new[] { series }); + var scatter = Assert.Single(chart.Plot.PlottableList.OfType()); + + return (series, scatter.Data.GetScatterPoints().ToList()); + }); + + var byX = line.DetailsByX(); + + Assert.Equal(20, byX.Count); + Assert.Equal(21, drawn.Count); + var inserted = Assert.Single(drawn, p => double.IsNaN(p.Y)); + Assert.False(byX.ContainsKey(inserted.X), "the point the break inserted has a hover line"); + var real = drawn.Where(p => !double.IsNaN(p.Y)).ToList(); + Assert.Equal(20, real.Count); + for (var k = 0; k < real.Count; k++) + { + Assert.Equal(line.Details[k], byX[real[k].X]); + } + } + + /// + /// A line with no gap, and a series registered without hover lines, keep what they always said: the label, the value and + /// the time, three lines, and the buckets before the first break are unchanged either way. + /// + [Fact] + public void ASeriesRegisteredWithoutDetails_KeepsTheThreeLineText() + { + var text = OnStaThread(() => + { + var chart = new ScottPlot.WPF.WpfPlot(); + var hover = new ChartHoverHelper(chart, "ms", displayZone: () => TimeZoneInfo.Utc); + var xs = new[] { Start.ToOADate(), Start.AddMinutes(10).ToOADate(), Start.AddMinutes(20).ToOADate() }; + var scatter = chart.Plot.Add.Scatter(xs, new double[] { 5, 9, 7 }); + hover.Add(scatter, "wait_stats"); + chart.Plot.Axes.AutoScale(); + using var render = chart.Plot.GetImage(1000, 400); + + return hover.HoverTextAt(chart.Plot.GetPixel(new ScottPlot.Coordinates(xs[1], 9))); + }); + + Assert.NotNull(text); + var lines = text!.Split('\n'); + Assert.Equal(3, lines.Length); + Assert.Equal("wait_stats", lines[0]); + Assert.Equal("9 ms", lines[1]); + } + + /// + /// Clearing the hover is what an empty range does to what the last range registered (the render clears it before it + /// draws): with the mouse over a point that had a line, the hover said it, and after the clear it says nothing. + /// + [Fact] + public void ClearingTheHover_LeavesItHoldingNothing() + { + var (before, after) = OnStaThread(() => + { + var chart = new ScottPlot.WPF.WpfPlot(); + var hover = new ChartHoverHelper(chart, "ms", displayZone: () => TimeZoneInfo.Utc); + var line = Assert.Single(ServerTab.BuildCollectorDurationSeries(TwentyBucketsWithOneBreak())); + ServerTab.PlotCollectorDurationSeries(chart, hover, new[] { line }); + chart.Plot.Axes.AutoScale(); + using var render = chart.Plot.GetImage(1000, 400); + var pixel = chart.Plot.GetPixel(new ScottPlot.Coordinates(line.Xs[10], line.MaxMs[10])); + + var held = hover.HoverTextAt(pixel); + hover.Clear(); + return (held, hover.HoverTextAt(pixel)); + }); + + Assert.NotNull(before); + Assert.Null(after); + } + + /// + /// The source of UpdateCollectorDurationChart, comments blanked where the pins below look for code and kept where + /// they look for the label's text: the method's braces, found in the code-only text, cut the same span out of the file. + /// + private static (string Source, string Code) UpdateCollectorDurationChartBody() + { + var file = Lite.Tests.ParitySource.ReadFile("Lite/Controls/ServerTab.Charts.cs"); + var fileCode = CSharpSourceWalker.StripCommentsAndStrings(file); + var start = fileCode.IndexOf("private void UpdateCollectorDurationChart(", StringComparison.Ordinal); + Assert.True(start >= 0, "ServerTab.Charts.cs has no UpdateCollectorDurationChart"); + var open = fileCode.IndexOf('{', start); + var length = CSharpSourceWalker.BraceBalanced(fileCode, open).Length; + + return (file.Substring(open, length), fileCode.Substring(open, length)); + } + + /// + /// The chart draws each bucket's slowest run, so its Y axis says that, in the words the Darling viewer's chart uses. + /// It used to say "Duration (ms)", which reads as the average a run takes. + /// + [Fact] + public void TheYAxisLabel_NamesTheSlowestRunPerBucket() + { + var (source, code) = UpdateCollectorDurationChartBody(); + var literals = CSharpSourceWalker.StringLiteralBodies(source).Select(l => l.Text).ToList(); + + Assert.Contains("Plot.YLabel(", code, StringComparison.Ordinal); + Assert.Contains("Slowest run per bucket (ms)", literals); + Assert.DoesNotContain("Duration (ms)", literals); + } + + /// + /// A range with no points returns early, so the hover must be cleared before that return: it was cleared only on the way + /// to drawing, and a tooltip registered by the range before was left on an empty chart. + /// + [Fact] + public void AnEmptyRange_ClearsTheHover_BeforeItReturns() + { + var (_, code) = UpdateCollectorDurationChartBody(); + + var clear = code.IndexOf("_collectorDurationHover?.Clear();", StringComparison.Ordinal); + var emptyReturn = code.IndexOf("if (data.Count == 0)", StringComparison.Ordinal); + + Assert.True(clear >= 0, "UpdateCollectorDurationChart no longer clears the hover"); + Assert.True(emptyReturn >= 0, "UpdateCollectorDurationChart no longer has its empty-range path"); + Assert.True(clear < emptyReturn, "UpdateCollectorDurationChart clears the hover only after the empty range has returned"); + Assert.Equal(code.IndexOf("_collectorDurationHover?.Clear();", StringComparison.Ordinal), code.LastIndexOf("_collectorDurationHover?.Clear();", StringComparison.Ordinal)); + } + + 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/Controls/ServerTab.Charts.cs b/Lite/Controls/ServerTab.Charts.cs index e23299f82e..5383f83098 100644 --- a/Lite/Controls/ServerTab.Charts.cs +++ b/Lite/Controls/ServerTab.Charts.cs @@ -1579,7 +1579,25 @@ is this tab. */ /// bucket's start), the Y it draws (the bucket's slowest run, in ms) and the hover line that says what stands behind /// the point (), all in the same order. /// - internal sealed record CollectorDurationSeries(string Collector, double[] Xs, double[] MaxMs, string[] Details); + internal sealed record CollectorDurationSeries(string Collector, double[] Xs, double[] MaxMs, string[] Details) + { + /// + /// The hover lines keyed by the X each point is drawn at: the very the scatter is built from, so the + /// hover's lookup by the nearest point's X is exact. Keyed by X and not by position because the line breaks at every + /// collection gap by inserting a point of its own, which shifts every position after the first break; that point + /// has no key and so no line. + /// + internal IReadOnlyDictionary DetailsByX() + { + var byX = new Dictionary(Xs.Length); + for (var i = 0; i < Xs.Length && i < Details.Length; i++) + { + byX[Xs[i]] = Details[i]; + } + + return byX; + } + } /// /// The hover line of one Duration Trends point (#4989): the point draws its bucket's slowest run, so the line says how @@ -1628,7 +1646,7 @@ internal static void PlotCollectorDurationSeries(ScottPlot.WPF.WpfPlot chart, Ch scatter.Color = ScottPlot.Color.FromHex(SeriesColors[colorIdx % SeriesColors.Length]); scatter.LineWidth = 2; scatter.MarkerSize = 0; - hover?.Add(scatter, line.Collector, line.Details); + hover?.Add(scatter, line.Collector, line.DetailsByX()); colorIdx++; } } @@ -1642,6 +1660,9 @@ internal static void PlotCollectorDurationSeries(ScottPlot.WPF.WpfPlot chart, Ch private void UpdateCollectorDurationChart(List data, int hoursBack, DateTime? fromDate, DateTime? toDate) { ClearChart(CollectorDurationChart); + /* Every render starts the hover empty, the empty range's early return below included: it holds the last render's + series, and a tooltip over a chart with nothing on it would still name them. */ + _collectorDurationHover?.Clear(); ApplyTheme(CollectorDurationChart); /* Pin the X axis to the settable window (the same idiom as the CPU / tempdb-size charts) rather than @@ -1662,12 +1683,12 @@ which reads as symmetric dead space. This is the one chart the window-pin campai /* One series per collector, each point a bucket's slowest successful run (the read keeps only the runs the chart has always drawn: SUCCESS with a duration). */ - _collectorDurationHover?.Clear(); PlotCollectorDurationSeries(CollectorDurationChart, _collectorDurationHover, BuildCollectorDurationSeries(data)); CollectorDurationChart.Plot.Axes.DateTimeTicksBottomUtc(GetPickerZone); ReapplyAxisColors(CollectorDurationChart); - CollectorDurationChart.Plot.YLabel("Duration (ms)"); + /* Each point is a bucket's slowest run, and the label says so (the Darling viewer's chart uses the same words). */ + CollectorDurationChart.Plot.YLabel("Slowest run per bucket (ms)"); CollectorDurationChart.Plot.Axes.AutoScaleY(); CollectorDurationChart.Plot.Axes.SetLimitsX(xMin, xMax); ShowChartLegend(CollectorDurationChart); diff --git a/PerformanceMonitor.Ui/ChartHoverHelper.cs b/PerformanceMonitor.Ui/ChartHoverHelper.cs index e8eff52245..a3fe55df6b 100644 --- a/PerformanceMonitor.Ui/ChartHoverHelper.cs +++ b/PerformanceMonitor.Ui/ChartHoverHelper.cs @@ -27,11 +27,11 @@ internal sealed class ChartHoverHelper { /// A registered series: the scatter, its full (untruncated) label, and the unmutated /// identity color captured at registration (see ). is an optional - /// extra hover line per point, by the point's index in the scatter (see ). + /// extra hover line per point, keyed by the point's X (see ). internal readonly record struct SeriesEntry( ScottPlot.Plottables.Scatter Scatter, string Label, ScottPlot.Color Identity, ScottPlot.Color OrigLineColor, float OrigLineWidth, float OrigMarkerSize, bool OrigFillY, - IReadOnlyList? PointDetails = null); + IReadOnlyDictionary? PointDetails = null); private readonly ScottPlot.WPF.WpfPlot _chart; private readonly List _series = new(); @@ -185,11 +185,14 @@ public void Clear() /// The series to hover. /// The series' name, the popup's first line. /// - /// Optional, one entry per point of in the order it was built (#4989): a fourth popup line - /// for the nearest point, for a series whose point stands for more than its value (a bucket's maximum drawn over the - /// runs behind it). Left out, the popup is the three lines it always was. + /// Optional, keyed by the X a point of is drawn at (#4989): a fourth popup line for the nearest + /// point, for a series whose point stands for more than its value (a bucket's maximum drawn over the runs behind it). + /// The key is the point's X and not its position in the scatter, because the scatter can hold points the caller never + /// gave it (Add.TimeSeries inserts one into every gap), which would put every position after the first gap one + /// point out. A point with no entry, such as an inserted one, shows no fourth line. Build the keys from the same X + /// values the scatter was built from, so the lookup is exact. Left out, the popup is the three lines it always was. /// - public void Add(ScottPlot.Plottables.Scatter scatter, string label, IReadOnlyList? pointDetails = null) => + public void Add(ScottPlot.Plottables.Scatter scatter, string label, IReadOnlyDictionary? pointDetails = null) => /* Capture the IDENTITY color from the marker fill, NOT scatter.Color: Add runs after ChartStyle.StyleScatter, which has already mutated the line color to identity.WithAlpha(215) but never touches the marker fill — so MarkerStyle.FillColor still holds the pure identity @@ -279,6 +282,66 @@ per plot (lazily on the first bar) instead of two GetPixel calls per bar. */ } } + /// + /// The popup's text for the mouse at (the chart's own pixel space), or null when no point + /// is near enough to say anything. The one place the hover decides what it shows, internal so the tests ask it + /// what a hover says without a mouse. + /// + internal string? HoverTextAt(ScottPlot.Pixel pixel) + { + var mouseCoords = _chart.Plot.GetCoordinates(pixel); + + /* Use X-axis (time) proximity as the primary filter, Y-axis distance + as tiebreaker. This makes tooltips appear reliably when hovering at + any Y position near a data point's time — standard for time-series. */ + double bestYDistance = double.MaxValue; + ScottPlot.DataPoint bestPoint = default; + string bestLabel = ""; + string? bestDetail = null; + bool found = false; + + foreach (var entry in _series) + { + var nearest = entry.Scatter.Data.GetNearest(mouseCoords, _chart.Plot.LastRender); + if (!nearest.IsReal) continue; + + var nearestPixel = _chart.Plot.GetPixel( + new ScottPlot.Coordinates(nearest.X, nearest.Y)); + double dx = Math.Abs(nearestPixel.X - pixel.X); + double dy = Math.Abs(nearestPixel.Y - pixel.Y); + + /* Must be within 80px horizontally (time axis). Among matches, + pick the series closest in Y (nearest line to cursor). */ + if (dx < 80 && dy < bestYDistance) + { + bestYDistance = dy; + bestPoint = nearest; + bestLabel = entry.Label; + bestDetail = entry.PointDetails is { } details && details.TryGetValue(nearest.X, out var detail) + ? detail + : null; + found = true; + } + } + + /* A bar that beats the series' nearest point (it lowers the Y distance) carries no detail line. */ + var distanceBeforeBars = bestYDistance; + FindNearestBar(pixel, ref bestYDistance, ref bestPoint, ref bestLabel, ref found); + if (bestYDistance < distanceBeforeBars) + { + bestDetail = null; + } + + if (!found) return null; + + var plottedX = DateTime.FromOADate(bestPoint.X); + var time = FormatHoverTime(plottedX, _displayZone, PlainTimeAt?.Invoke(plottedX) == true); + string valueFormatted = (bestPoint.Y == Math.Floor(bestPoint.Y)) + ? bestPoint.Y.ToString("N0") + : bestPoint.Y.ToString("N1"); + return HoverText(bestLabel, valueFormatted, _unit, time, bestDetail); + } + private void OnMouseMove(object sender, MouseEventArgs e) { if (_series.Count == 0 && _barPlots.Count == 0) return; @@ -293,57 +356,11 @@ private void OnMouseMove(object sender, MouseEventArgs e) var pixel = new ScottPlot.Pixel( (float)(pos.X * dpi.DpiScaleX), (float)(pos.Y * dpi.DpiScaleY)); - var mouseCoords = _chart.Plot.GetCoordinates(pixel); - - /* Use X-axis (time) proximity as the primary filter, Y-axis distance - as tiebreaker. This makes tooltips appear reliably when hovering at - any Y position near a data point's time — standard for time-series. */ - double bestYDistance = double.MaxValue; - ScottPlot.DataPoint bestPoint = default; - string bestLabel = ""; - string? bestDetail = null; - bool found = false; - foreach (var entry in _series) - { - var nearest = entry.Scatter.Data.GetNearest(mouseCoords, _chart.Plot.LastRender); - if (!nearest.IsReal) continue; - - var nearestPixel = _chart.Plot.GetPixel( - new ScottPlot.Coordinates(nearest.X, nearest.Y)); - double dx = Math.Abs(nearestPixel.X - pixel.X); - double dy = Math.Abs(nearestPixel.Y - pixel.Y); - - /* Must be within 80px horizontally (time axis). Among matches, - pick the series closest in Y (nearest line to cursor). */ - if (dx < 80 && dy < bestYDistance) - { - bestYDistance = dy; - bestPoint = nearest; - bestLabel = entry.Label; - bestDetail = entry.PointDetails is { } details && nearest.Index >= 0 && nearest.Index < details.Count - ? details[nearest.Index] - : null; - found = true; - } - } - - /* A bar that beats the series' nearest point (it lowers the Y distance) carries no detail line. */ - var distanceBeforeBars = bestYDistance; - FindNearestBar(pixel, ref bestYDistance, ref bestPoint, ref bestLabel, ref found); - if (bestYDistance < distanceBeforeBars) - { - bestDetail = null; - } - - if (found) + var text = HoverTextAt(pixel); + if (text is not null) { - var plottedX = DateTime.FromOADate(bestPoint.X); - var time = FormatHoverTime(plottedX, _displayZone, PlainTimeAt?.Invoke(plottedX) == true); - string valueFormatted = (bestPoint.Y == Math.Floor(bestPoint.Y)) - ? bestPoint.Y.ToString("N0") - : bestPoint.Y.ToString("N1"); - _text.Text = HoverText(bestLabel, valueFormatted, _unit, time, bestDetail); + _text.Text = text; _popup.HorizontalOffset = pos.X + 15; _popup.VerticalOffset = pos.Y + 15; /* Updating the offsets above moves an already-open popup, so only toggle IsOpen @@ -378,7 +395,7 @@ private void OnMouseLeave(object sender, MouseEventArgs e) /// /// The popup's text: the series' , the value with its , the time and, - /// when the nearest point has one (), + /// when the nearest point has one (), /// its line (#4989). Without a detail it is the three lines it always was. /// internal static string HoverText(string label, string valueFormatted, string unit, string time, string? detail = null) =>