feat(audit): start purge + reconciliation singletons; production ISiteEnumerator

This commit is contained in:
Joseph Doherty
2026-06-15 10:00:44 -04:00
parent d03c2af9a1
commit 36a08a4145
6 changed files with 337 additions and 6 deletions
@@ -50,6 +50,12 @@ public static class ServiceCollectionExtensions
/// <summary>Configuration section bound to <see cref="AuditLogPartitionMaintenanceOptions"/>.</summary>
public const string PartitionMaintenanceSectionName = "AuditLog:PartitionMaintenance";
/// <summary>Configuration section bound to <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Central.AuditLogPurgeOptions"/>.</summary>
public const string PurgeSectionName = "AuditLog:Purge";
/// <summary>Configuration section bound to <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Central.SiteAuditReconciliationOptions"/>.</summary>
public const string ReconciliationSectionName = "AuditLog:Reconciliation";
/// <summary>
/// Registers the Audit Log (#23) component services: options, the site
/// SQLite writer chain (primary + ring fallback + failure-counter sink),
@@ -390,19 +396,44 @@ public static class ServiceCollectionExtensions
/// the same options.
/// </para>
/// <para>
/// <see cref="ISiteEnumerator"/> is NOT registered here: its production
/// implementation (wrapping <c>ISiteRepository</c>) ships with the
/// reconciliation-singleton wiring in the Host. The client resolves the
/// enumerator lazily at actor-construction time, so this binding is safe to
/// issue before the enumerator binding lands.
/// The production <see cref="ISiteEnumerator"/> (<see cref="SiteEnumerator"/>,
/// wrapping the scoped <c>ISiteRepository</c>) IS registered here, alongside
/// the <see cref="AuditLogPurgeOptions"/> + <see cref="SiteAuditReconciliationOptions"/>
/// bindings — so the two central singletons wired in the Host
/// (<see cref="AuditLogPurgeActor"/> + <see cref="SiteAuditReconciliationActor"/>)
/// can resolve their collaborators + options from the same central-only
/// helper. Keeping the enumerator + options on this central path preserves
/// the "every <c>Add*</c> call is safe from any composition root" invariant:
/// a site host never calls this helper, so it never registers a
/// site-dialing enumerator.
/// </para>
/// </remarks>
/// <param name="services">The service collection to register into.</param>
/// <param name="config">Application configuration used to bind the purge + reconciliation options sections.</param>
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
public static IServiceCollection AddAuditLogCentralReconciliationClient(
this IServiceCollection services)
this IServiceCollection services,
IConfiguration config)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(config);
// Production ISiteEnumerator: projects the config-DB Site rows into the
// reconciliation targets the SiteAuditReconciliationActor polls. Scoped
// ISiteRepository is resolved per call inside the enumerator, so the
// singleton takes the ROOT provider (mirrors the per-tick scope pattern
// in SiteAuditReconciliationActor / AuditLogPurgeActor).
services.TryAddSingleton<ISiteEnumerator>(sp => new SiteEnumerator(sp));
// Bind the two central-singleton options to their config sections.
// Defaults are fine when the section is absent (24 h purge cadence /
// 5 min reconciliation tick); production exposes IntervalHours /
// ReconciliationIntervalSeconds only — the test-only *Override knobs
// are intentionally not bound.
services.AddOptions<AuditLogPurgeOptions>()
.Bind(config.GetSection(PurgeSectionName));
services.AddOptions<SiteAuditReconciliationOptions>()
.Bind(config.GetSection(ReconciliationSectionName));
// The invoker owns the per-endpoint GrpcChannel cache, so it must be a
// singleton — a fresh invoker per resolution would leak channels.