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:
Joseph Doherty
2026-07-09 16:22:28 -04:00
parent c823a7b60b
commit 0c6e5b3ace
22 changed files with 972 additions and 37 deletions
+34
View File
@@ -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
@@ -9,6 +9,7 @@ from .generated.galaxy_repository_pb2 import (
GalaxyObject,
WatchDeployEventsRequest,
)
from .events import ReplayGap
from .errors import (
MxAccessError,
MxGatewayAuthenticationError,
@@ -43,6 +44,7 @@ __all__ = [
"MxGatewayTransportError",
"MxGatewayWorkerError",
"MxValueView",
"ReplayGap",
"Session",
"WatchDeployEventsRequest",
"__version__",
@@ -0,0 +1,63 @@
"""Typed event-stream signals for the MXAccess Gateway Python client."""
from __future__ import annotations
from dataclasses import dataclass
from .generated import mxaccess_gateway_pb2 as pb
@dataclass(frozen=True, slots=True)
class ReplayGap:
"""Reconnect-replay gap signal surfaced on a resumed event stream.
The gateway emits this at the head of a stream resumed with
:meth:`Session.stream_events`'s ``after_worker_sequence`` cursor when the
requested sequence predates the oldest event still retained in the gateway's
replay ring. That means the events between ``requested_after_sequence`` and
``oldest_available_sequence`` were dropped from the ring and can no longer be
replayed — the client has an unrecoverable hole in its event history.
``ReplayGap`` is a *non-terminal, observable* signal: the stream keeps
delivering normal :class:`~zb_mom_ww_mxgateway.generated.mxaccess_gateway_pb2.MxEvent`
values after it. :meth:`Session.stream_events` yields it as a distinct type
(never as an ``MxEvent``) so a consumer can branch on
``isinstance(item, ReplayGap)`` and never mistake a gap for a real MXAccess
event. The client neither synthesizes nor swallows the gateway's sentinel —
it only makes that sentinel typed and observable.
On seeing a gap the consumer must discard any locally cached tag/alarm state
and re-snapshot (for example via :meth:`Session.read_bulk` or
:meth:`~zb_mom_ww_mxgateway.GatewayClient.query_active_alarms`). To resume
the stream without provoking another gap, reconnect with
``after_worker_sequence = gap.resume_after_worker_sequence`` (that is,
``oldest_available_sequence - 1``) so the next replayed event is the oldest
the gateway still retains.
The gateway sets this only on ``StreamEvents`` results — never on a normal
(non-resumed) stream and never on a ``DrainEvents`` reply.
"""
requested_after_sequence: int
"""The ``after_worker_sequence`` cursor the resumed stream was opened with."""
oldest_available_sequence: int
"""Oldest worker sequence the gateway can still replay."""
@classmethod
def from_proto(cls, gap: pb.ReplayGap) -> "ReplayGap":
"""Build a :class:`ReplayGap` from the generated ``ReplayGap`` message."""
return cls(
requested_after_sequence=gap.requested_after_sequence,
oldest_available_sequence=gap.oldest_available_sequence,
)
@property
def resume_after_worker_sequence(self) -> int:
"""``after_worker_sequence`` to resume the stream without another gap.
Equal to ``oldest_available_sequence - 1`` so the next event the gateway
replays is ``oldest_available_sequence`` — the oldest it still retains.
Clamped at ``0`` so it is never negative.
"""
return max(self.oldest_available_sequence - 1, 0)
@@ -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:
+106
View File
@@ -0,0 +1,106 @@
"""Tests for the typed ReplayGap signal on Session.stream_events (CLI-15)."""
from __future__ import annotations
from collections.abc import AsyncIterator
import pytest
from zb_mom_ww_mxgateway import ReplayGap, Session
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
class _FakeClient:
"""Minimal client stub exposing only what Session.stream_events needs."""
def __init__(self, events: list[pb.MxEvent]) -> None:
self._events = events
self.last_request: pb.StreamEventsRequest | None = None
def stream_events_raw(
self,
request: pb.StreamEventsRequest,
) -> AsyncIterator[pb.MxEvent]:
self.last_request = request
async def _gen() -> AsyncIterator[pb.MxEvent]:
for event in self._events:
yield event
return _gen()
def _gap_sentinel(*, requested: int, oldest: int) -> pb.MxEvent:
return pb.MxEvent(
session_id="session-1",
family=pb.MX_EVENT_FAMILY_UNSPECIFIED,
replay_gap=pb.ReplayGap(
requested_after_sequence=requested,
oldest_available_sequence=oldest,
),
)
def _normal_event(worker_sequence: int) -> pb.MxEvent:
return pb.MxEvent(
session_id="session-1",
worker_sequence=worker_sequence,
family=pb.MX_EVENT_FAMILY_ON_DATA_CHANGE,
)
def _session(events: list[pb.MxEvent]) -> tuple[Session, _FakeClient]:
client = _FakeClient(events)
session = Session(client=client, session_id="session-1") # type: ignore[arg-type]
return session, client
@pytest.mark.asyncio
async def test_replay_gap_sentinel_surfaces_as_typed_signal() -> None:
session, _client = _session(
[_gap_sentinel(requested=5, oldest=10), _normal_event(10)],
)
items = [item async for item in session.stream_events(after_worker_sequence=5)]
assert isinstance(items[0], ReplayGap)
assert items[0].requested_after_sequence == 5
assert items[0].oldest_available_sequence == 10
# Resume cursor is oldest_available_sequence - 1 so the next replayed event
# is the oldest the gateway still retains.
assert items[0].resume_after_worker_sequence == 9
# Normal events after the sentinel pass through unchanged as MxEvent.
assert isinstance(items[1], pb.MxEvent)
assert not items[1].HasField("replay_gap")
assert items[1].worker_sequence == 10
@pytest.mark.asyncio
async def test_normal_events_are_unaffected() -> None:
session, _client = _session([_normal_event(1), _normal_event(2)])
items = [item async for item in session.stream_events()]
assert all(isinstance(item, pb.MxEvent) for item in items)
assert [item.worker_sequence for item in items] == [1, 2]
assert not any(isinstance(item, ReplayGap) for item in items)
@pytest.mark.asyncio
async def test_stream_events_forwards_resume_cursor() -> None:
session, client = _session([])
async for _ in session.stream_events(after_worker_sequence=42):
pass
assert client.last_request is not None
assert client.last_request.after_worker_sequence == 42
assert client.last_request.session_id == "session-1"
def test_replay_gap_resume_cursor_never_negative() -> None:
gap = ReplayGap.from_proto(
pb.ReplayGap(requested_after_sequence=0, oldest_available_sequence=0),
)
assert gap.resume_after_worker_sequence == 0