Skip to content

Fix stale SSE heights spawning concurrent /height polls#1271

Merged
DZakh merged 5 commits into
mainfrom
claude/gifted-newton-NH7Yn
Jun 2, 2026
Merged

Fix stale SSE heights spawning concurrent /height polls#1271
DZakh merged 5 commits into
mainfrom
claude/gifted-newton-NH7Yn

Conversation

@JonoPrest

@JonoPrest JonoPrest commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes issue #1270 where stale (non-increasing) height events from a reconnecting SSE stream would spawn multiple concurrent REST polling fallbacks, overwhelming the height endpoint.

Key Changes

  • SourceManager.res: Refactored getSourceNewHeight to use a single, long-lived polling fallback instead of spawning a new one for each stale height event. The subscription and fallback poller now run concurrently in separate loops that internally wait for a qualifying height, preventing the outer loop from spinning on stale events.

    • Added nextFallbackDelay() to introduce jitter into the fallback polling interval, preventing thundering herd when multiple indexers fall back simultaneously
    • Changed fallback condition from checking newHeight.contents > initialHeight to status.contents !== Done for clearer semantics
    • Restructured as two async functions (waitForSubscription and pollFallback) that race against each other
  • EnvioApiClient.res (new file): Created a REST client wrapper that tags requests with a hyperindex User-Agent header, making them attributable on the server side and consistent with SSE and Rust client behavior

  • HyperSyncSource.res and HyperFuelSource.res: Updated to use EnvioApiClient.make() instead of Rest.client() for their height polling endpoints

  • SourceManager_test.res: Added comprehensive test case verifying that a burst of 20 stale SSE heights results in at most 1 concurrent /height poll, confirming the fix prevents unbounded polling proliferation

Implementation Details

The core fix changes the fallback polling from a "fire-and-forget" model (where each stale event could trigger a new poller) to a "single persistent poller" model. Both the subscription waiter and the fallback poller contain their own internal loops that continue until they observe a height greater than knownHeight, so stale events no longer cause the outer getSourceNewHeight loop to iterate and spawn additional pollers.

The jittered fallback delay ([x/2, 1.5x) with mean x) reduces synchronized polling load across multiple indexers experiencing simultaneous subscription staleness.

https://claude.ai/code/session_01MCsYorVnvp3AKFtbyaiefw

Summary by CodeRabbit

  • Bug Fixes

    • Prevented duplicate concurrent fallback polling when subscription heights are stale; polling now stops correctly and respects overall completion state.
    • Improved fallback to use a single bounded poller with jitter to avoid synchronized requests.
  • Chores

    • API requests now include a consistent User-Agent header.
  • Tests

    • Added a regression test ensuring stale subscription events do not multiply fallback /height polls.

@coderabbitai

coderabbitai Bot commented Jun 2, 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: 7d21bf04-8c0f-4666-9446-ba9c1f651efd

📥 Commits

Reviewing files that changed from the base of the PR and between 51af993 and d727572.

📒 Files selected for processing (2)
  • packages/envio/src/sources/SourceManager.res
  • scenarios/test_codegen/test/lib_tests/SourceManager_test.res
🚧 Files skipped from review as they are similar to previous changes (2)
  • scenarios/test_codegen/test/lib_tests/SourceManager_test.res
  • packages/envio/src/sources/SourceManager.res

📝 Walkthrough

Walkthrough

Adds EnvioApiClient.make to inject a hyperindex/ User-Agent into Rest clients, switches HyperFuel/HyperSync to use it, refactors SourceManager's stale-subscription fallback to a jittered Promise.race between subscription wait and a single fallback poll loop, and adds a regression test preventing concurrent fallback polls.

Changes

Stale subscription fallback polling fix

