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
100 changes: 100 additions & 0 deletions Darling/Darling.Tests/DailyHealthBandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -589,3 +589,103 @@ public void TodayCell_MinutesOld_FallsToTheUnrateableArm()
DailyHealthBandCalculator.Classify(Signals(deadlocks: 1, window: window)));
}
}


/// <summary>
/// #3541 A9: the one decision both SKUs' daily-summary readers make about a returned day — is it a
/// measurement, or the shape retention left behind — pinned identically here and in the twin project.
///
/// <para>The defect: the daily aggregate's spine is a UNION over nine sources aging out at different
/// horizons, each COALESCEd to zero, so a day between the shortest horizon (the signals' 30 days) and the
/// longest (the alert log's 90) kept its spine row while every signal the band reads was gone — and zeros
/// band Healthy. The state is decided from two inputs, the day and the horizon; the run count only decides
/// between the two INSIDE-retention states. The horizon test comes first, because <c>runs &gt; 0</c> alone was
/// half the fix and called the whole second month collected.</para>
/// </summary>
public class DailySummaryRetentionTests
{
private static readonly DateTime Horizon = new(2026, 8, 19);

[Theory]
[InlineData("2026-08-18", 1_440, 0, DailySummaryDataState.Purged)] /* the day before: a surviving run record does not rescue it */
[InlineData("2026-08-18", 0, 0, DailySummaryDataState.Purged)] /* nor does the absence of one change the verdict */
[InlineData("2026-07-01", 5, 0, DailySummaryDataState.Purged)]
[InlineData("2026-08-18", 1_440, 7, DailySummaryDataState.PastHorizon)] /* signal rows still there: the purge has not reached it */
[InlineData("2026-08-18", 0, 1, DailySummaryDataState.PastHorizon)] /* even one source present withholds "purged" */
[InlineData("2026-08-19", 1, 0, DailySummaryDataState.Collected)] /* the horizon day itself is held */
[InlineData("2026-09-01", 1_440, 7, DailySummaryDataState.Collected)]
[InlineData("2026-09-01", 1_440, 0, DailySummaryDataState.Collected)] /* inside retention a quiet day needs no signal rows to be collected */
[InlineData("2026-09-01", 0, 3, DailySummaryDataState.NoRunRecord)] /* inside retention, signals but no run recorded — a disclosure, the band stands */
public void TheState_IsDecidedByTheHorizonAndPresenceFirst_ThenByTheRunCount(string day, long runs, int present, DailySummaryDataState expected)
{
var date = DateTime.ParseExact(day, "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture);
Assert.Equal(expected, DailySummaryRetention.StateFor(date, runs, present, Horizon));
/* A time-of-day on either side changes nothing: the decision is on DATES. */
Assert.Equal(expected, DailySummaryRetention.StateFor(date.AddHours(23), runs, present, Horizon.AddHours(5)));
}

/// <summary>The horizon is the cutoff instant's DATE — see <see cref="DailySummaryRetention.HorizonFor"/>
/// for why that is exact on the TimescaleDB path and at most a partial day generous on the DELETE path.</summary>
[Fact]
public void TheHorizon_IsTheCutoffsDate()
{
var now = new DateTime(2026, 9, 18, 14, 30, 0, DateTimeKind.Utc);
Assert.Equal(new DateTime(2026, 8, 19), DailySummaryRetention.HorizonFor(now, 30));
Assert.Equal(new DateTime(2026, 9, 17), DailySummaryRetention.HorizonFor(now, 1));
Assert.Equal(DateTimeKind.Utc, DailySummaryRetention.HorizonFor(now, 30).Kind);
Assert.Throws<ArgumentOutOfRangeException>(() => DailySummaryRetention.HorizonFor(now, 0));
}

[Fact]
public void TheVocabulary_IsOneWordPerState_AndTheNoteSaysWhatTheZerosAre()
{
Assert.Equal("collected", DailySummaryRetention.Label(DailySummaryDataState.Collected));
Assert.Equal("purged", DailySummaryRetention.Label(DailySummaryDataState.Purged));
Assert.Equal("past_horizon", DailySummaryRetention.Label(DailySummaryDataState.PastHorizon));
Assert.Equal("no_run_record", DailySummaryRetention.Label(DailySummaryDataState.NoRunRecord));

Assert.Null(DailySummaryRetention.Note(DailySummaryDataState.Collected, Horizon));
var purged = DailySummaryRetention.Note(DailySummaryDataState.Purged, Horizon)!;
Assert.StartsWith("PURGED", purged, StringComparison.Ordinal);
Assert.Contains("2026-08-19", purged, StringComparison.Ordinal);
Assert.Contains("absences, not measurements", purged, StringComparison.Ordinal);
var past = DailySummaryRetention.Note(DailySummaryDataState.PastHorizon, Horizon, 3)!;
Assert.StartsWith("PAST HORIZON", past, StringComparison.Ordinal);
Assert.Contains("3 of 7 signal sources", past, StringComparison.Ordinal);
Assert.Equal(7, DailySummaryRetention.SignalSourceCount);
var noRun = DailySummaryRetention.Note(DailySummaryDataState.NoRunRecord, Horizon)!;
Assert.StartsWith("NO RUN RECORD", noRun, StringComparison.Ordinal);
Assert.Contains("the band stands", noRun, StringComparison.Ordinal);
}

/// <summary>The band's own contract, end to end: a signals projection with <c>HasData</c> folded from a
/// past-horizon state is No Data even under a Critical count — the calendar's grey, never green or red.
/// The two inside-retention states keep the band, because inside retention a zero is a measurement.</summary>
[Fact]
public void APastHorizonDay_BandsNoData_WhateverItsCounts_AndAnInsideRetentionDayKeepsItsBand()
{
foreach (var state in new[] { DailySummaryDataState.Collected, DailySummaryDataState.NoRunRecord })
{
var signals = new DailyHealthSignals
{
HasData = state is not (DailySummaryDataState.Purged or DailySummaryDataState.PastHorizon),
Deadlocks = 480,
CollectionRuns = 1_440,
Window = TimeSpan.FromDays(1),
};
Assert.Equal(DailyHealthBand.Critical, DailyHealthBandCalculator.Classify(signals));
}

foreach (var state in new[] { DailySummaryDataState.Purged, DailySummaryDataState.PastHorizon })
{
var signals = new DailyHealthSignals
{
HasData = state is not (DailySummaryDataState.Purged or DailySummaryDataState.PastHorizon),
Deadlocks = 480,
CollectionRuns = 1_440,
Window = TimeSpan.FromDays(1),
};
Assert.Equal(DailyHealthBand.NoData, DailyHealthBandCalculator.Classify(signals));
}
}
}
176 changes: 171 additions & 5 deletions Darling/Darling.Tests/DarlingMcpHealthToolsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,25 @@ public void DailySummarySql_DayBucketed_AllSources()
Assert.Contains("FROM v_memory_pressure_events", sql, StringComparison.Ordinal);
Assert.Contains("FROM config_alert_log", sql, StringComparison.Ordinal);
Assert.Contains("day_spine", sql, StringComparison.Ordinal);

/* #3541 A9: the presence count is the LAST projection, after collection_runs, so the thirteen positional
reads before it stay put — and it counts the seven signal joins, never the collection log or the
alert log, whose survival is the reason a day can outlive its signals. The same pin is written for
Lite's copy in DailySummaryCpuBarPinTests' neighbourhood by construction: both SQLs are read by the
SAME ordinal (13) and judged by the SAME DailySummaryRetention.StateFor. */
Assert.True(sql.IndexOf("AS collection_runs", StringComparison.Ordinal) < sql.IndexOf("AS signal_sources_present", StringComparison.Ordinal),
"signal_sources_present must trail collection_runs so the positional reads before it stay put");
Assert.Equal(DailySummaryRetention.SignalSourceCount, System.Text.RegularExpressions.Regex.Matches(sql, @"CASE WHEN (\w+)\.d IS NULL THEN 0 ELSE 1 END").Count);
Assert.DoesNotContain("CASE WHEN cl.d IS NULL", sql, StringComparison.Ordinal);
Assert.DoesNotContain("CASE WHEN al.d IS NULL", sql, StringComparison.Ordinal);
Assert.EndsWith("ORDER BY s.d", sql.TrimEnd(), StringComparison.Ordinal);

/* And Lite's copy carries the same arm, in the same position, read at the same ordinal. */
var lite = RepoFile.ReadRepoFile("Lite", "Services", "LocalDataService.DailySummary.cs");
Assert.True(lite.IndexOf("AS collection_runs", StringComparison.Ordinal) < lite.IndexOf("AS signal_sources_present", StringComparison.Ordinal));
Assert.Equal(DailySummaryRetention.SignalSourceCount, System.Text.RegularExpressions.Regex.Matches(lite, @"CASE WHEN (\w+)\.d IS NULL THEN 0 ELSE 1 END").Count);
Assert.Contains("SignalSourcesPresent = reader.IsDBNull(13)", lite, StringComparison.Ordinal);
Assert.Contains("SignalSourcesPresent = reader.IsDBNull(13)", RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingHealthReader.cs"), StringComparison.Ordinal);
}

[Theory]
Expand Down Expand Up @@ -183,6 +202,146 @@ day under raised tiers is not Critical. */
Assert.Equal("No Data", noData.OverallHealth);
}

