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
+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