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>
105 lines
6.3 KiB
C#
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));
|
|
}
|