/* ---------------- #3541 A9: retention ghosts (no live PG) ---------------- */

/// <summary>
/// The row the reader stamps <c>Purged</c> bands No Data whatever the spine still holds for it. This is
/// the defect in one row: <c>HasData: true</c> (a spine row exists — the run record outlives the signals
/// by 30 days), <c>CollectionRuns</c> non-zero, every signal a COALESCEd zero — and before the state
/// existed that banded Healthy. The same row judged Collected is the Healthy it always was, so the state
/// is the ONLY thing that moved the verdict.
/// </summary>
[Fact]
public void DailySummaryRow_PurgedOrPastHorizon_IsNoData_NeverHealthy()
{
var date = new DateTime(2026, 7, 9, 0, 0, 0, DateTimeKind.Unspecified);
var shell = new Reader.DailySummaryReadRow(date, 0m, "", 0, 0, 0, 0, 0, 0, 0, 0, 0, HasData: true) { CollectionRuns = 1_440 };

Assert.Equal(DailyHealthBand.Healthy, shell.HealthBand);
Assert.Equal(DailyHealthBand.NoData, (shell with { DataState = DailySummaryDataState.Purged }).HealthBand);
Assert.Equal("No Data", (shell with { DataState = DailySummaryDataState.Purged }).OverallHealth);
Assert.Equal(DailyHealthBand.NoData, (shell with { DataState = DailySummaryDataState.PastHorizon }).HealthBand);
/* Inside retention a zero is a measurement: a day with no run record keeps its band (an alert-only
day is Warning, as PerformanceCalendarDataTests has always pinned on Lite), with the caveat on the row. */
Assert.Equal(DailyHealthBand.Healthy, (shell with { DataState = DailySummaryDataState.NoRunRecord, CollectionRuns = 0 }).HealthBand);
Assert.Equal(DailyHealthBand.Warning, (shell with { DataState = DailySummaryDataState.NoRunRecord, CollectionRuns = 0, AlertCount = 1 }).HealthBand);

/* A purged day with a real, surviving alert is STILL No Data: the composite band needs every input,
and Warning-on-alerts-alone would understate a day whose deadlocks are gone. */
var withAlert = shell with { AlertCount = 3, DataState = DailySummaryDataState.Purged };
Assert.Equal(DailyHealthBand.NoData, withAlert.HealthBand);
Assert.False(withAlert.ToSignals().HasData);
}

