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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user