Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcClientFactory.cs
T

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();
}
}