Skip to content

Fix indexer startup failure with large dynamic contract sets - #1259

Merged
DZakh merged 6 commits into
mainfrom
claude/brave-davinci-5YQLh
Jun 1, 2026
Merged

Fix indexer startup failure with large dynamic contract sets#1259
DZakh merged 6 commits into
mainfrom
claude/brave-davinci-5YQLh

Conversation

@DZakh

@DZakh DZakh commented May 29, 2026

Copy link
Copy Markdown
Member

Summary

Fixes a critical issue where indexers with many registered dynamic contracts would fail to start due to exceeding V8's maximum string length when aggregating contract data in a single JSON column.

Problem

On startup, InternalTable.Chains.getInitialState loads all registered dynamic contracts for each chain by aggregating the entire envio_addresses table into a single JSON column using json_agg. With enough contracts (approximately 120+ contracts with typical metadata), the aggregated JSON string exceeds V8's maximum string length (0x1fffffe8), causing postgres.js to throw ERR_STRING_TOO_LONG during row decoding. This made it impossible for indexers to resume.

Solution

Split the data loading into two separate queries:

  • makeGetInitialStateQuery: Loads chain state without the addresses aggregate
  • makeGetIndexingAddressesQuery: Loads all addresses as individual rows

The getInitialState function now:

  1. Executes both queries in parallel using Promise.all2
  2. Groups addresses by chain ID in JavaScript instead of relying on SQL aggregation
  3. Reconstructs the indexed addresses per chain before returning

This approach avoids creating oversized JSON strings while maintaining the same API contract.

Changes

  • Refactored InternalTable.Chains.makeGetInitialStateQuery to remove the json_agg subquery
  • Added InternalTable.Chains.makeGetIndexingAddressesQuery to fetch addresses as individual rows
  • Updated InternalTable.Chains.getInitialState to be async and handle grouping in JavaScript
  • Added DynamicContractsStartupSize_test.res to verify the fix handles 120+ contracts with 5MB metadata each
  • Updated corresponding test expectations in PgStorage_test.res

https://claude.ai/code/session_01BXuRQX5sq8KKfqoKcGRLZv

Summary by CodeRabbit

  • Bug Fixes

    • Resolved potential startup failures when working with large numbers of dynamic contracts.
    • Improved stability for projects with oversized contract metadata values.
  • Performance

    • Optimized contract initialization process with enhanced parallel data fetching operations.

Review Change Stack

claude added 2 commits May 29, 2026 13:11
getInitialState aggregates the entire envio_addresses table into one
json column via json_agg. With enough dynamic contracts the aggregated
value exceeds V8's max string length and postgres.js throws
ERR_STRING_TOO_LONG while decoding the row, so the indexer cannot resume.

The test seeds a chain with dynamic-contract rows whose combined
contract_name length exceeds the limit and asserts getInitialState
returns them all. It currently fails with the exact ERR_STRING_TOO_LONG
error from the issue, demonstrating the bug.

https://claude.ai/code/session_01BXuRQX5sq8KKfqoKcGRLZv
getInitialState aggregated the entire envio_addresses table per chain
with json_agg, producing a single column value that postgres.js decodes
via Buffer.toString. Past V8's max string length (0x1fffffe8) that throws
ERR_STRING_TOO_LONG and the indexer cannot resume.

Read the addresses as plain rows in a separate query and group them by
chain in JS, so no individual column value can overflow.

https://claude.ai/code/session_01BXuRQX5sq8KKfqoKcGRLZv
@coderabbitai

coderabbitai Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@DZakh, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 19 minutes and 49 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e2529fc6-6a62-4082-ac9b-75c0931ae238

📥 Commits

Reviewing files that changed from the base of the PR and between f8d1eb7 and 46d2bfb.

📒 Files selected for processing (1)
  • scenarios/test_codegen/test/lib_tests/SourceManager_test.res
📝 Walkthrough

Walkthrough

Refactored dynamic contract state loading to split indexing addresses into a separate query executed in parallel with the initial chain state query. This avoids exceeding V8's string-length limits when large contract names aggregate into JSON. Added async merging logic and comprehensive test coverage including a regression test for oversized contract payloads.

