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
7 changes: 4 additions & 3 deletions Darling/Darling.Tests/DarlingMcpDefaultTraceToolsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,10 @@ public void ParamContract_ServerNameHoursBackLimit_AllOptional()
/// <c>last_collection</c>. At UTC-4 that renders a 04:28 event as 00:28, which reads as having PRECEDED
/// the 04:28 collector error it actually coincided with.
///
/// <para>Asserted as the same three expressions <c>ViewerSystemEventsTests</c> pins on
/// <c>ViewerDataService.DefaultTraceEventsByWindowSql</c>, because the two constants read the same column
/// out of the same store and a second convention on one of them is worse than either convention.
/// <para>Asserted as three expressions in the SQL. The viewer's twin
/// <c>ViewerDataService.DefaultTraceEventsByWindowSql</c> no longer carries all three (#4766): it returns
/// the local time raw and converts each row in C# with the server's time zone, keeping the offset only as
/// an hour-wide pre-filter, which <c>ViewerSystemEventsTests</c> pins. This service read is unchanged.
/// <c>ServerLocalReadFrameDisciplineTests</c> holds the corpus-level version of that.</para>
/// </summary>
[Fact]
Expand Down
172 changes: 172 additions & 0 deletions Darling/Darling.Tests/ServerClockTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/*
* 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 PerformanceMonitor.Analysis.Baselines;
using Xunit;

namespace Darling.Tests;

/// <summary>
/// #4766: <see cref="ServerClock"/> converts naive UTC to a server's wall clock and back with the server's
/// time zone, so a time on the far side of a daylight-saving change is not an hour off. The zone ids are the
/// ones <c>LocalClockBucketKeyTests</c> uses for <see cref="BaselineLocalClock"/>: US Eastern springs forward
/// on Sunday 2026-03-08 (07:00Z, -05:00 to -04:00) and falls back on Sunday 2026-11-01 (06:00Z).
/// </summary>
public sealed class ServerClockTests
{
private const string EasternWindowsId = "Eastern Standard Time";
private const string EasternIanaId = "America/New_York";

private static DateTime Naive(int year, int month, int day, int hour, int minute = 0) =>
new(year, month, day, hour, minute, 0, DateTimeKind.Unspecified);

[Theory]
[InlineData(EasternWindowsId)]
[InlineData(EasternIanaId)]
public void ToServerLocal_BeforeAndAfterTheFallBack_ShowsTheWallClockOnEachSide(string zoneId)
{
var clock = ServerClock.Resolve(zoneId, -300);

/* 2026-10-31 16:00Z is still daylight time (-4 h); 2026-11-02 16:00Z is standard time (-5 h). */
Assert.Equal(Naive(2026, 10, 31, 12), clock.ToServerLocal(Naive(2026, 10, 31, 16)));
Assert.Equal(Naive(2026, 11, 2, 11), clock.ToServerLocal(Naive(2026, 11, 2, 16)));
Assert.Equal(-240, clock.OffsetMinutesAt(Naive(2026, 10, 31, 16)));
Assert.Equal(-300, clock.OffsetMinutesAt(Naive(2026, 11, 2, 16)));
}

[Fact]
public void ToServerLocal_BeforeAndAfterTheSpringForward_ShowsTheWallClockOnEachSide()
{
var clock = ServerClock.Resolve(EasternWindowsId, -240);

Assert.Equal(Naive(2026, 3, 7, 11), clock.ToServerLocal(Naive(2026, 3, 7, 16)));
Assert.Equal(Naive(2026, 3, 9, 12), clock.ToServerLocal(Naive(2026, 3, 9, 16)));
}

[Fact]
public void ToServerLocal_KeepsUnspecifiedKind()
{
var clock = ServerClock.Resolve(EasternWindowsId, -300);

Assert.Equal(DateTimeKind.Unspecified, clock.ToServerLocal(Naive(2026, 6, 1, 12)).Kind);
Assert.Equal(DateTimeKind.Unspecified, clock.ToUtc(Naive(2026, 6, 1, 8)).Kind);
}