Layer / File(s) Summary
EnvioApiClient factory and User-Agent injection
packages/envio/src/sources/EnvioApiClient.res
Adds a make(baseUrl) function that wraps Rest.client with a custom fetcher injecting a User-Agent: hyperindex/<EnvioPackage version> header while preserving existing headers.
Source integrations with EnvioApiClient
packages/envio/src/sources/HyperFuelSource.res, packages/envio/src/sources/HyperSyncSource.res
HyperFuelSource and HyperSyncSource now construct their JSON API clients via EnvioApiClient.make(endpointUrl) instead of Rest.client, so height route fetches use the Envio client.
SourceManager stale subscription fallback with jitter and Promise.race
packages/envio/src/sources/SourceManager.res
Adds jittered delay computation and refactors getSourceNewHeight to race waitForSubscription (waits on pendingHeightResolvers) against pollFallback (starts after stallTimeout / 2 + jitter, repeatedly calls getHeightOrThrow() with jittered delays) until a higher height is observed or status becomes Done; subscription handler ignores non-increasing heights.
Regression test for concurrent stale height polls
scenarios/test_codegen/test/lib_tests/SourceManager_test.res
Adjusts quiet-subscription timing and adds "Stale SSE heights do not multiply concurrent /height polls (#1270)" realtime test that emits repeated stale SSE heights and asserts the number of getHeightOrThrow calls triggered by the burst remains ≤ 1.

Sequence Diagram(s)

sequenceDiagram
  participant SSE as SSESubscription
  participant SM as SourceManager
  participant PHR as PendingHeightResolvers
  participant SRC as Source.getHeightOrThrow

  SSE->>SM: emit height (may be stale)
  SM->>PHR: wait for pending resolver -> height > knownHeight
  SM->>SRC: start fallback poll after stallTimeout/2 + jitter
  SRC-->>SM: return height or error
  PHR-->>SM: resolved height (> knownHeight)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

🚥 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 directly captures the core fix: preventing stale SSE heights from spawning concurrent REST /height polls, which matches the main objective and implementation changes throughout the PR.
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.

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


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

