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:
@@ -137,6 +137,47 @@ redaction. Per-item bulk failures are reported inside each result entry
|
||||
`invoke_raw` / `client.invoke_raw` escape hatch performs neither check and
|
||||
returns the unvalidated reply.
|
||||
|
||||
## Event Streaming And Reconnect-Replay Gaps
|
||||
|
||||
`session.events()` / `session.events_after(after_worker_sequence)` (and the
|
||||
lower-level `client.stream_events`) return an `EventStream` that yields
|
||||
`EventItem` values, not bare `MxEvent`s:
|
||||
|
||||
```rust
|
||||
use zb_mom_ww_mxgateway_client::EventItem;
|
||||
|
||||
let mut stream = session.events_after(cursor).await?;
|
||||
while let Some(item) = stream.next().await {
|
||||
match item? {
|
||||
EventItem::Event(event) => { /* apply the MXAccess change */ }
|
||||
EventItem::ReplayGap(gap) => {
|
||||
// Recent history was evicted — discard local state and re-snapshot,
|
||||
// then resume without provoking another gap:
|
||||
let resume = gap.oldest_available_sequence.saturating_sub(1);
|
||||
stream = session.events_after(resume).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Almost every item is a normal `EventItem::Event`. `EventItem::ReplayGap` is a
|
||||
faithful, typed surfacing of the gateway's reconnect-replay gap sentinel — the
|
||||
client does not synthesize it. The gateway emits the sentinel at most once, at
|
||||
the head of a stream **resumed** via `events_after` (`after_worker_sequence`)
|
||||
when the requested sequence is older than the oldest event still retained in the
|
||||
session's replay ring: events in the open interval
|
||||
`(requested_after_sequence, oldest_available_sequence)` were evicted and cannot
|
||||
be replayed. A `ReplayGap` therefore means "you missed events — discard any
|
||||
local state and re-snapshot." To resume without a second gap, reconnect with
|
||||
`events_after(gap.oldest_available_sequence - 1)`, which replays starting at the
|
||||
first still-retained event. A stream opened from the beginning
|
||||
(`session.events()` / `events_after(0)`) never produces a `ReplayGap`.
|
||||
|
||||
`EventItem` provides `as_event()`, `into_event()`, and `replay_gap()` accessors
|
||||
for callers that prefer not to `match`. The `mxgw-cli stream-events` subcommand
|
||||
renders the sentinel as a distinct `REPLAY_GAP …` line (or a `replayGap` JSON
|
||||
object under `--json` / `--jsonl`).
|
||||
|
||||
## Write Semantics And Common Pitfalls
|
||||
|
||||
These are MXAccess parity behaviors that surprise new callers. The gateway
|
||||
|
||||
Reference in New Issue
Block a user