Changes

Indexing-addresses refactoring

Layer / File(s) Summary
SQL query separation and new types
packages/envio/src/db/InternalTable.res
Modified makeGetInitialStateQuery to remove embedded indexing-addresses aggregation and select source_block as sourceBlockNumber. Introduced rawIndexingAddress type and added makeGetIndexingAddressesQuery to fetch address records from envio_addresses separately.
Async initial-state and address merge
packages/envio/src/db/InternalTable.res
Converted Chains.getInitialState to async, executing both queries concurrently via Promise.all2, grouping fetched addresses by chainId in-memory, and merging them into each chain's initial state.
Unit test suite updates
scenarios/test_codegen/test/lib_tests/PgStorage_test.res
Updated makeGetInitialStateQuery test expectation to reflect removed JSON aggregation. Added test suite for makeGetIndexingAddressesQuery asserting correct SQL extraction of chainId, address, contractName, and registrationBlock from envio_addresses.
Regression test for large dynamic contracts
scenarios/test_codegen/test/lib_tests/DynamicContractsStartupSize_test.res
Added skipped integration test that reproduces issue #1242 by inserting 120 oversized contract entries and verifying successful loading despite JSON exceeding V8 string limits.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

  • Dynamic Contracts size causing startup error. #1242: The changes directly address the V8 string-length startup error by splitting the indexed dynamic contract JSON aggregation into a separate parallel query, preventing the oversized payload in the initial-state response.

Possibly related PRs

  • enviodev/hyperindex#1125: Both PRs coordinate the envio_addresses data flow—PR #1125 persists no-events dynamic contract addresses into envio_addresses, while this PR updates getInitialState to load those addresses via a dedicated query instead of JSON-aggregating them inline.
  • enviodev/hyperindex#1076: This PR's new makeGetIndexingAddressesQuery fetches envio_addresses rows using rawIndexingAddress, directly building on PR #1076's refactor that introduced the EnvioAddresses table.
  • enviodev/hyperindex#1121: Both PRs refactor InternalTable.Chains around the indexingAddresses model—this PR changes the SQL and data-fetch flow, while PR #1121 renames and reformats how indexingAddresses are represented.

Suggested reviewers

  • JonoPrest
  • JasoonS
🚥 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 and accurately summarizes the main change: fixing a startup failure caused by large dynamic contract sets exceeding V8's string length limit.
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.

The repro pushes ~600MB through Postgres to cross the V8 string limit,
too slow for every CI run. Keep it as a manually-runnable guard.

https://claude.ai/code/session_01BXuRQX5sq8KKfqoKcGRLZv

@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

🧹 Nitpick comments (1)
packages/envio/src/db/InternalTable.res (1)

215-249: ⚡ Quick win

Split the raw DB row type from the merged return type.

makeGetInitialStateQuery no longer returns indexingAddresses, but the cast here still treats those rows as rawInitialState. That makes the unsafe cast claim a field exists before the JS merge adds it. A dedicated raw-chain-row type would keep the cast aligned with the SQL and avoid future undefined-field bugs.

