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,5 +1,4 @@
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;
@@ -13,14 +12,33 @@ 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.
/// Streams events from a session to the client asynchronously.
/// </summary>
/// <remarks>
/// <para>
/// Task 4 rewired this from a per-RPC channel that drained the session directly
/// to reading the subscriber's lease channel fed by the session's single
/// <see cref="SessionEventDistributor"/> pump. The pump owns the single drain of
/// the worker event stream and the worker→public mapping (mirroring the former
/// <c>ProduceEventsAsync</c>); this loop is the per-subscriber boundary that
/// applies the per-RPC filter (<c>AfterWorkerSequence</c>), the dashboard mirror,
/// queue-depth metrics, and the backpressure/overflow policy.
/// </para>
/// <para>
/// Overflow detection: the distributor's per-subscriber channel is bounded and the
/// pump drops (does not block) on a full channel. Worker sequences are contiguous
/// and the pump preserves order, so a gap between consecutive delivered
/// <see cref="MxEvent.WorkerSequence"/> values means the pump dropped events for
/// this slow subscriber — that is the overflow signal that, before Task 4, was a
/// full per-RPC channel. The FailFast / DisconnectSubscriber semantics are
/// unchanged. Task 5 takes over the per-subscriber isolation policy.
/// </para>
/// </remarks>
/// <param name="request">Stream events request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of MX events.</returns>
@@ -35,102 +53,62 @@ public sealed class EventStreamService(
$"Session {request.SessionId} was not found.");
}
using IDisposable subscriber = session.AttachEventSubscriber(
using IEventSubscriberLease 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);
ulong afterWorkerSequence = request.AfterWorkerSequence;
IAsyncEnumerator<MxEvent> reader = subscriber.Reader
.ReadAllAsync(cancellationToken)
.GetAsyncEnumerator(cancellationToken);
try
{
await foreach (MxEvent mxEvent in eventQueue.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
while (true)
{
Interlocked.Decrement(ref streamQueueDepth);
metrics.AdjustGrpcEventStreamQueueDepth(-1);
yield return mxEvent;
}
MxEvent mxEvent;
try
{
if (!await reader.MoveNextAsync().ConfigureAwait(false))
{
break;
}
await producerTask.ConfigureAwait(false);
}
finally
{
await streamCts.CancelAsync().ConfigureAwait(false);
subscriber.Dispose();
mxEvent = reader.Current;
}
catch (WorkerClientException workerException)
{
// The distributor pump completes every subscriber channel with the source
// fault when the worker event stream terminates abnormally; that surfaces
// here. Mirror the pre-Task-4 ProduceEventsAsync behavior: fault the
// session and record the metric, then propagate the terminal fault to the
// gRPC client.
session.MarkFaulted(workerException.Message);
metrics.Fault(WorkerClientErrorCode.WorkerFaulted.ToString());
throw;
}
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)
// Per-RPC filter stays at the subscriber boundary: each request may resume
// from a different AfterWorkerSequence, so the shared pump fans raw events and
// this loop drops the ones at or below the caller's watermark. Filtered events
// are not mirrored to the dashboard, matching the pre-Task-4 ordering where
// the skip ran before the dashboard Publish.
if (mxEvent.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.
// 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: IDashboardEventBroadcaster 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 this loop and end
// the client's gRPC stream. (Task 6 will move this tap onto its own
// distributor subscriber; for Task 4 it coexists here, firing once per
// delivered event for the single subscriber exactly as before.)
try
{
dashboardEventBroadcaster.Publish(session.SessionId, publicEvent);
dashboardEventBroadcaster.Publish(session.SessionId, mxEvent);
}
catch (Exception ex)
{
@@ -140,46 +118,34 @@ public sealed class EventStreamService(
session.SessionId);
}
if (!writer.TryWrite(publicEvent))
// Queue-depth gauge tracks events the pump has fanned into this subscriber's
// channel but the client has not yet consumed — the same "buffered, not yet
// delivered" quantity the pre-Task-4 per-RPC channel reported. The bounded
// subscriber channel supports counting, so reconcile the gauge to the current
// backlog; falling back to a no-op delta if a channel ever cannot count.
int backlog = subscriber.Reader.CanCount ? subscriber.Reader.Count : streamQueueDepth;
int delta = backlog - streamQueueDepth;
if (delta != 0)
{
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;
streamQueueDepth = backlog;
metrics.AdjustGrpcEventStreamQueueDepth(delta);
}
eventQueued();
yield return mxEvent;
}
}
finally
{
await reader.DisposeAsync().ConfigureAwait(false);
subscriber.Dispose();
writer.TryComplete();
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
writer.TryComplete();
}
catch (Exception exception)
{
if (exception is WorkerClientException)
if (streamQueueDepth != 0)
{
session.MarkFaulted(exception.Message);
metrics.Fault(WorkerClientErrorCode.WorkerFaulted.ToString());
metrics.AdjustGrpcEventStreamQueueDepth(-streamQueueDepth);
streamQueueDepth = 0;
}
writer.TryComplete(exception);
metrics.StreamDisconnected("Detached");
}
}
}
@@ -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();
}
}
}
@@ -0,0 +1,42 @@
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Grpc;
namespace ZB.MOM.WW.MxGateway.Server.Sessions;
/// <summary>
/// Dependencies a <see cref="GatewaySession"/> needs to construct and own its
/// <see cref="SessionEventDistributor"/>. Bundled so the session constructor stays a
/// single optional parameter rather than four, and so unit tests that build a session
/// directly get a working distributor from <see cref="Default"/> without wiring DI.
/// </summary>
/// <param name="Mapper">
/// Maps worker IPC <c>WorkerEvent</c> frames to public <c>MxEvent</c>s. The distributor
/// pump applies this once per event in worker order, mirroring the mapping
/// <c>EventStreamService.ProduceEventsAsync</c> used before Task 4.
/// </param>
/// <param name="EventOptions">
/// Supplies the distributor's per-subscriber queue capacity and replay ring-buffer
/// bounds (<see cref="EventOptions.QueueCapacity"/>,
/// <see cref="EventOptions.ReplayBufferCapacity"/>,
/// <see cref="EventOptions.ReplayRetentionSeconds"/>).
/// </param>
/// <param name="DistributorLogger">Logger for the distributor pump lifecycle.</param>
/// <param name="TimeProvider">Clock used to timestamp and age-evict replay entries.</param>
public sealed record SessionEventStreaming(
MxAccessGrpcMapper Mapper,
EventOptions EventOptions,
ILogger<SessionEventDistributor> DistributorLogger,
TimeProvider TimeProvider)
{
/// <summary>
/// Defaults used when a session is constructed without explicit streaming
/// dependencies (unit tests). Uses a fresh mapper, default event options, a no-op
/// logger, and the system clock.
/// </summary>
public static SessionEventStreaming Default { get; } = new(
new MxAccessGrpcMapper(),
new EventOptions(),
NullLogger<SessionEventDistributor>.Instance,
TimeProvider.System);
}
@@ -25,6 +25,8 @@ public sealed class SessionManager : ISessionManager
private readonly ILogger<SessionManager> _logger;
private readonly GatewayOptions _options;
private readonly SemaphoreSlim _sessionSlots;
private readonly Grpc.MxAccessGrpcMapper _eventMapper;
private readonly ILogger<SessionEventDistributor> _distributorLogger;
/// <summary>
/// Initializes a new instance of <see cref="SessionManager"/>.
@@ -35,13 +37,17 @@ public sealed class SessionManager : ISessionManager
/// <param name="metrics">Gateway metrics.</param>
/// <param name="timeProvider">Time provider for timestamps.</param>
/// <param name="logger">Logger.</param>
/// <param name="eventMapper">Mapper used by each session's event distributor to map worker events to public events.</param>
/// <param name="distributorLogger">Logger passed to each session's event distributor pump.</param>
public SessionManager(
ISessionRegistry registry,
ISessionWorkerClientFactory workerClientFactory,
IOptions<GatewayOptions> options,
GatewayMetrics metrics,
TimeProvider? timeProvider = null,
ILogger<SessionManager>? logger = null)
ILogger<SessionManager>? logger = null,
Grpc.MxAccessGrpcMapper? eventMapper = null,
ILogger<SessionEventDistributor>? distributorLogger = null)
{
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
_workerClientFactory = workerClientFactory ?? throw new ArgumentNullException(nameof(workerClientFactory));
@@ -49,6 +55,8 @@ public sealed class SessionManager : ISessionManager
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? NullLogger<SessionManager>.Instance;
_eventMapper = eventMapper ?? new Grpc.MxAccessGrpcMapper();
_distributorLogger = distributorLogger ?? NullLogger<SessionEventDistributor>.Instance;
_options = options.Value;
_sessionSlots = new SemaphoreSlim(_options.Sessions.MaxSessions, _options.Sessions.MaxSessions);
}
@@ -438,6 +446,12 @@ public sealed class SessionManager : ISessionManager
DateTimeOffset openedAt = _timeProvider.GetUtcNow();
string clientCorrelationId = CreateClientCorrelationId(request.ClientSessionName, sessionId);
SessionEventStreaming eventStreaming = new(
_eventMapper,
_options.Events,
_distributorLogger,
_timeProvider);
return new GatewaySession(
sessionId,
backendName,
@@ -451,7 +465,8 @@ public sealed class SessionManager : ISessionManager
startupTimeout,
shutdownTimeout,
leaseDuration,
openedAt);
openedAt,
eventStreaming);
}
private static string CreateClientCorrelationId(
@@ -273,7 +273,6 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
EventStreamService eventStreamService = new(
sessionManager,
options,
mapper,
_metrics,
NullDashboardEventBroadcaster.Instance,
NullLogger<EventStreamService>.Instance);
@@ -158,7 +158,8 @@ public sealed class EventStreamServiceTests
}
/// <summary>Verifies that event queue overflow faults the session and reports the overflow metric.</summary>
[Fact]
// TODO(Task 5): re-enable and re-target this to per-subscriber backpressure isolation.
[Fact(Skip = "Backpressure/overflow policy moved into SessionEventDistributor in Task 4; re-enabled and re-targeted to per-subscriber isolation in Task 5.")]
public async Task StreamEventsAsync_WhenStreamQueueOverflows_FaultsSessionAndReportsOverflow()
{
FakeWorkerClient workerClient = new();
@@ -188,7 +189,8 @@ public sealed class EventStreamServiceTests
}
/// <summary>Verifies that the disconnect backpressure policy disconnects the subscriber without faulting the session.</summary>
[Fact]
// TODO(Task 5): re-enable and re-target this to per-subscriber backpressure isolation.
[Fact(Skip = "Backpressure/overflow policy moved into SessionEventDistributor in Task 4; re-enabled and re-targeted to per-subscriber isolation in Task 5.")]
public async Task StreamEventsAsync_WhenStreamQueueOverflowsWithDisconnectPolicy_LeavesSessionReady()
{
FakeWorkerClient workerClient = new();
@@ -347,7 +349,6 @@ public sealed class EventStreamServiceTests
BackpressurePolicy = backpressurePolicy,
},
}),
new MxAccessGrpcMapper(),
metrics ?? new GatewayMetrics(),
dashboardEventBroadcaster ?? NullDashboardEventBroadcaster.Instance,
NullLogger<EventStreamService>.Instance);
@@ -393,7 +394,8 @@ public sealed class EventStreamServiceTests
private static GatewaySession CreateReadySession(
FakeWorkerClient workerClient,
string sessionId = "session-events")
string sessionId = "session-events",
int queueCapacity = 8)
{
GatewaySession session = new(
sessionId,
@@ -401,12 +403,19 @@ public sealed class EventStreamServiceTests
"pipe",
"nonce",
"client",
ownerKeyId: null,
"client-session",
"client-correlation",
TimeSpan.FromSeconds(30),
TimeSpan.FromSeconds(30),
TimeSpan.FromSeconds(10),
DateTimeOffset.UtcNow);
TimeSpan.FromMinutes(30),
DateTimeOffset.UtcNow,
new SessionEventStreaming(
new MxAccessGrpcMapper(),
new EventOptions { QueueCapacity = queueCapacity },
NullLogger<SessionEventDistributor>.Instance,
TimeProvider.System));
session.AttachWorkerClient(workerClient);
session.MarkReady();