diff --git a/src/SQLInsight.Repository/InsightRepository.DynamicsAx.cs b/src/SQLInsight.Repository/InsightRepository.DynamicsAx.cs index eab4957..5ac6f39 100644 --- a/src/SQLInsight.Repository/InsightRepository.DynamicsAx.cs +++ b/src/SQLInsight.Repository/InsightRepository.DynamicsAx.cs @@ -32,7 +32,9 @@ public sealed partial class InsightRepository StartedAt = Iso(r.StartedUtc), EndedAt = Iso(r.EndedUtc), Status = (byte)r.Status, - })).Where(r => r.RunKey.Length <= 100).ToList(); + })).Where(r => r.RunKey.Length <= 100) + .GroupBy(r => (r.Database, r.RunKey)).Select(g => g.First()) // one row per key: two runs with one key would break the primary key + .ToList(); return runs.Count == 0 ? Task.FromResult(0) : connection.ExecuteAsync(""" @@ -112,26 +114,58 @@ public sealed partial class InsightRepository /// The daily samples since , keyed as : the agent's forecasts start from them after a restart. public async Task>> GetAxSequenceHistoryAsync(int instanceId, DateTimeOffset since, CancellationToken cancellationToken = default) { - var samples = await GetAxSequenceSamplesAsync(instanceId, null, null, null, since, cancellationToken).ConfigureAwait(false); + var samples = await GetAxSequenceSamplesAsync(instanceId, null, null, null, since, cancellationToken: cancellationToken).ConfigureAwait(false); return samples.GroupBy(s => AxSequenceSnapshot.HistoryKey(s.DatabaseName, s.Kind, s.SequenceKey), StringComparer.Ordinal) .ToDictionary(g => g.Key, g => (IReadOnlyList)g.OrderBy(s => s.SampledAt).Select(s => new AxSample(s.SampledAt, s.Value)).ToList(), StringComparer.Ordinal); } - /// Daily samples of AX sequences, oldest first; for one database, kind and key when given. + /// + /// Daily samples of AX sequences, oldest first; for one database, kind and key when given. At most + /// rows when given (API v1), in the order database, kind, key, day. + /// public async Task> GetAxSequenceSamplesAsync(int instanceId, string? database, char? kind, string? key, DateTimeOffset since, - CancellationToken cancellationToken = default) + int? top = null, CancellationToken cancellationToken = default) { await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); - var rows = await connection.QueryAsync(""" - SELECT DatabaseName, Kind, SequenceKey, SampledAt, Value + var rows = await connection.QueryAsync(new CommandDefinition(""" + SELECT TOP (@top) DatabaseName, Kind, SequenceKey, SampledAt, Value FROM insight.AxSequenceSamples WHERE InstanceId = @instanceId AND [Day] >= @since AND (@database IS NULL OR DatabaseName = @database) AND (@kind IS NULL OR Kind = @kind) AND (@key IS NULL OR SequenceKey = @key) ORDER BY DatabaseName, Kind, SequenceKey, [Day]; - """, new { instanceId, database, kind = kind?.ToString(), key, since = Utc(since).Date }).ConfigureAwait(false); + """, new { instanceId, database, kind = kind?.ToString(), key, since = Utc(since).Date, top = top ?? int.MaxValue }, + cancellationToken: cancellationToken)).ConfigureAwait(false); return rows.Select(r => new StoredAxSequenceSample(r.DatabaseName, r.Kind[0], r.SequenceKey, AsUtc(r.SampledAt), r.Value)).ToList(); } + /// + /// Blocking events since where the head blocker or a blocked session was in one of + /// (the AX databases), newest first, at most . Filtered in SQL, so + /// blocking elsewhere on the instance cannot push the AX events out of the limit. + /// + public async Task> GetBlockingInDatabasesAsync(int instanceId, IReadOnlyCollection databases, DateTimeOffset from, int top, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(databases); + if (databases.Count == 0) + { + return []; + } + + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = await connection.QueryAsync<(DateTime SampledAt, int HeadSessionId, int BlockedCount, long MaxWaitMs, string SessionsJson)>(new CommandDefinition(""" + SELECT TOP (@top) b.SampledAt, b.HeadSessionId, b.BlockedCount, b.MaxWaitMs, b.SessionsJson + FROM insight.BlockingEvents b + WHERE b.InstanceId = @instanceId AND b.SampledAt >= @from + AND EXISTS (SELECT 1 FROM OPENJSON(b.SessionsJson) WITH (DatabaseName nvarchar(128) '$.DatabaseName') s + WHERE s.DatabaseName IN (SELECT [value] FROM OPENJSON(@databases))) + ORDER BY b.SampledAt DESC; + """, new { instanceId, from = from.UtcDateTime, top, databases = JsonSerializer.Serialize(databases, Json) }, cancellationToken: cancellationToken)) + .ConfigureAwait(false); + return rows.Select(r => new StoredBlocking(r.SampledAt, r.HeadSessionId, r.BlockedCount, r.MaxWaitMs, + JsonSerializer.Deserialize>(r.SessionsJson, Json) ?? [])).ToList(); + } + private static DateTime AsUtc(DateTime value) => DateTime.SpecifyKind(value, DateTimeKind.Utc); private sealed record AxBatchRunRow diff --git a/src/SQLInsight.Web/DynamicsAxView.cs b/src/SQLInsight.Web/DynamicsAxView.cs index 0521c49..e3d98d7 100644 --- a/src/SQLInsight.Web/DynamicsAxView.cs +++ b/src/SQLInsight.Web/DynamicsAxView.cs @@ -21,18 +21,26 @@ public static class DynamicsAxView public const string BatchKind = "dynamics-ax-batch"; public const string SequencesKind = "dynamics-ax-sequences"; - /// The instances with recognised, monitored AX databases; one instance when is given. - public static async Task> LoadAsync(InsightRepository repository, string? instance, CancellationToken cancellationToken = default) + /// At most this many blocking events per instance (the newest). + public const int MaxBlockingEvents = 1000; + + /// The instances (not removed) with recognised, monitored AX databases; one instance when is given. + /// Also instances without AX databases where the detection could not read a database. + public static async Task> LoadAsync(InsightRepository repository, string? instance, bool withProblems = false, + CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(repository); IReadOnlyList instances = instance is { Length: > 0 } ? (await repository.GetInstanceAsync(instance, cancellationToken).ConfigureAwait(false)) is { } one ? [one] : [] : await repository.GetInstancesAsync(cancellationToken).ConfigureAwait(false); + + // Like the overview: an instance removed from monitoring is not shown (its history stays in the repository). + instances = instances.Where(i => !i.Removed).ToList(); var result = new List(); foreach (var i in instances) { var databases = await repository.GetSnapshotAsync(i.InstanceId, DatabasesKind, cancellationToken).ConfigureAwait(false); - if (databases is not { Databases.Count: > 0 }) + if (databases is null || (databases.Databases.Count == 0 && !(withProblems && databases.Problems.Count > 0))) { continue; } @@ -54,7 +62,8 @@ public static class DynamicsAxView var rows = new List(); foreach (var view in instances) { - foreach (var e in await repository.GetBlockingAsync(view.Instance.InstanceId, from, cancellationToken).ConfigureAwait(false)) + var names = view.Databases.Databases.Select(d => d.Database).ToList(); + foreach (var e in await repository.GetBlockingInDatabasesAsync(view.Instance.InstanceId, names, from, MaxBlockingEvents, cancellationToken).ConfigureAwait(false)) { var head = e.Sessions.FirstOrDefault(s => s.SessionId == e.HeadSessionId); var database = view.Databases.Find(head?.DatabaseName) ?? e.Sessions.Select(s => view.Databases.Find(s.DatabaseName)).FirstOrDefault(d => d is not null);