Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Server/Sessions/ISessionManager.cs
T
Joseph Doherty dc2df628e3 chore(followups): reviewer-recommended tests, comments, and hardening from the remediation reviews
The remediation reviews approved every task but left a tail of small notes.
This lands the gateway-side half of them.

Hardening (behavior changes, all narrow):

- BuildFilteredWriteBulkCommand's unreachable default case failed OPEN: a
  fifth bulk-write kind added upstream without a filter case here would have
  shipped the DENIED entries to the worker while reporting them denied to the
  caller. It now throws UnreachableException.
- SqliteCanonicalAuditStore.ListRecentAsync no longer throws on a row it
  cannot date. The retention sweep deliberately preserves such rows (SQLite's
  datetime() yields NULL, so the DELETE never matches), which guaranteed the
  dashboard's recent-audit view would meet one eventually and lose the whole
  page to it. The row is now reported at DateTimeOffset.MinValue with every
  other column intact, behind an optional logger.
- The audit drain loop's finally now completes the channel writer alongside
  detaching the drain, so a producer that raced past the attached check takes
  the write-through branch instead of stranding its event in a buffer nobody
  reads until shutdown. TryComplete is idempotent, so StopAsync is unaffected.

Tests:

- MapCommandReply ownership (Assert.Same on the inner reply), mirroring the
  existing MapEvent ownership test.
- Redactor key-id length boundary at exactly 64 and 65 characters, pinning
  which way it fails. Nothing validates key-id length at creation, so
  docs/Diagnostics.md's "which no issued key id does" is now stated as the
  heuristic it is.
- ApiKeyFailureLimiter.Reset with a PartitionResolution whose partition was
  evicted between the Check and the Reset: inert, and clears nobody else's
  block.
- Constraint-cache concurrency stress: the cap is enforced by the inserting
  thread, so overshoot must be transient and proportional to the in-flight
  inserters, and the cache must settle at or under the cap.
- ListRecentAsync against a raw-SQL undateable row.

Comment/doc accuracy:

- EventsHubViewerRegistry.ReleaseConnection records that it relies on
  SignalR's default sequential per-connection dispatch
  (MaximumParallelInvocationsPerClient = 1).
- A PERF(followup) note on Invoke's double session resolve and why removing it
  needs a SessionManager overload.
- SessionEventDistributor: the volatile-field comment named the pump as the
  lock-free reader, but the pump's single capture point is inside _replayLock;
  the genuinely lock-free reader is SubscriberCount. OnSubscriberOverflow's
  "cannot be observed here" now excepts the DisposeAsync abandon path. The
  churn test names its ConcurrentDictionary bucket-order assumption and that a
  violation surfaces as a read timeout, not a silent pass.
- The two "restores the sequential drain's behavior" claims (SessionManager,
  docs/Sessions.md) were wrong: the sequential drain leaked too, because
  KillWorkerAsync's entry ThrowIfCancellationRequested aborted the whole loop
  on the first session for zero kills. Reworded to "fixes a leak the
  sequential drain also had", with the sweep-bound/shutdown-unbound
  ParallelOptions asymmetry explained.
- ISessionManager.ShutdownAsync's token doc: it degrades the drain to a kill
  sweep rather than cancelling it, with the bounded overrun stated.
  SessionShutdownHostedService.StopAsync records that its cancellation-logging
  branch is now unreachable.
2026-08-15 17:54:31 -04:00

93 lines
4.8 KiB
C#

using System.Diagnostics.CodeAnalysis;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Server.Sessions;
public interface ISessionManager
{
/// <summary>Opens a new gateway session and launches a worker process.</summary>
/// <param name="request">Request payload.</param>
/// <param name="clientIdentity">Client identity string.</param>
/// <param name="ownerKeyId">API key identifier of the caller creating the session.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The newly opened session.</returns>
Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
string? ownerKeyId,
CancellationToken cancellationToken);
/// <summary>Attempts to retrieve a session by ID.</summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="session">The retrieved session, if found.</param>
/// <returns>True if the session exists; otherwise false.</returns>
bool TryGetSession(
string sessionId,
[MaybeNullWhen(false)] out GatewaySession session);
/// <summary>Invokes a command on the worker for the specified session.</summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="command">Command to invoke.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The command reply from the worker.</returns>
Task<WorkerCommandReply> InvokeAsync(
string sessionId,
WorkerCommand command,
CancellationToken cancellationToken);
/// <summary>Reads events streamed from the worker for the specified session.</summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>Events emitted by the worker.</returns>
IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId,
CancellationToken cancellationToken);
/// <summary>Closes a session and terminates its worker process.</summary>
/// <param name="sessionId">Identifier of the session to close.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The result of closing the session.</returns>
Task<SessionCloseResult> CloseSessionAsync(
string sessionId,
CancellationToken cancellationToken);
/// <summary>
/// Forcefully terminates a session's worker without attempting graceful shutdown,
/// transitions the session to Closed, and removes it from the registry.
/// </summary>
/// <param name="sessionId">Identifier of the session whose worker to kill.</param>
/// <param name="reason">Reason for killing the worker (recorded in logs/audit).</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The result of closing the session.</returns>
Task<SessionCloseResult> KillWorkerAsync(
string sessionId,
string reason,
CancellationToken cancellationToken);
/// <summary>Closes all sessions with expired leases at the specified time.</summary>
/// <param name="now">The current time to evaluate expiration against.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The number of sessions closed.</returns>
/// <remarks>
/// A close that fails does not abandon the rest of the pass: every session selected by this
/// sweep is attempted, and the first failure is then rethrown so the caller still observes
/// (and logs) that the sweep failed. Which failure surfaces is nondeterministic when several
/// closes fail in the same pass, because the closes run concurrently.
/// </remarks>
Task<int> CloseExpiredLeasesAsync(
DateTimeOffset now,
CancellationToken cancellationToken);
/// <summary>Shuts down all sessions and the session manager.</summary>
/// <param name="cancellationToken">
/// Token that <em>degrades</em> the drain rather than cancelling it. It is passed only to each
/// session's graceful close; the drain loop and the kill fallback are not bound to it, so
/// cancelling turns the drain into a kill sweep instead of abandoning the untried sessions as
/// leaked workers. The call therefore overruns a cancelled token by a bounded amount —
/// roughly <c>ceil(sessionCount / 4)</c> batches of the worker shutdown timeout in the worst
/// case, where 4 is <c>MaxParallelSessionCloses</c>.
/// </param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task ShutdownAsync(CancellationToken cancellationToken);
}