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