Files
mxaccessgw/clients/python/tests/test_replay_gap.py
T
Joseph Doherty 0c6e5b3ace 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
2026-07-09 16:22:28 -04:00

107 lines
3.3 KiB
Python

"""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