feat(clients): CLI-15 surface ReplayGap reconnect sentinel as typed signal (4/5 clients)

The gateway emits a ReplayGap sentinel MxEvent at the head of a StreamEvents
stream resumed via after_worker_sequence when the requested cursor predates the
oldest retained event. Clients previously ignored it, silently mis-treating a
lossy resume as continuous. Each client now surfaces the sentinel as a distinct,
typed, non-terminal signal (never synthesized, never swallowed) so a consumer can
detect the gap and re-snapshot; resume contract is
after_worker_sequence = oldest_available_sequence - 1.

- .NET: MxEventStreamItem (IsReplayGap/ReplayGap/Event) via new StreamEventItemsAsync
  + AsStreamItemsAsync extension. Build clean, 87 passed.
- Go: EventResult.ReplayGap field + IsReplayGap(); ReplayGap type alias. build/vet/test clean.
- Rust: EventItem enum (Event/ReplayGap); EventStream now yields Result<EventItem, Error>;
  CLI renders REPLAY_GAP line / replayGap JSON. fmt/check/test/clippy clean.
- Python: ReplayGap dataclass; stream_events yields pb.MxEvent | ReplayGap. 131 passed.
- Shared docs: ClientLibrariesDesign non-goals reframed (reconnect-replay protocol is
  consumable; auto-reconnect stays a non-goal); CrossLanguageSmokeMatrix resume-gap note.