/// <summary>
/// The horizon is the SHORTEST effective retention among the sources — on a default store the signal
/// collectors' shared 30 (<see cref="DarlingRetention.DataRetentionBaseDays"/>), never the collection
/// log's 60 or the alert log's 90, which are precisely the horizons that let a spine row outlive its
/// signals. A fleet override on one signal collector moves it; raising every collector past the log
/// leaves the log as the floor.
/// </summary>
[Fact]
public void ShortestSignalRetention_IsTheSignalsDefault_AndFollowsFleetOverrides()
{
var none = System.Array.Empty<PerformanceMonitor.Darling.Service.ScheduleOverride>();
Assert.Equal(PerformanceMonitor.Darling.Service.DarlingRetention.DataRetentionBaseDays, Reader.ShortestSignalRetentionDays(none));
Assert.Equal(30, PerformanceMonitor.Darling.Service.DarlingRetention.DataRetentionBaseDays);
Assert.True(Reader.ShortestSignalRetentionDays(none) < PerformanceMonitor.Darling.Service.DarlingRetention.CollectionLogRetentionDays);
Assert.True(Reader.ShortestSignalRetentionDays(none) < PerformanceMonitor.Darling.Service.DarlingRetention.AlertHistoryRetentionDays);

/* Every signal collector the aggregate reads has a schedule entry — the resolver indexes by name. */
foreach (var collector in Reader.DailySummarySignalCollectors)
Assert.True(CollectorScheduleDefaults.All.ContainsKey(collector), $"{collector} has no CollectorScheduleDefaults entry");

var shortened = new[] { new PerformanceMonitor.Darling.Service.ScheduleOverride(null, "deadlocks", null, 10, true) };
Assert.Equal(10, Reader.ShortestSignalRetentionDays(shortened));

/* A PER-SERVER override does not move a shared-table purge, so it does not move the horizon. */
var perServer = new[] { new PerformanceMonitor.Darling.Service.ScheduleOverride(42, "deadlocks", null, 10, true) };
Assert.Equal(30, Reader.ShortestSignalRetentionDays(perServer));

/* cpu_utilization is floored at the baseline window exactly as the purge floors it. */
var cpuShort = new[] { new PerformanceMonitor.Darling.Service.ScheduleOverride(null, "cpu_utilization", null, 5, true) };
Assert.Equal(30, Reader.ShortestSignalRetentionDays(cpuShort));

var lengthened = Reader.DailySummarySignalCollectors
.Select(c => new PerformanceMonitor.Darling.Service.ScheduleOverride(null, c, null, 365, true)).ToArray();
Assert.Equal(PerformanceMonitor.Darling.Service.DarlingRetention.CollectionLogRetentionDays, Reader.ShortestSignalRetentionDays(lengthened));
}

