refactor(sessions): derive subscriber mode from session config; close Task 8 review nits

Remove the per-call allowMultipleSubscribers param from AttachEventSubscriber and
derive the mode internally from _eventStreaming.AllowMultipleEventSubscribers — the
same source SessionEventDistributor uses for singleSubscriberMode — so the two can
never structurally diverge. The maxSubscribers cap param is kept because
MaxEventSubscribersPerSession lives in SessionOptions, which the session does not hold
directly (only EventOptions flows through SessionEventStreaming).

Other nits:
- SubscriberCount XML doc clarifies it includes internal subscribers and differs from
  GatewaySession.ActiveEventSubscriberCount (external/gRPC only).
- SingleSubscriberMode_LoneExternalOverflow test: add Assert.Equal(1, observedSet) guard
  before the value assertion so the test cannot pass vacuously if the handler never fired.
- GatewayOptionsValidator.ValidateSessions: add explanatory code comment documenting why
  !AllowMultipleEventSubscribers && MaxEventSubscribersPerSession > 1 is NOT rejected as
  a hard error (the default config ships with this combination; the cap is simply unused
  in single-subscriber mode, not a behavior bug).
- GatewaySession.DetachEventSubscriber: add Debug.Assert before the clamp so a genuine
  double-decrement surfaces in debug builds.
This commit is contained in:
Joseph Doherty
2026-06-15 15:53:27 -04:00
parent ac42783e36
commit 281e00b300
8 changed files with 62 additions and 43 deletions
@@ -181,6 +181,15 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
options.MaxEventSubscribersPerSession,
"MxGateway:Sessions:MaxEventSubscribersPerSession must be greater than zero.",
builder);
// NOTE: We intentionally do NOT reject !AllowMultipleEventSubscribers &&
// MaxEventSubscribersPerSession > 1 as a hard validation error here. The default
// SessionOptions ships with AllowMultipleEventSubscribers=false and
// MaxEventSubscribersPerSession=8; making those defaults a validation failure would
// break every deployment that has not explicitly set the cap. The cap is simply
// ignored in single-subscriber mode (AttachEventSubscriber derives effectiveCap=1),
// so the only practical consequence of the apparent inconsistency is a dead config
// knob, not incorrect behavior.
}
private static void ValidateEvents(EventOptions options, ValidationBuilder builder)
@@ -68,8 +68,10 @@ public sealed class EventStreamService(
// No `using` here — subscriber.Dispose() is called exactly once in the finally
// block below, which also disposes the reader. A `using` declaration would add a
// second Dispose on the same path and double-decrement the session subscriber count.
// The subscriber mode (single vs. multi) is derived inside AttachEventSubscriber from
// the session's own SessionEventStreaming.AllowMultipleEventSubscribers field — the
// same source the distributor uses — so the two cannot diverge.
IEventSubscriberLease subscriber = session.AttachEventSubscriber(
options.Value.Sessions.AllowMultipleEventSubscribers,
options.Value.Sessions.MaxEventSubscribersPerSession);
int streamQueueDepth = 0;
@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
@@ -684,31 +685,31 @@ public sealed class GatewaySession
/// <see cref="MxEvent"/>s for this subscriber. The returned lease, when disposed,
/// unregisters the distributor subscriber AND decrements the active-subscriber count.
/// </summary>
/// <param name="allowMultipleSubscribers">
/// When <see langword="false"/>, single-subscriber mode: a second concurrent EXTERNAL
/// subscriber is rejected with <see cref="SessionManagerErrorCode.EventSubscriberAlreadyActive"/>.
/// When <see langword="true"/>, multi-subscriber mode: up to
/// <paramref name="maxSubscribers"/> concurrent EXTERNAL subscribers are allowed; the
/// next attach is rejected with
/// <see cref="SessionManagerErrorCode.EventSubscriberLimitReached"/>.
/// </param>
/// <param name="maxSubscribers">
/// Maximum concurrent external subscribers in multi-subscriber mode
/// (<c>MxGateway:Sessions:MaxEventSubscribersPerSession</c>). Ignored when
/// <paramref name="allowMultipleSubscribers"/> is <see langword="false"/> (the effective
/// cap is then 1). The gateway-owned internal dashboard subscriber is registered
/// directly on the distributor and is NOT counted here, so it never consumes cap budget.
/// (<c>MxGateway:Sessions:MaxEventSubscribersPerSession</c>). Ignored when the
/// session is in single-subscriber mode (<c>AllowMultipleEventSubscribers == false</c>);
/// the effective cap is then 1. The gateway-owned internal dashboard subscriber is
/// registered directly on the distributor and is NOT counted here, so it never
/// consumes cap budget.
/// </param>
/// <remarks>
/// The count-check-and-increment runs atomically under <c>_syncRoot</c>, so two
/// concurrent attaches racing toward the cap can never both succeed past it. On
/// distributor-register failure the count is rolled back (see the catch below).
/// The subscriber mode is derived internally from
/// <see cref="SessionEventStreaming.AllowMultipleEventSubscribers"/> — the same source
/// the <see cref="SessionEventDistributor"/> uses to gate its FailFast decision — so
/// the cap-enforcement mode and the distributor's <c>singleSubscriberMode</c> field
/// cannot diverge. The count-check-and-increment runs atomically under
/// <c>_syncRoot</c>, so two concurrent attaches racing toward the cap can never both
/// succeed past it. On distributor-register failure the count is rolled back (see the
/// catch below).
/// </remarks>
public IEventSubscriberLease AttachEventSubscriber(bool allowMultipleSubscribers, int maxSubscribers)
public IEventSubscriberLease AttachEventSubscriber(int maxSubscribers)
{
// Effective cap: 1 in single-subscriber mode, otherwise the configured maximum
// (clamped to at least 1 so a misconfigured non-positive value can never deadlock
// attaches in multi-subscriber mode).
// Derive the mode from the same source the distributor uses so the two can never
// diverge. Effective cap: 1 in single-subscriber mode, otherwise the configured
// maximum (clamped to at least 1 so a misconfigured non-positive value can never
// deadlock attaches in multi-subscriber mode).
bool allowMultipleSubscribers = _eventStreaming.AllowMultipleEventSubscribers;
int effectiveCap = allowMultipleSubscribers ? Math.Max(1, maxSubscribers) : 1;
lock (_syncRoot)
@@ -1493,6 +1494,10 @@ public sealed class GatewaySession
{
lock (_syncRoot)
{
// Assert in debug so a genuine double-decrement (a logic error) surfaces
// loudly; the clamp below keeps release builds safe if it somehow fires.
Debug.Assert(_activeEventSubscriberCount > 0,
"DetachEventSubscriber called with _activeEventSubscriberCount already at 0 — possible double-dispose.");
if (_activeEventSubscriberCount > 0)
{
_activeEventSubscriberCount--;
@@ -236,7 +236,11 @@ public sealed class SessionEventDistributor : IAsyncDisposable
}
/// <summary>
/// Gets the count of currently-registered subscribers.
/// Gets the count of currently-registered subscribers. This count INCLUDES internal
/// subscribers (e.g. the gateway-owned dashboard mirror registered via
/// <c>Register(isInternal: true)</c>), and therefore differs from
/// <see cref="GatewaySession.ActiveEventSubscriberCount"/>, which tracks only external
/// (gRPC) subscribers and excludes the internal dashboard subscriber.
/// </summary>
public int SubscriberCount => _subscribers.Count;