Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs
T
Joseph Doherty 0ffd4bdbea docs(src): fix CommentChecker findings across src/ — 1,032 → 5 (all false positives)
Comment/XML-doc changes only, no code logic:
- strip project-tracking references (WP*, P*-*, row-NN, #NN, Task N) from
  comments, keeping the explanatory rationale
- add missing summaries, <param>, <returns>, <typeparam> and <inheritdoc/>
- replace misused <inheritdoc/> with real docs; where <inheritdoc/> was
  combined with useful remarks, move the rationale into // comments
- drop stale replicationActor <param> on DeploymentManagerActor ctor

Remaining 5 are false positives (CWE-1236 x2, failures-1, year-0001, M365).
Build 0 errors / 0 warnings.
2026-10-07 14:12:25 -04:00

533 lines
27 KiB
C#

using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
/// <summary>
/// EF Core data access for the central notification outbox. See
/// <see cref="INotificationOutboxRepository"/> for the behaviour contract.
/// </summary>
public class NotificationOutboxRepository : INotificationOutboxRepository
{
// SQL Server duplicate-key error numbers, matching the AuditLogRepository
// and SiteCallAuditRepository race-fixes. 2601 is a unique-index violation;
// 2627 is a primary-key/unique-constraint violation. The IF NOT EXISTS …
// INSERT pattern has a check-then-act race window — two sessions can both
// pass the EXISTS check and then both attempt the INSERT — and the loser
// surfaces as one of these. The site→central handoff is documented
// at-least-once with insert-if-not-exists, so the collision IS the expected
// contention mode; idempotency demands we swallow them rather than let the
// site retry the same NotificationId forever.
private const int SqlErrorUniqueIndexViolation = 2601;
private const int SqlErrorPrimaryKeyViolation = 2627;
private readonly ScadaBridgeDbContext _context;
private readonly ILogger<NotificationOutboxRepository> _logger;
// Statuses that represent a finished notification lifecycle. Non-terminal is the complement.
private static readonly NotificationStatus[] TerminalStatuses =
{
NotificationStatus.Delivered,
NotificationStatus.Parked,
NotificationStatus.Discarded,
};
/// <summary>Initializes a new instance of <see cref="NotificationOutboxRepository"/> with the given EF Core context.</summary>
/// <param name="context">The EF Core database context.</param>
/// <param name="logger">Optional logger instance.</param>
public NotificationOutboxRepository(ScadaBridgeDbContext context, ILogger<NotificationOutboxRepository>? logger = null)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_logger = logger ?? NullLogger<NotificationOutboxRepository>.Instance;
}
/// <inheritdoc />
public async Task<bool> InsertIfNotExistsAsync(Notification n, CancellationToken cancellationToken = default)
{
if (n is null)
{
throw new ArgumentNullException(nameof(n));
}
// Enum columns are stored as varchar(32) (HasConversion<string>()); convert
// in C# rather than relying on parameter type inference (SqlClient would
// otherwise bind enums as int by default and break the column conversion).
var type = n.Type.ToString();
var status = n.Status.ToString();
// Provider-aware idempotent insert. The production path is SQL Server; the
// NotificationOutbox integration tests run over an in-memory SQLite database,
// and SQLite does not understand the T-SQL `IF NOT EXISTS … INSERT` form
// (it raises 'near "IF": syntax error'). Detect SQLite by provider name so
// ConfigurationDatabase needs no Sqlite package reference; the SQL Server
// path below stays byte-identical.
if (_context.Database.ProviderName == "Microsoft.EntityFrameworkCore.Sqlite")
{
// SQLite's idempotent insert: INSERT OR IGNORE skips the row when the
// NotificationId primary key already exists (no schema prefix in SQLite).
// Same parameterised columns/values as the SQL Server branch — the
// FormattableString interpolation still parameterises every value
// (no concatenation), so this is injection-safe.
var sqliteRowsAffected = await _context.Database.ExecuteSqlInterpolatedAsync(
$@"INSERT OR IGNORE INTO Notifications
(NotificationId, Type, ListName, Subject, Body, TypeData, Status, RetryCount, LastError,
ResolvedTargets, SourceSiteId, SourceNode, SourceInstanceId, SourceScript,
OriginExecutionId, OriginParentExecutionId,
SiteEnqueuedAt, CreatedAt, LastAttemptAt, NextAttemptAt, DeliveredAt)
VALUES
({n.NotificationId}, {type}, {n.ListName}, {n.Subject}, {n.Body}, {n.TypeData}, {status}, {n.RetryCount}, {n.LastError},
{n.ResolvedTargets}, {n.SourceSiteId}, {n.SourceNode}, {n.SourceInstanceId}, {n.SourceScript},
{n.OriginExecutionId}, {n.OriginParentExecutionId},
{n.SiteEnqueuedAt}, {n.CreatedAt}, {n.LastAttemptAt}, {n.NextAttemptAt}, {n.DeliveredAt});",
cancellationToken);
// rowsAffected == 1 -> we inserted; 0 -> the row was IGNOREd because the
// NotificationId already existed. Preserves the true=inserted / false=already-existed
// contract. No SqlException catch needed — INSERT OR IGNORE never raises a
// unique-constraint violation.
return sqliteRowsAffected == 1;
}
// FormattableString interpolation parameterises every value (no concatenation),
// so this is safe against injection even for the string columns.
try
{
var rowsAffected = await _context.Database.ExecuteSqlInterpolatedAsync(
$@"IF NOT EXISTS (SELECT 1 FROM dbo.Notifications WHERE NotificationId = {n.NotificationId})
INSERT INTO dbo.Notifications
(NotificationId, Type, ListName, Subject, Body, TypeData, Status, RetryCount, LastError,
ResolvedTargets, SourceSiteId, SourceNode, SourceInstanceId, SourceScript,
OriginExecutionId, OriginParentExecutionId,
SiteEnqueuedAt, CreatedAt, LastAttemptAt, NextAttemptAt, DeliveredAt)
VALUES
({n.NotificationId}, {type}, {n.ListName}, {n.Subject}, {n.Body}, {n.TypeData}, {status}, {n.RetryCount}, {n.LastError},
{n.ResolvedTargets}, {n.SourceSiteId}, {n.SourceNode}, {n.SourceInstanceId}, {n.SourceScript},
{n.OriginExecutionId}, {n.OriginParentExecutionId},
{n.SiteEnqueuedAt}, {n.CreatedAt}, {n.LastAttemptAt}, {n.NextAttemptAt}, {n.DeliveredAt});",
cancellationToken);
// rowsAffected == 1 -> we inserted; 0 -> a prior row was already there
// (IF NOT EXISTS short-circuited the INSERT).
return rowsAffected == 1;
}
catch (SqlException ex) when (
ex.Number == SqlErrorUniqueIndexViolation
|| ex.Number == SqlErrorPrimaryKeyViolation)
{
// Two concurrent sessions both passed IF NOT EXISTS and both
// attempted the INSERT — the loser raises 2601/2627 against the
// NotificationId primary key. First-write-wins idempotency is the
// documented contract (the site→central handoff is at-least-once,
// and the actor discards the return value), so the race outcome is
// semantically a no-op. Returning false here matches the
// "row already existed" branch of the success path.
_logger.LogDebug(
ex,
"InsertIfNotExistsAsync swallowed duplicate-key violation (error {SqlErrorNumber}) for NotificationId {NotificationId}; treating as no-op.",
ex.Number,
n.NotificationId);
return false;
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<Notification>> GetDueAsync(
DateTimeOffset now, int batchSize, CancellationToken cancellationToken = default)
{
// AsNoTracking: the dispatcher mutates each row's delivery state in
// memory and persists it through UpdateAsync, which is now a targeted
// server-side ExecuteUpdate and needs no change tracker. Tracking a batch
// of notifications — each carrying an nvarchar(max) Body and TypeData —
// paid for a full snapshot copy per row plus a DetectChanges scan of the
// whole batch on every save.
return await _context.Notifications
.AsNoTracking()
.Where(n => n.Status == NotificationStatus.Pending
|| (n.Status == NotificationStatus.Retrying
&& n.NextAttemptAt != null
&& n.NextAttemptAt <= now))
.OrderBy(n => n.CreatedAt)
.Take(batchSize)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<bool> UpdateAsync(Notification n, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(n);
// Targeted server-side UPDATE of the seven mutable delivery-state columns.
// The predecessor called DbSet.Update(n) + SaveChanges, which
// marks EVERY property modified and rewrites all 21 columns — including
// the immutable nvarchar(max) Body/TypeData payloads — on every single
// delivery attempt. ExecuteUpdate also bypasses the change tracker
// entirely, so it composes with the untracked GetDueAsync read.
//
// Immutable-by-contract columns (NotificationId, Type, ListName, Subject,
// Body, TypeData, Source*, Origin*, SiteEnqueuedAt, CreatedAt) are
// deliberately absent — see the interface contract: nothing in the
// notification lifecycle ever changes them, and omitting them is what
// makes the write narrow.
var status = n.Status;
var retryCount = n.RetryCount;
var lastError = n.LastError;
var resolvedTargets = n.ResolvedTargets;
var lastAttemptAt = n.LastAttemptAt;
var nextAttemptAt = n.NextAttemptAt;
var deliveredAt = n.DeliveredAt;
var notificationId = n.NotificationId;
// The row count is the not-found signal and MUST NOT be discarded.
// DbSet.Update + SaveChanges used to throw DbUpdateConcurrencyException
// when the row had vanished (the daily retention purge deletes terminal
// rows); ExecuteUpdate just reports "0 rows" and returns, which made an
// operator Retry/Discard of a purged notification answer "success".
var rowsAffected = await _context.Notifications
.Where(row => row.NotificationId == notificationId)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(row => row.Status, status)
.SetProperty(row => row.RetryCount, retryCount)
.SetProperty(row => row.LastError, lastError)
.SetProperty(row => row.ResolvedTargets, resolvedTargets)
.SetProperty(row => row.LastAttemptAt, lastAttemptAt)
.SetProperty(row => row.NextAttemptAt, nextAttemptAt)
.SetProperty(row => row.DeliveredAt, deliveredAt),
cancellationToken);
return rowsAffected > 0;
}
/// <inheritdoc />
public async Task<bool> TryUpdateFromParkedAsync(Notification n, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(n);
// Same seven mutable delivery-state columns as UpdateAsync, but the WHERE
// clause below ALSO requires the row's current Status to still be Parked
// -- a single-statement compare-and-swap.
// Two round trips (GetByIdAsync then this write) with a status check in
// between is not a correctness guard by itself: a detached PipeTo task
// can race the dispatcher's own write (or a second concurrent operator
// action) in that window. Mirrors SecuredWriteRepository.TryMarkApprovedAsync's
// conditional ExecuteUpdate/ExecuteSql pattern, expressed via LINQ
// ExecuteUpdateAsync (like UpdateAsync above) so this stays portable
// across the SQL Server production provider and the SQLite provider the
// repository tests run against.
var status = n.Status;
var retryCount = n.RetryCount;
var lastError = n.LastError;
var resolvedTargets = n.ResolvedTargets;
var lastAttemptAt = n.LastAttemptAt;
var nextAttemptAt = n.NextAttemptAt;
var deliveredAt = n.DeliveredAt;
var notificationId = n.NotificationId;
var rowsAffected = await _context.Notifications
.Where(row => row.NotificationId == notificationId && row.Status == NotificationStatus.Parked)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(row => row.Status, status)
.SetProperty(row => row.RetryCount, retryCount)
.SetProperty(row => row.LastError, lastError)
.SetProperty(row => row.ResolvedTargets, resolvedTargets)
.SetProperty(row => row.LastAttemptAt, lastAttemptAt)
.SetProperty(row => row.NextAttemptAt, nextAttemptAt)
.SetProperty(row => row.DeliveredAt, deliveredAt),
cancellationToken);
return rowsAffected > 0;
}
/// <inheritdoc />
public async Task<Notification?> GetByIdAsync(string notificationId, CancellationToken cancellationToken = default)
{
// AsNoTracking + a keyed predicate rather than FindAsync: the write that
// follows this read is ExecuteUpdate, which bypasses the change tracker
// entirely, so tracking the entity bought nothing but a snapshot copy of
// the nvarchar(max) Body/TypeData columns — and left a stale tracked
// instance in the context that a later read of the same id would return
// in preference to the database. FindAsync cannot be made no-tracking.
return await _context.Notifications
.AsNoTracking()
.FirstOrDefaultAsync(row => row.NotificationId == notificationId, cancellationToken);
}
/// <inheritdoc />
public async Task<(IReadOnlyList<Notification> Rows, int TotalCount)> QueryAsync(
NotificationOutboxFilter filter, int pageNumber, int pageSize, CancellationToken cancellationToken = default)
{
// AsNoTracking: this is the Central UI's read-only list page. The
// rows are projected to the wire and never saved, so tracking them cost a
// snapshot copy of every nvarchar(max) Body in the page for nothing.
var query = _context.Notifications.AsNoTracking().AsQueryable();
if (filter.Status is { } status)
{
query = query.Where(n => n.Status == status);
}
if (filter.Type is { } type)
{
query = query.Where(n => n.Type == type);
}
// Exact match on one notification — the ?correlationId= deep link.
if (!string.IsNullOrEmpty(filter.NotificationId))
{
query = query.Where(n => n.NotificationId == filter.NotificationId);
}
if (!string.IsNullOrEmpty(filter.SourceSiteId))
{
query = query.Where(n => n.SourceSiteId == filter.SourceSiteId);
}
// SourceNode is exact-match like SourceSiteId. Rows with NULL
// SourceNode (legacy / unconfigured) are excluded when the filter is set.
if (!string.IsNullOrEmpty(filter.SourceNode))
{
query = query.Where(n => n.SourceNode == filter.SourceNode);
}
if (!string.IsNullOrEmpty(filter.ListName))
{
query = query.Where(n => n.ListName == filter.ListName);
}
if (!string.IsNullOrEmpty(filter.SubjectKeyword))
{
query = query.Where(n => n.Subject.Contains(filter.SubjectKeyword));
}
if (filter.StuckOnly && filter.StuckCutoff is { } stuckCutoff)
{
query = query.Where(n =>
(n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying)
&& n.CreatedAt < stuckCutoff);
}
if (filter.From is { } from)
{
query = query.Where(n => n.CreatedAt >= from);
}
if (filter.To is { } to)
{
query = query.Where(n => n.CreatedAt <= to);
}
var totalCount = await query.CountAsync(cancellationToken);
// NotificationId breaks CreatedAt ties so the OFFSET window is deterministic —
// without it two rows sharing a CreatedAt could appear on both pages or on
// neither. (This page keeps OFFSET paging rather than the sibling repos'
// keyset cursor because its contract surfaces a page number and a total
// count, neither of which a keyset cursor can express.)
var rows = await query
.OrderByDescending(n => n.CreatedAt)
.ThenByDescending(n => n.NotificationId)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
return (rows, totalCount);
}
/// <inheritdoc />
public async Task<int> DeleteTerminalOlderThanAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default)
{
// Time-sliced batches: each DELETE covers at most one hour of terminal rows,
// capping the lock/log footprint per statement — mirrors
// KpiHistoryRepository.PurgeOlderThanAsync (an unbounded
// single-statement ExecuteDeleteAsync would otherwise turn a long-idle catch-up
// of the default 365-day window into one giant transaction against
// dbo.Notifications). Pure EF LINQ (no raw SQL) so this stays portable across
// the SQL Server production provider and the SQLite provider the repository
// tests run against.
var total = 0;
var floor = await _context.Notifications
.Where(n => TerminalStatuses.Contains(n.Status) && n.CreatedAt < cutoff)
.MinAsync(n => (DateTimeOffset?)n.CreatedAt, cancellationToken);
while (floor is not null && floor < cutoff)
{
var ceiling = floor.Value.AddHours(1) < cutoff ? floor.Value.AddHours(1) : cutoff;
total += await _context.Notifications
.Where(n => TerminalStatuses.Contains(n.Status) && n.CreatedAt < ceiling)
.ExecuteDeleteAsync(cancellationToken);
floor = await _context.Notifications
.Where(n => TerminalStatuses.Contains(n.Status) && n.CreatedAt < cutoff)
.MinAsync(n => (DateTimeOffset?)n.CreatedAt, cancellationToken);
}
return total;
}
/// <inheritdoc />
public async Task<NotificationKpiSnapshot> ComputeKpisAsync(
DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
// One conditional-aggregation pass replaces four sequential COUNT round trips:
// each metric is a COUNT(CASE WHEN <predicate> THEN 1 END) over the same scan
// (arch-review 04). GroupBy(_ => 1) yields a single group (no rows → no group).
//
// The aggregation is PREDICATE-RESTRICTED instead of scanning the
// whole table. Every KPI here is about the live queue (Pending/Retrying), the
// parked backlog, or the last delivery interval; the overwhelming bulk of the
// Notifications table is historical Delivered and Discarded rows that
// contribute to NONE of them. The pre-filter below is the union of the
// metric-contributing predicates, which lets the optimizer seek the status
// index for the live/parked legs and the filtered
// (DeliveredAt) WHERE Status='Delivered' index for the interval leg,
// instead of paying a full scan whose cost grows with retained history.
// Mirrors the pre-filter ComputePerSiteKpisAsync/ComputePerNodeKpisAsync
// already use — the global snapshot was the odd one out.
var counts = await _context.Notifications
.Where(n => n.Status == NotificationStatus.Pending
|| n.Status == NotificationStatus.Retrying
|| n.Status == NotificationStatus.Parked
|| (n.Status == NotificationStatus.Delivered
&& n.DeliveredAt != null && n.DeliveredAt >= deliveredSince))
.GroupBy(_ => 1)
.Select(g => new
{
QueueDepth = g.Count(n => n.Status == NotificationStatus.Pending
|| n.Status == NotificationStatus.Retrying),
StuckCount = g.Count(n => (n.Status == NotificationStatus.Pending
|| n.Status == NotificationStatus.Retrying)
&& n.CreatedAt < stuckCutoff),
ParkedCount = g.Count(n => n.Status == NotificationStatus.Parked),
DeliveredLastInterval = g.Count(n => n.Status == NotificationStatus.Delivered
&& n.DeliveredAt != null && n.DeliveredAt >= deliveredSince),
})
.FirstOrDefaultAsync(cancellationToken);
// Oldest non-terminal CreatedAt — TOP(1) via ordered FirstOrDefault, so the
// live queue is never materialized. The nullable cast lets FirstOrDefault
// return null (no non-terminal rows) instead of throwing.
var oldestCreatedAt = await _context.Notifications
.Where(n => n.Status == NotificationStatus.Pending
|| n.Status == NotificationStatus.Retrying)
.OrderBy(n => n.CreatedAt)
.Select(n => (DateTimeOffset?)n.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
return new NotificationKpiSnapshot(
QueueDepth: counts?.QueueDepth ?? 0,
StuckCount: counts?.StuckCount ?? 0,
ParkedCount: counts?.ParkedCount ?? 0,
DeliveredLastInterval: counts?.DeliveredLastInterval ?? 0,
OldestPendingAge: oldestCreatedAt is null ? null : now - oldestCreatedAt.Value);
}
/// <inheritdoc />
public async Task<IReadOnlyList<SiteNotificationKpiSnapshot>> ComputePerSiteKpisAsync(
DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
// One grouped conditional-aggregation query per scope: counts AND the oldest
// non-terminal CreatedAt (server-side MIN over a CASE, so the live queue is
// never materialized) computed in a single scan. Pre-filter to rows that
// contribute to at least one metric so a site with only fully-uncounted rows
// (e.g. Discarded, or out-of-window Delivered) does not surface as an
// all-zero row — preserving the prior union-of-metric-keys semantics.
var rows = await _context.Notifications
.Where(n => n.Status == NotificationStatus.Pending
|| n.Status == NotificationStatus.Retrying
|| n.Status == NotificationStatus.Parked
|| (n.Status == NotificationStatus.Delivered
&& n.DeliveredAt != null && n.DeliveredAt >= deliveredSince))
.GroupBy(n => n.SourceSiteId)
.Select(g => new
{
Site = g.Key,
QueueDepth = g.Count(n => n.Status == NotificationStatus.Pending
|| n.Status == NotificationStatus.Retrying),
StuckCount = g.Count(n => (n.Status == NotificationStatus.Pending
|| n.Status == NotificationStatus.Retrying)
&& n.CreatedAt < stuckCutoff),
ParkedCount = g.Count(n => n.Status == NotificationStatus.Parked),
DeliveredLastInterval = g.Count(n => n.Status == NotificationStatus.Delivered
&& n.DeliveredAt != null && n.DeliveredAt >= deliveredSince),
OldestCreatedAt = g.Min(n => (n.Status == NotificationStatus.Pending
|| n.Status == NotificationStatus.Retrying)
? (DateTimeOffset?)n.CreatedAt
: null),
})
.ToListAsync(cancellationToken);
return rows
.OrderBy(r => r.Site, StringComparer.Ordinal)
.Select(r => new SiteNotificationKpiSnapshot(
SourceSiteId: r.Site,
QueueDepth: r.QueueDepth,
StuckCount: r.StuckCount,
ParkedCount: r.ParkedCount,
DeliveredLastInterval: r.DeliveredLastInterval,
OldestPendingAge: r.OldestCreatedAt is null ? null : now - r.OldestCreatedAt.Value))
.ToList();
}
/// <inheritdoc />
public async Task<IReadOnlyList<NodeNotificationKpiSnapshot>> ComputePerNodeKpisAsync(
DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
// Same single-scan grouped aggregation as ComputePerSiteKpisAsync, keyed by
// SourceNode. Rows with a NULL SourceNode (legacy / unstamped) are excluded —
// per-node KPIs are only meaningful when node identity is known.
var rows = await _context.Notifications
.Where(n => n.SourceNode != null
&& (n.Status == NotificationStatus.Pending
|| n.Status == NotificationStatus.Retrying
|| n.Status == NotificationStatus.Parked
|| (n.Status == NotificationStatus.Delivered
&& n.DeliveredAt != null && n.DeliveredAt >= deliveredSince)))
.GroupBy(n => n.SourceNode!)
.Select(g => new
{
Node = g.Key,
QueueDepth = g.Count(n => n.Status == NotificationStatus.Pending
|| n.Status == NotificationStatus.Retrying),
StuckCount = g.Count(n => (n.Status == NotificationStatus.Pending
|| n.Status == NotificationStatus.Retrying)
&& n.CreatedAt < stuckCutoff),
ParkedCount = g.Count(n => n.Status == NotificationStatus.Parked),
DeliveredLastInterval = g.Count(n => n.Status == NotificationStatus.Delivered
&& n.DeliveredAt != null && n.DeliveredAt >= deliveredSince),
OldestCreatedAt = g.Min(n => (n.Status == NotificationStatus.Pending
|| n.Status == NotificationStatus.Retrying)
? (DateTimeOffset?)n.CreatedAt
: null),
})
.ToListAsync(cancellationToken);
return rows
.OrderBy(r => r.Node, StringComparer.Ordinal)
.Select(r => new NodeNotificationKpiSnapshot(
SourceNode: r.Node,
QueueDepth: r.QueueDepth,
StuckCount: r.StuckCount,
ParkedCount: r.ParkedCount,
DeliveredLastInterval: r.DeliveredLastInterval,
OldestPendingAge: r.OldestCreatedAt is null ? null : now - r.OldestCreatedAt.Value))
.ToList();
}
/// <inheritdoc />
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
=> await _context.SaveChangesAsync(cancellationToken);
}