feat(notification-outbox): add NotificationOutboxActor ingest

This commit is contained in:
Joseph Doherty
2026-05-19 01:36:13 -04:00
parent 435c853dce
commit 4dc9f9e159
3 changed files with 254 additions and 0 deletions

View File

@@ -0,0 +1,110 @@
using Akka.Actor;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Entities.Notifications;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Messages.Notification;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.NotificationOutbox.Messages;
namespace ScadaLink.NotificationOutbox;
/// <summary>
/// Central-side actor that owns the notification outbox. This task implements the ingest
/// path only: it accepts <see cref="NotificationSubmit"/> messages forwarded from sites,
/// persists each as a <see cref="Notification"/> row, and acks the submitting site.
/// Dispatch, query, and purge are added by later tasks.
/// </summary>
public class NotificationOutboxActor : ReceiveActor
{
private readonly IServiceProvider _serviceProvider;
private readonly NotificationOutboxOptions _options;
private readonly ILogger<NotificationOutboxActor> _logger;
public NotificationOutboxActor(
IServiceProvider serviceProvider,
NotificationOutboxOptions options,
ILogger<NotificationOutboxActor> logger)
{
_serviceProvider = serviceProvider;
_options = options;
_logger = logger;
Receive<NotificationSubmit>(HandleSubmit);
Receive<InternalMessages.IngestPersisted>(HandleIngestPersisted);
}
/// <summary>
/// Maps an inbound <see cref="NotificationSubmit"/> onto a <see cref="Notification"/>,
/// persists it idempotently, and pipes the outcome back to <see cref="Self"/> so the
/// ack is sent from the actor thread with the original sender preserved.
/// </summary>
private void HandleSubmit(NotificationSubmit msg)
{
var sender = Sender;
var notification = BuildNotification(msg);
// The success projection fires for both a fresh insert and an existing row;
// only a thrown repository error reaches the failure projection.
PersistAsync(notification).PipeTo(
Self,
success: () => new InternalMessages.IngestPersisted(
msg.NotificationId, sender, Succeeded: true, Error: null),
failure: ex => new InternalMessages.IngestPersisted(
msg.NotificationId, sender, Succeeded: false, Error: ex.GetBaseException().Message));
}
/// <summary>
/// Resolves a scoped <see cref="INotificationOutboxRepository"/> and inserts the
/// notification if a row with the same id does not already exist. The boolean result
/// of <c>InsertIfNotExistsAsync</c> is intentionally ignored: an existing row is an
/// idempotent re-submission and is acked just like a fresh insert so the site can
/// clear its forward buffer. Only a thrown error must surface to the caller.
/// </summary>
private async Task PersistAsync(Notification notification)
{
using var scope = _serviceProvider.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<INotificationOutboxRepository>();
await repository.InsertIfNotExistsAsync(notification);
}
/// <summary>
/// Acks the original submitter once persistence completes. <see cref="NotificationSubmitAck"/>
/// is <c>Accepted</c> for both a fresh insert and an existing row; only a thrown
/// repository error produces <c>Accepted: false</c> so the site retries the forward.
/// </summary>
private void HandleIngestPersisted(InternalMessages.IngestPersisted msg)
{
if (msg.Succeeded)
{
_logger.LogDebug("Notification {NotificationId} ingested into outbox.", msg.NotificationId);
msg.Sender.Tell(new NotificationSubmitAck(msg.NotificationId, Accepted: true, Error: null));
}
else
{
_logger.LogWarning(
"Failed to ingest notification {NotificationId}: {Error}",
msg.NotificationId, msg.Error);
msg.Sender.Tell(new NotificationSubmitAck(msg.NotificationId, Accepted: false, Error: msg.Error));
}
}
private static Notification BuildNotification(NotificationSubmit msg)
{
// All current notifications are email; NotificationType has only the Email member.
return new Notification(
msg.NotificationId,
NotificationType.Email,
msg.ListName,
msg.Subject,
msg.Body,
msg.SourceSiteId)
{
SourceInstanceId = msg.SourceInstanceId,
SourceScript = msg.SourceScript,
SiteEnqueuedAt = msg.SiteEnqueuedAt,
CreatedAt = DateTimeOffset.UtcNow,
// Status stays at its Pending default for the dispatch sweep to claim.
};
}
}