diff --git a/src/ScadaLink.NotificationOutbox/Delivery/DeliveryOutcome.cs b/src/ScadaLink.NotificationOutbox/Delivery/DeliveryOutcome.cs new file mode 100644 index 0000000..772e3b3 --- /dev/null +++ b/src/ScadaLink.NotificationOutbox/Delivery/DeliveryOutcome.cs @@ -0,0 +1,40 @@ +namespace ScadaLink.NotificationOutbox.Delivery; + +/// +/// Classification of a single delivery attempt. Transient failures are eligible for +/// retry; permanent failures are terminal and not retried. +/// +public enum DeliveryResult +{ + /// The notification was delivered successfully. + Success, + + /// Delivery failed for a transient reason and may succeed on retry. + TransientFailure, + + /// Delivery failed for a permanent reason and must not be retried. + PermanentFailure +} + +/// +/// Result of a delivery attempt produced by an . +/// +/// The classification of the attempt. +/// +/// The concrete delivery targets used, snapshotted for audit. Set only on success. +/// +/// A human-readable failure description. Set only on failure. +public record DeliveryOutcome(DeliveryResult Result, string? ResolvedTargets, string? Error) +{ + /// Creates a successful outcome carrying the resolved delivery targets. + public static DeliveryOutcome Success(string resolvedTargets) => + new(DeliveryResult.Success, resolvedTargets, null); + + /// Creates a transient-failure outcome carrying an error description. + public static DeliveryOutcome Transient(string error) => + new(DeliveryResult.TransientFailure, null, error); + + /// Creates a permanent-failure outcome carrying an error description. + public static DeliveryOutcome Permanent(string error) => + new(DeliveryResult.PermanentFailure, null, error); +} diff --git a/src/ScadaLink.NotificationOutbox/Delivery/INotificationDeliveryAdapter.cs b/src/ScadaLink.NotificationOutbox/Delivery/INotificationDeliveryAdapter.cs new file mode 100644 index 0000000..54ced60 --- /dev/null +++ b/src/ScadaLink.NotificationOutbox/Delivery/INotificationDeliveryAdapter.cs @@ -0,0 +1,23 @@ +using ScadaLink.Commons.Entities.Notifications; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.NotificationOutbox.Delivery; + +/// +/// Channel-specific delivery strategy for outbox notifications. Each adapter handles +/// a single ; the outbox dispatcher selects the adapter +/// matching a notification's type. +/// +public interface INotificationDeliveryAdapter +{ + /// The notification channel this adapter delivers. + NotificationType Type { get; } + + /// + /// Attempts delivery of the given notification and reports the classified outcome. + /// + /// The notification to deliver. + /// Token used to cancel the delivery attempt. + /// The outcome of the delivery attempt. + Task DeliverAsync(Notification notification, CancellationToken cancellationToken = default); +} diff --git a/src/ScadaLink.NotificationOutbox/NotificationOutboxOptions.cs b/src/ScadaLink.NotificationOutbox/NotificationOutboxOptions.cs new file mode 100644 index 0000000..2e69d04 --- /dev/null +++ b/src/ScadaLink.NotificationOutbox/NotificationOutboxOptions.cs @@ -0,0 +1,26 @@ +namespace ScadaLink.NotificationOutbox; + +/// +/// Configuration options for the Notification Outbox component: dispatch cadence, +/// batch sizing, stuck-message detection, terminal retention, and KPI windowing. +/// +public class NotificationOutboxOptions +{ + /// Interval between dispatch sweeps that pick up pending notifications for delivery. + public TimeSpan DispatchInterval { get; set; } = TimeSpan.FromSeconds(10); + + /// Maximum number of notifications claimed for delivery in a single dispatch sweep. + public int DispatchBatchSize { get; set; } = 100; + + /// Age past which an in-progress notification is considered stuck and re-claimed. + public TimeSpan StuckAgeThreshold { get; set; } = TimeSpan.FromMinutes(10); + + /// Retention period for notifications in a terminal state before they are purged. + public TimeSpan TerminalRetention { get; set; } = TimeSpan.FromDays(365); + + /// Interval between background purge sweeps of terminal notifications. + public TimeSpan PurgeInterval { get; set; } = TimeSpan.FromDays(1); + + /// Trailing window used to compute the delivered-notifications throughput KPI. + public TimeSpan DeliveredKpiWindow { get; set; } = TimeSpan.FromMinutes(1); +} diff --git a/tests/ScadaLink.NotificationOutbox.Tests/Delivery/DeliveryOutcomeTests.cs b/tests/ScadaLink.NotificationOutbox.Tests/Delivery/DeliveryOutcomeTests.cs new file mode 100644 index 0000000..3a9d826 --- /dev/null +++ b/tests/ScadaLink.NotificationOutbox.Tests/Delivery/DeliveryOutcomeTests.cs @@ -0,0 +1,36 @@ +using ScadaLink.NotificationOutbox.Delivery; + +namespace ScadaLink.NotificationOutbox.Tests.Delivery; + +public class DeliveryOutcomeTests +{ + [Fact] + public void Success_SetsResolvedTargets_AndNoError() + { + var outcome = DeliveryOutcome.Success("targets"); + + Assert.Equal(DeliveryResult.Success, outcome.Result); + Assert.Equal("targets", outcome.ResolvedTargets); + Assert.Null(outcome.Error); + } + + [Fact] + public void Transient_SetsError_AndNoResolvedTargets() + { + var outcome = DeliveryOutcome.Transient("e"); + + Assert.Equal(DeliveryResult.TransientFailure, outcome.Result); + Assert.Equal("e", outcome.Error); + Assert.Null(outcome.ResolvedTargets); + } + + [Fact] + public void Permanent_SetsError_AndNoResolvedTargets() + { + var outcome = DeliveryOutcome.Permanent("e"); + + Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result); + Assert.Equal("e", outcome.Error); + Assert.Null(outcome.ResolvedTargets); + } +} diff --git a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxOptionsTests.cs b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxOptionsTests.cs new file mode 100644 index 0000000..c9d0cf6 --- /dev/null +++ b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxOptionsTests.cs @@ -0,0 +1,17 @@ +namespace ScadaLink.NotificationOutbox.Tests; + +public class NotificationOutboxOptionsTests +{ + [Fact] + public void Defaults_AreExpectedValues() + { + var options = new NotificationOutboxOptions(); + + Assert.Equal(TimeSpan.FromSeconds(10), options.DispatchInterval); + Assert.Equal(100, options.DispatchBatchSize); + Assert.Equal(TimeSpan.FromMinutes(10), options.StuckAgeThreshold); + Assert.Equal(TimeSpan.FromDays(365), options.TerminalRetention); + Assert.Equal(TimeSpan.FromDays(1), options.PurgeInterval); + Assert.Equal(TimeSpan.FromMinutes(1), options.DeliveredKpiWindow); + } +}