feat(sessions): replay-on-reconnect with ReplayGap sentinel

This commit is contained in:
Joseph Doherty
2026-06-16 07:22:19 -04:00
parent 042f5e3d82
commit 36ab8d15f1
7 changed files with 736 additions and 29 deletions
@@ -433,6 +433,32 @@ public sealed class GatewaySession
return lease;
}
// Reconnect/resume variant of StartDistributorAndRegister (Task 12). Snapshots the replay
// ring for events newer than afterSequence AND registers the live subscriber atomically
// under the distributor's replay lock, so the replay→live handoff has no gap and no
// duplicate (see SessionEventDistributor.RegisterWithReplay). The pump is started after
// registration, exactly as the fresh-attach path, so the very first subscriber on a
// freshly-Ready session still sees the stream from its beginning.
private IEventSubscriberLease StartDistributorAndRegisterWithReplay(
ulong afterSequence,
out IReadOnlyList<MxEvent> replayedEvents,
out bool gap,
out ulong oldestAvailableSequence,
out ulong liveResumeSequence)
{
SessionEventDistributor distributor = EnsureDistributorCreated(out bool startNow);
IEventSubscriberLease lease = distributor.RegisterWithReplay(
afterSequence,
out replayedEvents,
out gap,
out oldestAvailableSequence,
out liveResumeSequence);
StartPumpIfRequested(distributor, startNow);
return lease;
}
// Constructs the distributor exactly once and reports whether THIS caller is the one
// that should start the pump (i.e. it observed the unstarted state and claimed the
// start). Both the construction and the started-flag flip happen under _syncRoot so two
@@ -811,6 +837,75 @@ public sealed class GatewaySession
}
}
/// <summary>
/// Reconnect/resume variant of <see cref="AttachEventSubscriber"/> (Task 12). Attaches
/// an event subscriber AND atomically snapshots the session replay ring for events newer
/// than <paramref name="afterSequence"/>, so a resuming client can replay what it missed
/// before live delivery resumes — with no gap and no duplicate across the handoff.
/// </summary>
/// <param name="maxSubscribers">See <see cref="AttachEventSubscriber"/>.</param>
/// <param name="afterSequence">
/// The last worker sequence the resuming client already observed. Replay returns events
/// strictly newer than this; the caller must filter the live channel to events strictly
/// newer than <see cref="EventSubscriberReplayAttachment.LiveResumeSequence"/>.
/// </param>
/// <returns>
/// The lease plus the replay batch, gap flag, and resume watermarks. See
/// <see cref="SessionEventDistributor.RegisterWithReplay"/> for the no-gap/no-duplicate
/// guarantee.
/// </returns>
public EventSubscriberReplayAttachment AttachEventSubscriberWithReplay(int maxSubscribers, ulong afterSequence)
{
bool allowMultipleSubscribers = _eventStreaming.AllowMultipleEventSubscribers;
int effectiveCap = allowMultipleSubscribers ? Math.Max(1, maxSubscribers) : 1;
lock (_syncRoot)
{
if (_state != SessionState.Ready || _workerClient?.State != WorkerClientState.Ready)
{
throw new SessionManagerException(
SessionManagerErrorCode.SessionNotReady,
$"Session {SessionId} is not ready for event streaming. Current state is {_state}.");
}
if (_activeEventSubscriberCount >= effectiveCap)
{
throw allowMultipleSubscribers
? new SessionManagerException(
SessionManagerErrorCode.EventSubscriberLimitReached,
$"Session {SessionId} has reached its maximum of {effectiveCap} concurrent event stream subscribers.")
: new SessionManagerException(
SessionManagerErrorCode.EventSubscriberAlreadyActive,
$"Session {SessionId} already has an active event stream subscriber.");
}
_activeEventSubscriberCount++;
_detachedAtUtc = null;
}
try
{
IEventSubscriberLease distributorLease = StartDistributorAndRegisterWithReplay(
afterSequence,
out IReadOnlyList<MxEvent> replayedEvents,
out bool gap,
out ulong oldestAvailableSequence,
out ulong liveResumeSequence);
return new EventSubscriberReplayAttachment(
new EventSubscriberLease(this, distributorLease),
replayedEvents,
gap,
oldestAvailableSequence,
liveResumeSequence);
}
catch
{
DetachEventSubscriber();
throw;
}
}
/// <summary>
/// Invokes a worker command synchronously and returns the reply.
/// </summary>