Skip to content

SOL-24c: Add WebSocketTransactionStream (free adapter)#91

Merged
Puneethkumarck merged 3 commits intomainfrom
feature/SOL-24c-websocket-transaction-stream
Apr 11, 2026
Merged

SOL-24c: Add WebSocketTransactionStream (free adapter)#91
Puneethkumarck merged 3 commits intomainfrom
feature/SOL-24c-websocket-transaction-stream

Conversation

@Puneethkumarck
Copy link
Copy Markdown
Owner

@Puneethkumarck Puneethkumarck commented Apr 11, 2026

Summary

Adds WebSocketTransactionStream, the free-tier adapter implementing the TransactionStream port, paired with the paid YellowstoneTransactionStream gRPC sibling. The adapter:

  • Uses JDK java.net.http.WebSocket only — no new build dependency
  • On connect, sends the exact blockSubscribe JSON-RPC payload per the spec (commitment=confirmed, encoding=jsonParsed, transactionDetails=full, maxSupportedTransactionVersion=0)
  • Accumulates onText fragments in a StringBuilder until last=true, then parses the frame via Jackson, extracts params.result.value, logs [SLOT n], and routes non-vote transactions through the existing BlockNotificationParser
  • Filters vote transactions client-side — checks both the direct programId field and the programIdIndex-resolved accountKeys entry against Vote111111111111111111111111111111111111111 (supports textual accountKeys and the jsonParsed {pubkey} object form)
  • Runs a reconnect loop on a virtual thread, backed by the shared ReconnectHandler (exponential backoff, resetIfStable on clean disconnects)
  • Closes cleanly via AtomicBoolean + CountDownLatch + thread interrupt — no synchronized, matching the gRPC sibling's lifecycle
  • Isolates consumer exceptions and parser failures per-call so one bad handler cannot kill the stream

The HttpClient in defaultFactory() is built with a connect timeout so close() during a hanging connect attempt does not stall the shutdown path. A package-private WebSocketFactory seam is introduced so unit tests can stub HttpClient.newWebSocketBuilder() without mocking a builder chain.

Test plan

  • blockSubscribe payload sent exactly as specified on connect — asserted via a literal payload match on sendText(..., last=true)
  • Vote transactions filtered when referenced via direct programId string
  • Vote transactions filtered when referenced via programIdIndex pointing into accountKeys
  • Non-vote transactions and fee payers forwarded to both consumers — asserted with a single usingRecursiveComparison over a composite (txs, feePayers) record
  • onClose triggers reconnect — verified by asserting a second listener registration and a ReconnectHandler.nextDelay() invocation
  • ./gradlew build — green (compile + Spotless + unit + integration + ArchUnit)
  • No synchronized; domain layer untouched; no new entries in build.gradle.kts or libs.versions.toml

Notes

  • Test harness uses a lambda-based WebSocketFactory stub that captures registered listeners into a CopyOnWriteArrayList, allowing deterministic assertions without network I/O
  • ReconnectHandler is spied and its nextDelay() stubbed to 0L so reconnect tests don't sleep
  • Same TransactionStream port as the gRPC sibling; no changes to domain or application layers required for the alternative streaming mode

Closes #26

Summary by CodeRabbit

  • New Features

    • Real-time WebSocket transaction streaming with automatic reconnection, idle-timeout handling, and graceful shutdown.
    • Server-side filtering to exclude vote transactions and extraction/routing of fee-payer accounts for downstream processing.
  • Tests

    • Comprehensive end-to-end tests validating subscription handshakes, message filtering, reconnection behavior, race-condition handling, and proper shutdown.

Implements the free-tier WebSocket adapter for the TransactionStream port,
mirroring the paid Yellowstone gRPC sibling. Uses JDK java.net.http.WebSocket
with no external dependency.

On connect, sends a blockSubscribe JSON-RPC with commitment=confirmed and
encoding=jsonParsed. Accumulates text fragments until last=true, parses the
resulting frame, extracts the block notification value, and routes
transactions through the existing BlockNotificationParser. Vote transactions
are filtered client-side by matching Vote111... against both the direct
programId field and the programIdIndex-resolved accountKeys entry.

The run loop follows the gRPC sibling's pattern: virtual thread with
AtomicBoolean lifecycle, CountDownLatch per connection attempt, and
ReconnectHandler for exponential backoff with resetIfStable on clean
disconnects. HttpClient is built with a connect timeout so close() during a
hanging connect does not stall the shutdown path. Consumer exceptions and
parser failures are isolated per-call so one bad handler cannot kill the
stream.

