Skip to content

feat(indexer): replace polling with Stellar RPC event fetching - #666

Merged
ritik4ever merged 2 commits into
ritik4ever:mainfrom
MJ-RWA:feat/stellar-rpc-event-subscription-indexer
Jul 31, 2026
Merged

feat(indexer): replace polling with Stellar RPC event fetching#666
ritik4ever merged 2 commits into
ritik4ever:mainfrom
MJ-RWA:feat/stellar-rpc-event-subscription-indexer

Conversation

@MJ-RWA

@MJ-RWA MJ-RWA commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Replaces the indexer's fixed-interval event polling with Stellar RPC getEvents and cursor-based pagination to reduce event processing latency.

Changes

  • Added Stellar RPC event fetching with cursor-based pagination.
  • Persisted the last processed ledger sequence in SQLite.
  • Added checkpoint recovery to resume indexing after restarts.
  • Added configurable polling fallback via environment variables.
  • Added error handling and retry behavior for RPC failures.
  • Updated environment configuration/documentation.
  • Added/updated tests covering event fetching, pagination, checkpoint persistence, restart recovery, and fallback polling.

Acceptance Criteria

  • Event latency reduced to under 5 seconds after ledger close under normal conditions.
  • Last processed ledger sequence persists across restarts.
  • Indexer resumes from the persisted checkpoint.
  • Cursor-based pagination is supported.
  • Fallback polling can be enabled/configured through environment variables.
  • Existing event processing behavior is preserved.
  • Tests and type checks pass.

Closes: #599

Summary by CodeRabbit

  • New Features

    • Indexing now resumes from its last successfully processed ledger after restarts.
    • Added optional fallback polling with configurable enablement and polling interval.
    • Initial indexing runs immediately when the service starts.
    • Added safeguards against overlapping indexing runs and duplicate processing.
    • Improved pagination, temporary error recovery, and circuit-breaker handling.
  • Bug Fixes

    • Prevented checkpoints from advancing when event retrieval fails.
    • Improved duplicate prevention and handling of empty or malformed events.

@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown

@MJ-RWA is attempting to deploy a commit to the ritik4ever's projects Team on Vercel.

A member of the Team first needs to authorize it.

@drips-wave

drips-wave Bot commented Jul 27, 2026

Copy link
Copy Markdown

@MJ-RWA Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c234144e-0ec6-498f-b67b-57d8aeb7051c

📥 Commits

Reviewing files that changed from the base of the PR and between dad123a and eb887cd.

📒 Files selected for processing (3)
  • backend/.env.example
  • backend/src/config/validateEnv.ts
  • backend/src/services/indexer.ts

📝 Walkthrough

Walkthrough

The indexer now supports persisted ledger checkpoints, cursor-based event pagination, configurable fallback polling, startup checkpoint recovery, concurrency and circuit-breaker guards, and expanded tests for pagination, failures, duplicates, and fallback behavior.

Changes

Indexer reliability

Layer / File(s) Summary
Fallback polling configuration
backend/.env.example, backend/src/config/validateEnv.ts
Adds fallback polling enablement and interval settings to environment configuration, validation schema, and validated config type. Replaces the prior webhook dead-letter prune interval configuration.
Indexer cursor persistence
backend/src/migrations/0003_add_indexer_cursor.sql, backend/src/migrations/0003_add_indexer_cursor.down.sql, backend/src/migrations/0003_add_indexer_cursor.ts, backend/src/services/indexer.ts
Creates a singleton indexer_cursor table with single-row enforcement via id = 1 constraint. Supports migration execution and rollback. Loads or saves the last processed ledger sequence on init and during indexing runs.
Indexing execution and event processing
backend/src/services/indexer.ts
Adds immediate startup indexing with environment-selected polling interval. Prevents overlapping runs and skips when circuit breaker is open. Routes to fallback event retrieval or cursor pagination. Processes events transactionally. Persists checkpoints and updates indexing metrics on success or failure.
Indexer behavior coverage
backend/src/services/indexer.test.ts
Expands coverage for event ledger field mapping, poll cycle execution windows, pagination cursor stopping conditions, checkpoint persistence to database, fallback polling enabled and disabled modes, RPC failures without checkpoint advance, malformed event handling, and duplicate prevention via database insert uniqueness.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
  participant App
  participant startIndexer
  participant indexEvents
  participant StellarRPC
  participant indexer_cursor
  participant recordEventWithDb

  App->>startIndexer: initialize indexer
  startIndexer->>indexer_cursor: load last processed ledger
  startIndexer->>indexEvents: schedule polling with interval
  startIndexer->>indexEvents: run initial indexing attempt
  
  loop polling cycle
    indexEvents->>StellarRPC: fetch events from ledger or cursor
    StellarRPC-->>indexEvents: return events and pagination cursor
    indexEvents->>recordEventWithDb: process events transactionally
    recordEventWithDb-->>indexEvents: confirmed recorded
    indexEvents->>indexer_cursor: persist checkpoint
  end
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: replacing indexer polling with Stellar RPC event fetching.
Linked Issues check ✅ Passed The changes match #599 with getEvents pagination, persisted checkpoints, restart recovery, and fallback polling env vars.
Out of Scope Changes check ✅ Passed The changes are scoped to the indexer, config, migrations, tests, and env docs that support the stated objectives.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Fix failing CI checks
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🧹 Nitpick comments (3)
backend/src/services/indexer.test.ts (2)

