From a6a33099b2bf673a50454b893408e9e81c9233bf Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:16:06 -0400 Subject: [PATCH] A server dark past collection_log's retention reads Offline everywhere, not "Awaiting first collection" (#3967) #3966 fixed the fleet card's 48-hour read. The surfaces that read a server's newest collection with no window still called a null "never collected": list_servers, the WPF sidebar dot and Overview card, and Lite's Overview card. Retention bounds what they can see (collection_log 60 days, Lite's archive 3 months), so a server dark for longer turned amber. The rule now lives once in PerformanceMonitor.Common: ServerHealthClassifier.ClassifyFreshness(lastCollection, registeredAt, searchedFrom, now). A null on a server registered before what the read could see is Offline; otherwise the ladder decides. The fleet card delegates with its 48 h window start. The unbounded Darling surfaces pass DarlingRetentionHorizons.CollectionLogHorizon(now). Lite passes RetentionService.OldestRetainedInstant(now), the first month start the archive still holds whole. Registration comes from servers.created_date: on ServerListSql, and on both ServersSql and ManagedServersSql via DarlingServer.RegisteredAt, which the Overview loader stamps onto the card. In Lite it comes from ServerConnection.CreatedDate, a required parameter on GetServerSummaryAsync. The "Never" / "No data collected" texts now say "None retained" for that case. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018Z4T51J4E6L8fjriPHpsif --- .../DarkPastRetentionReadsOfflineTests.cs | 455 ++++++++++++++++++ .../Mcp/DarlingDataReader.cs | 18 +- .../Mcp/DarlingFleetReader.cs | 40 +- .../Mcp/DarlingMcpDataTools.cs | 12 +- .../DailySummaryHorizon.cs | 12 + .../MainWindow.xaml.cs | 4 + .../ManageServersWindow.xaml | 3 +- .../ManageServersWindow.xaml.cs | 36 +- .../ViewerDataService.MonitoredServers.cs | 10 +- .../ViewerDataService.Overview.cs | 29 +- .../ViewerDataService.cs | 29 +- .../ViewerServerTab.xaml.cs | 10 +- .../DarkPastRetentionReadsOfflineToolTests.cs | 184 +++++++ .../OverviewNewestReadsHotFirstTests.cs | 6 +- Lite.Tests/ServerCollectionFreshnessTests.cs | 12 +- Lite.Tests/TimeHonestyRungTests.cs | 4 +- Lite/MainWindow.xaml.cs | 2 +- Lite/Mcp/McpDiscoveryTools.cs | 8 +- Lite/Mcp/McpHealthTools.cs | 3 +- Lite/Mcp/ServerResolver.cs | 12 + Lite/Models/ServerConnection.cs | 10 + Lite/Services/LocalDataService.Overview.cs | 28 +- Lite/Services/RetentionService.cs | 19 + .../ServerHealthBands.cs | 44 +- 24 files changed, 918 insertions(+), 72 deletions(-) create mode 100644 Darling/Darling.Tests/DarkPastRetentionReadsOfflineTests.cs create mode 100644 Lite.Tests/DarkPastRetentionReadsOfflineToolTests.cs diff --git a/Darling/Darling.Tests/DarkPastRetentionReadsOfflineTests.cs b/Darling/Darling.Tests/DarkPastRetentionReadsOfflineTests.cs new file mode 100644 index 0000000000..4af06abbd2 --- /dev/null +++ b/Darling/Darling.Tests/DarkPastRetentionReadsOfflineTests.cs @@ -0,0 +1,455 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Darling.Service.Mcp; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// #3967: a server whose whole history the collection log's retention has dropped reads Offline on every +/// surface that reads a server's newest collection with no window, not "Awaiting first collection". +/// +/// The defect. list_servers, the WPF sidebar dot and the WPF Overview card read the newest +/// collection with no window, and collection_log keeps +/// days. A server dark for longer comes back null, and to +/// a null is never collected. #3935 fixed the same mislabel on the fleet card, whose read looks back 48 hours; +/// this is the rule it used, moved into and applied to the other reads +/// with the retention as what they could see. +/// +/// What is pinned. The shared rule over every input, its boundary, the horizon, the fleet card +/// delegating to it, each surface's reading (dot, card, list_servers, the two text cells), every +/// registry read carrying the registration, and the loader stamping it. +/// holds the seam against a store. +/// +public sealed class DarkPastRetentionReadsOfflineTests +{ + private static readonly DateTime Now = new(2026, 9, 23, 12, 0, 0, DateTimeKind.Utc); + + private static DateTime Horizon => DarlingRetentionHorizons.CollectionLogHorizon(Now); + + /// A registration far enough back that retention has had time to take every row. + private static DateTime LongAgo => Now.AddDays(-90); + + /// A registration inside the retained span: any collection it made would still be there. + private static DateTime Recently => Now.AddDays(-10); + + /// + /// The rule. A newest collection bands on the ladder, whatever the registration says; with none, a + /// registration before what the read could see is Offline, one at or after it keeps the ladder's + /// never-collected reading, and no registration keeps it too. + /// + [Fact] + public void TheSharedRule_DepartsFromTheLadder_OnlyForANullOnAnOlderRegistration() + { + foreach (var registeredAt in new DateTime?[] { null, LongAgo, Horizon.AddTicks(-1), Horizon, Recently }) + { + foreach (var lastCollection in Readings()) + { + Assert.Equal( + ServerHealthClassifier.ClassifyFreshness(lastCollection, Now), + ServerHealthClassifier.ClassifyFreshness(lastCollection, registeredAt, Horizon, Now)); + } + } + + Assert.Equal(ServerFreshness.Offline, ServerHealthClassifier.ClassifyFreshness(null, LongAgo, Horizon, Now)); + Assert.Equal(ServerFreshness.NeverCollected, ServerHealthClassifier.ClassifyFreshness(null, Recently, Horizon, Now)); + Assert.Equal(ServerFreshness.NeverCollected, ServerHealthClassifier.ClassifyFreshness(null, null, Horizon, Now)); + } + + /// + /// The boundary is the instant the read could first see. Registered AT it, any collection would be at or + /// after it and so still visible, which proves an empty read means never collected. One tick earlier, a + /// collection the read cannot see is possible. + /// + [Fact] + public void TheBoundary_IsTheFirstInstantTheReadCouldSee() + { + Assert.Equal(ServerFreshness.NeverCollected, ServerHealthClassifier.ClassifyFreshness(null, Horizon, Horizon, Now)); + Assert.Equal(ServerFreshness.Offline, ServerHealthClassifier.ClassifyFreshness(null, Horizon.AddTicks(-1), Horizon, Now)); + } + + /// The horizon an unbounded read of the collection log can see: the log's retention back, as + /// naive UTC, the store's convention. + [Fact] + public void TheHorizon_IsTheCollectionLogsRetentionBack_AsNaiveUtc() + { + Assert.Equal(60, DarlingRetentionHorizons.CollectionLogRetentionDays); + Assert.Equal(new DateTime(2026, 7, 25, 12, 0, 0), Horizon); + Assert.Equal(DateTimeKind.Unspecified, Horizon.Kind); + Assert.Equal(DarlingRetention.CollectionLogRetentionDays, DarlingRetentionHorizons.CollectionLogRetentionDays); + } + + /// + /// The fleet card is the same rule with its own reach: its read looks back 48 hours, so that is what it + /// hands the shared rule. One rule, two horizons, and the two can only differ where the reads do. + /// + [Fact] + public void TheFleetCard_AppliesTheSharedRuleToItsWindow() + { + var windowStart = DarlingFleetReader.LastCollectionWindowStart(Now); + + foreach (var registeredAt in new DateTime?[] { null, LongAgo, Recently, windowStart.AddTicks(-1), windowStart, Now.AddMinutes(-10) }) + { + foreach (var lastCollection in Readings().Append(null)) + { + Assert.Equal( + ServerHealthClassifier.ClassifyFreshness(lastCollection, registeredAt, windowStart, Now), + DarlingFleetReader.ClassifyWindowedFreshness(lastCollection, registeredAt, Now)); + } + } + } + + /// + /// list_servers: the status token, through the tool's own renderer. A server whose history aged out + /// is Offline with no last collection; one registered inside the retention with none is still + /// AwaitingFirstCollection, which is the published token the fix must not disturb. + /// + [Fact] + public void ListServers_ReadsAServerWhoseHistoryAgedOut_Offline() + { + Assert.Equal("Offline", DarlingMcpDataTools.FreshnessStatus(null, LongAgo, Now)); + Assert.Equal("AwaitingFirstCollection", DarlingMcpDataTools.FreshnessStatus(null, Recently, Now)); + Assert.Equal("AwaitingFirstCollection", DarlingMcpDataTools.FreshnessStatus(null, null, Now)); + + var json = DarlingMcpDataTools.RenderServerList( + new[] + { + new DarlingDataReader.ServerListRow(1, "aged-out", null, 16, null, MonitoredEngineKind.SqlServer, null, LongAgo), + new DarlingDataReader.ServerListRow(2, "just-added", null, 16, null, MonitoredEngineKind.SqlServer, null, Now.AddMinutes(-10)), + new DarlingDataReader.ServerListRow(3, "collecting", null, 16, Now.AddSeconds(-30), MonitoredEngineKind.SqlServer, null, LongAgo), + }, + Now, + DarlingPeerDirectory.Snapshot.Empty); + + var rows = Statuses(json); + Assert.Equal("Offline", rows["aged-out"].Status); + Assert.Null(rows["aged-out"].LastCollection); + Assert.Equal("AwaitingFirstCollection", rows["just-added"].Status); + Assert.Equal("Online", rows["collecting"].Status); + } + + /// + /// Every registry read behind an unbounded freshness surface carries the registration: both of the + /// viewer's server reads, because the sidebar uses ManagedServersSql on any seeded store and + /// ServersSql otherwise (the #3145 lesson), and list_servers's. The managed read takes it from + /// the OBSERVED registry, the only side that has one. + /// + [Fact] + public void EveryUnboundedFreshnessRead_CarriesTheRegistration() + { + Assert.Contains("postgres_major_version, created_date FROM servers", ViewerDataService.ServersSql, StringComparison.Ordinal); + Assert.Contains("s.created_date", ViewerDataService.ManagedServersSql, StringComparison.Ordinal); + Assert.Contains("LEFT JOIN servers s ON s.server_id = c.server_id", ViewerDataService.ManagedServersSql, StringComparison.Ordinal); + Assert.Contains("s.created_date", DarlingDataReader.ServerListSql, StringComparison.Ordinal); + } + + /// The sidebar dot, through the row's own ApplyFreshness: Offline for a server whose + /// history aged out, awaiting for one registered inside the retention, awaiting for one the service has not + /// connected to (no registration). + [Fact] + public void TheSidebarDot_ReadsAServerWhoseHistoryAgedOut_Offline() + { + Assert.Equal(ServerCollectionStatus.Offline, Dot(LongAgo, null)); + Assert.Equal(ServerCollectionStatus.AwaitingFirstCollection, Dot(Recently, null)); + Assert.Equal(ServerCollectionStatus.AwaitingFirstCollection, Dot(null, null)); + } + + /// + /// The Overview card, through its own ApplyFreshness: Offline, and a Last collection cell that says + /// none is retained rather than "Never". A card awaiting its first collection still says "Never". + /// + [Fact] + public void TheOverviewCard_ReadsAServerWhoseHistoryAgedOut_Offline_AndSaysNoneRetained() + { + var agedOut = Card(LongAgo, null); + Assert.False(agedOut.IsOnline); + Assert.Equal(ServerCollectionStatus.Offline.Word(), agedOut.StatusDisplay); + Assert.Equal("None retained", agedOut.LastCollectionDisplay); + + var justAdded = Card(Recently, null); + Assert.Null(justAdded.IsOnline); + Assert.True(justAdded.AwaitingFirstCollection); + Assert.Equal("Never", justAdded.LastCollectionDisplay); + } + + /// + /// Every reading with a newest collection is untouched on every surface: the dot, the card and + /// list_servers band fresh, stale and offline exactly as the ladder does, whatever the registration. + /// + [Fact] + public void FreshStaleAndOfflineReadings_AreUnchangedOnEverySurface() + { + foreach (var registeredAt in new DateTime?[] { null, LongAgo, Recently }) + { + foreach (var lastCollection in Readings()) + { + var expected = ServerCollectionStatusRules.FromFreshness(ServerHealthClassifier.ClassifyFreshness(lastCollection, Now)); + Assert.Equal(expected, Dot(registeredAt, lastCollection)); + Assert.Equal(expected.Word(), Card(registeredAt, lastCollection).StatusDisplay); + Assert.Equal(expected.McpToken(), DarlingMcpDataTools.FreshnessStatus(lastCollection, registeredAt, Now)); + } + } + } + + /// + /// The seam the card cannot pin by itself: the Overview loader stamps the registration from the registry + /// row it already holds, and it does so BEFORE banding. A stamp after ApplyFreshness, or none, compiles + /// clean and leaves every aged-out card on "Awaiting first collection". + /// + [Fact] + public void TheOverviewLoader_StampsTheRegistrationBeforeItBands() + { + var loader = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "MainWindow.xaml.cs"); + + var stamp = loader.IndexOf("summary.RegisteredAt = server.RegisteredAt;", StringComparison.Ordinal); + var band = loader.IndexOf("summary.ApplyFreshness(nowUtc);", StringComparison.Ordinal); + Assert.True(stamp >= 0, "The Overview loader no longer stamps the registration onto the card."); + Assert.True(band > stamp, "The Overview loader bands the card before it has the registration."); + Assert.Equal(band, loader.LastIndexOf("summary.ApplyFreshness(nowUtc);", StringComparison.Ordinal)); + } + + /// The Manage Servers "Last Collected" cell says none is retained for a server whose history aged + /// out, and "Never" only for one that has not collected. + [Fact] + public void TheManageServersCell_SaysNoneRetained_ForAServerWhoseHistoryAgedOut() + { + var row = new MonitoredServerRow { ServerId = 1, Name = "sql-01", Host = "sql-01" }; + + Assert.Equal("None retained", new ManagedServerListItem(row, false) { HistoryAgedOut = true }.LastCollectedDisplay); + Assert.Equal("Never", new ManagedServerListItem(row, false).LastCollectedDisplay); + } + + /* ─────────────────────────── helpers ─────────────────────────── */ + + /// A newest collection on each rung of the ladder. + private static IEnumerable Readings() => new DateTime?[] + { + Now.AddSeconds(-30), + Now - ServerHealthThresholds.StaleThreshold - TimeSpan.FromMinutes(1), + Now - ServerHealthThresholds.OfflineThreshold - TimeSpan.FromHours(2), + Now.AddDays(-30), + }; + + private static ServerCollectionStatus Dot(DateTime? registeredAt, DateTime? lastCollection) + { + var server = new DarlingServer(1, "sql-01", "sql-01", true, 16, registeredAt: registeredAt); + server.ApplyFreshness(lastCollection, Now); + return server.CardStatus; + } + + private static ServerSummaryItem Card(DateTime? registeredAt, DateTime? lastCollection) + { + var card = new ServerSummaryItem { ServerId = 1, DisplayName = "sql-01", LastCollectionTime = lastCollection, RegisteredAt = registeredAt }; + card.ApplyFreshness(Now); + return card; + } + + private static Dictionary Statuses(string json) + { + using var document = JsonDocument.Parse(json); + var rows = new Dictionary(StringComparer.Ordinal); + foreach (var entry in document.RootElement.GetProperty("servers").EnumerateArray()) + { + rows[entry.GetProperty("server_name").GetString()!] = ( + entry.GetProperty("status").GetString()!, + entry.GetProperty("last_collection").ValueKind == JsonValueKind.Null ? null : entry.GetProperty("last_collection").GetString()); + } + + return rows; + } +} + +/// +/// Gated (DARLING_TEST_PG): the seam, against a store. Four registered servers — one whose history retention +/// has dropped (registered 90 days ago, no rows), one registered ten minutes ago with no rows yet, one dark +/// for five days inside the retention, and one collecting — read through the SHIPPED reads: list_servers, +/// both of the viewer's registry reads, the sidebar's freshness read, the Overview card's summary read, and +/// the fleet overview. Every surface must call the first Offline and the second awaiting, and all of them must +/// agree about all four. +/// +[Collection("live-postgres")] +public sealed class DarkPastRetentionReadsOfflineLivePostgresTests +{ + private const int AgedOut = -967301; + private const int JustRegistered = -967302; + private const int DarkInsideRetention = -967303; + private const int Collecting = -967304; + + private static readonly int[] s_all = { AgedOut, JustRegistered, DarkInsideRetention, Collecting }; + + [Fact] + public async Task EverySurface_ReadsAServerWhoseHistoryAgedOut_Offline_AndAgrees_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live #3967 test."); + + var ct = TestContext.Current.CancellationToken; + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + await DeleteSentinelRowsAsync(connection, ct); + + await using var postgres = NpgsqlDataSource.Create(connectionString!); + await using var viewer = new ViewerDataService(connectionString!); + + var bodySucceeded = false; + try + { + var now = Micro(DateTime.UtcNow); + var registrations = new Dictionary + { + [AgedOut] = now.AddDays(-90), + [JustRegistered] = now.AddMinutes(-10), + [DarkInsideRetention] = now.AddDays(-30), + [Collecting] = now.AddDays(-9), + }; + + foreach (var (id, registeredAt) in registrations) + { + await ExecAsync(connection, ct, + "INSERT INTO servers (server_id, server_name, display_name, is_enabled, sql_engine_edition, sql_major_version, engine_kind, created_date, modified_date) VALUES ($1, $2, $2, TRUE, 3, 16, 'sqlserver', $3, $3)", + id, Name(id), registeredAt); + await ExecAsync(connection, ct, + "INSERT INTO config.config_monitored_servers (server_id, name, host, is_enabled, created_at, modified_at) VALUES ($1, $2, $2, TRUE, $3, $3) ON CONFLICT (server_id) DO UPDATE SET is_enabled = TRUE", + id, Name(id), registeredAt); + } + + await ExecAsync(connection, ct, "INSERT INTO config.config_service (id) VALUES (1) ON CONFLICT (id) DO NOTHING"); + + /* The aged-out server's rows are gone, which is the whole case; the other two with history keep it. */ + await InsertCollectionLogAsync(connection, ct, DarkInsideRetention, now.AddDays(-5)); + await InsertCollectionLogAsync(connection, ct, Collecting, now.AddMinutes(-1)); + + /* list_servers. */ + var list = Statuses(await DarlingMcpDataTools.ListServers(postgres)); + Assert.Equal("Offline", list[Name(AgedOut)].Status); + Assert.Null(list[Name(AgedOut)].LastCollection); + Assert.Equal("AwaitingFirstCollection", list[Name(JustRegistered)].Status); + Assert.Equal("Offline", list[Name(DarkInsideRetention)].Status); + Assert.Equal("Online", list[Name(Collecting)].Status); + + /* Both registry reads carry the registration, off the ordinals the shipped readers use. */ + Assert.True(await viewer.IsConfigSeededAsync(ct), "the store reads as unseeded, so the managed read would not be the one exercised"); + foreach (var servers in new[] { await viewer.GetServersAsync(ct), await viewer.GetManagedServersAsync(ct) }) + { + foreach (var id in s_all) + { + Assert.Equal(registrations[id], servers.Single(s => s.ServerId == id).RegisteredAt); + } + } + + /* The sidebar dot, through the freshness read and the row's own ApplyFreshness. */ + var managed = (await viewer.GetManagedServersAsync(ct)).Where(s => s_all.Contains(s.ServerId)).ToDictionary(s => s.ServerId); + var freshness = await viewer.GetServerFreshnessAsync(ct); + foreach (var server in managed.Values) + { + server.ApplyFreshness(freshness.TryGetValue(server.ServerId, out var last) ? last : null, now); + } + + Assert.Equal(ServerCollectionStatus.Offline, managed[AgedOut].CardStatus); + Assert.Equal(ServerCollectionStatus.AwaitingFirstCollection, managed[JustRegistered].CardStatus); + Assert.Equal(ServerCollectionStatus.Offline, managed[DarkInsideRetention].CardStatus); + Assert.Equal(ServerCollectionStatus.Online, managed[Collecting].CardStatus); + + /* The Overview card: the summary read, stamped the way the loader stamps it, then banded. */ + var cards = new Dictionary(); + foreach (var server in managed.Values) + { + var card = await viewer.GetServerSummaryAsync(server.ServerId, server.DisplayName, cancellationToken: ct); + card.RegisteredAt = server.RegisteredAt; + card.ApplyFreshness(now); + cards[server.ServerId] = card; + } + + Assert.Equal(ServerCollectionStatus.Offline.Word(), cards[AgedOut].StatusDisplay); + Assert.Equal("None retained", cards[AgedOut].LastCollectionDisplay); + Assert.Equal(ServerCollectionStatus.AwaitingFirstCollection.Word(), cards[JustRegistered].StatusDisplay); + Assert.Equal("Never", cards[JustRegistered].LastCollectionDisplay); + + /* The fleet card (#3935's rule over its 48 hours) agrees with all of them. */ + var fleet = (await DarlingFleetReader.GetFleetOverviewAsync(postgres, now.AddHours(-1), now, now, cancellationToken: ct)) + .Cards.Where(c => s_all.Contains(c.ServerId)).ToDictionary(c => c.ServerId); + + /* One answer per server, on every surface. */ + foreach (var id in s_all) + { + var dot = managed[id].CardStatus; + Assert.Equal(dot.McpToken(), list[Name(id)].Status); + Assert.Equal(dot.Word(), cards[id].StatusDisplay); + Assert.Equal(dot.Word(), fleet[id].Status); + } + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(connectionString!, bodySucceeded, DeleteSentinelRowsAsync); + } + } + + private static string Name(int id) => "retention3967" + id.ToString(System.Globalization.CultureInfo.InvariantCulture); + + private static Task InsertCollectionLogAsync(NpgsqlConnection connection, CancellationToken ct, int id, DateTime at) => + ExecAsync(connection, ct, + "INSERT INTO collection_log (log_id, server_id, server_name, collector_name, collection_time, status) VALUES ($1, $2, $3, 'wait_stats', $4, 'SUCCESS')", + CollectionIdGenerator.Next(), id, Name(id), at); + + private static async Task ExecAsync(NpgsqlConnection connection, CancellationToken ct, string sql, params object[] args) + { + using var command = new NpgsqlCommand(sql, connection); + foreach (var arg in args) + { + command.Parameters.AddWithValue(arg is DateTime dt ? DateTime.SpecifyKind(dt, DateTimeKind.Unspecified) : arg); + } + + await command.ExecuteNonQueryAsync(ct); + } + + private static Dictionary Statuses(string json) + { + using var document = JsonDocument.Parse(json); + var rows = new Dictionary(StringComparer.Ordinal); + foreach (var entry in document.RootElement.GetProperty("servers").EnumerateArray()) + { + rows[entry.GetProperty("server_name").GetString()!] = ( + entry.GetProperty("status").GetString()!, + entry.GetProperty("last_collection").ValueKind == JsonValueKind.Null ? null : entry.GetProperty("last_collection").GetString()); + } + + return rows; + } + + /// PostgreSQL timestamp is microsecond-resolution; .NET ticks are 100 ns. Seeded instants + /// are floored to the microsecond so the values read back compare equal to the ones written. + private static DateTime Micro(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % 10)), DateTimeKind.Unspecified); + + private static async Task DeleteSentinelRowsAsync(NpgsqlConnection connection, CancellationToken ct) + { + var ids = string.Join(", ", s_all.Select(id => id.ToString(System.Globalization.CultureInfo.InvariantCulture))); + foreach (var table in new[] { "collection_log", "servers", "config.config_monitored_servers" }) + { + using var cleanup = new NpgsqlCommand($"DELETE FROM {table} WHERE server_id IN ({ids})", connection); + await cleanup.ExecuteNonQueryAsync(ct); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs index 0ec6e5660c..13d8958203 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs @@ -238,7 +238,11 @@ public sealed record QueryStoreRow( /// freshness-derived status the tool assigns; the viewer has no live ping either). /// EngineKind and PostgresMajorVersion ride along because the row's version label is /// engine-aware (#3145): without the discriminator this read fed SqlMajorVersion — 0 at - /// every PostgreSQL target — through a SQL-Server-only table and published "SQL Server v0". + /// every PostgreSQL target — through a SQL-Server-only table and published "SQL Server v0". + /// RegisteredAt is the registry's created_date, the server's first successful connect + /// (#3967). The newest-collection read has no window, but the collection log's retention bounds what it + /// can see, and the registration is what tells a server whose history retention dropped (Offline) from + /// one that has never collected. public sealed record ServerListRow( int ServerId, string ServerName, @@ -246,7 +250,8 @@ public sealed record ServerListRow( int? SqlMajorVersion, DateTime? LastCollection, string? EngineKind = null, - int? PostgresMajorVersion = null); + int? PostgresMajorVersion = null, + DateTime? RegisteredAt = null); /// The latest server_properties snapshot (Lite's ServerPropertiesRow). /// is the offset IN FORCE at that collection (V16; null on a pre-V16 row) @@ -1312,7 +1317,8 @@ public static async Task> GetQueryStoreTopAsync( /// Every enabled server plus its newest collection instant — the list_servers read. The /// correlated MAX(collection_time) per server drives the freshness-derived status the tool /// assigns (the headless viewer has no live ping either — see ServerSummaryItem.ClassifyFreshness). - /// $-free (no parameters, no bare now()) so a test can pin the dialect ungated. + /// created_date rides on the same registry row for the retention rule + /// describes (#3967). $-free (no parameters, no bare now()) so a test can pin the dialect ungated. /// public const string ServerListSql = """ SELECT @@ -1322,7 +1328,8 @@ public static async Task> GetQueryStoreTopAsync( s.sql_major_version, (SELECT MAX(cl.collection_time) FROM v_collection_log cl WHERE cl.server_id = s.server_id) AS last_collection, s.engine_kind, - s.postgres_major_version + s.postgres_major_version, + s.created_date FROM servers s WHERE s.is_enabled ORDER BY s.server_name @@ -1344,7 +1351,8 @@ public static async Task> GetServerListAsync( reader.IsDBNull(3) ? null : reader.GetInt32(3), reader.IsDBNull(4) ? null : reader.GetDateTime(4), reader.IsDBNull(5) ? null : reader.GetString(5), - reader.IsDBNull(6) ? null : reader.GetInt32(6))); + reader.IsDBNull(6) ? null : reader.GetInt32(6), + reader.IsDBNull(7) ? null : reader.GetDateTime(7))); } return rows; diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs index ea2a4b8bae..58b39ae620 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs @@ -531,41 +531,29 @@ internal static DateTime LastCollectionWindowStart(DateTime now) => /// /// A card's collection freshness from 's row (#3935): the shared - /// ladder, except for the one reading a bounded - /// read cannot hand that ladder, because to the ladder a null means "never". + /// registration rule ('s ClassifyFreshness overload, #3967) with + /// this read's window as what it could see. A newest collection inside the window bands on the ladder; with + /// none, a server first connected BEFORE the window reads Offline and one first connected inside it reads + /// awaiting its first collection, which is exact because the worker collects only after connecting (on + /// DARLING01 every one of the 17 registry rows has its first retained collection after its + /// created_date). /// - /// The rule. A newest collection inside the window bands exactly as it always did. With none, - /// the registry decides: a server first connected BEFORE the window began is Offline, and one first - /// connected inside it is awaiting its first collection. The second half is exact rather than a guess, - /// because the registry row is written at the first successful connect and the worker collects only after - /// connecting: a server registered inside the window could only have collected inside it, and the window - /// holds nothing. (On DARLING01 every one of the 17 registry rows, enabled or not, has its first retained - /// collection after its created_date.) The first half is the ruling: a server that connected more - /// than two days ago and has sent nothing since is dark, which is what the WPF viewer calls it from its - /// unbounded read, and "the service has not reached it yet" would be false — the service reached it, - /// which is what wrote the row. - /// - /// Where the two surfaces can still differ, and why that is the right way round. The viewer - /// reads "never" whenever it finds no row at all, so it says "Awaiting first collection" for a server whose - /// rows retention has all dropped (dark for longer than the 60-day collection_log horizon), and for - /// one that connected over two days ago and has written no collection_log row since. The second is - /// rare, because every collector run writes a row whatever its outcome, so it takes a connect no sweep - /// followed. This card reads both Offline: in each, the service reached the server and nothing has come - /// back for at least two days. + /// The window, not the retention, is what this read could see. So a server that connected more + /// than two days ago and has never written a collection_log row reads Offline here, while the + /// viewer and list_servers, whose reads have no window, can still prove it never collected and say + /// so. That case is rare: every collector run writes a row whatever its outcome, so it takes a connect no + /// sweep followed. A server dark past the retention reads Offline on every surface. /// /// A null registration keeps the ladder's reading. The service writes created_date on - /// every registry insert; a row without one was inserted by hand, and nothing here can say when it was - /// reached. The same holds for a server the read did not report at all, which the caller passes as two - /// nulls. + /// every registry insert; a row without one was inserted by hand. The same holds for a server the read did + /// not report at all, which the caller passes as two nulls. /// /// The newest collection inside the window, or null when the window holds /// none. /// The registry's created_date from the same row, or null. /// The roll-up's reference instant, the one the window was cut from. internal static ServerFreshness ClassifyWindowedFreshness(DateTime? lastCollection, DateTime? registeredAt, DateTime now) => - !lastCollection.HasValue && registeredAt.HasValue && registeredAt.Value < LastCollectionWindowStart(now) - ? ServerFreshness.Offline - : ServerHealthClassifier.ClassifyFreshness(lastCollection, now); + ServerHealthClassifier.ClassifyFreshness(lastCollection, registeredAt, LastCollectionWindowStart(now), now); /// Cross-server per-collector 7-day health aggregate — one row per (server, collector) pair carrying /// the columns the shared CollectorHealth.HealthStatus banding needs, so the caller counts each diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs index 7638ddb5ca..c8b34ae106 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs @@ -955,7 +955,7 @@ a consumer keys on a field instead of parsing the label. */ sql_version: "PostgreSQL 18" — so it is documented as deprecated everywhere the payload is described, and retiring it belongs to a later major of the MCP contract, not here. */ sql_version = engineVersion, - status = FreshnessStatus(s.LastCollection, nowUtc), + status = FreshnessStatus(s.LastCollection, s.RegisteredAt, nowUtc), read_only = s.ServerName.EndsWith(":RO", StringComparison.Ordinal), last_collection = s.LastCollection?.ToString("o") }; @@ -1482,10 +1482,16 @@ the keys Lite's tool emits. */ /// it does NOT share is the vocabulary: spells the /// never-collected state as one word because that value was published to MCP clients, and a status value /// a client keys on is a consumer API. + /// + /// #3967: the read has no window, but the collection log's retention bounds what it can see, so a + /// server whose whole history retention has dropped comes back null. Its registration tells that server + /// (Offline) from one that has never collected (AwaitingFirstCollection), through the same rule the fleet + /// card applies to its 48-hour window. /// - private static string FreshnessStatus(DateTime? lastCollectionUtc, DateTime nowUtc) => + internal static string FreshnessStatus(DateTime? lastCollectionUtc, DateTime? registeredAtUtc, DateTime nowUtc) => ServerCollectionStatusRules - .FromFreshness(ServerHealthClassifier.ClassifyFreshness(lastCollectionUtc, nowUtc)) + .FromFreshness(ServerHealthClassifier.ClassifyFreshness( + lastCollectionUtc, registeredAtUtc, DarlingRetentionHorizons.CollectionLogHorizon(nowUtc), nowUtc)) .McpToken(); /// diff --git a/Darling/PerformanceMonitor.Darling.Storage/DailySummaryHorizon.cs b/Darling/PerformanceMonitor.Darling.Storage/DailySummaryHorizon.cs index 8f41739166..fe08baded0 100644 --- a/Darling/PerformanceMonitor.Darling.Storage/DailySummaryHorizon.cs +++ b/Darling/PerformanceMonitor.Darling.Storage/DailySummaryHorizon.cs @@ -45,6 +45,18 @@ public static class DarlingRetentionHorizons /// public const int CollectionLogRetentionDays = DataRetentionBaseDays * 2; + /// + /// The earliest instant a read of collection_log with no window can still see at + /// : back, as naive UTC (#3967). The purge + /// never removes a row newer than this. It drops whole chunks whose every row is older, or deletes rows older + /// than its own run's cutoff, which is earlier still, so some older rows can survive and none newer are + /// lost. It is the searchedFromUtc every surface that reads a server's newest collection with no + /// window hands ServerHealthClassifier.ClassifyFreshness, so a server whose whole history retention + /// has dropped reads Offline rather than "Awaiting first collection". + /// + public static DateTime CollectionLogHorizon(DateTime nowUtc) => + DateTime.SpecifyKind(nowUtc.AddDays(-CollectionLogRetentionDays), DateTimeKind.Unspecified); + /// /// config_alert_log (the fired-alert history) is a plain registry table — not a collector, so no /// schedule horizon; not a hypertable, so a batched DELETE purge. Kept 90 days (a quarter): alert history is diff --git a/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml.cs index 88fb7d4daf..0dde037e7f 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml.cs @@ -1511,6 +1511,10 @@ reads have no engine column and need none. It is what selects which deadlock-sou stamped from the one registry row, so a card cannot end up claiming Aurora-ness the engine token does not support. */ summary.IsAurora = server.IsAurora; + /* #3967: and the registration, for the same reason — it is a registry fact, and it is what + tells a server whose history retention has dropped (Offline) from one that has never + collected. The sidebar dot reads the same field off the same row. */ + summary.RegisteredAt = server.RegisteredAt; summary.ApplyFreshness(nowUtc); found.Add(summary); } diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ManageServersWindow.xaml b/Darling/PerformanceMonitor.Darling.Viewer/ManageServersWindow.xaml index 89828b3ac4..a89869edd4 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ManageServersWindow.xaml +++ b/Darling/PerformanceMonitor.Darling.Viewer/ManageServersWindow.xaml @@ -69,7 +69,8 @@ monitored server. --> + viewer. Reuses the sidebar dots' MAX(collection_time) freshness read; "Never" until first collect, + "None retained" once retention has dropped a dark server's whole history (#3967). --> diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ManageServersWindow.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/ManageServersWindow.xaml.cs index 77c3d5cd2f..cc6228e830 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ManageServersWindow.xaml.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ManageServersWindow.xaml.cs @@ -8,10 +8,12 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Reflection; using System.Threading.Tasks; using System.Windows; using System.Windows.Input; +using PerformanceMonitor.Common; using PerformanceMonitor.Ui; namespace PerformanceMonitor.Darling.Viewer; @@ -74,14 +76,32 @@ last collected each server (the viewer never connects to a monitored server). De freshness = new Dictionary(); } + /* #3967: the registry's first-connect instants, so a server whose whole history retention has + dropped reads "None retained" rather than "Never" — the registration rule the sidebar dot and the + Overview card apply. Defensive like the freshness read: a failure leaves every row on "Never". */ + Dictionary registered; + try + { + registered = (await _dataService.GetServersAsync()).ToDictionary(s => s.ServerId, s => s.RegisteredAt); + } + catch (Exception ex) + { + ViewerLogger.Warn("ManageServersWindow", $"registry read failed: {ex.Message}"); + registered = new Dictionary(); + } + + var nowUtc = DateTime.UtcNow; var items = new List(rows.Count); foreach (var row in rows) { var lastCollected = freshness.TryGetValue(row.ServerId, out var t) ? t : (DateTime?)null; + var registeredAt = registered.TryGetValue(row.ServerId, out var r) ? r : null; items.Add(new ManagedServerListItem(row, _serverStore.IsFavorite(row.Host)) { InstalledVersion = appVersion, LastCollectedUtc = lastCollected, + HistoryAgedOut = lastCollected is null + && ServerSummaryItem.ClassifyFreshness(null, registeredAt, nowUtc) == ServerFreshness.Offline, }); } @@ -318,10 +338,18 @@ public ManagedServerListItem(MonitoredServerRow row, bool isFavorite) /// collectors), set once before binding; null when the Darling service has not collected this server yet. public DateTime? LastCollectedUtc { get; set; } + /// True when there is no collection to show because retention has dropped all of it (#3967): the + /// server registered before the collection log's horizon and nothing newer is left. Set once before + /// binding. + public bool HistoryAgedOut { get; set; } + /// The "Last Collected" cell: the newest collection time rendered in the viewer's timestamp-display - /// mode (Server/Local/UTC via ), or "Never" when the service has - /// not collected this server yet. Labeled "Last Collected" — the SERVICE connects and collects, the viewer - /// never does — so this reflects service activity, not a viewer connection. + /// mode (Server/Local/UTC via ), "None retained" when retention + /// has dropped all of it, or "Never" when the service has not collected this server yet. Labeled "Last + /// Collected" — the SERVICE connects and collects, the viewer never does — so this reflects service + /// activity, not a viewer connection. public string LastCollectedDisplay => - LastCollectedUtc is { } utc ? ViewerTimeHelper.ForDisplay(utc).ToString("yyyy-MM-dd HH:mm") : "Never"; + LastCollectedUtc is { } utc ? ViewerTimeHelper.ForDisplay(utc).ToString("yyyy-MM-dd HH:mm") + : HistoryAgedOut ? "None retained" + : "Never"; } diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.MonitoredServers.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.MonitoredServers.cs index 6fff3c71e8..b3a36e96be 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.MonitoredServers.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.MonitoredServers.cs @@ -218,6 +218,10 @@ AND database IS NOT DISTINCT FROM $2 /// whose tab set, chip and card were already correct. It is read from the OBSERVED side beside the kind, /// for the same reason: a probed fact lives on collect.servers, and the desired-state config plane /// cannot carry one. + /// + /// created_date (#3967) is read from the OBSERVED side for the same reason, and on both + /// queries for the #3145 one: it is the first successful connect, and a server the operator added that the + /// service has not reached has none, which keeps its dot on "Awaiting first collection". /// public const string ManagedServersSql = @" SELECT @@ -229,7 +233,8 @@ AND database IS NOT DISTINCT FROM $2 c.monthly_cost_usd, s.engine_kind, COALESCE(s.sql_engine_edition, 0) AS sql_engine_edition, - s.postgres_major_version + s.postgres_major_version, + s.created_date FROM config_monitored_servers c LEFT JOIN servers s ON s.server_id = c.server_id ORDER BY COALESCE(s.display_name, c.name)"; @@ -264,7 +269,8 @@ public async Task> GetManagedServersAsync(CancellationToken reader.IsDBNull(5) ? 0m : Convert.ToDecimal(reader.GetValue(5)), reader.IsDBNull(6) ? null : reader.GetString(6), reader.IsDBNull(7) ? CollectorEngineCapability.UnknownEngineEdition : reader.GetInt32(7), - reader.IsDBNull(8) ? null : reader.GetInt32(8))); + reader.IsDBNull(8) ? null : reader.GetInt32(8), + reader.IsDBNull(9) ? null : reader.GetDateTime(9))); } return servers; diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs index ddbfa625a1..02e5be873a 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs @@ -815,6 +815,15 @@ public sealed class ServerSummaryItem public DateTime? LastCollectionTime { get; set; } + /// + /// The server's registration, servers.created_date (its first successful connect), stamped by the + /// Overview loader from the registry row it already holds, the way is (#3967). + /// Null when the loader did not stamp one, which keeps the ladder's reading. It is what + /// reads to tell a server whose whole history retention has dropped (Offline) + /// from one that has never collected. + /// + public DateTime? RegisteredAt { get; set; } + /// /// Headline CPU display: total non-idle CPU prominently with the SQL-only number alongside, e.g. /// "64% (SQL 60%)". Falls back to a single number when only one value is available. @@ -985,10 +994,14 @@ public string ThreadsDisplay /// /// The stored collection_time is naive UTC; the viewer shows it in the viewer machine's local time /// (the viewer convention — Lite used its per-server offset helper instead). + /// + /// With no collection to show, "Never" only when the card is awaiting its first one. A card that + /// reads Offline with nothing to show is a server whose history retention has dropped (#3967): something + /// was collected, and none of it is retained, which is what the row says. /// public string LastCollectionDisplay => LastCollectionTime.HasValue ? ViewerTimeHelper.ForDisplay(LastCollectionTime.Value).ToString("HH:mm:ss") - : "Never"; + : IsOnline == false ? "None retained" : "Never"; /* Collection status. The (IsOnline, CollectionStale, AwaitingFirstCollection) triple is resolved by ServerCollectionStatusRules.Classify and nowhere else in the viewer — the sidebar row's dot carried its @@ -1246,6 +1259,18 @@ public SolidColorBrush CardBorderBrush public static ServerFreshness ClassifyFreshness(DateTime? lastCollectionUtc, DateTime nowUtc) => ServerHealthClassifier.ClassifyFreshness(lastCollectionUtc, nowUtc); + /// + /// The same derivation with the registration rule (#3967), and the one the sidebar dot and the Overview + /// card both call. Their newest-collection reads have no window, but the collection log's retention bounds + /// what they can see (), so a server whose whole + /// history retention has dropped comes back null. Registered before that horizon, it reads Offline; + /// registered after it, or with no registration, it keeps the ladder's never-collected reading. One + /// horizon for both surfaces, so the dot and the card cannot disagree about the same server. + /// + public static ServerFreshness ClassifyFreshness(DateTime? lastCollectionUtc, DateTime? registeredAtUtc, DateTime nowUtc) => + ServerHealthClassifier.ClassifyFreshness( + lastCollectionUtc, registeredAtUtc, DarlingRetentionHorizons.CollectionLogHorizon(nowUtc), nowUtc); + /// /// Maps the freshness band onto the card's three status flags, taking the live-ping's place: Fresh → /// Online, Stale → the amber Warning state, Offline → the red Offline overlay, NeverCollected → the amber @@ -1259,7 +1284,7 @@ public static ServerFreshness ClassifyFreshness(DateTime? lastCollectionUtc, Dat /// public void ApplyFreshness(DateTime nowUtc) { - var flags = ServerCollectionStatusRules.FlagsFor(ClassifyFreshness(LastCollectionTime, nowUtc)); + var flags = ServerCollectionStatusRules.FlagsFor(ClassifyFreshness(LastCollectionTime, RegisteredAt, nowUtc)); IsOnline = flags.IsOnline; CollectionStale = flags.CollectionStale; AwaitingFirstCollection = flags.AwaitingFirstCollection; diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs index 22ad3cd57d..e1aa4c62c1 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs @@ -50,6 +50,10 @@ public sealed class DarlingServer : INotifyPropertyChanged /// construct a SQL Server row keep compiling, and so the two reader call sites stay the only places that /// have to know the column exists. /// + /// + /// servers.created_date (#3967), defaulted for the same two reasons. Null for a server the operator + /// added that the service has not connected to yet, which is the honest answer: nothing has registered it. + /// public DarlingServer( int serverId, string serverName, @@ -59,7 +63,8 @@ public DarlingServer( decimal monthlyCostUsd = 0, string? engineKind = null, int engineEdition = CollectorEngineCapability.UnknownEngineEdition, - int? postgresMajorVersion = null) + int? postgresMajorVersion = null, + DateTime? registeredAt = null) { ServerId = serverId; ServerName = serverName; @@ -70,6 +75,7 @@ public DarlingServer( EngineKind = engineKind; EngineEdition = engineEdition; PostgresMajorVersion = postgresMajorVersion; + RegisteredAt = registeredAt; } public int ServerId { get; } @@ -134,6 +140,15 @@ public DarlingServer( public string VersionLabel => MonitoredEngineVersion.DescribeEngineVersion(EngineKind, SqlMajorVersion, PostgresMajorVersion); + /// + /// servers.created_date: the service's first successful connect to this server, or null when it has + /// not connected yet (#3967). The newest-collection reads behind the dot and the Overview card have no + /// window, but the collection log's retention bounds what they can see, and this is what tells a server + /// whose whole history retention has dropped (Offline) from one that has never collected (awaiting). See + /// . + /// + public DateTime? RegisteredAt { get; } + // ── Runtime-only sidebar state (not from Postgres; drives the ported Lite server-row chrome) ── private bool _isFavorite; @@ -276,7 +291,7 @@ private void RaiseDotChanged() public void ApplyFreshness(DateTime? lastCollectionUtc, DateTime nowUtc) { var flags = ServerCollectionStatusRules.FlagsFor( - ServerSummaryItem.ClassifyFreshness(lastCollectionUtc, nowUtc)); + ServerSummaryItem.ClassifyFreshness(lastCollectionUtc, RegisteredAt, nowUtc)); IsOnline = flags.IsOnline; CollectionStale = flags.CollectionStale; AwaitingFirstCollection = flags.AwaitingFirstCollection; @@ -393,9 +408,14 @@ public sealed partial class ViewerDataService : IAsyncDisposable /// both-queries requirement (#3145): it is the PostgreSQL vocabulary's own major, and without it the /// sidebar's version label has nothing but sql_major_version — which is 0 on every /// PostgreSQL target — to describe the row with. + /// + /// created_date (#3967) rides along with the same both-queries requirement: it is the server's + /// first successful connect, and is what lets the sidebar dot and + /// the Overview card read a server whose history retention has dropped as Offline rather than awaiting its + /// first collection. /// public const string ServersSql = - "SELECT server_id, server_name, display_name, is_enabled, sql_major_version, COALESCE(monthly_cost_usd, 0), engine_kind, COALESCE(sql_engine_edition, 0), postgres_major_version FROM servers ORDER BY display_name"; + "SELECT server_id, server_name, display_name, is_enabled, sql_major_version, COALESCE(monthly_cost_usd, 0), engine_kind, COALESCE(sql_engine_edition, 0), postgres_major_version, created_date FROM servers ORDER BY display_name"; /// /// The authoritative read-only probe (V8 security hardening): does the connected role hold UPDATE on @@ -2230,7 +2250,8 @@ public async Task> GetServersAsync(CancellationToken cancell reader.IsDBNull(5) ? 0m : Convert.ToDecimal(reader.GetValue(5)), reader.IsDBNull(6) ? null : reader.GetString(6), reader.IsDBNull(7) ? CollectorEngineCapability.UnknownEngineEdition : reader.GetInt32(7), - reader.IsDBNull(8) ? null : reader.GetInt32(8))); + reader.IsDBNull(8) ? null : reader.GetInt32(8), + reader.IsDBNull(9) ? null : reader.GetDateTime(9))); } return servers; diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml.cs index db01f64623..aceb9e0aa9 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml.cs @@ -345,8 +345,10 @@ gets the same answer on its own Overview tab instead. Writing a header nobody ca /// Fills the toolbar's freshness readout with this server's newest collection time (the shared /// MAX(collection_time) read the sidebar dots use), rendered in the active Server/Local/UTC display mode /// exactly like the Manage Servers "Last Collected" column. A server the service hasn't collected yet is - /// absent from the freshness map, shown as "no data collected yet". Best-effort chrome: a read failure - /// blanks the readout rather than disturbing the tab load. + /// absent from the freshness map, shown as "no data collected yet"; so is a server whose whole history + /// retention has dropped, which reads "no collection retained" instead (#3967), by the same registration + /// rule the sidebar dot applies. Best-effort chrome: a read failure blanks the readout rather than + /// disturbing the tab load. /// private async Task UpdateServerFreshnessLabelAsync() { @@ -355,7 +357,9 @@ private async Task UpdateServerFreshnessLabelAsync() var freshness = await _dataService.GetServerFreshnessAsync(); ServerFreshnessText.Text = freshness.TryGetValue(_server.ServerId, out var lastUtc) ? $"collected {ViewerTimeHelper.ForDisplay(lastUtc):yyyy-MM-dd HH:mm}" - : "no data collected yet"; + : ServerSummaryItem.ClassifyFreshness(null, _server.RegisteredAt, DateTime.UtcNow) == ServerFreshness.Offline + ? "no collection retained" + : "no data collected yet"; } catch { diff --git a/Lite.Tests/DarkPastRetentionReadsOfflineToolTests.cs b/Lite.Tests/DarkPastRetentionReadsOfflineToolTests.cs new file mode 100644 index 0000000000..938fcec5b2 --- /dev/null +++ b/Lite.Tests/DarkPastRetentionReadsOfflineToolTests.cs @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using PerformanceMonitor.Common; +using PerformanceMonitorLite.Database; +using PerformanceMonitorLite.Mcp; +using PerformanceMonitorLite.Models; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// +/// #3967, the Lite half: a server whose whole history has aged out of the archive reads Offline on the Overview +/// card, not "Never" beside an amber "No collection has ever landed for this server". +/// +/// Why Lite has the case. The card's newest-collection read has no window (hot table, then the +/// archive view), but deletes archive files after +/// months. A server dark for longer has no row anywhere, +/// and the ladder calls a null never collected. The registration is the server's CreatedDate in +/// servers.json: Lite collects from a server only once it has been added, so the Darling rule applies unchanged, +/// with what the archive is certain to still hold as the horizon. +/// +public sealed class DarkPastRetentionReadsOfflineToolTests : IClassFixture, IDisposable +{ + private readonly DuckDbInitializer _duckDb; + private readonly string _configDir; + private readonly ServerManager _serverManager; + + public DarkPastRetentionReadsOfflineToolTests(SharedDuckDbFixture fixture) + { + fixture.ResetData(); + _duckDb = fixture.DuckDb; + + _configDir = Path.Combine(Path.GetTempPath(), "pmlite-3967-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_configDir); + _serverManager = new ServerManager(_configDir); + } + + public void Dispose() + { + try { Directory.Delete(_configDir, recursive: true); } catch (IOException) { /* temp dir */ } + } + + /// + /// The horizon: the first month start at or after the cleanup's cutoff, because a month's archive file goes + /// whole. Exact month starts stay where they are; a month-end clamps the way AddMonths does. + /// + [Fact] + public void TheHorizon_IsTheFirstMonthStartTheArchiveStillHoldsWhole() + { + Assert.Equal(new DateTime(2026, 7, 1), RetentionService.OldestRetainedInstant(new DateTime(2026, 9, 23, 12, 0, 0, DateTimeKind.Utc))); + Assert.Equal(new DateTime(2026, 6, 1), RetentionService.OldestRetainedInstant(new DateTime(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc))); + Assert.Equal(new DateTime(2026, 3, 1), RetentionService.OldestRetainedInstant(new DateTime(2026, 5, 31, 10, 0, 0, DateTimeKind.Utc))); + Assert.Equal(DateTimeKind.Unspecified, RetentionService.OldestRetainedInstant(DateTime.UtcNow).Kind); + } + + /// + /// The horizon against the cleanup itself, so the two cannot drift: the monthly file for the horizon's + /// month survives , and the month before it does not. + /// + [Fact] + public void TheCleanup_KeepsTheHorizonsMonth_AndDeletesTheOneBefore() + { + var archive = Path.Combine(_configDir, "archive"); + Directory.CreateDirectory(archive); + + var horizon = RetentionService.OldestRetainedInstant(DateTime.UtcNow); + var kept = Path.Combine(archive, $"{horizon:yyyyMM}_collection_log.parquet"); + var deleted = Path.Combine(archive, $"{horizon.AddMonths(-1):yyyyMM}_collection_log.parquet"); + File.WriteAllBytes(kept, [1]); + File.WriteAllBytes(deleted, [1]); + + new RetentionService(archive).CleanupOldArchives(); + + Assert.True(File.Exists(kept), "the archive deleted the month the horizon says is still held"); + Assert.False(File.Exists(deleted), "the month before the horizon survived the cleanup, so the horizon is later than it needs to be"); + } + + /// + /// The card, through the shipped summary read: a server added six months ago with no row left reads + /// Offline, and its Last Collect row says none is retained; one added ten days ago with no row still reads + /// never collected; one with no registration keeps that reading too. + /// + [Fact] + public async Task TheOverviewCard_ReadsAServerWhoseHistoryAgedOut_Offline() + { + var service = new LocalDataService(_duckDb); + + var agedOut = await service.GetServerSummaryAsync(967001, "aged-out", DateTime.UtcNow.AddMonths(-6)); + Assert.Equal(ServerFreshness.Offline, agedOut!.CollectionFreshness); + Assert.Null(agedOut.LastCollectionTime); + Assert.Equal("None retained (stopped)", agedOut.LastCollectionDisplay); + + var justAdded = await service.GetServerSummaryAsync(967002, "just-added", DateTime.UtcNow.AddDays(-10)); + Assert.Equal(ServerFreshness.NeverCollected, justAdded!.CollectionFreshness); + Assert.Equal("Never", justAdded.LastCollectionDisplay); + + var unknown = await service.GetServerSummaryAsync(967003, "unknown", registeredAtUtc: null); + Assert.Equal(ServerFreshness.NeverCollected, unknown!.CollectionFreshness); + } + + /// A reading with a newest collection is untouched: an old registration does not move a fresh, + /// stale or offline card. + [Fact] + public void AReadingWithANewestCollection_BandsOnTheLadder_WhateverTheRegistration() + { + var now = DateTime.UtcNow; + foreach (var lastCollection in new[] { now.AddSeconds(-30), now.AddMinutes(-10), now.AddHours(-3), now.AddDays(-20) }) + { + var card = new ServerSummaryItem { LastCollectionTime = lastCollection }; + card.ApplyCollectionFreshness(now, now.AddMonths(-6)); + Assert.Equal(ServerHealthClassifier.ClassifyFreshness(lastCollection, now), card.CollectionFreshness); + } + } + + /// + /// The seam from servers.json: the registration the Overview and both MCP reads pass is the server's + /// CreatedDate in UTC, and it is not written back to servers.json. + /// + [Fact] + public async Task TheRegistration_ComesFromTheServerEntry_AndIsNotPersisted() + { + var added = DateTime.Now.AddMonths(-6); + var server = new ServerConnection { Id = Guid.NewGuid().ToString(), ServerName = "AgedOutSrv", IsEnabled = true, CreatedDate = added }; + _serverManager.AddServer(server); + + Assert.Equal(added.ToUniversalTime(), server.RegisteredAtUtc); + Assert.DoesNotContain("RegisteredAtUtc", JsonSerializer.Serialize(server), StringComparison.Ordinal); + + var serverId = RemoteCollectorService.GetDeterministicHashCode(RemoteCollectorService.GetServerNameForStorage(server)); + Assert.Equal(server.RegisteredAtUtc, ServerResolver.RegisteredAtUtc(_serverManager, serverId)); + + /* And list_servers says so, where it used to say "No data collected". */ + var list = await McpDiscoveryTools.ListServers(_serverManager, new LocalDataService(_duckDb)); + Assert.Contains("(last collection: none retained)", list, StringComparison.Ordinal); + Assert.DoesNotContain("No data collected", list, StringComparison.Ordinal); + } + + /// A server added ten days ago with nothing collected yet is still "No data collected" in + /// list_servers — the reading the fix must not disturb. + [Fact] + public async Task ListServers_StillSaysNoDataCollected_ForAServerAddedInsideTheRetention() + { + _serverManager.AddServer(new ServerConnection { Id = Guid.NewGuid().ToString(), ServerName = "NewSrv", IsEnabled = true, CreatedDate = DateTime.Now.AddDays(-10) }); + + var list = await McpDiscoveryTools.ListServers(_serverManager, new LocalDataService(_duckDb)); + Assert.Contains("(last collection: No data collected)", list, StringComparison.Ordinal); + } + + /// + /// A newest collection that IS retained is still read and shown: the rule does not reach past a real row. + /// Seeded into the hot table, where the card's read looks first. + /// + [Fact] + public async Task ARetainedCollection_IsStillReadAndShown() + { + var collected = DateTime.UtcNow.AddDays(-20); + using (var connection = _duckDb.CreateConnection()) + { + await connection.OpenAsync(TestContext.Current.CancellationToken); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "INSERT INTO collection_log (log_id, server_id, server_name, collector_name, collection_time, status) VALUES (96700401, 967004, 'dark-inside', 'wait_stats', $1, 'SUCCESS')"; + cmd.Parameters.Add(new DuckDBParameter { Value = collected }); + await cmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + var card = await new LocalDataService(_duckDb).GetServerSummaryAsync(967004, "dark-inside", DateTime.UtcNow.AddMonths(-6)); + Assert.Equal(ServerFreshness.Offline, card!.CollectionFreshness); + Assert.NotNull(card.LastCollectionTime); + Assert.EndsWith("(stopped)", card.LastCollectionDisplay, StringComparison.Ordinal); + Assert.NotEqual("None retained (stopped)", card.LastCollectionDisplay); + } +} diff --git a/Lite.Tests/OverviewNewestReadsHotFirstTests.cs b/Lite.Tests/OverviewNewestReadsHotFirstTests.cs index d7bc7abe7d..3e2ed8a52f 100644 --- a/Lite.Tests/OverviewNewestReadsHotFirstTests.cs +++ b/Lite.Tests/OverviewNewestReadsHotFirstTests.cs @@ -103,7 +103,7 @@ public async Task ANewestHotRow_IsReadWithoutOpeningTheArchive_AndAnArchiveOnlyS await initializer.CreateArchiveViewsAsync(); var data = new LocalDataService(initializer); - var hot = await data.GetServerSummaryAsync(HotServer, "hot"); + var hot = await data.GetServerSummaryAsync(HotServer, "hot", registeredAtUtc: null); Assert.NotNull(hot); Assert.Equal(42, hot!.CpuPercent); Assert.Equal(4200, hot.MemoryMb); @@ -111,7 +111,7 @@ public async Task ANewestHotRow_IsReadWithoutOpeningTheArchive_AndAnArchiveOnlyS Assert.Equal(hotNewest, hot.LastCollectionTime); /* The fallback: nothing hot, so the archive view answers — with the archived NEWEST row. */ - var archived = await data.GetServerSummaryAsync(ArchiveOnlyServer, "archived"); + var archived = await data.GetServerSummaryAsync(ArchiveOnlyServer, "archived", registeredAtUtc: null); Assert.NotNull(archived); Assert.Equal(20, archived!.CpuPercent); Assert.Equal(2000, archived.MemoryMb); @@ -134,7 +134,7 @@ public async Task ANewestHotRow_IsReadWithoutOpeningTheArchive_AndAnArchiveOnlyS } /* The hot server's card is untouched by that: none of its three newest-row reads opened the archive. */ - var again = await data.GetServerSummaryAsync(HotServer, "hot"); + var again = await data.GetServerSummaryAsync(HotServer, "hot", registeredAtUtc: null); Assert.NotNull(again); Assert.Equal(42, again!.CpuPercent); Assert.Equal(4200, again.MemoryMb); diff --git a/Lite.Tests/ServerCollectionFreshnessTests.cs b/Lite.Tests/ServerCollectionFreshnessTests.cs index fac9de3b39..4d70525467 100644 --- a/Lite.Tests/ServerCollectionFreshnessTests.cs +++ b/Lite.Tests/ServerCollectionFreshnessTests.cs @@ -57,7 +57,7 @@ private static ServerSummaryItem QuietServer(TimeSpan? sinceLastCollection) LastCollectionTime = sinceLastCollection.HasValue ? Now - sinceLastCollection.Value : null, }; - card.ApplyCollectionFreshness(Now); + card.ApplyCollectionFreshness(Now, registeredAtUtc: null); return card; } @@ -300,10 +300,10 @@ public void TheBandIsPureOverTheClockItIsGiven() { var card = new ServerSummaryItem { LastCollectionTime = Now }; - card.ApplyCollectionFreshness(Now); + card.ApplyCollectionFreshness(Now, registeredAtUtc: null); Assert.Equal(ServerFreshness.Fresh, card.CollectionFreshness); - card.ApplyCollectionFreshness(Now + ServerHealthThresholds.OfflineThreshold + TimeSpan.FromMinutes(1)); + card.ApplyCollectionFreshness(Now + ServerHealthThresholds.OfflineThreshold + TimeSpan.FromMinutes(1), registeredAtUtc: null); Assert.Equal(ServerFreshness.Offline, card.CollectionFreshness); } @@ -342,13 +342,15 @@ public void EveryCardIsStampedWithItsBandWhereItIsBuilt() var source = ParitySource.ReadFile("Lite/Services/LocalDataService.Overview.cs"); Assert.Equal(1, CountOccurrences(source, "new ServerSummaryItem")); - Assert.Equal(1, CountOccurrences(source, "ApplyCollectionFreshness(DateTime.UtcNow)")); + /* #3967: the stamp carries the caller's registration, so every caller's card bands a server whose + history has aged out of the archive alike. */ + Assert.Equal(1, CountOccurrences(source, "ApplyCollectionFreshness(DateTime.UtcNow, registeredAtUtc)")); /* And the stamp is after the object is built: banding an object that has already been handed back would band nothing, and the ordering is the half a count cannot see. */ Assert.True( source.IndexOf("new ServerSummaryItem", StringComparison.Ordinal) - < source.IndexOf("ApplyCollectionFreshness(DateTime.UtcNow)", StringComparison.Ordinal), + < source.IndexOf("ApplyCollectionFreshness(DateTime.UtcNow, registeredAtUtc)", StringComparison.Ordinal), "The freshness stamp no longer follows the summary it is meant to band."); } diff --git a/Lite.Tests/TimeHonestyRungTests.cs b/Lite.Tests/TimeHonestyRungTests.cs index b0be05f6a4..93a4999de7 100644 --- a/Lite.Tests/TimeHonestyRungTests.cs +++ b/Lite.Tests/TimeHonestyRungTests.cs @@ -438,7 +438,7 @@ public async Task TheOverviewRead_CarriesBothStampsOfTheNewestCpuRow() var utcPre = now.AddMinutes(-2); await SeedCpuAsync(_serverId, "TimeHonestySrv", utcPre.AddMinutes(CollectedOffset), null, 41); - var summary = await _dataService.GetServerSummaryAsync(_serverId, "TimeHonestySrv"); + var summary = await _dataService.GetServerSummaryAsync(_serverId, "TimeHonestySrv", registeredAtUtc: null); Assert.NotNull(summary); Assert.Equal(41, summary!.CpuPercent); Assert.Equal(utcPre.AddMinutes(CollectedOffset), summary.CpuSampleTime); @@ -448,7 +448,7 @@ public async Task TheOverviewRead_CarriesBothStampsOfTheNewestCpuRow() var utcPost = now.AddMinutes(-1); await SeedCpuAsync(_serverId, "TimeHonestySrv", utcPost.AddMinutes(CollectedOffset), utcPost, 42); - summary = await _dataService.GetServerSummaryAsync(_serverId, "TimeHonestySrv"); + summary = await _dataService.GetServerSummaryAsync(_serverId, "TimeHonestySrv", registeredAtUtc: null); Assert.NotNull(summary); Assert.Equal(42, summary!.CpuPercent); Assert.Equal(utcPost.AddMinutes(CollectedOffset), summary.CpuSampleTime); diff --git a/Lite/MainWindow.xaml.cs b/Lite/MainWindow.xaml.cs index 8d8a49dc65..90a69b2e03 100644 --- a/Lite/MainWindow.xaml.cs +++ b/Lite/MainWindow.xaml.cs @@ -787,7 +787,7 @@ RefreshServerList runs before tags are known on the very first load. */ try { var serverId = RemoteCollectorService.GetDeterministicHashCode(RemoteCollectorService.GetServerNameForStorage(server)); - var summary = await Task.Run(() => _dataService.GetServerSummaryAsync(serverId, server.DisplayNameWithIntent)); + var summary = await Task.Run(() => _dataService.GetServerSummaryAsync(serverId, server.DisplayNameWithIntent, server.RegisteredAtUtc)); if (summary != null) { summary.ServerName = server.ServerName; diff --git a/Lite/Mcp/McpDiscoveryTools.cs b/Lite/Mcp/McpDiscoveryTools.cs index a11b815c6a..a56e8e0084 100644 --- a/Lite/Mcp/McpDiscoveryTools.cs +++ b/Lite/Mcp/McpDiscoveryTools.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using ModelContextProtocol.Server; +using PerformanceMonitor.Common; using PerformanceMonitorLite.Services; namespace PerformanceMonitorLite.Mcp; @@ -33,8 +34,11 @@ public static async Task ListServers(ServerManager serverManager, LocalD }; var serverId = RemoteCollectorService.GetDeterministicHashCode(RemoteCollectorService.GetServerNameForStorage(s)); - var summary = await dataService.GetServerSummaryAsync(serverId, s.DisplayNameWithIntent); - var lastCollection = summary?.LastCollectionTime?.ToString("o") ?? "No data collected"; + var summary = await dataService.GetServerSummaryAsync(serverId, s.DisplayNameWithIntent, s.RegisteredAtUtc); + /* #3967: a server whose history has aged out of the archive has no collection to show either, + and "No data collected" would claim it never collected. */ + var lastCollection = summary?.LastCollectionTime?.ToString("o") + ?? (summary?.CollectionFreshness == ServerFreshness.Offline ? "none retained" : "No data collected"); lines.Add($"- {display} [{statusText}] (last collection: {lastCollection})"); } diff --git a/Lite/Mcp/McpHealthTools.cs b/Lite/Mcp/McpHealthTools.cs index 97f7224e76..18495cac8b 100644 --- a/Lite/Mcp/McpHealthTools.cs +++ b/Lite/Mcp/McpHealthTools.cs @@ -39,7 +39,8 @@ public static async Task GetServerSummary( try { - var summary = await dataService.GetServerSummaryAsync(resolved.ServerId, resolved.ServerName); + var summary = await dataService.GetServerSummaryAsync( + resolved.ServerId, resolved.ServerName, ServerResolver.RegisteredAtUtc(serverManager, resolved.ServerId)); if (summary == null) { return McpHelpers.Status( diff --git a/Lite/Mcp/ServerResolver.cs b/Lite/Mcp/ServerResolver.cs index 1b2eb5cfc2..2bdeb8caa1 100644 --- a/Lite/Mcp/ServerResolver.cs +++ b/Lite/Mcp/ServerResolver.cs @@ -80,6 +80,18 @@ public static ((int ServerId, string ServerName) resolved, string? error) Resolv : (resolved.Value, null); } + /// + /// When the enabled server whose storage identity is was added + /// (), or null when none matches (#3967). The resolver + /// hands back an id and a name, and the summary read needs the registration too, so its band agrees with + /// the Overview card's for the same server. + /// + internal static DateTime? RegisteredAtUtc(ServerManager serverManager, int serverId) => + serverManager.GetEnabledServers() + .Where(s => RemoteCollectorService.GetDeterministicHashCode(RemoteCollectorService.GetServerNameForStorage(s)) == serverId) + .Select(s => (DateTime?)s.RegisteredAtUtc) + .FirstOrDefault(); + private static string ListAvailableServers(ServerManager serverManager) { var servers = serverManager.GetEnabledServers(); diff --git a/Lite/Models/ServerConnection.cs b/Lite/Models/ServerConnection.cs index 36a8432431..c51c2ac8ef 100644 --- a/Lite/Models/ServerConnection.cs +++ b/Lite/Models/ServerConnection.cs @@ -78,6 +78,16 @@ public bool UseWindowsAuth public string? Description { get; set; } public DateTime CreatedDate { get; set; } = DateTime.Now; + + /// + /// in UTC: when this server was added, which is the first moment Lite could have + /// collected from it (#3967). It is what tells a server whose whole history has aged out of the archive + /// (Offline) from one that has never collected. The stored value is the adding machine's local clock, so + /// it is converted here once rather than at each reader. Not persisted. + /// + [JsonIgnore] + public DateTime RegisteredAtUtc => CreatedDate.ToUniversalTime(); + public DateTime LastConnected { get; set; } = DateTime.Now; public bool IsFavorite { get; set; } public bool IsEnabled { get; set; } = true; diff --git a/Lite/Services/LocalDataService.Overview.cs b/Lite/Services/LocalDataService.Overview.cs index 999a8c8fb0..d31f7ba368 100644 --- a/Lite/Services/LocalDataService.Overview.cs +++ b/Lite/Services/LocalDataService.Overview.cs @@ -20,7 +20,11 @@ public partial class LocalDataService /// /// Gets a summary of server health for the overview dashboard. /// - public async Task GetServerSummaryAsync(int serverId, string displayName) + /// When the server was added (), + /// or null when the caller has none. Required rather than defaulted (#3967): it is what tells a server whose + /// history has aged out of the archive from one that has never collected, and a caller that kept the old + /// call would compile and band the first as the second. + public async Task GetServerSummaryAsync(int serverId, string displayName, DateTime? registeredAtUtc) { using var connection = await OpenConnectionAsync(); @@ -167,8 +171,9 @@ SELECT MAX(collection_time) place a ServerSummaryItem is built: the two MCP reads call this method too, and a band only the Overview applied would be a fact that depended on which caller asked. The clock is handed in rather than read inside the band, so the classification stays a pure function of - (last collection, now) — the shape the viewer's ApplyFreshness already has. */ - summary.ApplyCollectionFreshness(DateTime.UtcNow); + (last collection, now) — the shape the viewer's ApplyFreshness already has. The registration rides + in with the caller for #3967, so every caller's card bands a dark-past-retention server alike. */ + summary.ApplyCollectionFreshness(DateTime.UtcNow, registeredAtUtc); return summary; } @@ -396,13 +401,19 @@ public string CpuDisplay /// /// Stamp from this card's own . Pure - /// over (last collection, now): both instants are UTC (the DuckDB store is naive UTC and + /// over (last collection, registration, now): the instants are UTC (the DuckDB store is naive UTC and /// is ), so the subtraction inside the classifier /// is a true elapsed time regardless of Kind. Stamped rather than computed on read so the band cannot /// change between the row's brush binding and the tooltip that explains it. + /// + /// #3967: the newest-collection read has no window, but the archive's retention bounds what it can + /// see (), so a server whose whole history has aged out + /// comes back null. Added before that instant, it bands Offline; added after it, or with no registration, + /// it keeps the ladder's never-collected reading. The same rule the Darling viewer and fleet card apply. /// - public void ApplyCollectionFreshness(DateTime nowUtc) => - CollectionFreshness = ServerHealthClassifier.ClassifyFreshness(LastCollectionTime, nowUtc); + public void ApplyCollectionFreshness(DateTime nowUtc, DateTime? registeredAtUtc) => + CollectionFreshness = ServerHealthClassifier.ClassifyFreshness( + LastCollectionTime, registeredAtUtc, RetentionService.OldestRetainedInstant(nowUtc), nowUtc); /// /// The Last Collect row. Names its band in words when the collection is not current, because a colour @@ -414,7 +425,10 @@ public string LastCollectionDisplay { get { - if (!LastCollectionTime.HasValue) return "Never"; + /* #3967: with nothing to show, "Never" only for a server that has never collected. One banded + Offline with nothing to show collected once, and none of it is still archived. */ + if (!LastCollectionTime.HasValue) + return CollectionFreshness == ServerFreshness.Offline ? "None retained (stopped)" : "Never"; var stamp = ServerTimeHelper.FormatServerTime(LastCollectionTime, "HH:mm:ss"); return CollectionFreshness switch diff --git a/Lite/Services/RetentionService.cs b/Lite/Services/RetentionService.cs index ef078edc30..a029e23279 100644 --- a/Lite/Services/RetentionService.cs +++ b/Lite/Services/RetentionService.cs @@ -31,6 +31,25 @@ public class RetentionService /// public const int ArchiveRetentionMonths = 3; + /// + /// The oldest instant whose rows Lite is certain to still hold at : what a read + /// with no window can still see, and the searchedFromUtc the Overview card's freshness band is + /// handed so a server whose whole history has aged out reads Offline rather than never collected (#3967). + /// + /// Why a month start and not the cutoff itself. An archive file is named for the month (or + /// the day) it was WRITTEN, and deletes a file once that date is before + /// the cutoff, back. A month's file therefore goes whole, holding rows + /// up to a month younger than the cutoff. A row is written into a file of its own month or a later one, + /// because archival never runs before the row is collected, so it is certain to survive only from the + /// first month start at or after the cutoff. + /// + public static DateTime OldestRetainedInstant(DateTime utcNow) + { + var cutoff = DateTime.SpecifyKind(utcNow.AddMonths(-ArchiveRetentionMonths), DateTimeKind.Unspecified); + var monthStart = new DateTime(cutoff.Year, cutoff.Month, 1, 0, 0, 0, DateTimeKind.Unspecified); + return monthStart == cutoff ? monthStart : monthStart.AddMonths(1); + } + private readonly string _archivePath; private readonly ILogger? _logger; diff --git a/PerformanceMonitor.Common/ServerHealthBands.cs b/PerformanceMonitor.Common/ServerHealthBands.cs index 8642596aee..00808c9663 100644 --- a/PerformanceMonitor.Common/ServerHealthBands.cs +++ b/PerformanceMonitor.Common/ServerHealthBands.cs @@ -32,7 +32,10 @@ public enum ServerFreshness /// No collection has EVER landed for this server (this run or any prior) — the service has not reached it /// yet. Distinct from (which means data STOPPED): during a slow fleet bootstrap a /// red "Offline" on a server that was merely still queued sent a 24-server field report chasing a phantom - /// scheduler bug. Rendered as an amber "Awaiting first collection", never the red overlay. + /// scheduler bug. Rendered as an amber "Awaiting first collection", never the red overlay. A read that + /// cannot see all of history (a window, or retention) tells this apart from a server whose history is out + /// of its reach through 's registration overload of + /// ClassifyFreshness (#3967). /// NeverCollected, } @@ -696,6 +699,45 @@ public static ServerFreshness ClassifyFreshness(DateTime? lastCollectionUtc, Dat return ServerFreshness.Fresh; } + /// + /// Classify freshness from a newest-collection read that could only see collections from + /// on (#3935, #3967): the ladder above, except where that read's + /// null is not the ladder's "never". + /// + /// Why a null can mean two things. A read with a window drops a server that went quiet + /// before the window, and a read with no window drops one whose rows retention has purged. Both come + /// back null, and to the ladder above a null is , so a server + /// dark for longer than the read could see was called "Awaiting first collection" — the fleet card after + /// its 48 hours (#3935), and every surface that reads the collection log with no window once the + /// store's retention had dropped a long-dark server's history (#3967). + /// + /// The rule. A newest collection bands exactly as the ladder bands it. With none, the + /// server's registration decides: registered before , it is Offline; + /// registered at or after it, the ladder's never-collected reading stands, and that half is exact. A + /// registration is written when collection can first begin (Darling's registry row at the first + /// successful connect, Lite's server entry when it is added), so a server registered inside what the + /// read could see could only have collected inside it, and the read found nothing. Registered before + /// it, the server had time to collect and has sent nothing the read could see: Offline is the word, and + /// it is what a read that could see all of history says whenever the server did once collect. + /// + /// No registration keeps the ladder's reading, because nothing then says when collection + /// could have begun. That is also the reading a caller gets by passing null for a server its read did + /// not report at all: an absence of evidence never becomes a red Offline. + /// + /// The newest collection the read found, or null when it found none. + /// When the server was registered for collection, or null when unknown. + /// The earliest instant the read could have found a collection at: a + /// windowed read's lower bound, or the retention horizon of a read with no window. + /// The reference instant the ladder measures age from. + public static ServerFreshness ClassifyFreshness( + DateTime? lastCollectionUtc, + DateTime? registeredAtUtc, + DateTime searchedFromUtc, + DateTime nowUtc) => + !lastCollectionUtc.HasValue && registeredAtUtc.HasValue && registeredAtUtc.Value < searchedFromUtc + ? ServerFreshness.Offline + : ClassifyFreshness(lastCollectionUtc, nowUtc); + /// /// CPU band: >= 95% Critical, >= 80% Warning; nothing bandable Unknown. One ladder, applied to /// whichever percentage is a fraction of a capacity that does not move.