sqlinsight-pro/src/SQLInsight.Collectors/DynamicsAx/AxSequenceCollector.cs
Søren Eggert Lundsteen Olsen 66ed4e11b3 Review 1.4: AX 3.0/4.0 local times in the AX time zone, with its DST rules
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>
2026-10-02 14:19:17 +02:00

105 lines
6.3 KiB
C#

using SQLInsight.Core.DynamicsAx;
using SQLInsight.Core.Instances;
namespace SQLInsight.Collectors.DynamicsAx;
/// <summary>
/// Number sequences (NUMBERSEQUENCETABLE) and RecId allocation (SYSTEMSEQUENCES) in every monitored AX database where
/// the agent may read them, every hour. SQLInsight samples NEXTREC and NEXTVAL once a day into its own repository
/// (insight.AxSequenceSamples, bounded retention) and forecasts from that history when each runs out. Bounded with TOP
/// (the most used first); never written to.
/// </summary>
public sealed class AxSequenceCollector(AxDatabaseDetector detector, AxThresholds? thresholds = null) : Collector<AxSequenceSnapshot>
{
private readonly AxThresholds _thresholds = thresholds ?? new AxThresholds();
public override string Name => "dynamics-ax-sequences";
/// <summary>How many days of daily samples the forecasts use (and the agent reads back after a restart).</summary>
public int HistoryDays => _thresholds.ForecastBasisDays + 1;
public override TimeSpan DefaultInterval => TimeSpan.FromHours(1);
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
protected override async Task<AxSequenceSnapshot> CollectAsync(CollectorContext context, AxSequenceSnapshot? previous)
{
var ax = await detector.GetAsync(context).ConfigureAwait(false);
var now = context.Now.UtcDateTime;
var history = new Dictionary<string, IReadOnlyList<AxSample>>(StringComparer.Ordinal);
var databases = new List<AxSequenceDatabase>();
foreach (var database in ax.Databases)
{
var name = database.Database;
var earlier = previous?.Databases.FirstOrDefault(d => string.Equals(d.Database, name, StringComparison.OrdinalIgnoreCase));
if (AxSchemaAdapter.For(database.Version) is not { } adapter)
{
const string unknown = "Ikke målbar: AX-versionen kunne ikke bestemmes ud fra skemaet.";
databases.Add(new AxSequenceDatabase { Database = name, Version = database.Version, NumberSequencesNotMeasurable = unknown, RecIdsNotMeasurable = unknown });
continue;
}
IReadOnlyList<AxSample> Samples(char kind, string key, long value)
{
var historyKey = AxSequenceSnapshot.HistoryKey(name, kind, key);
var samples = (previous?.History.TryGetValue(historyKey, out var stored) == true ? stored : [])
.Where(s => s.SampledUtc >= now.AddDays(-HistoryDays) && s.SampledUtc.Date != now.Date)
.Append(new AxSample(now, value))
.ToList();
history[historyKey] = samples;
return samples;
}
var notes = new List<string>();
var numbersQuery = adapter.NumberSequences(database);
notes.AddRange(numbersQuery.Notes);
var (numberRows, numberProblem) = await AxReader.ReadAsync(context, database, numbersQuery, MapNumberSequence).ConfigureAwait(false);
// Axapta 3.0/4.0 store the last cleanup as local time: it becomes UTC in the AX time zone (see AxLocalTime).
var (zone, zoneNote) = adapter.LocalBatchTimes && numberRows is { Count: > 0 }
? AxLocalTime.Resolve(_thresholds.LocalTimeZone, numberRows[0].ServerUtcOffsetMinutes, now)
: (null, null);
if (zoneNote is not null)
{
notes.Add(zoneNote);
}
var sequences = numberRows?.Select(r => adapter.Interpret(r, Samples(AxSequenceSnapshot.NumberSequenceKind, adapter.SequenceKey(r), r.Next), now,
_thresholds.ForecastBasisDays, zone)).ToList();
var recIdQuery = adapter.RecIds(database);
notes.AddRange(recIdQuery.Notes);
var nextValIsInt = string.Equals(database.Table("SYSTEMSEQUENCES")?.Column("NEXTVAL")?.Type, "int", StringComparison.OrdinalIgnoreCase);
var (recIdRows, recIdProblem) = await AxReader.ReadAsync(context, database, recIdQuery, MapRecId).ConfigureAwait(false);
var recIds = recIdRows?.Select(r => AxSchemaAdapter.Interpret(r, nextValIsInt, Samples(AxSequenceSnapshot.RecIdKind, AxSchemaAdapter.RecIdKey(r), r.NextVal), now,
_thresholds.ForecastBasisDays)).ToList();
// A lock timeout keeps the previous values, so findings do not flap.
databases.Add(new AxSequenceDatabase
{
Database = name,
Version = database.Version,
NumberSequences = sequences ?? (AxReader.IsTransient(numberProblem) ? earlier?.NumberSequences ?? [] : []),
NumberSequencesNotMeasurable = sequences is null && !(AxReader.IsTransient(numberProblem) && earlier?.NumberSequencesNotMeasurable is null && earlier is not null) ? numberProblem : null,
RecIds = recIds ?? (AxReader.IsTransient(recIdProblem) ? earlier?.RecIds ?? [] : []),
RecIdsNotMeasurable = recIds is null && !(AxReader.IsTransient(recIdProblem) && earlier?.RecIdsNotMeasurable is null && earlier is not null) ? recIdProblem : null,
Notes = notes,
});
// Sequences not read this time keep their history (a lock timeout must not lose it).
foreach (var (key, samples) in previous?.History ?? new Dictionary<string, IReadOnlyList<AxSample>>())
{
if (key.StartsWith(name + "\u001f", StringComparison.Ordinal) && !history.ContainsKey(key))
{
history[key] = samples.Where(s => s.SampledUtc >= now.AddDays(-HistoryDays)).ToList();
}
}
}
return new AxSequenceSnapshot { CapturedAt = context.Now, Thresholds = _thresholds, Databases = databases, History = history };
}
private static AxNumberSequenceRow MapNumberSequence(Row r) => new(
r.Long(0), r.String(1), r.NullableString(2), r.NullableString(3), r.Long(4), r.Long(5), r.Long(6), r.NullableString(7),
r.Bool(8), r.Bool(9), r.Bool(10), r.Bool(11), r.IsNull(12) ? null : r.Bool(12), r.NullableDateTime(13), r.NullableInt(14),
r.IsNull(15) ? null : r.Double(15), r.Int(16));
private static AxRecIdRow MapRecId(Row r) => new(r.Int(0), r.NullableString(1), r.Long(2), r.NullableLong(3), r.NullableString(4));
}