193 lines
9.3 KiB
C#
193 lines
9.3 KiB
C#
using System.Collections.Concurrent;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
|
|
|
/// <summary>
|
|
/// Caches one <see cref="SiteStreamGrpcClient"/> per <c>(site, endpoint)</c> pair.
|
|
/// The DebugStreamBridgeActor uses this factory to obtain (or create) a
|
|
/// gRPC client for a given site+node before opening a streaming subscription.
|
|
/// <para>
|
|
/// Both of a site's node channels (NodeA and NodeB) coexist in the cache, so a
|
|
/// per-session NodeA↔NodeB failover flip never disposes a channel another debug
|
|
/// session is still using (arch review 02, High). <see cref="GetOrCreate"/> no
|
|
/// longer disposes on endpoint mismatch; site *removal* (<see cref="RemoveSiteAsync"/>)
|
|
/// is the only shared-disposal path — it disposes every endpoint for that site.
|
|
/// </para>
|
|
/// <para>
|
|
/// Trade-off: an edited gRPC address leaves the old endpoint's now-idle channel
|
|
/// cached until site removal or process shutdown — bounded at a handful of entries
|
|
/// per site (2 nodes + rare edits), each an idle HTTP/2 channel.
|
|
/// </para>
|
|
/// </summary>
|
|
public class SiteStreamGrpcClientFactory : IAsyncDisposable, IDisposable
|
|
{
|
|
private readonly ConcurrentDictionary<(string Site, string Endpoint), SiteStreamGrpcClient> _clients = new();
|
|
private readonly ILoggerFactory _loggerFactory;
|
|
private readonly CommunicationOptions _options;
|
|
private readonly ISitePskProvider? _pskProvider;
|
|
|
|
/// <summary>
|
|
/// Test/default constructor — uses default <see cref="CommunicationOptions"/> and creates
|
|
/// unauthenticated channels.
|
|
/// </summary>
|
|
/// <param name="loggerFactory">Logger factory passed to created clients.</param>
|
|
public SiteStreamGrpcClientFactory(ILoggerFactory loggerFactory)
|
|
: this(loggerFactory, Options.Create(new CommunicationOptions()))
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Constructor without a key provider — creates unauthenticated channels, which a gated
|
|
/// site will refuse. Retained for tests and for hosts that never dial a site.
|
|
/// </summary>
|
|
/// <param name="loggerFactory">Logger factory passed to created clients.</param>
|
|
/// <param name="options">Communication options applied to each created client.</param>
|
|
public SiteStreamGrpcClientFactory(ILoggerFactory loggerFactory, IOptions<CommunicationOptions> options)
|
|
: this(loggerFactory, options, pskProvider: null)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// DI constructor — flows <see cref="CommunicationOptions"/> into every created
|
|
/// <see cref="SiteStreamGrpcClient"/> so the configured gRPC keepalive settings are applied
|
|
/// rather than hard-coded defaults, and attaches the per-site preshared key that the site's
|
|
/// <c>ControlPlaneAuthInterceptor</c> requires.
|
|
/// </summary>
|
|
/// <param name="loggerFactory">Logger factory passed to created clients.</param>
|
|
/// <param name="options">Communication options applied to each created client.</param>
|
|
/// <param name="pskProvider">
|
|
/// Resolves each site's preshared key. Optional in DI so a host that registers no provider
|
|
/// (a site node, which never dials another site) still resolves this factory.
|
|
/// </param>
|
|
public SiteStreamGrpcClientFactory(
|
|
ILoggerFactory loggerFactory,
|
|
IOptions<CommunicationOptions> options,
|
|
ISitePskProvider? pskProvider)
|
|
{
|
|
_loggerFactory = loggerFactory;
|
|
_options = options.Value;
|
|
_pskProvider = pskProvider;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the cached client for <c>(site, endpoint)</c>, or creates one. Both node
|
|
/// channels for a site coexist under distinct keys, so requesting a different
|
|
/// endpoint for the same site adds a second entry rather than disposing the first —
|
|
/// per-session failover can never dispose a channel a concurrent session still holds.
|
|
/// </summary>
|
|
/// <param name="siteIdentifier">Unique site identifier (first half of the cache key).</param>
|
|
/// <param name="grpcEndpoint">gRPC endpoint (second half of the cache key) the client is bound to.</param>
|
|
/// <returns>The cached or newly-created client bound to <paramref name="grpcEndpoint"/>.</returns>
|
|
public virtual SiteStreamGrpcClient GetOrCreate(string siteIdentifier, string grpcEndpoint) =>
|
|
_clients.GetOrAdd((siteIdentifier, grpcEndpoint), key => CreateClient(key.Site, key.Endpoint));
|
|
|
|
/// <summary>
|
|
/// Returns the cached client for <c>(site, endpoint)</c>, or <c>null</c> — never creates.
|
|
/// Teardown paths (DebugStreamBridgeActor.CleanupGrpc / failed-stream unsubscribe)
|
|
/// use this so cleanup can never open a fresh channel or touch another endpoint's client.
|
|
/// </summary>
|
|
/// <param name="siteIdentifier">Unique site identifier (first half of the cache key).</param>
|
|
/// <param name="grpcEndpoint">gRPC endpoint (second half of the cache key).</param>
|
|
/// <returns>The cached client, or <c>null</c> when no client is cached for the pair.</returns>
|
|
public virtual SiteStreamGrpcClient? TryGet(string siteIdentifier, string grpcEndpoint) =>
|
|
_clients.TryGetValue((siteIdentifier, grpcEndpoint), out var client) ? client : null;
|
|
|
|
/// <summary>
|
|
/// Creates a single <see cref="SiteStreamGrpcClient"/>. Overridable so tests
|
|
/// can substitute a tracking client while still exercising the factory's real
|
|
/// caching and disposal machinery.
|
|
/// </summary>
|
|
/// <param name="siteIdentifier">Site the new client talks to; selects which preshared key it presents.</param>
|
|
/// <param name="grpcEndpoint">gRPC endpoint the new client will connect to.</param>
|
|
/// <returns>A new <see cref="SiteStreamGrpcClient"/> connected to <paramref name="grpcEndpoint"/>.</returns>
|
|
protected virtual SiteStreamGrpcClient CreateClient(string siteIdentifier, string grpcEndpoint)
|
|
{
|
|
var logger = _loggerFactory.CreateLogger<SiteStreamGrpcClient>();
|
|
return new SiteStreamGrpcClient(grpcEndpoint, logger, _options, _pskProvider, siteIdentifier);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes and disposes every cached client for the given site — all of its node
|
|
/// endpoints. This is the only shared-disposal path: call it when a site record is
|
|
/// deleted so the site's gRPC channels do not linger for the life of the process.
|
|
/// </summary>
|
|
/// <param name="siteIdentifier">Unique site identifier whose clients should be removed.</param>
|
|
/// <returns>A task that completes when all of the site's cached clients have been disposed.</returns>
|
|
public async Task RemoveSiteAsync(string siteIdentifier)
|
|
{
|
|
foreach (var key in _clients.Keys.Where(k => k.Site == siteIdentifier).ToList())
|
|
{
|
|
if (_clients.TryRemove(key, out var client))
|
|
await DisposeClientAsync(client, key);
|
|
}
|
|
|
|
// Drop the cached preshared key too, so a site removed and re-added under the same
|
|
// identifier (with a rotated key) is not dialed with the stale one.
|
|
_pskProvider?.Invalidate(siteIdentifier);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Asynchronously disposes all cached clients and clears the cache. Each client's disposal
|
|
/// is fault-isolated: one throwing client must not abort the loop and strand every later
|
|
/// client's channel undisposed (arch-review 2026-08-19 P3-1).
|
|
/// </summary>
|
|
/// <returns>A value task that completes when all clients have been disposed.</returns>
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
foreach (var entry in _clients)
|
|
{
|
|
await DisposeClientAsync(entry.Value, entry.Key);
|
|
}
|
|
_clients.Clear();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Disposes one client, logging and swallowing any failure so the caller's disposal loop
|
|
/// continues to the remaining clients.
|
|
/// </summary>
|
|
/// <param name="client">The client to dispose.</param>
|
|
/// <param name="key">Cache key of the client, for diagnostics.</param>
|
|
/// <returns>A task that completes when the client has been disposed (or its failure logged).</returns>
|
|
private async Task DisposeClientAsync(SiteStreamGrpcClient client, (string Site, string Endpoint) key)
|
|
{
|
|
try
|
|
{
|
|
await client.DisposeAsync();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_loggerFactory.CreateLogger<SiteStreamGrpcClientFactory>().LogWarning(
|
|
ex, "Disposing the site stream gRPC client for site {SiteId} endpoint {Endpoint} failed",
|
|
key.Site, key.Endpoint);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Synchronous disposal. This used to block on
|
|
/// <c>DisposeAsync().AsTask().GetAwaiter().GetResult()</c> (sync-over-async,
|
|
/// a stall/deadlock risk during host shutdown). Each
|
|
/// <see cref="SiteStreamGrpcClient"/> releases all of its resources
|
|
/// synchronously, so we dispose them directly with no async path.
|
|
/// Fault-isolated per client, exactly as <see cref="DisposeAsync"/> is.
|
|
/// </summary>
|
|
public void Dispose()
|
|
{
|
|
foreach (var entry in _clients)
|
|
{
|
|
try
|
|
{
|
|
entry.Value.Dispose();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_loggerFactory.CreateLogger<SiteStreamGrpcClientFactory>().LogWarning(
|
|
ex, "Disposing the site stream gRPC client for site {SiteId} endpoint {Endpoint} failed",
|
|
entry.Key.Site, entry.Key.Endpoint);
|
|
}
|
|
}
|
|
_clients.Clear();
|
|
}
|
|
}
|