feat(audit): start purge + reconciliation singletons; production ISiteEnumerator
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
|
||||
/// <summary>
|
||||
/// Production <see cref="ISiteEnumerator"/> backing the central
|
||||
/// <see cref="SiteAuditReconciliationActor"/>. Enumerates the configured sites
|
||||
/// from the config DB via <see cref="ISiteRepository.GetAllSitesAsync"/> and
|
||||
/// projects each site to a <see cref="SiteEntry"/> using the site's
|
||||
/// <c>SiteIdentifier</c> as the cursor key and its <c>GrpcNodeAAddress</c> as
|
||||
/// the dial target.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Scope-per-call.</b> <see cref="ISiteRepository"/> is a SCOPED EF Core
|
||||
/// service (registered by <c>AddConfigurationDatabase</c>); resolving it from
|
||||
/// the root provider would fail DI scope validation. The enumerator therefore
|
||||
/// takes the root <see cref="IServiceProvider"/> and opens one
|
||||
/// <c>CreateAsyncScope</c> per <see cref="EnumerateAsync"/> call — mirroring the
|
||||
/// per-tick scope pattern in <see cref="SiteAuditReconciliationActor.OnTickAsync"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Blank-address skip.</b> Sites with no <c>GrpcNodeAAddress</c> configured
|
||||
/// are silently skipped: the reconciliation pull cannot dial them, but absence
|
||||
/// of an address is a configuration decision, not a runtime error (per the
|
||||
/// <see cref="ISiteEnumerator"/> contract).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>NodeA-only first cut.</b> This implementation always uses NodeA's gRPC
|
||||
/// address. NodeA/NodeB failover endpoint selection (dial NodeB when NodeA is
|
||||
/// unreachable) is a follow-up — the <see cref="SiteEntry"/> shape already
|
||||
/// carries a single endpoint, so failover will live in the puller/client, not
|
||||
/// here.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SiteEnumerator : ISiteEnumerator
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the enumerator with the root service provider used to open a
|
||||
/// fresh DI scope per enumeration call.
|
||||
/// </summary>
|
||||
/// <param name="services">Root service provider for resolving the scoped <see cref="ISiteRepository"/>.</param>
|
||||
public SiteEnumerator(IServiceProvider services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
_services = services;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default)
|
||||
{
|
||||
await using var scope = _services.CreateAsyncScope();
|
||||
var repository = scope.ServiceProvider.GetRequiredService<ISiteRepository>();
|
||||
|
||||
var sites = await repository.GetAllSitesAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var entries = new List<SiteEntry>(sites.Count);
|
||||
foreach (var site in sites)
|
||||
{
|
||||
// First cut: NodeA's gRPC address is the dial target. NodeA/NodeB
|
||||
// failover endpoint selection is a follow-up.
|
||||
if (string.IsNullOrWhiteSpace(site.GrpcNodeAAddress))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.Add(new SiteEntry(site.SiteIdentifier, site.GrpcNodeAAddress));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user