dynamics-ax (hourly) lists the monitored AX databases with version, schema, companies and read access; discovery and the AX community rules (now marked dynamicsAx) use the same detection. dynamics-ax-batch (5 min) reads BATCH (3.0/4.0) or BATCHJOB/BATCHJOBHISTORY (2009+), keeps each job's own run history; dynamics-ax-sequences (hourly) reads NUMBERSEQUENCETABLE and SYSTEMSEQUENCES and forecasts from SQLInsight's own daily samples. All reads run through [db].sys.sp_executesql under read committed with a 2 s lock timeout (snapshot when allowed), bounded with TOP, never NOLOCK, never written. Blocking now captures CONTEXT_INFO for the AX user mapping. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
140 lines
6.4 KiB
C#
140 lines
6.4 KiB
C#
using System.Data.Common;
|
|
using SQLInsight.Core.DynamicsAx;
|
|
using SQLInsight.Core.Instances;
|
|
|
|
namespace SQLInsight.Collectors.DynamicsAx;
|
|
|
|
/// <summary>
|
|
/// Finds the Dynamics AX databases on one instance with the central detection (<see cref="AxDetection"/>) and keeps
|
|
/// the answer for <see cref="Refresh"/>, so new and removed AX databases are noticed within the hour. Shared by
|
|
/// everything AX-specific on the instance (the AX collectors and the AX community rules). Only databases chosen for
|
|
/// monitoring are looked at; reads metadata, plus the company list when the agent may read DATAAREA. Not thread-safe:
|
|
/// one instance's collectors run one at a time.
|
|
/// </summary>
|
|
public sealed class AxDatabaseDetector
|
|
{
|
|
public static readonly TimeSpan Refresh = TimeSpan.FromHours(1);
|
|
|
|
private readonly Dictionary<string, (DateTimeOffset At, AxDatabase? Database, string? Problem)> _cache = new(StringComparer.OrdinalIgnoreCase);
|
|
private string? _login;
|
|
|
|
/// <summary>Online user databases the login can enter, with their snapshot settings.</summary>
|
|
internal const string DatabasesSql = """
|
|
SELECT name, snapshot_isolation_state, is_read_committed_snapshot_on
|
|
FROM sys.databases
|
|
WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL AND HAS_DBACCESS(name) = 1
|
|
ORDER BY name
|
|
""";
|
|
|
|
/// <summary>The monitored AX databases, from the cache when younger than <see cref="Refresh"/> (unless <paramref name="force"/>).</summary>
|
|
public async Task<AxDatabaseSnapshot> GetAsync(CollectorContext context, bool force = false)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(context);
|
|
var connection = context.Connection;
|
|
var ct = context.CancellationToken;
|
|
var databases = await Db.QueryAsync(connection, DatabasesSql, r => (Name: r.String(0), Snapshot: r.Int(1), Rcsi: r.Bool(2)), ct).ConfigureAwait(false);
|
|
_login ??= (await Db.QueryAsync(connection, "SELECT SUSER_SNAME()", r => r.NullableString(0), ct).ConfigureAwait(false)).FirstOrDefault();
|
|
var names = databases.Select(d => d.Name).ToList();
|
|
var found = new List<AxDatabase>();
|
|
var problems = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var (name, snapshot, rcsi) in databases)
|
|
{
|
|
if (!context.IncludesDatabase(name) || name.EndsWith("_model", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (force || !_cache.TryGetValue(name, out var cached) || context.Now - cached.At >= Refresh)
|
|
{
|
|
cached = await DetectAsync(context, name, names).ConfigureAwait(false);
|
|
_cache[name] = cached;
|
|
}
|
|
|
|
if (cached.Problem is { } problem)
|
|
{
|
|
problems[name] = problem;
|
|
}
|
|
|
|
if (cached.Database is { } ax)
|
|
{
|
|
found.Add(ax with
|
|
{
|
|
AgentLogin = _login,
|
|
Isolation = rcsi ? AxReadIsolation.ReadCommittedSnapshot : snapshot == 1 ? AxReadIsolation.Snapshot : AxReadIsolation.ReadCommitted,
|
|
});
|
|
}
|
|
}
|
|
|
|
foreach (var gone in _cache.Keys.Where(k => !names.Contains(k, StringComparer.OrdinalIgnoreCase)).ToList())
|
|
{
|
|
_cache.Remove(gone);
|
|
}
|
|
|
|
return new AxDatabaseSnapshot { CapturedAt = context.Now, Databases = found, AgentLogin = _login, Problems = problems };
|
|
}
|
|
|
|
private async Task<(DateTimeOffset, AxDatabase?, string?)> DetectAsync(CollectorContext context, string database, IReadOnlyList<string> names)
|
|
{
|
|
try
|
|
{
|
|
var rows = await Db.QueryAsync(context.Connection, AxSql.InDatabase(database, AxDetection.MetadataSql),
|
|
r => new AxDetectionRow(r.String(0), r.String(1), r.String(2), r.NullableString(3) ?? "?", r.NullableInt(4) ?? 0, r.NullableInt(5) ?? 0, r.Bool(6), r.NullableString(7)),
|
|
AxSql.CommandTimeoutSeconds, context.CancellationToken).ConfigureAwait(false);
|
|
if (AxDetection.Recognise(database, rows, names) is not { } ax)
|
|
{
|
|
return (context.Now, null, null);
|
|
}
|
|
|
|
ax = ax with { AgentLogin = _login };
|
|
if (AxSchemaAdapter.For(ax.Version) is not null)
|
|
{
|
|
var (companies, reason) = await AxReader.ReadAsync(context, ax, AxSchemaAdapter.Companies(ax),
|
|
r => new AxCompany(r.String(0), r.NullableString(1), r.Bool(2))).ConfigureAwait(false);
|
|
ax = ax with { Companies = companies, CompaniesReason = reason };
|
|
}
|
|
|
|
return (context.Now, ax, null);
|
|
}
|
|
catch (DbException ex)
|
|
{
|
|
// No access to its metadata (e.g. no database user before 2014): it cannot be told apart, and nothing AX-specific runs there.
|
|
return (context.Now, null, "AX-genkendelsen kunne ikke læse databasens metadata: " + ex.Message);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Reads an AX query: the rows, or why not (Danish). A lock timeout or a missing permission is a reason, never an exception.</summary>
|
|
internal static class AxReader
|
|
{
|
|
public const string LockTimeoutText = "Ikke målt denne gang: AX-tabellen var låst i mere end 2 sekunder, og SQLInsight venter ikke på AX' låse. Prøves igen ved næste indsamling.";
|
|
|
|
public static async Task<(List<T>? Rows, string? Problem)> ReadAsync<T>(CollectorContext context, AxDatabase database, AxQuery? query, Func<Row, T> map)
|
|
{
|
|
if (query is null)
|
|
{
|
|
return (null, null);
|
|
}
|
|
|
|
if (query.Sql is not { } sql)
|
|
{
|
|
return (null, query.NotMeasurable);
|
|
}
|
|
|
|
try
|
|
{
|
|
return (await Db.QueryAsync(context.Connection, AxSql.InDatabase(database.Database, sql, database.Isolation), map, AxSql.CommandTimeoutSeconds,
|
|
context.CancellationToken).ConfigureAwait(false), null);
|
|
}
|
|
catch (DbException ex)
|
|
{
|
|
return (null, Db.ErrorNumber(ex) switch
|
|
{
|
|
AxSql.LockTimeoutError => LockTimeoutText,
|
|
229 or 230 or 297 or 916 => "Ikke målbar: adgang nægtet. " + AxReadAccess.NotMeasurable(database, AxReadAccess.Tables(database)),
|
|
_ => "Ikke målbar: læsningen fejlede (" + ex.Message + ").",
|
|
});
|
|
}
|
|
}
|
|
|
|
public static bool IsTransient(string? problem) => problem == LockTimeoutText;
|
|
}
|