Skip to content

Fix infinite reorg loop when reorg chain has no events processed - #1027

Merged
DZakh merged 3 commits into
mainfrom
claude/fix-indexer-reorg-loop-7hffU
Mar 10, 2026
Merged

DZakh merged 3 commits into
mainfrom
claude/fix-indexer-reorg-loop-7hffU

Conversation

@DZakh

@DZakh DZakh commented Mar 9, 2026 •

Copy link
Copy Markdown
Member

Summary

Fixed an infinite reorg→rollback loop that occurred when a chain experienced a reorganization but had no events processed since the target checkpoint. The issue was that rollback state wasn't being cleared for chains with no progress diff entries, causing the same reorg to be re-detected on subsequent fetches.

Key Changes

  • GlobalState.res: Modified the rollback logic to handle the case where a reorg chain has no progress diff entry. Now, even when getRollbackProgressDiff returns None for the reorg chain, we still rollback its reorgDetection and fetchState to prevent stale block hashes from triggering infinite reorg detection cycles.

  • Rollback_test.res: Added comprehensive test case "Should not enter infinite reorg loop when reorg chain has no events processed since target checkpoint" that verifies:

    • A chain can detect a reorg even with zero events processed
    • The rollback correctly resets fetch state to the rollback target block
    • No second reorg is detected after the rollback (confirming the infinite loop is fixed)

Implementation Details

The fix specifically checks if the current chain being processed is the reorg chain. If so, it applies rollback operations to both reorgDetection and fetchState regardless of whether a progress diff entry exists. This ensures that the stale block hash information stored in reorgDetection.dataByBlockNumber is cleared, preventing re-detection of the same reorg on the next fetch cycle.

https://claude.ai/code/session_01K8NhAjzDHp9H3sLJ7gHjQs

Summary by CodeRabbit

  • Tests

    • Added an end-to-end test covering rollback behavior in multi-chain unordered batch scenarios to ensure no infinite reorg/rollback loop occurs.
  • New Features

    • Added an optional batch-size configuration parameter for indexer initialization.
  • Bug Fixes

    • Improved rollback handling to consistently roll back reorg chain state, preventing repeated reorg/rollback cycles.

@coderabbitai

coderabbitai Bot commented Mar 9, 2026 •

Copy link
Copy Markdown
Contributor

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

Walkthrough

Adds an E2E rollback test, extends the test mock Indexer to accept an optional ~batchSize parameter, and changes GlobalState rollback handling to apply a rollback to the reorg chain even when there is no progress diff, preventing potential infinite reorg/rollback loops.

Changes

Cohort / File(s) Summary
Rollback Loop Prevention Test
scenarios/test_codegen/test/rollback/Rollback_test.res
Adds an E2E test that simulates a multi-chain unordered batch where one chain has no events since the target checkpoint; validates rollback depth calculation, re-fetch processing, and that a second reorg/rollback loop is not triggered.
Mock Indexer Configuration
scenarios/test_codegen/test/helpers/Mock.res
Updates Indexer.make public signature to add optional ~batchSize=? and passes it into generated config so tests can override batch size.
GlobalState Rollback Behavior
packages/envio/src/GlobalState.res
Modifies rollback path so that when no progress diff is present, the reorg chain’s fetcher state is rolled back to the rollback target (reorgDetection and fetchState), and non-reorg chains remain unchanged — preventing inconsistent state and infinite reorg loops.

Sequence Diagram(s)

sequenceDiagram
  participant ReorgDetector as ReorgDetector
  participant GlobalState as GlobalState
  participant ChainFetcher as ChainFetcher
  participant Processor as Processor
  participant BlockSource as BlockSource

  Note right of ReorgDetector: reorg detected
  ReorgDetector->>GlobalState: notifyReorg(reorgChain, rollbackTarget)
  GlobalState->>ChainFetcher: rollback(reorgChain, rollbackTarget) rgba(255,0,0,0.5)
  ChainFetcher->>BlockSource: fetchBlocks(from=rollbackTarget+1) rgba(0,128,0,0.5)
  BlockSource-->>ChainFetcher: blocks (may include zero-event blocks)
  ChainFetcher->>Processor: deliverBlocks(blocks) rgba(0,0,255,0.5)
  Processor-->>GlobalState: processingComplete(result)
  alt no further reorg triggered
    GlobalState-->>ReorgDetector: resumeNormalOperation
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • JonoPrest
  • JasoonS

