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;
}