111-117: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Wall-clock driven polling makes every test timing-dependent.

runOnePoll sleeps a fixed 150 ms with a 50 ms interval, so 3–4 poll cycles execute per "one poll". Exact-count assertions (Lines 265, 278) only pass because lastProcessedLedger happens to short-circuit later cycles. Consider driving the indexer with vi.useFakeTimers() + await vi.advanceTimersByTimeAsync(...), or exporting a single indexEvents() invocation for tests.

🤖 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 `@backend/src/services/indexer.test.ts` around lines 111 - 117, Make runOnePoll
deterministic by replacing its wall-clock setTimeout wait with Vitest fake
timers and an explicit advanceTimersByTimeAsync call, or invoke an exported
single-cycle indexEvents operation. Ensure each test run performs exactly one
intended polling cycle and keeps the indexer cleanup via stopIndexer.

230-240: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

This test does not exercise a restart.

The body is identical to "saves checkpoint to database after processing events" (Lines 207-217) — one poll, one assertion. To cover restart recovery, call stopIndexer(), re-run initIndexer against the same db, and assert the checkpoint is reloaded rather than reset to 0.

🤖 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 `@backend/src/services/indexer.test.ts` around lines 230 - 240, Update the
“does not reset checkpoint on restart when events are already processed” test to
exercise an actual restart: after the first run and checkpoint assertion, call
stopIndexer(), reinitialize the indexer with the same db, run polling again, and
assert the persisted checkpoint is reloaded rather than reset to zero. Keep the
existing event setup and checkpoint expectations intact.
backend/src/services/indexer.ts (1)

184-191: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

indexer_cursor table DDL is duplicated across three files with no single source of truth. The same CREATE TABLE IF NOT EXISTS indexer_cursor (...) statement is independently maintained in the migration SQL, the migration TypeScript wrapper, and the indexer's own startup helper; any future schema change must be applied in all three or they silently drift.

  • backend/src/services/indexer.ts#L184-L191: replace the inline db.exec(...) DDL in ensureIndexerCursorTable with a call to the migration's exported up(db) function.
  • backend/src/migrations/0003_add_indexer_cursor.ts#L3-L10: keep as the single source of truth for the up() DDL that both the migration runner and indexer.ts invoke.
  • backend/src/migrations/0003_add_indexer_cursor.sql#L9-L12: keep as the canonical raw-SQL reference (or generate it from the .ts migration) so all three don't need independent edits.
🤖 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 `@backend/src/services/indexer.ts` around lines 184 - 191, The indexer cursor
table DDL is duplicated across the startup helper and migration files. In
backend/src/services/indexer.ts lines 184-191, replace the inline DDL in
ensureIndexerCursorTable with a call to the exported up(db) from
backend/src/migrations/0003_add_indexer_cursor.ts; keep that up() implementation
as the migration source of truth, and retain
backend/src/migrations/0003_add_indexer_cursor.sql lines 9-12 as the canonical
raw-SQL reference or generate it from the TypeScript migration without requiring
separate schema edits.
🤖 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 `@backend/src/config/validateEnv.ts`:
- Around line 58-65: Align the fallback polling interval floor across all three
sites, using one shared constant where possible: update
backend/src/config/validateEnv.ts lines 58-65 and its validation message,
backend/.env.example lines 39-41, and backend/src/services/indexer.ts lines
105-107 so they consistently enforce and document the intended minimum, with the
runtime Math.max using the same floor as the schema.