/// <summary>
/// The fleet-override read names the same table and the same fleet predicate the purge's resolver uses,
/// so the horizon this tool publishes is the horizon the purge enforces.
/// </summary>
[Fact]
public void FleetRetentionOverridesSql_ReadsTheFleetRows_OfTheSignalCollectors()
{
var sql = Reader.FleetRetentionOverridesSql;
Assert.Contains("FROM config_collector_schedules", sql, StringComparison.Ordinal);
Assert.Contains("server_id IS NULL", sql, StringComparison.Ordinal);
Assert.Contains("collector_name = ANY($1)", sql, StringComparison.Ordinal);
Assert.Contains("retention_days IS NOT NULL", sql, StringComparison.Ordinal);
}

/// <summary>
/// The descriptions carry the vocabulary an agent will branch on: the horizon field, the count of days
/// before it, the purged state, and the promise that such a day is never Healthy.
/// </summary>
[Fact]
public void DailySummaryDescriptions_NameTheHorizon_ThePurgedState_AndTheExactDateFormat()
{
var range = ToolMethods().Single(m => m.GetCustomAttribute<McpServerToolAttribute>()!.Name == "get_daily_summary_range");
var rangeText = range.GetCustomAttribute<DescriptionAttribute>()!.Description;
Assert.Contains("retention_horizon", rangeText, StringComparison.Ordinal);
Assert.Contains("days_before_horizon", rangeText, StringComparison.Ordinal);
Assert.Contains("data_state=purged", rangeText, StringComparison.Ordinal);
Assert.Contains("NEVER Healthy", rangeText, StringComparison.Ordinal);

var single = ToolMethods().Single(m => m.GetCustomAttribute<McpServerToolAttribute>()!.Name == "get_daily_summary");
Assert.Contains("data_state=purged", single.GetCustomAttribute<DescriptionAttribute>()!.Description, StringComparison.Ordinal);
var date = single.GetParameters().Single(p => p.Name == "summary_date");
Assert.Contains("yyyy-MM-dd ONLY", date.GetCustomAttribute<DescriptionAttribute>()!.Description, StringComparison.Ordinal);
}