[Fact]
public void ToUtc_ASkippedLocalTime_MovesForwardByTheGapAndDoesNotThrow()
{
var clock = ServerClock.Resolve(EasternWindowsId, -300);

/* 02:30 on 2026-03-08 never happened: the clock went from 02:00 straight to 03:00. It reads as 03:30
daylight time, which is 07:30Z. TimeZoneInfo.ConvertTimeToUtc throws on this value. */
var utc = clock.ToUtc(Naive(2026, 3, 8, 2, 30));

Assert.Equal(Naive(2026, 3, 8, 7, 30), utc);
Assert.Equal(Naive(2026, 3, 8, 3, 30), clock.ToServerLocal(utc));
}

[Fact]
public void ToUtc_ARepeatedLocalTime_TakesTheFirstOccurrence()
{
var clock = ServerClock.Resolve(EasternWindowsId, -300);

/* 01:30 on 2026-11-01 happens twice: 05:30Z (daylight, -4 h) and 06:30Z (standard, -5 h). The first
occurrence wins. */
Assert.Equal(Naive(2026, 11, 1, 5, 30), clock.ToUtc(Naive(2026, 11, 1, 1, 30)));
}

[Theory]
[InlineData(2026, 3, 7, 12, 0, 17, 0)] /* winter: -5 h */
[InlineData(2026, 3, 9, 12, 0, 16, 0)] /* after spring forward: -4 h */
[InlineData(2026, 10, 31, 12, 0, 16, 0)] /* before fall back: -4 h */
[InlineData(2026, 11, 2, 12, 0, 17, 0)] /* after fall back: -5 h */
public void ToUtc_AnOrdinaryLocalTime_UsesTheOffsetInForceAtThatTime(
int year, int month, int day, int hour, int minute, int expectedUtcHour, int expectedUtcMinute)
{
var clock = ServerClock.Resolve(EasternWindowsId, -300);

Assert.Equal(Naive(year, month, day, expectedUtcHour, expectedUtcMinute), clock.ToUtc(Naive(year, month, day, hour, minute)));
}

[Fact]
public void ToUtc_RoundTripsToServerLocal_AcrossBothChanges()
{
var clock = ServerClock.Resolve(EasternWindowsId, -300);
var utc = Naive(2026, 1, 1, 0);

for (var i = 0; i < 24 * 365; i += 3)
{
var instant = utc.AddHours(i);
var local = clock.ToServerLocal(instant);
var back = clock.ToUtc(local);

/* Only the repeated hour cannot round trip: its second occurrence comes back as the first. */
if (back != instant)
{
Assert.Equal(instant.AddHours(-1), back);
Assert.True(instant >= Naive(2026, 11, 1, 6) && instant < Naive(2026, 11, 1, 7));
}
}
}

[Fact]
public void Resolve_AnIdThatDoesNotResolve_FallsBackToTheOffset()
{
var clock = ServerClock.Resolve("No Such Zone Id", -420);

Assert.Equal(Naive(2026, 7, 1, 5), clock.ToServerLocal(Naive(2026, 7, 1, 12)));
Assert.Equal(Naive(2026, 1, 1, 12), clock.ToUtc(Naive(2026, 1, 1, 5)));
Assert.Equal(-420, clock.OffsetMinutesAt(Naive(2026, 1, 1, 12)));
}

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Resolve_ANullOrBlankId_UsesTheOffset(string? zoneId)
{
var clock = ServerClock.Resolve(zoneId, 330);

Assert.Equal(Naive(2026, 7, 1, 17, 30), clock.ToServerLocal(Naive(2026, 7, 1, 12)));
Assert.Equal(Naive(2026, 7, 1, 12), clock.ToUtc(Naive(2026, 7, 1, 17, 30)));
}

[Fact]
public void Resolve_NeitherAnIdNorAnOffset_IsUtc()
{
var clock = ServerClock.Resolve(null, null);

Assert.Equal(Naive(2026, 7, 1, 12), clock.ToServerLocal(Naive(2026, 7, 1, 12)));
Assert.Equal(Naive(2026, 7, 1, 12), clock.ToUtc(Naive(2026, 7, 1, 12)));
Assert.Equal(0, clock.OffsetMinutesAt(Naive(2026, 7, 1, 12)));

Assert.Equal(Naive(2026, 7, 1, 12), ServerClock.Resolve("No Such Zone Id", null).ToUtc(Naive(2026, 7, 1, 12)));
}

[Fact]
public void Resolve_AResolvableId_WinsOverAStaleOffset()
{
/* The offset says winter; the zone knows it is July. */
var clock = ServerClock.Resolve(EasternWindowsId, -300);

Assert.Equal(Naive(2026, 7, 1, 8), clock.ToServerLocal(Naive(2026, 7, 1, 12)));
}

[Fact]
public void TheEndsOfTheCalendar_ClampInsteadOfThrowing()
{
var eastern = ServerClock.Resolve(EasternWindowsId, -300);
var ahead = ServerClock.FixedOffset(600);

Assert.Equal(DateTime.MinValue, ahead.ToUtc(DateTime.MinValue));
Assert.Equal(DateTime.MaxValue, ServerClock.FixedOffset(-600).ToUtc(DateTime.MaxValue));
Assert.Equal(DateTime.MinValue, eastern.ToServerLocal(DateTime.MinValue));
Assert.Equal(DateTime.MaxValue, ahead.ToServerLocal(DateTime.MaxValue));
}
}
40 changes: 30 additions & 10 deletions Darling/Darling.Tests/ServerLocalReadFrameDisciplineTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,26 +77,35 @@ public sealed class ServerLocalReadFrameDisciplineTests
private static readonly Regex LiteRowDeSkew =
new(@"GetDateTime\(0\)\.AddMinutes\(\s*-\s*offset\s*\)");

/// <summary>The viewer's de-skew is in C# too, but through the server's <c>ServerClock</c> (#4766): the SQL
/// returns the stored server-local time RAW (one bare <c>dte.event_time</c>, the projection) and the
/// loader converts each row with the server's time zone where SQL Server reports one, so an event on the
/// far side of a daylight-saving change lands at its real UTC time (one subtracted offset would be an
/// hour off there). Matched on the conversion because that is the step that puts the RETURNED value in
/// UTC; the window bounds stay in SQL as an hour-wide pre-filter (<see cref="PgDeSkew"/>).</summary>
private static readonly Regex ClockRowDeSkew =
new(@"clock\.ToUtc\(\s*reader\.GetDateTime\(0\)\s*\)");