Java client is deferred to the windev batch (no local JRE); CLI-15 stays open until it lands.

Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
This commit is contained in:
Joseph Doherty
2026-07-09 16:22:28 -04:00
parent c823a7b60b
commit 0c6e5b3ace
22 changed files with 972 additions and 37 deletions
+42
View File
@@ -84,6 +84,48 @@ messages. `MxGatewaySession.OpenSessionReply` keeps the raw session-open reply
available, and command helpers have `*RawAsync` variants when callers need the
complete `MxCommandReply`.
### Event Streaming And Replay Gaps
`StreamEventsAsync(afterWorkerSequence)` yields raw generated `MxEvent`
messages. Passing a non-zero `afterWorkerSequence` resumes a session's event
stream after a known worker sequence — this is the reconnect cursor. If that
cursor is *stale* — older than the oldest event the gateway still retains in the
session replay ring — the events in between were evicted and cannot be replayed.
The gateway signals this by emitting a single **replay-gap sentinel** at the head
of the resumed stream: an `MxEvent` with its `ReplayGap` field set, `Family`
unspecified, and no body. It means "you missed events — discard local state and
re-snapshot."
Rather than force callers to inspect the raw sentinel, the client exposes a
typed surface, `StreamEventItemsAsync`, which yields `MxEventStreamItem` values:
```csharp
await foreach (MxEventStreamItem item in session.StreamEventItemsAsync(
afterWorkerSequence: lastSeenSequence))
{
if (item.IsReplayGap)
{
// We missed events: throw away local state and re-snapshot.
ReplayGap gap = item.ReplayGap!;
// Resume without incurring another gap:
lastSeenSequence = gap.OldestAvailableSequence - 1;
await ReSnapshotAsync();
continue;
}
HandleEvent(item.Event); // normal MXAccess event, IsReplayGap == false
lastSeenSequence = item.Event.WorkerSequence;
}
```
The typed surface never synthesizes or drops events — it only makes the
gateway's own sentinel observable. Normal events pass through with
`IsReplayGap == false` and `ReplayGap == null`. The gap is only ever produced by
`StreamEvents`; the diagnostic drain path never emits it. If you already consume
the raw `StreamEventsAsync` (or the client-level stream), the
`AsStreamItemsAsync()` extension projects any `IAsyncEnumerable<MxEvent>` into
the same `MxEventStreamItem` surface.
For alarms, the client exposes `QueryActiveAlarmsAsync` (one-shot snapshot of
the active alarms the gateway's central monitor currently holds),
`StreamAlarmsAsync` (server-streaming feed of alarm-state-change messages
@@ -215,6 +215,55 @@ public sealed class MxGatewayClientSessionTests
Assert.Equal("session-fixture", request.SessionId);
}
/// <summary>
/// Verifies that a reconnect-replay gap sentinel is surfaced as a typed
/// <see cref="MxEventStreamItem"/> with the gap populated, while normal
/// events pass through unchanged with IsReplayGap false.
/// </summary>
[Fact]
public async Task StreamEventItemsAsync_SurfacesReplayGapSentinel()
{
FakeGatewayTransport transport = CreateTransport();
transport.AddEvent(new MxEvent
{
SessionId = "session-fixture",
ReplayGap = new ReplayGap
{
RequestedAfterSequence = 5,
OldestAvailableSequence = 42,
},
});
transport.AddEvent(new MxEvent
{
SessionId = "session-fixture",
Family = MxEventFamily.OnDataChange,
WorkerSequence = 42,
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
List<MxEventStreamItem> items = [];
await foreach (MxEventStreamItem item in session.StreamEventItemsAsync(afterWorkerSequence: 5))
{
items.Add(item);
}
Assert.Equal(2, items.Count);
MxEventStreamItem gap = items[0];
Assert.True(gap.IsReplayGap);
Assert.NotNull(gap.ReplayGap);
Assert.Equal(5UL, gap.ReplayGap!.RequestedAfterSequence);
Assert.Equal(42UL, gap.ReplayGap.OldestAvailableSequence);
Assert.Same(gap.ReplayGap, gap.Event.ReplayGap);
MxEventStreamItem normal = items[1];
Assert.False(normal.IsReplayGap);
Assert.Null(normal.ReplayGap);
Assert.Equal(42UL, normal.Event.WorkerSequence);
Assert.Equal(MxEventFamily.OnDataChange, normal.Event.Family);
}
/// <summary>Verifies that close is explicit and idempotent.</summary>
[Fact]
public async Task CloseAsync_IsExplicitAndIdempotent()
@@ -0,0 +1,40 @@
using System.Runtime.CompilerServices;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>
/// Extension methods that project a raw <see cref="MxEvent"/> stream into the
/// typed <see cref="MxEventStreamItem"/> surface, making the gateway's
/// reconnect-replay gap sentinel observable.
/// </summary>
public static class MxEventStreamExtensions
{
/// <summary>
/// Projects a raw <see cref="MxEvent"/> stream (e.g.
/// <see cref="MxGatewaySession.StreamEventsAsync"/> or
/// <see cref="MxGatewayClient.StreamEventsAsync"/>) into typed
/// <see cref="MxEventStreamItem"/> values. Normal events pass through with
/// <see cref="MxEventStreamItem.IsReplayGap"/> false; the gateway's
/// reconnect-replay gap sentinel is surfaced with
/// <see cref="MxEventStreamItem.IsReplayGap"/> true and
/// <see cref="MxEventStreamItem.ReplayGap"/> populated. The stream is
/// forwarded faithfully — no event is synthesized or dropped.
/// </summary>
/// <param name="source">The raw event stream to wrap.</param>
/// <param name="cancellationToken">Cancellation token for the enumeration.</param>
/// <returns>The same events, each wrapped as an <see cref="MxEventStreamItem"/>.</returns>
public static async IAsyncEnumerable<MxEventStreamItem> AsStreamItemsAsync(
this IAsyncEnumerable<MxEvent> source,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(source);
await foreach (MxEvent gatewayEvent in source
.WithCancellation(cancellationToken)
.ConfigureAwait(false))
{
yield return MxEventStreamItem.From(gatewayEvent);
}
}
}
@@ -0,0 +1,82 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>
/// One item yielded by the typed event stream. It is either a normal MXAccess
/// <see cref="MxEvent"/> or a reconnect-replay <em>gap sentinel</em>.
/// </summary>
/// <remarks>
/// <para>
/// The gateway emits a single sentinel <see cref="MxEvent"/> at the head of a
/// <c>StreamEvents</c> stream that was resumed via
/// <c>StreamEventsRequest.after_worker_sequence</c> when the requested sequence
/// predates the oldest event still retained in the session replay ring — i.e.
/// events were evicted and cannot be replayed. On that sentinel the
/// <c>MxEvent.replay_gap</c> field is set, <see cref="MxEvent.Family"/> is
/// <see cref="MxEventFamily.Unspecified"/>, the <c>body</c> oneof is unset, and
/// no per-item fields are populated.
/// </para>
/// <para>
/// This wrapper makes that sentinel observable instead of forcing the consumer
/// to inspect the raw <see cref="MxEvent"/>. It never synthesizes or swallows an
/// event: the gap is exactly the gateway's own sentinel, exposed with
/// <see cref="IsReplayGap"/> set and <see cref="ReplayGap"/> populated. Every
/// other event flows through unchanged with <see cref="IsReplayGap"/> false.
/// </para>
/// <para>
/// When <see cref="IsReplayGap"/> is <see langword="true"/> the consumer has
/// missed events and MUST discard local state and re-snapshot. To resume without
/// incurring another gap, reconnect with
/// <c>after_worker_sequence = ReplayGap.OldestAvailableSequence - 1</c>.
/// </para>
/// </remarks>
public sealed class MxEventStreamItem
{
private MxEventStreamItem(MxEvent gatewayEvent, ReplayGap? replayGap)
{
Event = gatewayEvent;
ReplayGap = replayGap;
}
/// <summary>
/// The underlying raw <see cref="MxEvent"/>. For a normal event this is the
/// MXAccess event itself; for a replay-gap item this is the gateway's
/// sentinel event whose only meaningful payload is <see cref="ReplayGap"/>.
/// Never <see langword="null"/>.
/// </summary>
public MxEvent Event { get; }
/// <summary>
/// The reconnect-replay gap payload when this item is a gap sentinel;
/// otherwise <see langword="null"/>. Read
/// <see cref="Contracts.Proto.ReplayGap.RequestedAfterSequence"/> and
/// <see cref="Contracts.Proto.ReplayGap.OldestAvailableSequence"/> to learn
/// which events were lost.
/// </summary>
public ReplayGap? ReplayGap { get; }
/// <summary>
/// <see langword="true"/> when this item is a reconnect-replay gap sentinel:
/// the consumer missed events and must discard local state and re-snapshot.
/// Resume without another gap by reconnecting with
/// <c>after_worker_sequence = ReplayGap.OldestAvailableSequence - 1</c>.
/// </summary>
public bool IsReplayGap => ReplayGap is not null;
/// <summary>
/// Wraps a raw stream <see cref="MxEvent"/> as a typed item, classifying it
/// as a replay-gap sentinel when <c>MxEvent.replay_gap</c> is present.
/// </summary>
/// <param name="gatewayEvent">The raw event from the <c>StreamEvents</c> stream.</param>
/// <returns>A typed item exposing either the normal event or the replay gap.</returns>
internal static MxEventStreamItem From(MxEvent gatewayEvent)
{
ArgumentNullException.ThrowIfNull(gatewayEvent);
// For a message-typed proto3 field, presence is a non-null reference.
return gatewayEvent.ReplayGap is { } replayGap
? new MxEventStreamItem(gatewayEvent, replayGap)
: new MxEventStreamItem(gatewayEvent, replayGap: null);
}
}
@@ -875,6 +875,34 @@ public sealed class MxGatewaySession : IAsyncDisposable
cancellationToken);
}
/// <summary>
/// Streams events as typed <see cref="MxEventStreamItem"/> values, surfacing
/// the gateway's reconnect-replay gap sentinel as an observable, typed signal.
/// </summary>
/// <remarks>
/// When resuming with a stale <paramref name="afterWorkerSequence"/> (older than
/// the oldest event still retained in the session replay ring), the gateway emits
/// a single gap sentinel at the head of the stream. It arrives here as an item
/// with <see cref="MxEventStreamItem.IsReplayGap"/> true and
/// <see cref="MxEventStreamItem.ReplayGap"/> populated, meaning the consumer
/// missed events and MUST discard local state and re-snapshot. To resume without
/// incurring another gap, reconnect with
/// <c>afterWorkerSequence = item.ReplayGap.OldestAvailableSequence - 1</c>.
/// All other events pass through with <see cref="MxEventStreamItem.IsReplayGap"/>
/// false. Use <see cref="StreamEventsAsync"/> when raw generated
/// <see cref="MxEvent"/> messages are needed instead.
/// </remarks>
/// <param name="afterWorkerSequence">The sequence number to stream from. Defaults to 0.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>An async enumerable of typed event items.</returns>
public IAsyncEnumerable<MxEventStreamItem> StreamEventItemsAsync(
ulong afterWorkerSequence = 0,
CancellationToken cancellationToken = default)
{
return StreamEventsAsync(afterWorkerSequence, cancellationToken)
.AsStreamItemsAsync(cancellationToken);
}
/// <summary>
/// Closes the session and releases resources.
/// </summary>