🤖 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/db/InternalTable.res` around lines 215 - 249, The code
incorrectly casts DB rows from makeGetInitialStateQuery as containing
indexingAddresses; update getInitialState to use a distinct raw row type for
rows returned by makeGetInitialStateQuery (e.g., rawInitialStateRow) and a
separate rawIndexingAddress type for makeGetIndexingAddressesQuery, then only
merge indexingAddresses into the final mapped object after building
indexingAddressesByChainId; specifically change the unsafe cast calls around
Postgres.unsafe(makeGetInitialStateQuery(~pgSchema)) and
Postgres.unsafe(makeGetIndexingAddressesQuery(~pgSchema)) to cast to the
appropriate raw types, and in the rawInitialStates->Array.map step construct the
final shape by adding indexingAddresses from indexingAddressesByChainId (using
rawInitialState.id->Int.toString) rather than assuming the field exists on the
DB row.
🤖 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 `@packages/envio/src/db/InternalTable.res`:
- Around line 215-223: getInitialState currently issues two independent
Postgres.unsafe calls which can observe different snapshots; wrap both reads in
a single repeatable-read transaction so they see the same DB snapshot. Modify
getInitialState to begin a transaction with isolation level REPEATABLE READ (or
use the library's transaction helper, e.g. Postgres.transaction or
Postgres.withTransaction) and inside that transaction invoke
makeGetInitialStateQuery and makeGetIndexingAddressesQuery on the same
transaction-bound `sql` object (replacing the current Promise.all2 flow), then
cast results to rawInitialState/rawIndexingAddress and return before committing;
ensure you use the same connection/`sql` for both Postgres.unsafe calls so both
reads use the same snapshot.

---

Nitpick comments:
In `@packages/envio/src/db/InternalTable.res`:
- Around line 215-249: The code incorrectly casts DB rows from
makeGetInitialStateQuery as containing indexingAddresses; update getInitialState
to use a distinct raw row type for rows returned by makeGetInitialStateQuery
(e.g., rawInitialStateRow) and a separate rawIndexingAddress type for
makeGetIndexingAddressesQuery, then only merge indexingAddresses into the final
mapped object after building indexingAddressesByChainId; specifically change the
unsafe cast calls around Postgres.unsafe(makeGetInitialStateQuery(~pgSchema))
and Postgres.unsafe(makeGetIndexingAddressesQuery(~pgSchema)) to cast to the
appropriate raw types, and in the rawInitialStates->Array.map step construct the
final shape by adding indexingAddresses from indexingAddressesByChainId (using
rawInitialState.id->Int.toString) rather than assuming the field exists on the
DB row.
🪄 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: 078979ea-04ae-4903-8cec-9f4162057727

📥 Commits

Reviewing files that changed from the base of the PR and between d9adc80 and f8d1eb7.

📒 Files selected for processing (3)
  • packages/envio/src/db/InternalTable.res
  • scenarios/test_codegen/test/lib_tests/DynamicContractsStartupSize_test.res
  • scenarios/test_codegen/test/lib_tests/PgStorage_test.res

Comment on lines +215 to +223
let getInitialState = async (sql, ~pgSchema) => {
let (rawInitialStates, rawIndexingAddresses) = await Promise.all2((
sql
->Postgres.unsafe(makeGetInitialStateQuery(~pgSchema))
->(Utils.magic: promise<array<unknown>> => promise<array<rawInitialState>>),
sql
->Postgres.unsafe(makeGetIndexingAddressesQuery(~pgSchema))
->(Utils.magic: promise<array<unknown>> => promise<array<rawIndexingAddress>>),
))

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.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Keep both startup reads on one database snapshot.

The old implementation got chain state and indexing addresses from one SQL statement; this split now reads them from two independent statements. Under Postgres' default READ COMMITTED behavior, a contract inserted or removed between these calls can make getInitialState resume from mismatched chain and address state. Please run both reads under a repeatable-read transaction, or otherwise force a single snapshot.

🤖 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/db/InternalTable.res` around lines 215 - 223,
getInitialState currently issues two independent Postgres.unsafe calls which can
observe different snapshots; wrap both reads in a single repeatable-read
transaction so they see the same DB snapshot. Modify getInitialState to begin a
transaction with isolation level REPEATABLE READ (or use the library's
transaction helper, e.g. Postgres.transaction or Postgres.withTransaction) and
inside that transaction invoke makeGetInitialStateQuery and
makeGetIndexingAddressesQuery on the same transaction-bound `sql` object
(replacing the current Promise.all2 flow), then cast results to
rawInitialState/rawIndexingAddress and return before committing; ensure you use
the same connection/`sql` for both Postgres.unsafe calls so both reads use the
same snapshot.

The test coordinates source recovery with real timers around a 50ms
recovery timeout, which is occasionally too tight under CI load. Retry
up to 3 times.

https://claude.ai/code/session_01BXuRQX5sq8KKfqoKcGRLZv
@DZakh
DZakh merged commit 3798ba8 into main Jun 1, 2026
8 checks passed
@DZakh
DZakh deleted the claude/brave-davinci-5YQLh branch June 1, 2026 08:20
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