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
@@ -200,6 +200,63 @@ func TestEventsSlowConsumerYieldsErrSlowConsumerBeforeClose(t *testing.T) {
}
}
func TestEventsSurfacesReplayGapSentinelAsTypedSignal(t *testing.T) {
fake := &fakeGatewayServer{
streamStarted: make(chan struct{}),
streamReplayGap: &pb.ReplayGap{
RequestedAfterSequence: 5,
OldestAvailableSequence: 42,
},
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
events, err := session.EventsAfter(ctx, 5)
if err != nil {
t.Fatalf("EventsAfter() error = %v", err)
}
<-fake.streamStarted
// First result must be the typed replay-gap signal, not a normal event.
first := <-events
if first.Err != nil {
t.Fatalf("first result error = %v", first.Err)
}
if !first.IsReplayGap() {
t.Fatalf("first result IsReplayGap() = false, want true")
}
if first.Event != nil {
t.Fatalf("replay-gap result carried a non-nil Event %+v; want the sentinel to clear Event", first.Event)
}
if got := first.ReplayGap.GetRequestedAfterSequence(); got != 5 {
t.Fatalf("ReplayGap.RequestedAfterSequence = %d, want 5", got)
}
if got := first.ReplayGap.GetOldestAvailableSequence(); got != 42 {
t.Fatalf("ReplayGap.OldestAvailableSequence = %d, want 42", got)
}
// Normal events after the sentinel are unaffected: Event set, ReplayGap nil.
second := <-events
if second.Err != nil {
t.Fatalf("second result error = %v", second.Err)
}
if second.IsReplayGap() {
t.Fatalf("second result IsReplayGap() = true, want false for a normal event")
}
if second.Event == nil {
t.Fatal("second result Event = nil, want a normal event")
}
if got := second.Event.GetWorkerSequence(); got != 1 {
t.Fatalf("normal event worker sequence = %d, want 1", got)
}
if second.Event.GetFamily() != pb.MxEventFamily_MX_EVENT_FAMILY_ON_DATA_CHANGE {
t.Fatalf("normal event family = %s, want ON_DATA_CHANGE", second.Event.GetFamily())
}
}
func TestSessionHelpersBuildCommandsAndExposeRawReply(t *testing.T) {
fake := &fakeGatewayServer{
invokeReply: &pb.MxCommandReply{
@@ -643,6 +700,7 @@ type fakeGatewayServer struct {
streamStarted chan struct{}
streamDone chan struct{}
streamEventCount int
streamReplayGap *pb.ReplayGap
invokeReply *pb.MxCommandReply
invokeRequest *pb.MxCommandRequest
}
@@ -691,6 +749,16 @@ func (s *fakeGatewayServer) StreamEvents(req *pb.StreamEventsRequest, stream grp
if s.streamStarted != nil {
close(s.streamStarted)
}
if s.streamReplayGap != nil {
// Emit the reconnect-replay gap sentinel at the head of the resumed
// stream: family UNSPECIFIED, body unset, only replay_gap populated.
if err := stream.Send(&pb.MxEvent{
SessionId: req.GetSessionId(),
ReplayGap: s.streamReplayGap,
}); err != nil {
return err
}
}
eventCount := s.streamEventCount
if eventCount == 0 {
eventCount = 1
+36 -3
View File
@@ -27,14 +27,39 @@ const eventBufferSize = 16
// non-blockingly on overflow, even when all data slots are full.
const eventBufferReservedSlots = 1
// EventResult carries either the next ordered event or a terminal stream error.
// EventResult carries the next ordered event, a replay-gap signal, or a
// terminal stream error. Exactly one of Event, ReplayGap, or Err is set on any
// delivered result.
type EventResult struct {
// Event is the next event from the stream when Err is nil.
// Event is the next MXAccess event from the stream when both ReplayGap and
// Err are nil.
Event *MxEvent
// ReplayGap, when non-nil, is the gateway's reconnect-replay gap sentinel: it
// is delivered at the head of a resumed stream (one opened with a non-zero
// after_worker_sequence via EventsAfter/SubscribeEventsAfter) when the
// requested sequence predates the oldest event still retained in the replay
// ring, so the events in between were lost.
//
// It is a non-terminal, observable signal — the stream continues with normal
// events after it, and Event is nil on a gap result so a gap is never
// mistaken for a normal MXAccess event. On seeing a gap the consumer must
// discard any locally cached tag/alarm state and re-snapshot. To resume
// cleanly (without provoking another gap), reconnect with EventsAfter using
// afterWorkerSequence = ReplayGap.GetOldestAvailableSequence() - 1.
//
// The gateway sets ReplayGap only on StreamEvents results, never on a normal
// (non-resumed) stream and never on DrainEvents.
ReplayGap *ReplayGap
// Err is the terminal stream error; when non-nil no further results follow.
Err error
}
// IsReplayGap reports whether this result carries the gateway's reconnect-replay
// gap sentinel rather than a normal event or a terminal error.
func (r EventResult) IsReplayGap() bool {
return r.ReplayGap != nil
}
// EventSubscription owns a running gateway event stream.
type EventSubscription struct {
results <-chan EventResult
@@ -736,7 +761,15 @@ func (s *Session) subscribeEventsAfter(ctx context.Context, afterWorkerSequence
for {
event, err := stream.Recv()
if err == nil {
if !sendEventResult(streamCtx, results, EventResult{Event: event}, cancelWhenResultBufferFull, cancel) {
result := EventResult{Event: event}
// The gateway marks a reconnect-replay gap with a sentinel MxEvent
// carrying replay_gap (family UNSPECIFIED, body unset). Surface it
// as a distinct typed signal rather than a normal event: clear
// Event so consumers never process the sentinel as a data change.
if gap := event.GetReplayGap(); gap != nil {
result = EventResult{ReplayGap: gap}
}
if !sendEventResult(streamCtx, results, result, cancelWhenResultBufferFull, cancel) {
return
}
continue
+6
View File
@@ -30,6 +30,12 @@ type (
MxCommand = pb.MxCommand
// MxEvent is one ordered event delivered on a session event stream.
MxEvent = pb.MxEvent
// ReplayGap is the gateway sentinel payload signalling that a resumed event
// stream skipped past the oldest event still retained in the replay ring.
// RequestedAfterSequence is the after_worker_sequence the client resumed
// from; OldestAvailableSequence is the oldest sequence the gateway can still
// replay. See EventResult.ReplayGap for consumption guidance.
ReplayGap = pb.ReplayGap
// MxValue is the protobuf representation of an MXAccess value.
MxValue = pb.MxValue
// Value is an alias for MxValue retained for symmetry with other clients.