327e9c5f94
Server-031: re-triaged. The recommended gateway-side "skip-while-command-in-flight" guard is already in place at WorkerClient.HeartbeatLoopAsync via WorkerClientOptions.HeartbeatStuckCeiling (default 75s = 5× HeartbeatGrace). Two regression tests pin the behaviour. Recommendation #1 (decouple worker-side _writeLock) is a Worker-module concern (Worker-017 / Worker-023) and out of scope here. Server-032: re-triaged. Recommendation #2 (rich diagnostic) is already in EnqueueWorkerEventAsync, with #3 (overflow grace) absorbed by the TryWrite → WriteAsync-with-timeout fall-through. Test EnqueueWorkerEvent_WhenChannelFullPastTimeout_FaultsWithRichDiagnostic pins the diagnostic string. Recommendation #1 (prose contract in gateway.md / docs) is deferred — outside this pass's edit scope. Server-038 (Security): EventsHub.SubscribeSession's missing per-session ACL is documented with a TODO(per-session-acl) and a <remarks> block explaining the v1 acceptance (any dashboard role can subscribe to any session — non-secret metadata, redacted value logging). The per-session ACL design lands in a follow-up once a session-scoped role exists. Server-039 (Error handling): HubTokenService.Validate now rejects a deserialized payload where both Name and NameIdentifier are null/empty. New test file HubTokenServiceTests.cs covers the regression and five sanity cases. TDD confirmed. Server-040 (Conventions): MapGroupsToRoles gains a precedence comment explaining "full literal match first, leading-RDN fallback; OrdinalIgnoreCase via DashboardOptions.GroupToRole". Documentation-only. Server-041 (Design adherence): EventStreamService.ProduceEventsAsync wraps the broadcaster.Publish call in try/catch (Exception). The producer loop and gRPC stream are no longer at the mercy of the broadcaster's never-throw discipline. New regression test StreamEventsAsync_WhenDashboardBroadcasterThrows_StillYieldsEventsAndDoesNotFaultSession. Server-042 (Performance): DashboardSnapshotPublisher.ExecuteAsync now mirrors AlarmsHubPublisher's reconnect loop — wraps the await foreach in a while-not-cancelled, catches general exceptions, and Task.Delays 5s before retrying. An internal ctor accepts a shorter delay for the test. New test file DashboardSnapshotPublisherTests.cs covers the throw-then-yield reconnect path and the normal-completion case. Server-043 (Documentation): HubTokenService class XML doc gains a <remarks> describing the singleton lifetime, the two consumer scopes (DashboardHubConnectionFactory scoped, HubTokenAuthenticationHandler transient), and the thread-safety contract. Verification: dotnet build src/ZB.MOM.WW.MxGateway.slnx clean (0 warnings / 0 errors); src/ZB.MOM.WW.MxGateway.Tests 486/486 passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
186 lines
6.8 KiB
C#
186 lines
6.8 KiB
C#
using System.Runtime.CompilerServices;
|
|
using System.Threading.Channels;
|
|
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
|
using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
|
using ZB.MOM.WW.MxGateway.Server.Metrics;
|
|
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
|
using ZB.MOM.WW.MxGateway.Server.Workers;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Server.Grpc;
|
|
|
|
public sealed class EventStreamService(
|
|
ISessionManager sessionManager,
|
|
IOptions<GatewayOptions> options,
|
|
MxAccessGrpcMapper mapper,
|
|
GatewayMetrics metrics,
|
|
IDashboardEventBroadcaster dashboardEventBroadcaster,
|
|
ILogger<EventStreamService> logger) : IEventStreamService
|
|
{
|
|
/// <summary>
|
|
/// Streams events from a session to the client asynchronously.
|
|
/// </summary>
|
|
/// <param name="request">Stream events request.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>Async enumerable of MX events.</returns>
|
|
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
|
StreamEventsRequest request,
|
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
|
{
|
|
if (!sessionManager.TryGetSession(request.SessionId, out GatewaySession? session) || session is null)
|
|
{
|
|
throw new SessionManagerException(
|
|
SessionManagerErrorCode.SessionNotFound,
|
|
$"Session {request.SessionId} was not found.");
|
|
}
|
|
|
|
using IDisposable subscriber = session.AttachEventSubscriber(
|
|
options.Value.Sessions.AllowMultipleEventSubscribers);
|
|
using CancellationTokenSource streamCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
|
|
int streamQueueDepth = 0;
|
|
Channel<MxEvent> eventQueue = Channel.CreateBounded<MxEvent>(
|
|
new BoundedChannelOptions(options.Value.Events.QueueCapacity)
|
|
{
|
|
SingleReader = true,
|
|
SingleWriter = true,
|
|
FullMode = BoundedChannelFullMode.Wait,
|
|
AllowSynchronousContinuations = false,
|
|
});
|
|
Task producerTask = ProduceEventsAsync(
|
|
session,
|
|
request.AfterWorkerSequence,
|
|
eventQueue.Writer,
|
|
() =>
|
|
{
|
|
Interlocked.Increment(ref streamQueueDepth);
|
|
metrics.AdjustGrpcEventStreamQueueDepth(1);
|
|
},
|
|
streamCts.Token);
|
|
|
|
try
|
|
{
|
|
await foreach (MxEvent mxEvent in eventQueue.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
|
|
{
|
|
Interlocked.Decrement(ref streamQueueDepth);
|
|
metrics.AdjustGrpcEventStreamQueueDepth(-1);
|
|
yield return mxEvent;
|
|
}
|
|
|
|
await producerTask.ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
await streamCts.CancelAsync().ConfigureAwait(false);
|
|
subscriber.Dispose();
|
|
|
|
try
|
|
{
|
|
await producerTask.ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException) when (streamCts.IsCancellationRequested)
|
|
{
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogDebug(
|
|
exception,
|
|
"Event stream producer stopped for session {SessionId}.",
|
|
request.SessionId);
|
|
}
|
|
|
|
int remainingDepth = Interlocked.Exchange(ref streamQueueDepth, 0);
|
|
if (remainingDepth > 0)
|
|
{
|
|
metrics.AdjustGrpcEventStreamQueueDepth(-remainingDepth);
|
|
}
|
|
|
|
metrics.StreamDisconnected("Detached");
|
|
}
|
|
}
|
|
|
|
private async Task ProduceEventsAsync(
|
|
GatewaySession session,
|
|
ulong afterWorkerSequence,
|
|
ChannelWriter<MxEvent> writer,
|
|
Action eventQueued,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
await foreach (WorkerEvent workerEvent in session
|
|
.ReadEventsAsync(cancellationToken)
|
|
.WithCancellation(cancellationToken)
|
|
.ConfigureAwait(false))
|
|
{
|
|
MxEvent publicEvent = mapper.MapEvent(workerEvent);
|
|
if (publicEvent.WorkerSequence <= afterWorkerSequence)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Mirror the event to the dashboard EventsHub group for this
|
|
// session. Fire-and-forget — broadcast errors must not affect
|
|
// the source gRPC stream. Server-041: the
|
|
// IDashboardEventBroadcaster contract documents Publish as
|
|
// never-throw, but we enforce that at the seam too, so a
|
|
// future implementation that adds synchronous validation or
|
|
// a serializer hop cannot fault the producer loop and end
|
|
// this client's gRPC stream.
|
|
try
|
|
{
|
|
dashboardEventBroadcaster.Publish(session.SessionId, publicEvent);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogDebug(
|
|
ex,
|
|
"Dashboard event mirror threw for session {SessionId}; continuing.",
|
|
session.SessionId);
|
|
}
|
|
|
|
if (!writer.TryWrite(publicEvent))
|
|
{
|
|
string message = $"Session {session.SessionId} event stream queue overflowed.";
|
|
metrics.QueueOverflow("grpc-event-stream");
|
|
if (options.Value.Events.BackpressurePolicy == EventBackpressurePolicy.FailFast)
|
|
{
|
|
session.MarkFaulted(message);
|
|
metrics.Fault(SessionManagerErrorCode.EventQueueOverflow.ToString());
|
|
}
|
|
else
|
|
{
|
|
logger.LogDebug(
|
|
"Disconnecting event stream for session {SessionId} after queue overflow.",
|
|
session.SessionId);
|
|
}
|
|
|
|
writer.TryComplete(new SessionManagerException(
|
|
SessionManagerErrorCode.EventQueueOverflow,
|
|
message));
|
|
return;
|
|
}
|
|
|
|
eventQueued();
|
|
}
|
|
|
|
writer.TryComplete();
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
writer.TryComplete();
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
if (exception is WorkerClientException)
|
|
{
|
|
session.MarkFaulted(exception.Message);
|
|
metrics.Fault(WorkerClientErrorCode.WorkerFaulted.ToString());
|
|
}
|
|
|
|
writer.TryComplete(exception);
|
|
}
|
|
}
|
|
}
|