Skip to content

Implement optional StreamMaxLifetime for server streams - #1142

Open
s373nZ wants to merge 7 commits into
masterfrom
fix/streaming-subscription-max-lifetime
Open

Implement optional StreamMaxLifetime for server streams#1142
s373nZ wants to merge 7 commits into
masterfrom
fix/streaming-subscription-max-lifetime

Conversation

@s373nZ

@s373nZ s373nZ commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Implements an optional StreamMaxLifetime setting which bounds the duration a stream should live for. When set, clients are expected to re-connect when the connection is reaped.

Summary by CodeRabbit

  • New Features
    • Added STREAM_MAX_LIFETIME configuration to cap maximum lifetime of server-streaming RPCs (default: 1800s).
    • Supports STREAM_MAX_LIFETIME=0 to disable the cap.
  • Bug Fixes
    • Improved cleanup of abandoned/disconnected streaming sessions by terminating streams when the configured lifetime elapses.
  • Documentation
    • Updated the Environment Variables section in the README with the new setting and its behavior.
  • Tests
    • Added unit tests verifying stream lifetime expiration and bounded context timeout handling.

@s373nZ
s373nZ requested a review from altafan July 7, 2026 16:09
@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 03f0d8c8-4946-4864-8beb-74f086da4581

📥 Commits

Reviewing files that changed from the base of the PR and between 576d4c0 and 28de8b5.

📒 Files selected for processing (4)
  • README.md
  • internal/config/config.go
  • internal/interface/grpc/handlers/indexer.go
  • internal/interface/grpc/handlers/indexer_test.go
🚧 Files skipped from review as they are similar to previous changes (4)
  • README.md
  • internal/interface/grpc/handlers/indexer.go
  • internal/interface/grpc/handlers/indexer_test.go
  • internal/config/config.go

Walkthrough

Adds configurable StreamMaxLifetime handling from configuration through gRPC service construction to server-streaming handlers. GetEventStream, GetTransactionsStream, and GetSubscription now terminate through bounded contexts, with tests covering lifetime expiry and context behavior.

Changes

Stream Max Lifetime

Layer / File(s) Summary
Configuration field and defaults
internal/config/config.go, README.md
Adds StreamMaxLifetime, its Viper key, 30-minute default, loading logic, and environment-variable documentation.
gRPC service configuration and startup wiring
internal/interface/grpc/config.go, cmd/arkd/main.go, internal/interface/grpc/service.go
Passes StreamMaxLifetime through gRPC configuration and server construction into the application handler.
Application stream lifetime enforcement
internal/interface/grpc/handlers/arkservice.go
Bounds event and transaction streams using cancellable or timeout-based derived contexts.
Indexer subscription enforcement and tests
internal/interface/grpc/handlers/indexer.go, internal/interface/grpc/handlers/indexer_test.go
Bounds subscription lifetime and tests timeout termination plus streamContext behavior.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Config
  participant GRPCService
  participant StreamHandler
  participant StreamContext
  Client->>Config: Set STREAM_MAX_LIFETIME
  Config->>GRPCService: Load StreamMaxLifetime
  GRPCService->>StreamHandler: Construct handler with lifetime
  Client->>StreamHandler: Start streaming RPC
  StreamHandler->>StreamContext: Create bounded context
  StreamContext-->>StreamHandler: Return context
  StreamHandler-->>Client: End stream on timeout or cancellation
Loading

Possibly related PRs

  • arkade-os/arkd#1143: Also changes GetSubscription stream lifecycle handling in internal/interface/grpc/handlers/indexer.go.

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: adding optional StreamMaxLifetime support for server 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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/streaming-subscription-max-lifetime

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.

🧹 Nitpick comments (1)
internal/interface/grpc/handlers/arkservice.go (1)

271-296: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Bounded context wiring looks correct.

ctx is properly derived from stream.Context() so client-side disconnects still propagate through the child context, and ctx.Done() correctly replaces stream.Context().Done() in the select loop. Returning nil on expiry lets the client observe io.EOF and reconnect as intended.

One gap: unlike GetSubscription in indexer.go (which now has TestGetSubscriptionMaxLifetime), there's no equivalent test here asserting GetEventStream/GetTransactionsStream actually terminate once maxStreamLifetime elapses. Consider adding analogous coverage for these two handlers.

Also applies to: 467-491