A package-private WebSocketFactory seam is introduced to let unit tests
stub HttpClient.newWebSocketBuilder() interactions without mocking a builder
chain.

Closes #26

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@Puneethkumarck Puneethkumarck added the phase:3-streaming gRPC + WebSocket streaming label Apr 11, 2026
@Puneethkumarck Puneethkumarck self-assigned this Apr 11, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 11, 2026

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

Adds a package-private WebSocketFactory functional interface and a new WebSocketTransactionStream that opens a long-lived Solana blockSubscribe WebSocket, parses and filters block notifications (skips vote transactions), forwards transactions and fee-payer accounts to consumers, handles reconnects, and includes end-to-end unit tests.

Changes

Cohort / File(s) Summary
WebSocket Factory
prism/src/main/java/com/stablebridge/prism/infrastructure/websocket/WebSocketFactory.java
New package-private @FunctionalInterface WebSocketFactory declaring CompletableFuture<WebSocket> connect(URI, WebSocket.Listener) for asynchronous JDK WebSocket creation.
WebSocket Transaction Stream
prism/src/main/java/com/stablebridge/prism/infrastructure/websocket/WebSocketTransactionStream.java
New WebSocketTransactionStream class: spawns a single virtual-thread loop, connects via WebSocketFactory (fallback HttpClient factory), sends JSON-RPC blockSubscribe, buffers text frames, parses block notifications, filters vote-program instructions, forwards non-vote SolanaTransaction and fee-payer Account to consumers, records connection durations, uses ReconnectHandler for backoff, and supports graceful close.
WebSocket Tests
prism/src/test/java/com/stablebridge/prism/infrastructure/websocket/WebSocketTransactionStreamTest.java
New JUnit5 tests that inject a custom WebSocketFactory and mocked WebSocket to verify subscribe payload, vote-filtering (direct and via programIdIndex), routing of non-vote transactions and fee-payers, reconnection behavior, pending-connect race handling, idle timeout reconnection, and proper shutdown.

Sequence Diagram

sequenceDiagram
    participant App as Application
    participant Stream as WebSocketTransactionStream
    participant Factory as WebSocketFactory
    participant WS as WebSocket
    participant Server as Solana RPC
    participant Parser as BlockNotificationParser
    participant Consumer as Consumers

    App->>Stream: subscribe(txConsumer, acctConsumer)
    Stream->>Factory: connect(endpoint, listener)
    Factory-->>Stream: CompletableFuture<WebSocket>
    Stream->>WS: (onOpen) send blockSubscribe JSON-RPC
    loop Message Processing
        Server->>WS: text frame (block notification)
        WS->>Stream: onText(fragment)
        Stream->>Stream: buffer / assemble frame
        Stream->>Parser: parse block JSON
        Parser-->>Stream: transactions + fee payer accounts
        Stream->>Stream: filter out Vote111... transactions
        Stream->>Consumer: txConsumer.accept(tx)  (non-vote)
        Stream->>Consumer: acctConsumer.accept(account)
    end
    Server->>WS: close / error
    WS->>Stream: onClose/onError
    Stream->>Stream: record duration, compute reconnection delay
    Stream->>Factory: connect(endpoint, listener)  (retry)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰
I hop to the socket, ears on the wire,
I nibble the JSON, sorting sparks from the mire,
Votes I skip with a whiskered flick, not dire,
Reconnects hum softly, stitching thread to wire,
Streams sing carrot-laced code—what a choir!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'SOL-24c: Add WebSocketTransactionStream (free adapter)' directly and specifically describes the main change—adding a new WebSocketTransactionStream implementation, which aligns with the changeset.
Linked Issues check ✅ Passed The pull request successfully implements all core requirements from issue #26: uses JDK java.net.http.WebSocket, sends the exact JSON-RPC blockSubscribe payload, filters vote transactions, parses blocks via BlockNotificationParser, implements auto-reconnect with ReconnectHandler, and includes comprehensive unit tests covering subscribe format, vote filtering, transaction routing, and reconnect behavior.
Out of Scope Changes check ✅ Passed All changes are within scope: WebSocketFactory and WebSocketTransactionStream implement the TransactionStream port as specified, tests validate the required functionality, and no unrelated modifications are present.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/SOL-24c-websocket-transaction-stream

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@prism/src/main/java/com/stablebridge/prism/infrastructure/websocket/WebSocketTransactionStream.java`:
- Around line 136-139: The code installs a just-completed connection
unconditionally which can register a stale socket when close() or another
connect happened concurrently; after the join of
webSocketFactory.connect(endpoint, listener) in WebSocketTransactionStream,
instead of unconditionally calling currentSocket.set(socket) and
socket.sendText(...), atomically check that the stream is still accepting a new
socket (e.g., use currentSocket.compareAndSet(null, socket) or check a closed
flag) and only send BLOCK_SUBSCRIBE_PAYLOAD if the CAS/check succeeds; if it
fails (another socket or closed), immediately cleanly abort/close the newly
connected socket so it cannot become an orphaned duplicate.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: dcace92a-1b76-4169-8ee8-4f0ae04ee79b

📥 Commits

Reviewing files that changed from the base of the PR and between f5cf0c0 and 900229e.

📒 Files selected for processing (3)
  • prism/src/main/java/com/stablebridge/prism/infrastructure/websocket/WebSocketFactory.java
  • prism/src/main/java/com/stablebridge/prism/infrastructure/websocket/WebSocketTransactionStream.java
  • prism/src/test/java/com/stablebridge/prism/infrastructure/websocket/WebSocketTransactionStreamTest.java

Prevent a stale WebSocket from being installed when close() or a
close-then-subscribe cycle fires while the connect future is still in
flight. After join() returns, verify the stream is still running and
the current thread owns the loop; otherwise close the just-connected
socket and abort the iteration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
prism/src/main/java/com/stablebridge/prism/infrastructure/websocket/WebSocketTransactionStream.java (1)

136-146: ⚠️ Potential issue | 🔴 Critical

Make socket registration atomic with shutdown.

The new stale-socket guard still leaves a race window: if close() flips running after Line 137 but before Line 145, close() sees currentSocket == null, then this method installs the just-connected socket and sends the subscribe payload anyway. That can still leave an orphaned live subscription after shutdown or a fast close→subscribe cycle.

Use an atomic install step here (for example, a CAS-based ownership check, paired with clearing currentSocket in the close/error callbacks) before sending BLOCK_SUBSCRIBE_PAYLOAD.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@prism/src/main/java/com/stablebridge/prism/infrastructure/websocket/WebSocketTransactionStream.java`
around lines 136 - 146, The connection install has a race where close() can flip
running between the running/loopThread check and installing/sending on
currentSocket, leaving an orphaned subscription; modify
WebSocketTransactionStream to atomically install the newly opened socket into
currentSocket (use a CAS like currentSocket.compareAndSet(null, socket) or an
ownership token) immediately after connect and before calling
socket.sendText(BLOCK_SUBSCRIBE_PAYLOAD, true), and if the CAS fails (or running
is false) close the unowned socket; also ensure close() clears currentSocket and
that error/close callbacks only act on the socket instance currently held in
currentSocket to avoid clearing someone else’s socket.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@prism/src/test/java/com/stablebridge/prism/infrastructure/websocket/WebSocketTransactionStreamTest.java`:
- Line 123: Add a fragmented-frame regression test in
WebSocketTransactionStreamTest that exercises the buffering branch of
BlockSubscribeListener.onText by calling listener.onText(webSocket, frame,
false) for the initial fragment(s) and then listener.onText(webSocket,
finalFrame, true) for the final fragment; assert that parsing/handling (the code
path that processes a complete block notification) only occurs after the final
fragment is delivered. Update the other two similar test spots (the cases around
the comments at the other occurrences) to include an equivalent multi-fragment
case so onText is exercised with last=false followed by last=true and verify no
parse/handle side-effects occur until the final fragment.

---

Duplicate comments:
In
`@prism/src/main/java/com/stablebridge/prism/infrastructure/websocket/WebSocketTransactionStream.java`:
- Around line 136-146: The connection install has a race where close() can flip
running between the running/loopThread check and installing/sending on
currentSocket, leaving an orphaned subscription; modify
WebSocketTransactionStream to atomically install the newly opened socket into
currentSocket (use a CAS like currentSocket.compareAndSet(null, socket) or an
ownership token) immediately after connect and before calling
socket.sendText(BLOCK_SUBSCRIBE_PAYLOAD, true), and if the CAS fails (or running
is false) close the unowned socket; also ensure close() clears currentSocket and
that error/close callbacks only act on the socket instance currently held in
currentSocket to avoid clearing someone else’s socket.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 66c6da84-8ff7-44e0-8304-22620975e450

📥 Commits

Reviewing files that changed from the base of the PR and between 900229e and 82a6dd4.

📒 Files selected for processing (2)
  • prism/src/main/java/com/stablebridge/prism/infrastructure/websocket/WebSocketTransactionStream.java
  • prism/src/test/java/com/stablebridge/prism/infrastructure/websocket/WebSocketTransactionStreamTest.java

.formatted(SOME_SIGNATURE_BASE58, SOME_VOTE_AUTHORITY_BASE58));

// when
listener.onText(webSocket, frame, true);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Add a fragmented-frame regression test.

Every onText(...) call here uses last = true, so the new buffering path in BlockSubscribeListener.onText(...) is still untested. Add one case that delivers a block notification across multiple fragments and asserts parsing happens only after the final fragment.

Also applies to: 171-171, 212-212

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@prism/src/test/java/com/stablebridge/prism/infrastructure/websocket/WebSocketTransactionStreamTest.java`
at line 123, Add a fragmented-frame regression test in
WebSocketTransactionStreamTest that exercises the buffering branch of
BlockSubscribeListener.onText by calling listener.onText(webSocket, frame,
false) for the initial fragment(s) and then listener.onText(webSocket,
finalFrame, true) for the final fragment; assert that parsing/handling (the code
path that processes a complete block notification) only occurs after the final
fragment is delivered. Update the other two similar test spots (the cases around
the comments at the other occurrences) to include an equivalent multi-fragment
case so onText is exercised with last=false followed by last=true and verify no
parse/handle side-effects occur until the final fragment.