In `@backend/src/services/indexer.test.ts`:
- Around line 219-228: The test at backend/src/services/indexer.test.ts lines
219-228 must assert the checkpoint behavior exercised by initIndexer, such as
polling with getLatestLedger at 501 and verifying getEvents receives startLedger
501 (or that the cursor remains unchanged when sequence <= 500), instead of
asserting the local event fixture. The test at
backend/src/services/indexer.test.ts lines 369-388 must verify actual
deduplication by querying stream_events for stream_id '1' and event_type
'claimed' and expecting a count of 1, replacing the unconditional
mock-call-length comparison.
- Around line 328-357: Isolate the RPC error-handling tests from later suites by
cleaning up fallback-mode configuration and module-level indexer state. Add an
afterEach that removes INDEXER_FALLBACK_POLLING_ENABLED, calls
vi.resetModules(), and re-imports the indexer entry points used by runOnePoll so
lastProcessedLedger, isIndexing, and the circuit breaker reset between tests.
Preserve the existing test assertions and setup behavior.

In `@backend/src/services/indexer.ts`:
- Around line 286-292: Update indexEventsWithFallback and
indexEventsWithCursorPagination so a completed scan with zero matching events
still advances lastProcessedLedger to currentLedger and persists the checkpoint.
Preserve existing event-processing behavior, but ensure the zero-event path
saves the confirmed currentLedger instead of returning or skipping the
checkpoint update.
- Around line 19-20: Replace the raw environment parsing in indexer.ts with the
validated indexerFallbackPollingEnabled and indexerFallbackPollIntervalMs values
from validateEnv(). Thread these settings through startIndexer() and
initIndexer(), and remove the local FALLBACK_POLLING_ENABLED and
FALLBACK_POLL_INTERVAL_MS constants while preserving the existing fallback
polling behavior.
- Around line 109-125: Update loadCheckpoint’s catch block to log the database
error and fail the current load attempt without assigning lastProcessedLedger =
0, preserving the existing checkpoint so the caller’s retry or circuit-breaker
flow can handle the failure. Keep the ledger-0 fallback only for a successful
query that finds no valid checkpoint.
- Around line 268-305: The single-call fallback path in indexEventsWithFallback
can skip events beyond the first getEvents page. Add cursor-based pagination
using the response cursor, process every returned page before checkpointing, and
set lastProcessedLedger to the highest ledger actually processed rather than
currentLedger; preserve the existing transaction, metrics, and checkpoint
behavior after all pages complete.

---

Nitpick comments:
In `@backend/src/services/indexer.test.ts`:
- Around line 111-117: Make runOnePoll deterministic by replacing its wall-clock
setTimeout wait with Vitest fake timers and an explicit advanceTimersByTimeAsync
call, or invoke an exported single-cycle indexEvents operation. Ensure each test
run performs exactly one intended polling cycle and keeps the indexer cleanup
via stopIndexer.
- Around line 230-240: Update the “does not reset checkpoint on restart when
events are already processed” test to exercise an actual restart: after the
first run and checkpoint assertion, call stopIndexer(), reinitialize the indexer
with the same db, run polling again, and assert the persisted checkpoint is
reloaded rather than reset to zero. Keep the existing event setup and checkpoint
expectations intact.

In `@backend/src/services/indexer.ts`:
- Around line 184-191: The indexer cursor table DDL is duplicated across the
startup helper and migration files. In backend/src/services/indexer.ts lines
184-191, replace the inline DDL in ensureIndexerCursorTable with a call to the
exported up(db) from backend/src/migrations/0003_add_indexer_cursor.ts; keep
that up() implementation as the migration source of truth, and retain
backend/src/migrations/0003_add_indexer_cursor.sql lines 9-12 as the canonical
raw-SQL reference or generate it from the TypeScript migration without requiring
separate schema edits.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0094d24a-d4e4-46df-8ed1-4282cedcb3ca

📥 Commits

Reviewing files that changed from the base of the PR and between b3d32c1 and dad123a.

