using System.Data.Common; using SQLInsight.Core.DynamicsAx; using SQLInsight.Core.Instances; namespace SQLInsight.Collectors.DynamicsAx; /// /// Finds the Dynamics AX databases on one instance with the central detection () and keeps /// the answer for , 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. /// public sealed class AxDatabaseDetector { public static readonly TimeSpan Refresh = TimeSpan.FromHours(1); private readonly Dictionary _cache = new(StringComparer.OrdinalIgnoreCase); private string? _login; /// Online user databases the login can enter, with their snapshot settings. 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 """; /// The monitored AX databases, from the cache when younger than (unless ). public async Task 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(); var problems = new Dictionary(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 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); } } } /// Reads an AX query: the rows, or why not (Danish). A lock timeout or a missing permission is a reason, never an exception. 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? Rows, string? Problem)> ReadAsync(CollectorContext context, AxDatabase database, AxQuery? query, Func 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; }