Extend the WebSocket stream with an idle-timeout watchdog that forces a
reconnect when no frames arrive for a sustained period, preventing the
runLoop from hanging on a silent half-open socket. Refactor the lifecycle
so the socket is always closed and the connected duration always
recorded after each iteration, eliminating the orphan-socket path on
partial connect failure. Log asynchronous sendText failures via
whenComplete so write errors surface instead of being silently
discarded. Add regression coverage for connect-attempt failure,
explicit close, and the idle-timeout reconnect, and strengthen the
existing reconnect assertion to verify the second subscribe payload.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@Puneethkumarck
Copy link
Copy Markdown
Owner Author

Review feedback addressed

CodeRabbit #discussion_r3067994490 — stale connect completion race
Fixed in 82a6dd4. After join() returns, the runLoop now verifies it still owns the stream (running.get() + Thread.currentThread() == loopThread.get()) before installing the new socket. If either check fails, the just-arrived socket is closed and the iteration aborts. Regression test shouldDiscardStaleSocketWhenStreamClosedDuringConnect holds the connect future pending, calls close(), then completes the future with a stale socket mock — asserts the stale socket is closed and sendText is never called.

Additional lifecycle hardening (e65f325)
Addresses related issues surfaced during the fix:

  • Idle-timeout watchdogIDLE_TIMEOUT = 60s, IDLE_CHECK_INTERVAL_SECONDS = 1. New awaitWithIdleTimeout polls the latch every second and forces a reconnect if no frames arrive for 60s. Survives silent half-open sockets where the peer stops sending without closing.
  • Always-close-on-exit — runLoop now always calls closeCurrentSocket() + recordConnectedDuration() after the try/catch (not just on exception). Eliminates the orphan-socket path on partial connect failure. Extracted closeCurrentSocket() helper shared with close().
  • Async sendText failure logging.whenComplete((ws, error) -> ...) surfaces write errors that previously disappeared into an ignored CompletableFuture.
  • Test coverage — strengthened shouldReconnectOnDisconnect to assert the second subscribe payload, added shouldReconnectWhenConnectAttemptFails, shouldSendCloseFrameWhenClosed, and shouldReconnectWhenIdleTimeoutExceeded.

Full build green: ./gradlew spotlessApply build — compile + Spotless + unit + integration + ArchUnit all pass. 9 tests in WebSocketTransactionStreamTest.

@Puneethkumarck Puneethkumarck merged commit e32fe47 into main Apr 11, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

phase:3-streaming gRPC + WebSocket streaming

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SOL-24c: WebSocketTransactionStream (free adapter)

1 participant