🤖 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/arkservice.go` around lines 271 - 296, The
bounded stream lifetime wiring in GetEventStream and GetTransactionsStream looks
correct, but there is no test coverage proving they actually stop when
maxStreamLifetime expires. Add analogous lifetime-expiry tests, similar to
TestGetSubscriptionMaxLifetime in indexer.go, that exercise these handler paths
and assert the stream terminates after the configured lifetime while still using
the existing streamContext/ctx.Done flow.
🤖 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.

Nitpick comments:
In `@internal/interface/grpc/handlers/arkservice.go`:
- Around line 271-296: The bounded stream lifetime wiring in GetEventStream and
GetTransactionsStream looks correct, but there is no test coverage proving they
actually stop when maxStreamLifetime expires. Add analogous lifetime-expiry
tests, similar to TestGetSubscriptionMaxLifetime in indexer.go, that exercise
these handler paths and assert the stream terminates after the configured
lifetime while still using the existing streamContext/ctx.Done flow.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d94c3fd3-7c58-4988-9a05-2db39187a3ee

📥 Commits

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

📒 Files selected for processing (8)
  • README.md
  • cmd/arkd/main.go
  • internal/config/config.go
  • internal/interface/grpc/config.go
  • internal/interface/grpc/handlers/arkservice.go
  • internal/interface/grpc/handlers/indexer.go
  • internal/interface/grpc/handlers/indexer_test.go
  • internal/interface/grpc/service.go

Comment thread README.md Outdated
Comment thread internal/config/config.go Outdated

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Summary

Bounding server-streaming RPCs is a reasonable defense against phantom sessions that a proxy prevents us from noticing. The plumbing itself (config → grpc.Config → handler → streamContext helper) is clean, and the helper's contract (<=0 disables, positive bounds) is locked down by tests. Two behaviour concerns and a few cleanup items below; requesting changes on #1/#2.

1. GetSubscription new-flow: reap destroys the subscription → reconnect gets SUBSCRIPTION_NOT_FOUND

internal/interface/grpc/handlers/indexer.go:452-469 — for a new-flow stream (client sends empty subscription_id) reconnectWindow is forced to 0. When maxStreamLifetime fires, the deferred h.scriptSubsHandler.release(subscriptionId, att, 0) deletes the listener immediately (broker.go:258-264: reconnectWindow > 0 && hasFilters is required to schedule expiry). A client that reconnects with the ID it received in SubscriptionStartedEvent gets SUBSCRIPTION_NOT_FOUND and must re-issue SubscribeForScripts and re-apply its filters.

That contradicts the comment added at indexer.go:497-500 ("live clients reconnect transparently"). It only holds for the legacy flow where reconnectWindow = subscriptionTimeoutDuration (60s). New-flow clients that don't have SDK-level stale-ID recovery lose events silently every 30 min.

Either:

  • Bind reconnectWindow = h.subscriptionTimeoutDuration when release is triggered by the server-side lifetime (distinguish server-driven expiry from client-cancel), or
  • Update the docstring and README to state that new-flow clients must handle SUBSCRIPTION_NOT_FOUND on reconnect and re-apply their filters, and add a test that covers a new-flow stream getting reaped, reconnecting with the ID, and having to resubscribe.

2. Cross-repo consumer breakage: go-sdk listenForArkTxs returns on io.EOF

go-sdk/wallet.go:939-942:

if errors.Is(event.Err, io.EOF) {
    closeFunc()
    return
}

There is no reconnect. When arkd 1142 ships with the default STREAM_MAX_LIFETIME=1800, any wallet built on the current go-sdk stops receiving ArkTx/CommitmentTx/SweepTx notifications 30 min after startup — settlements complete but the wallet-side VTXO store is never updated. This is a silent regression, no error is logged.

Options: (a) ship go-sdk with a reconnect loop first and default arkd to 0 for one release, then flip; (b) ship the two coordinated; (c) at minimum, call this out in the README with a migration note. The ts-sdk contractWatcher (contracts/contractWatcher.ts:684-688) already handles it; only the go-sdk tx listener needs work.

3. Non-blocking pre-select still watches stream.Context().Done()

indexer.go:519-527 (the "priority" non-blocking select) still uses stream.Context().Done() while the blocking select (now line 534) uses ctx.Done(). Not a correctness bug — a max-lifetime expiry is still caught on the next iteration of the blocking select — but it's inconsistent and means the priority optimization does not apply to the new exit path. Just switch it to ctx.Done() for consistency.

4. Thundering herd at expiry

Every stream opened within the same window (e.g. after an arkd restart) will be reaped at the same wall-clock instant. Add ±10% jitter to maxStreamLifetime in streamContext so reconnect load is smoothed.

5. Test coverage

TestGetSubscriptionMaxLifetime and TestStreamContext are good, but the equivalent path through GetEventStream / GetTransactionsStream in arkservice.go has no direct test. Since they use the same helper the risk is small, but a mock-stream test per handler is cheap and prevents future refactors from silently regressing.

Nits

  • README.md:104 stray period at end of ARKD_INDEXER_EXPOSURE. (pre-existing).
  • On reap the server just returns nil (client sees OK/EOF). Consider stream.SetTrailer(md{"x-ark-stream-reaped": "true"}) so clients can distinguish "reaped, reconnect now" from "server draining" and log accordingly.

@arkana-ai-bot

Copy link
Copy Markdown

Changes were requested 2+ days ago. @s373nZ need any help addressing the feedback?

@arkana-ai-bot

Copy link
Copy Markdown

Changes were requested 8+ days ago. @s373nZ need any help addressing the feedback?

@arkana-ai-bot

Copy link
Copy Markdown

Changes were requested 5 days ago. @s373nZ need any help addressing the feedback?

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Arkana review — sha 28de8b5

Looks ready to merge.

StreamMaxLifetime is a straightforward and well-targeted defence against abandoned gRPC streams that survive because a proxy masks the client disconnect. The implementation is consistent across all three streaming handlers (GetEventStream, GetTransactionsStream, GetSubscription). The streamContext helper is a clean single-responsibility extraction.

Behaviour is exactly right: the stream returns nil on expiry so the client sees io.EOF and can reconnect, rather than an error that might be treated as permanent.

Default of 1800s (30min) is a sensible starting point; operators can tune or disable with ARKD_STREAM_MAX_LIFETIME=0.

The test TestGetSubscriptionMaxLifetime verifies the reaping at 150ms without relying on the production default, and TestStreamContext locks the disabled/bounded branches. Both pass. No concerns.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review — StreamMaxLifetime for server streams

Verdict: looks ready to merge.

Adds a bounded lifetime to all three server-streaming RPCs (GetEventStream, GetTransactionsStream, GetSubscription) so abandoned streams — clients that vanished behind a proxy that masks the TCP RST — are reaped after at most ARKD_STREAM_MAX_LIFETIME seconds (default 30 min).

Design observations:

  • streamContext is a clean, testable helper that abstracts the zero-disables-bound semantics. ✅
  • Returning nil on timeout means the client sees io.EOF rather than a gRPC error, which is the correct signal for a reconnect. ✅
  • The 30-minute default is reasonable for clients that reconnect on EOF; operators running behind L7 proxies with shorter keepalive timeouts can lower it.
  • The bound uses context.WithTimeout from the stream's own context, so normal client disconnects still cancel promptly — the bound only fires when the parent context outlives the deadline. ✅

Tests: TestGetSubscriptionMaxLifetime and TestStreamContext are present and cover the key paths (zero disables, positive imposes deadline, handler exits gracefully). ✅

Minor note: the CHANGES_REQUESTED decision is from a prior reviewer — worth checking if their concerns have been addressed before merging.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Changes were requested 48 days ago. @s373nZ need any help addressing the feedback on StreamMaxLifetime?

@arkana-ai-bot

Copy link
Copy Markdown

Changes were requested 52+ days ago. @s373nZ — need any help addressing the feedback?

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Arkana review — #1142 (sha 28de8b5)

Implement optional StreamMaxLifetime for server streams

What's here

  • ARKD_STREAM_MAX_LIFETIME (default 1800s = 30min) bounds how long GetEventStream and GetTransactionsStream may stay open
  • streamContext() helper: returns WithCancel when lifetime ≤ 0 (disabled), WithTimeout otherwise
  • On expiry, handler returns nil → client receives io.EOF → reconnects transparently
  • Same logic applied to indexer GetSubscription

Looks good

  • The maxLifetime <= 0 guard correctly disables the bound
  • Returning nil (not an error) on timeout is correct for gRPC server-streaming — io.EOF is the clean stream end that clients expect to reconnect on
  • streamContext defers properly — cancel() is always called

One question

When maxStreamLifetime > 0 and the stream times out, does the broker entry get cleaned up via the defer h.eventsListenerHandler.removeListener(listener.id) that's already in place? If the listener is removed on context expiry, a reconnecting client will register a fresh listener — confirm the broker's dedup check won't block a re-registration with the same ID.

Verdict: Clean, minimal change. Looks ready.

@arkana-ai-bot

Copy link
Copy Markdown

Changes were requested 7+ weeks ago. @s373nZ — need any help addressing the feedback?

@arkana-ai-bot

Copy link
Copy Markdown

Changes were requested 56+ days ago. @s373nZ need any help addressing the feedback on StreamMaxLifetime?

@arkana-ai-bot

Copy link
Copy Markdown

Changes were requested 2+ days ago. @s373nZ — StreamMaxLifetime: need any help addressing the feedback?

@arkana-ai-bot

Copy link
Copy Markdown

Changes have been requested on this PR for 2+ months. @s373nZ need any help addressing the StreamMaxLifetime review feedback?

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