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
@@ -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;
}
}