using System.Text.Json; using Akka.Actor; using ScadaLink.Commons.Messages.Notification; namespace ScadaLink.StoreAndForward; /// /// Notification Outbox: the site Store-and-Forward delivery handler for the /// /// category. /// /// In the outbox design the site no longer sends notification email itself. /// "Delivering" a buffered notification means forwarding it to the central cluster /// and treating central's as the outcome: /// /// ack Accepted returns /// true; the S&F engine removes the message from the buffer. /// ack not Accepted, or the Ask times out / fails → /// throws; the S&F engine treats any thrown /// exception as transient and retries the forward at the fixed interval. /// /// /// The forward travels over the ClusterClient command/control transport: the handler /// Asks /// the site communication actor, which wraps the message in a /// ClusterClient.Send("/user/central-communication", …) and routes central's /// reply straight back to this Ask. /// public sealed class NotificationForwarder { private readonly IActorRef _siteCommunicationActor; private readonly string _sourceSiteId; private readonly TimeSpan _forwardTimeout; /// /// The site communication actor. It forwards a to /// central via the registered ClusterClient and replies with the /// . /// /// This site's identifier, stamped on every submit. /// /// How long to wait for central's ack before treating the forward as a transient /// failure. Sourced from host configuration. /// public NotificationForwarder( IActorRef siteCommunicationActor, string sourceSiteId, TimeSpan forwardTimeout) { _siteCommunicationActor = siteCommunicationActor; _sourceSiteId = sourceSiteId; _forwardTimeout = forwardTimeout; } /// /// Store-and-Forward delivery handler entry point — matches the /// Func<StoreAndForwardMessage, Task<bool>> handler contract. /// Returns true when central accepts the notification; throws on a /// non-accepted ack or an Ask timeout/failure so the engine retries. /// public async Task DeliverAsync(StoreAndForwardMessage message) { // An unreadable payload cannot be fixed by retrying — park it (return false), // mirroring how the former SMTP handler treated a corrupt buffered payload. if (!TryBuildSubmit(message, out var submit)) { return false; } // The reply may legitimately be a non-accepted ack, so it is not requested as // a status-failing Ask: ask for the bare NotificationSubmitAck and classify it // here. An Ask timeout surfaces as a TimeoutException, which — like any other // thrown exception — the S&F engine treats as transient. var ack = await _siteCommunicationActor .Ask(submit, _forwardTimeout) .ConfigureAwait(false); if (ack.Accepted) { return true; } // A non-accepted ack is a transient failure: central could not persist the // notification right now. Throw so the engine keeps buffering and retries. throw new NotificationForwardException( $"Central rejected notification {submit.NotificationId}: {ack.Error ?? "no detail"}"); } /// /// Maps a buffered S&F notification message onto the /// forwarded to central, returning false if the payload is unreadable. /// /// The buffered payload IS a serialized written by /// the site Notify.Send enqueue path (Task 19). Its /// is the central idempotency key — /// it was generated by the script, equals the buffered row's /// , and is stable across every retry. The /// forwarder forwards the payload as-is except that it re-stamps the fields it /// authoritatively owns: (this site's /// id) and (the buffered row's /// origin instance), and it falls the list name back to the S&F /// when the payload list name is blank. /// private bool TryBuildSubmit(StoreAndForwardMessage message, out NotificationSubmit submit) { submit = null!; NotificationSubmit? payload; try { payload = JsonSerializer.Deserialize(message.PayloadJson); } catch (JsonException) { return false; } if (payload == null) { return false; } submit = payload with { // The NotificationId is the script-generated idempotency key carried in the // payload. Defend against a payload missing it by falling back to the // buffered row id, which the enqueue path pins to the same value. NotificationId = string.IsNullOrEmpty(payload.NotificationId) ? message.Id : payload.NotificationId, // A null OR empty/blank ListName falls back to the S&F Target — so an empty // list name is never forwarded to central. ListName = string.IsNullOrEmpty(payload.ListName) ? message.Target : payload.ListName, // SourceSiteId/SourceInstanceId are authoritatively owned by the site: the // forwarder knows the real site id, and the buffered row records the origin // instance even after the instance is deleted. SourceSiteId = _sourceSiteId, SourceInstanceId = message.OriginInstanceName, }; return true; } } /// /// Raised by on a transient forward failure — /// a non-accepted central ack. The Store-and-Forward engine treats any thrown /// exception as transient and retries the forward at the fixed interval. /// public sealed class NotificationForwardException : Exception { public NotificationForwardException(string message) : base(message) { } }