/// <summary>
/// <c>summary_date</c> is EXACT ISO-8601 on both SKUs' tools (through the shared parser): the spelling
/// the description promised parses, the ambiguous <c>01/02/2026</c> the general parser used to accept
/// as 2 January is refused, and the refusal names the one accepted form.
/// </summary>
[Theory]
[InlineData("2026-07-09", true)]
[InlineData(" 2026-07-09 ", true)]
[InlineData("01/02/2026", false)]
[InlineData("07/09/2026", false)]
[InlineData("2026-7-9", false)]
[InlineData("2026-07-09T00:00:00Z", false)]
[InlineData("July 9, 2026", false)]
public void SummaryDate_IsParsedExactly_OrRefusedNamingTheFormat(string input, bool accepted)
{
var error = McpHelpers.ParseSummaryDate(input, out var date);
if (accepted)
{
Assert.Null(error);
Assert.Equal(new DateTime(2026, 7, 9), date!.Value);
Assert.Equal(DateTimeKind.Utc, date.Value.Kind);
}
else
{
Assert.Null(date);
Assert.StartsWith($"Invalid summary_date value '{input}'", error, StringComparison.Ordinal);
Assert.Contains("yyyy-MM-dd", error, StringComparison.Ordinal);
}
}

[Fact]
public void SummaryDate_Absent_MeansToday_ResolvedByTheReader()
{
Assert.Null(McpHelpers.ParseSummaryDate(null, out var none));
Assert.Null(none);
Assert.Null(McpHelpers.ParseSummaryDate(" ", out var blank));
Assert.Null(blank);
}

/* ---------------- advertised MCP schema ---------------- */

private static System.Collections.Generic.List<ModelContextProtocol.Protocol.Tool> BuildToolSchemas()
Expand Down Expand Up @@ -281,12 +440,19 @@ await DarlingMcpTestData.ExecAsync(connection, ct,
postgres, ServerName, boundary.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));

DarlingMcpTestData.AssertEnvelope(onItsOwnDay, ServerName, "overall_health");
/* #3525: deadlocks band as a per-hour RATE through the card band's tiers now, so one deadlock
across a 24h day (0.04/hr, far below the measured Warning tier of 5/hr) is a Healthy day —
the old any-deadlock-is-Critical reading is gone by design. The row's VISIBILITY to the
explicit-date read is what this test pins, so assert the evidence and the honest band. */
/* The row's VISIBILITY to the explicit-date read is what this test pins, so assert the evidence
first. The band it carries changed twice on purpose: #3525 made one deadlock across a 24h day
(0.04/hr) Healthy rather than Critical, and #3541 A9 then withheld the verdict altogether for
THIS row — a fixed day two months back is before the store's 30-day retention horizon, and a
deadlock row surviving there means the purge has not reached the day (data_state
past_horizon: the row is real, the zeros beside it may not be, so No Data rather than a green
cell). A day with no run record INSIDE the horizon reads no_run_record and keeps its band. */
Assert.Contains("\"deadlock_count\":1", onItsOwnDay, StringComparison.Ordinal);
Assert.Contains("Healthy", onItsOwnDay, StringComparison.Ordinal);
var judged = JsonDocument.Parse(onItsOwnDay).RootElement;
Assert.Equal("past_horizon", judged.GetProperty("data_state").GetString());
Assert.Equal("No Data", judged.GetProperty("overall_health").GetString());
Assert.Contains("1 of 7 signal sources", judged.GetProperty("data_note").GetString(), StringComparison.Ordinal);
Assert.DoesNotContain("Healthy", onItsOwnDay, StringComparison.Ordinal);
Assert.Contains("2026-07-20", onItsOwnDay, StringComparison.Ordinal);

/* The bug: the same rows are invisible to an implicit "today", which is what the sibling test
Expand Down
Loading
Loading