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