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:
@@ -105,6 +105,40 @@ terminate the stream.
|
||||
Canceling a Python task cancels the client-side gRPC call or stream wait. It
|
||||
does not abort an in-flight MXAccess COM call inside the worker process.
|
||||
|
||||
### Event streaming and reconnect gaps
|
||||
|
||||
`Session.stream_events()` yields an async iterator whose items are either a
|
||||
normal `MxEvent` or a `ReplayGap`. Track the `worker_sequence` of the last event
|
||||
you processed and pass it back as `after_worker_sequence` to resume after a
|
||||
disconnect:
|
||||
|
||||
```python
|
||||
from zb_mom_ww_mxgateway import ReplayGap
|
||||
|
||||
cursor = 0
|
||||
async for item in session.stream_events(after_worker_sequence=cursor):
|
||||
if isinstance(item, ReplayGap):
|
||||
# The gateway dropped events between item.requested_after_sequence and
|
||||
# item.oldest_available_sequence — they are gone from the replay ring.
|
||||
# Discard local tag/alarm state and re-snapshot (e.g. read_bulk /
|
||||
# query_active_alarms), then resume without another gap:
|
||||
cursor = item.resume_after_worker_sequence # oldest_available_sequence - 1
|
||||
continue
|
||||
# Normal MXAccess event.
|
||||
cursor = item.worker_sequence
|
||||
handle(item)
|
||||
```
|
||||
|
||||
`ReplayGap` is the gateway's reconnect-replay gap sentinel made typed and
|
||||
observable. It is delivered only at the head of a stream resumed with a non-zero
|
||||
`after_worker_sequence` when the requested cursor predates the oldest retained
|
||||
event. It is a **non-terminal** signal — the stream continues with normal events
|
||||
after it — and it is never yielded as an `MxEvent`, so it can never be mistaken
|
||||
for a real MXAccess event. The client does not synthesize or swallow it; the
|
||||
gateway only sets it on `StreamEvents` results (never on a fresh stream or a
|
||||
`DrainEvents` reply). `GatewayClient.stream_events_raw` remains the raw protobuf
|
||||
stream (the sentinel arrives there as an `MxEvent` with `replay_gap` set).
|
||||
|
||||
## Write Semantics And Common Pitfalls
|
||||
|
||||
These are MXAccess parity behaviors that surprise new callers. The gateway
|
||||
|
||||
Reference in New Issue
Block a user