feat(notification-outbox): async Notify.Send with status handle

Notify.To(list).Send(subject,body) now generates a NotificationId GUID,
enqueues a Notification-category message into the site Store-and-Forward
Engine, and returns the NotificationId immediately (Task<string>). The
NotificationId is the single idempotency key end-to-end: it is the S&F
message Id, it is carried inside the buffered NotificationSubmit payload,
and it is the id the forwarder submits to central.

NotificationForwarder now deserializes the buffered payload as a
NotificationSubmit and reads NotificationId from it (re-stamping only the
site-owned SourceSiteId / SourceInstanceId), instead of deriving the id
from StoreAndForwardMessage.Id.

Adds NotifyHelper.Status(id): queries central via the site communication
actor; reports the site-local Forwarding state while the notification is
still buffered at the site, maps central's response when found, and
Unknown otherwise. Adds a NotificationDeliveryStatus record.

SiteCommunicationActor gains a NotificationStatusQuery forwarding handler
mirroring NotificationSubmit. StoreAndForwardService.EnqueueAsync gains an
optional messageId parameter and exposes GetMessageByIdAsync.
This commit is contained in:
Joseph Doherty
2026-05-19 02:30:51 -04:00
parent 05614e037a
commit 3326bddeb0
9 changed files with 562 additions and 77 deletions

View File

@@ -8,6 +8,7 @@ using ScadaLink.Commons.Types;
using ScadaLink.HealthMonitoring;
using ScadaLink.SiteEventLogging;
using ScadaLink.SiteRuntime.Scripts;
using ScadaLink.StoreAndForward;
namespace ScadaLink.SiteRuntime.Actors;
@@ -78,6 +79,11 @@ public class ScriptExecutionActor : ReceiveActor
// starve the global pool and stall Akka dispatchers / HTTP handling.
var scheduler = ScriptExecutionScheduler.Shared(options);
// Notification Outbox: the site communication actor that Notify.Status queries
// central through. Resolved by actor path so the Notify helper does not need an
// IActorRef threaded all the way down from the host wiring.
var siteCommunicationActor = Context.System.ActorSelection("/user/site-communication");
// CTS must be created inside the async lambda so it outlives this method
_ = Task.Factory.StartNew(async () =>
{
@@ -91,14 +97,19 @@ public class ScriptExecutionActor : ReceiveActor
// Resolve integration services from DI (scoped lifetime)
IExternalSystemClient? externalSystemClient = null;
IDatabaseGateway? databaseGateway = null;
INotificationDeliveryService? notificationService = null;
// Notification Outbox: the S&F engine is a singleton; the site identity
// provider supplies the site id stamped on enqueued notifications.
StoreAndForwardService? storeAndForward = null;
var siteId = string.Empty;
if (serviceProvider != null)
{
serviceScope = serviceProvider.CreateScope();
externalSystemClient = serviceScope.ServiceProvider.GetService<IExternalSystemClient>();
databaseGateway = serviceScope.ServiceProvider.GetService<IDatabaseGateway>();
notificationService = serviceScope.ServiceProvider.GetService<INotificationDeliveryService>();
storeAndForward = serviceScope.ServiceProvider.GetService<StoreAndForwardService>();
siteId = serviceScope.ServiceProvider.GetService<ISiteIdentityProvider>()?.SiteId
?? string.Empty;
}
var context = new ScriptRuntimeContext(
@@ -112,7 +123,9 @@ public class ScriptExecutionActor : ReceiveActor
logger,
externalSystemClient,
databaseGateway,
notificationService);
storeAndForward,
siteCommunicationActor,
siteId);
var globals = new ScriptGlobals
{