using System.Collections.Concurrent; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc; /// /// Caches one per (site, endpoint) pair. /// The DebugStreamBridgeActor uses this factory to obtain (or create) a /// gRPC client for a given site+node before opening a streaming subscription. /// /// 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). no /// longer disposes on endpoint mismatch; site *removal* () /// is the only shared-disposal path — it disposes every endpoint for that site. /// /// /// 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. /// /// 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; /// /// Test/default constructor — uses default and creates /// unauthenticated channels. /// /// Logger factory passed to created clients. public SiteStreamGrpcClientFactory(ILoggerFactory loggerFactory) : this(loggerFactory, Options.Create(new CommunicationOptions())) { } /// /// 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. /// /// Logger factory passed to created clients. /// Communication options applied to each created client. public SiteStreamGrpcClientFactory(ILoggerFactory loggerFactory, IOptions options) : this(loggerFactory, options, pskProvider: null) { } /// /// DI constructor — flows into every created /// so the configured gRPC keepalive settings are applied /// rather than hard-coded defaults, and attaches the per-site preshared key that the site's /// ControlPlaneAuthInterceptor requires. /// /// Logger factory passed to created clients. /// Communication options applied to each created client. /// /// 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. /// public SiteStreamGrpcClientFactory( ILoggerFactory loggerFactory, IOptions options, ISitePskProvider? pskProvider) { _loggerFactory = loggerFactory; _options = options.Value; _pskProvider = pskProvider; } /// /// Returns the cached client for (site, endpoint), 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. /// /// Unique site identifier (first half of the cache key). /// gRPC endpoint (second half of the cache key) the client is bound to. /// The cached or newly-created client bound to . public virtual SiteStreamGrpcClient GetOrCreate(string siteIdentifier, string grpcEndpoint) => _clients.GetOrAdd((siteIdentifier, grpcEndpoint), key => CreateClient(key.Site, key.Endpoint)); /// /// Returns the cached client for (site, endpoint), or null — 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. /// /// Unique site identifier (first half of the cache key). /// gRPC endpoint (second half of the cache key). /// The cached client, or null when no client is cached for the pair. public virtual SiteStreamGrpcClient? TryGet(string siteIdentifier, string grpcEndpoint) => _clients.TryGetValue((siteIdentifier, grpcEndpoint), out var client) ? client : null; /// /// Creates a single . Overridable so tests /// can substitute a tracking client while still exercising the factory's real /// caching and disposal machinery. /// /// Site the new client talks to; selects which preshared key it presents. /// gRPC endpoint the new client will connect to. /// A new connected to . protected virtual SiteStreamGrpcClient CreateClient(string siteIdentifier, string grpcEndpoint) { var logger = _loggerFactory.CreateLogger(); return new SiteStreamGrpcClient(grpcEndpoint, logger, _options, _pskProvider, siteIdentifier); } /// /// 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. /// /// Unique site identifier whose clients should be removed. /// A task that completes when all of the site's cached clients have been disposed. 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); } /// /// 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). /// /// A value task that completes when all clients have been disposed. public async ValueTask DisposeAsync() { foreach (var entry in _clients) { await DisposeClientAsync(entry.Value, entry.Key); } _clients.Clear(); } /// /// Disposes one client, logging and swallowing any failure so the caller's disposal loop /// continues to the remaining clients. /// /// The client to dispose. /// Cache key of the client, for diagnostics. /// A task that completes when the client has been disposed (or its failure logged). private async Task DisposeClientAsync(SiteStreamGrpcClient client, (string Site, string Endpoint) key) { try { await client.DisposeAsync(); } catch (Exception ex) { _loggerFactory.CreateLogger().LogWarning( ex, "Disposing the site stream gRPC client for site {SiteId} endpoint {Endpoint} failed", key.Site, key.Endpoint); } } /// /// Synchronous disposal. This used to block on /// DisposeAsync().AsTask().GetAwaiter().GetResult() (sync-over-async, /// a stall/deadlock risk during host shutdown). Each /// releases all of its resources /// synchronously, so we dispose them directly with no async path. /// Fault-isolated per client, exactly as is. /// public void Dispose() { foreach (var entry in _clients) { try { entry.Value.Dispose(); } catch (Exception ex) { _loggerFactory.CreateLogger().LogWarning( ex, "Disposing the site stream gRPC client for site {SiteId} endpoint {Endpoint} failed", entry.Key.Site, entry.Key.Endpoint); } } _clients.Clear(); } }