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) <noreply@anthropic.com>
This commit is contained in:
Søren Eggert Lundsteen Olsen 2026-10-02 14:29:34 +02:00
parent f4d2603181
commit 8365ba5357
2 changed files with 54 additions and 11 deletions

View file

@ -32,7 +32,9 @@ public sealed partial class InsightRepository
StartedAt = Iso(r.StartedUtc), StartedAt = Iso(r.StartedUtc),
EndedAt = Iso(r.EndedUtc), EndedAt = Iso(r.EndedUtc),
Status = (byte)r.Status, 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 return runs.Count == 0
? Task.FromResult(0) ? Task.FromResult(0)
: connection.ExecuteAsync(""" : connection.ExecuteAsync("""
@ -112,26 +114,58 @@ public sealed partial class InsightRepository
/// <summary>The daily samples since <paramref name="since"/>, keyed as <see cref="AxSequenceSnapshot.HistoryKey"/>: the agent's forecasts start from them after a restart.</summary> /// <summary>The daily samples since <paramref name="since"/>, keyed as <see cref="AxSequenceSnapshot.HistoryKey"/>: the agent's forecasts start from them after a restart.</summary>
public async Task<IReadOnlyDictionary<string, IReadOnlyList<AxSample>>> GetAxSequenceHistoryAsync(int instanceId, DateTimeOffset since, CancellationToken cancellationToken = default) public async Task<IReadOnlyDictionary<string, IReadOnlyList<AxSample>>> 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) return samples.GroupBy(s => AxSequenceSnapshot.HistoryKey(s.DatabaseName, s.Kind, s.SequenceKey), StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => (IReadOnlyList<AxSample>)g.OrderBy(s => s.SampledAt).Select(s => new AxSample(s.SampledAt, s.Value)).ToList(), StringComparer.Ordinal); .ToDictionary(g => g.Key, g => (IReadOnlyList<AxSample>)g.OrderBy(s => s.SampledAt).Select(s => new AxSample(s.SampledAt, s.Value)).ToList(), StringComparer.Ordinal);
} }
/// <summary>Daily samples of AX sequences, oldest first; for one database, kind and key when given.</summary> /// <summary>
/// Daily samples of AX sequences, oldest first; for one database, kind and key when given. At most
/// <paramref name="top"/> rows when given (API v1), in the order database, kind, key, day.
/// </summary>
public async Task<IReadOnlyList<StoredAxSequenceSample>> GetAxSequenceSamplesAsync(int instanceId, string? database, char? kind, string? key, DateTimeOffset since, public async Task<IReadOnlyList<StoredAxSequenceSample>> 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); await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<AxSampleRow>(""" var rows = await connection.QueryAsync<AxSampleRow>(new CommandDefinition("""
SELECT DatabaseName, Kind, SequenceKey, SampledAt, Value SELECT TOP (@top) DatabaseName, Kind, SequenceKey, SampledAt, Value
FROM insight.AxSequenceSamples FROM insight.AxSequenceSamples
WHERE InstanceId = @instanceId AND [Day] >= @since 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) 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]; 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(); return rows.Select(r => new StoredAxSequenceSample(r.DatabaseName, r.Kind[0], r.SequenceKey, AsUtc(r.SampledAt), r.Value)).ToList();
} }
/// <summary>
/// Blocking events since <paramref name="from"/> where the head blocker or a blocked session was in one of
/// <paramref name="databases"/> (the AX databases), newest first, at most <paramref name="top"/>. Filtered in SQL, so
/// blocking elsewhere on the instance cannot push the AX events out of the limit.
/// </summary>
public async Task<IReadOnlyList<StoredBlocking>> GetBlockingInDatabasesAsync(int instanceId, IReadOnlyCollection<string> 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<List<Core.Data.SessionInfo>>(r.SessionsJson, Json) ?? [])).ToList();
}
private static DateTime AsUtc(DateTime value) => DateTime.SpecifyKind(value, DateTimeKind.Utc); private static DateTime AsUtc(DateTime value) => DateTime.SpecifyKind(value, DateTimeKind.Utc);
private sealed record AxBatchRunRow private sealed record AxBatchRunRow

View file

@ -21,18 +21,26 @@ public static class DynamicsAxView
public const string BatchKind = "dynamics-ax-batch"; public const string BatchKind = "dynamics-ax-batch";
public const string SequencesKind = "dynamics-ax-sequences"; public const string SequencesKind = "dynamics-ax-sequences";
/// <summary>The instances with recognised, monitored AX databases; one instance when <paramref name="instance"/> is given.</summary> /// <summary>At most this many blocking events per instance (the newest).</summary>
public static async Task<IReadOnlyList<AxInstanceView>> LoadAsync(InsightRepository repository, string? instance, CancellationToken cancellationToken = default) public const int MaxBlockingEvents = 1000;
/// <summary>The instances (not removed) with recognised, monitored AX databases; one instance when <paramref name="instance"/> is given.</summary>
/// <param name="withProblems">Also instances without AX databases where the detection could not read a database.</param>
public static async Task<IReadOnlyList<AxInstanceView>> LoadAsync(InsightRepository repository, string? instance, bool withProblems = false,
CancellationToken cancellationToken = default)
{ {
ArgumentNullException.ThrowIfNull(repository); ArgumentNullException.ThrowIfNull(repository);
IReadOnlyList<InstanceOverview> instances = instance is { Length: > 0 } IReadOnlyList<InstanceOverview> instances = instance is { Length: > 0 }
? (await repository.GetInstanceAsync(instance, cancellationToken).ConfigureAwait(false)) is { } one ? [one] : [] ? (await repository.GetInstanceAsync(instance, cancellationToken).ConfigureAwait(false)) is { } one ? [one] : []
: await repository.GetInstancesAsync(cancellationToken).ConfigureAwait(false); : 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<AxInstanceView>(); var result = new List<AxInstanceView>();
foreach (var i in instances) foreach (var i in instances)
{ {
var databases = await repository.GetSnapshotAsync<AxDatabaseSnapshot>(i.InstanceId, DatabasesKind, cancellationToken).ConfigureAwait(false); var databases = await repository.GetSnapshotAsync<AxDatabaseSnapshot>(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; continue;
} }
@ -54,7 +62,8 @@ public static class DynamicsAxView
var rows = new List<AxBlockingRow>(); var rows = new List<AxBlockingRow>();
foreach (var view in instances) 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 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); var database = view.Databases.Find(head?.DatabaseName) ?? e.Sessions.Select(s => view.Databases.Find(s.DatabaseName)).FirstOrDefault(d => d is not null);