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:
@@ -57,6 +57,25 @@ type commandReplyOutput struct {
|
||||
Reply json.RawMessage `json:"reply"`
|
||||
}
|
||||
|
||||
// replayGapRow is the JSON row stream-events emits for a reconnect-replay gap:
|
||||
// {"replayGap":{"requestedAfterSequence":N,"oldestAvailableSequence":N}}.
|
||||
//
|
||||
// The cursors are typed by hand rather than marshalled with protojson on
|
||||
// purpose. The proto3 JSON mapping renders 64-bit integers as JSON *strings*
|
||||
// ("7"), but the Rust and Python CLIs emit JSON *numbers* (7) for this row —
|
||||
// routing through protojson would silently make Go the odd one out and break
|
||||
// the cross-language smoke matrix's row comparison. encoding/json renders
|
||||
// uint64 as a number, which is the canonical rendering here.
|
||||
type replayGapRow struct {
|
||||
ReplayGap replayGapCursors `json:"replayGap"`
|
||||
}
|
||||
|
||||
// replayGapCursors is the nested cursor object of replayGapRow.
|
||||
type replayGapCursors struct {
|
||||
RequestedAfterSequence uint64 `json:"requestedAfterSequence"`
|
||||
OldestAvailableSequence uint64 `json:"oldestAvailableSequence"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := runWithIO(context.Background(), os.Args[1:], os.Stdout, os.Stderr); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
@@ -970,7 +989,31 @@ func runStreamEvents(ctx context.Context, args []string, stdout, stderr io.Write
|
||||
if result.Err != nil {
|
||||
return result.Err
|
||||
}
|
||||
if *jsonOutput {
|
||||
// A reconnect-replay gap is a typed signal, not an event: the library
|
||||
// clears Event on it, so formatting Event here would print a meaningless
|
||||
// zero row and discard the resume cursors the operator needs. Render it
|
||||
// as its own row (matching the Rust CLI) and count it toward -limit like
|
||||
// any other emitted row.
|
||||
if result.IsReplayGap() {
|
||||
if *jsonOutput {
|
||||
row, err := json.Marshal(replayGapRow{
|
||||
ReplayGap: replayGapCursors{
|
||||
RequestedAfterSequence: result.ReplayGap.GetRequestedAfterSequence(),
|
||||
OldestAvailableSequence: result.ReplayGap.GetOldestAvailableSequence(),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(stdout, string(row))
|
||||
} else {
|
||||
fmt.Fprintf(
|
||||
stdout,
|
||||
"REPLAY_GAP requested_after=%d oldest_available=%d\n",
|
||||
result.ReplayGap.GetRequestedAfterSequence(),
|
||||
result.ReplayGap.GetOldestAvailableSequence())
|
||||
}
|
||||
} else if *jsonOutput {
|
||||
fmt.Fprintln(stdout, string(mustMarshalProto(result.Event)))
|
||||
} else {
|
||||
fmt.Fprintf(stdout, "%d %s\n", result.Event.GetWorkerSequence(), result.Event.GetFamily())
|
||||
|
||||
Reference in New Issue
Block a user