Axapta 3.0 and AX 4.0 store batch and cleanup times as a local date plus seconds. They were turned into UTC with the SQL Server's offset at collection time, so every time from the other side of a DST change was an hour off (a job planned 01:50 CET on the spring night looked 70 minutes late at 03:00 CEST), and a SQL Server running UTC behind a CET AOS shifted every time. AxLocalTime converts with the rules of the AX time zone on that date: SQLInsight:DynamicsAx:LocalTimeZone (Windows or IANA id), else the agent's own zone when its offset matches the SQL Server's, else the server's offset (to the nearest quarter hour) with a Danish data note. The skipped spring hour and the repeated autumn hour get standard time (deterministic, never throws, never a false "late"); seconds outside 0..86400 are unknown and 86400 is midnight. The AX 3.0 cleanup time is local by version, also when LATESTCLEANTIME is missing. AX 2009+ UTC times are untouched. Also: remaining numbers cannot overflow for HIGHEST = Int64.MaxValue, and run keys use the invariant culture. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
73 lines
4.4 KiB
C#
73 lines
4.4 KiB
C#
namespace SQLInsight.Core.DynamicsAx;
|
||
|
||
/// <summary>
|
||
/// Axapta 3.0 and AX 4.0 store batch and cleanup times as a local date plus seconds since midnight, in the time of the
|
||
/// machine that wrote them (the AOS, or the batch client in a 2-tier installation). They become UTC with the rules of
|
||
/// that time zone on that date, so a time from before a change between summer and winter time is not an hour off.
|
||
/// <list type="bullet">
|
||
/// <item>The zone is SQLInsight:DynamicsAx:LocalTimeZone when set (a Windows or IANA id), else the agent's own zone when
|
||
/// its offset right now is the SQL Server's (the usual case: agent, SQL Server and AOS in one country).</item>
|
||
/// <item>Otherwise the SQL Server's current offset is used for every time, with a note that times from the other side of a
|
||
/// change can be an hour off.</item>
|
||
/// <item>A local time that does not exist (the hour skipped in spring) or exists twice (the hour repeated in autumn) gets
|
||
/// the zone's standard offset: deterministic, never an exception, and in autumn the later of the two moments, so a job
|
||
/// is never reported late because of the repeated hour.</item>
|
||
/// </list>
|
||
/// </summary>
|
||
public static class AxLocalTime
|
||
{
|
||
/// <summary>The setting that names the AX time zone.</summary>
|
||
public const string Setting = "SQLInsight:DynamicsAx:LocalTimeZone";
|
||
|
||
/// <summary>
|
||
/// A local AX date and seconds since midnight as UTC. AX's empty date (1900-01-01 or earlier) and seconds outside
|
||
/// 0..86400 are unknown (null); 86400 is midnight at the end of the day.
|
||
/// </summary>
|
||
/// <param name="serverOffsetMinutes">DATEDIFF(minute, GETDATE(), GETUTCDATE()) on the SQL Server when read (UTC − local).</param>
|
||
public static DateTime? ToUtc(DateTime? date, int? seconds, int serverOffsetMinutes, TimeZoneInfo? zone)
|
||
{
|
||
if (date is not { } d || d.Year <= 1900 || seconds is < 0 or > 86_400)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
var local = DateTime.SpecifyKind(d.Date.AddSeconds(seconds ?? 0), DateTimeKind.Unspecified);
|
||
var offset = zone is null ? TimeSpan.FromMinutes(-RoundedOffset(serverOffsetMinutes)) : zone.GetUtcOffset(local);
|
||
return DateTime.SpecifyKind(local - offset, DateTimeKind.Utc);
|
||
}
|
||
|
||
/// <summary>
|
||
/// The SQL Server's offset to the nearest quarter of an hour: GETDATE() and GETUTCDATE() are two calls, and
|
||
/// DATEDIFF(minute) counts minute boundaries, so the raw value can be a minute off.
|
||
/// </summary>
|
||
public static int RoundedOffset(int minutes) => (int)Math.Round(minutes / 15.0, MidpointRounding.AwayFromZero) * 15;
|
||
|
||
/// <summary>
|
||
/// The zone for a database's local AX times, or null to use the SQL Server's current offset, and a note (Danish) when
|
||
/// that is less exact than it could be.
|
||
/// </summary>
|
||
/// <param name="configured">SQLInsight:DynamicsAx:LocalTimeZone.</param>
|
||
/// <param name="serverOffsetMinutes">The SQL Server's offset (UTC − local) when read, or null when nothing was read.</param>
|
||
/// <param name="agentZone">The agent's own zone (TimeZoneInfo.Local; a parameter for tests).</param>
|
||
public static (TimeZoneInfo? Zone, string? Note) Resolve(string? configured, int? serverOffsetMinutes, DateTime nowUtc, TimeZoneInfo? agentZone = null)
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(configured))
|
||
{
|
||
return TimeZoneInfo.TryFindSystemTimeZoneById(configured.Trim(), out var zone)
|
||
? (zone, null)
|
||
: (null, $"Tidszonen \"{configured.Trim()}\" ({Setting}) findes ikke på agentens server. AX' lokale tider omregnes i stedet med SQL Servers "
|
||
+ "nuværende forskel til UTC, så tider fra før et skift mellem sommer- og vintertid kan være en time forkerte.");
|
||
}
|
||
|
||
agentZone ??= TimeZoneInfo.Local;
|
||
var now = DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc);
|
||
if (serverOffsetMinutes is not { } server || -(int)Math.Round(agentZone.GetUtcOffset(now).TotalMinutes) == RoundedOffset(server))
|
||
{
|
||
return (agentZone, null);
|
||
}
|
||
|
||
return (null, $"AX' lokale tider (Axapta 3.0/4.0) omregnes med SQL Servers nuværende forskel til UTC, fordi agentens tidszone ({agentZone.Id}) "
|
||
+ $"har en anden forskel lige nu. Tider fra før et skift mellem sommer- og vintertid kan derfor være en time forkerte. Sæt {Setting} "
|
||
+ "til AOS'ens tidszone for at få dem helt rigtige.");
|
||
}
|
||
}
|