feat(sessions): detach-grace retention window for reconnect
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
||||
@@ -432,6 +433,183 @@ public sealed class GatewaySessionTests
|
||||
await session.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 11. With a positive detach-grace, dropping the last external subscriber must
|
||||
/// RETAIN the session (it stays <see cref="SessionState.Ready"/>, not Closed/Faulted)
|
||||
/// and record a detached timestamp so the lease monitor can age it out later.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DetachGrace_LastSubscriberDrops_RetainsSessionAndRecordsDetachedTimestamp()
|
||||
{
|
||||
FakeTimeProvider clock = new(DateTimeOffset.UtcNow);
|
||||
FakeWorkerClient workerClient = new();
|
||||
await using GatewaySession session = CreateReadySessionWithDetachGrace(
|
||||
workerClient,
|
||||
clock,
|
||||
detachGrace: TimeSpan.FromSeconds(30));
|
||||
|
||||
IEventSubscriberLease lease = session.AttachEventSubscriber(maxSubscribers: 1);
|
||||
Assert.Null(session.DetachedAtUtc);
|
||||
|
||||
lease.Dispose();
|
||||
|
||||
// Retained, not torn down.
|
||||
Assert.Equal(SessionState.Ready, session.State);
|
||||
Assert.Equal(0, session.ActiveEventSubscriberCount);
|
||||
Assert.Equal(clock.GetUtcNow(), session.DetachedAtUtc);
|
||||
Assert.False(session.IsDetachGraceExpired(clock.GetUtcNow()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 11. Advancing the clock past the detach-grace window makes the retained,
|
||||
/// detached session eligible for close (<see cref="GatewaySession.IsDetachGraceExpired"/>).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DetachGrace_ClockAdvancesPastWindow_SessionBecomesEligibleForClose()
|
||||
{
|
||||
FakeTimeProvider clock = new(DateTimeOffset.UtcNow);
|
||||
FakeWorkerClient workerClient = new();
|
||||
await using GatewaySession session = CreateReadySessionWithDetachGrace(
|
||||
workerClient,
|
||||
clock,
|
||||
detachGrace: TimeSpan.FromSeconds(30));
|
||||
|
||||
IEventSubscriberLease lease = session.AttachEventSubscriber(maxSubscribers: 1);
|
||||
lease.Dispose();
|
||||
|
||||
// Just before the window elapses: not yet eligible.
|
||||
clock.Advance(TimeSpan.FromSeconds(29));
|
||||
Assert.False(session.IsDetachGraceExpired(clock.GetUtcNow()));
|
||||
|
||||
// At/after the window: eligible for close.
|
||||
clock.Advance(TimeSpan.FromSeconds(1));
|
||||
Assert.True(session.IsDetachGraceExpired(clock.GetUtcNow()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 11. Re-attaching a subscriber before the window elapses cancels the grace:
|
||||
/// the detached timestamp clears and a subsequent clock advance does NOT make the
|
||||
/// session eligible for close.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DetachGrace_ReattachBeforeExpiry_CancelsGrace()
|
||||
{
|
||||
FakeTimeProvider clock = new(DateTimeOffset.UtcNow);
|
||||
FakeWorkerClient workerClient = new();
|
||||
await using GatewaySession session = CreateReadySessionWithDetachGrace(
|
||||
workerClient,
|
||||
clock,
|
||||
detachGrace: TimeSpan.FromSeconds(30));
|
||||
|
||||
IEventSubscriberLease first = session.AttachEventSubscriber(maxSubscribers: 1);
|
||||
first.Dispose();
|
||||
Assert.NotNull(session.DetachedAtUtc);
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(10));
|
||||
IEventSubscriberLease second = session.AttachEventSubscriber(maxSubscribers: 1);
|
||||
|
||||
// Re-attach cancelled the grace window.
|
||||
Assert.Null(session.DetachedAtUtc);
|
||||
|
||||
// Advancing well past what would have been the window does not make it eligible while
|
||||
// a subscriber is attached.
|
||||
clock.Advance(TimeSpan.FromMinutes(5));
|
||||
Assert.False(session.IsDetachGraceExpired(clock.GetUtcNow()));
|
||||
|
||||
second.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 11. With detach-grace disabled (<c>0</c>), dropping the last subscriber must
|
||||
/// match today's behavior: the session stays <see cref="SessionState.Ready"/> with no
|
||||
/// detached timestamp and is never eligible for a detach-grace close — it lingers only
|
||||
/// until its normal lease expires.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DetachGrace_Disabled_MatchesTodaysBehavior()
|
||||
{
|
||||
FakeTimeProvider clock = new(DateTimeOffset.UtcNow);
|
||||
FakeWorkerClient workerClient = new();
|
||||
await using GatewaySession session = CreateReadySessionWithDetachGrace(
|
||||
workerClient,
|
||||
clock,
|
||||
detachGrace: TimeSpan.Zero);
|
||||
|
||||
IEventSubscriberLease lease = session.AttachEventSubscriber(maxSubscribers: 1);
|
||||
lease.Dispose();
|
||||
|
||||
Assert.Equal(SessionState.Ready, session.State);
|
||||
Assert.Null(session.DetachedAtUtc);
|
||||
|
||||
clock.Advance(TimeSpan.FromHours(1));
|
||||
Assert.False(session.IsDetachGraceExpired(clock.GetUtcNow()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 11. The gateway-owned internal dashboard subscriber must NOT keep a session out
|
||||
/// of detach-grace: with only the dashboard mirror attached (and no external gRPC
|
||||
/// subscriber), dropping the last external subscriber still enters grace and the
|
||||
/// window still expires.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DetachGrace_DashboardMirrorAlone_DoesNotPreventGraceEntry()
|
||||
{
|
||||
FakeTimeProvider clock = new(DateTimeOffset.UtcNow);
|
||||
FakeWorkerClient workerClient = new();
|
||||
RecordingDashboardEventBroadcaster broadcaster = new();
|
||||
await using GatewaySession session = CreateReadySessionWithDetachGrace(
|
||||
workerClient,
|
||||
clock,
|
||||
detachGrace: TimeSpan.FromSeconds(30),
|
||||
dashboardBroadcaster: broadcaster);
|
||||
|
||||
// The dashboard mirror is the only subscriber (registered internally at MarkReady).
|
||||
// It is not counted as an external subscriber.
|
||||
Assert.Equal(0, session.ActiveEventSubscriberCount);
|
||||
|
||||
IEventSubscriberLease lease = session.AttachEventSubscriber(maxSubscribers: 1);
|
||||
lease.Dispose();
|
||||
|
||||
// Entered grace despite the dashboard mirror still being attached.
|
||||
Assert.NotNull(session.DetachedAtUtc);
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(30));
|
||||
Assert.True(session.IsDetachGraceExpired(clock.GetUtcNow()));
|
||||
}
|
||||
|
||||
private static GatewaySession CreateReadySessionWithDetachGrace(
|
||||
IWorkerClient workerClient,
|
||||
TimeProvider timeProvider,
|
||||
TimeSpan detachGrace,
|
||||
IDashboardEventBroadcaster? dashboardBroadcaster = null)
|
||||
{
|
||||
GatewaySession session = new(
|
||||
sessionId: "session-test-detach-grace",
|
||||
backendName: "mxaccess",
|
||||
pipeName: "mxaccess-gateway-1-session-test-detach-grace",
|
||||
nonce: "nonce",
|
||||
clientIdentity: "client-1",
|
||||
ownerKeyId: null,
|
||||
clientSessionName: "test-session",
|
||||
clientCorrelationId: "client-correlation-1",
|
||||
commandTimeout: TimeSpan.FromSeconds(5),
|
||||
startupTimeout: TimeSpan.FromSeconds(5),
|
||||
shutdownTimeout: TimeSpan.FromSeconds(5),
|
||||
leaseDuration: TimeSpan.FromMinutes(30),
|
||||
openedAt: timeProvider.GetUtcNow(),
|
||||
eventStreaming: new SessionEventStreaming(
|
||||
new MxAccessGrpcMapper(),
|
||||
new EventOptions { QueueCapacity = 8 },
|
||||
NullLogger<SessionEventDistributor>.Instance,
|
||||
timeProvider,
|
||||
new GatewayMetrics(),
|
||||
dashboardBroadcaster),
|
||||
detachGrace: detachGrace);
|
||||
session.AttachWorkerClient(workerClient);
|
||||
session.MarkReady();
|
||||
return session;
|
||||
}
|
||||
|
||||
private static GatewaySession CreateReadySession(IWorkerClient workerClient)
|
||||
{
|
||||
GatewaySession session = new(
|
||||
|
||||
Reference in New Issue
Block a user