Skip to content

Bridge broker events onto SSE streams and replay history on reconnect - #48

Merged
johardi merged 4 commits into
epic/303-ssefrom
feat/304-sse-bridge
Jul 27, 2026
Merged

Bridge broker events onto SSE streams and replay history on reconnect#48
johardi merged 4 commits into
epic/303-ssefrom
feat/304-sse-bridge

Conversation

@johardi

@johardi johardi commented Jul 25, 2026

Copy link
Copy Markdown
Member

Commits 2–3 of protegeproject/webprotege-gwt-ui#304 (part of protegeproject/webprotege-gwt-ui#303). Stacked on #47.

Bridge: the sequenced change events fan out to open SSE streams alongside STOMP (identical payload bytes on both sinks; STOMP retired by #308), with the per-project sequence as the SSE id and emitter writes on the dispatch executor — a slow stream can never stall the broker listener.

Replay: a reconnect with Last-Event-ID (or ?lastEventId=) queries the event archive for everything after that id, sends one replay frame (id = archive end position), then flushes per-subscriber-buffered live events dropping anything ≤ the replay position — nothing missed, nothing double-delivered, regardless of interleaving. Bounded buffer; failed/overflowing catch-up degrades to live-only and the client's #297 gap detection recovers.

Tests: full gateway suite 45/45 under JDK 17, including a Testcontainers context-load proving the wiring. Known deliberate tradeoff: the history query blocks the request thread during catch-up (same pattern as the existing permission check); noted in code for a future non-blocking pass.

johardi added 4 commits July 24, 2026 20:56
Viewers can now open GET /data/projects/{projectId}/events as a
server-sent-events stream. A registry keeps the open streams per project
and fans incoming payloads out on an executor, evicting dead emitters on
send failure; a scheduled comment heartbeat keeps idle streams alive
through nginx's 60-second proxy read timeout, and the path rides the
existing /data/ route so no proxy changes are needed (response buffering
is disabled per-response with X-Accel-Buffering).

Authorization at stream-open mirrors the STOMP subscribe check: the
caller must hold ViewProject on the project, with identity resolution
isolated in one seam that #305 replaces with a short-lived redeemable
ticket. The Last-Event-ID header (and a lastEventId query fallback for
fresh loads, which never send the header) is plumbed to a catch-up seam
whose real replay implementation arrives with the sequence work; streams
also get a finite lifetime so every reconnect re-authorizes.

Nothing publishes into the registry yet — the broker bridge lands
separately once events carry real sequence numbers (#296).

Part of protegeproject/webprotege-gwt-ui#304
(epic protegeproject/webprotege-gwt-ui#303).
…tags

Every pushed event window carried the hardcoded tags 0 to 1, so after the
first window advanced a client's bookmark past 1, the client's stale-
window guard dropped every subsequent push — the websocket went silent
after one event and delivery quietly fell back to the 10-second poll.
That is the direct cause of the ~9.5s median UI latency measured in the
epic's baseline.

The emitter now consumes the sequenced event that the event-history
service publishes after archiving a change bundle, and pushes it with
truthful tags: startTag is the previous ordinal, endTag is the bundle's
ordinal. Because that event is emitted strictly after persistence,
anything pushed is already fetchable through the pull path. The wire
payload shape is unchanged, so the client deserializes it exactly as
before. The error log now includes the exception so failures are
diagnosable.

The gateway half of protegeproject/webprotege-gwt-ui#296
(part of protegeproject/webprotege-gwt-ui#303).
The sequenced change events the gateway consumes now fan out to the open
SSE streams as well as the STOMP topic, carrying the per-project
sequence ordinal as the SSE event id and the same truthful tags in the
payload. Both sinks send the identical payload object, so the bytes a
client sees are the same regardless of transport, and the registry hands
each emitter write to its dispatch executor so a slow stream can never
stall the broker listener thread. STOMP delivery stays in place until
the old transport is retired.

Part of protegeproject/webprotege-gwt-ui#304
(epic protegeproject/webprotege-gwt-ui#303).
A stream that opens with a last-seen event id now gets exact catch-up:
the gateway queries the durable event archive for everything after that
id, sends the result as a single replay frame whose id is the archive's
end position, and only then releases live delivery. Live events arriving
during the replay are buffered per subscriber and flushed in order with
anything at or below the replay position dropped, so nothing is missed
and nothing is delivered twice regardless of how the broker and the
query interleave. The buffer is bounded; on overflow or a failed history
query the stream stays alive and falls back to live-only, where the
client's gap detection recovers the difference.

Fresh connections carry no id and start at now — project open downloads
no history on this transport either.

Part of protegeproject/webprotege-gwt-ui#304
(epic protegeproject/webprotege-gwt-ui#303).
@johardi
johardi changed the base branch from feat/296-shared-sequence to epic/303-sse July 27, 2026 19:19
@johardi
johardi merged commit 113af54 into epic/303-sse Jul 27, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant