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:
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
|
||||
from .errors import ensure_mxaccess_success
|
||||
from .events import ReplayGap
|
||||
from .generated import mxaccess_gateway_pb2 as pb
|
||||
from .values import MxValueInput, to_mx_value
|
||||
|
||||
@@ -572,14 +573,57 @@ class Session:
|
||||
self,
|
||||
*,
|
||||
after_worker_sequence: int = 0,
|
||||
) -> AsyncIterator[pb.MxEvent]:
|
||||
"""Return an async iterator of `MxEvent` messages for this session."""
|
||||
return self.client.stream_events_raw(
|
||||
) -> AsyncIterator[pb.MxEvent | ReplayGap]:
|
||||
"""Return an async iterator over this session's `MxEvent` stream.
|
||||
|
||||
Each yielded item is either a normal :class:`~...MxEvent` or a
|
||||
:class:`ReplayGap`. Branch on ``isinstance(item, ReplayGap)`` — a gap is
|
||||
never delivered as an ``MxEvent`` so it cannot be mistaken for a real
|
||||
MXAccess event.
|
||||
|
||||
Pass a non-zero *after_worker_sequence* to resume a previously observed
|
||||
stream. If that cursor predates the oldest event the gateway still
|
||||
retains in its replay ring, the stream opens with a single
|
||||
:class:`ReplayGap` sentinel (events in the gap were dropped and cannot be
|
||||
replayed), then continues with normal events. On a gap, discard locally
|
||||
cached state, re-snapshot, and — to resume without another gap —
|
||||
reconnect with ``after_worker_sequence = gap.resume_after_worker_sequence``.
|
||||
See :class:`ReplayGap` for the full semantics. The underlying protobuf
|
||||
stream is available raw via ``GatewayClient.stream_events_raw``.
|
||||
"""
|
||||
raw = self.client.stream_events_raw(
|
||||
pb.StreamEventsRequest(
|
||||
session_id=self.session_id,
|
||||
after_worker_sequence=after_worker_sequence,
|
||||
),
|
||||
)
|
||||
return _surface_replay_gaps(raw)
|
||||
|
||||
|
||||
async def _surface_replay_gaps(
|
||||
raw: AsyncIterator[pb.MxEvent],
|
||||
) -> AsyncIterator[pb.MxEvent | ReplayGap]:
|
||||
"""Map the gateway's ``replay_gap`` sentinel event to a typed :class:`ReplayGap`.
|
||||
|
||||
Normal events pass through unchanged. The sentinel (``replay_gap`` set,
|
||||
``family`` unspecified, body unset) is converted to a distinct
|
||||
:class:`ReplayGap` so a consumer can branch on it without inspecting proto
|
||||
presence, and is never yielded as an ``MxEvent``. The sentinel is forwarded
|
||||
faithfully — it is neither dropped nor turned into a normal event.
|
||||
|
||||
Closing this generator (``aclose``) propagates to *raw* so the underlying
|
||||
gRPC call is cancelled, preserving the raw stream's cancel-on-stop contract.
|
||||
"""
|
||||
try:
|
||||
async for event in raw:
|
||||
if event.HasField("replay_gap"):
|
||||
yield ReplayGap.from_proto(event.replay_gap)
|
||||
else:
|
||||
yield event
|
||||
finally:
|
||||
aclose = getattr(raw, "aclose", None)
|
||||
if aclose is not None:
|
||||
await aclose()
|
||||
|
||||
|
||||
def _ensure_bulk_size(name: str, count: int) -> None:
|
||||
|
||||
Reference in New Issue
Block a user