Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
455 changes: 455 additions & 0 deletions Darling/Darling.Tests/DarkPastRetentionReadsOfflineTests.cs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -238,15 +238,20 @@ public sealed record QueryStoreRow(
/// freshness-derived status the tool assigns; the viewer has no live ping either).
/// <para><c>EngineKind</c> and <c>PostgresMajorVersion</c> ride along because the row's version label is
/// engine-aware (#3145): without the discriminator this read fed <c>SqlMajorVersion</c> — <c>0</c> at
/// every PostgreSQL target — through a SQL-Server-only table and published "SQL Server v0".</para></summary>
/// every PostgreSQL target — through a SQL-Server-only table and published "SQL Server v0".</para>
/// <para><c>RegisteredAt</c> is the registry's <c>created_date</c>, 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.</para></summary>
public sealed record ServerListRow(
int ServerId,
string ServerName,
string? DisplayName,
int? SqlMajorVersion,
DateTime? LastCollection,
string? EngineKind = null,
int? PostgresMajorVersion = null);
int? PostgresMajorVersion = null,
DateTime? RegisteredAt = null);

/// <summary>The latest server_properties snapshot (Lite's <c>ServerPropertiesRow</c>).
/// <paramref name="UtcOffsetMinutes"/> is the offset IN FORCE at that collection (V16; null on a pre-V16 row)
Expand Down Expand Up @@ -1312,7 +1317,8 @@ public static async Task<List<QueryStoreRow>> GetQueryStoreTopAsync(
/// Every enabled server plus its newest collection instant — the list_servers read. The
/// correlated <c>MAX(collection_time)</c> per server drives the freshness-derived status the tool
/// assigns (the headless viewer has no live ping either — see <c>ServerSummaryItem.ClassifyFreshness</c>).
/// $-free (no parameters, no bare now()) so a test can pin the dialect ungated.
/// <c>created_date</c> rides on the same registry row for the retention rule <see cref="ServerListRow"/>
/// describes (#3967). $-free (no parameters, no bare now()) so a test can pin the dialect ungated.
/// </summary>
public const string ServerListSql = """
SELECT
Expand All @@ -1322,7 +1328,8 @@ public static async Task<List<QueryStoreRow>> 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
Expand All @@ -1344,7 +1351,8 @@ public static async Task<List<ServerListRow>> 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -531,41 +531,29 @@ internal static DateTime LastCollectionWindowStart(DateTime now) =>

/// <summary>
/// A card's collection freshness from <see cref="FleetLastCollectionSql"/>'s row (#3935): the shared
/// <see cref="ServerHealthClassifier.ClassifyFreshness"/> ladder, except for the one reading a bounded
/// read cannot hand that ladder, because to the ladder a null means "never".
/// registration rule (<see cref="ServerHealthClassifier"/>'s <c>ClassifyFreshness</c> 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
/// <c>created_date</c>).
///
/// <para><b>The rule.</b> 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 <c>created_date</c>.) 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.</para>
///
/// <para><b>Where the two surfaces can still differ, and why that is the right way round.</b> 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 <c>collection_log</c> horizon), and for
/// one that connected over two days ago and has written no <c>collection_log</c> 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.</para>
/// <para><b>The window, not the retention, is what this read could see.</b> So a server that connected more
/// than two days ago and has never written a <c>collection_log</c> row reads Offline here, while the
/// viewer and <c>list_servers</c>, 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.</para>
///
/// <para><b>A null registration keeps the ladder's reading.</b> The service writes <c>created_date</c> 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.</para>
/// 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.</para>
/// </summary>
/// <param name="lastCollection">The newest collection inside the window, or null when the window holds
/// none.</param>
/// <param name="registeredAt">The registry's <c>created_date</c> from the same row, or null.</param>
/// <param name="now">The roll-up's reference instant, the one the window was cut from.</param>
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);

/// <summary>Cross-server per-collector 7-day health aggregate — one row per (server, collector) pair carrying
/// the columns the shared <c>CollectorHealth.HealthStatus</c> banding needs, so the caller counts each
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
};
Expand Down Expand Up @@ -1482,10 +1482,16 @@ the keys Lite's tool emits. */
/// it does NOT share is the vocabulary: <see cref="ServerCollectionStatusRules.McpToken"/> 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.</para>
///
/// <para>#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.</para>
/// </summary>
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();

/// <summary>
Expand Down
12 changes: 12 additions & 0 deletions Darling/PerformanceMonitor.Darling.Storage/DailySummaryHorizon.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,18 @@ public static class DarlingRetentionHorizons
/// </summary>
public const int CollectionLogRetentionDays = DataRetentionBaseDays * 2;

/// <summary>
/// The earliest instant a read of <c>collection_log</c> with no window can still see at
/// <paramref name="nowUtc"/>: <see cref="CollectionLogRetentionDays"/> 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 <c>searchedFromUtc</c> every surface that reads a server's newest collection with no
/// window hands <c>ServerHealthClassifier.ClassifyFreshness</c>, so a server whose whole history retention
/// has dropped reads Offline rather than "Awaiting first collection".
/// </summary>
public static DateTime CollectionLogHorizon(DateTime nowUtc) =>
DateTime.SpecifyKind(nowUtc.AddDays(-CollectionLogRetentionDays), DateTimeKind.Unspecified);

/// <summary>
/// <c>config_alert_log</c> (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
Expand Down
4 changes: 4 additions & 0 deletions Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@
monitored server. -->
<DataGridTextColumn Header="Added" Binding="{Binding CreatedDate, StringFormat=yyyy-MM-dd HH:mm}" Width="140"/>
<!-- "Last Collected" (not "Last Connected"): the Darling SERVICE connects + collects, not the
viewer. Reuses the sidebar dots' MAX(collection_time) freshness read; "Never" until first collect. -->
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). -->
<DataGridTextColumn Header="Last Collected" Binding="{Binding LastCollectedDisplay}" Width="140"/>
</DataGrid.Columns>
</DataGrid>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -74,14 +76,32 @@ last collected each server (the viewer never connects to a monitored server). De
freshness = new Dictionary<int, DateTime>();
}

/* #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<int, DateTime?> 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<int, DateTime?>();
}

var nowUtc = DateTime.UtcNow;
var items = new List<ManagedServerListItem>(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,
});
}

Expand Down Expand Up @@ -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.</summary>
public DateTime? LastCollectedUtc { get; set; }

/// <summary>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.</summary>
public bool HistoryAgedOut { get; set; }

/// <summary>The "Last Collected" cell: the newest collection time rendered in the viewer's timestamp-display
/// mode (Server/Local/UTC via <see cref="ViewerTimeHelper.ForDisplay"/>), 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.</summary>
/// mode (Server/Local/UTC via <see cref="ViewerTimeHelper.ForDisplay"/>), "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.</summary>
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";
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 <c>collect.servers</c>, and the desired-state config plane
/// cannot carry one.</para>
///
/// <para><c>created_date</c> (#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".</para>
/// </summary>
public const string ManagedServersSql = @"
SELECT
Expand All @@ -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)";
Expand Down Expand Up @@ -264,7 +269,8 @@ public async Task<List<DarlingServer>> 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;
Expand Down
Loading
Loading