Skip to content

fix(indexer): prevent overlapping poll executions#291

Open
drojas1316 wants to merge 1 commit into
tributary-protocol:mainfrom
drojas1316:fix/indexer-poll-reentrancy
Open

fix(indexer): prevent overlapping poll executions#291
drojas1316 wants to merge 1 commit into
tributary-protocol:mainfrom
drojas1316:fix/indexer-poll-reentrancy

Conversation

@drojas1316

Copy link
Copy Markdown
Contributor

Closes #256

Prevent overlapping indexer poll executions

What this changes

This PR replaces the indexer's fixed setInterval polling schedule with a self-scheduling polling loop.

The new polling flow:

  • Runs the first poll immediately.
  • Waits for the current poll to fully settle before scheduling another one.
  • Schedules the next poll with setTimeout only after the previous poll succeeds or fails.
  • Preserves a full POLL_MS delay between polling executions.
  • Logs polling errors without stopping future polling.
  • Clears the pending timeout during graceful shutdown.
  • Prevents the indexer from starting automatically when index.mjs is imported by tests.
  • Adds regression coverage for slow and failed polling executions.

Why

The previous implementation used setInterval, which could start another asynchronous poll() call before the previous one had finished.

This was especially likely during:

  • The initial 100,000-ledger backfill.
  • Slow RPC responses.
  • Large ledger ranges.
  • Temporary network or rate-limit delays.

Concurrent polls could load the same cursor and process the same ledger range simultaneously, causing:

  • Duplicate events in events.ndjson.
  • Duplicate rows for downstream consumers such as export-csv.mjs.
  • Competing cursor writes.
  • The persisted cursor moving backwards when a slower poll finished last.
  • Already indexed ledger ranges being scanned again.

The new self-scheduling loop guarantees that only one poll can run at a time inside the process.

Checklist

  • cargo fmt --all and cargo clippy --all-targets -- -D warnings pass
  • cargo test passes
  • New behavior has tests
  • Linked issue, if there is one

The Cargo checks could not be run locally because Rust/Cargo is not installed or is not available in the current environment.
This PR only modifies the JavaScript indexer. The relevant indexer test suite was run successfully with npm test: 6 tests passed and 0 failed.

Implementation details

Self-scheduling polling loop

A new exported runPollingLoop() function is responsible for polling and scheduling.

Each iteration:

  1. Executes and awaits the current poll.
  2. Catches and logs any polling error.
  3. Checks whether shutdown was requested.
  4. Schedules the next execution with setTimeout.
  5. Waits a full POLL_MS before starting the next poll.

Because the timeout is created only after poll() settles, two polling executions cannot overlap.

Graceful shutdown

The previous interval identifier was replaced with a timeout identifier.

During shutdown, the indexer now calls:

clearTimeout(pollingTimerId);

This prevents a scheduled future poll from starting after shutdown has been requested.

Safe module imports

The indexer now checks whether index.mjs is being executed directly before starting the polling loop.

This allows the module to export runPollingLoop() for testing without automatically starting the real indexer when the file is imported.

Error recovery

Errors thrown by a poll are logged using the existing error-message behavior:

error.message ?? error

A failed poll does not terminate the scheduler. The next poll is still scheduled after the configured delay.

Tests

A regression test was added to verify the polling scheduler.

The test confirms that:

  • The first poll starts immediately.
  • A second poll is not scheduled while the first poll remains pending.
  • The next execution is scheduled only after the first poll settles.
  • The configured polling delay is preserved.
  • A rejected poll is logged correctly.
  • A rejected poll does not stop future polling.
  • A third poll can run after the failed second poll.
  • The active poll counter returns to zero after both successful and failed polls.
  • The maximum number of simultaneous polls remains exactly one.
  • Future polling executions continue to be scheduled.

The test uses an injected scheduler and controlled promises, so it does not depend on arbitrary real-time delays.

Validation

The following command was run from the indexer directory:

npm test

Result:

6 tests passed
0 tests failed

The diff was also checked with:

git diff --check

Result:

No whitespace errors found.

The polling path was reviewed to confirm that it no longer uses setInterval.

Files changed

indexer/index.mjs

  • Replaced interval-based polling with runPollingLoop().
  • Added timeout-based self-scheduling.
  • Updated graceful-shutdown timer cleanup.
  • Added direct-execution detection for test-safe imports.
  • Exported the polling scheduler for deterministic testing.

indexer/index.test.mjs

  • Added regression coverage for long-running polls.
  • Added coverage for failed polls.
  • Verified that polling executions never overlap.
  • Verified that polling continues after an error.

Acceptance criteria

  • Two polls can never run concurrently, regardless of how long a poll takes.
  • A slow poll delays the next poll instead of overlapping it.
  • A complete POLL_MS delay occurs between polling executions.
  • Polling continues after a handled error.
  • A regression test covers a poll that remains active longer than the scheduling interval.

Scope

This change addresses polling races within a single running indexer process.

It is separate from the restart, reorganization, and idempotency work discussed in:

@drojas1316
drojas1316 requested a review from Spagero763 as a code owner July 18, 2026 04:03
@Spagero763

Copy link
Copy Markdown
Contributor

This PR's contract check is failing on markdown-link-check / cspell, but not because of anything in your diff — your branch was forked before a docs fix landed on main (the flagged word/link doesn't exist in your changes, and main passes this check cleanly). Since "Update branch" is enabled on this repo, click it on this PR and CI should go green without any code changes on your end.

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.

Guard the indexer poll loop against overlapping runs

2 participants