Skip to content

fix(indexer): terminate abandoned GetSubscription streams on reconnect - #1143

Merged
louisinger merged 9 commits into
masterfrom
fix/indexer-subscription-stream-displacement
Jul 8, 2026
Merged

fix(indexer): terminate abandoned GetSubscription streams on reconnect#1143
louisinger merged 9 commits into
masterfrom
fix/indexer-subscription-stream-displacement

Conversation

@Kukks

@Kukks Kukks commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

GetSubscription streams currently have no tie to their listener's lifecycle. Reconnecting with the same subscription id attaches a second stream to the same listener channel while the previous one stays parked in its select loop: if the server never observes the old client's disconnect (typical behind a load balancer that keeps the connection alive), every reconnect leaks the previous stream, and the leaked streams keep competing for events on the shared channel, so the reconnected client randomly loses events. A stream also survives its own subscription's removal (unsubscribe or reconnect-timeout expiry), since the loop never watches listener.done — unlike GetEventStream and GetTransactionsStream.

This gives the broker an explicit notion of a listener's active consumer: attach registers the calling stream as the sole consumer (cancelling any pending reap timeout) and displaces a previously attached stream by closing its displaced channel; detach releases it and reports whether the caller still owned the listener, so a displaced stream no longer arms the reap timer or removes the listener out from under its successor. GetSubscription's select now also exits on listener.done and on displacement, and the new and old flows share the same attach/detach path. Events buffered on the listener channel survive a reconnect and are delivered to the new stream.

One behavioral note: a subscription id now serves exactly one stream at a time, newest wins. Two clients deliberately sharing an id previously got a random split of events (each event went to exactly one of them), which doesn't seem like behavior anyone could rely on; with this change the latest stream takes over and earlier ones end cleanly. Happy to adjust if concurrent consumers per subscription are meant to be supported.

Complements #1142: a max stream lifetime bounds streams whose clients never come back; this change removes the leak on the reconnect path and ties stream lifetime to the subscription itself.

Covered by new handler-level tests (reconnect displaces the previous stream and events flow to the successor only, streams end on listener removal, displaced streams do not reap or remove the listener under the successor) plus broker-level tests for the attach/detach contract. go test -race ./internal/interface/grpc/handlers/ and golangci-lint run --tests=false are green.

Summary by CodeRabbit

  • New Features
    • Added explicit subscription take-over so reconnecting streams become the single active consumer.
    • Stream setup now treats blank subscription IDs as new and immediately starts sending from an inline listener.
  • Bug Fixes
    • Older/displaced streams no longer receive events after takeover.
    • Improved timeout handling to avoid premature listener removal and ensure correct cleanup on detachment/reconnect.
  • Tests
    • Expanded coverage for attach/release ownership, timeout/no-op rules, and high-concurrency reconnect races with deterministic ordering.
  • Chores
    • Enabled gRPC keepalive pings to better detect and close dead connections.

A stream serving a subscription id now holds an exclusive attachment on
the listener: reconnecting with the same id displaces the previous
stream instead of leaving it competing for events, and a stream whose
listener is removed (unsubscribe or reconnect-timeout expiry) now
terminates instead of idling forever. Cleanup on stream exit runs only
for the stream that still owns the listener, so a displaced stream can
no longer arm the reap timer or remove the listener out from under its
successor.
@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

The broker now tracks exclusive listener attachments, GetSubscription uses them for new streams and reconnects, the stream exits on displacement or cleanup, and gRPC server keepalive parameters were added.

Changes

Attachment lifecycle and subscription reconnect handling

Layer / File(s) Summary
Attachment model and attach/release/timeout logic
internal/interface/grpc/handlers/broker.go
Adds attachment state to listeners, replaces listener channel lookup with attach and release, and refactors timeout arming to respect active attachments.
GetSubscription attach and stream loop
internal/interface/grpc/handlers/indexer.go
Reworks GetSubscription to create new subscriptions, attach through the broker, defer a single release call, send SubscriptionStarted for new streams, and consume events through the attached listener channel with displacement-aware exits.
Broker attachment and timeout tests
internal/interface/grpc/handlers/broker_test.go
Extends broker tests for attachment ownership, displacement, timeout interaction, and concurrent release behavior, and updates direct channel access in the existing send-path test.
GetSubscription lifecycle and displacement tests
internal/interface/grpc/handlers/indexer_test.go
Updates subscription tests to inspect listener state directly, adds reconnect and displacement coverage, and introduces a gated mock stream to control send ordering.
gRPC keepalive server settings
internal/interface/grpc/service.go
Adds gRPC keepalive parameters to the server configuration.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

  • arkade-os/arkd#1140: Shares the GetSubscription error-mapping path for attachment/listener failures through subscriptionErr(subscriptionId, err).

