Merge stream A (session lifecycle: startup orphan, sweep metrics, stopping gate, kill-fallback Closed, unattached grace, worker-exit faults session)
This commit is contained in:
@@ -325,6 +325,10 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
options.FaultedGraceSeconds,
|
||||
"MxGateway:Sessions:FaultedGraceSeconds must be zero or greater (0 reaps a faulted session on the next sweep).",
|
||||
builder);
|
||||
AddIfNegative(
|
||||
options.UnattachedSessionGraceSeconds,
|
||||
"MxGateway:Sessions:UnattachedSessionGraceSeconds must be zero or greater (0 disables reaping of never-subscribed sessions).",
|
||||
builder);
|
||||
AddIfNegative(
|
||||
options.WorkerReadyWaitTimeoutMs,
|
||||
"MxGateway:Sessions:WorkerReadyWaitTimeoutMs must be greater than or equal to zero.",
|
||||
|
||||
@@ -57,6 +57,28 @@ public sealed class SessionOptions
|
||||
/// </summary>
|
||||
public int FaultedGraceSeconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the idle window, in seconds, after which the lease monitor reaps a session that
|
||||
/// has <em>never</em> attached an event-stream subscriber. A client that opens a session
|
||||
/// and vanishes before it ever streams (for example OtOpcUa's <c>GalaxyDriver</c> failing
|
||||
/// part-way through its start-up) otherwise pins a session slot and a live x86 worker for
|
||||
/// the full <see cref="DefaultLeaseSeconds"/> (30 minutes by default). The window is
|
||||
/// measured from the later of the session becoming Ready and its most recent unary
|
||||
/// command, so a slow worker start does not eat it and a client that only ever reads and
|
||||
/// writes is not reaped while it keeps calling; it stops applying the moment the session's
|
||||
/// first subscriber — external, or the gateway's own internal alarm-monitor subscriber —
|
||||
/// attaches (from then on <see cref="DetachGraceSeconds"/> and the normal lease govern
|
||||
/// it). A value of <c>0</c> (the default) disables the sweep. Must be greater than or
|
||||
/// equal to zero.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Set it comfortably above the time a well-behaved client needs between
|
||||
/// <c>OpenSession</c> and its first <c>StreamEvents</c>, and remember the close lands on
|
||||
/// the next sweep after the window elapses (up to <see cref="LeaseSweepIntervalSeconds"/>
|
||||
/// later).
|
||||
/// </remarks>
|
||||
public int UnattachedSessionGraceSeconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether multiple event subscribers are allowed per session.
|
||||
/// </summary>
|
||||
|
||||
@@ -1104,6 +1104,7 @@ public sealed class MxAccessGatewayService(
|
||||
SessionManagerErrorCode.OpenFailed => StatusCode.Unavailable,
|
||||
SessionManagerErrorCode.CloseFailed => StatusCode.Unavailable,
|
||||
SessionManagerErrorCode.PermissionDenied => StatusCode.PermissionDenied,
|
||||
SessionManagerErrorCode.GatewayStopping => StatusCode.Unavailable,
|
||||
_ => StatusCode.Unavailable,
|
||||
};
|
||||
|
||||
|
||||
@@ -26,9 +26,11 @@ public sealed class GatewaySession
|
||||
private DateTimeOffset _lastClientActivityAt;
|
||||
private DateTimeOffset? _leaseExpiresAt;
|
||||
private bool _closeStarted;
|
||||
private bool _openRecorded;
|
||||
private int _activeEventSubscriberCount;
|
||||
private readonly TimeSpan _detachGrace;
|
||||
private readonly TimeSpan _faultedGrace;
|
||||
private readonly TimeSpan _unattachedGrace;
|
||||
private readonly TimeSpan _workerReadyWaitTimeout;
|
||||
private DateTimeOffset? _detachedAtUtc;
|
||||
private DateTimeOffset? _faultedAtUtc;
|
||||
@@ -37,6 +39,12 @@ public sealed class GatewaySession
|
||||
// FAILED first attach — which still runs the rollback DetachEventSubscriber from the
|
||||
// attach catch path — does not push a never-subscribed session into the grace window.
|
||||
private bool _everHadEventSubscriber;
|
||||
// True once an in-process (internal, non-counted) subscriber attached via
|
||||
// AttachInternalEventSubscriber. Consulted only by the unattached-grace sweep rule.
|
||||
private bool _everHadInternalEventSubscriber;
|
||||
// Stamped once, when the session first becomes Ready; the unattached-grace idle clock cannot
|
||||
// start before it.
|
||||
private DateTimeOffset? _readyAtUtc;
|
||||
private SessionEventDistributor? _eventDistributor;
|
||||
private bool _eventDistributorStarted;
|
||||
// Set under _syncRoot when DisposeAsync nulls _eventDistributor, making the
|
||||
@@ -168,6 +176,12 @@ public sealed class GatewaySession
|
||||
/// The tags come from the owner key, never from the client's wire request, so a client
|
||||
/// cannot label its own session with another tenant's tag.
|
||||
/// </param>
|
||||
/// <param name="unattachedGrace">
|
||||
/// Idle window after which the lease monitor reaps a Ready session that has never attached
|
||||
/// an event subscriber (see <see cref="IsUnattachedGraceExpired"/>), so a client that
|
||||
/// opens and vanishes does not pin a worker for the full lease. <see cref="TimeSpan.Zero"/>
|
||||
/// (the default) disables it.
|
||||
/// </param>
|
||||
public GatewaySession(
|
||||
string sessionId,
|
||||
string backendName,
|
||||
@@ -187,7 +201,8 @@ public sealed class GatewaySession
|
||||
TimeSpan workerReadyWaitTimeout = default,
|
||||
ArrayAddressNormalizer? addressNormalizer = null,
|
||||
TimeSpan faultedGrace = default,
|
||||
IReadOnlyList<string>? ownerDashboardTags = null)
|
||||
IReadOnlyList<string>? ownerDashboardTags = null,
|
||||
TimeSpan unattachedGrace = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
{
|
||||
@@ -230,6 +245,7 @@ public sealed class GatewaySession
|
||||
_eventStreaming = eventStreaming ?? SessionEventStreaming.Default;
|
||||
_detachGrace = detachGrace > TimeSpan.Zero ? detachGrace : TimeSpan.Zero;
|
||||
_faultedGrace = faultedGrace > TimeSpan.Zero ? faultedGrace : TimeSpan.Zero;
|
||||
_unattachedGrace = unattachedGrace > TimeSpan.Zero ? unattachedGrace : TimeSpan.Zero;
|
||||
_workerReadyWaitTimeout = workerReadyWaitTimeout > TimeSpan.Zero ? workerReadyWaitTimeout : TimeSpan.Zero;
|
||||
_addressNormalizer = addressNormalizer;
|
||||
}
|
||||
@@ -413,13 +429,128 @@ public sealed class GatewaySession
|
||||
/// Attaches the worker client for this session.
|
||||
/// </summary>
|
||||
/// <param name="workerClient">Worker client to attach.</param>
|
||||
public void AttachWorkerClient(IWorkerClient workerClient)
|
||||
/// <returns>
|
||||
/// <see langword="true"/> when the client was attached; <see langword="false"/> when the
|
||||
/// session is already <see cref="SessionState.Closing"/>, <see cref="SessionState.Closed"/>
|
||||
/// or <see cref="SessionState.Faulted"/>, or has been disposed — the caller owns the client
|
||||
/// and must discard it.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// The session is registered before its worker is created, so an admin kill or the host
|
||||
/// drain can close it while the worker is still starting (up to the startup timeout). A
|
||||
/// worker client arriving after that must not be stored on the dead session: nothing would
|
||||
/// ever shut it down (the close already ran with no client to stop) and the open would hand
|
||||
/// a closed session back to the caller as if it had succeeded. The decision is taken under
|
||||
/// <c>_syncRoot</c> so it cannot interleave with the close's state write.
|
||||
/// </remarks>
|
||||
public bool AttachWorkerClient(IWorkerClient workerClient)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workerClient);
|
||||
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (_state is SessionState.Closing or SessionState.Closed or SessionState.Faulted
|
||||
|| _eventDistributorDisposed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_workerClient = workerClient;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the end of the open sequence — attach the worker client, mark the session
|
||||
/// <see cref="SessionState.Ready"/>, record it as opened — under the per-session close
|
||||
/// gate, so a concurrent <see cref="CloseAsync"/> or
|
||||
/// <see cref="KillWorkerWithCloseGateAsync"/> runs either entirely before it (the attach
|
||||
/// is refused) or entirely after it (an ordinary close of a Ready session). Without the
|
||||
/// gate a kill could land between the attach and the open accounting and the two teardown
|
||||
/// bookkeepings would disagree about whether the session ever counted as open.
|
||||
/// </summary>
|
||||
/// <param name="workerClient">Worker client to attach.</param>
|
||||
/// <param name="onReady">
|
||||
/// Invoked inside the gate once the session is Ready; the caller records its open-session
|
||||
/// accounting here, and <see cref="OpenRecorded"/> is set right after it returns.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// <see langword="true"/> when the session became Ready; <see langword="false"/> when the
|
||||
/// attach was refused (see <see cref="AttachWorkerClient"/>), including when the session was
|
||||
/// disposed before the gate could be taken.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// Not cancellable: the worker client already exists, so the only acceptable outcomes are
|
||||
/// "attached" or "refused, caller discards it". The gate is held only for the duration of
|
||||
/// a close or kill, so the wait is bounded by the worker shutdown timeout. The caller
|
||||
/// still decides what to do about a cancelled open before calling this.
|
||||
/// </remarks>
|
||||
internal async ValueTask<bool> TryAttachWorkerClientAndMarkReadyAsync(
|
||||
IWorkerClient workerClient,
|
||||
Action onReady)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workerClient);
|
||||
ArgumentNullException.ThrowIfNull(onReady);
|
||||
|
||||
try
|
||||
{
|
||||
await _closeLock.WaitAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Disposed while the worker was starting: the session has been torn down.
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!AttachWorkerClient(workerClient))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
MarkReady();
|
||||
onReady();
|
||||
lock (_syncRoot)
|
||||
{
|
||||
_openRecorded = true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
_closeLock.Release();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// A double-dispose that slipped past DisposeAsync's own gate; the session is torn
|
||||
// down either way and there is no one left to hand the slot to.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the open-session accounting
|
||||
/// (<c>GatewayMetrics.SessionOpened</c>) has been recorded for this session. Every
|
||||
/// teardown path consults it so a session closed or killed while its worker was still
|
||||
/// starting — never counted as opened — is not counted as closed either.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Written only by <see cref="TryAttachWorkerClientAndMarkReadyAsync"/>, inside the close
|
||||
/// gate; read by teardown paths that hold (or just released) the same gate, so the two
|
||||
/// accountings never observe a half-finished open.
|
||||
/// </remarks>
|
||||
internal bool OpenRecorded
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
return _openRecorded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,6 +612,14 @@ public sealed class GatewaySession
|
||||
public void MarkReady()
|
||||
{
|
||||
TransitionTo(SessionState.Ready);
|
||||
lock (_syncRoot)
|
||||
{
|
||||
// The unattached-grace idle clock starts here, not at open: the client cannot
|
||||
// subscribe before it has the session, and a slow worker start must not hand it a
|
||||
// session the next sweep reaps.
|
||||
_readyAtUtc ??= _eventStreaming.TimeProvider.GetUtcNow();
|
||||
}
|
||||
|
||||
StartDashboardMirror();
|
||||
}
|
||||
|
||||
@@ -634,6 +773,15 @@ public sealed class GatewaySession
|
||||
SessionEventDistributor distributor = EnsureDistributorCreated(out bool startNow);
|
||||
IEventSubscriberLease lease = distributor.Register(isInternal: true);
|
||||
StartPumpIfRequested(distributor, startNow);
|
||||
lock (_syncRoot)
|
||||
{
|
||||
// An in-process consumer (the alarm monitor) is a real subscriber for the
|
||||
// unattached-grace rule: its session is attached, not abandoned. It stays out of
|
||||
// _everHadEventSubscriber on purpose — that latch drives detach-grace, which is keyed
|
||||
// on EXTERNAL subscribers only.
|
||||
_everHadInternalEventSubscriber = true;
|
||||
}
|
||||
|
||||
return lease;
|
||||
}
|
||||
|
||||
@@ -856,12 +1004,54 @@ public sealed class GatewaySession
|
||||
IWorkerClient workerClient = await GetReadyWorkerClientAsync(cancellationToken).ConfigureAwait(false);
|
||||
TouchClientActivity(_eventStreaming.TimeProvider.GetUtcNow());
|
||||
|
||||
await foreach (WorkerEvent workerEvent in workerClient
|
||||
.ReadEventsAsync(cancellationToken)
|
||||
.WithCancellation(cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
try
|
||||
{
|
||||
yield return mapper.MapEvent(workerEvent);
|
||||
await foreach (WorkerEvent workerEvent in workerClient
|
||||
.ReadEventsAsync(cancellationToken)
|
||||
.WithCancellation(cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
yield return mapper.MapEvent(workerEvent);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// The worker event stream only ends when the worker client has reached a terminal
|
||||
// state (it enters Closed/Faulted BEFORE completing the channel) or the pump is being
|
||||
// torn down. This is the session's one always-on view of the worker, so it is where
|
||||
// a worker that went away on its own is noticed.
|
||||
ObserveWorkerTerminalState();
|
||||
}
|
||||
}
|
||||
|
||||
// IWorkerClient has no state-change callback, so a worker that reached Closed/Faulted
|
||||
// without the gateway closing it — the client sent ShutdownWorker and the worker exited,
|
||||
// the worker crashed, its pipe broke, a heartbeat timed out — used to leave the session
|
||||
// reporting Ready until its lease expired (live finding). The session follows the worker
|
||||
// into Faulted: it is permanently unusable (every command fails the readiness check), stays
|
||||
// observable via GetSessionStatus with the reason, and the faulted-session sweep reaps it
|
||||
// after FaultedGraceSeconds, accounting SessionClosed exactly once there. Not Closed: Closed
|
||||
// means the gateway closed it, and a Closed session still in the registry would linger until
|
||||
// its lease expired with nothing reaping it. Only a Ready session follows — during a
|
||||
// gateway-initiated close the worker closing is the expected outcome, not a fault.
|
||||
private void ObserveWorkerTerminalState()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (_state is not SessionState.Ready || _workerClient is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
WorkerClientState workerState = _workerClient.State;
|
||||
if (workerState is not (WorkerClientState.Closed or WorkerClientState.Faulted))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
MarkFaulted(
|
||||
$"Worker for session {SessionId} reached {workerState} outside a gateway-initiated close "
|
||||
+ "(worker exited or faulted).");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -901,18 +1091,6 @@ public sealed class GatewaySession
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extends the session lease to the specified expiration time.
|
||||
/// </summary>
|
||||
/// <param name="leaseExpiresAt">Timestamp when the lease expires.</param>
|
||||
public void ExtendLease(DateTimeOffset leaseExpiresAt)
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
_leaseExpiresAt = leaseExpiresAt;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the session lease has expired.
|
||||
/// </summary>
|
||||
@@ -969,6 +1147,25 @@ public sealed class GatewaySession
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a <see cref="SessionState.Ready"/> session that has <em>never</em>
|
||||
/// attached an event-stream subscriber has been idle — no unary command since open or
|
||||
/// since its last one — for at least the configured unattached grace. The lease monitor
|
||||
/// reaps such a session exactly as it reaps an expired lease, so a client that opened and
|
||||
/// vanished does not pin a worker for the full lease. Always <see langword="false"/> when
|
||||
/// the grace is disabled, when the session ever had a subscriber (detach-grace and the
|
||||
/// normal lease govern it from then on) or when one is attached now.
|
||||
/// </summary>
|
||||
/// <param name="now">Current timestamp for comparison.</param>
|
||||
/// <returns><see langword="true"/> if the session is reapable as never-subscribed and idle; otherwise <see langword="false"/>.</returns>
|
||||
public bool IsUnattachedGraceExpired(DateTimeOffset now)
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
return IsUnattachedGraceExpiredCore(now);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches an event subscriber and returns a lease whose
|
||||
/// <see cref="IEventSubscriberLease.Reader"/> reads the fanned public
|
||||
@@ -1627,6 +1824,7 @@ public sealed class GatewaySession
|
||||
return new SessionCloseResult(SessionId, SessionState.Closed, AlreadyClosed: true);
|
||||
}
|
||||
|
||||
Exception? workerShutdownFailure = null;
|
||||
if (_workerClient is not null)
|
||||
{
|
||||
try
|
||||
@@ -1635,6 +1833,12 @@ public sealed class GatewaySession
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// The kill is the documented fallback for a worker that will not shut down
|
||||
// (typically a ShutdownTimeout on a hung worker). When it succeeds the
|
||||
// worker is gone and this close has done its job, so it completes as a
|
||||
// normal close carrying the shutdown failure for the caller to log; only a
|
||||
// kill that ALSO fails is a close failure (SessionCloseStartedException →
|
||||
// CloseFailed), because then a worker may still be running.
|
||||
try
|
||||
{
|
||||
_workerClient.Kill(reason);
|
||||
@@ -1646,12 +1850,12 @@ public sealed class GatewaySession
|
||||
new AggregateException(exception, killException));
|
||||
}
|
||||
|
||||
throw;
|
||||
workerShutdownFailure = exception;
|
||||
}
|
||||
}
|
||||
|
||||
MarkClosed();
|
||||
return new SessionCloseResult(SessionId, SessionState.Closed, alreadyClosing);
|
||||
return new SessionCloseResult(SessionId, SessionState.Closed, alreadyClosing, workerShutdownFailure);
|
||||
}
|
||||
catch (Exception exception) when (exception is not SessionCloseStartedException)
|
||||
{
|
||||
@@ -1718,6 +1922,15 @@ public sealed class GatewaySession
|
||||
/// committing to <c>Closing</c>, so a reattach that wins the race leaves the session
|
||||
/// in <c>Ready</c> and usable.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This is a <em>reservation</em>, not the close itself: it flips the state so no
|
||||
/// reattach can revive the session and no other sweep pass re-selects it, but it
|
||||
/// deliberately leaves <c>_closeStarted</c> untouched. The sweep's own
|
||||
/// <see cref="CloseAsync"/> that follows is the first close to run, and must report
|
||||
/// <c>AlreadyClosed: false</c> so the manager accounts <c>SessionClosed</c> for it;
|
||||
/// setting <c>_closeStarted</c> here made that close look like a second, concurrent one
|
||||
/// and every sweep-driven close went unaccounted.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool TryBeginCloseIfExpired(DateTimeOffset now, out bool alreadyClosing)
|
||||
{
|
||||
@@ -1731,7 +1944,10 @@ public sealed class GatewaySession
|
||||
|
||||
// Re-verify eligibility atomically. If a subscriber reattached between the sweep's
|
||||
// eligibility check and this point, neither condition holds and we decline.
|
||||
bool eligible = IsLeaseExpiredCore(now) || IsFaultedReapableCore(now) || IsDetachGraceExpiredCore(now);
|
||||
bool eligible = IsLeaseExpiredCore(now)
|
||||
|| IsFaultedReapableCore(now)
|
||||
|| IsDetachGraceExpiredCore(now)
|
||||
|| IsUnattachedGraceExpiredCore(now);
|
||||
if (!eligible)
|
||||
{
|
||||
alreadyClosing = false;
|
||||
@@ -1739,7 +1955,6 @@ public sealed class GatewaySession
|
||||
}
|
||||
|
||||
alreadyClosing = _closeStarted;
|
||||
_closeStarted = true;
|
||||
_state = SessionState.Closing;
|
||||
return true;
|
||||
}
|
||||
@@ -1763,6 +1978,29 @@ public sealed class GatewaySession
|
||||
|| _faultedAtUtc is null
|
||||
|| now - _faultedAtUtc.Value >= _faultedGrace);
|
||||
|
||||
// Ready only: a session still starting its worker is not idle, it is waiting, and reaping it
|
||||
// mid-startup would turn a slow worker start into an OpenFailed for a client that did nothing
|
||||
// wrong. Idle is measured from the later of Ready and the last client activity (last unary
|
||||
// command) rather than from open, so a slow start does not eat the window and a unary-only
|
||||
// client that keeps calling is never reaped by this rule. An internal (in-process) subscriber
|
||||
// — the gateway's own alarm monitor — counts as attached, or the gateway would reap its own
|
||||
// monitor session every cycle.
|
||||
private bool IsUnattachedGraceExpiredCore(DateTimeOffset now)
|
||||
{
|
||||
if (_unattachedGrace <= TimeSpan.Zero
|
||||
|| _state is not SessionState.Ready
|
||||
|| _readyAtUtc is null
|
||||
|| _everHadEventSubscriber
|
||||
|| _everHadInternalEventSubscriber
|
||||
|| _activeEventSubscriberCount != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
DateTimeOffset idleSince = _lastClientActivityAt > _readyAtUtc.Value ? _lastClientActivityAt : _readyAtUtc.Value;
|
||||
return now - idleSince >= _unattachedGrace;
|
||||
}
|
||||
|
||||
// Final terminal transition; under _syncRoot to keep _state writes single-lock.
|
||||
// Closed is unconditionally terminal — TransitionTo refuses to overwrite it —
|
||||
// so we don't need to re-check the precondition here.
|
||||
|
||||
@@ -2,7 +2,21 @@ using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||
|
||||
/// <summary>Outcome of closing a session.</summary>
|
||||
/// <param name="SessionId">Identifier of the closed session.</param>
|
||||
/// <param name="FinalState">State the session ended in.</param>
|
||||
/// <param name="AlreadyClosed">
|
||||
/// <see langword="true"/> when the session was already closed (or being closed by another
|
||||
/// caller) when this close acquired the close lock, so this call did no teardown of its own.
|
||||
/// </param>
|
||||
/// <param name="WorkerShutdownFailure">
|
||||
/// The exception the worker's graceful shutdown threw when the close had to fall back to
|
||||
/// killing the worker; <see langword="null"/> when the worker shut down cleanly. The close is
|
||||
/// still a success — the worker is gone and the session is closed — so this is diagnostic
|
||||
/// detail for the caller's log, not an error.
|
||||
/// </param>
|
||||
public sealed record SessionCloseResult(
|
||||
string SessionId,
|
||||
SessionState FinalState,
|
||||
bool AlreadyClosed);
|
||||
bool AlreadyClosed,
|
||||
Exception? WorkerShutdownFailure = null);
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.ExceptionServices;
|
||||
using System.Security.Cryptography;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -20,6 +21,7 @@ public sealed class SessionManager : ISessionManager
|
||||
public const string LeaseExpiredReason = "lease-expired";
|
||||
public const string DetachGraceExpiredReason = "detach-grace-expired";
|
||||
public const string FaultedReason = "faulted-reaped";
|
||||
public const string UnattachedGraceExpiredReason = "unattached-grace-expired";
|
||||
|
||||
// Bounded so a mass expiry (or a host stop with a full registry) cannot stampede
|
||||
// worker-process teardown: every concurrent close is one x86 worker being shut down or
|
||||
@@ -27,6 +29,13 @@ public sealed class SessionManager : ISessionManager
|
||||
// registry down at once.
|
||||
private const int MaxParallelSessionCloses = 4;
|
||||
|
||||
// ShutdownAsync re-snapshots the registry until it is empty so an open that registered its
|
||||
// session just after a snapshot is still drained. The stopping gate in OpenSessionAsync is
|
||||
// what actually closes that window (it is re-checked after registration), so the loop is a
|
||||
// backstop and a small bound is enough; anything still registered after the last pass is
|
||||
// logged and left to the orphan terminator on the next start.
|
||||
private const int MaxShutdownDrainPasses = 3;
|
||||
|
||||
private readonly ISessionRegistry _registry;
|
||||
private readonly ISessionWorkerClientFactory _workerClientFactory;
|
||||
private readonly GatewayMetrics _metrics;
|
||||
@@ -39,6 +48,12 @@ public sealed class SessionManager : ISessionManager
|
||||
private readonly Dashboard.Hubs.IDashboardEventBroadcaster? _dashboardEventBroadcaster;
|
||||
private readonly ArrayAddressNormalizer? _addressNormalizer;
|
||||
|
||||
// 0 = accepting opens, 1 = stopping. Set by the host's ApplicationStopping signal (which fires
|
||||
// before hosted services stop, while Kestrel still accepts gRPC calls) and, as a fallback for
|
||||
// hosts without a lifetime, at the start of ShutdownAsync. Never reset: a manager that has
|
||||
// begun draining does not come back.
|
||||
private int _stopping;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="SessionManager"/>.
|
||||
/// </summary>
|
||||
@@ -61,6 +76,12 @@ public sealed class SessionManager : ISessionManager
|
||||
/// metadata; handed to each session so the normalization runs at the outbound choke point.
|
||||
/// Null in unit tests that do not exercise array-write ergonomics.
|
||||
/// </param>
|
||||
/// <param name="applicationLifetime">
|
||||
/// Host lifetime whose <c>ApplicationStopping</c> closes the open gate as soon as the host
|
||||
/// begins stopping — before hosted services stop and while Kestrel is still accepting gRPC
|
||||
/// calls — so no session is opened behind the shutdown drain. Null in unit tests; the gate
|
||||
/// then closes when <see cref="ShutdownAsync"/> starts.
|
||||
/// </param>
|
||||
public SessionManager(
|
||||
ISessionRegistry registry,
|
||||
ISessionWorkerClientFactory workerClientFactory,
|
||||
@@ -71,7 +92,8 @@ public sealed class SessionManager : ISessionManager
|
||||
Grpc.MxAccessGrpcMapper? eventMapper = null,
|
||||
ILogger<SessionEventDistributor>? distributorLogger = null,
|
||||
Dashboard.Hubs.IDashboardEventBroadcaster? dashboardEventBroadcaster = null,
|
||||
ArrayAddressNormalizer? addressNormalizer = null)
|
||||
ArrayAddressNormalizer? addressNormalizer = null,
|
||||
IHostApplicationLifetime? applicationLifetime = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_workerClientFactory = workerClientFactory ?? throw new ArgumentNullException(nameof(workerClientFactory));
|
||||
@@ -85,6 +107,10 @@ public sealed class SessionManager : ISessionManager
|
||||
_addressNormalizer = addressNormalizer;
|
||||
_options = options.Value;
|
||||
_sessionSlots = new SemaphoreSlim(_options.Sessions.MaxSessions, _options.Sessions.MaxSessions);
|
||||
|
||||
// The registration is never disposed: the manager is a host singleton that lives exactly
|
||||
// as long as the lifetime it observes.
|
||||
applicationLifetime?.ApplicationStopping.Register(MarkStopping);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -104,10 +130,11 @@ public sealed class SessionManager : ISessionManager
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ThrowIfStopping();
|
||||
EnsureSessionCapacity();
|
||||
|
||||
GatewaySession? session = null;
|
||||
bool sessionOpenedRecorded = false;
|
||||
bool registered = false;
|
||||
try
|
||||
{
|
||||
session = CreateSession(request, clientIdentity, ownerKeyId, ownerDashboardTags);
|
||||
@@ -118,36 +145,58 @@ public sealed class SessionManager : ISessionManager
|
||||
$"Session id collision while opening session {session.SessionId}.");
|
||||
}
|
||||
|
||||
registered = true;
|
||||
|
||||
// Re-check after registration. The stopping flag is written before ShutdownAsync takes
|
||||
// its registry snapshot, so an open that does not observe it here registered its
|
||||
// session before that snapshot and will be drained; one that does observe it rolls
|
||||
// back itself. Either way no session is created behind the drain.
|
||||
ThrowIfStopping();
|
||||
session.TransitionTo(SessionState.StartingWorker);
|
||||
IWorkerClient workerClient = await _workerClientFactory
|
||||
.CreateAsync(session, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
session.AttachWorkerClient(workerClient);
|
||||
session.MarkReady();
|
||||
_metrics.SessionOpened();
|
||||
sessionOpenedRecorded = true;
|
||||
// From here on a live worker exists and nothing but this method knows about it: every
|
||||
// way out of this block other than "attached and Ready" must discard (kill + dispose)
|
||||
// it, or it is an orphan the gateway cannot reattach. The session has been registered
|
||||
// (and killable / drainable) for the whole worker startup, so the attach is refused
|
||||
// if a kill or the host drain closed it meanwhile — it must not be returned on a dead
|
||||
// session; and a caller that abandoned the open while the worker was starting gets
|
||||
// no session either.
|
||||
bool ready;
|
||||
try
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
ready = await session
|
||||
.TryAttachWorkerClientAndMarkReadyAsync(workerClient, _metrics.SessionOpened)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await DiscardWorkerClientAsync(session, workerClient).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
if (!ready)
|
||||
{
|
||||
await DiscardWorkerClientAsync(session, workerClient).ConfigureAwait(false);
|
||||
throw new InvalidOperationException(
|
||||
$"Session {session.SessionId} was closed while its worker was starting.");
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
catch (SessionManagerException exception) when (exception.ErrorCode == SessionManagerErrorCode.GatewayStopping)
|
||||
{
|
||||
// Not an open failure: the gateway is going away and the caller should retry against
|
||||
// the restarted one, so no fault is recorded and the code is surfaced as-is.
|
||||
await RollBackOpenAsync(session, registered, exception).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
session?.MarkFaulted(exception.Message);
|
||||
if (session is not null)
|
||||
{
|
||||
_registry.TryRemove(session.SessionId, out _);
|
||||
await session.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// If SessionOpened() already incremented the open-session gauge,
|
||||
// a failure after that point (e.g. auto-subscribe rejection) must
|
||||
// decrement it again so mxgateway.sessions.open does not leak.
|
||||
if (sessionOpenedRecorded)
|
||||
{
|
||||
_metrics.SessionRemoved();
|
||||
}
|
||||
|
||||
ReleaseSessionSlot();
|
||||
await RollBackOpenAsync(session, registered, exception).ConfigureAwait(false);
|
||||
_metrics.Fault(SessionManagerErrorCode.OpenFailed.ToString());
|
||||
_logger.LogWarning(
|
||||
exception,
|
||||
@@ -161,6 +210,62 @@ public sealed class SessionManager : ISessionManager
|
||||
}
|
||||
}
|
||||
|
||||
// Order — fault, deregister (dropping per-session event metrics), release the slot, dispose —
|
||||
// matters: releasing the slot before disposal would let the next open race this worker's
|
||||
// teardown on the same machine. A registered session goes through RemoveSessionAsync, the
|
||||
// single removal path, which is a no-op when a concurrent kill or drain already removed it
|
||||
// (that teardown released the slot and disposed the session). The open-session gauge is never
|
||||
// touched here: SessionOpened is recorded inside the attach gate, after which nothing in the
|
||||
// open sequence can fail.
|
||||
private async Task RollBackOpenAsync(GatewaySession? session, bool registered, Exception cause)
|
||||
{
|
||||
if (session is null)
|
||||
{
|
||||
ReleaseSessionSlot();
|
||||
return;
|
||||
}
|
||||
|
||||
session.MarkFaulted(cause.Message);
|
||||
if (registered)
|
||||
{
|
||||
await RemoveSessionAsync(session).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
ReleaseSessionSlot();
|
||||
await session.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Kills and disposes a worker client that arrived after its session was closed. Best-effort:
|
||||
// the client is already orphaned from the session, so a failure here is logged rather than
|
||||
// allowed to mask the open failure the caller is about to receive.
|
||||
private async Task DiscardWorkerClientAsync(GatewaySession session, IWorkerClient workerClient)
|
||||
{
|
||||
try
|
||||
{
|
||||
workerClient.Kill("session-closed-during-startup");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
exception,
|
||||
"Failed to kill the late worker for closed session {SessionId}.",
|
||||
session.SessionId);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await workerClient.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
exception,
|
||||
"Failed to dispose the late worker client for closed session {SessionId}.",
|
||||
session.SessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryGetSession(
|
||||
string sessionId,
|
||||
@@ -226,16 +331,27 @@ public sealed class SessionManager : ISessionManager
|
||||
{
|
||||
wasClosed = await session.KillWorkerWithCloseGateAsync(reason, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (ObjectDisposedException) when (!_registry.TryGet(sessionId, out _))
|
||||
{
|
||||
// Lost the race to a concurrent close: between the lookup above and the gate wait, the
|
||||
// close finished, removed the session, accounted SessionClosed and disposed the gate.
|
||||
// Nothing is left to kill and nothing to account; report it as already closed.
|
||||
return new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: true);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
session.MarkFaulted(exception.Message);
|
||||
_metrics.Fault(SessionManagerErrorCode.CloseFailed.ToString());
|
||||
|
||||
// The open-session gauge was incremented in OpenSessionAsync; every
|
||||
// session reaching KillWorkerAsync had SessionOpened recorded. If the
|
||||
// kill path throws, decrement the gauge here so mxgateway.sessions.open
|
||||
// does not leak — mirroring the equivalent guard in OpenSessionAsync.
|
||||
_metrics.SessionRemoved();
|
||||
// If the open-session gauge was incremented for this session (it was not when the
|
||||
// kill landed while the worker was still starting) and nobody has removed it yet
|
||||
// (a removal accounts the gauge itself), decrement it here so mxgateway.sessions.open
|
||||
// does not leak when the kill path throws.
|
||||
if (session.OpenRecorded && _registry.TryGet(sessionId, out _))
|
||||
{
|
||||
_metrics.SessionRemoved();
|
||||
}
|
||||
|
||||
await RemoveSessionAsync(session).ConfigureAwait(false);
|
||||
throw new SessionManagerException(
|
||||
SessionManagerErrorCode.CloseFailed,
|
||||
@@ -243,7 +359,7 @@ public sealed class SessionManager : ISessionManager
|
||||
exception);
|
||||
}
|
||||
|
||||
if (!wasClosed)
|
||||
if (!wasClosed && session.OpenRecorded)
|
||||
{
|
||||
_metrics.SessionClosed();
|
||||
}
|
||||
@@ -270,12 +386,14 @@ public sealed class SessionManager : ISessionManager
|
||||
{
|
||||
// A session is swept when its normal lease has expired, it has FAULTED (a faulted
|
||||
// session is permanently unusable yet otherwise pins a session slot and a live x86
|
||||
// worker until its DefaultLeaseSeconds lease expires), OR its detach-grace retention
|
||||
// worker until its DefaultLeaseSeconds lease expires), its detach-grace retention
|
||||
// window has elapsed (last external subscriber dropped and no client reconnected
|
||||
// within DetachGraceSeconds). All three are the same teardown; only the reason differs
|
||||
// so operators can tell a fault reap from a short reconnect-window expiry from a long
|
||||
// idle-lease expiry in logs/metrics. Precedence when several fire simultaneously:
|
||||
// lease-expired, then faulted, then detach-grace.
|
||||
// within DetachGraceSeconds), OR it never attached a subscriber and has been idle for
|
||||
// UnattachedSessionGraceSeconds (a client that opened and vanished). All four are the
|
||||
// same teardown; only the reason differs so operators can tell a fault reap from a
|
||||
// short reconnect-window expiry from a never-subscribed reap from a long idle-lease
|
||||
// expiry in logs/metrics. Precedence when several fire simultaneously: lease-expired,
|
||||
// then faulted, then detach-grace, then unattached-grace.
|
||||
//
|
||||
// TOCTOU note: eligibility is re-verified atomically inside TryBeginCloseIfExpired
|
||||
// under _syncRoot, so a client that reattaches a subscriber between the check above
|
||||
@@ -286,7 +404,9 @@ public sealed class SessionManager : ISessionManager
|
||||
? FaultedReason
|
||||
: session.IsDetachGraceExpired(now)
|
||||
? DetachGraceExpiredReason
|
||||
: null;
|
||||
: session.IsUnattachedGraceExpired(now)
|
||||
? UnattachedGraceExpiredReason
|
||||
: null;
|
||||
if (reason is null)
|
||||
{
|
||||
continue;
|
||||
@@ -330,8 +450,11 @@ public sealed class SessionManager : ISessionManager
|
||||
{
|
||||
try
|
||||
{
|
||||
await CloseSessionCoreAsync(candidate.Session, candidate.Reason, closeToken).ConfigureAwait(false);
|
||||
Interlocked.Increment(ref closedCount);
|
||||
SessionCloseResult result = await CloseSessionCoreAsync(candidate.Session, candidate.Reason, closeToken).ConfigureAwait(false);
|
||||
if (!result.AlreadyClosed)
|
||||
{
|
||||
Interlocked.Increment(ref closedCount);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
@@ -381,49 +504,86 @@ public sealed class SessionManager : ISessionManager
|
||||
// intentional: that sweep is periodic maintenance whose missed sessions are picked up by
|
||||
// the next pass and, ultimately, by this drain. This drain is terminal — nothing runs after
|
||||
// it — so it must not be abandoned partway.
|
||||
await Parallel.ForEachAsync(
|
||||
_registry.Snapshot(),
|
||||
new ParallelOptions { MaxDegreeOfParallelism = MaxParallelSessionCloses },
|
||||
async (session, _) =>
|
||||
//
|
||||
// Close the open gate BEFORE the first snapshot (see the re-check in OpenSessionAsync), then
|
||||
// re-snapshot until the registry is empty so an open that registered just before the gate
|
||||
// closed is drained too.
|
||||
MarkStopping();
|
||||
for (int pass = 0; pass < MaxShutdownDrainPasses; pass++)
|
||||
{
|
||||
if (_registry.Count == 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
await CloseSessionCoreAsync(session, GatewayShutdownReason, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
exception,
|
||||
"Graceful shutdown failed for session {SessionId}; killing worker.",
|
||||
session.SessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Defensive fallback: CloseSessionCoreAsync's inner
|
||||
// SessionCloseStartedException catch normally removes the session
|
||||
// and accounts the close. This outer fallback only fires for
|
||||
// sessions still in the registry — route through KillWorkerAsync
|
||||
// so the bookkeeping is identical to the dashboard kill path.
|
||||
if (_registry.TryGet(session.SessionId, out GatewaySession? registeredSession)
|
||||
&& registeredSession is not null)
|
||||
await Parallel.ForEachAsync(
|
||||
_registry.Snapshot(),
|
||||
new ParallelOptions { MaxDegreeOfParallelism = MaxParallelSessionCloses },
|
||||
async (session, _) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
try
|
||||
await CloseSessionCoreAsync(session, GatewayShutdownReason, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
exception,
|
||||
"Graceful shutdown failed for session {SessionId}; killing worker.",
|
||||
session.SessionId);
|
||||
|
||||
// Defensive fallback: CloseSessionCoreAsync's inner
|
||||
// SessionCloseStartedException catch normally removes the session
|
||||
// and accounts the close. This outer fallback only fires for
|
||||
// sessions still in the registry — route through KillWorkerAsync
|
||||
// so the bookkeeping is identical to the dashboard kill path.
|
||||
if (_registry.TryGet(session.SessionId, out GatewaySession? registeredSession)
|
||||
&& registeredSession is not null)
|
||||
{
|
||||
// Deliberately NOT the caller's token: the kill is the last-resort orphan
|
||||
// preventer, so it must still run when the host stop deadline (or a
|
||||
// sibling body's failure) has already cancelled the drain. It is a
|
||||
// synchronous Kill plus registry/dispose bookkeeping, not a wait on
|
||||
// the worker, so it cannot extend the drain meaningfully.
|
||||
await KillWorkerAsync(session.SessionId, GatewayShutdownReason, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception killException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
killException,
|
||||
"Worker kill fallback failed for session {SessionId}.",
|
||||
session.SessionId);
|
||||
try
|
||||
{
|
||||
// Deliberately NOT the caller's token: the kill is the last-resort orphan
|
||||
// preventer, so it must still run when the host stop deadline (or a
|
||||
// sibling body's failure) has already cancelled the drain. It is a
|
||||
// synchronous Kill plus registry/dispose bookkeeping, not a wait on
|
||||
// the worker, so it cannot extend the drain meaningfully.
|
||||
await KillWorkerAsync(session.SessionId, GatewayShutdownReason, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception killException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
killException,
|
||||
"Worker kill fallback failed for session {SessionId}.",
|
||||
session.SessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}).ConfigureAwait(false);
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
int remaining = _registry.Count;
|
||||
if (remaining > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Session drain finished with {Remaining} session(s) still registered after {Passes} passes; their workers are left to the orphan terminator on the next start.",
|
||||
remaining,
|
||||
MaxShutdownDrainPasses);
|
||||
}
|
||||
}
|
||||
|
||||
private void MarkStopping()
|
||||
{
|
||||
Volatile.Write(ref _stopping, 1);
|
||||
}
|
||||
|
||||
private void ThrowIfStopping()
|
||||
{
|
||||
if (Volatile.Read(ref _stopping) != 0)
|
||||
{
|
||||
throw new SessionManagerException(
|
||||
SessionManagerErrorCode.GatewayStopping,
|
||||
"The gateway is stopping and is not accepting new sessions.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<SessionCloseResult> CloseSessionCoreAsync(
|
||||
@@ -435,7 +595,19 @@ public sealed class SessionManager : ISessionManager
|
||||
try
|
||||
{
|
||||
SessionCloseResult result = await session.CloseAsync(reason, cancellationToken).ConfigureAwait(false);
|
||||
if (!wasClosed && !result.AlreadyClosed)
|
||||
if (result.WorkerShutdownFailure is not null)
|
||||
{
|
||||
// A clean close from the session's point of view (the worker was killed and the
|
||||
// session is Closed), but the operator should see that the worker would not stop
|
||||
// gracefully — a run of these is the signal that workers are hanging on shutdown.
|
||||
_logger.LogWarning(
|
||||
result.WorkerShutdownFailure,
|
||||
"Worker for session {SessionId} did not shut down gracefully and was killed; reason={Reason}.",
|
||||
session.SessionId,
|
||||
reason);
|
||||
}
|
||||
|
||||
if (!wasClosed && !result.AlreadyClosed && session.OpenRecorded)
|
||||
{
|
||||
_metrics.SessionClosed();
|
||||
}
|
||||
@@ -447,10 +619,17 @@ public sealed class SessionManager : ISessionManager
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (ObjectDisposedException) when (!_registry.TryGet(session.SessionId, out _))
|
||||
{
|
||||
// Lost the race to a concurrent close that removed, accounted and disposed the
|
||||
// session after this caller picked it up (a sweep's snapshot, a client close that
|
||||
// looked it up first). Not a failure and nothing to redo.
|
||||
return new SessionCloseResult(session.SessionId, SessionState.Closed, AlreadyClosed: true);
|
||||
}
|
||||
catch (SessionCloseStartedException exception)
|
||||
{
|
||||
session.MarkFaulted(exception.Message);
|
||||
if (!wasClosed)
|
||||
if (!wasClosed && session.OpenRecorded)
|
||||
{
|
||||
// Account the close as a SessionClosed (decrements the open-session
|
||||
// gauge AND increments the sessions.closed counter), not just
|
||||
@@ -566,7 +745,8 @@ public sealed class SessionManager : ISessionManager
|
||||
TimeSpan.FromMilliseconds(Math.Max(0, _options.Sessions.WorkerReadyWaitTimeoutMs)),
|
||||
_addressNormalizer,
|
||||
TimeSpan.FromSeconds(Math.Max(0, _options.Sessions.FaultedGraceSeconds)),
|
||||
ownerDashboardTags);
|
||||
ownerDashboardTags,
|
||||
TimeSpan.FromSeconds(Math.Max(0, _options.Sessions.UnattachedSessionGraceSeconds)));
|
||||
}
|
||||
|
||||
private static string CreateClientCorrelationId(
|
||||
|
||||
@@ -17,4 +17,12 @@ public enum SessionManagerErrorCode
|
||||
/// Maps to gRPC <c>PermissionDenied</c>.
|
||||
/// </summary>
|
||||
PermissionDenied,
|
||||
|
||||
/// <summary>
|
||||
/// The gateway is stopping, so no new session can be opened: a session created behind the
|
||||
/// shutdown drain's snapshot would have nobody left to close it and its worker would
|
||||
/// outlive the gateway as an orphan. Maps to gRPC <c>Unavailable</c> so the client retries
|
||||
/// against the restarted gateway.
|
||||
/// </summary>
|
||||
GatewayStopping,
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"DefaultLeaseSeconds": 1800,
|
||||
"LeaseSweepIntervalSeconds": 30,
|
||||
"DetachGraceSeconds": 30,
|
||||
"UnattachedSessionGraceSeconds": 0,
|
||||
"AllowMultipleEventSubscribers": false,
|
||||
"MaxEventSubscribersPerSession": 8
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user