abbbcd1a8d
Defect: the hourly KPI rollup fold (KpiHistoryRepository.FoldHourlyRollupsAsync) sums every Rate-classified sample recorded in an hour, correct only if each sample is a non-overlapping delta covering exactly one recorder tick (KpiHistoryOptions.SampleInterval). That invariant was an unenforced silent assumption across three independently-owned config keys — SampleInterval, NotificationOutboxOptions.DeliveredKpiWindow, SiteCallAuditOptions.KpiInterval — so widening DeliveredKpiWindow to e.g. 15 min would silently over-count the rollup ~15x. Robust fix: NotificationOutboxKpiSampleSource and SiteCallAuditKpiSampleSource now rescale their Rate-classified samples (deliveredLastInterval, failedLastInterval) by SampleInterval / (DeliveredKpiWindow or KpiInterval) before recording, so the rollup's per-hour Sum is correct regardless of how the other two keys are tuned — the live dashboard tile's own cutoff/window is untouched. Both sources gain a project reference to KpiHistory to read SampleInterval; no cycle (KpiHistory references only Commons). Eager cross-validation (defense-in-depth): KpiRateFoldIntervalCrossValidator (Host, the one composition root referencing all three component projects) fails boot naming all three config keys when they diverge, protecting the separate (still-real) invariant that the recorded/charted KPI-history sample equals the live tile at the same instant — which the rescale trades away whenever the scale factor isn't 1. Rides KpiHistoryOptions' existing ValidateOnStart trigger. Also: NotificationOutboxOptions had no registered validator at all before this (the gap this task's "whichever options class owns DeliveredKpiWindow" file scope pointed at) — added NotificationOutboxOptionsValidator mirroring SiteCallAuditOptionsValidator's positive-duration/count convention, wired with ValidateOnStart. Tests: new rescale tests in both sample-source suites prove the fold is numerically correct at a non-default SampleInterval (30/40 counted over a 15/20-minute window correctly divides down to a 1-minute-tick delta); new KpiRateFoldIntervalCrossValidatorTests prove divergent config fails with all three keys named; new NotificationOutboxOptionsValidatorTests. Every new test proven to fail against the unfixed computation first (temporarily short-circuited the scale factor / cross-check to the pre-fix behavior, confirmed failure, restored) — not via git stash (repo-wide, shared across concurrent worktrees this session) but via file-copy swap. Full suites green: Host.Tests (527), ConfigurationDatabase.Tests (401), KpiHistory.Tests (54), NotificationOutbox.Tests (163), SiteCallAudit.Tests (67).
106 lines
6.3 KiB
C#
106 lines
6.3 KiB
C#
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
|
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi;
|
|
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
|
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Kpi;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox;
|
|
|
|
/// <summary>
|
|
/// DI registration for the Notification Outbox component: binds
|
|
/// <see cref="NotificationOutboxOptions"/> and registers the channel delivery adapters.
|
|
/// </summary>
|
|
public static class ServiceCollectionExtensions
|
|
{
|
|
/// <summary>Configuration section bound to <see cref="NotificationOutboxOptions"/>.</summary>
|
|
public const string OptionsSection = "ScadaBridge:NotificationOutbox";
|
|
|
|
/// <summary>Configuration section bound to <see cref="SmsOptions"/>.</summary>
|
|
public const string SmsOptionsSection = "ScadaBridge:Sms";
|
|
|
|
/// <summary>
|
|
/// Registers the Notification Outbox services: the <see cref="NotificationOutboxOptions"/>
|
|
/// binding and the channel delivery adapters.
|
|
///
|
|
/// This extension covers only the outbox-specific registrations. The
|
|
/// <see cref="EmailNotificationDeliveryAdapter"/> reuses the
|
|
/// <see cref="ZB.MOM.WW.ScadaBridge.NotificationService"/> SMTP machinery —
|
|
/// <c>Func<ISmtpClientWrapper></c>, <c>OAuth2TokenService</c>,
|
|
/// <c>NotificationOptions</c> and — for configuration rows selecting the EWS transport —
|
|
/// <c>IEwsMailSender</c> with its named HTTP client — so the caller (the Host on the
|
|
/// central node) must also call <c>AddNotificationService()</c>. Re-registering those
|
|
/// services here would duplicate them; relying on <c>AddNotificationService</c> keeps a
|
|
/// single source of truth. Each is an optional constructor parameter on the adapter, so a
|
|
/// host that skips <c>AddNotificationService()</c> still constructs — an EWS row then
|
|
/// fails permanently ("no EWS sender is registered") rather than falling back to SMTP.
|
|
///
|
|
/// <see cref="EmailNotificationDeliveryAdapter"/> is registered <em>scoped</em> because it
|
|
/// takes a scoped <see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.INotificationRepository"/>
|
|
/// directly. The <see cref="NotificationOutboxActor"/> resolves the matching adapter from
|
|
/// the fresh DI scope it opens <em>per delivery</em> — not per sweep and never held on the
|
|
/// actor — so no scoped adapter is captured by the singleton actor and no two of the
|
|
/// dispatcher's concurrent deliveries (<c>MaxParallelDeliveries</c>) can land on one
|
|
/// DbContext.
|
|
///
|
|
/// The <see cref="SmsNotificationDeliveryAdapter"/> (Twilio-REST) is registered the same
|
|
/// way alongside the Email adapter, so both resolve from
|
|
/// <c>GetServices<INotificationDeliveryAdapter>()</c>. It also binds + validates
|
|
/// <see cref="SmsOptions"/> (<c>ScadaBridge:Sms</c>) and the named <c>"Twilio"</c>
|
|
/// <see cref="HttpClient"/>; unlike the Email adapter it needs no NotificationService
|
|
/// machinery (a single outbound HTTPS POST per recipient).
|
|
/// </summary>
|
|
/// <param name="services">The DI service collection to register notification outbox services into.</param>
|
|
/// <returns>The same <paramref name="services"/> instance for chaining.</returns>
|
|
public static IServiceCollection AddNotificationOutbox(this IServiceCollection services)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(services);
|
|
|
|
// Eager startup validation (arch-review §1.5 / P5-5): this options class had no
|
|
// registered validator before — ValidateOnStart makes a bad
|
|
// ScadaBridge:NotificationOutbox section fail fast at boot with a clear,
|
|
// key-naming message instead of spinning a scheduler or, for DeliveredKpiWindow,
|
|
// dividing by zero in NotificationOutboxKpiSampleSource's KpiHistory rescale.
|
|
services.AddOptions<NotificationOutboxOptions>()
|
|
.BindConfiguration(OptionsSection)
|
|
.ValidateOnStart();
|
|
services.TryAddEnumerable(
|
|
ServiceDescriptor.Singleton<IValidateOptions<NotificationOutboxOptions>, NotificationOutboxOptionsValidator>());
|
|
|
|
// Bind + validate SmsOptions (ScadaBridge:Sms): the SMS adapter's body cap and
|
|
// per-request timeout fallback. ValidateOnStart surfaces a bad option at host
|
|
// startup rather than at first delivery; the validator is registered via
|
|
// TryAddEnumerable so a re-call of AddNotificationOutbox does not double-register it.
|
|
services.AddOptions<SmsOptions>()
|
|
.BindConfiguration(SmsOptionsSection)
|
|
.ValidateOnStart();
|
|
services.TryAddEnumerable(
|
|
ServiceDescriptor.Singleton<IValidateOptions<SmsOptions>, SmsOptionsValidator>());
|
|
|
|
// Named HTTP client backing the Twilio-REST SMS adapter. Pooled handler lifetime
|
|
// is managed by IHttpClientFactory; the adapter applies the per-request timeout
|
|
// via a linked CTS (so the client's own Timeout stays at its default).
|
|
services.AddHttpClient(SmsNotificationDeliveryAdapter.HttpClientName);
|
|
|
|
// Scoped: the adapters hold a scoped INotificationRepository. Each is registered
|
|
// both under the interface (so the dispatch sweep can enumerate every channel
|
|
// adapter — both Email and SMS must resolve from GetServices<INotificationDeliveryAdapter>())
|
|
// and as the concrete type (so callers and tests can resolve it directly).
|
|
services.AddScoped<EmailNotificationDeliveryAdapter>();
|
|
services.AddScoped<INotificationDeliveryAdapter>(
|
|
sp => sp.GetRequiredService<EmailNotificationDeliveryAdapter>());
|
|
|
|
services.AddScoped<SmsNotificationDeliveryAdapter>();
|
|
services.AddScoped<INotificationDeliveryAdapter>(
|
|
sp => sp.GetRequiredService<SmsNotificationDeliveryAdapter>());
|
|
|
|
// KPI history: the recorder singleton enumerates every IKpiSampleSource each
|
|
// sampling pass to snapshot the outbox delivery KPIs into the central history store.
|
|
// TryAddEnumerable is idempotent — no double-registration if AddNotificationOutbox
|
|
// is ever called more than once, matching the AuditLog idiom.
|
|
services.TryAddEnumerable(ServiceDescriptor.Scoped<IKpiSampleSource, NotificationOutboxKpiSampleSource>());
|
|
|
|
return services;
|
|
}
|
|
}
|