Suggested reviewers: altafan

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: fixing GetSubscription reconnect behavior to terminate abandoned streams.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/indexer-subscription-stream-displacement

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/interface/grpc/handlers/indexer.go (1)

528-540: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Displaced stream can still consume a buffered event
At internal/interface/grpc/handlers/indexer.go:528-540, listener.done / attachment.displaced race with listener.ch in the same select, so a stale stream can win the receive and drop an event that should go to the successor. Move the exit check ahead of the receive path, or otherwise make the receive conditional on the current attachment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/interface/grpc/handlers/indexer.go` around lines 528 - 540, The
event loop in indexer stream handling can still read from listener.ch after
listener.done or attachment.displaced is signaled, letting a stale stream
consume a buffered event. Update the select/loop in the stream handler so the
exit conditions are checked before any receive from listener.ch, or gate the
receive on the current attachment state, ensuring only the active stream can
consume events.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@internal/interface/grpc/handlers/indexer.go`:
- Around line 528-540: The event loop in indexer stream handling can still read
from listener.ch after listener.done or attachment.displaced is signaled,
letting a stale stream consume a buffered event. Update the select/loop in the
stream handler so the exit conditions are checked before any receive from
listener.ch, or gate the receive on the current attachment state, ensuring only
the active stream can consume events.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6ba7c4e1-fe8f-424e-b90a-048881bc1ff9

📥 Commits

Reviewing files that changed from the base of the PR and between db93f3d and e298b6b.

📒 Files selected for processing (4)
  • internal/interface/grpc/handlers/broker.go
  • internal/interface/grpc/handlers/broker_test.go
  • internal/interface/grpc/handlers/indexer.go
  • internal/interface/grpc/handlers/indexer_test.go

The GetSubscription select loop treats the exit signals (context done,
listener removed, displaced by reconnect) and the event channel as peers,
so a displaced stream could still win the random select and drain an
event that belongs to its successor. Check the exit signals in a
non-blocking select before the blocking one, so a displaced stream stops
consuming and leaves buffered events on the channel for the stream that
took over.
@Kukks

Kukks commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in 2d7307d. The select now checks the exit signals (ctx done / listener removed / displaced) in a non-blocking select before the blocking one, so a displaced stream stops draining listener.ch and leaves buffered events for its successor.

Since select picks a ready case at random and the channel is shared, this can't be a hard guarantee at the exact instant an event and the displacement become ready together — but that boundary is already best-effort (the dispatch goroutine drops on a full buffer), and the priority check removes the case that actually mattered: a displaced stream steadily draining events into a dead connection. New test old flow displaced stream does not consume buffered events parks the predecessor mid-Send, buffers an event, displaces it, then asserts the event survives for the successor; it fails reliably without the priority check.

…on selects

Remove the freshly-pushed listener when attach fails on the new flow so a
failed attach cannot leak it. Add a concurrent attach/detach broker test and
document why GetSubscription uses two selects (priority gate plus blocking
wait).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

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 current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/interface/grpc/handlers/broker_test.go`:
- Around line 561-562: The comment near the release assertion is stale and
contradicts the current test behavior in broker_test.go. Update the inline
comment around the `releaseTrue` assertion in the `Test...` block to describe
the intended idempotency/no-second-release-wins behavior, and keep the
`require.Zero(t, releaseTrue.Load())` assertion unchanged since the second
`release(att)` should always be false. Use the surrounding `release(att)` calls
and `releaseTrue.Load()` as the anchor when editing.
🪄 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: CHILL

Plan: Pro

Run ID: aaa219aa-22c9-4045-a9e1-c2a3c96021c4

📥 Commits

Reviewing files that changed from the base of the PR and between b090dc1 and 7f1de91.

📒 Files selected for processing (5)
  • internal/interface/grpc/handlers/broker.go
  • internal/interface/grpc/handlers/broker_test.go
  • internal/interface/grpc/handlers/indexer.go
  • internal/interface/grpc/handlers/indexer_test.go
  • internal/interface/grpc/service.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/interface/grpc/handlers/indexer_test.go

Comment thread internal/interface/grpc/handlers/broker_test.go Outdated
@louisinger
louisinger force-pushed the fix/indexer-subscription-stream-displacement branch from c04238e to 3f37a32 Compare July 8, 2026 10:03
@louisinger
louisinger merged commit 5c56d54 into master Jul 8, 2026
6 checks 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.

3 participants