feat(sessions): route event streaming through SessionEventDistributor

This commit is contained in:
Joseph Doherty
2026-06-15 13:18:28 -04:00
parent c2c518862f
commit 7f1018bac1
6 changed files with 288 additions and 131 deletions
@@ -1,4 +1,7 @@
using System.Runtime.CompilerServices;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Grpc;
using ZB.MOM.WW.MxGateway.Server.Workers;
namespace ZB.MOM.WW.MxGateway.Server.Sessions;
@@ -7,6 +10,7 @@ public sealed class GatewaySession
{
private readonly object _syncRoot = new();
private readonly SemaphoreSlim _closeLock = new(1, 1);
private readonly SessionEventStreaming _eventStreaming;
private IWorkerClient? _workerClient;
private SessionState _state = SessionState.Creating;
private string? _finalFault;
@@ -14,6 +18,8 @@ public sealed class GatewaySession
private DateTimeOffset? _leaseExpiresAt;
private bool _closeStarted;
private int _activeEventSubscriberCount;
private SessionEventDistributor? _eventDistributor;
private bool _eventDistributorStarted;
private readonly Dictionary<(int ServerHandle, int ItemHandle), SessionItemRegistration> _items = [];
/// <summary>
@@ -80,6 +86,15 @@ public sealed class GatewaySession
/// <param name="shutdownTimeout">Timeout for worker process shutdown.</param>
/// <param name="leaseDuration">Duration of the session lease.</param>
/// <param name="openedAt">Timestamp when the session opened.</param>
/// <param name="eventStreaming">
/// Dependencies the session uses to construct and own its
/// <see cref="SessionEventDistributor"/> (the single per-session worker-event pump
/// that fans raw mapped <see cref="MxEvent"/>s to every subscriber lease). When
/// <see langword="null"/>, defaults are used (no replay logger, system clock, a
/// fresh mapper, and default <see cref="EventOptions"/>) so unit tests that build a
/// session directly still get a working distributor. Production passes the
/// DI-resolved dependencies.
/// </param>
public GatewaySession(
string sessionId,
string backendName,
@@ -93,7 +108,8 @@ public sealed class GatewaySession
TimeSpan startupTimeout,
TimeSpan shutdownTimeout,
TimeSpan leaseDuration,
DateTimeOffset openedAt)
DateTimeOffset openedAt,
SessionEventStreaming? eventStreaming = null)
{
if (string.IsNullOrWhiteSpace(sessionId))
{
@@ -130,6 +146,7 @@ public sealed class GatewaySession
OpenedAt = openedAt;
_lastClientActivityAt = openedAt;
_leaseExpiresAt = openedAt + leaseDuration;
_eventStreaming = eventStreaming ?? SessionEventStreaming.Default;
}
/// <summary>
@@ -337,6 +354,72 @@ public sealed class GatewaySession
TransitionTo(SessionState.Ready);
}
// Constructs and starts the distributor exactly once, registering the subscriber under
// the same start so no event the pump fans can be missed between start and register.
// Started lazily on the FIRST AttachEventSubscriber rather than at MarkReady: today the
// worker event stream is only drained when a client begins streaming, so deferring the
// single drain to first-attach preserves that "events start flowing on subscribe"
// behavior and avoids draining a fast-completing source into the void before any
// subscriber exists. The source factory mirrors the mapping/ordering/start that
// EventStreamService.ProduceEventsAsync used before Task 4: it drains the worker event
// stream in source order and maps each WorkerEvent to the public MxEvent with the same
// mapper, with no skip/filter — per-RPC filtering (e.g. AfterWorkerSequence) stays at the
// subscriber boundary in EventStreamService. Returns a registered lease atomically with
// the start so the very first subscriber sees the stream from its beginning.
private IEventSubscriberLease StartDistributorAndRegister()
{
SessionEventDistributor distributor;
bool startNow = false;
lock (_syncRoot)
{
if (_eventDistributor is null)
{
EventOptions eventOptions = _eventStreaming.EventOptions;
_eventDistributor = new SessionEventDistributor(
SessionId,
MapWorkerEventsAsync,
eventOptions.QueueCapacity,
eventOptions.ReplayBufferCapacity,
eventOptions.ReplayRetentionSeconds,
_eventStreaming.DistributorLogger,
_eventStreaming.TimeProvider);
}
distributor = _eventDistributor;
if (!_eventDistributorStarted)
{
_eventDistributorStarted = true;
startNow = true;
}
}
// Register BEFORE starting the pump so a subscriber is present when the pump begins
// draining — no event is fanned to an empty subscriber set and then missed by this
// first subscriber. StartAsync only schedules the pump task; it never blocks.
IEventSubscriberLease lease = distributor.Register();
if (startNow)
{
distributor.StartAsync(CancellationToken.None).GetAwaiter().GetResult();
}
return lease;
}
// The distributor's single event source. Drains the worker event stream once (the
// distributor guarantees a single consumer) and maps each frame to the public MxEvent,
// preserving worker order. Mirrors the former ProduceEventsAsync mapping exactly.
private async IAsyncEnumerable<MxEvent> MapWorkerEventsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
MxAccessGrpcMapper mapper = _eventStreaming.Mapper;
await foreach (WorkerEvent workerEvent in ReadEventsAsync(cancellationToken)
.WithCancellation(cancellationToken)
.ConfigureAwait(false))
{
yield return mapper.MapEvent(workerEvent);
}
}
/// <summary>
/// Transitions the session to the Faulted state with a fault description.
/// </summary>
@@ -395,10 +478,15 @@ public sealed class GatewaySession
}
/// <summary>
/// Attaches an event subscriber and returns a disposable lease.
/// Attaches an event subscriber and returns a lease whose
/// <see cref="IEventSubscriberLease.Reader"/> reads the fanned public
/// <see cref="MxEvent"/>s for this subscriber. The single-subscriber guard
/// (Tasks 7/8 relax it) is unchanged: with multi-subscriber disabled a second
/// attach is rejected. The returned lease, when disposed, unregisters the
/// distributor subscriber AND decrements the active-subscriber count.
/// </summary>
/// <param name="allowMultipleSubscribers">If true, allows multiple concurrent event subscribers.</param>
public IDisposable AttachEventSubscriber(bool allowMultipleSubscribers)
public IEventSubscriberLease AttachEventSubscriber(bool allowMultipleSubscribers)
{
lock (_syncRoot)
{
@@ -417,7 +505,20 @@ public sealed class GatewaySession
}
_activeEventSubscriberCount++;
return new EventSubscriberLease(this);
}
// Construct/start the distributor and register this subscriber. Done outside the
// guard lock (StartDistributorAndRegister takes _syncRoot itself for construction).
// On any failure roll back the count we just took so the guard stays consistent.
try
{
IEventSubscriberLease distributorLease = StartDistributorAndRegister();
return new EventSubscriberLease(this, distributorLease);
}
catch
{
DetachEventSubscriber();
throw;
}
}
@@ -974,6 +1075,23 @@ public sealed class GatewaySession
{
}
// Stop the event pump and complete every subscriber channel before tearing down the
// worker client (the pump's source). DisposeAsync is the single session teardown
// point (SessionManager.RemoveSessionAsync awaits it after close), so awaiting it
// here guarantees the distributor's pump task is observed and subscribers are
// completed rather than left dangling.
SessionEventDistributor? distributor;
lock (_syncRoot)
{
distributor = _eventDistributor;
_eventDistributor = null;
}
if (distributor is not null)
{
await distributor.DisposeAsync().ConfigureAwait(false);
}
if (_workerClient is not null)
{
await _workerClient.DisposeAsync().ConfigureAwait(false);
@@ -1115,12 +1233,19 @@ public sealed class GatewaySession
}
}
private sealed class EventSubscriberLease(GatewaySession session) : IDisposable
private sealed class EventSubscriberLease(GatewaySession session, IEventSubscriberLease distributorLease)
: IEventSubscriberLease
{
private bool _disposed;
/// <inheritdoc />
public System.Threading.Channels.ChannelReader<MxEvent> Reader => distributorLease.Reader;
/// <summary>
/// Disposes the lease and detaches the event subscriber.
/// Disposes the lease: unregisters this subscriber from the distributor (completing
/// its channel) and decrements the session's active-subscriber count. Ordering is
/// not significant — the count guard and the distributor registration are
/// independent — but both must run exactly once.
/// </summary>
public void Dispose()
{
@@ -1129,8 +1254,9 @@ public sealed class GatewaySession
return;
}
session.DetachEventSubscriber();
_disposed = true;
distributorLease.Dispose();
session.DetachEventSubscriber();
}
}
}