-
Notifications
You must be signed in to change notification settings - Fork 94
feat(indexer): backfill role for historical catch-up #3582
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
baktun14
merged 14 commits into
feat/indexer-scaffold-chain-indexer-app
from
feat/indexer-backfill-role-historical-catchup
Aug 12, 2026
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
60a7bab
feat(indexer): add backfill range and tuning env config
baktun14 50378c5
feat(indexer): support ordered batched commits with a parameterized c…
baktun14 ff12f21
feat(indexer): add backfill range planner
baktun14 00b466b
feat(indexer): implement the backfill role for historical catch-up
baktun14 9b722c9
fix(indexer): address backfill review findings
baktun14 25a74c9
refactor(indexer): share retry backoff and runner-role lifecycle acro…
baktun14 760fd5d
refactor(indexer): move continuity error and transient retry policy t…
baktun14 c50ddd6
refactor(indexer): retry the continuity seed read like other transien…
baktun14 628c0bb
refactor(indexer): drop advisory-lock leader election for concurrency…
baktun14 a82f39a
refactor(indexer): use the exported LoggerService type instead of a l…
baktun14 5458736
fix(indexer): retry an interrupted backfill instead of completing the…
baktun14 6b04f63
fix(indexer): surface env validation errors without the DI wrapper
baktun14 a4ae247
refactor(indexer): parallelize backfill startup reads and signal comp…
baktun14 d6a4dd1
fix(indexer): track backfill completion by committed height, not the …
baktun14 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
38 changes: 38 additions & 0 deletions
38
apps/chain-indexer/src/lib/retry-with-backoff/retry-with-backoff.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import { describe, expect, it, vi } from "vitest"; | ||
|
|
||
| import { retryWithBackoff } from "@src/lib/retry-with-backoff/retry-with-backoff"; | ||
|
|
||
| describe(retryWithBackoff.name, () => { | ||
| it("returns the result after a transient failure", async () => { | ||
| const operation = vi.fn().mockRejectedValueOnce(new Error("transient")).mockResolvedValue("ok"); | ||
| const onRetry = vi.fn(); | ||
|
|
||
| const result = await retryWithBackoff(operation, { maxAttempts: 3, baseDelayMs: 1, onRetry }); | ||
|
|
||
| expect(result).toBe("ok"); | ||
| expect(onRetry).toHaveBeenCalledWith(expect.any(Error), 1, 1); | ||
| }); | ||
|
|
||
| it("rethrows once the attempts are exhausted", async () => { | ||
| const operation = vi.fn().mockRejectedValue(new Error("persistent")); | ||
|
|
||
| await expect(retryWithBackoff(operation, { maxAttempts: 3, baseDelayMs: 1, onRetry: vi.fn() })).rejects.toThrow("persistent"); | ||
| expect(operation).toHaveBeenCalledTimes(3); | ||
| }); | ||
|
|
||
| it("rethrows immediately when shouldRethrow matches", async () => { | ||
| const operation = vi.fn().mockRejectedValue(new Error("fatal")); | ||
|
|
||
| await expect(retryWithBackoff(operation, { maxAttempts: 3, baseDelayMs: 1, shouldRethrow: () => true, onRetry: vi.fn() })).rejects.toThrow("fatal"); | ||
| expect(operation).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it("caps the backoff delay at maxDelayMs", async () => { | ||
| const operation = vi.fn().mockRejectedValueOnce(new Error("a")).mockRejectedValueOnce(new Error("b")).mockResolvedValue("ok"); | ||
| const onRetry = vi.fn(); | ||
|
|
||
| await retryWithBackoff(operation, { maxAttempts: 5, baseDelayMs: 2, maxDelayMs: 3, onRetry }); | ||
|
|
||
| expect(onRetry.mock.calls.map(call => call[2])).toEqual([2, 3]); | ||
| }); | ||
| }); |
35 changes: 35 additions & 0 deletions
35
apps/chain-indexer/src/lib/retry-with-backoff/retry-with-backoff.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| export interface RetryWithBackoffOptions { | ||
| maxAttempts: number; | ||
| baseDelayMs: number; | ||
| maxDelayMs?: number; | ||
| /** Errors for which a retry cannot help (fatal conditions, shutdown in progress); they propagate immediately. */ | ||
| shouldRethrow?: (error: unknown) => boolean; | ||
| onRetry: (error: unknown, attempt: number, delayMs: number) => void; | ||
| } | ||
|
|
||
| export async function retryWithBackoff<T>(operation: () => Promise<T>, options: RetryWithBackoffOptions): Promise<T> { | ||
| let attempt = 0; | ||
|
|
||
| while (true) { | ||
| attempt++; | ||
| try { | ||
| return await operation(); | ||
| } catch (error) { | ||
| if (options.shouldRethrow?.(error) || attempt >= options.maxAttempts) { | ||
| throw error; | ||
| } | ||
|
|
||
| const uncappedDelayMs = options.baseDelayMs * 2 ** (attempt - 1); | ||
| const delayMs = options.maxDelayMs === undefined ? uncappedDelayMs : Math.min(uncappedDelayMs, options.maxDelayMs); | ||
| options.onRetry(error, attempt, delayMs); | ||
| await delay(delayMs); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** Global setTimeout rather than node:timers/promises so tests can advance the backoff with vitest fake timers, which do not intercept node:timers/promises. */ | ||
| function delay(ms: number): Promise<void> { | ||
| return new Promise(resolve => { | ||
| setTimeout(resolve, ms); | ||
| }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
|
|
||
| import { planBackfill } from "@src/pipeline/backfill-planner"; | ||
|
|
||
| describe(planBackfill.name, () => { | ||
| it("runs the full range when there is no checkpoint", () => { | ||
| const plan = planBackfill({ fromHeight: 100, toHeight: 200, checkpointHeight: null, tipHeight: 1_000 }); | ||
|
|
||
| expect(plan).toEqual({ kind: "run", startHeight: 100, endHeight: 200 }); | ||
| }); | ||
|
|
||
| it("resumes after the checkpoint when one exists mid-range", () => { | ||
| const plan = planBackfill({ fromHeight: 100, toHeight: 200, checkpointHeight: 150, tipHeight: 1_000 }); | ||
|
|
||
| expect(plan).toEqual({ kind: "run", startHeight: 151, endHeight: 200 }); | ||
| }); | ||
|
|
||
| it("reports already-complete when the checkpoint reached the end of the range", () => { | ||
| const plan = planBackfill({ fromHeight: 100, toHeight: 200, checkpointHeight: 200, tipHeight: 1_000 }); | ||
|
|
||
| expect(plan).toEqual({ kind: "already-complete" }); | ||
| }); | ||
|
|
||
| it("reports already-complete even when a lagging node returns a stale tip below the range end", () => { | ||
| const plan = planBackfill({ fromHeight: 100, toHeight: 200, checkpointHeight: 200, tipHeight: 150 }); | ||
|
|
||
| expect(plan).toEqual({ kind: "already-complete" }); | ||
| }); | ||
|
|
||
| it("rejects a range ending above the chain tip", () => { | ||
| const plan = planBackfill({ fromHeight: 100, toHeight: 2_000, checkpointHeight: null, tipHeight: 1_000 }); | ||
|
|
||
| expect(plan).toEqual({ kind: "invalid", reason: "BACKFILL_TO_HEIGHT 2000 is above the chain tip 1000" }); | ||
| }); | ||
|
|
||
| it("runs a single-block range", () => { | ||
| const plan = planBackfill({ fromHeight: 100, toHeight: 100, checkpointHeight: null, tipHeight: 1_000 }); | ||
|
|
||
| expect(plan).toEqual({ kind: "run", startHeight: 100, endHeight: 100 }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| export interface BackfillPlanInput { | ||
| fromHeight: number; | ||
| toHeight: number; | ||
| checkpointHeight: number | null; | ||
| tipHeight: number; | ||
| } | ||
|
|
||
| export type BackfillPlan = { kind: "run"; startHeight: number; endHeight: number } | { kind: "already-complete" } | { kind: "invalid"; reason: string }; | ||
|
|
||
| /** | ||
| * A range above the chain tip is rejected rather than clamped: clamping would mark the range's | ||
| * checkpoint complete for heights that were never indexed. Completion is checked first so a | ||
| * re-run of a finished range stays a no-op even when a lagging RPC node reports a stale tip. | ||
| */ | ||
| export function planBackfill(input: BackfillPlanInput): BackfillPlan { | ||
| if (input.checkpointHeight !== null && input.checkpointHeight >= input.toHeight) { | ||
| return { kind: "already-complete" }; | ||
| } | ||
|
|
||
| if (input.toHeight > input.tipHeight) { | ||
| return { kind: "invalid", reason: `BACKFILL_TO_HEIGHT ${input.toHeight} is above the chain tip ${input.tipHeight}` }; | ||
| } | ||
|
|
||
| return { | ||
| kind: "run", | ||
| startHeight: input.checkpointHeight !== null ? input.checkpointHeight + 1 : input.fromHeight, | ||
| endHeight: input.toHeight | ||
| }; | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.