249 lines
9.8 KiB
C#
249 lines
9.8 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using ScadaLink.Commons.Entities.Notifications;
|
|
using ScadaLink.Commons.Interfaces.Repositories;
|
|
using ScadaLink.Commons.Types.Enums;
|
|
using ScadaLink.Commons.Types.Notifications;
|
|
|
|
namespace ScadaLink.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
|
|
{
|
|
private readonly ScadaLinkDbContext _context;
|
|
|
|
// Statuses that represent a finished notification lifecycle. Non-terminal is the complement.
|
|
private static readonly NotificationStatus[] TerminalStatuses =
|
|
{
|
|
NotificationStatus.Delivered,
|
|
NotificationStatus.Parked,
|
|
NotificationStatus.Discarded,
|
|
};
|
|
|
|
public NotificationOutboxRepository(ScadaLinkDbContext context)
|
|
{
|
|
_context = context ?? throw new ArgumentNullException(nameof(context));
|
|
}
|
|
|
|
public async Task<bool> InsertIfNotExistsAsync(Notification n, CancellationToken cancellationToken = default)
|
|
{
|
|
var exists = await _context.Notifications
|
|
.AnyAsync(x => x.NotificationId == n.NotificationId, cancellationToken);
|
|
if (exists)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
await _context.Notifications.AddAsync(n, cancellationToken);
|
|
await _context.SaveChangesAsync(cancellationToken);
|
|
return true;
|
|
}
|
|
|
|
public async Task<IReadOnlyList<Notification>> GetDueAsync(
|
|
DateTimeOffset now, int batchSize, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.Notifications
|
|
.Where(n => n.Status == NotificationStatus.Pending
|
|
|| (n.Status == NotificationStatus.Retrying
|
|
&& n.NextAttemptAt != null
|
|
&& n.NextAttemptAt <= now))
|
|
.OrderBy(n => n.CreatedAt)
|
|
.Take(batchSize)
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
public async Task<Notification?> GetByIdAsync(string notificationId, CancellationToken cancellationToken = default)
|
|
=> await _context.Notifications.FindAsync(new object[] { notificationId }, cancellationToken);
|
|
|
|
public async Task UpdateAsync(Notification n, CancellationToken cancellationToken = default)
|
|
{
|
|
_context.Notifications.Update(n);
|
|
await _context.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
public async Task<(IReadOnlyList<Notification> Rows, int TotalCount)> QueryAsync(
|
|
NotificationOutboxFilter filter, int pageNumber, int pageSize, CancellationToken cancellationToken = default)
|
|
{
|
|
var query = _context.Notifications.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);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(filter.SourceSiteId))
|
|
{
|
|
query = query.Where(n => n.SourceSiteId == filter.SourceSiteId);
|
|
}
|
|
|
|
// Task 16: 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);
|
|
|
|
var rows = await query
|
|
.OrderByDescending(n => n.CreatedAt)
|
|
.Skip((pageNumber - 1) * pageSize)
|
|
.Take(pageSize)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
return (rows, totalCount);
|
|
}
|
|
|
|
public async Task<int> DeleteTerminalOlderThanAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.Notifications
|
|
.Where(n => TerminalStatuses.Contains(n.Status) && n.CreatedAt < cutoff)
|
|
.ExecuteDeleteAsync(cancellationToken);
|
|
}
|
|
|
|
public async Task<NotificationKpiSnapshot> ComputeKpisAsync(
|
|
DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default)
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
|
|
var queueDepth = await _context.Notifications
|
|
.CountAsync(n => n.Status == NotificationStatus.Pending
|
|
|| n.Status == NotificationStatus.Retrying, cancellationToken);
|
|
|
|
var stuckCount = await _context.Notifications
|
|
.CountAsync(n => (n.Status == NotificationStatus.Pending
|
|
|| n.Status == NotificationStatus.Retrying)
|
|
&& n.CreatedAt < stuckCutoff, cancellationToken);
|
|
|
|
var parkedCount = await _context.Notifications
|
|
.CountAsync(n => n.Status == NotificationStatus.Parked, cancellationToken);
|
|
|
|
var deliveredLastInterval = await _context.Notifications
|
|
.CountAsync(n => n.Status == NotificationStatus.Delivered
|
|
&& n.DeliveredAt != null
|
|
&& n.DeliveredAt >= deliveredSince, cancellationToken);
|
|
|
|
// Oldest non-terminal CreatedAt. The DateTimeOffset value converter makes a SQL
|
|
// Min aggregate awkward, so order ascending and take the first instead.
|
|
var nonTerminal = _context.Notifications
|
|
.Where(n => n.Status == NotificationStatus.Pending
|
|
|| n.Status == NotificationStatus.Retrying);
|
|
|
|
TimeSpan? oldestPendingAge = null;
|
|
if (await nonTerminal.AnyAsync(cancellationToken))
|
|
{
|
|
var oldestCreatedAt = await nonTerminal
|
|
.OrderBy(n => n.CreatedAt)
|
|
.Select(n => n.CreatedAt)
|
|
.FirstAsync(cancellationToken);
|
|
oldestPendingAge = now - oldestCreatedAt;
|
|
}
|
|
|
|
return new NotificationKpiSnapshot(
|
|
QueueDepth: queueDepth,
|
|
StuckCount: stuckCount,
|
|
ParkedCount: parkedCount,
|
|
DeliveredLastInterval: deliveredLastInterval,
|
|
OldestPendingAge: oldestPendingAge);
|
|
}
|
|
|
|
public async Task<IReadOnlyList<SiteNotificationKpiSnapshot>> ComputePerSiteKpisAsync(
|
|
DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default)
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
|
|
var queueDepth = await CountBySiteAsync(
|
|
n => n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying,
|
|
cancellationToken);
|
|
|
|
var stuck = await CountBySiteAsync(
|
|
n => (n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying)
|
|
&& n.CreatedAt < stuckCutoff,
|
|
cancellationToken);
|
|
|
|
var parked = await CountBySiteAsync(
|
|
n => n.Status == NotificationStatus.Parked, cancellationToken);
|
|
|
|
var delivered = await CountBySiteAsync(
|
|
n => n.Status == NotificationStatus.Delivered
|
|
&& n.DeliveredAt != null && n.DeliveredAt >= deliveredSince,
|
|
cancellationToken);
|
|
|
|
// Oldest non-terminal CreatedAt per site. A SQL Min over the DateTimeOffset
|
|
// converter is awkward (see ComputeKpisAsync), so project the non-terminal
|
|
// (site, created) pairs — the live queue, which stays bounded — and reduce
|
|
// in memory.
|
|
var oldest = (await _context.Notifications
|
|
.Where(n => n.Status == NotificationStatus.Pending
|
|
|| n.Status == NotificationStatus.Retrying)
|
|
.Select(n => new { n.SourceSiteId, n.CreatedAt })
|
|
.ToListAsync(cancellationToken))
|
|
.GroupBy(x => x.SourceSiteId)
|
|
.ToDictionary(g => g.Key, g => g.Min(x => x.CreatedAt));
|
|
|
|
var siteIds = queueDepth.Keys
|
|
.Concat(stuck.Keys).Concat(parked.Keys).Concat(delivered.Keys)
|
|
.Distinct()
|
|
.OrderBy(s => s, StringComparer.Ordinal);
|
|
|
|
return siteIds.Select(site => new SiteNotificationKpiSnapshot(
|
|
SourceSiteId: site,
|
|
QueueDepth: queueDepth.GetValueOrDefault(site),
|
|
StuckCount: stuck.GetValueOrDefault(site),
|
|
ParkedCount: parked.GetValueOrDefault(site),
|
|
DeliveredLastInterval: delivered.GetValueOrDefault(site),
|
|
OldestPendingAge: oldest.TryGetValue(site, out var createdAt)
|
|
? now - createdAt
|
|
: null)).ToList();
|
|
}
|
|
|
|
/// <summary>Counts notification rows matching <paramref name="predicate"/>, grouped by source site.</summary>
|
|
private async Task<Dictionary<string, int>> CountBySiteAsync(
|
|
System.Linq.Expressions.Expression<Func<Notification, bool>> predicate,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return await _context.Notifications
|
|
.Where(predicate)
|
|
.GroupBy(n => n.SourceSiteId)
|
|
.Select(g => new { Site = g.Key, Count = g.Count() })
|
|
.ToDictionaryAsync(x => x.Site, x => x.Count, cancellationToken);
|
|
}
|
|
|
|
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
|
=> await _context.SaveChangesAsync(cancellationToken);
|
|
}
|