using Grpc.Core; using Microsoft.Extensions.Logging; using ZB.MOM.WW.OtOpcUa.Commons.Protos.Telemetry.V1; using ZB.MOM.WW.OtOpcUa.Runtime.Telemetry; // The generated service container is itself named TelemetryStreamService; alias its nested server // base so this impl can keep the natural name without colliding with the generated type. using GeneratedServiceBase = ZB.MOM.WW.OtOpcUa.Commons.Protos.Telemetry.V1.TelemetryStreamService.TelemetryStreamServiceBase; namespace ZB.MOM.WW.OtOpcUa.Host.Grpc; /// /// Node-side live-telemetry streaming server (per-cluster mesh Phase 5). Central dials a driver /// node and opens ; the node attaches a fresh reader on its /// and pumps this node's own telemetry /// (alarm-transitions / script-logs / driver-health / driver-resilience) to the caller as a /// sequence of envelopes until the client cancels or the /// max-lifetime cap fires. /// /// /// The hub carries ONLY this node's telemetry, so a per-node stream never double-counts a peer's /// events. Fan-out is lossy under backpressure by design (the per-subscriber channel is /// bounded/DropOldest inside the hub). A simple process-wide concurrency cap sheds excess dials /// with , and every accepted stream is bounded by a /// linked max-lifetime CTS. /// public sealed class TelemetryStreamGrpcService : GeneratedServiceBase { /// Process-wide cap on concurrently-open telemetry streams. private const int MaxConcurrentStreams = 100; /// Upper bound on an accepted correlation id (defends against absurd inputs). private const int MaxCorrelationIdLength = 256; /// Per-subscriber bounded-channel capacity requested from the hub. private const int SubscriptionCapacity = 1000; /// Hard ceiling on a single stream's lifetime; the linked CTS cancels the pump when it fires. private static readonly TimeSpan MaxStreamLifetime = TimeSpan.FromHours(4); /// Process-wide count of currently-open streams; mutated only via . private static int _activeStreams; private readonly ITelemetryLocalHub _hub; private readonly ILogger _log; /// Initializes a new instance of the class. /// The node-local telemetry fan-out hub this stream drains. /// Logger. public TelemetryStreamGrpcService(ITelemetryLocalHub hub, ILogger log) { _hub = hub; _log = log; } /// public override async Task Subscribe( TelemetryStreamRequest request, IServerStreamWriter responseStream, ServerCallContext context) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(responseStream); ArgumentNullException.ThrowIfNull(context); var correlationId = request.CorrelationId; if (string.IsNullOrWhiteSpace(correlationId) || correlationId.Length > MaxCorrelationIdLength) throw new RpcException(new Status( StatusCode.InvalidArgument, "correlation_id must be non-empty and at most 256 characters")); // Concurrency cap. The increment is ALWAYS paired: on rejection we decrement here and throw // before entering the try; on acceptance the finally decrements exactly once. No path // decrements without a matching successful increment, and no path double-decrements. var active = Interlocked.Increment(ref _activeStreams); if (active > MaxConcurrentStreams) { Interlocked.Decrement(ref _activeStreams); _log.LogWarning( "Telemetry stream rejected (correlationId={CorrelationId}): {Active} open streams exceeds cap {Cap}", correlationId, active - 1, MaxConcurrentStreams); throw new RpcException(new Status( StatusCode.ResourceExhausted, "too many concurrent telemetry streams")); } try { using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken); lifetime.CancelAfter(MaxStreamLifetime); using var sub = _hub.Subscribe(SubscriptionCapacity); _log.LogDebug("Telemetry stream opened (correlationId={CorrelationId})", correlationId); try { await foreach (var item in sub.Reader.ReadAllAsync(lifetime.Token).ConfigureAwait(false)) await responseStream .WriteAsync(TelemetryProtoMapNode.ToProto(item, correlationId), lifetime.Token) .ConfigureAwait(false); } catch (OperationCanceledException) { // Normal termination: the client disconnected (context token) or the max-lifetime cap // fired (linked token). Neither is an error — swallow and let the stream close cleanly. } catch (Exception ex) when (ex is IOException or RpcException) { // Routine mid-stream disconnect: central's TelemetryStreamClient reconnects on ANY // non-Cancelled error, so a broken pipe / connection-reset here is expected churn, not a // fault. Swallow at Debug so grpc-dotnet doesn't log it at Error on every reconnect. // (A genuinely unexpected exception still escapes to surface as an error.) _log.LogDebug( ex, "Telemetry client disconnected mid-stream (correlationId={CorrelationId})", correlationId); } _log.LogDebug("Telemetry stream closed (correlationId={CorrelationId})", correlationId); } finally { Interlocked.Decrement(ref _activeStreams); } } }