fix(GWC-25,CLI-35,CLI-36): make the empty-ring ReplayGap resumable end to end
An empty replay ring reported oldest_available_sequence = 0 even when gap was
true. Clients follow the documented after_worker_sequence = oldest - 1 formula,
so an unsigned client computed ulong.MaxValue: the follow-up resume replayed
nothing, reported no gap, and the live filter dropped every subsequent event —
a silently dead stream in the headline detach-and-resume scenario, reachable on
default config once ReplayRetentionSeconds (300) age-evicts the ring.
GWC-25: SessionEventDistributor.RegisterWithReplay's empty-ring branch now
reports _highestSequenceSeen + 1 — the next sequence that can possibly be
delivered — when gap is true, so oldest - 1 lands exactly on the highest
observed sequence and the resume delivers everything newer. Still 0 when there
is no gap, where the field is meaningless and never emitted. Nothing is lost:
the evicted interval was unrecoverable either way, and the sentinel's job is to
say "re-snapshot".
CLI-35: the Python CLI fed every stream item into MessageToDict, which raised on
the ReplayGap dataclass and aborted the command after consuming the stream. A
new _event_row helper renders a gap as {"replayGap": {...}} — the same camelCase
shape the Rust CLI emits — and leaves proto events on the existing path.
CLI-36: the Go CLI formatted result.Event on every row, but the library
deliberately clears Event on a gap, so text mode printed
"0 MX_EVENT_FAMILY_UNSPECIFIED" and JSON mode an empty object, discarding the
resume cursors. The loop now branches on result.IsReplayGap() and renders the
typed row in both modes, counting it toward -limit like any other row. The JSON
row's cursors are typed by hand rather than marshalled with protojson: the
proto3 JSON mapping renders 64-bit integers as strings ("7") while the Rust and
Python CLIs emit numbers (7), so going through protojson would have made Go the
only canonical CLI with a different value type.
Docs in the same change: docs/Sessions.md documents the empty-ring sentinel
value and that oldest - 1 is the universal resume formula in both the retained
and fully-evicted cases; docs/CrossLanguageSmokeMatrix.md gains a per-CLI
gap-rendering table covering both client findings, and records exactly what is
and is not comparable across CLIs (same keys and numeric cursors for Rust/Go/
Python; quoted cursors for .NET/Java; differing key order, whitespace, and
container), so a matrix runner compares parsed values rather than raw bytes.
Tests, all written red first and each reproducing its defect verbatim:
- SessionEventDistributorTests: RegisterWithReplayReportsNextDeliverableSequence
WhenRingEmptiedByAge, ...WithRetentionDisabled, and
ResumeUsingSentinelFormulaAfterEmptyRingGapDeliversLiveEvents.
- GatewayEndToEndReconnectReplayTests.ReconnectAfterFullAgeEvictionResumesWith
SentinelFormula — fake-worker e2e resume walk on a fake clock; the fixture now
takes a retention window and a TimeProvider.
- clients/python test_stream_events_renders_replay_gap.
- clients/go TestRunStreamEventsPrintsReplayGap.
GWC-25's ReplayGap.oldest_available_sequence proto-comment amendment is
deliberately deferred to the later codegen wave (see the tracker change log): it
is comment-only but triggers the full five-client regen fan-out.
This commit is contained in:
@@ -817,3 +817,65 @@ def test_write_secured_command_does_not_echo_value_on_failure(
|
||||
def test_write_secured_and_authenticate_user_commands_are_registered() -> None:
|
||||
names = set(main.commands)
|
||||
assert {"write-secured", "authenticate-user"} <= names
|
||||
|
||||
|
||||
class _FakeReplayGapSession:
|
||||
"""Session stand-in whose event stream starts with a ReplayGap sentinel.
|
||||
|
||||
Mirrors what ``Session.stream_events`` yields on a resume that predates the
|
||||
gateway's retained replay ring: the typed gap first, then normal events.
|
||||
"""
|
||||
|
||||
def __init__(self, gap, event) -> None:
|
||||
self._gap = gap
|
||||
self._event = event
|
||||
|
||||
def stream_events(self, **_kwargs):
|
||||
async def _iterate():
|
||||
yield self._gap
|
||||
yield self._event
|
||||
|
||||
return _iterate()
|
||||
|
||||
|
||||
def test_stream_events_renders_replay_gap(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""CLI-35: a ReplayGap renders as its own JSON row instead of crashing the command."""
|
||||
from zb_mom_ww_mxgateway.events import ReplayGap
|
||||
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
|
||||
|
||||
gap = ReplayGap(requested_after_sequence=7, oldest_available_sequence=42)
|
||||
event = pb.MxEvent(session_id="cli-test-session", worker_sequence=43)
|
||||
|
||||
async def fake_connect(options, **_kwargs):
|
||||
return _FakeAsyncClient()
|
||||
|
||||
monkeypatch.setattr(commands_module.GatewayClient, "connect", fake_connect)
|
||||
monkeypatch.setattr(
|
||||
commands_module,
|
||||
"_session",
|
||||
lambda _client, _session_id: _FakeReplayGapSession(gap, event),
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
main,
|
||||
[
|
||||
"stream-events",
|
||||
"--plaintext",
|
||||
"--session-id",
|
||||
"cli-test-session",
|
||||
"--after-worker-sequence",
|
||||
"7",
|
||||
"--max-events",
|
||||
"2",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
rows = json.loads(result.output)["events"]
|
||||
assert rows[0] == {
|
||||
"replayGap": {"requestedAfterSequence": 7, "oldestAvailableSequence": 42},
|
||||
}
|
||||
# The gap is rendered, never swallowed, and the normal event still follows it.
|
||||
assert "replayGap" not in rows[1]
|
||||
assert rows[1]["workerSequence"] == "43"
|
||||
|
||||
Reference in New Issue
Block a user