/// <summary>
/// Every literal-SQL read of the Default Trace, with the de-skew form it must carry and why it differs.
/// A floor AND a ceiling per file: a bare total would let a Darling site vanish and a Lite one appear and
/// still add up, which is precisely the one-sided-port regression #2992 found nothing guarding against.
/// </summary>
private static readonly (string RelativePath, int PgSites, bool LiteRowDeSkew, string Why)[] KnownReaders =
private static readonly (string RelativePath, int PgSites, bool LiteRowDeSkew, bool ClockRowDeSkew, string Why)[] KnownReaders =
[
("Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDefaultTraceReader.cs", 3, false,
("Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDefaultTraceReader.cs", 3, false, false,
"the get_default_trace_events MCP read: one projection + both window bounds"),
("Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.SystemEvents.cs", 3, false,
"the viewer's System Events tab: the same three, byte-identical to the MCP read above"),
("Lite/Services/LocalDataService.SystemEvents.cs", 0, true,
("Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.SystemEvents.cs", 2, false, true,
"the viewer's System Events tab: the raw event_time projection, converted per row in C# with the server's ServerClock, and both window bounds as an hour-wide pre-filter"),
("Lite/Services/LocalDataService.SystemEvents.cs", 0, true, false,
"Lite's DuckDB read: server-local window via GetTimeRangeServerLocal, then the row de-skewed in C#"),
/* #3740: the CONFIG_CHANGED attribution anchors on the sp_configure trace line. Its span is the two
config captures' naive-UTC times, so an un-de-skewed bound would put the line OUTSIDE the span on
every non-UTC server and the anchor would silently never resolve — the selection defect, not the
rendering one. */
("Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs", 3, false,
("Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs", 3, false, false,
"the pass's trace-anchor read (ReconfigureTraceLinesForAttributionSql): one projection + both span bounds, byte-identical in form to the MCP read"),
("Lite/Analysis/AnalysisService.cs", 0, true,
("Lite/Analysis/AnalysisService.cs", 0, true, false,
"the Lite pass's trace-anchor read: both span bounds shifted into the server's frame by the one collected offset, then the row de-skewed in C#"),
];

Expand Down Expand Up @@ -128,26 +137,30 @@ files across Darling/ and Lite/ when this was written. */
[Fact]
public void EveryKnownReader_CarriesItsDeSkew_AndNoBareEventTime()
{
foreach (var (relativePath, pgSites, liteRowDeSkew, why) in KnownReaders)
foreach (var (relativePath, pgSites, liteRowDeSkew, clockRowDeSkew, why) in KnownReaders)
{
var path = RepoPath(relativePath);
Assert.True(File.Exists(path), $"{relativePath} is gone — update this guard deliberately");
var text = File.ReadAllText(path);

Assert.Equal(pgSites, PgDeSkew.Matches(text).Count);
Assert.Equal(liteRowDeSkew, LiteRowDeSkew.IsMatch(text));
Assert.Equal(clockRowDeSkew, ClockRowDeSkew.IsMatch(text));

if (pgSites > 0)
{
/* The anchor first: the bare-read check below is scoped by the dte alias, so if the alias
were renamed the check would pass by matching nothing at all. */
Assert.Contains("default_trace_events AS dte", text, StringComparison.Ordinal);

/* A read that converts each row in C# returns the column raw, so its ONE bare projection is
the expected shape (and any second one is a bound or an ORDER BY skipping the conversion). */
var allowedBare = clockRowDeSkew ? 1 : 0;
var bare = BareAliasedEventTime.Matches(text).Count;

Assert.True(
bare == 0,
$"{relativePath} ({why}) still reads a bare dte.event_time in {bare} place(s). "
bare == allowedBare,
$"{relativePath} ({why}) reads a bare dte.event_time in {bare} place(s), expected {allowedBare}. "
+ "The Default Trace StartTime is the monitored server's LOCAL wall clock, while as_of, "
+ "collection_time, last_collection and every XE event_time are naive UTC — so an "
+ "un-de-skewed value is wrong by the server's offset in the direction that inverts "
Expand Down Expand Up @@ -356,6 +369,13 @@ finding nothing rather than by the sites being there. */
Assert.Matches(LiteRowDeSkew, "reader.GetDateTime(0).AddMinutes(-offset);");
Assert.DoesNotMatch(LiteRowDeSkew, "reader.GetDateTime(0);");

/* The viewer's C# conversion: recognised when the row goes through the clock, and not when it is read
raw. Its raw projection is still a bare dte.event_time to the discriminator above, which is why the
viewer reader is allowed exactly one. */
Assert.Matches(ClockRowDeSkew, "var eventTimeUtc = reader.IsDBNull(0) ? (DateTime?)null : clock.ToUtc(reader.GetDateTime(0));");
Assert.DoesNotMatch(ClockRowDeSkew, "var eventTimeUtc = reader.IsDBNull(0) ? (DateTime?)null : reader.GetDateTime(0);");
Assert.Matches(BareAliasedEventTime, " dte.event_time AS event_time_local,");

/* TimeColumnAssignment: the alias-on-left form, and the projections it must not mistake for one. */
Assert.Equal(
"ft.StartTime,",
Expand Down
Loading
Loading