From 8365ba5357602e594af97580dfd86b532f82cf81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Eggert=20Lundsteen=20Olsen?= Date: Fri, 2 Oct 2026 14:29:34 +0200 Subject: [PATCH] Review 1.4: AX pages and API leave out removed instances; bounded, complete reads - DynamicsAxView (pages and API v1) listed instances removed from monitoring; /batch-runs without ?instance= returned runs of every instance. Both now only cover the instances the overview shows. - AX blocking read the instance's newest 200 blocking events and filtered for AX afterwards, so other blocking could push AX blocking out entirely. GetBlockingInDatabasesAsync filters on the AX databases in SQL (OPENJSON) with its own TOP (1000). - /sequence-samples loaded every sample and cut at 10,000 in memory; the limit is in SQL now, with X-Truncated when there are more. - Two new runs with the same run key in one snapshot would break the primary key of insight.AxBatchRuns; they are deduplicated first. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../InsightRepository.DynamicsAx.cs | 48 ++++++++++++++++--- src/SQLInsight.Web/DynamicsAxView.cs | 17 +++++-- 2 files changed, 54 insertions(+), 11 deletions(-) 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);