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:
Joseph Doherty
2026-08-07 05:37:27 -04:00
parent ead921cace
commit 44b8e37900
12 changed files with 596 additions and 28 deletions
+117
View File
@@ -617,3 +617,120 @@ func TestRunWriteBulkVariantRejectsMismatchedHandlesAndValues(t *testing.T) {
t.Fatalf("write-bulk mismatched handles/values error = %v", err)
}
}
// replayGapFakeGateway streams the gateway's reconnect-replay sentinel (an MxEvent
// carrying replay_gap, family UNSPECIFIED, body unset) followed by one normal data
// event — exactly what a resume whose cursor predates the retained replay ring sees.
type replayGapFakeGateway struct {
pb.UnimplementedMxAccessGatewayServer
}
func (g *replayGapFakeGateway) StreamEvents(
req *pb.StreamEventsRequest,
stream grpc.ServerStreamingServer[pb.MxEvent],
) error {
sentinel := &pb.MxEvent{
SessionId: req.GetSessionId(),
Family: pb.MxEventFamily_MX_EVENT_FAMILY_UNSPECIFIED,
ReplayGap: &pb.ReplayGap{
RequestedAfterSequence: 7,
OldestAvailableSequence: 42,
},
}
if err := stream.Send(sentinel); err != nil {
return err
}
return stream.Send(&pb.MxEvent{
SessionId: req.GetSessionId(),
Family: pb.MxEventFamily_MX_EVENT_FAMILY_ON_DATA_CHANGE,
WorkerSequence: 43,
})
}
func startReplayGapGateway(t *testing.T) string {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
server := grpc.NewServer()
pb.RegisterMxAccessGatewayServer(server, &replayGapFakeGateway{})
go func() { _ = server.Serve(listener) }()
t.Cleanup(func() {
server.Stop()
_ = listener.Close()
})
return listener.Addr().String()
}
// TestRunStreamEventsPrintsReplayGap pins CLI-36: the CLI must render the typed
// ReplayGap signal in both output modes instead of formatting the library's
// cleared Event field (which printed "0 MX_EVENT_FAMILY_UNSPECIFIED" in text mode
// and an empty object in JSON mode, destroying the resume cursors).
func TestRunStreamEventsPrintsReplayGap(t *testing.T) {
endpoint := startReplayGapGateway(t)
baseArgs := []string{
"stream-events",
"-endpoint", endpoint,
"-plaintext",
"-api-key", "test",
"-session-id", "gap-session",
"-after-worker-sequence", "7",
"-limit", "2",
}
var stdout, stderr bytes.Buffer
if err := runWithIO(t.Context(), baseArgs, &stdout, &stderr); err != nil {
t.Fatalf("runWithIO() error = %v; stderr = %s", err, stderr.String())
}
text := stdout.String()
if !strings.Contains(text, "REPLAY_GAP requested_after=7 oldest_available=42") {
t.Fatalf("stream-events text output missing typed gap row: %q", text)
}
if strings.Contains(text, "0 MX_EVENT_FAMILY_UNSPECIFIED") {
t.Fatalf("stream-events text output destroyed the gap into a zero row: %q", text)
}
if !strings.Contains(text, "43 MX_EVENT_FAMILY_ON_DATA_CHANGE") {
t.Fatalf("stream-events text output dropped the normal event: %q", text)
}
stdout.Reset()
stderr.Reset()
if err := runWithIO(t.Context(), append(baseArgs, "-json"), &stdout, &stderr); err != nil {
t.Fatalf("runWithIO(-json) error = %v; stderr = %s", err, stderr.String())
}
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
if len(lines) != 2 {
t.Fatalf("stream-events -json emitted %d rows, want 2: %q", len(lines), stdout.String())
}
// The cursors must decode as JSON numbers, not the strings the proto3 JSON
// mapping would produce for 64-bit fields: the Rust and Python CLIs emit
// numbers, and the cross-language matrix compares these rows across clients.
var gapRow struct {
ReplayGap *struct {
RequestedAfterSequence uint64 `json:"requestedAfterSequence"`
OldestAvailableSequence uint64 `json:"oldestAvailableSequence"`
} `json:"replayGap"`
}
if err := json.Unmarshal([]byte(lines[0]), &gapRow); err != nil {
t.Fatalf("parse gap row: %v\nrow: %s", err, lines[0])
}
if gapRow.ReplayGap == nil {
t.Fatalf("stream-events -json first row is not a replayGap row: %s", lines[0])
}
if gapRow.ReplayGap.RequestedAfterSequence != 7 || gapRow.ReplayGap.OldestAvailableSequence != 42 {
t.Fatalf("stream-events -json gap cursors = %+v, want 7/42", *gapRow.ReplayGap)
}
// Belt and braces on the value type: a protojson-rendered `"7"` already
// fails the decode above (encoding/json rejects a JSON string for an
// untagged uint64 field), but assert the raw bytes so a regression names
// the real problem instead of surfacing as an opaque unmarshal error.
if !strings.Contains(lines[0], `"requestedAfterSequence":7`) ||
!strings.Contains(lines[0], `"oldestAvailableSequence":42`) {
t.Fatalf("stream-events -json gap cursors must be JSON numbers, got: %s", lines[0])
}
}