feat(notification-outbox): add NotificationOutbox repository

This commit is contained in:
Joseph Doherty
2026-05-19 01:02:06 -04:00
parent 3022aa8379
commit 2c59d59b61
6 changed files with 630 additions and 0 deletions

View File

@@ -0,0 +1,179 @@
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 ct = default)
{
var exists = await _context.Notifications
.AnyAsync(x => x.NotificationId == n.NotificationId, ct);
if (exists)
{
return false;
}
await _context.Notifications.AddAsync(n, ct);
await _context.SaveChangesAsync(ct);
return true;
}
public async Task<IReadOnlyList<Notification>> GetDueAsync(
DateTimeOffset now, int batchSize, CancellationToken ct = 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(ct);
}
public async Task<Notification?> GetByIdAsync(string notificationId, CancellationToken ct = default)
=> await _context.Notifications.FindAsync(new object[] { notificationId }, ct);
public async Task UpdateAsync(Notification n, CancellationToken ct = default)
{
_context.Notifications.Update(n);
await _context.SaveChangesAsync(ct);
}
public async Task<(IReadOnlyList<Notification> Rows, int TotalCount)> QueryAsync(
NotificationOutboxFilter filter, int pageNumber, int pageSize, CancellationToken ct = 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);
}
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(ct);
var rows = await query
.OrderByDescending(n => n.CreatedAt)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync(ct);
return (rows, totalCount);
}
public async Task<int> DeleteTerminalOlderThanAsync(DateTimeOffset cutoff, CancellationToken ct = default)
{
return await _context.Notifications
.Where(n => TerminalStatuses.Contains(n.Status) && n.CreatedAt < cutoff)
.ExecuteDeleteAsync(ct);
}
public async Task<NotificationKpiSnapshot> ComputeKpisAsync(
DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken ct = default)
{
var now = DateTimeOffset.UtcNow;
var queueDepth = await _context.Notifications
.CountAsync(n => n.Status == NotificationStatus.Pending
|| n.Status == NotificationStatus.Retrying, ct);
var stuckCount = await _context.Notifications
.CountAsync(n => (n.Status == NotificationStatus.Pending
|| n.Status == NotificationStatus.Retrying)
&& n.CreatedAt < stuckCutoff, ct);
var parkedCount = await _context.Notifications
.CountAsync(n => n.Status == NotificationStatus.Parked, ct);
var deliveredLastInterval = await _context.Notifications
.CountAsync(n => n.Status == NotificationStatus.Delivered
&& n.DeliveredAt != null
&& n.DeliveredAt >= deliveredSince, ct);
// 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(ct))
{
var oldestCreatedAt = await nonTerminal
.OrderBy(n => n.CreatedAt)
.Select(n => n.CreatedAt)
.FirstAsync(ct);
oldestPendingAge = now - oldestCreatedAt;
}
return new NotificationKpiSnapshot(
QueueDepth: queueDepth,
StuckCount: stuckCount,
ParkedCount: parkedCount,
DeliveredLastInterval: deliveredLastInterval,
OldestPendingAge: oldestPendingAge);
}
public async Task<int> SaveChangesAsync(CancellationToken ct = default)
=> await _context.SaveChangesAsync(ct);
}

View File

@@ -45,6 +45,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<ISiteRepository, SiteRepository>();
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
services.AddScoped<INotificationOutboxRepository, NotificationOutboxRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
services.AddScoped<IAuditService, AuditService>();
services.AddScoped<IInstanceLocator, InstanceLocator>();