Skip to content
Closed
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3f5dc4f
Create `ensRainbowClient` singleton for ENSIndexer app
tk-o Mar 30, 2026
f9e4e11
Simplify `ensrainbow/signleton.ts` file
tk-o Mar 30, 2026
40bab87
Create `waitForEnsRainbowToBeReady` function
tk-o Mar 30, 2026
e84c0c7
Implement `eventHandlerPreconditios`
tk-o Mar 30, 2026
febed1c
docs(changeset): Introduced event handler preconditions to improve re…
tk-o Mar 30, 2026
911035a
Merge remote-tracking branch 'origin/main' into feat/onchain-event-ha…
tk-o Mar 30, 2026
22c3c22
Fix URL comparison for ENSRainbow singleton instnace
tk-o Mar 30, 2026
d0dd98c
Apply suggestions from code review
tk-o Mar 30, 2026
b9a0829
Apply PR feedback
tk-o Mar 30, 2026
ef53bf8
Update testing suite
tk-o Mar 30, 2026
d51b2ea
Update testing suite
tk-o Mar 31, 2026
e60b06f
Update ENSDb SDK to allow storing and reading `EnsRainbowPublicConfig…
tk-o Mar 31, 2026
f929618
docs(changeset): Extended the ENSNode Metadata with ENSRainbow Public…
tk-o Mar 31, 2026
63fbec5
Enable ENSDb Writer Worker to store "unstarted" Indexing Status object
tk-o Mar 31, 2026
fb1d93e
Require valid ENSRainbow connection before starting indexing
tk-o Mar 31, 2026
c3967b9
Update testing suite
tk-o Mar 31, 2026
f346b2a
Apply AI PR feedback
tk-o Apr 1, 2026
4e2454b
Improve docs for Ponder Indexing Engine
tk-o Apr 1, 2026
caef441
Merge remote-tracking branch 'origin/main' into feat/ensure-valid-ens…
tk-o Apr 2, 2026
3d7bfb3
Apply PR feedback
tk-o Apr 2, 2026
8b96b5d
Merge branch 'main' into feat/ensure-valid-ensrainbow-connection
shrugs Apr 3, 2026
4a80427
fix: tidy up ensdb reader fns
shrugs Apr 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sharp-moons-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ensindexer": minor
---

Introduced indexing event handler preconditions to optimize the cross-service availability in an ENSNode instance when ENSRainbow is performing a cold-start.
5 changes: 5 additions & 0 deletions .changeset/whole-lines-smoke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ensnode/ensdb-sdk": minor
---

Extended the ENSNode Metadata with ENSRainbow Public Config.
Original file line number Diff line number Diff line change
Expand Up @@ -261,21 +261,28 @@ describe("EnsDbWriterWorker", () => {
});

describe("interval behavior - snapshot upserts", () => {
it("continues upserting after snapshot validation errors", async () => {
it("upserts snapshots across different omnichain statuses", async () => {
// arrange
const unstartedSnapshot = createMockOmnichainSnapshot({
omnichainStatus: OmnichainIndexingStatusIds.Unstarted,
});
const validSnapshot = createMockOmnichainSnapshot({
omnichainIndexingCursor: 200,
});
const crossChainSnapshot = createMockCrossChainSnapshot({
const unstartedCrossChainSnapshot = createMockCrossChainSnapshot({
slowestChainIndexingCursor: 100,
snapshotTime: 200,
omnichainSnapshot: unstartedSnapshot,
});
const validCrossChainSnapshot = createMockCrossChainSnapshot({
slowestChainIndexingCursor: 200,
snapshotTime: 300,
omnichainSnapshot: validSnapshot,
});

vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(crossChainSnapshot);
vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain)
.mockReturnValueOnce(unstartedCrossChainSnapshot)
.mockReturnValueOnce(validCrossChainSnapshot);

const ensDbClient = createMockEnsDbWriter();
const indexingStatusBuilder = {
Expand All @@ -297,8 +304,15 @@ describe("EnsDbWriterWorker", () => {

// assert
expect(indexingStatusBuilder.getOmnichainIndexingStatusSnapshot).toHaveBeenCalledTimes(2);
expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenCalledTimes(1);
expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenCalledWith(crossChainSnapshot);
expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenCalledTimes(2);
expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenNthCalledWith(
1,
unstartedCrossChainSnapshot,
);
expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenNthCalledWith(
2,
validCrossChainSnapshot,
);

// cleanup
worker.stop();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ import {
type CrossChainIndexingStatusSnapshot,
type Duration,
type EnsIndexerPublicConfig,
OmnichainIndexingStatusIds,
type OmnichainIndexingStatusSnapshot,
validateEnsIndexerPublicConfigCompatibility,
} from "@ensnode/ensnode-sdk";
import type { LocalPonderClient } from "@ensnode/ponder-sdk";
Expand Down Expand Up @@ -231,7 +229,8 @@ export class EnsDbWriterWorker {
// get system timestamp for the current iteration
const snapshotTime = getUnixTime(new Date());

const omnichainSnapshot = await this.getValidatedIndexingStatusSnapshot();
const omnichainSnapshot =
await this.indexingStatusBuilder.getOmnichainIndexingStatusSnapshot();

const crossChainSnapshot = buildCrossChainIndexingStatusSnapshotOmnichain(
omnichainSnapshot,
Expand All @@ -248,24 +247,4 @@ export class EnsDbWriterWorker {
// should not cause the ENSDb Writer Worker to stop functioning.
}
}

/**
* Get validated Omnichain Indexing Status Snapshot
*
* @returns Validated Omnichain Indexing Status Snapshot.
* @throws Error if the Omnichain Indexing Status is not in expected status yet.
*/
private async getValidatedIndexingStatusSnapshot(): Promise<OmnichainIndexingStatusSnapshot> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm not confident it's right to completely remove this logic.

My worry is: how do we tell the difference between the following situations:

  1. Omnichain status is Unstarted because no indexing has started in ENSDb yet.
  2. Indexing has started in ENSDb with an earlier instance of ENSIndexer, but now ENSIndexer is being restarted and it's still working to discover it's true omnichain indexing status from the state in ENSDb. During this case I'm worried that ENSIndexer also thinks it's "Unstarted"?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Omnichain status is "unstarted" if an only if no indexing has started. This is related to the fact that the omnichain status can only be "unstarted" if and only if all chains are queued. And a chain is queued if and only if its config.startBlock is equal to its checkpointBlock (checkpointBlock is sourced from Ponder Indexing Status, which is sourced from _ponder_checkpoint table in the ENSIndexer Schema).

If indexing has started and ENSIndexer has written any indexed data into the ENSIndexer Schema, the checkpointBlock for some indexed chain has also been stored in the _ponder_checkpoint table in the ENSIndexer Schema in ENSDb. Therefore, if ENSIndexer instance restarts, some of the indexed chains will not be "queued" anyomore as checkpointBlock will be ahead of config.startBlock for that chain. That leads us to the fact that the omnichain status cannot be "unstarted" anymore, and goes straight to "backfill".

const omnichainSnapshot = await this.indexingStatusBuilder.getOmnichainIndexingStatusSnapshot();

// It only makes sense to write Indexing Status Snapshots into ENSDb once
// the indexing process has started, as before that there is no meaningful
// status to record.
// Invariant: the Omnichain Status must indicate that indexing has started already.
if (omnichainSnapshot.omnichainStatus === OmnichainIndexingStatusIds.Unstarted) {
throw new Error("Omnichain Status must not be 'Unstarted'.");
}

return omnichainSnapshot;
}
}
21 changes: 0 additions & 21 deletions apps/ensindexer/src/lib/ensraibow-api-client.ts

This file was deleted.

73 changes: 73 additions & 0 deletions apps/ensindexer/src/lib/ensrainbow/singleton.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import config from "@/config";

import pRetry from "p-retry";

import { EnsRainbowApiClient } from "@ensnode/ensrainbow-sdk";

const { ensRainbowUrl, labelSet } = config;

if (ensRainbowUrl.href === EnsRainbowApiClient.defaultOptions().endpointUrl.href) {
console.warn(
`Using default public ENSRainbow server which may cause increased network latency. For production, use your own ENSRainbow server that runs on the same network as the ENSIndexer server.`,
);
}

/**

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I note a few lines below that EnsRainbowApiClient uses a param named endpointUrl. Suggest to rename that to ensRainbowUrl if you think it's ok to do that in this PR.

* Singleton ENSRainbow Client instance for ENSIndexer app.
*/
export const ensRainbowClient = new EnsRainbowApiClient({
endpointUrl: ensRainbowUrl,
labelSet,
});

/**
* Cached promise for waiting for ENSRainbow to be ready.
*
* This ensures that multiple concurrent calls to
* {@link waitForEnsRainbowToBeReady} will share the same underlying promise
* in order to use the same retry sequence.
*/
let waitForEnsRainbowToBeReadyPromise: Promise<void> | undefined;

/**
* Wait for ENSRainbow to be ready
*
* Blocks execution until the ENSRainbow instance is ready to serve requests.
*
* Note: It may take 30+ minutes for the ENSRainbow instance to become ready in

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
* Note: It may take 30+ minutes for the ENSRainbow instance to become ready in
* Note: It may take 30+ minutes for the ENSRainbow instance to become healthy in

* a cold start scenario. We use retries for the ENSRainbow health check with
* an exponential backoff strategy to handle this.
*
* @throws When ENSRainbow fails to become ready after all configured retry attempts.
* This error will trigger termination of the ENSIndexer process.
*/
export function waitForEnsRainbowToBeReady(): Promise<void> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
export function waitForEnsRainbowToBeReady(): Promise<void> {
export function waitForEnsRainbowToBeHealthy(): Promise<void> {

Please note: There's many places in this file where the word "healthy" is written. I assume every single instance where this word is used should change to "ready".

We should always write in a way that is maximally accurate. Here we are not checking the /ready API endpoint. We are checking the /healthy endpoint. Therefore the language we use everywhere here needs to be updated.

if (waitForEnsRainbowToBeReadyPromise) {
return waitForEnsRainbowToBeReadyPromise;
}

console.log(`Waiting for ENSRainbow instance to be ready at '${ensRainbowUrl}'...`);

waitForEnsRainbowToBeReadyPromise = pRetry(async () => ensRainbowClient.health(), {
retries: 12, // This allows for a total of over 1 hour of retries with the exponential backoff strategy.
// 1 + 2 + 4 + ... + 2048 = 2^12 - 1 = 4,095s ≈ 1h 8m
Comment thread
tk-o marked this conversation as resolved.
Outdated
onFailedAttempt: ({ error, attemptNumber, retriesLeft }) => {
console.warn(
`Attempt ${attemptNumber} failed for the ENSRainbow health check at '${ensRainbowUrl}' (${error.message}). ${retriesLeft} retries left.`,
);
},
})
.then(() => console.log(`ENSRainbow instance is ready at '${ensRainbowUrl}'.`))
.catch((error) => {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
Comment thread
tk-o marked this conversation as resolved.
Outdated

console.error(`ENSRainbow health check failed after multiple attempts: ${errorMessage}`);

// Throw the error to terminate the ENSIndexer process due to the failed health check of a critical dependency
throw new Error(errorMessage, {
cause: error instanceof Error ? error : undefined,
});
});

return waitForEnsRainbowToBeReadyPromise;
}
10 changes: 4 additions & 6 deletions apps/ensindexer/src/lib/graphnode-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ import pRetry from "p-retry";
import type { LabelHash, LiteralLabel } from "@ensnode/ensnode-sdk";
import { type EnsRainbow, ErrorCode, isHealError } from "@ensnode/ensrainbow-sdk";

import { getENSRainbowApiClient } from "@/lib/ensraibow-api-client";

const ensRainbowApiClient = getENSRainbowApiClient();
import { ensRainbowClient } from "@/lib/ensrainbow/singleton";

/**
* Attempt to heal a labelHash to its original label.
Expand Down Expand Up @@ -44,13 +42,13 @@ export async function labelByLabelHash(labelHash: LabelHash): Promise<LiteralLab
// "last failure was HealServerError" (set) from "last failure was a network throw" (undefined).
let lastServerError: EnsRainbow.HealServerError | undefined;

let response: Awaited<ReturnType<typeof ensRainbowApiClient.heal>>;
let response: EnsRainbow.HealResponse;

try {
response = await pRetry(
async () => {
lastServerError = undefined;
const result = await ensRainbowApiClient.heal(labelHash);
const result = await ensRainbowClient.heal(labelHash);

if (isHealError(result) && result.errorCode === ErrorCode.ServerError) {
lastServerError = result;
Expand Down Expand Up @@ -81,7 +79,7 @@ export async function labelByLabelHash(labelHash: LabelHash): Promise<LiteralLab

// Not recoverable; causes the ENSIndexer process to terminate.
if (error instanceof Error) {
error.message = `ENSRainbow Heal Request Failed: ENSRainbow unavailable at '${ensRainbowApiClient.getOptions().endpointUrl}'.`;
error.message = `ENSRainbow Heal Request Failed: ENSRainbow unavailable at '${ensRainbowClient.getOptions().endpointUrl}'.`;
}

throw error;
Expand Down
Loading
Loading