⛔ Files ignored due to path filters (2)
  • backend/package-lock.json is excluded by !**/package-lock.json
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (7)
  • backend/.env.example
  • backend/src/config/validateEnv.ts
  • backend/src/migrations/0003_add_indexer_cursor.down.sql
  • backend/src/migrations/0003_add_indexer_cursor.sql
  • backend/src/migrations/0003_add_indexer_cursor.ts
  • backend/src/services/indexer.test.ts
  • backend/src/services/indexer.ts

Comment on lines +58 to +65
// Indexer fallback polling interval validation
const fallbackPollIntervalSchema = z
.string()
.transform((val: string) => parseInt(val, 10))
.refine((val: number) => !isNaN(val) && val >= 1000, {
message: "must be a valid number >= 1000 (minimum 1 second)",
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Fallback poll interval minimum disagrees across docs, schema, and runtime floor (5000ms vs 1000ms). backend/.env.example documents a 5000ms minimum, but backend/src/config/validateEnv.ts's Zod schema enforces >= 1000 and backend/src/services/indexer.ts's runtime floor is Math.max(1000, ...) — both consistent with each other at 1000ms, but not with the documented 5000ms.

  • backend/src/config/validateEnv.ts#L58-L65: either update the .refine(val >= 1000, ...) check (and message) to >= 5000 if 5000ms is the intended floor, or update the .env.example comment to say 1000ms if 1000ms is intended.
  • backend/.env.example#L39-L41: update the "minimum 5000" comment to match whichever floor is chosen in validateEnv.ts.
  • backend/src/services/indexer.ts#L105-L107: update Math.max(1000, FALLBACK_POLL_INTERVAL_MS) to use the same floor constant as validateEnv.ts (ideally sourced from one shared constant) so all three stay in sync.
📍 Affects 3 files
  • backend/src/config/validateEnv.ts#L58-L65 (this comment)
  • backend/.env.example#L39-L41
  • backend/src/services/indexer.ts#L105-L107
🤖 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 `@backend/src/config/validateEnv.ts` around lines 58 - 65, Align the fallback
polling interval floor across all three sites, using one shared constant where
possible: update backend/src/config/validateEnv.ts lines 58-65 and its
validation message, backend/.env.example lines 39-41, and
backend/src/services/indexer.ts lines 105-107 so they consistently enforce and
document the intended minimum, with the runtime Math.max using the same floor as
the schema.

Comment on lines +219 to +228
it("loads checkpoint from database on initIndexer", async () => {
const cid = nextContractId();
setupDb(cid, 500);
const event = makeClaimedEvent({ streamId: "1", ledger: 501 });
mockGetEvents.mockResolvedValue({ events: [event] });

initIndexer("https://rpc.example.com", cid, "Test SDF Network ; September 2015");

expect(event).toBeDefined();
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Two tests assert conditions that can never fail. Both cases exercise setup but then assert something tautological, so the named behavior is not actually covered.

  • backend/src/services/indexer.test.ts#L219-L228: expect(event).toBeDefined() only checks a local fixture. Assert the loaded checkpoint instead — e.g. after initIndexer, run a poll with getLatestLedger at 501 and verify getEvents was called with startLedger 501 (not 0), or that the cursor row is unchanged when sequence <= 500.
  • backend/src/services/indexer.test.ts#L369-L388: callsAfterSecond >= callsAfterFirst holds unconditionally because the mock call array only grows. Assert real deduplication instead — query SELECT COUNT(*) FROM stream_events WHERE stream_id = '1' AND event_type = 'claimed' and expect 1.
📍 Affects 1 file
  • backend/src/services/indexer.test.ts#L219-L228 (this comment)
  • backend/src/services/indexer.test.ts#L369-L388
🤖 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 `@backend/src/services/indexer.test.ts` around lines 219 - 228, The test at
backend/src/services/indexer.test.ts lines 219-228 must assert the checkpoint
behavior exercised by initIndexer, such as polling with getLatestLedger at 501
and verifying getEvents receives startLedger 501 (or that the cursor remains
unchanged when sequence <= 500), instead of asserting the local event fixture.
The test at backend/src/services/indexer.test.ts lines 369-388 must verify
actual deduplication by querying stream_events for stream_id '1' and event_type
'claimed' and expecting a count of 1, replacing the unconditional
mock-call-length comparison.

Comment on lines +328 to +357
describe("indexer RPC error handling", () => {
let ledgerSeq = 600;

beforeEach(() => {
vi.clearAllMocks();
ledgerSeq += 200;
mockGetLatestLedger.mockResolvedValue({ sequence: ledgerSeq });
});

it("does not crash when getEvents throws an error", async () => {
const cid = nextContractId();
setupDb(cid, ledgerSeq - 100);
mockGetEvents.mockRejectedValue(new Error("RPC timeout"));

await runOnePoll(cid);

expect(mockRecordEventWithDb).not.toHaveBeenCalled();
});

it("does not advance checkpoint when getEvents fails", async () => {
process.env.INDEXER_FALLBACK_POLLING_ENABLED = "true";
const cid = nextContractId();
setupDb(cid, ledgerSeq - 100);
mockGetEvents.mockRejectedValue(new Error("RPC error"));

await runOnePoll(cid);

const row = db.prepare("SELECT last_ledger_sequence FROM indexer_cursor WHERE id = 1").get() as { last_ledger_sequence: number };
expect(row.last_ledger_sequence).toBe(ledgerSeq - 100);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

INDEXER_FALLBACK_POLLING_ENABLED leaks into subsequent suites, and indexer module state is never reset.

Line 348 sets the env var with no cleanup — the "indexer duplicate prevention" describe (Lines 360+) then runs in fallback mode depending on file order. Separately, indexer.ts keeps lastProcessedLedger, isIndexing, and the circuit breaker in module scope; the two forced RPC failures here push the breaker toward OPEN, after which indexEvents returns early and later tests can pass vacuously. Add an afterEach (already imported at Line 5) to clear the env var, and vi.resetModules() + re-import to isolate indexer state.

🔧 Suggested cleanup
   beforeEach(() => {
     vi.clearAllMocks();
     ledgerSeq += 200;
     mockGetLatestLedger.mockResolvedValue({ sequence: ledgerSeq });
   });
+
+  afterEach(() => {
+    delete process.env.INDEXER_FALLBACK_POLLING_ENABLED;
+  });
🤖 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 `@backend/src/services/indexer.test.ts` around lines 328 - 357, Isolate the RPC
error-handling tests from later suites by cleaning up fallback-mode
configuration and module-level indexer state. Add an afterEach that removes
INDEXER_FALLBACK_POLLING_ENABLED, calls vi.resetModules(), and re-imports the
indexer entry points used by runOnePoll so lastProcessedLedger, isIndexing, and
the circuit breaker reset between tests. Preserve the existing test assertions
and setup behavior.

Comment on lines +19 to +20
const FALLBACK_POLLING_ENABLED = process.env.INDEXER_FALLBACK_POLLING_ENABLED === "true";
const FALLBACK_POLL_INTERVAL_MS = Number(process.env.INDEXER_FALLBACK_POLL_INTERVAL_MS ?? 10000);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP -A5 -B5 'startIndexer\s*\(' --type=ts | grep -v indexer.test.ts
rg -nP 'validateEnv\s*\(' --type=ts -g '!*.test.ts'

Repository: ritik4ever/stellar-stream

Length of output: 163


🏁 Script executed:

#!/bin/bash
set -u

printf '--- repo files matching indexer / validateEnv ---\n'
git ls-files | rg '(^|/)indexer\.ts$|(^|/)validateEnv\.ts$|backend/src/config/.*\.ts|backend/src/index\.ts' || true

printf '\n--- backend/src/services/indexer.ts outline ---\n'
ast-grep outline backend/src/services/indexer.ts 2>/dev/null || true

printf '\n--- search validateEnv usages/config structure ---\n'
rg -n "validateEnv|fallbackPollIntervalSchema|INDEXER_FALLBACK|fallbackPollingEnabled|fallbackPollIntervalMs" backend/src || true

printf '\n--- relevant validateEnv.ts content ---\n'
fd -a 'validateEnv\.ts$' backend/src | while read -r f; do
  echo "### $f"
  wc -l "$f"
  sed -n '1,220p' "$f"
done

Repository: ritik4ever/stellar-stream

Length of output: 16364


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- backend/src/index.ts tail around validateEnv usage ---'
wc -l backend/src/index.ts
sed -n '1800,1900p' backend/src/index.ts

printf '\n--- backend/src/services/indexer.ts relevant lines ---\n'
sed -n '1,60p' backend/src/services/indexer.ts
sed -n '90,126p' backend/src/services/indexer.ts
sed -n '140,220p' backend/src/services/indexer.ts

printf '\n--- behavioral probes for fallback interval edge cases ---\n'
node - <<'JS'
const inputs = ["", "abc", "-50", "0", "500", "1000", "5000", "NaN"];
for (const raw of inputs) {
  const v = Number(raw ?? "10000");
  const fallbackEnabled = raw === "true";
  const fallbackInterval = Number.isNaN(v) ? "NaN" : Math.max(1000, v);
  console.log(JSON.stringify({raw, fallbackEnabled, fallbackInterval, isFinite: Number.isFinite(Number(raw ?? "10000"))}));
}
JS
printf '%s\n' 'setInterval(NaN) does not throw synchronously; it coerces delay to 1 in Node.'

Repository: ritik4ever/stellar-stream

Length of output: 8667


Use validated fallback-indexer config instead of raw process.env.

indexer.ts re-parses INDEXER_FALLBACK_POLLING_ENABLED and INDEXER_FALLBACK_POLL_INTERVAL_MS with Number(...), bypassing validateEnv(). A malformed fallback interval produces NaN, and Math.max(1000, NaN) stays NaN; setInterval(..., NaN) is coerced by Node to 1ms, causing rapid polling. Since validateEnv() already exposes indexerFallbackPollingEnabled and indexerFallbackPollIntervalMs, thread that validated config through startIndexer()/initIndexer() instead of reading raw env in backend/src/services/indexer.ts.

🤖 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 `@backend/src/services/indexer.ts` around lines 19 - 20, Replace the raw
environment parsing in indexer.ts with the validated
indexerFallbackPollingEnabled and indexerFallbackPollIntervalMs values from
validateEnv(). Thread these settings through startIndexer() and initIndexer(),
and remove the local FALLBACK_POLLING_ENABLED and FALLBACK_POLL_INTERVAL_MS
constants while preserving the existing fallback polling behavior.

Comment on lines +109 to +125
function loadCheckpoint(db: any): void {
try {
const row = db
.prepare(`SELECT last_ledger_sequence FROM ${INDEXER_CURSOR_TABLE} WHERE id = @id`)
.get({ id: CHECKPOINT_ROW_ID }) as { last_ledger_sequence: number } | undefined;

if (row && row.last_ledger_sequence > 0) {
lastProcessedLedger = row.last_ledger_sequence;
logger.info({ lastProcessedLedger }, "loaded indexer checkpoint from database");
} else {
logger.info("no checkpoint found, starting from ledger 0");
}
} catch (err) {
logger.error({ err }, "failed to load indexer checkpoint, starting from ledger 0");
lastProcessedLedger = 0;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Checkpoint load failure silently resets progress to ledger 0.

On any DB error (lock contention, corruption, transient I/O), loadCheckpoint resets lastProcessedLedger = 0 and the indexer resumes "from ledger 0". Stellar RPC only retains a short window of event history (commonly ~7 days), so a checkpoint request starting near ledger 0/1 will either error out against the RPC (startLedger below the oldest retained ledger) or, if the range happens to still be retrievable, trigger reprocessing of a huge backlog of already-handled events — directly undermining the "preventing... duplicate processing" goal stated in the migration's own header comment.

Consider failing the poll cycle (leave lastProcessedLedger untouched, let the circuit breaker/retry handle it) rather than defaulting to 0 on transient read errors.

🛡️ Avoid resetting to 0 on transient errors
   } catch (err) {
-    logger.error({ err }, "failed to load indexer checkpoint, starting from ledger 0");
-    lastProcessedLedger = 0;
+    logger.error({ err }, "failed to load indexer checkpoint; leaving lastProcessedLedger unset and deferring to retry");
+    throw err;
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function loadCheckpoint(db: any): void {
try {
const row = db
.prepare(`SELECT last_ledger_sequence FROM ${INDEXER_CURSOR_TABLE} WHERE id = @id`)
.get({ id: CHECKPOINT_ROW_ID }) as { last_ledger_sequence: number } | undefined;
if (row && row.last_ledger_sequence > 0) {
lastProcessedLedger = row.last_ledger_sequence;
logger.info({ lastProcessedLedger }, "loaded indexer checkpoint from database");
} else {
logger.info("no checkpoint found, starting from ledger 0");
}
} catch (err) {
logger.error({ err }, "failed to load indexer checkpoint, starting from ledger 0");
lastProcessedLedger = 0;
}
}
function loadCheckpoint(db: any): void {
try {
const row = db
.prepare(`SELECT last_ledger_sequence FROM ${INDEXER_CURSOR_TABLE} WHERE id = `@id``)
.get({ id: CHECKPOINT_ROW_ID }) as { last_ledger_sequence: number } | undefined;
if (row && row.last_ledger_sequence > 0) {
lastProcessedLedger = row.last_ledger_sequence;
logger.info({ lastProcessedLedger }, "loaded indexer checkpoint from database");
} else {
logger.info("no checkpoint found, starting from ledger 0");
}
} catch (err) {
logger.error({ err }, "failed to load indexer checkpoint; leaving lastProcessedLedger unset and deferring to retry");
throw err;
}
}
🤖 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 `@backend/src/services/indexer.ts` around lines 109 - 125, Update
loadCheckpoint’s catch block to log the database error and fail the current load
attempt without assigning lastProcessedLedger = 0, preserving the existing
checkpoint so the caller’s retry or circuit-breaker flow can handle the failure.
Keep the ledger-0 fallback only for a successful query that finds no valid
checkpoint.

Comment on lines +268 to +305
async function indexEventsWithFallback(db: any, currentLedger: number): Promise<void> {
const startLedger = lastProcessedLedger + 1;
let events;

try {
events = await rpcServer.getEvents({
startLedger,
filters: [
{
type: "contract",
contractIds: [contractId],
contractIds: [contractId!],
},
],
});
} catch (err) {
logger.error({ err }, "RPC getEvents failed in fallback mode");
throw err;
}

const startLedgerForMetrics = lastProcessedLedger;
const eventCount = events.events?.length ?? 0;

if (eventCount === 0) {
return;
}

db.transaction(() => {
for (const event of events.events || []) {
processEvent(db, event);
eventsIndexedTotal.inc();
}

lastProcessedLedger = currentLedger;
})();

saveCheckpoint(db, lastProcessedLedger);
ledgersScannedTotal.inc(lastProcessedLedger - startLedgerForMetrics);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg 'backend/src/services/indexer.ts|backend/src/validation/schemas.ts|package.json' || true

echo "== indexer outline =="
ast-grep outline backend/src/services/indexer.ts --view expanded || true

echo "== relevant indexer lines =="
nl -ba backend/src/services/indexer.ts | sed -n '230,330p'

echo "== usages of indexEventsWithFallback/indexEventsWithCursorPagination/getEvents =="
rg -n "indexEventsWithFallback|indexEventsWithCursorPagination|getEvents|lastProcessedLedger|currentLedger|cursor" backend/src/services/indexer.ts backend/src -S || true

Repository: ritik4ever/stellar-stream

Length of output: 2468


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file size =="
wc -l backend/src/services/indexer.ts

echo "== fallback and cursor implementations =="
sed -n '222,380p' backend/src/services/indexer.ts

echo "== fetch/currency/client config for rpc =="
sed -n '1,110p' backend/src/services/indexer.ts

echo "== search for pagination and cursor =="
rg -n "cursor|getEvents|indexEventsWithFallback|indexEventsWithCursorPagination|lastProcessedLedger|currentLedger|checkpoint" backend/src/services/indexer.ts

Repository: ritik4ever/stellar-stream

Length of output: 9372


🌐 Web query:

Stellar RPC getEvents pagination default limit max events cursor documentation

💡 Result:

In the Stellar RPC getEvents method, pagination is handled through an optional pagination object passed within the request parameters [1][2]. The pagination object supports the following fields: - limit: An optional integer specifying the maximum number of records to return [1][2]. The allowed range is 1 to 10,000, and if no limit is provided, it defaults to 100 [1][2]. - cursor: An optional string acting as an opaque paging token [1][2]. It is used to fetch the next page of results; each response includes a cursor field that can be provided in a subsequent request to continue pagination [1][2]. When using a cursor, you must omit the startLedger and endLedger parameters from your request [3][4]. If a cursor is not used, you can specify a ledger range using startLedger and endLedger [3]. Note that the 10,000 record limit is hardcoded in Stellar-RPC for performance reasons [1][2].

Citations:


🌐 Web query:

Stellar RPC Soroban getEvents API object cursor startLedger cursor in response

💡 Result:

The Stellar RPC getEvents method uses a pagination system based on a cursor to navigate through event results [1][2][3]. Key details regarding the relationship between startLedger and the cursor in the request and response are as follows: Request Parameters When making a getEvents request, you can provide either a startLedger or a pagination.cursor, but they are mutually exclusive [1][2][4]: - startLedger: Defines the inclusive ledger sequence number to begin searching from [1][2][5]. - pagination.cursor: An opaque string acting as a paging token used to fetch subsequent pages of results [3]. If a cursor is included in the request, startLedger (and endLedger) must be omitted [1][2][4]. Response Object The getEvents response contains a cursor field [6][7][8]. This cursor is an opaque string that represents the current position in the event stream [3]. To retrieve the next page of events, the client should pass this cursor value back into the pagination.cursor field of a subsequent getEvents request [7][9][3]. Summary of Behavior - Initial Request: Set startLedger to the desired starting point and optionally include a pagination.limit [2][10]. - Subsequent Requests: Extract the cursor from the previous response and include it in the pagination object for the next request [7][3]. Do not include startLedger in these follow-up requests [1][2][4]. - Termination: Pagination is typically complete when no further cursor is returned or the stream reaches the end of the available data [7][10].

Citations:


🌐 Web query:

site:developers.stellar.org/get-events Soroban RPC GetEventsRequest cursor pagination.cursor

💡 Result:

For Soroban RPC getEvents, pagination.cursor is the opaque paging token used to fetch the next page of results. Set it to the cursor returned in the previous response. If you include cursor, you must omit startLedger and endLedger. The limit for getEvents is 1 to 10000 (default 100). (developers.stellar.org)

Citations:


Loop pages in fallback mode before advancing the checkpoint.

indexEventsWithFallback calls rpcServer.getEvents({ startLedger, filters }) once and then sets lastProcessedLedger = currentLedger, but getEvents defaults to 100 events and only returns a cursor for the next page. If more events exist in the catch-up range, those beyond page one are skipped and can never be recovered because the next poll starts after currentLedger; reuse or copy the cursor-pagination loop and advance the checkpoint to the highest ledger actually processed instead.

🤖 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 `@backend/src/services/indexer.ts` around lines 268 - 305, The single-call
fallback path in indexEventsWithFallback can skip events beyond the first
getEvents page. Add cursor-based pagination using the response cursor, process
every returned page before checkpointing, and set lastProcessedLedger to the
highest ledger actually processed rather than currentLedger; preserve the
existing transaction, metrics, and checkpoint behavior after all pages complete.

Comment on lines +286 to +292

const startLedgerForMetrics = lastProcessedLedger;
const eventCount = events.events?.length ?? 0;

if (eventCount === 0) {
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Checkpoint never advances past currentLedger when no events are found.

Both indexEventsWithFallback (returns early on eventCount === 0 without touching lastProcessedLedger) and indexEventsWithCursorPagination (only updates/saves when totalProcessed > 0) leave the checkpoint stuck at the old value even though currentLedger (fetched via getLatestLedger()) has already moved forward. During a long quiet period with no matching contract events, this means the indexer keeps re-requesting the same growing startLedger…currentLedger range on every poll, and if that idle period exceeds the RPC's retention window, the eventual poll will fail with startLedger below the oldest retained ledger — the indexer becomes permanently stuck and can never recover automatically.

Consider persisting lastProcessedLedger = currentLedger (with a checkpoint save) even when zero events are found, since currentLedger was already confirmed scanned.

Also applies to: 373-377

🤖 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 `@backend/src/services/indexer.ts` around lines 286 - 292, Update
indexEventsWithFallback and indexEventsWithCursorPagination so a completed scan
with zero matching events still advances lastProcessedLedger to currentLedger
and persists the checkpoint. Preserve existing event-processing behavior, but
ensure the zero-event path saves the confirmed currentLedger instead of
returning or skipping the checkpoint update.

@coderabbitai coderabbitai Bot mentioned this pull request Jul 27, 2026
7 tasks
@ritik4ever

Copy link
Copy Markdown
Owner

Hi @MJ-RWA,

This PR could not be merged because it has merge conflicts with the target branch.

Please resolve the merge conflicts, push the updated changes, and the PR can be reviewed and merged.

Thank you!

@ritik4ever
ritik4ever merged commit c484915 into ritik4ever:main Jul 31, 2026
1 check failed
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.

[FEATURE] Replace SQLite polling with Stellar RPC event subscription in indexer

2 participants