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
33 changes: 32 additions & 1 deletion Darling/Darling.Tests/PgTargetAnomalyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
Expand Down Expand Up @@ -1460,7 +1461,14 @@ public async Task TheAuroraWaitProfile_OneHotCollectionStaysQuiet_ASustainedShif
var bodySucceeded = false;
try
{
var end = TruncateToMinutes(DateTime.UtcNow).AddMinutes(-1);
/* #4274: anchored on the hour, not on the raw minute. The spike sits at the window's LAST
collection (n == minutes, below), so the worst-scoring tile it lands in (#3653 A8 option B)
is the target-local hour ending at `end`. A raw `TruncateToMinutes(UtcNow)` anchor let that
hour's tile hold as few as 1 sample when `UtcNow`'s minute was :00 or :01 — under
AnomalyThresholds.MinTileSamples (3) — so EvaluateTiles skipped it and the worst SCORED tile
came from the flat 1,500 stretch instead, reporting current_ms_per_sec 1,500, not 3,200. See
AnchorEndUtc's doc comment for the fix and the proof matrix in PR #4274's description. */
var end = AnchorEndUtc();
const int minutes = 31 * 24 * 60;
var start = end.AddMinutes(-minutes);
var windowStart = end.AddHours(-4);
Expand Down Expand Up @@ -1589,6 +1597,29 @@ private static async Task DeleteWaitGateRowsAsync(NpgsqlConnection connection, C
private static DateTime TruncateToMinutes(DateTime value) =>
DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerMinute)), DateTimeKind.Unspecified);

private static DateTime TruncateToHour(DateTime value) =>
DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerHour)), DateTimeKind.Unspecified);

/// <summary>#4274's test-only clock seam: <c>DARLING_TEST_NOW_UTC</c> (ISO-8601, e.g.
/// <c>2026-09-25T00:15:00Z</c>) stands in for <c>DateTime.UtcNow</c> when set, so the wall-clock proof
/// matrix in PR #4274 can drive <see cref="AnchorEndUtc"/> at chosen instants without waiting for real
/// clock minutes to land there. Unset in every normal run (CI included) — falls through to the real clock.</summary>
private static DateTime SimulatedUtcNow()
{
var raw = Environment.GetEnvironmentVariable("DARLING_TEST_NOW_UTC");
return string.IsNullOrEmpty(raw)
? DateTime.UtcNow
: DateTime.Parse(raw, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal);
}

/// <summary>#4274: the window's END, pinned to the hour instead of the raw minute. Truncating to the
/// CURRENT hour and stepping back one minute always lands on :59 of the PRIOR hour — deterministic
/// regardless of what minute <see cref="SimulatedUtcNow"/> (or the real clock) happens to read, unlike
/// the old <c>TruncateToMinutes(DateTime.UtcNow).AddMinutes(-1)</c>, whose minute-of-hour varied with
/// wall-clock time and could land the spike's tile (or, in the blocking test, <c>windowStart</c>'s tile)
/// at 1-2 samples — under <c>AnomalyThresholds.MinTileSamples</c> (3) — on roughly a 2-in-60 draw.</summary>
private static DateTime AnchorEndUtc() => TruncateToHour(SimulatedUtcNow()).AddMinutes(-1);

