feat(notifications): SMS notifications retry under SmsConfiguration.MaxRetries/RetryDelay (SMTP policy remains the Email + fallback policy) — wires the dead fields
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
@@ -162,7 +162,7 @@ Other peers in the `scadaproj` family (see `scadaproj/CLAUDE.md` for details): `
|
||||
- Dispatcher loop polls due rows, resolves the list, delivers via the typed adapter; transient failures retry to `Parked`, permanent failures park immediately.
|
||||
- Site→central handoff is at-least-once: ack-after-persist plus insert-if-not-exists on `NotificationId`.
|
||||
- No Akka replication — MS SQL is the HA store; daily purge of terminal rows after a configurable window (default 365 days).
|
||||
- Notification Outbox retry reuses central SMTP max-retry-count and fixed interval.
|
||||
- Notification Outbox retry reuses central SMTP max-retry-count and fixed interval for Email; Sms uses SmsConfiguration.MaxRetries/RetryDelay, falling back to the SMTP policy when unset.
|
||||
- Cached calls (`ExternalSystem.CachedCall`, `Database.CachedWrite`) return a `TrackedOperationId` tracking handle, unified with `Notify.Send`'s existing tracking model (`Notify.Status` retained as a thin alias).
|
||||
- A site-local operation tracking table (SQLite, alongside the S&F buffer) is the source of truth for cached-call status; `Tracking.Status(id)` reads it site-locally and authoritatively; terminal rows purged after a configurable window (default 7 days).
|
||||
- Unified tracking status lifecycle `Pending → Retrying → Delivered / Parked / Failed / Discarded`; `Failed` = permanent failure (also returned synchronously to the calling script). No `Forwarding` state for cached calls.
|
||||
|
||||
@@ -62,7 +62,7 @@ The `SmsConfiguration` entity is defined centrally and used by the central SMS d
|
||||
- **Messaging Service SID** (optional): Twilio Messaging Service SID. When present, Twilio uses it for sender selection; used instead of the From number (so a Messaging-Service-only config needs no From number).
|
||||
- **API base URL** (optional): Override for the Twilio REST API base URL (default: `https://api.twilio.com`). Allows pointing at a test/stub handler or a regional endpoint.
|
||||
- **Connection timeout**: Maximum time to wait for a Twilio API response (honored per-send by the delivery adapter).
|
||||
- **Max retries** / **Retry delay**: Reserved. The Notification Outbox dispatcher currently derives the retry policy (max-retries + interval) from the central SMTP configuration for all notification types; these per-SMS values are persisted and transported for forward-compatibility but are not yet read at dispatch time. Honoring them per-type is a deferred enhancement.
|
||||
- **Max retries** / **Retry delay**: Live for SMS. The Notification Outbox dispatcher selects the retry policy by notification type — SMS notifications retry under these `SmsConfiguration` values, while Email (and every other type) reuses the central SMTP policy. When no SMS configuration row exists the dispatcher falls back to the SMTP policy, preserving the historical behavior until SMS is configured. A non-positive value is clamped to the outbox fallback (10 retries / 1-minute delay) with a Warning, matching the SMTP-policy clamp, so a misconfiguration cannot silently park SMS on the first transient failure.
|
||||
|
||||
The `SmsConfiguration` entity travels in Transport bundles — the Auth Token rides the encrypted `SecretsBlock` (keyed by Account SID), consistent with how SMTP credentials are bundled.
|
||||
|
||||
|
||||
@@ -28,19 +28,21 @@ public class SmsConfiguration
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of delivery retries before parking.
|
||||
/// <para>
|
||||
/// RESERVED: the Notification Outbox dispatcher currently derives the retry policy
|
||||
/// (max-retries + interval) from the central SMTP configuration for <em>all</em>
|
||||
/// notification types — see the "retry reuses central SMTP max-retry-count and fixed
|
||||
/// interval" design decision and <c>NotificationOutboxActor.ResolveRetryPolicyAsync</c>.
|
||||
/// This per-SMS value is persisted/transported for forward-compatibility but is NOT
|
||||
/// yet read at dispatch time. Honoring it per-type is a deferred enhancement (it would
|
||||
/// supersede the shared-SMTP-policy decision and is not a silent behavioral change).
|
||||
/// LIVE for SMS: the Notification Outbox dispatcher selects the retry policy by
|
||||
/// notification <c>Type</c> — SMS notifications retry under this value (and
|
||||
/// <see cref="RetryDelay"/>), while Email (and every other type) uses the central SMTP
|
||||
/// policy. When no SMS configuration row exists the dispatcher falls back to the SMTP
|
||||
/// policy. A non-positive value is clamped to the outbox fallback (10) with a Warning so
|
||||
/// a misconfiguration cannot silently park SMS on the first transient failure. See
|
||||
/// <c>NotificationOutboxActor.ResolveRetryPoliciesAsync</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public int MaxRetries { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the delay between retry attempts. RESERVED — see <see cref="MaxRetries"/>:
|
||||
/// the dispatcher currently uses the shared SMTP-derived retry interval for all types.
|
||||
/// Gets or sets the delay between retry attempts. LIVE for SMS — see
|
||||
/// <see cref="MaxRetries"/>: the dispatcher schedules the next SMS attempt with this delay
|
||||
/// (clamped to the 1-minute fallback if non-positive), falling back to the SMTP interval
|
||||
/// when no SMS configuration exists.
|
||||
/// </summary>
|
||||
public TimeSpan RetryDelay { get; set; }
|
||||
|
||||
|
||||
@@ -316,7 +316,7 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
||||
return;
|
||||
}
|
||||
|
||||
var (maxRetries, retryDelay) = await ResolveRetryPolicyAsync(notificationRepository);
|
||||
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
|
||||
@@ -333,7 +333,7 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
||||
foreach (var notification in due)
|
||||
{
|
||||
deliveries.Add(DeliverGatedAsync(
|
||||
notification, now, maxRetries, retryDelay, adapters, deliveryGate, cancellationToken));
|
||||
notification, now, policies, adapters, deliveryGate, cancellationToken));
|
||||
}
|
||||
|
||||
// Each DeliverGatedAsync isolates its own faults, so WhenAll never throws.
|
||||
@@ -358,8 +358,7 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
||||
private async Task DeliverGatedAsync(
|
||||
Notification notification,
|
||||
DateTimeOffset now,
|
||||
int maxRetries,
|
||||
TimeSpan retryDelay,
|
||||
RetryPolicies policies,
|
||||
IReadOnlyDictionary<NotificationType, INotificationDeliveryAdapter> adapters,
|
||||
SemaphoreSlim gate,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -388,7 +387,7 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var outboxRepository = scope.ServiceProvider.GetRequiredService<INotificationOutboxRepository>();
|
||||
await DeliverOneAsync(
|
||||
notification, now, maxRetries, retryDelay, outboxRepository, adapters, cancellationToken)
|
||||
notification, now, policies, outboxRepository, adapters, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
@@ -409,52 +408,107 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the retry policy from the first SMTP configuration row. When no SMTP
|
||||
/// configuration exists, falls back to a conservative default — delivery itself will
|
||||
/// permanently fail in that case, so the policy only acts as a guard.
|
||||
/// 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>
|
||||
/// <remarks>
|
||||
/// A non-positive <see cref="SmtpConfiguration.MaxRetries"/> (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. Both values are clamped to the fallback constants
|
||||
/// with a Warning so an operator can spot the misconfiguration in logs.
|
||||
/// </remarks>
|
||||
private async Task<(int MaxRetries, TimeSpan RetryDelay)> ResolveRetryPolicyAsync(
|
||||
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 first 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();
|
||||
var configuration = configurations.Count > 0 ? configurations[0] : null;
|
||||
if (configuration is null)
|
||||
{
|
||||
return (FallbackMaxRetries, FallbackRetryDelay);
|
||||
return new RetryPolicy(FallbackMaxRetries, FallbackRetryDelay);
|
||||
}
|
||||
|
||||
var maxRetries = configuration.MaxRetries;
|
||||
var retryDelay = configuration.RetryDelay;
|
||||
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(
|
||||
"SmtpConfiguration.MaxRetries={ConfiguredMaxRetries} is non-positive; " +
|
||||
"{ConfigName}.MaxRetries={ConfiguredMaxRetries} is non-positive; " +
|
||||
"clamping to FallbackMaxRetries={FallbackMaxRetries} so transient failures " +
|
||||
"actually retry before parking.",
|
||||
maxRetries, FallbackMaxRetries);
|
||||
configName, maxRetries, FallbackMaxRetries);
|
||||
maxRetries = FallbackMaxRetries;
|
||||
}
|
||||
|
||||
if (retryDelay <= TimeSpan.Zero)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"SmtpConfiguration.RetryDelay={ConfiguredRetryDelay} is non-positive; " +
|
||||
"{ConfigName}.RetryDelay={ConfiguredRetryDelay} is non-positive; " +
|
||||
"clamping to FallbackRetryDelay={FallbackRetryDelay} so the dispatcher does " +
|
||||
"not burn-loop on transient failures.",
|
||||
retryDelay, FallbackRetryDelay);
|
||||
configName, retryDelay, FallbackRetryDelay);
|
||||
retryDelay = FallbackRetryDelay;
|
||||
}
|
||||
|
||||
return (maxRetries, retryDelay);
|
||||
return new RetryPolicy(maxRetries, retryDelay);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -519,12 +573,17 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
||||
private async Task DeliverOneAsync(
|
||||
Notification notification,
|
||||
DateTimeOffset now,
|
||||
int maxRetries,
|
||||
TimeSpan retryDelay,
|
||||
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
|
||||
|
||||
+74
@@ -684,4 +684,78 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
||||
// Strictly sequential: never more than one delivery in flight at once.
|
||||
Assert.Equal(1, adapter.HighWater);
|
||||
}
|
||||
|
||||
// ── Task 18: per-type retry policy (SMS honors SmsConfiguration) ──────────
|
||||
|
||||
private void SetupSmsRetryPolicy(int maxRetries, TimeSpan retryDelay)
|
||||
{
|
||||
var config = new SmsConfiguration("ACtestaccountsid", "+15550000000")
|
||||
{
|
||||
MaxRetries = maxRetries,
|
||||
RetryDelay = retryDelay,
|
||||
};
|
||||
_notificationRepository.GetSmsConfigurationAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(config);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SmsNotification_TransientFailure_UsesSmsRetryPolicy()
|
||||
{
|
||||
// Task 18: an SMS-typed notification resolves its retry policy from the SMS
|
||||
// configuration, NOT the shared SMTP policy. The SMTP policy (MaxRetries=10)
|
||||
// would keep this row Retrying; the SMS policy (MaxRetries=1) parks it after
|
||||
// this attempt (RetryCount 1 -> 2 >= 1). A pre-fix dispatcher (SMTP-for-all)
|
||||
// would leave it Retrying with RetryCount=2 — the assertion below fails.
|
||||
SetupSmtpRetryPolicy(maxRetries: 10, retryDelay: TimeSpan.FromMinutes(1));
|
||||
SetupSmsRetryPolicy(maxRetries: 1, retryDelay: TimeSpan.FromMinutes(5));
|
||||
var notification = MakeNotification(type: NotificationType.Sms, retryCount: 1);
|
||||
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new[] { notification });
|
||||
var adapter = new StubAdapter(() => DeliveryOutcome.Transient("twilio 503"),
|
||||
type: NotificationType.Sms);
|
||||
var actor = CreateActor([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
_outboxRepository.Received(1).UpdateAsync(
|
||||
Arg.Is<Notification>(n =>
|
||||
n.Status == NotificationStatus.Parked &&
|
||||
n.RetryCount == 2 &&
|
||||
n.LastError == "twilio 503"),
|
||||
Arg.Any<CancellationToken>());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmailNotification_StillUsesSmtpPolicy()
|
||||
{
|
||||
// Task 18: an Email row continues to resolve against the SMTP policy even when a
|
||||
// (tighter) SMS policy is present. SMTP MaxRetries=10 keeps it Retrying
|
||||
// (RetryCount 1 -> 2 < 10) and schedules the next attempt with the SMTP delay.
|
||||
SetupSmtpRetryPolicy(maxRetries: 10, retryDelay: TimeSpan.FromMinutes(3));
|
||||
SetupSmsRetryPolicy(maxRetries: 1, retryDelay: TimeSpan.FromMinutes(5));
|
||||
var before = DateTimeOffset.UtcNow;
|
||||
var notification = MakeNotification(type: NotificationType.Email, retryCount: 1);
|
||||
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new[] { notification });
|
||||
var adapter = new StubAdapter(() => DeliveryOutcome.Transient("smtp timeout"),
|
||||
type: NotificationType.Email);
|
||||
var actor = CreateActor([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
_outboxRepository.Received(1).UpdateAsync(
|
||||
Arg.Is<Notification>(n =>
|
||||
n.Status == NotificationStatus.Retrying &&
|
||||
n.RetryCount == 2 &&
|
||||
n.NextAttemptAt != null &&
|
||||
n.NextAttemptAt > before + TimeSpan.FromMinutes(2) &&
|
||||
n.LastError == "smtp timeout"),
|
||||
Arg.Any<CancellationToken>());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user