"I’m a hopping rabbit, tidy and spry,
I nudge the rollback so loops say goodbye.
Batches adjusted, no spins in the night,
Chains step back — then forward — all right! 🐇"

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and clearly summarizes the main fix: addressing an infinite reorg loop when a reorg chain has no events processed since the target checkpoint.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch claude/fix-indexer-reorg-loop-7hffU

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.

@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/GlobalState.res (1)

1078-1125: ⚠️ Potential issue | 🔴 Critical

Handle the reorg chain in the None rollback branch.

When getRollbackProgressDiff has no row for the reorg chain, Line 1125 returns cf unchanged. That leaves stale reorgDetection data in memory and keeps fetchState at the pre-rollback position, so the next fetch can continue from 102 instead of 101 and immediately re-detect the same reorg. The new regression test at scenarios/test_codegen/test/rollback/Rollback_test.res Lines 3033-3177 is exercising exactly this path.

💡 Proposed fix
       let chainFetchers = state.chainManager.chainFetchers->ChainMap.mapWithKey((chain, cf) => {
         switch newProgressBlockNumberPerChain->Utils.Dict.dangerouslyGetByIntNonOption(
           chain->ChainMap.Chain.toChainId,
         ) {
         | Some(newProgressBlockNumber) =>
           let fetchState =
             cf.fetchState->FetchState.rollback(~targetBlockNumber=newProgressBlockNumber)
           let newTotalEventsProcessed =
             cf.numEventsProcessed -
             eventsProcessedDiffByChain
             ->Utils.Dict.dangerouslyGetByIntNonOption(chain->ChainMap.Chain.toChainId)
             ->Option.getUnsafe
@@
           {
             ...cf,
             reorgDetection: chain == reorgChain
               ? cf.reorgDetection->ReorgDetection.rollbackToValidBlockNumber(
                   ~blockNumber=rollbackTargetBlockNumber,
                 )
               : cf.reorgDetection,
             safeCheckpointTracking: switch cf.safeCheckpointTracking {
             | Some(safeCheckpointTracking) =>
               Some(
                 safeCheckpointTracking->SafeCheckpointTracking.rollback(
                   ~targetBlockNumber=newProgressBlockNumber,
                 ),
               )
             | None => None
             },
             fetchState,
             committedProgressBlockNumber: newProgressBlockNumber,
             numEventsProcessed: newTotalEventsProcessed,
           }
 
-        | None => cf
+        | None =>
+          if chain == reorgChain {
+            {
+              ...cf,
+              reorgDetection: cf.reorgDetection->ReorgDetection.rollbackToValidBlockNumber(
+                ~blockNumber=rollbackTargetBlockNumber,
+              ),
+              fetchState: cf.fetchState->FetchState.rollback(
+                ~targetBlockNumber=rollbackTargetBlockNumber,
+              ),
+            }
+          } else {
+            cf
+          }
         }
       })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/envio/src/GlobalState.res` around lines 1078 - 1125, The None branch
currently returns cf unchanged which leaves stale reorgDetection and fetchState;
update that branch so when chain == reorgChain you roll back reorgDetection and
fetchState (and safeCheckpointTracking if present) to rollbackTargetBlockNumber:
set reorgDetection =
cf.reorgDetection->ReorgDetection.rollbackToValidBlockNumber(~blockNumber=rollbackTargetBlockNumber),
set fetchState =
cf.fetchState->FetchState.rollback(~targetBlockNumber=rollbackTargetBlockNumber),
and if cf.safeCheckpointTracking is Some(...) call
SafeCheckpointTracking.rollback(~targetBlockNumber=rollbackTargetBlockNumber);
leave other fields as-is unless you need to adjust
committedProgressBlockNumber/numEventsProcessed elsewhere.
🧹 Nitpick comments (1)
scenarios/test_codegen/test/rollback/Rollback_test.res (1)

3138-3141: Drop the temporary debug logging before merge.

The assertion below already captures the payload state, so Lines 3140-3141 look like leftover debugging.

🧹 Proposed cleanup
-      // DEBUG: check actual value
       let actualPayloads = sourceMock1.getItemsOrThrowCalls->Js.Array2.map(c => c.payload)
-      Js.log2("DEBUG actualPayloads:", actualPayloads)
-      Js.log2("DEBUG last:", actualPayloads->Utils.Array.last)
       t.expect(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scenarios/test_codegen/test/rollback/Rollback_test.res` around lines 3138 -