private static async Task PlantSeriesAsync(NpgsqlConnection connection, string sql, DateTime start, int spikeFrom, int deadlocksFrom, int minutes, CancellationToken ct)
{
using var command = new NpgsqlCommand(sql, connection) { CommandTimeout = 120 };
Expand Down
36 changes: 34 additions & 2 deletions Darling/Darling.Tests/PgTargetBlockingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -653,7 +653,12 @@ public async Task ThirtyOneDaysOfLightBlocking_ThenAFourHourChainUnderAnIdleHead
{
await PgTargetFactCollectorTests.RegisterServerAsync(connection, ServerId, ServerName, "postgres", 18, ct);

var windowEnd = TruncateToMinutes(DateTime.UtcNow).AddMinutes(-1);
/* #4274: anchored on the hour, not on the raw minute — see AnchorEndUtc's doc comment. The
chain's edges start at windowStart's minute + 1 (below), so windowStart's own minute is the
window's only zero-blocked reading; a raw TruncateToMinutes(UtcNow) anchor let that reading
land in a tile large enough to drag the worst tile's mean under the pinned 2.8 floor (observed
2.727 in CI) on some wall clocks. See the proof matrix in PR #4274's description. */
var windowEnd = AnchorEndUtc();
var windowStart = windowEnd.AddHours(-4);
var historyStart = windowStart.AddDays(-31);

Expand Down Expand Up @@ -789,7 +794,10 @@ sessions family emits no saturation fact (context only) and the idle fact roots
Assert.Equal(3, anomaly.Metadata["peak_blocked_sessions"]);
/* #3653 A8 option B: avg_blocked_sessions now comes from the WORST-SCORING TILE's own mean (one target-local
hour of the 4h chain), not the whole window's mean across all four hours — so it can differ slightly from
the pre-tile whole-window figure (was pinned 2.9–3.0; the worst tile's hour reads 2.8333...). */
the pre-tile whole-window figure. #4274: with AnchorEndUtc's hour-pinned windowStart, the one tile that
would hold the window's sole zero-blocked minute (windowStart's own) is always sized 1 — below
MinTileSamples (3) — so it never scores, and every tile that does score reads a clean 3.0. The range
stays (not tightened to 3.0 exactly) because it is the pre-existing, deliberately loose assertion. */
Assert.InRange(anomaly.Metadata["avg_blocked_sessions"], 2.8, 3.0);
Assert.Equal(0, anomaly.Metadata["baseline_low_quality"]);
Assert.Equal(0, anomaly.Metadata["threshold_lineage"]);
Expand Down Expand Up @@ -1184,6 +1192,30 @@ private static Fact Chain(
private static DateTime TruncateToMinutes(DateTime value) =>
DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerMinute)), DateTimeKind.Unspecified);

private static DateTime TruncateToHour(DateTime value) =>
DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerHour)), DateTimeKind.Unspecified);

/// <summary>#4274's test-only clock seam: <c>DARLING_TEST_NOW_UTC</c> (ISO-8601, e.g.
/// <c>2026-09-25T00:15:00Z</c>) stands in for <c>DateTime.UtcNow</c> when set, so the wall-clock proof
/// matrix in PR #4274 can drive <see cref="AnchorEndUtc"/> at chosen instants without waiting for real
/// clock minutes to land there. Unset in every normal run (CI included) — falls through to the real clock.</summary>
private static DateTime SimulatedUtcNow()
{
var raw = Environment.GetEnvironmentVariable("DARLING_TEST_NOW_UTC");
return string.IsNullOrEmpty(raw)
? DateTime.UtcNow
: DateTime.Parse(raw, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal);
}

/// <summary>#4274: the window's END, pinned to the hour instead of the raw minute. Truncating to the
/// CURRENT hour and stepping back one minute always lands on :59 of the PRIOR hour — deterministic
/// regardless of what minute <see cref="SimulatedUtcNow"/> (or the real clock) happens to read, unlike
/// the old <c>TruncateToMinutes(DateTime.UtcNow).AddMinutes(-1)</c>, whose minute-of-hour varied with
/// wall-clock time and could land <c>windowStart</c>'s own tile (the one holding the window's sole
/// zero-blocked minute) at a size large enough to drag the worst tile's mean under the pinned floor, on
/// roughly a 1-in-60 draw.</summary>
private static DateTime AnchorEndUtc() => TruncateToHour(SimulatedUtcNow()).AddMinutes(-1);

/* ───────────────────────── planting ───────────────────────── */

private static async Task PlantSettingsSnapshotAsync(NpgsqlConnection connection, DateTime at, CancellationToken ct)
Expand Down
Loading