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:
Joseph Doherty
2026-07-10 04:07:03 -04:00
parent f0dd016207
commit 178ae35308
5 changed files with 173 additions and 38 deletions
+1 -1
View File
@@ -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. - 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`. - 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). - 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). - 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). - 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. - 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). - **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. - **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). - **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. 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> /// <summary>
/// Gets or sets the maximum number of delivery retries before parking. /// Gets or sets the maximum number of delivery retries before parking.
/// <para> /// <para>
/// RESERVED: the Notification Outbox dispatcher currently derives the retry policy /// LIVE for SMS: the Notification Outbox dispatcher selects the retry policy by
/// (max-retries + interval) from the central SMTP configuration for <em>all</em> /// notification <c>Type</c> — SMS notifications retry under this value (and
/// notification types — see the "retry reuses central SMTP max-retry-count and fixed /// <see cref="RetryDelay"/>), while Email (and every other type) uses the central SMTP
/// interval" design decision and <c>NotificationOutboxActor.ResolveRetryPolicyAsync</c>. /// policy. When no SMS configuration row exists the dispatcher falls back to the SMTP
/// This per-SMS value is persisted/transported for forward-compatibility but is NOT /// policy. A non-positive value is clamped to the outbox fallback (10) with a Warning so
/// yet read at dispatch time. Honoring it per-type is a deferred enhancement (it would /// a misconfiguration cannot silently park SMS on the first transient failure. See
/// supersede the shared-SMTP-policy decision and is not a silent behavioral change). /// <c>NotificationOutboxActor.ResolveRetryPoliciesAsync</c>.
/// </para> /// </para>
/// </summary> /// </summary>
public int MaxRetries { get; set; } public int MaxRetries { get; set; }
/// <summary> /// <summary>
/// Gets or sets the delay between retry attempts. RESERVED — see <see cref="MaxRetries"/>: /// Gets or sets the delay between retry attempts. LIVE for SMS — see
/// the dispatcher currently uses the shared SMTP-derived retry interval for all types. /// <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> /// </summary>
public TimeSpan RetryDelay { get; set; } public TimeSpan RetryDelay { get; set; }
@@ -316,7 +316,7 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
return; return;
} }
var (maxRetries, retryDelay) = await ResolveRetryPolicyAsync(notificationRepository); var policies = await ResolveRetryPoliciesAsync(notificationRepository);
// Deliver the claimed batch with bounded parallelism. Delivery is I/O-bound // Deliver the claimed batch with bounded parallelism. Delivery is I/O-bound
// (multi-second SMTP/Twilio round trips), so a strictly sequential sweep caps // (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) foreach (var notification in due)
{ {
deliveries.Add(DeliverGatedAsync( 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. // Each DeliverGatedAsync isolates its own faults, so WhenAll never throws.
@@ -358,8 +358,7 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
private async Task DeliverGatedAsync( private async Task DeliverGatedAsync(
Notification notification, Notification notification,
DateTimeOffset now, DateTimeOffset now,
int maxRetries, RetryPolicies policies,
TimeSpan retryDelay,
IReadOnlyDictionary<NotificationType, INotificationDeliveryAdapter> adapters, IReadOnlyDictionary<NotificationType, INotificationDeliveryAdapter> adapters,
SemaphoreSlim gate, SemaphoreSlim gate,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -388,7 +387,7 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
using var scope = _serviceProvider.CreateScope(); using var scope = _serviceProvider.CreateScope();
var outboxRepository = scope.ServiceProvider.GetRequiredService<INotificationOutboxRepository>(); var outboxRepository = scope.ServiceProvider.GetRequiredService<INotificationOutboxRepository>();
await DeliverOneAsync( await DeliverOneAsync(
notification, now, maxRetries, retryDelay, outboxRepository, adapters, cancellationToken) notification, now, policies, outboxRepository, adapters, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
@@ -409,52 +408,107 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
} }
/// <summary> /// <summary>
/// Resolves the retry policy from the first SMTP configuration row. When no SMTP /// A single channel's transient-failure retry policy: the max attempt count before a
/// configuration exists, falls back to a conservative default — delivery itself will /// row parks and the fixed delay between attempts. Resolved once per sweep and selected
/// permanently fail in that case, so the policy only acts as a guard. /// per notification by <see cref="NotificationType"/> in <see cref="DeliverOneAsync"/>.
/// </summary> /// </summary>
/// <remarks> private readonly record struct RetryPolicy(int MaxRetries, TimeSpan RetryDelay);
/// A non-positive <see cref="SmtpConfiguration.MaxRetries"/> (zero or negative)
/// would otherwise satisfy <c>RetryCount >= maxRetries</c> on the very first transient /// <summary>
/// failure and park the row without a single retry — silently halving the outbox's /// The per-type retry policies resolved once per dispatch sweep: <see cref="SmtpPolicy"/>
/// delivery guarantees. The same applies to a non-positive <c>RetryDelay</c>, which /// drives Email (and every non-SMS type), <see cref="SmsPolicy"/> drives SMS.
/// would burn-loop the dispatcher. Both values are clamped to the fallback constants /// </summary>
/// with a Warning so an operator can spot the misconfiguration in logs. private readonly record struct RetryPolicies(RetryPolicy SmtpPolicy, RetryPolicy SmsPolicy);
/// </remarks>
private async Task<(int MaxRetries, TimeSpan RetryDelay)> ResolveRetryPolicyAsync( /// <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) INotificationRepository notificationRepository)
{ {
var configurations = await notificationRepository.GetAllSmtpConfigurationsAsync(); var configurations = await notificationRepository.GetAllSmtpConfigurationsAsync();
var configuration = configurations.Count > 0 ? configurations[0] : null; var configuration = configurations.Count > 0 ? configurations[0] : null;
if (configuration is null) if (configuration is null)
{ {
return (FallbackMaxRetries, FallbackRetryDelay); return new RetryPolicy(FallbackMaxRetries, FallbackRetryDelay);
} }
var maxRetries = configuration.MaxRetries; return ClampRetryPolicy(
var retryDelay = configuration.RetryDelay; 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) if (maxRetries <= 0)
{ {
_logger.LogWarning( _logger.LogWarning(
"SmtpConfiguration.MaxRetries={ConfiguredMaxRetries} is non-positive; " + "{ConfigName}.MaxRetries={ConfiguredMaxRetries} is non-positive; " +
"clamping to FallbackMaxRetries={FallbackMaxRetries} so transient failures " + "clamping to FallbackMaxRetries={FallbackMaxRetries} so transient failures " +
"actually retry before parking.", "actually retry before parking.",
maxRetries, FallbackMaxRetries); configName, maxRetries, FallbackMaxRetries);
maxRetries = FallbackMaxRetries; maxRetries = FallbackMaxRetries;
} }
if (retryDelay <= TimeSpan.Zero) if (retryDelay <= TimeSpan.Zero)
{ {
_logger.LogWarning( _logger.LogWarning(
"SmtpConfiguration.RetryDelay={ConfiguredRetryDelay} is non-positive; " + "{ConfigName}.RetryDelay={ConfiguredRetryDelay} is non-positive; " +
"clamping to FallbackRetryDelay={FallbackRetryDelay} so the dispatcher does " + "clamping to FallbackRetryDelay={FallbackRetryDelay} so the dispatcher does " +
"not burn-loop on transient failures.", "not burn-loop on transient failures.",
retryDelay, FallbackRetryDelay); configName, retryDelay, FallbackRetryDelay);
retryDelay = FallbackRetryDelay; retryDelay = FallbackRetryDelay;
} }
return (maxRetries, retryDelay); return new RetryPolicy(maxRetries, retryDelay);
} }
/// <summary> /// <summary>
@@ -519,12 +573,17 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
private async Task DeliverOneAsync( private async Task DeliverOneAsync(
Notification notification, Notification notification,
DateTimeOffset now, DateTimeOffset now,
int maxRetries, RetryPolicies policies,
TimeSpan retryDelay,
INotificationOutboxRepository outboxRepository, INotificationOutboxRepository outboxRepository,
IReadOnlyDictionary<NotificationType, INotificationDeliveryAdapter> adapters, IReadOnlyDictionary<NotificationType, INotificationDeliveryAdapter> adapters,
CancellationToken cancellationToken) 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)) if (!adapters.TryGetValue(notification.Type, out var adapter))
{ {
// Missing-adapter park: from the dispatcher's perspective this is an // Missing-adapter park: from the dispatcher's perspective this is an
@@ -684,4 +684,78 @@ public class NotificationOutboxActorDispatchTests : TestKit
// Strictly sequential: never more than one delivery in flight at once. // Strictly sequential: never more than one delivery in flight at once.
Assert.Equal(1, adapter.HighWater); 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>());
});
}
} }