3141, Remove the temporary debug logging lines: the Js.log2 calls that print
"DEBUG actualPayloads:" and "DEBUG last:" which use actualPayloads derived from
sourceMock1.getItemsOrThrowCalls->Js.Array2.map and Utils.Array.last; the
assertion already verifies payload state so delete those two debug log
statements to clean up Rollback_test.res.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@packages/envio/src/GlobalState.res`:
- Around line 1078-1125: The None branch currently returns cf unchanged which
leaves stale reorgDetection and fetchState; update that branch so when chain ==
reorgChain you roll back reorgDetection and fetchState (and
safeCheckpointTracking if present) to rollbackTargetBlockNumber: set
reorgDetection =
cf.reorgDetection->ReorgDetection.rollbackToValidBlockNumber(~blockNumber=rollbackTargetBlockNumber),
set fetchState =
cf.fetchState->FetchState.rollback(~targetBlockNumber=rollbackTargetBlockNumber),
and if cf.safeCheckpointTracking is Some(...) call
SafeCheckpointTracking.rollback(~targetBlockNumber=rollbackTargetBlockNumber);
leave other fields as-is unless you need to adjust
committedProgressBlockNumber/numEventsProcessed elsewhere.

---

Nitpick comments:
In `@scenarios/test_codegen/test/rollback/Rollback_test.res`:
- Around line 3138-3141: Remove the temporary debug logging lines: the Js.log2
calls that print "DEBUG actualPayloads:" and "DEBUG last:" which use
actualPayloads derived from sourceMock1.getItemsOrThrowCalls->Js.Array2.map and
Utils.Array.last; the assertion already verifies payload state so delete those
two debug log statements to clean up Rollback_test.res.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cf3950d5-0827-4150-9f75-7b12b2ac0421

📥 Commits

Reviewing files that changed from the base of the PR and between 1843678 and 3214890.

📒 Files selected for processing (3)
  • packages/envio/src/GlobalState.res
  • scenarios/test_codegen/test/helpers/Mock.res
  • scenarios/test_codegen/test/rollback/Rollback_test.res

@DZakh
DZakh force-pushed the claude/fix-indexer-reorg-loop-7hffU branch from 3214890 to e895670 Compare March 9, 2026 14:59
When a reorg is detected on a chain that had no events in the current
batch (e.g. another chain filled batchSize=1 first), the reorg chain
has no progress diff entry. Without a fix, the fetchState and
reorgDetection are not rolled back, causing stale block hashes to
persist and the same reorg to be re-detected infinitely.

This test currently fails, demonstrating the bug.

https://claude.ai/code/session_01K8NhAjzDHp9H3sLJ7gHjQs
@DZakh
DZakh force-pushed the claude/fix-indexer-reorg-loop-7hffU branch from e895670 to f61922f Compare March 9, 2026 19:54
claude and others added 2 commits March 10, 2026 09:23
When a reorg is detected on a chain that had no events in the current
batch, getRollbackProgressDiff returns no entry for that chain. Roll back
reorgDetection and fetchState for the reorg chain in the None branch to
clear stale block hashes from dataByBlockNumber and prevent re-detecting
the same reorg infinitely.

https://claude.ai/code/session_01K8NhAjzDHp9H3sLJ7gHjQs
@DZakh
DZakh enabled auto-merge (squash) March 10, 2026 09:49
@DZakh
DZakh merged commit 3a42931 into main Mar 10, 2026
12 of 13 checks passed
@DZakh
DZakh deleted the claude/fix-indexer-reorg-loop-7hffU branch March 10, 2026 11:15
DZakh pushed a commit that referenced this pull request Mar 10, 2026
…essed (#1027)

Cherry-pick from main. Fixes infinite reorg->rollback loop when a
blockchain reorganization is detected on a chain with no events
processed since the target checkpoint. Now properly rolls back
reorgDetection and fetchState even when no progress diff exists.

https://claude.ai/code/session_017jqSDS8homENyhbexMaoG4
DZakh pushed a commit that referenced this pull request Mar 10, 2026
The cherry-picked tests from PRs #1026/#1027 used `sourceConfig:
Config.CustomSources(...)` which is the v3 API. This branch uses
`sources: [...]` in Mock.Indexer.chainConfig.

https://claude.ai/code/session_017jqSDS8homENyhbexMaoG4
DZakh pushed a commit that referenced this pull request Mar 10, 2026
The tests from PRs #1026/#1027 use Vitest APIs (t.expect().toEqual()),
partition-aware Mock APIs (~resolveAt, call.resolve/payload), and
metrics with "p" field that don't exist on this branch (pre-Vitest
migration). The production code fixes are retained.

https://claude.ai/code/session_017jqSDS8homENyhbexMaoG4
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