5d075f1374
Six adversarial-review findings in the central SQL/ingest layer. F1 (AuditLogRepository.InsertChunkAsync) — the set-based ingest declared each string parameter at its COLUMN width (Actor/Target 256, Action 64, Outcome 16, Category 32, SourceNode 64), so SqlClient truncated an over-long value at bind time and committed the mutilated row — silent, in an append-only store, with no PayloadTruncated flag — while the per-row and reconciliation paths sent the same value in full and let the server reject it with 2628. Bind at the value's own length instead; explicit SqlDbType is kept (it fixes the VALUES constructor's derived column types and datetime2 precision). Design: reject everywhere, truncate nowhere — matching today's per-row behaviour. F2 (SiteCallAuditRepository.UpsertAsync) — the single-statement upsert ran the monotonic UPDATE first and INSERTed only if nothing matched. Two writers racing the first packet of one TrackedOperationId (the cached dual-write and the reconciliation pull carry DIFFERENT lifecycle states) both matched nothing, and the loser then skipped its INSERT or swallowed a 2627 — dropping its Status/RetryCount/HttpStatus/TerminalAtUtc. Legs swapped to `IF NOT EXISTS … INSERT; UPDATE <monotonic>` — still one round trip, and the loser's UPDATE now lands on the winner's row. The duplicate-key catch re-runs the monotonic UPDATE for the same reason. Moved to raw SQL with explicitly-typed parameters so the intricate rank predicate exists in exactly one place (an untyped DateTime would bind as `datetime` and round the freshness tiebreaker). F3 (docs/plans/sql/*.sql) — filtered-index DDL failed with error 1934 under the documented `docker exec … sqlcmd` path, which defaults QUOTED_IDENTIFIER OFF; once IX_Notifications_Delivered exists, QI-OFF DML on Notifications fails too. All four scripts now open with `SET QUOTED_IDENTIFIER ON; SET ANSI_NULLS ON; GO` (own batch, so it is in force when the next batch parses), and the migration convention in Component-ConfigurationDatabase.md documents `sqlcmd -I`. Verified live: the pre-fix script fails 1934 without -I, the fixed one applies. F4 (SiteCallAuditActor) — the off-mailbox reconciliation/purge passes reuse the injected repository, so tests drove one DbContext from the pass and a mailbox handler concurrently. Serialized at the CALL via a private SerializedRepository wrapper applied only by the test constructors, rather than running the pass on-mailbox: production keeps its PipeTo shape untouched, and the existing "a blocked drain does not stall ingest/query/KPI" regression tests stay meaningful (they would have been invalidated by suspending the mailbox). F5 (AuditLogIngestActor) — when the batch failed because the 20 s IngestBudget expired, the per-row fallback reused the same expired token: N instant failures, N counter bumps, zero accepted. The fallback now gets a fresh 5 s budget (inside the 30 s outer Ask), and a blown budget bumps the failure counter ONCE for the batch instead of once per row. F6 (NotificationOutboxRepository.UpdateAsync) — ExecuteUpdate's row count was discarded, so an operator Retry/Discard of a notification the retention purge had already deleted reported success (the pre-ExecuteUpdate code threw DbUpdateConcurrencyException). UpdateAsync now returns whether a row matched; the operator one-shots answer "notification not found" and emit no audit row for the action that did not happen, while the dispatcher logs a warning (its delivery already happened; nothing to retry). GetByIdAsync switched to AsNoTracking since the write is out-of-band. Tests: 5 new SQL-backed regressions (over-long Target rejected on both paths + boundary round-trip; concurrent first-write and already-created-by-another-writer upserts; vanished-row UpdateAsync), a token-identity pin on the ingest fallback, a repository-concurrency detector for the SiteCallAudit passes, and vanished-row operator-path tests. The F1/F2/F4 regressions were each confirmed failing against the pre-fix code. Suites: ConfigurationDatabase 369, AuditLog 378, SiteCallAudit 66, NotificationOutbox 152 — all green, solution builds with 0 warnings.
1400 lines
64 KiB
C#
1400 lines
64 KiB
C#
using Akka.Actor;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using ZB.MOM.WW.Audit;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
|
|
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
|
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Messages;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox;
|
|
|
|
/// <summary>
|
|
/// Central-side actor that owns the notification outbox. It accepts
|
|
/// <see cref="NotificationSubmit"/> messages forwarded from sites and persists each as a
|
|
/// <see cref="Notification"/> row (the ingest path), and runs a periodic dispatch loop
|
|
/// that claims due notifications, delivers them through the matching channel adapter, and
|
|
/// applies the resulting status transition. It also runs a periodic purge that bulk-deletes
|
|
/// terminal notification rows once they age past the configured retention window.
|
|
/// </summary>
|
|
public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
|
{
|
|
private const string DispatchTimerKey = "dispatch";
|
|
private const string PurgeTimerKey = "purge";
|
|
|
|
/// <summary>Retry policy fallback used when no SMTP configuration row is present.</summary>
|
|
private const int FallbackMaxRetries = 10;
|
|
private static readonly TimeSpan FallbackRetryDelay = TimeSpan.FromMinutes(1);
|
|
|
|
/// <summary>
|
|
/// Audit <c>Actor</c> stamped on central-dispatch (<c>NotifyDeliver</c>) rows.
|
|
/// The Actor-column spec assigns central-originated audit rows a system
|
|
/// identity — there is no per-call authenticated user at dispatch time.
|
|
/// </summary>
|
|
private const string SystemActor = "system";
|
|
|
|
private readonly IServiceProvider _serviceProvider;
|
|
private readonly NotificationOutboxOptions _options;
|
|
private readonly ICentralAuditWriter _auditWriter;
|
|
private readonly ILogger<NotificationOutboxActor> _logger;
|
|
|
|
/// <summary>
|
|
/// In-flight guard for the dispatch loop. Set true at the start of a sweep and cleared
|
|
/// when the sweep's <see cref="InternalMessages.DispatchComplete"/> arrives. While true,
|
|
/// further <see cref="InternalMessages.DispatchTick"/>s are dropped so sweeps never overlap.
|
|
/// </summary>
|
|
private bool _dispatching;
|
|
|
|
/// <summary>
|
|
/// Cached <see cref="NotificationType"/> → adapter lookup, built
|
|
/// lazily on the first dispatch sweep and reused for the lifetime of the actor. The
|
|
/// adapter registration is decided at startup by <c>AddNotificationOutbox</c> (the set is
|
|
/// keyed by <see cref="NotificationType"/> and is static per process lifetime), so
|
|
/// rebuilding this dictionary on every sweep was pure allocation waste.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The cache is paired with <see cref="_adaptersScope"/>, an actor-lifetime
|
|
/// <see cref="IServiceScope"/> created on first use so the cached scoped adapter
|
|
/// instances and their dependencies live as long as the cache itself. The scope is
|
|
/// disposed in <see cref="PostStop"/>. The adapters are stateless wrappers that
|
|
/// resolve their per-call collaborators (e.g. <see cref="INotificationRepository"/>'s
|
|
/// underlying DbContext) through their own injected dependencies; holding them for
|
|
/// the actor's lifetime is consistent with the actor's own singleton lifetime on the
|
|
/// active central node.
|
|
/// </remarks>
|
|
private IReadOnlyDictionary<NotificationType, INotificationDeliveryAdapter>? _adaptersCache;
|
|
|
|
/// <summary>
|
|
/// Actor-lifetime DI scope that owns the cached
|
|
/// <see cref="_adaptersCache"/> adapter instances. Created lazily on the first
|
|
/// dispatch sweep that needs adapters; disposed in <see cref="PostStop"/> so the
|
|
/// scoped adapter graph (and any disposable dependencies it transitively holds) is
|
|
/// torn down with the actor.
|
|
/// </summary>
|
|
private IServiceScope? _adaptersScope;
|
|
|
|
/// <summary>
|
|
/// Lifecycle-scoped cancellation source, cancelled in <see cref="PostStop"/> so
|
|
/// any in-flight dispatch sweep — including a long-running SMTP send via the channel
|
|
/// adapter — observes shutdown promptly instead of blocking <c>CoordinatedShutdown</c>
|
|
/// for the full SMTP connect/auth/send timeout per in-progress notification.
|
|
/// </summary>
|
|
private CancellationTokenSource? _shutdownCts;
|
|
|
|
/// <summary>Akka timer scheduler, assigned by the actor system via <see cref="IWithTimers"/>.</summary>
|
|
public ITimerScheduler Timers { get; set; } = null!;
|
|
|
|
/// <summary>
|
|
/// Initializes the actor with its dependencies and registers all message handlers.
|
|
/// </summary>
|
|
/// <param name="serviceProvider">DI service provider used to open scopes for database operations.</param>
|
|
/// <param name="options">Notification outbox configuration options.</param>
|
|
/// <param name="auditWriter">Central audit writer for recording dispatch events.</param>
|
|
/// <param name="logger">Logger for this actor.</param>
|
|
public NotificationOutboxActor(
|
|
IServiceProvider serviceProvider,
|
|
NotificationOutboxOptions options,
|
|
ICentralAuditWriter auditWriter,
|
|
ILogger<NotificationOutboxActor> logger)
|
|
{
|
|
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
|
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
|
|
Receive<NotificationSubmit>(HandleSubmit);
|
|
Receive<InternalMessages.IngestPersisted>(HandleIngestPersisted);
|
|
Receive<InternalMessages.DispatchTick>(_ => HandleDispatchTick());
|
|
Receive<InternalMessages.DispatchComplete>(_ => _dispatching = false);
|
|
Receive<InternalMessages.PurgeTick>(_ => HandlePurgeTick());
|
|
// No-op: purge has no in-flight guard to lower, and the outcome is already logged
|
|
// by the PipeTo projections, so PurgeComplete carries nothing to act on.
|
|
Receive<InternalMessages.PurgeComplete>(_ => { });
|
|
Receive<NotificationOutboxQueryRequest>(HandleQuery);
|
|
Receive<NotificationStatusQuery>(HandleStatusQuery);
|
|
Receive<NotificationDetailRequest>(HandleDetailRequest);
|
|
Receive<RetryNotificationRequest>(HandleRetry);
|
|
Receive<DiscardNotificationRequest>(HandleDiscard);
|
|
Receive<NotificationKpiRequest>(HandleKpiRequest);
|
|
Receive<PerSiteNotificationKpiRequest>(HandlePerSiteKpiRequest);
|
|
Receive<PerNodeNotificationKpiRequest>(HandlePerNodeKpiRequest);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
protected override void PreStart()
|
|
{
|
|
base.PreStart();
|
|
// Shutdown token is alive for the lifetime of the actor; cancelled in PostStop
|
|
// so dispatcher sweeps and the SMTP send beneath them observe coordinated shutdown.
|
|
_shutdownCts = new CancellationTokenSource();
|
|
Timers.StartPeriodicTimer(
|
|
DispatchTimerKey, InternalMessages.DispatchTick.Instance, _options.DispatchInterval);
|
|
Timers.StartPeriodicTimer(
|
|
PurgeTimerKey, InternalMessages.PurgeTick.Instance, _options.PurgeInterval);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
protected override void PostStop()
|
|
{
|
|
// Cancel the shutdown token first so the in-flight sweep's adapter call
|
|
// observes cancellation, then dispose the source. Order matters — disposing first
|
|
// would race with an in-flight sweep registering with the token.
|
|
try
|
|
{
|
|
_shutdownCts?.Cancel();
|
|
}
|
|
catch (ObjectDisposedException)
|
|
{
|
|
// Already disposed under a restarted-actor race; nothing to do.
|
|
}
|
|
|
|
_shutdownCts?.Dispose();
|
|
_shutdownCts = null;
|
|
|
|
// Dispose the actor-lifetime adapter scope so the cached
|
|
// scoped adapter instances and their disposable dependencies are torn down with
|
|
// the actor (e.g. on a CoordinatedShutdown / failover that stops the singleton).
|
|
_adaptersScope?.Dispose();
|
|
_adaptersScope = null;
|
|
_adaptersCache = null;
|
|
|
|
base.PostStop();
|
|
}
|
|
|
|
/// <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;
|
|
|
|
// The success projection fires for both a fresh insert and an existing row;
|
|
// only a thrown repository error reaches the failure projection. The list
|
|
// lookup that stamps the channel Type happens inside PersistAsync so it can
|
|
// share the same DI scope as the insert.
|
|
PersistAsync(msg).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 the target notification list to stamp the delivery channel
|
|
/// <see cref="NotificationType"/> on a fresh row, then 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 (and its persisted
|
|
/// Type is left untouched) so the site can clear its forward buffer. Only a thrown
|
|
/// error must surface to the caller.
|
|
/// </summary>
|
|
private async Task PersistAsync(NotificationSubmit msg)
|
|
{
|
|
using var scope = _serviceProvider.CreateScope();
|
|
var repository = scope.ServiceProvider.GetRequiredService<INotificationOutboxRepository>();
|
|
|
|
// The list's Type is the authoritative delivery channel. Resolve it here so a
|
|
// fresh row is stamped with the right channel (Email/Sms/...). GetService (not
|
|
// GetRequiredService) so the lookup is optional: a host without INotification
|
|
// Repository, or a missing list, falls back to Email — the notification then
|
|
// parks at delivery with "list not found", which is the unchanged behaviour.
|
|
var listRepository = scope.ServiceProvider.GetService<INotificationRepository>();
|
|
var list = listRepository is null
|
|
? null
|
|
: await listRepository.GetListByNameAsync(msg.ListName);
|
|
var type = list?.Type ?? NotificationType.Email;
|
|
|
|
var notification = BuildNotification(msg, type);
|
|
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));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles a dispatch tick. If a sweep is already in flight the tick is dropped so
|
|
/// sweeps never overlap; otherwise the guard is raised and an asynchronous sweep is
|
|
/// launched, with a <see cref="InternalMessages.DispatchComplete"/> piped back to
|
|
/// <see cref="Self"/> to lower the guard on the actor thread.
|
|
/// </summary>
|
|
private void HandleDispatchTick()
|
|
{
|
|
if (_dispatching)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_dispatching = true;
|
|
var now = DateTimeOffset.UtcNow;
|
|
// Hand the lifecycle token to the sweep so cancellation reaches the
|
|
// adapter. A null token (very early start / post-stop race) is replaced with
|
|
// None — no behaviour change vs. the previous dispatcher.
|
|
var cancellationToken = _shutdownCts?.Token ?? CancellationToken.None;
|
|
|
|
// RunDispatchPass swallows its own errors, but the failure projection is kept as a
|
|
// belt-and-braces guard so even a faulted task still lowers the in-flight guard —
|
|
// otherwise the dispatcher would wedge permanently.
|
|
RunDispatchPass(now, cancellationToken).PipeTo(
|
|
Self,
|
|
success: () => InternalMessages.DispatchComplete.Instance,
|
|
failure: ex =>
|
|
{
|
|
_logger.LogError(ex, "Dispatch sweep faulted unexpectedly.");
|
|
return InternalMessages.DispatchComplete.Instance;
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Runs a single dispatch sweep: claims the due batch, resolves the retry policy, and
|
|
/// delivers each notification sequentially. Per-notification failures are caught and
|
|
/// logged so one bad row never aborts the rest of the batch. The whole body is wrapped
|
|
/// in a try/catch so the returned task never faults — scope creation, service resolution,
|
|
/// and retry-policy resolution can all throw, and a faulted task would otherwise leave
|
|
/// the dispatcher's in-flight guard stuck and wedge the loop permanently.
|
|
///
|
|
/// The per-sweep DI scope still owns the repository graph
|
|
/// (<see cref="INotificationOutboxRepository"/> + <see cref="INotificationRepository"/>),
|
|
/// which is correct because those services back a fresh DbContext per sweep. The
|
|
/// channel delivery adapters, however, are cached for the actor's lifetime via
|
|
/// <see cref="ResolveAdapters"/> — see <see cref="_adaptersCache"/> for the
|
|
/// rationale.
|
|
/// </summary>
|
|
private async Task RunDispatchPass(DateTimeOffset now, CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
using var scope = _serviceProvider.CreateScope();
|
|
var outboxRepository = scope.ServiceProvider.GetRequiredService<INotificationOutboxRepository>();
|
|
var notificationRepository = scope.ServiceProvider.GetRequiredService<INotificationRepository>();
|
|
var adapters = ResolveAdapters();
|
|
|
|
IReadOnlyList<Notification> due;
|
|
try
|
|
{
|
|
due = await outboxRepository.GetDueAsync(now, _options.DispatchBatchSize, cancellationToken);
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
// Shutdown cancelled the claim; row stays Pending and the next active
|
|
// node picks it up. Not a failure.
|
|
return;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Dispatch sweep failed to claim due notifications.");
|
|
return;
|
|
}
|
|
|
|
if (due.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var policies = await ResolveRetryPoliciesAsync(notificationRepository);
|
|
|
|
// Deliver the claimed batch with bounded parallelism. Delivery is I/O-bound
|
|
// (multi-second SMTP/Twilio round trips), so a strictly sequential sweep caps
|
|
// throughput at ~1/attempt-latency — an alarm-storm backlog behind one slow
|
|
// host would drain far too slowly (arch-review 04). A SemaphoreSlim caps the
|
|
// number of in-flight deliveries; each delivery runs on its OWN DI scope/
|
|
// repository (the EF DbContext is not thread-safe and rows are distinct per
|
|
// notification, so concurrent deliveries never contend). The cached adapters
|
|
// and the lifetime-scoped audit writer are already safe for concurrent use.
|
|
// MaxParallelDeliveries = 1 preserves the strictly sequential behaviour.
|
|
using var deliveryGate = new SemaphoreSlim(
|
|
_options.ResolvedMaxParallelDeliveries, _options.ResolvedMaxParallelDeliveries);
|
|
var deliveries = new List<Task>(due.Count);
|
|
foreach (var notification in due)
|
|
{
|
|
deliveries.Add(DeliverGatedAsync(
|
|
notification, now, policies, adapters, deliveryGate, cancellationToken));
|
|
}
|
|
|
|
// Each DeliverGatedAsync isolates its own faults, so WhenAll never throws.
|
|
await Task.WhenAll(deliveries);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Scope/service resolution or retry-policy resolution faulted; swallow and log so
|
|
// the returned task completes normally and the in-flight guard is always cleared.
|
|
_logger.LogError(ex, "Dispatch sweep failed unexpectedly.");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Delivers one notification under the sweep's concurrency gate. Acquires a permit,
|
|
/// runs <see cref="DeliverOneAsync"/> on a fresh per-notification DI scope (so parallel
|
|
/// deliveries never share an EF <c>DbContext</c>), and always releases the permit.
|
|
/// Faults are isolated here — shutdown cancellation is swallowed (the row stays in its
|
|
/// pre-attempt state for the next active sweep) and any other exception is logged — so
|
|
/// one bad delivery never aborts the rest of the batch and <c>Task.WhenAll</c> never faults.
|
|
/// </summary>
|
|
private async Task DeliverGatedAsync(
|
|
Notification notification,
|
|
DateTimeOffset now,
|
|
RetryPolicies policies,
|
|
IReadOnlyDictionary<NotificationType, INotificationDeliveryAdapter> adapters,
|
|
SemaphoreSlim gate,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Shutdown cancelled before this delivery acquired a permit; the row stays
|
|
// Pending and the next active node's sweep re-claims it. No permit to release.
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
if (cancellationToken.IsCancellationRequested)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Each delivery owns its DI scope + repository so parallel UpdateAsync calls
|
|
// never share a DbContext. The adapters map + audit writer are already safe
|
|
// for concurrent use.
|
|
using var scope = _serviceProvider.CreateScope();
|
|
var outboxRepository = scope.ServiceProvider.GetRequiredService<INotificationOutboxRepository>();
|
|
await DeliverOneAsync(
|
|
notification, now, policies, outboxRepository, adapters, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
// In-flight delivery interrupted by shutdown. Row remains in its pre-attempt
|
|
// state; next active sweep retries.
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Isolate per-notification failures so the remainder of the batch still runs.
|
|
_logger.LogError(
|
|
ex, "Dispatch failed for notification {NotificationId}.", notification.NotificationId);
|
|
}
|
|
finally
|
|
{
|
|
gate.Release();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A single channel's transient-failure retry policy: the max attempt count before a
|
|
/// row parks and the fixed delay between attempts. Resolved once per sweep and selected
|
|
/// per notification by <see cref="NotificationType"/> in <see cref="DeliverOneAsync"/>.
|
|
/// </summary>
|
|
private readonly record struct RetryPolicy(int MaxRetries, TimeSpan RetryDelay);
|
|
|
|
/// <summary>
|
|
/// The per-type retry policies resolved once per dispatch sweep: <see cref="SmtpPolicy"/>
|
|
/// drives Email (and every non-SMS type), <see cref="SmsPolicy"/> drives SMS.
|
|
/// </summary>
|
|
private readonly record struct RetryPolicies(RetryPolicy SmtpPolicy, RetryPolicy SmsPolicy);
|
|
|
|
/// <summary>
|
|
/// Resolves the per-type retry policies for the sweep: the SMTP policy from the first
|
|
/// SMTP configuration row (drives Email and every non-SMS type), and the SMS policy from
|
|
/// the <see cref="SmsConfiguration"/> row — falling back to the SMTP policy when no SMS
|
|
/// configuration exists. Resolved once and reused for the whole batch;
|
|
/// <see cref="DeliverOneAsync"/> selects the right policy by
|
|
/// <see cref="Notification.Type"/>.
|
|
/// </summary>
|
|
private async Task<RetryPolicies> ResolveRetryPoliciesAsync(
|
|
INotificationRepository notificationRepository)
|
|
{
|
|
var smtpPolicy = await ResolveSmtpPolicyAsync(notificationRepository);
|
|
var smsPolicy = await ResolveSmsPolicyAsync(notificationRepository, smtpPolicy);
|
|
return new RetryPolicies(smtpPolicy, smsPolicy);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves the SMTP-derived retry policy from the lowest-Id SMTP configuration row. When no
|
|
/// SMTP configuration exists, falls back to the conservative default constants — delivery
|
|
/// itself will permanently fail in that case, so the policy only acts as a guard.
|
|
/// </summary>
|
|
private async Task<RetryPolicy> ResolveSmtpPolicyAsync(
|
|
INotificationRepository notificationRepository)
|
|
{
|
|
var configurations = await notificationRepository.GetAllSmtpConfigurationsAsync();
|
|
// Deterministic pick: the lowest-Id row wins regardless of repository order, so the
|
|
// retry policy resolves the same way the delivery adapter selects its config (S7).
|
|
var configuration = configurations.OrderBy(c => c.Id).FirstOrDefault();
|
|
if (configuration is null)
|
|
{
|
|
return new RetryPolicy(FallbackMaxRetries, FallbackRetryDelay);
|
|
}
|
|
|
|
if (configurations.Count > 1)
|
|
{
|
|
_logger.LogWarning(
|
|
"Multiple SMTP configurations exist; using the lowest-Id row ({Id}). "
|
|
+ "Enforce a single row or delete extras.",
|
|
configuration.Id);
|
|
}
|
|
|
|
return ClampRetryPolicy(
|
|
nameof(SmtpConfiguration), configuration.MaxRetries, configuration.RetryDelay);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves the SMS-specific retry policy from the <see cref="SmsConfiguration"/> row,
|
|
/// honoring its own <see cref="SmsConfiguration.MaxRetries"/>/<see cref="SmsConfiguration.RetryDelay"/>.
|
|
/// When no SMS configuration row exists the SMTP policy (<paramref name="smtpFallback"/>)
|
|
/// is reused so SMS delivery keeps the historical shared-SMTP behaviour until an operator
|
|
/// configures SMS.
|
|
/// </summary>
|
|
private async Task<RetryPolicy> ResolveSmsPolicyAsync(
|
|
INotificationRepository notificationRepository, RetryPolicy smtpFallback)
|
|
{
|
|
var configuration = await notificationRepository.GetSmsConfigurationAsync();
|
|
if (configuration is null)
|
|
{
|
|
return smtpFallback;
|
|
}
|
|
|
|
return ClampRetryPolicy(
|
|
nameof(SmsConfiguration), configuration.MaxRetries, configuration.RetryDelay);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clamps a configured retry policy's non-positive values to the fallback constants,
|
|
/// logging a Warning (parameterized by <paramref name="configName"/> so an operator can
|
|
/// tell which configuration is misconfigured).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A non-positive <c>MaxRetries</c> (zero or negative) would otherwise satisfy
|
|
/// <c>RetryCount >= maxRetries</c> on the very first transient failure and park the row
|
|
/// without a single retry — silently halving the outbox's delivery guarantees. The same
|
|
/// applies to a non-positive <c>RetryDelay</c>, which would burn-loop the dispatcher.
|
|
/// </remarks>
|
|
private RetryPolicy ClampRetryPolicy(string configName, int maxRetries, TimeSpan retryDelay)
|
|
{
|
|
if (maxRetries <= 0)
|
|
{
|
|
_logger.LogWarning(
|
|
"{ConfigName}.MaxRetries={ConfiguredMaxRetries} is non-positive; " +
|
|
"clamping to FallbackMaxRetries={FallbackMaxRetries} so transient failures " +
|
|
"actually retry before parking.",
|
|
configName, maxRetries, FallbackMaxRetries);
|
|
maxRetries = FallbackMaxRetries;
|
|
}
|
|
|
|
if (retryDelay <= TimeSpan.Zero)
|
|
{
|
|
_logger.LogWarning(
|
|
"{ConfigName}.RetryDelay={ConfiguredRetryDelay} is non-positive; " +
|
|
"clamping to FallbackRetryDelay={FallbackRetryDelay} so the dispatcher does " +
|
|
"not burn-loop on transient failures.",
|
|
configName, retryDelay, FallbackRetryDelay);
|
|
retryDelay = FallbackRetryDelay;
|
|
}
|
|
|
|
return new RetryPolicy(maxRetries, retryDelay);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the <see cref="NotificationType"/> → adapter lookup, building it lazily on
|
|
/// the first call and caching it on <see cref="_adaptersCache"/> for the actor's
|
|
/// lifetime. The last adapter registered for a given type wins, mirroring DI's
|
|
/// last-wins resolution semantics.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The lookup used to be rebuilt on every dispatch sweep
|
|
/// from the per-sweep DI scope. Adapter registration is static per process
|
|
/// lifetime, so the dict is now built ONCE — on the first sweep that needs it —
|
|
/// and reused. To respect each adapter's scoped lifetime
|
|
/// (<see cref="EmailNotificationDeliveryAdapter"/> takes a scoped
|
|
/// <see cref="INotificationRepository"/>), the cache is paired with
|
|
/// <see cref="_adaptersScope"/>, an actor-lifetime <see cref="IServiceScope"/> that
|
|
/// owns the cached adapter instances and is disposed in <see cref="PostStop"/>.
|
|
/// </remarks>
|
|
private IReadOnlyDictionary<NotificationType, INotificationDeliveryAdapter> ResolveAdapters()
|
|
{
|
|
if (_adaptersCache is not null)
|
|
{
|
|
return _adaptersCache;
|
|
}
|
|
|
|
_adaptersScope = _serviceProvider.CreateScope();
|
|
var adapters = new Dictionary<NotificationType, INotificationDeliveryAdapter>();
|
|
foreach (var adapter in _adaptersScope.ServiceProvider.GetServices<INotificationDeliveryAdapter>())
|
|
{
|
|
adapters[adapter.Type] = adapter;
|
|
}
|
|
|
|
_adaptersCache = adapters;
|
|
return _adaptersCache;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Delivers a single notification through its channel adapter and applies the resulting
|
|
/// status transition. A missing adapter parks the notification; otherwise the
|
|
/// <see cref="DeliveryOutcome"/> drives the transition. The updated row is always persisted.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// A single
|
|
/// <see cref="AuditChannel.Notification"/>/<see cref="AuditKind.NotifyDeliver"/>
|
|
/// row is emitted with <see cref="AuditStatus.Attempted"/> per attempt
|
|
/// (success, transient, permanent); when the post-outcome status is a
|
|
/// terminal one (Delivered, Parked) a SECOND row is emitted carrying
|
|
/// that terminal status. Both emissions are wrapped in a try/catch so a
|
|
/// thrown audit writer NEVER aborts the user-facing dispatch — the
|
|
/// <see cref="CentralAuditWriter"/> itself swallows internal failures,
|
|
/// but the dispatcher wraps defensively per alog.md §13. The
|
|
/// missing-adapter park path also emits both rows because it IS an
|
|
/// attempt that resolved to a park from the dispatcher's point of view.
|
|
/// </para>
|
|
/// <para>
|
|
/// Attempt duration is measured around the adapter call and recorded on
|
|
/// the Attempted row so downstream KPIs can compute per-attempt latency
|
|
/// without joining to the row update timestamps.
|
|
/// </para>
|
|
/// </remarks>
|
|
private async Task DeliverOneAsync(
|
|
Notification notification,
|
|
DateTimeOffset now,
|
|
RetryPolicies policies,
|
|
INotificationOutboxRepository outboxRepository,
|
|
IReadOnlyDictionary<NotificationType, INotificationDeliveryAdapter> adapters,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Select the retry policy by delivery channel: SMS honors SmsConfiguration's own
|
|
// MaxRetries/RetryDelay; every other type (Email today) uses the SMTP policy.
|
|
var (maxRetries, retryDelay) = notification.Type == NotificationType.Sms
|
|
? policies.SmsPolicy
|
|
: policies.SmtpPolicy;
|
|
|
|
if (!adapters.TryGetValue(notification.Type, out var adapter))
|
|
{
|
|
// Missing-adapter park: from the dispatcher's perspective this is an
|
|
// attempt that resolved to a terminal park. Emit Attempted then the
|
|
// terminal Parked row, both carrying the same explanatory error.
|
|
var missingAdapterError = $"no delivery adapter for type {notification.Type}";
|
|
notification.Status = NotificationStatus.Parked;
|
|
notification.LastError = missingAdapterError;
|
|
notification.LastAttemptAt = now;
|
|
await WarnIfVanishedAsync(outboxRepository, notification, cancellationToken);
|
|
await EmitAttemptAuditAsync(
|
|
notification,
|
|
now,
|
|
durationMs: 0,
|
|
errorMessage: missingAdapterError);
|
|
await EmitTerminalAuditAsync(notification, now, errorMessage: missingAdapterError);
|
|
return;
|
|
}
|
|
|
|
// Measure the attempt duration around the adapter call so the
|
|
// Attempted row carries it for KPI use.
|
|
// Pass the lifecycle token so a coordinated shutdown promptly cancels the
|
|
// in-flight SMTP send instead of waiting for the SMTP connect/auth/send timeout.
|
|
var attemptStart = DateTimeOffset.UtcNow;
|
|
var outcome = await adapter.DeliverAsync(notification, cancellationToken);
|
|
var durationMs = (int)Math.Min(
|
|
int.MaxValue, Math.Max(0, (DateTimeOffset.UtcNow - attemptStart).TotalMilliseconds));
|
|
|
|
switch (outcome.Result)
|
|
{
|
|
case DeliveryResult.Success:
|
|
notification.Status = NotificationStatus.Delivered;
|
|
notification.DeliveredAt = now;
|
|
notification.LastAttemptAt = now;
|
|
notification.ResolvedTargets = outcome.ResolvedTargets;
|
|
notification.LastError = null;
|
|
break;
|
|
|
|
case DeliveryResult.TransientFailure:
|
|
notification.LastAttemptAt = now;
|
|
notification.RetryCount++;
|
|
notification.LastError = outcome.Error;
|
|
if (notification.RetryCount >= maxRetries)
|
|
{
|
|
notification.Status = NotificationStatus.Parked;
|
|
}
|
|
else
|
|
{
|
|
notification.Status = NotificationStatus.Retrying;
|
|
notification.NextAttemptAt = now + retryDelay;
|
|
}
|
|
break;
|
|
|
|
case DeliveryResult.PermanentFailure:
|
|
notification.Status = NotificationStatus.Parked;
|
|
notification.LastAttemptAt = now;
|
|
notification.LastError = outcome.Error;
|
|
break;
|
|
}
|
|
|
|
await WarnIfVanishedAsync(outboxRepository, notification, cancellationToken);
|
|
|
|
// Emit the per-attempt Attempted row exactly once regardless of the
|
|
// outcome (B2). The error message comes from the outcome, not from
|
|
// notification.LastError, so a success row is null and a transient
|
|
// row carries the SMTP failure reason verbatim.
|
|
await EmitAttemptAuditAsync(
|
|
notification,
|
|
now,
|
|
durationMs: durationMs,
|
|
errorMessage: outcome.Result == DeliveryResult.Success ? null : outcome.Error);
|
|
|
|
// If the post-outcome status is terminal (Delivered or Parked — the
|
|
// dispatcher never sets Discarded; that lives on the manual discard
|
|
// path), emit the terminal NotifyDeliver row (B3). The error message
|
|
// on a Delivered terminal is null; on Parked it carries the outcome's
|
|
// reason so downstream consumers can link Attempted+Parked rows.
|
|
if (IsTerminal(notification.Status))
|
|
{
|
|
await EmitTerminalAuditAsync(
|
|
notification,
|
|
now,
|
|
errorMessage: outcome.Result == DeliveryResult.Success ? null : outcome.Error);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Persists the dispatcher's delivery-state write and logs when the row has
|
|
/// VANISHED underneath it — <see cref="INotificationOutboxRepository.UpdateAsync"/>
|
|
/// returning false means the retention purge deleted the notification between
|
|
/// the claim and the write.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The dispatcher deliberately does not treat this as an error: the delivery
|
|
/// itself already happened (or failed) and there is no row left to record the
|
|
/// outcome on, so there is nothing to retry or roll back. The audit rows are
|
|
/// still emitted — they are the durable record. The operator one-shots
|
|
/// (retry/discard) take the opposite stance and answer "not found", because a
|
|
/// human is waiting on that answer.
|
|
/// </remarks>
|
|
private async Task WarnIfVanishedAsync(
|
|
INotificationOutboxRepository repository,
|
|
Notification notification,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var persisted = await repository.UpdateAsync(notification, cancellationToken);
|
|
if (!persisted)
|
|
{
|
|
_logger.LogWarning(
|
|
"Notification {NotificationId} disappeared before its delivery state could be written (status {Status}); the row was most likely purged mid-flight.",
|
|
notification.NotificationId,
|
|
notification.Status);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// True for <see cref="NotificationStatus.Delivered"/>,
|
|
/// <see cref="NotificationStatus.Parked"/>, or
|
|
/// <see cref="NotificationStatus.Discarded"/> — the three terminal states
|
|
/// on the central outbox lifecycle. Used by the dispatcher and the manual
|
|
/// discard handler to decide when to emit the terminal NotifyDeliver row.
|
|
/// </summary>
|
|
private static bool IsTerminal(NotificationStatus status)
|
|
{
|
|
return status is NotificationStatus.Delivered
|
|
or NotificationStatus.Parked
|
|
or NotificationStatus.Discarded;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Emits a single
|
|
/// <see cref="AuditChannel.Notification"/>/<see cref="AuditKind.NotifyDeliver"/>
|
|
/// audit row carrying the terminal status (Delivered, Parked, or
|
|
/// Discarded) of <paramref name="notification"/>. Wrapped in try/catch
|
|
/// for the same defensive reason as <see cref="EmitAttemptAuditAsync"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <see cref="ICentralAuditWriter.WriteAsync"/> is awaited inside the
|
|
/// try/catch so the catch is actually reachable for writer faults and so the
|
|
/// audit task does not outlive the per-sweep DI scope. The audit-write-never-
|
|
/// affects-delivery invariant is preserved by the surrounding catch.
|
|
/// </remarks>
|
|
private async Task EmitTerminalAuditAsync(
|
|
Notification notification,
|
|
DateTimeOffset now,
|
|
string? errorMessage,
|
|
string? actorOverride = null)
|
|
{
|
|
try
|
|
{
|
|
var terminalStatus = MapNotificationStatusToAuditStatus(notification.Status);
|
|
var evt = BuildNotifyDeliverEvent(
|
|
notification, now, terminalStatus, errorMessage, actorOverride: actorOverride);
|
|
await _auditWriter.WriteAsync(evt);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(
|
|
ex,
|
|
"Failed to emit terminal {Status} audit row for notification {NotificationId}.",
|
|
notification.Status, notification.NotificationId);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps the central-outbox <see cref="NotificationStatus"/> terminal
|
|
/// values onto the corresponding <see cref="AuditStatus"/> values used by
|
|
/// AuditLog. Non-terminal statuses throw — the caller must gate on
|
|
/// <see cref="IsTerminal"/>.
|
|
/// </summary>
|
|
private static AuditStatus MapNotificationStatusToAuditStatus(NotificationStatus status)
|
|
{
|
|
return status switch
|
|
{
|
|
NotificationStatus.Delivered => AuditStatus.Delivered,
|
|
NotificationStatus.Parked => AuditStatus.Parked,
|
|
NotificationStatus.Discarded => AuditStatus.Discarded,
|
|
_ => throw new ArgumentOutOfRangeException(
|
|
nameof(status), status, "non-terminal status has no audit terminal mapping"),
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Emits a single
|
|
/// <see cref="AuditChannel.Notification"/>/<see cref="AuditKind.NotifyDeliver"/>
|
|
/// audit row with <see cref="AuditStatus.Attempted"/>. Wrapped in
|
|
/// try/catch so an audit-write failure never propagates back into the
|
|
/// dispatcher loop (alog.md §13).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Previously the writer task was discarded (<c>_ = WriteAsync(...)</c>),
|
|
/// which made the surrounding catch unreachable for any fault originating in the
|
|
/// awaited body of <c>WriteAsync</c> and let the audit task outlive the dispatcher's
|
|
/// per-sweep DI scope. The task is now awaited inside the try/catch: the
|
|
/// audit-write-never-affects-delivery invariant is preserved by the catch, and
|
|
/// writer faults reach the operator log instead of being silently lost.
|
|
/// </remarks>
|
|
private async Task EmitAttemptAuditAsync(
|
|
Notification notification,
|
|
DateTimeOffset now,
|
|
int durationMs,
|
|
string? errorMessage)
|
|
{
|
|
try
|
|
{
|
|
var evt = BuildNotifyDeliverEvent(
|
|
notification, now, AuditStatus.Attempted, errorMessage, durationMs);
|
|
await _auditWriter.WriteAsync(evt);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(
|
|
ex,
|
|
"Failed to emit Attempted audit row for notification {NotificationId}.",
|
|
notification.NotificationId);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a <see cref="AuditChannel.Notification"/>/<see cref="AuditKind.NotifyDeliver"/>
|
|
/// row with the per-notification provenance fields (correlation id, list
|
|
/// name, source site/instance/script) populated from
|
|
/// <paramref name="notification"/>. <see cref="AuditEvent.CorrelationId"/>
|
|
/// parses the notification's id as a Guid; sites generate the id with
|
|
/// <c>Guid.NewGuid().ToString("N")</c> so the parse always succeeds, but
|
|
/// a non-Guid id is recorded as null rather than crashing the dispatcher.
|
|
/// <see cref="AuditEvent.ExecutionId"/> is copied straight from
|
|
/// <see cref="Notification.OriginExecutionId"/> so the dispatcher's
|
|
/// <c>NotifyDeliver</c> rows carry the same per-run id as the site's
|
|
/// <c>NotifySend</c> row; <see cref="AuditEvent.ParentExecutionId"/>
|
|
/// is likewise copied from <see cref="Notification.OriginParentExecutionId"/>.
|
|
/// </summary>
|
|
private static AuditEvent BuildNotifyDeliverEvent(
|
|
Notification notification,
|
|
DateTimeOffset now,
|
|
AuditStatus status,
|
|
string? errorMessage,
|
|
int? durationMs = null,
|
|
string? actorOverride = null)
|
|
{
|
|
Guid? correlationId = Guid.TryParse(notification.NotificationId, out var parsed)
|
|
? parsed
|
|
: null;
|
|
|
|
return ScadaBridgeAuditEventFactory.Create(
|
|
channel: AuditChannel.Notification,
|
|
kind: AuditKind.NotifyDeliver,
|
|
status: status,
|
|
occurredAtUtc: now.UtcDateTime,
|
|
// Central dispatch is a system identity per the Actor-column spec —
|
|
// there is no per-call authenticated user for automatic delivery. An
|
|
// operator-initiated transition (Retry/Discard) passes actorOverride
|
|
// so the row is attributable to the person who un-parked/cancelled it.
|
|
// The originating script is still captured on SourceScript.
|
|
actor: actorOverride ?? SystemActor,
|
|
target: notification.ListName,
|
|
correlationId: correlationId,
|
|
// ExecutionId: the originating script execution's id,
|
|
// carried from the site on NotificationSubmit and persisted on the
|
|
// Notification row. Echoing it here links the central NotifyDeliver
|
|
// rows to the site-emitted NotifySend row for the same run. Null when
|
|
// the notification was raised outside a script execution.
|
|
executionId: notification.OriginExecutionId,
|
|
// ParentExecutionId: the originating routed run's
|
|
// parent ExecutionId, carried from the site on NotificationSubmit and
|
|
// persisted on the Notification row. Echoing it here links the central
|
|
// NotifyDeliver rows to the routed run's parent. Null for non-routed runs.
|
|
parentExecutionId: notification.OriginParentExecutionId,
|
|
sourceSiteId: notification.SourceSiteId,
|
|
sourceInstanceId: notification.SourceInstanceId,
|
|
sourceScript: notification.SourceScript,
|
|
durationMs: durationMs,
|
|
errorMessage: errorMessage);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles a purge tick by launching an asynchronous sweep that bulk-deletes terminal
|
|
/// notification rows older than <see cref="NotificationOutboxOptions.TerminalRetention"/>.
|
|
/// Purges are daily and idempotent, so no in-flight guard is needed. <see cref="RunPurgePass"/>
|
|
/// self-isolates its faults — it logs internally and never faults its task — so the
|
|
/// success projection is the normal completion path that logs the deleted count. The
|
|
/// failure projection is kept as a belt-and-braces backup, consistent with
|
|
/// <see cref="HandleDispatchTick"/>/<see cref="RunDispatchPass"/>.
|
|
/// </summary>
|
|
private void HandlePurgeTick()
|
|
{
|
|
var cutoff = DateTimeOffset.UtcNow - _options.TerminalRetention;
|
|
|
|
RunPurgePass(cutoff).PipeTo(
|
|
Self,
|
|
success: deleted =>
|
|
{
|
|
_logger.LogInformation(
|
|
"Purge removed {DeletedCount} terminal notification(s) older than {Cutoff:o}.",
|
|
deleted, cutoff);
|
|
return InternalMessages.PurgeComplete.Instance;
|
|
},
|
|
failure: ex =>
|
|
{
|
|
_logger.LogError(ex, "Purge sweep faulted unexpectedly.");
|
|
return InternalMessages.PurgeComplete.Instance;
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Runs a single purge sweep: resolves a scoped <see cref="INotificationOutboxRepository"/>
|
|
/// and bulk-deletes terminal rows created before <paramref name="cutoff"/>, returning the
|
|
/// deleted count. The whole body is wrapped in a try/catch so the returned task never
|
|
/// faults — scope creation, service resolution, and the bulk delete can all throw, and
|
|
/// self-isolating the fault here keeps the fault-handling strategy symmetric with
|
|
/// <see cref="RunDispatchPass"/>. On failure the exception is logged and 0 is returned.
|
|
/// </summary>
|
|
private async Task<int> RunPurgePass(DateTimeOffset cutoff)
|
|
{
|
|
try
|
|
{
|
|
using var scope = _serviceProvider.CreateScope();
|
|
var repository = scope.ServiceProvider.GetRequiredService<INotificationOutboxRepository>();
|
|
return await repository.DeleteTerminalOlderThanAsync(cutoff);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Scope/service resolution or the bulk delete faulted; swallow and log so the
|
|
// returned task completes normally, mirroring RunDispatchPass.
|
|
_logger.LogError(ex, "Purge sweep failed unexpectedly.");
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles a paginated, filtered query over the outbox. Builds a
|
|
/// <see cref="NotificationOutboxFilter"/> from the request (parsing the string status/type
|
|
/// filters to their enums and deriving the stuck cutoff when <c>StuckOnly</c> is set),
|
|
/// runs the query on a scoped repository, and pipes the mapped response back to the
|
|
/// captured sender. A repository fault yields a failure response with an empty list.
|
|
/// </summary>
|
|
private void HandleQuery(NotificationOutboxQueryRequest request)
|
|
{
|
|
var sender = Sender;
|
|
var now = DateTimeOffset.UtcNow;
|
|
|
|
QueryOutboxAsync(request, now).PipeTo(
|
|
sender,
|
|
success: response => response,
|
|
failure: ex => new NotificationOutboxQueryResponse(
|
|
request.CorrelationId,
|
|
Success: false,
|
|
ErrorMessage: ex.GetBaseException().Message,
|
|
Notifications: Array.Empty<NotificationSummary>(),
|
|
TotalCount: 0));
|
|
}
|
|
|
|
private async Task<NotificationOutboxQueryResponse> QueryOutboxAsync(
|
|
NotificationOutboxQueryRequest request, DateTimeOffset now)
|
|
{
|
|
var filter = new NotificationOutboxFilter(
|
|
Status: ParseEnum<NotificationStatus>(request.StatusFilter),
|
|
Type: ParseEnum<NotificationType>(request.TypeFilter),
|
|
SourceSiteId: request.SourceSiteFilter,
|
|
ListName: request.ListNameFilter,
|
|
SubjectKeyword: request.SubjectKeyword,
|
|
StuckOnly: request.StuckOnly,
|
|
StuckCutoff: request.StuckOnly ? StuckCutoff(now) : null,
|
|
From: request.From,
|
|
To: request.To,
|
|
SourceNode: request.SourceNodeFilter);
|
|
|
|
using var scope = _serviceProvider.CreateScope();
|
|
var repository = scope.ServiceProvider.GetRequiredService<INotificationOutboxRepository>();
|
|
var (rows, totalCount) = await repository.QueryAsync(filter, request.PageNumber, request.PageSize);
|
|
|
|
var stuckCutoff = StuckCutoff(now);
|
|
var summaries = rows
|
|
.Select(row => new NotificationSummary(
|
|
row.NotificationId,
|
|
row.Type.ToString(),
|
|
row.ListName,
|
|
row.Subject,
|
|
row.Status.ToString(),
|
|
row.RetryCount,
|
|
row.LastError,
|
|
row.SourceSiteId,
|
|
row.SourceInstanceId,
|
|
row.CreatedAt,
|
|
row.DeliveredAt,
|
|
IsStuck: IsStuck(row, stuckCutoff),
|
|
SourceNode: row.SourceNode))
|
|
.ToList();
|
|
|
|
return new NotificationOutboxQueryResponse(
|
|
request.CorrelationId, Success: true, ErrorMessage: null, summaries, totalCount);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles a single-notification status query. Replies <c>Found: false</c> with empty
|
|
/// detail when no row matches, otherwise the row's current status, retry count, last
|
|
/// error, and delivery time.
|
|
/// </summary>
|
|
private void HandleStatusQuery(NotificationStatusQuery query)
|
|
{
|
|
var sender = Sender;
|
|
|
|
StatusQueryAsync(query).PipeTo(
|
|
sender,
|
|
success: response => response,
|
|
failure: ex =>
|
|
{
|
|
// NotificationStatusResponse has no error field, so a repository fault is
|
|
// reported as Found: false — log the fault so a transient DB error is not
|
|
// silently indistinguishable from a genuinely-missing notification.
|
|
_logger.LogWarning(
|
|
ex, "Status query for notification {NotificationId} failed.", query.NotificationId);
|
|
return new NotificationStatusResponse(
|
|
query.CorrelationId, Found: false, Status: string.Empty,
|
|
RetryCount: 0, LastError: null, DeliveredAt: null);
|
|
});
|
|
}
|
|
|
|
private async Task<NotificationStatusResponse> StatusQueryAsync(NotificationStatusQuery query)
|
|
{
|
|
using var scope = _serviceProvider.CreateScope();
|
|
var repository = scope.ServiceProvider.GetRequiredService<INotificationOutboxRepository>();
|
|
var notification = await repository.GetByIdAsync(query.NotificationId);
|
|
|
|
if (notification is null)
|
|
{
|
|
return new NotificationStatusResponse(
|
|
query.CorrelationId, Found: false, Status: string.Empty,
|
|
RetryCount: 0, LastError: null, DeliveredAt: null);
|
|
}
|
|
|
|
return new NotificationStatusResponse(
|
|
query.CorrelationId,
|
|
Found: true,
|
|
Status: notification.Status.ToString(),
|
|
RetryCount: notification.RetryCount,
|
|
LastError: notification.LastError,
|
|
DeliveredAt: notification.DeliveredAt);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles a full-detail query for a single notification — backs the report detail
|
|
/// modal, which needs the Body and resolved recipients that the grid summary omits.
|
|
/// </summary>
|
|
private void HandleDetailRequest(NotificationDetailRequest request)
|
|
{
|
|
var sender = Sender;
|
|
|
|
DetailAsync(request).PipeTo(
|
|
sender,
|
|
success: response => response,
|
|
failure: ex => new NotificationDetailResponse(
|
|
request.CorrelationId, Success: false,
|
|
ErrorMessage: ex.GetBaseException().Message, Detail: null));
|
|
}
|
|
|
|
private async Task<NotificationDetailResponse> DetailAsync(NotificationDetailRequest request)
|
|
{
|
|
using var scope = _serviceProvider.CreateScope();
|
|
var repository = scope.ServiceProvider.GetRequiredService<INotificationOutboxRepository>();
|
|
var notification = await repository.GetByIdAsync(request.NotificationId);
|
|
|
|
if (notification is null)
|
|
{
|
|
return new NotificationDetailResponse(
|
|
request.CorrelationId, Success: false,
|
|
ErrorMessage: "notification not found", Detail: null);
|
|
}
|
|
|
|
var detail = new NotificationDetail(
|
|
notification.NotificationId,
|
|
notification.Type.ToString(),
|
|
notification.ListName,
|
|
notification.Subject,
|
|
notification.Body,
|
|
notification.Status.ToString(),
|
|
notification.RetryCount,
|
|
notification.LastError,
|
|
notification.ResolvedTargets,
|
|
notification.TypeData,
|
|
notification.SourceSiteId,
|
|
notification.SourceInstanceId,
|
|
notification.SourceScript,
|
|
notification.SiteEnqueuedAt,
|
|
notification.CreatedAt,
|
|
notification.LastAttemptAt,
|
|
notification.NextAttemptAt,
|
|
notification.DeliveredAt,
|
|
notification.SourceNode);
|
|
|
|
return new NotificationDetailResponse(
|
|
request.CorrelationId, Success: true, ErrorMessage: null, detail);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles a manual retry request. Only a <c>Parked</c> notification can be retried;
|
|
/// it is reset to <c>Pending</c> with a cleared retry count, next-attempt time, and
|
|
/// last error so the dispatch loop re-claims it on the next sweep.
|
|
/// </summary>
|
|
private void HandleRetry(RetryNotificationRequest request)
|
|
{
|
|
var sender = Sender;
|
|
|
|
RetryAsync(request).PipeTo(
|
|
sender,
|
|
success: response => response,
|
|
failure: ex => new RetryNotificationResponse(
|
|
request.CorrelationId, Success: false, ErrorMessage: ex.GetBaseException().Message));
|
|
}
|
|
|
|
private async Task<RetryNotificationResponse> RetryAsync(RetryNotificationRequest request)
|
|
{
|
|
using var scope = _serviceProvider.CreateScope();
|
|
var repository = scope.ServiceProvider.GetRequiredService<INotificationOutboxRepository>();
|
|
var notification = await repository.GetByIdAsync(request.NotificationId);
|
|
|
|
if (notification is null)
|
|
{
|
|
return new RetryNotificationResponse(
|
|
request.CorrelationId, Success: false, ErrorMessage: "notification not found");
|
|
}
|
|
|
|
if (notification.Status != NotificationStatus.Parked)
|
|
{
|
|
return new RetryNotificationResponse(
|
|
request.CorrelationId, Success: false,
|
|
ErrorMessage: "only parked notifications can be retried");
|
|
}
|
|
|
|
notification.Status = NotificationStatus.Pending;
|
|
notification.RetryCount = 0;
|
|
notification.NextAttemptAt = null;
|
|
notification.LastError = null;
|
|
|
|
// Zero rows updated means the row was purged between the read above and
|
|
// this write. Answer the operator honestly instead of reporting a
|
|
// re-queue that never happened — and emit no un-park audit row, because
|
|
// there is nothing to attribute it to.
|
|
if (!await repository.UpdateAsync(notification))
|
|
{
|
|
return new RetryNotificationResponse(
|
|
request.CorrelationId, Success: false, ErrorMessage: "notification not found");
|
|
}
|
|
|
|
// Operator re-queued a parked notification. Emit a Submitted NotifyDeliver
|
|
// row attributing the un-park to the operator — otherwise the lifecycle
|
|
// reads Parked → Attempted → Delivered with no record of who un-parked it.
|
|
// Best-effort: audit failure never aborts the retry.
|
|
await EmitRetrySubmittedAuditAsync(notification, DateTimeOffset.UtcNow, request.RequestedBy);
|
|
|
|
return new RetryNotificationResponse(request.CorrelationId, Success: true, ErrorMessage: null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Emits a single <see cref="AuditKind.NotifyDeliver"/> row with
|
|
/// <see cref="AuditStatus.Submitted"/> recording that <paramref name="actorOverride"/>
|
|
/// re-queued a parked notification. Best-effort with the same
|
|
/// audit-never-affects-the-action shape as <see cref="EmitTerminalAuditAsync"/>.
|
|
/// </summary>
|
|
private async Task EmitRetrySubmittedAuditAsync(
|
|
Notification notification,
|
|
DateTimeOffset now,
|
|
string? actorOverride)
|
|
{
|
|
try
|
|
{
|
|
var evt = BuildNotifyDeliverEvent(
|
|
notification, now, AuditStatus.Submitted, errorMessage: null, actorOverride: actorOverride);
|
|
await _auditWriter.WriteAsync(evt);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(
|
|
ex,
|
|
"Failed to emit Submitted (operator retry) audit row for notification {NotificationId}.",
|
|
notification.NotificationId);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles a manual discard request. Only a <c>Parked</c> notification can be discarded;
|
|
/// it is moved to the terminal <c>Discarded</c> status.
|
|
/// </summary>
|
|
private void HandleDiscard(DiscardNotificationRequest request)
|
|
{
|
|
var sender = Sender;
|
|
|
|
DiscardAsync(request).PipeTo(
|
|
sender,
|
|
success: response => response,
|
|
failure: ex => new DiscardNotificationResponse(
|
|
request.CorrelationId, Success: false, ErrorMessage: ex.GetBaseException().Message));
|
|
}
|
|
|
|
private async Task<DiscardNotificationResponse> DiscardAsync(DiscardNotificationRequest request)
|
|
{
|
|
using var scope = _serviceProvider.CreateScope();
|
|
var repository = scope.ServiceProvider.GetRequiredService<INotificationOutboxRepository>();
|
|
var notification = await repository.GetByIdAsync(request.NotificationId);
|
|
|
|
if (notification is null)
|
|
{
|
|
return new DiscardNotificationResponse(
|
|
request.CorrelationId, Success: false, ErrorMessage: "notification not found");
|
|
}
|
|
|
|
if (notification.Status != NotificationStatus.Parked)
|
|
{
|
|
return new DiscardNotificationResponse(
|
|
request.CorrelationId, Success: false,
|
|
ErrorMessage: "only parked notifications can be discarded");
|
|
}
|
|
|
|
notification.Status = NotificationStatus.Discarded;
|
|
|
|
// Same vanished-row honesty as the retry path: a purge between the read
|
|
// and the write leaves nothing to discard, so report not-found rather
|
|
// than success (and skip the terminal audit row below).
|
|
if (!await repository.UpdateAsync(notification))
|
|
{
|
|
return new DiscardNotificationResponse(
|
|
request.CorrelationId, Success: false, ErrorMessage: "notification not found");
|
|
}
|
|
|
|
// A manual discard is the OTHER code path that produces
|
|
// a terminal NotificationStatus transition (alongside the dispatcher).
|
|
// Emit a Discarded NotifyDeliver row to match the dispatcher's
|
|
// Delivered/Parked emissions; the row carries no error message because
|
|
// the discard is an operator-driven cancellation, not a delivery error.
|
|
// The operator identity is stamped as Actor so the cancellation is
|
|
// attributable.
|
|
await EmitTerminalAuditAsync(
|
|
notification, DateTimeOffset.UtcNow, errorMessage: null, actorOverride: request.RequestedBy);
|
|
|
|
return new DiscardNotificationResponse(request.CorrelationId, Success: true, ErrorMessage: null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles a KPI snapshot request, computing the outbox metrics with the stuck cutoff
|
|
/// derived from <see cref="NotificationOutboxOptions.StuckAgeThreshold"/> and the
|
|
/// delivered window from <see cref="NotificationOutboxOptions.DeliveredKpiWindow"/>.
|
|
/// </summary>
|
|
private void HandleKpiRequest(NotificationKpiRequest request)
|
|
{
|
|
var sender = Sender;
|
|
var now = DateTimeOffset.UtcNow;
|
|
var stuckCutoff = StuckCutoff(now);
|
|
var deliveredSince = now - _options.DeliveredKpiWindow;
|
|
|
|
ComputeKpisAsync(request.CorrelationId, stuckCutoff, deliveredSince).PipeTo(
|
|
sender,
|
|
success: response => response,
|
|
failure: ex => new NotificationKpiResponse(
|
|
request.CorrelationId,
|
|
Success: false,
|
|
ErrorMessage: ex.GetBaseException().Message,
|
|
QueueDepth: 0,
|
|
StuckCount: 0,
|
|
ParkedCount: 0,
|
|
DeliveredLastInterval: 0,
|
|
OldestPendingAge: null));
|
|
}
|
|
|
|
private async Task<NotificationKpiResponse> ComputeKpisAsync(
|
|
string correlationId, DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince)
|
|
{
|
|
using var scope = _serviceProvider.CreateScope();
|
|
var repository = scope.ServiceProvider.GetRequiredService<INotificationOutboxRepository>();
|
|
var snapshot = await repository.ComputeKpisAsync(stuckCutoff, deliveredSince);
|
|
|
|
return new NotificationKpiResponse(
|
|
correlationId,
|
|
Success: true,
|
|
ErrorMessage: null,
|
|
snapshot.QueueDepth,
|
|
snapshot.StuckCount,
|
|
snapshot.ParkedCount,
|
|
snapshot.DeliveredLastInterval,
|
|
snapshot.OldestPendingAge);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles a per-site KPI request, computing the per-source-site outbox metrics with the
|
|
/// same stuck cutoff and delivered window as <see cref="HandleKpiRequest"/>.
|
|
/// </summary>
|
|
private void HandlePerSiteKpiRequest(PerSiteNotificationKpiRequest request)
|
|
{
|
|
var sender = Sender;
|
|
var now = DateTimeOffset.UtcNow;
|
|
var stuckCutoff = StuckCutoff(now);
|
|
var deliveredSince = now - _options.DeliveredKpiWindow;
|
|
|
|
ComputePerSiteKpisAsync(request.CorrelationId, stuckCutoff, deliveredSince).PipeTo(
|
|
sender,
|
|
success: response => response,
|
|
failure: ex => new PerSiteNotificationKpiResponse(
|
|
request.CorrelationId,
|
|
Success: false,
|
|
ErrorMessage: ex.GetBaseException().Message,
|
|
Sites: Array.Empty<SiteNotificationKpiSnapshot>()));
|
|
}
|
|
|
|
private async Task<PerSiteNotificationKpiResponse> ComputePerSiteKpisAsync(
|
|
string correlationId, DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince)
|
|
{
|
|
using var scope = _serviceProvider.CreateScope();
|
|
var repository = scope.ServiceProvider.GetRequiredService<INotificationOutboxRepository>();
|
|
var sites = await repository.ComputePerSiteKpisAsync(stuckCutoff, deliveredSince);
|
|
|
|
return new PerSiteNotificationKpiResponse(correlationId, Success: true, ErrorMessage: null, sites);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles a per-node KPI request, computing the per-source-node outbox metrics with the
|
|
/// same stuck cutoff and delivered window as <see cref="HandleKpiRequest"/>. Additive
|
|
/// alongside <see cref="HandlePerSiteKpiRequest"/> — does not change per-site behaviour.
|
|
/// </summary>
|
|
private void HandlePerNodeKpiRequest(PerNodeNotificationKpiRequest request)
|
|
{
|
|
var sender = Sender;
|
|
var now = DateTimeOffset.UtcNow;
|
|
var stuckCutoff = StuckCutoff(now);
|
|
var deliveredSince = now - _options.DeliveredKpiWindow;
|
|
|
|
ComputePerNodeKpisAsync(request.CorrelationId, stuckCutoff, deliveredSince).PipeTo(
|
|
sender,
|
|
success: response => response,
|
|
failure: ex => new PerNodeNotificationKpiResponse(
|
|
request.CorrelationId,
|
|
Success: false,
|
|
ErrorMessage: ex.GetBaseException().Message,
|
|
Nodes: Array.Empty<NodeNotificationKpiSnapshot>()));
|
|
}
|
|
|
|
private async Task<PerNodeNotificationKpiResponse> ComputePerNodeKpisAsync(
|
|
string correlationId, DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince)
|
|
{
|
|
using var scope = _serviceProvider.CreateScope();
|
|
var repository = scope.ServiceProvider.GetRequiredService<INotificationOutboxRepository>();
|
|
var nodes = await repository.ComputePerNodeKpisAsync(stuckCutoff, deliveredSince);
|
|
|
|
return new PerNodeNotificationKpiResponse(correlationId, Success: true, ErrorMessage: null, nodes);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The instant before which a still-pending notification counts as stuck — <paramref name="now"/>
|
|
/// offset back by <see cref="NotificationOutboxOptions.StuckAgeThreshold"/>.
|
|
/// </summary>
|
|
private DateTimeOffset StuckCutoff(DateTimeOffset now) => now - _options.StuckAgeThreshold;
|
|
|
|
/// <summary>
|
|
/// A notification counts as stuck when it is still in a non-terminal status
|
|
/// (<c>Pending</c> or <c>Retrying</c>) and was created before the supplied cutoff.
|
|
/// </summary>
|
|
private static bool IsStuck(Notification notification, DateTimeOffset stuckCutoff)
|
|
{
|
|
return notification.Status is NotificationStatus.Pending or NotificationStatus.Retrying
|
|
&& notification.CreatedAt < stuckCutoff;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses a string filter value to a nullable enum, ignoring case. An empty, whitespace,
|
|
/// or unrecognised value yields <c>null</c> — meaning "no constraint on that dimension".
|
|
/// </summary>
|
|
private static TEnum? ParseEnum<TEnum>(string? value) where TEnum : struct, Enum
|
|
{
|
|
return Enum.TryParse<TEnum>(value, ignoreCase: true, out var parsed) ? parsed : null;
|
|
}
|
|
|
|
private static Notification BuildNotification(NotificationSubmit msg, NotificationType type)
|
|
{
|
|
// The delivery channel is taken from the target list's Type (resolved by the
|
|
// caller); it defaults to Email when the list cannot be resolved.
|
|
return new Notification(
|
|
msg.NotificationId,
|
|
type,
|
|
msg.ListName,
|
|
msg.Subject,
|
|
msg.Body,
|
|
msg.SourceSiteId)
|
|
{
|
|
SourceInstanceId = msg.SourceInstanceId,
|
|
SourceScript = msg.SourceScript,
|
|
// SourceNode: the cluster node on which the
|
|
// notification was emitted (node-a/node-b for site rows). Stamped by the
|
|
// emitting site from INodeIdentityProvider and carried, inside the
|
|
// serialized payload, through the S&F buffer to central. EF tracked-entity
|
|
// insert flows it through to the Notifications.SourceNode column. Null on
|
|
// submissions buffered before the field existed.
|
|
SourceNode = msg.SourceNode,
|
|
// OriginExecutionId: the originating script execution's id,
|
|
// carried from the site so the dispatcher can echo it onto NotifyDeliver rows.
|
|
OriginExecutionId = msg.OriginExecutionId,
|
|
// OriginParentExecutionId: the originating routed run's parent
|
|
// ExecutionId, carried from the site so the dispatcher can echo it onto
|
|
// NotifyDeliver rows.
|
|
OriginParentExecutionId = msg.OriginParentExecutionId,
|
|
SiteEnqueuedAt = msg.SiteEnqueuedAt,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
// Status stays at its Pending default for the dispatch sweep to claim.
|
|
};
|
|
}
|
|
}
|