@JonoPrest JonoPrest requested a review from DZakh June 2, 2026 12:04
JonoPrest added 3 commits June 2, 2026 12:06
…heights (#1270)

Add a test reproducing the per-process /height request amplification: when the
SSE height stream re-emits non-increasing heights (e.g. a flapping/reconnecting
stream re-sending the current head), each event spins the outer wait loop in
getSourceNewHeight and leaks another uncancelled pollingFallback. The leaked
loops accumulate and each polls at pollingInterval, so N stale events produce
~N concurrent /height poll loops instead of one bounded loop.

The test drives 20 stale events and observes 21 concurrent polls, demonstrating
the effective request rate scales with stale-event count and is not bounded by
pollingInterval.

https://claude.ai/code/session_01MCsYorVnvp3AKFtbyaiefw
…poll (#1270)

The SSE->REST height fallback created a fresh pollingFallback promise on every
outer-loop iteration. When the height stream re-emitted non-increasing heights
(e.g. a reconnecting stream re-sending the current head, which onHeight does not
filter), the loop spun and leaked an uncancelled poller each time. The leaked
loops accumulated and each polled at pollingInterval (100ms), so a single stalled
chain could drive thousands of /height req/s from one process and starve the
shared HTTP client for every other chain.

- Replace the per-iteration fallback with one subscription waiter and one REST
  poller, each looping internally until it sees a height > knownHeight. A burst
  of stale events can no longer spawn additional pollers.
- Poll the fallback at stalledPollingInterval with jitter instead of the raw
  100ms pollingInterval, so the no-subscription and subscription-stall paths
  behave consistently and many indexers don't poll in lockstep.
- Both waiters stop when status becomes Done, so an orphaned poller doesn't keep
  polling after another source wins.

Also tag the REST /height poll with the hyperindex User-Agent (HyperSync and
HyperFuel) via a shared EnvioApiClient, matching the SSE stream and Rust data
client so the requests are attributable instead of the default node agent.

https://claude.ai/code/session_01MCsYorVnvp3AKFtbyaiefw
@JonoPrest JonoPrest force-pushed the claude/gifted-newton-NH7Yn branch from 1c341fa to 874333d Compare June 2, 2026 12:12
sourceState.pendingHeightResolvers->Array.push(resolve)
})
// If subscription goes quiet for half the stall timeout, fall back to REST polling
let pollingFallback = Utils.delay(stallTimeout / 2)->Promise.then(async () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think you should only update Utils.delay(stallTimeout / 2) here, so it runs in a random order. Other changes in the files look irrelevant. status.done is not needed, since h.contents <= knownHeight is enough.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This isn't a lockstep timing issue, it's a leak. The outer while creates a new
pollingFallback on every iteration. A stale height resolves the race through the
subscription but doesn't advance newHeight, so the loop runs again and creates
another pollingFallback, and the previous one is never cancelled. So N stale events
give N concurrent poll loops, each hitting /height every pollingInterval. That's
the runaway we saw, around 1.2 to 2.9k req/s from one process rather than 10.

Jittering stallTimeout / 2 only randomizes when each leaked poller starts, not how
many there are. 20 stale events still spawn 20 pollers. The regression test shows
this. 20 stale events give 21 concurrent /height calls on the current code and 1
with the single poller. Jitter alone still fails it.

The stale events are real. /height/sse is a WatchStream, so every reconnect
re-emits the current head, which is at or below what we already know, more so when LB
instances drift.

So the restructure is the fix, not the jitter. Trigger jitter is still worth having
on top, so I added it.

On status !== Done, agreed for the subscription waiter since it's parked on the
await, so I dropped it there. I kept it in the poll loop to stop an orphaned poller
hammering a drifted instance forever after another source has won. h <= knownHeight
doesn't cover that case. Added a comment. Can drop it if you'd rather.

UA/EnvioApiClient is defect 1 from the issue. Can split it into its own PR if you
want this one scoped to the polling fix.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do you mean that the actual issue that pendingHeightResolvers resolves with the current height, not increased one?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes exactly. Do you think we should just filter?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think we need to fix the line instead - by adding a protective if that newHeight is not lower than sourceState.knownHeight

let unsubscribe = createSubscription(~onHeight=newHeight => {
              sourceState.knownHeight = newHeight

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Much cleaner, will do 👍🏼

…eck in subscription waiter

- Jitter the fallback poll trigger across [stallTimeout/2, stallTimeout) so
  indexers that go quiet together don't start polling in lockstep.
- Drop the status check from the subscription waiter (it's parked on the await
  there, so the guard never runs). Keep it in the poll loop, where it stops an
  orphaned poller from hammering a lagging instance after another source wins.
- Widen the two timing-sensitive fallback tests to wait past the jittered trigger.

https://claude.ai/code/session_01MCsYorVnvp3AKFtbyaiefw

@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)
packages/envio/src/sources/SourceManager.res (1)

346-383: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Remove the raced subscription resolver when REST wins.

The resolver pushed on Line 352 is never removed if pollFallback() wins the race on Line 383. In the exact “stale/quiet SSE, REST keeps finding blocks” path this PR targets, each waitForNewBlock call leaves one more abandoned resolver in pendingHeightResolvers, so the array can grow unbounded until some future SSE event happens to flush it.

🧹 Suggested cleanup
-      let waitForSubscription = async () => {
-        let h = ref(initialHeight)
-        while h.contents <= knownHeight {
-          h :=
-            (
-              await Promise.make((resolve, _reject) => {
-                sourceState.pendingHeightResolvers->Array.push(resolve)
-              })
-            )
-        }
-        h.contents
-      }
+      let waitForSubscription = () => {
+        let resolverRef = ref(None)
+        let wait = async () => {
+          let h = ref(initialHeight)
+          while h.contents <= knownHeight {
+            h := await Promise.make((resolve, _reject) => {
+              resolverRef := Some(resolve)
+              sourceState.pendingHeightResolvers->Array.push(resolve)
+            })
+          }
+          h.contents
+        }
+        let cleanup = () =>
+          switch resolverRef.contents {
+          | Some(resolve) =>
+            sourceState.pendingHeightResolvers =
+              sourceState.pendingHeightResolvers->Array.keep(r => r !== resolve)
+          | None => ()
+          }
+        (wait(), cleanup)
+      }
       let pollFallback = async () => {
         let half = stallTimeout / 2
         await Utils.delay(half + (Math.random() *. half->Belt.Int.toFloat)->Belt.Float.toInt)
         logger->Logging.childTrace({
           "msg": "onHeight subscription stale, switching to polling fallback",
@@
-      let height = await Promise.race([waitForSubscription(), pollFallback()])
+      let (subscriptionPromise, cleanupSubscriptionWait) = waitForSubscription()
+      let height = await Promise.race([subscriptionPromise, pollFallback()])
+      cleanupSubscriptionWait()
🤖 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 `@packages/envio/src/sources/SourceManager.res` around lines 346 - 383, The
subscription resolver pushed into sourceState.pendingHeightResolvers inside
waitForSubscription can be left orphaned if pollFallback wins the Promise.race;
fix this by registering the resolver in a local variable when creating the
Promise (inside waitForSubscription), pushing it into
sourceState.pendingHeightResolvers, and ensuring it is removed from
sourceState.pendingHeightResolvers if the promise is never fulfilled (e.g., on
cleanup/cancellation when pollFallback completes or before returning from
waitForSubscription). Modify waitForSubscription (and its Promise.make resolver
registration) so that the resolver is removed from
sourceState.pendingHeightResolvers in the finally/cancellation path to avoid
unbounded growth of pendingHeightResolvers when pollFallback wins the race.
🤖 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 `@packages/envio/src/sources/SourceManager.res`:
- Around line 346-383: The subscription resolver pushed into
sourceState.pendingHeightResolvers inside waitForSubscription can be left
orphaned if pollFallback wins the Promise.race; fix this by registering the
resolver in a local variable when creating the Promise (inside
waitForSubscription), pushing it into sourceState.pendingHeightResolvers, and
ensuring it is removed from sourceState.pendingHeightResolvers if the promise is
never fulfilled (e.g., on cleanup/cancellation when pollFallback completes or
before returning from waitForSubscription). Modify waitForSubscription (and its
Promise.make resolver registration) so that the resolver is removed from
sourceState.pendingHeightResolvers in the finally/cancellation path to avoid
unbounded growth of pendingHeightResolvers when pollFallback wins the race.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: adb310fc-0e04-430a-ba2e-f1d8b9e921f3

📥 Commits

Reviewing files that changed from the base of the PR and between 874333d and 51af993.

📒 Files selected for processing (2)
  • packages/envio/src/sources/SourceManager.res
  • scenarios/test_codegen/test/lib_tests/SourceManager_test.res
🚧 Files skipped from review as they are similar to previous changes (1)
  • scenarios/test_codegen/test/lib_tests/SourceManager_test.res

)

Resolve the pending height resolvers only when the stream reports a strictly
higher height. The height stream re-emits the current head on every (re)connect,
and waking the wait loop on a height we already know spun the loop and created
another uncancelled pollingFallback each time, so N stale re-emits produced N
concurrent /height poll loops.

This fixes the runaway at the source, so the earlier wait-loop restructure is no
longer needed and is reverted here, leaving the jittered fallback trigger.

https://claude.ai/code/session_01MCsYorVnvp3AKFtbyaiefw

@DZakh DZakh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nice - I like how the change looks now 👍

@DZakh DZakh merged commit f6cf072 into main Jun 2, 2026
8 checks passed
@DZakh DZakh deleted the claude/gifted-newton-NH7Yn branch June 2, 2026 14:18
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.

2 participants