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