0ffd4bdbea
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.
533 lines
27 KiB
C#
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);
|
|
}
|