From 3f5dc4f6bfa815a30e143804bba570174153b5e5 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 30 Mar 2026 08:46:27 +0200 Subject: [PATCH 01/12] Create `ensRainbowClient` singleton for ENSIndexer app --- .../singleton.ts} | 5 +++++ apps/ensindexer/src/lib/graphnode-helpers.ts | 10 ++++------ .../src/lib/public-config-builder/singleton.ts | 4 +--- 3 files changed, 10 insertions(+), 9 deletions(-) rename apps/ensindexer/src/lib/{ensraibow-api-client.ts => ensrainbow/singleton.ts} (83%) diff --git a/apps/ensindexer/src/lib/ensraibow-api-client.ts b/apps/ensindexer/src/lib/ensrainbow/singleton.ts similarity index 83% rename from apps/ensindexer/src/lib/ensraibow-api-client.ts rename to apps/ensindexer/src/lib/ensrainbow/singleton.ts index 877943b439..aaf6c9757c 100644 --- a/apps/ensindexer/src/lib/ensraibow-api-client.ts +++ b/apps/ensindexer/src/lib/ensrainbow/singleton.ts @@ -19,3 +19,8 @@ export function getENSRainbowApiClient(): EnsRainbow.ApiClient { return ensRainbowApiClient; } + +/** + * Singleton ENSRainbow API Client instance for ENSIndexer app. + */ +export const ensRainbowClient = getENSRainbowApiClient(); diff --git a/apps/ensindexer/src/lib/graphnode-helpers.ts b/apps/ensindexer/src/lib/graphnode-helpers.ts index df8d4a82e3..cb04339ace 100644 --- a/apps/ensindexer/src/lib/graphnode-helpers.ts +++ b/apps/ensindexer/src/lib/graphnode-helpers.ts @@ -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. @@ -44,13 +42,13 @@ export async function labelByLabelHash(labelHash: LabelHash): Promise>; + 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; @@ -81,7 +79,7 @@ export async function labelByLabelHash(labelHash: LabelHash): Promise Date: Mon, 30 Mar 2026 08:49:52 +0200 Subject: [PATCH 02/12] Simplify `ensrainbow/signleton.ts` file --- .../src/lib/ensrainbow/singleton.ts | 27 +++++++------------ 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/apps/ensindexer/src/lib/ensrainbow/singleton.ts b/apps/ensindexer/src/lib/ensrainbow/singleton.ts index aaf6c9757c..9e2d97342a 100644 --- a/apps/ensindexer/src/lib/ensrainbow/singleton.ts +++ b/apps/ensindexer/src/lib/ensrainbow/singleton.ts @@ -1,26 +1,19 @@ import config from "@/config"; -import { type EnsRainbow, EnsRainbowApiClient } from "@ensnode/ensrainbow-sdk"; +import { EnsRainbowApiClient } from "@ensnode/ensrainbow-sdk"; -export function getENSRainbowApiClient(): EnsRainbow.ApiClient { - const ensRainbowApiClient = new EnsRainbowApiClient({ - endpointUrl: config.ensRainbowUrl, - labelSet: config.labelSet, - }); +const { ensRainbowUrl, labelSet } = config; - if ( - ensRainbowApiClient.getOptions().endpointUrl === - EnsRainbowApiClient.defaultOptions().endpointUrl - ) { - 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.`, - ); - } - - return ensRainbowApiClient; +if (ensRainbowUrl === EnsRainbowApiClient.defaultOptions().endpointUrl) { + 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.`, + ); } /** * Singleton ENSRainbow API Client instance for ENSIndexer app. */ -export const ensRainbowClient = getENSRainbowApiClient(); +export const ensRainbowClient = new EnsRainbowApiClient({ + endpointUrl: ensRainbowUrl, + labelSet, +}); From 40bab875d1e05ca869bb187ac8c5328e5069991f Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 30 Mar 2026 09:46:21 +0200 Subject: [PATCH 03/12] Create `waitForEnsRainbowToBeReady` function This function will enbale ENSIndexer modules to wait for when the ENSRainbow instance is ready to serve traffic. --- .../src/lib/ensrainbow/singleton.ts | 55 ++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/apps/ensindexer/src/lib/ensrainbow/singleton.ts b/apps/ensindexer/src/lib/ensrainbow/singleton.ts index 9e2d97342a..8f4f06acdd 100644 --- a/apps/ensindexer/src/lib/ensrainbow/singleton.ts +++ b/apps/ensindexer/src/lib/ensrainbow/singleton.ts @@ -1,5 +1,7 @@ import config from "@/config"; +import pRetry from "p-retry"; + import { EnsRainbowApiClient } from "@ensnode/ensrainbow-sdk"; const { ensRainbowUrl, labelSet } = config; @@ -11,9 +13,60 @@ if (ensRainbowUrl === EnsRainbowApiClient.defaultOptions().endpointUrl) { } /** - * Singleton ENSRainbow API Client instance for ENSIndexer app. + * 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 | 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 + * 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 { + 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 + onFailedAttempt: ({ error, attemptNumber, retriesLeft }) => { + console.log( + `ENSRainbow health check attempt ${attemptNumber} failed (${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"; + + console.error(`ENSRainbow health check failed after multiple attempts: ${errorMessage}`); + + // Throw the error to terminate the ENSIndexer process due to failed health check of critical dependency + throw new Error(errorMessage, { + cause: error instanceof Error ? error : undefined, + }); + }); + + return waitForEnsRainbowToBeReadyPromise; +} From e84c0c79bd1c09d45f31d4ed1d8fb2f98e349fa9 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 30 Mar 2026 09:47:38 +0200 Subject: [PATCH 04/12] Implement `eventHandlerPreconditios` Allows to wait with indexing onchain events until ENSRainbow instance is ready. --- .../src/lib/indexing-engines/ponder.test.ts | 181 ++++++++++++++++-- .../src/lib/indexing-engines/ponder.ts | 77 +++++++- 2 files changed, 236 insertions(+), 22 deletions(-) diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts index 4bb7f5a792..3919b703aa 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts @@ -9,6 +9,8 @@ import { const { mockPonderOn } = vi.hoisted(() => ({ mockPonderOn: vi.fn() })); +const mockWaitForEnsRainbow = vi.hoisted(() => vi.fn()); + vi.mock("ponder:registry", () => ({ ponder: { on: (...args: unknown[]) => mockPonderOn(...args), @@ -19,9 +21,14 @@ vi.mock("ponder:schema", () => ({ ensIndexerSchema: {}, })); +vi.mock("@/lib/ensrainbow/singleton", () => ({ + waitForEnsRainbowToBeReady: mockWaitForEnsRainbow, +})); + describe("addOnchainEventListener", () => { beforeEach(() => { vi.clearAllMocks(); + mockWaitForEnsRainbow.mockResolvedValue(undefined); }); describe("registration", () => { @@ -45,7 +52,7 @@ describe("addOnchainEventListener", () => { }); describe("context transformation", () => { - it("adds ensDb property referencing the same object as db", () => { + it("adds ensDb property referencing the same object as db", async () => { const testHandler = vi.fn(); const mockDb = vi.fn(); const mockContext = { db: mockDb } as unknown as Context; @@ -54,14 +61,14 @@ describe("addOnchainEventListener", () => { addOnchainEventListener("Resolver:AddrChanged" as EventNames, testHandler); const [, callback] = mockPonderOn.mock.calls[0]!; - callback({ context: mockContext, event: mockEvent }); + await callback({ context: mockContext, event: mockEvent }); const callArg = testHandler.mock.calls[0]?.[0]; expect(callArg?.context.ensDb).toBe(callArg?.context.db); }); - it("preserves all other context properties", () => { - const testHandler = vi.fn(); + it("preserves all other context properties", async () => { + const testHandler = vi.fn().mockResolvedValue(undefined); const mockDb = vi.fn(); const mockContext = { db: mockDb, @@ -73,7 +80,7 @@ describe("addOnchainEventListener", () => { addOnchainEventListener("Resolver:AddrChanged" as EventNames, testHandler); const [, callback] = mockPonderOn.mock.calls[0]!; - callback({ context: mockContext, event: mockEvent }); + await callback({ context: mockContext, event: mockEvent }); expect(testHandler).toHaveBeenCalledWith({ context: expect.objectContaining({ @@ -88,9 +95,9 @@ describe("addOnchainEventListener", () => { }); describe("event handling", () => { - it("supports multiple event names independently", () => { - const handler1 = vi.fn(); - const handler2 = vi.fn(); + it("supports multiple event names independently", async () => { + const handler1 = vi.fn().mockResolvedValue(undefined); + const handler2 = vi.fn().mockResolvedValue(undefined); addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler1); addOnchainEventListener("Resolver:NameChanged" as EventNames, handler2); @@ -100,7 +107,10 @@ describe("addOnchainEventListener", () => { const [, callback1] = mockPonderOn.mock.calls[0]!; const mockDb1 = vi.fn(); const event1 = {} as IndexingEngineEvent; - callback1({ context: { db: mockDb1 } as unknown as Context, event: event1 }); + await callback1({ + context: { db: mockDb1 } as unknown as Context, + event: event1, + }); expect(handler1).toHaveBeenCalledTimes(1); expect(handler2).toHaveBeenCalledTimes(0); @@ -108,13 +118,16 @@ describe("addOnchainEventListener", () => { const [, callback2] = mockPonderOn.mock.calls[1]!; const mockDb2 = vi.fn(); const event2 = {} as IndexingEngineEvent; - callback2({ context: { db: mockDb2 } as unknown as Context, event: event2 }); + await callback2({ + context: { db: mockDb2 } as unknown as Context, + event: event2, + }); expect(handler2).toHaveBeenCalledTimes(1); }); - it("passes the event argument through to the handler", () => { - const testHandler = vi.fn(); + it("passes the event argument through to the handler", async () => { + const testHandler = vi.fn().mockResolvedValue(undefined); const mockDb = vi.fn(); const mockEvent = { args: { node: "0x123", label: "0x456" }, @@ -123,7 +136,10 @@ describe("addOnchainEventListener", () => { addOnchainEventListener("Resolver:AddrChanged" as EventNames, testHandler); const [, callback] = mockPonderOn.mock.calls[0]!; - callback({ context: { db: mockDb } as unknown as Context, event: mockEvent }); + await callback({ + context: { db: mockDb } as unknown as Context, + event: mockEvent, + }); expect(testHandler).toHaveBeenCalledWith(expect.objectContaining({ event: mockEvent })); }); @@ -144,7 +160,7 @@ describe("addOnchainEventListener", () => { expect(asyncHandler).toHaveBeenCalled(); }); - it("handles sync handlers", () => { + it("handles sync handlers", async () => { const syncHandler = vi.fn(); const mockDb = vi.fn(); const mockContext = { db: mockDb } as unknown as Context; @@ -153,14 +169,14 @@ describe("addOnchainEventListener", () => { addOnchainEventListener("Resolver:AddrChanged" as EventNames, syncHandler); const [, callback] = mockPonderOn.mock.calls[0]!; - callback({ context: mockContext, event: mockEvent }); + await callback({ context: mockContext, event: mockEvent }); expect(syncHandler).toHaveBeenCalled(); }); }); describe("error handling", () => { - it("propagates errors from sync handlers", () => { + it("propagates errors from sync handlers", async () => { const error = new Error("Handler failed"); const failingHandler = vi.fn(() => { throw error; @@ -172,7 +188,9 @@ describe("addOnchainEventListener", () => { addOnchainEventListener("Resolver:AddrChanged" as EventNames, failingHandler); const [, callback] = mockPonderOn.mock.calls[0]!; - expect(() => callback({ context: mockContext, event: mockEvent })).toThrow("Handler failed"); + await expect(callback({ context: mockContext, event: mockEvent })).rejects.toThrow( + "Handler failed", + ); }); it("propagates errors from async handlers", async () => { @@ -190,6 +208,135 @@ describe("addOnchainEventListener", () => { ); }); }); + + describe("preconditions", () => { + it("waits for ENSRainbow to be ready before executing onchain event handlers", async () => { + const testHandler = vi.fn().mockResolvedValue(undefined); + const mockDb = vi.fn(); + const mockContext = { db: mockDb } as unknown as Context; + const mockEvent = {} as IndexingEngineEvent; + + addOnchainEventListener("Resolver:AddrChanged" as EventNames, testHandler); + + const [, callback] = mockPonderOn.mock.calls[0]!; + await callback({ context: mockContext, event: mockEvent }); + + expect(mockWaitForEnsRainbow).toHaveBeenCalled(); + expect(testHandler).toHaveBeenCalled(); + }); + + it("does not execute handler if ENSRainbow precondition fails", async () => { + const testHandler = vi.fn().mockResolvedValue(undefined); + const mockDb = vi.fn(); + const mockContext = { db: mockDb } as unknown as Context; + const mockEvent = {} as IndexingEngineEvent; + const preconditionError = new Error("ENSRainbow not ready"); + + mockWaitForEnsRainbow.mockRejectedValue(preconditionError); + + addOnchainEventListener("Resolver:AddrChanged" as EventNames, testHandler); + + const [, callback] = mockPonderOn.mock.calls[0]!; + await expect(callback({ context: mockContext, event: mockEvent })).rejects.toThrow( + "ENSRainbow not ready", + ); + expect(testHandler).not.toHaveBeenCalled(); + }); + + it("does not throw error for setup events - they have no preconditions", async () => { + const testHandler = vi.fn().mockResolvedValue(undefined); + const mockDb = vi.fn(); + const mockContext = { db: mockDb } as unknown as Context; + const mockEvent = {} as IndexingEngineEvent; + + addOnchainEventListener("Registry:setup" as EventNames, testHandler); + + const [, callback] = mockPonderOn.mock.calls[0]!; + // Should not throw and should call handler without waiting for ENSRainbow + await expect(callback({ context: mockContext, event: mockEvent })).resolves.toBeUndefined(); + expect(testHandler).toHaveBeenCalled(); + expect(mockWaitForEnsRainbow).not.toHaveBeenCalled(); + }); + + it("resolves ENSRainbow precondition before calling handler", async () => { + const testHandler = vi.fn().mockResolvedValue(undefined); + const mockDb = vi.fn(); + const mockContext = { db: mockDb } as unknown as Context; + const mockEvent = {} as IndexingEngineEvent; + + let preconditionResolved = false; + mockWaitForEnsRainbow.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + preconditionResolved = true; + }); + + addOnchainEventListener("Resolver:AddrChanged" as EventNames, testHandler); + + const [, callback] = mockPonderOn.mock.calls[0]!; + await callback({ context: mockContext, event: mockEvent }); + + expect(preconditionResolved).toBe(true); + expect(testHandler).toHaveBeenCalled(); + }); + }); + + describe("event type detection", () => { + it("correctly identifies onchain events by name", async () => { + const onchainHandler = vi.fn().mockResolvedValue(undefined); + const mockDb = vi.fn(); + const mockContext = { db: mockDb } as unknown as Context; + const mockEvent = {} as IndexingEngineEvent; + + addOnchainEventListener("ETHRegistry:NewOwner" as EventNames, onchainHandler); + + const [, callback] = mockPonderOn.mock.calls[0]!; + await callback({ context: mockContext, event: mockEvent }); + + expect(mockWaitForEnsRainbow).toHaveBeenCalled(); + expect(onchainHandler).toHaveBeenCalled(); + }); + + it("correctly identifies setup events by :setup suffix and skips preconditions", async () => { + const setupHandler = vi.fn().mockResolvedValue(undefined); + const mockDb = vi.fn(); + const mockContext = { db: mockDb } as unknown as Context; + const mockEvent = {} as IndexingEngineEvent; + + addOnchainEventListener("PublicResolver:setup" as EventNames, setupHandler); + + const [, callback] = mockPonderOn.mock.calls[0]!; + await callback({ context: mockContext, event: mockEvent }); + + // Setup events should not call ENSRainbow preconditions + expect(mockWaitForEnsRainbow).not.toHaveBeenCalled(); + expect(setupHandler).toHaveBeenCalled(); + }); + + it("handles various onchain event name formats", async () => { + const testHandler = vi.fn().mockResolvedValue(undefined); + const mockDb = vi.fn(); + const mockContext = { db: mockDb } as unknown as Context; + const mockEvent = {} as IndexingEngineEvent; + + const onchainEvents = [ + "Resolver:AddrChanged", + "Registry:Transfer", + "ETHRegistry:NewResolver", + "BaseRegistrar:NameRegistered", + ]; + + for (const eventName of onchainEvents) { + vi.clearAllMocks(); + addOnchainEventListener(eventName as EventNames, testHandler); + + const [, callback] = mockPonderOn.mock.calls[0]!; + await callback({ context: mockContext, event: mockEvent }); + + expect(mockWaitForEnsRainbow).toHaveBeenCalled(); + expect(testHandler).toHaveBeenCalled(); + } + }); + }); }); describe("IndexingEngineContext type", () => { diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.ts index fca836a2be..516b71c67d 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.ts @@ -15,6 +15,8 @@ import { ponder, } from "ponder:registry"; +import { waitForEnsRainbowToBeReady } from "@/lib/ensrainbow/singleton"; + /** * Context passed to event handlers registered with * {@link addOnchainEventListener}. @@ -91,6 +93,69 @@ function buildIndexingEngineContext( }; } +/** + * Event type IDs for indexing handlers. + */ +const EventTypeIds = { + /** + * Setup event + * + * Driven by code, not by an onchain event. + * + * Event handlers for the setup events are fully executed before + * any onchain event handlers are executed, so they can be used to set up + * necessary state for onchain event handlers. + */ + Setup: "Setup", + + /** + * Onchain event + * + * Driven by an onchain event emitted by an indexed contract. + */ + Onchain: "Onchain", +} as const; + +/** + * The derived string union of possible {@link EventTypeIds}. + */ +type EventTypeId = (typeof EventTypeIds)[keyof typeof EventTypeIds]; + +function buildEventTypeId(eventName: EventNames): EventTypeId { + if (eventName.endsWith(":setup")) { + return EventTypeIds.Setup; + } else { + return EventTypeIds.Onchain; + } +} + +/** + * Execute any necessary preconditions before running an event handler + * for a given event type. + * + * Some event handlers may have preconditions that need to be met before + * they can run. For example, onchain event handlers depend on ENSRainbow + * instance being ready to serve "heal" requests. + */ +async function eventHandlerPreconditions(eventName: EventName) { + const eventType = buildEventTypeId(eventName); + + switch (eventType) { + case EventTypeIds.Setup: + /** + * Setup event handlers should not have any precondition. This is because + * only after all setup handlers have run, the indexing metrics for + * Ponder app are populated for all indexed chains. + * ENSIndexer relies on these indexing metrics immediately on startup to + * store the current Indexing Status in ENSDb. + */ + return; + case EventTypeIds.Onchain: { + return await waitForEnsRainbowToBeReady(); + } + } +} + /** * A thin wrapper around `ponder.on` that allows us to: * - Provide custom context to event handlers. @@ -106,10 +171,12 @@ export function addOnchainEventListener( eventName: EventName, eventHandler: (args: IndexingEngineEventHandlerArgs) => Promise | void, ) { - return ponder.on(eventName, ({ context, event }) => - eventHandler({ - context: buildIndexingEngineContext(context), - event, - }), + return ponder.on(eventName, async ({ context, event }) => + eventHandlerPreconditions(eventName).then(() => + eventHandler({ + context: buildIndexingEngineContext(context), + event, + }), + ), ); } From febed1cdc8f6644bcc0120853320f596b6d0675f Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 30 Mar 2026 10:07:53 +0200 Subject: [PATCH 05/12] docs(changeset): Introduced event handler preconditions to improve resiliency. --- .changeset/sharp-moons-shave.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/sharp-moons-shave.md diff --git a/.changeset/sharp-moons-shave.md b/.changeset/sharp-moons-shave.md new file mode 100644 index 0000000000..05c70f89ba --- /dev/null +++ b/.changeset/sharp-moons-shave.md @@ -0,0 +1,5 @@ +--- +"ensindexer": minor +--- + +Introduced event handler preconditions to improve resiliency. From 22c3c22497e3b9d3fd7cce5f0b30e4bcffddf36d Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 30 Mar 2026 11:57:27 +0200 Subject: [PATCH 06/12] Fix URL comparison for ENSRainbow singleton instnace --- apps/ensindexer/src/lib/ensrainbow/singleton.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ensindexer/src/lib/ensrainbow/singleton.ts b/apps/ensindexer/src/lib/ensrainbow/singleton.ts index 8f4f06acdd..348eed869f 100644 --- a/apps/ensindexer/src/lib/ensrainbow/singleton.ts +++ b/apps/ensindexer/src/lib/ensrainbow/singleton.ts @@ -6,7 +6,7 @@ import { EnsRainbowApiClient } from "@ensnode/ensrainbow-sdk"; const { ensRainbowUrl, labelSet } = config; -if (ensRainbowUrl === EnsRainbowApiClient.defaultOptions().endpointUrl) { +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.`, ); From d0dd98ce508352e6c789a745e85aad2e28531979 Mon Sep 17 00:00:00 2001 From: Tomek Kopacki Date: Mon, 30 Mar 2026 12:11:30 +0200 Subject: [PATCH 07/12] Apply suggestions from code review Co-authored-by: lightwalker.eth <126201998+lightwalker-eth@users.noreply.github.com> --- .changeset/sharp-moons-shave.md | 2 +- apps/ensindexer/src/lib/ensrainbow/singleton.ts | 2 +- apps/ensindexer/src/lib/indexing-engines/ponder.ts | 14 ++++++-------- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/.changeset/sharp-moons-shave.md b/.changeset/sharp-moons-shave.md index 05c70f89ba..d5e8ba7328 100644 --- a/.changeset/sharp-moons-shave.md +++ b/.changeset/sharp-moons-shave.md @@ -2,4 +2,4 @@ "ensindexer": minor --- -Introduced event handler preconditions to improve resiliency. +Introduced indexing event handler preconditions to optimize the cross-service availability in an ENSNode instance when ENSRainbow is performing a cold-start. diff --git a/apps/ensindexer/src/lib/ensrainbow/singleton.ts b/apps/ensindexer/src/lib/ensrainbow/singleton.ts index 348eed869f..9f1b41a3b9 100644 --- a/apps/ensindexer/src/lib/ensrainbow/singleton.ts +++ b/apps/ensindexer/src/lib/ensrainbow/singleton.ts @@ -62,7 +62,7 @@ export function waitForEnsRainbowToBeReady(): Promise { console.error(`ENSRainbow health check failed after multiple attempts: ${errorMessage}`); - // Throw the error to terminate the ENSIndexer process due to failed health check of critical dependency + // 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, }); diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.ts index 516b71c67d..f25a5db4be 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.ts @@ -100,7 +100,7 @@ const EventTypeIds = { /** * Setup event * - * Driven by code, not by an onchain event. + * Driven by indexing initialization code, not by indexing an onchain event. * * Event handlers for the setup events are fully executed before * any onchain event handlers are executed, so they can be used to set up @@ -134,8 +134,7 @@ function buildEventTypeId(eventName: EventNames): EventTypeId { * for a given event type. * * Some event handlers may have preconditions that need to be met before - * they can run. For example, onchain event handlers depend on ENSRainbow - * instance being ready to serve "heal" requests. + * they can run. */ async function eventHandlerPreconditions(eventName: EventName) { const eventType = buildEventTypeId(eventName); @@ -143,11 +142,10 @@ async function eventHandlerPreconditions(eventName switch (eventType) { case EventTypeIds.Setup: /** - * Setup event handlers should not have any precondition. This is because - * only after all setup handlers have run, the indexing metrics for - * Ponder app are populated for all indexed chains. - * ENSIndexer relies on these indexing metrics immediately on startup to - * store the current Indexing Status in ENSDb. + * Setup event handlers should not have any *blocking* preconditions. This is because + * Ponder populates the indexing metrics for all indexed chains only after all setup handlers have run. + * ENSIndexer relies on these indexing metrics being immediately available on startup to build and + * store the current Indexing Status in ENSDb. */ return; case EventTypeIds.Onchain: { From b9a0829326c598bc3d399ee6e5d75412247b27ea Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 30 Mar 2026 20:44:58 +0200 Subject: [PATCH 08/12] Apply PR feedback --- .../src/lib/ensrainbow/singleton.ts | 9 ++- .../src/lib/indexing-engines/ponder.ts | 76 ++++++++++++++++--- 2 files changed, 70 insertions(+), 15 deletions(-) diff --git a/apps/ensindexer/src/lib/ensrainbow/singleton.ts b/apps/ensindexer/src/lib/ensrainbow/singleton.ts index 9f1b41a3b9..c1909e07a3 100644 --- a/apps/ensindexer/src/lib/ensrainbow/singleton.ts +++ b/apps/ensindexer/src/lib/ensrainbow/singleton.ts @@ -49,10 +49,11 @@ export function waitForEnsRainbowToBeReady(): Promise { 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 + 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 onFailedAttempt: ({ error, attemptNumber, retriesLeft }) => { - console.log( - `ENSRainbow health check attempt ${attemptNumber} failed (${error.message}). ${retriesLeft} retries left.`, + console.warn( + `Attempt ${attemptNumber} failed for the ENSRainbow health check at '${ensRainbowUrl}' (${error.message}). ${retriesLeft} retries left.`, ); }, }) @@ -62,7 +63,7 @@ export function waitForEnsRainbowToBeReady(): Promise { 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 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, }); diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.ts index f25a5db4be..48611523f2 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.ts @@ -100,7 +100,7 @@ const EventTypeIds = { /** * Setup event * - * Driven by indexing initialization code, not by indexing an onchain event. + * Driven by indexing initialization code, not by indexing an onchain event. * * Event handlers for the setup events are fully executed before * any onchain event handlers are executed, so they can be used to set up @@ -129,27 +129,81 @@ function buildEventTypeId(eventName: EventNames): EventTypeId { } } +let preparedIndexingSetup = false; + +/** + * Prepare for executing the "setup" event handlers. + * + * This function is idempotent and will only execute its logic once, even if + * called multiple times. This is to ensure that we affect the "hot path" of + * indexing as little as possible, since this function is called for + * every "setup" event. + */ +function prepareIndexingSetup(): void { + if (preparedIndexingSetup) { + return; + } + + preparedIndexingSetup = true; + + /** + * Setup event handlers should not have any *blocking* preconditions. This is because + * Ponder populates the indexing metrics for all indexed chains only after all setup handlers have run. + * ENSIndexer relies on these indexing metrics being immediately available on startup to build and + * store the current Indexing Status in ENSDb. + */ +} + +let preparedIndexingActivation = false; + +/** + * Prepare for executing the "onchain" event handlers. + * + * This function is idempotent and will only execute its logic once, even if + * called multiple times. This is to ensure that we affect the "hot path" of + * indexing as little as possible, since this function is called for every + * "onchain" event. + * + * @example A single blocking precondition + * ```ts + * await waitForEnsRainbowToBeReady(); + * ``` + * + * @example Multiple blocking preconditions + * ```ts + * await Promise.all([ + * waitForEnsRainbowToBeReady(), + * waitForAnotherPrecondition(), + * ]); + * ``` + */ +async function prepareIndexingActivation() { + if (preparedIndexingActivation) { + return; + } + + preparedIndexingActivation = true; + + await waitForEnsRainbowToBeReady(); +} + /** * Execute any necessary preconditions before running an event handler * for a given event type. * * Some event handlers may have preconditions that need to be met before - * they can run. + * they can run. */ -async function eventHandlerPreconditions(eventName: EventName) { +async function eventHandlerPreconditions( + eventName: EventName, +): Promise { const eventType = buildEventTypeId(eventName); switch (eventType) { case EventTypeIds.Setup: - /** - * Setup event handlers should not have any *blocking* preconditions. This is because - * Ponder populates the indexing metrics for all indexed chains only after all setup handlers have run. - * ENSIndexer relies on these indexing metrics being immediately available on startup to build and - * store the current Indexing Status in ENSDb. - */ - return; + return prepareIndexingSetup(); case EventTypeIds.Onchain: { - return await waitForEnsRainbowToBeReady(); + return prepareIndexingActivation(); } } } From ef53bf854d721dcc2bc6f9a92f7021d83a6fb4bd Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 30 Mar 2026 20:45:16 +0200 Subject: [PATCH 09/12] Update testing suite --- .../src/lib/indexing-engines/ponder.test.ts | 411 ++++++++++-------- 1 file changed, 234 insertions(+), 177 deletions(-) diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts index 3919b703aa..8de3cee43e 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts @@ -1,11 +1,7 @@ import type { Context, EventNames } from "ponder:registry"; import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest"; -import { - addOnchainEventListener, - type IndexingEngineContext, - type IndexingEngineEvent, -} from "./ponder"; +import type { IndexingEngineContext, IndexingEngineEvent } from "./ponder"; const { mockPonderOn } = vi.hoisted(() => ({ mockPonderOn: vi.fn() })); @@ -26,76 +22,118 @@ vi.mock("@/lib/ensrainbow/singleton", () => ({ })); describe("addOnchainEventListener", () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); mockWaitForEnsRainbow.mockResolvedValue(undefined); + // Reset module state to test idempotent behavior correctly + vi.resetModules(); }); - describe("registration", () => { - it("registers the handler with the correct event name", () => { - const testHandler = vi.fn(); + // Helper to get fresh module reference after resetModules() + async function getPonderModule() { + return await import("./ponder"); + } - addOnchainEventListener("Resolver:AddrChanged" as EventNames, testHandler); + // Helper to extract the callback registered with ponder.on + function getRegisteredCallback( + callIndex = 0, + ): (args: { + context: Context; + event: IndexingEngineEvent; + }) => Promise { + return mockPonderOn.mock.calls[callIndex]![1] as ReturnType; + } + + describe("handler registration", () => { + it("registers the event name and handler with ponder.on", async () => { + const { addOnchainEventListener } = await getPonderModule(); + const handler = vi.fn(); + + addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler); expect(mockPonderOn).toHaveBeenCalledWith("Resolver:AddrChanged", expect.any(Function)); }); - it("returns the result from ponder.on", () => { - const mockReturnValue = { unsubscribe: vi.fn() }; - mockPonderOn.mockReturnValue(mockReturnValue); - const testHandler = vi.fn(); + it("returns the subscription object from ponder.on", async () => { + const { addOnchainEventListener } = await getPonderModule(); + const mockSubscription = { unsubscribe: vi.fn() }; + mockPonderOn.mockReturnValue(mockSubscription); + const handler = vi.fn(); - const result = addOnchainEventListener("Resolver:AddrChanged" as EventNames, testHandler); + const result = addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler); - expect(result).toBe(mockReturnValue); + expect(result).toBe(mockSubscription); }); }); describe("context transformation", () => { - it("adds ensDb property referencing the same object as db", async () => { - const testHandler = vi.fn(); + it("adds ensDb as an alias to the Ponder db", async () => { + const { addOnchainEventListener } = await getPonderModule(); + const handler = vi.fn(); const mockDb = vi.fn(); const mockContext = { db: mockDb } as unknown as Context; - const mockEvent = {} as IndexingEngineEvent; - - addOnchainEventListener("Resolver:AddrChanged" as EventNames, testHandler); - const [, callback] = mockPonderOn.mock.calls[0]!; - await callback({ context: mockContext, event: mockEvent }); + addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler); + await getRegisteredCallback()({ + context: mockContext, + event: {} as IndexingEngineEvent, + }); - const callArg = testHandler.mock.calls[0]?.[0]; - expect(callArg?.context.ensDb).toBe(callArg?.context.db); + const receivedContext = handler.mock.calls[0]![0].context; + expect(receivedContext.ensDb).toBe(mockDb); + expect(receivedContext.ensDb).toBe(receivedContext.db); }); - it("preserves all other context properties", async () => { - const testHandler = vi.fn().mockResolvedValue(undefined); + it("preserves all other Ponder context properties", async () => { + const { addOnchainEventListener } = await getPonderModule(); + const handler = vi.fn().mockResolvedValue(undefined); const mockDb = vi.fn(); const mockContext = { db: mockDb, chain: { id: 1 }, block: { number: 100n }, + client: { request: vi.fn() }, } as unknown as Context; - const mockEvent = { args: { a: "0x123" } } as unknown as IndexingEngineEvent; - - addOnchainEventListener("Resolver:AddrChanged" as EventNames, testHandler); + const mockEvent = { args: { node: "0x123" } } as unknown as IndexingEngineEvent; - const [, callback] = mockPonderOn.mock.calls[0]!; - await callback({ context: mockContext, event: mockEvent }); + addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler); + await getRegisteredCallback()({ context: mockContext, event: mockEvent }); - expect(testHandler).toHaveBeenCalledWith({ + expect(handler).toHaveBeenCalledWith({ context: expect.objectContaining({ db: mockDb, ensDb: mockDb, chain: { id: 1 }, block: { number: 100n }, + client: expect.any(Object), }), event: mockEvent, }); }); }); - describe("event handling", () => { - it("supports multiple event names independently", async () => { + describe("event forwarding", () => { + it("passes the original event to the handler unchanged", async () => { + const { addOnchainEventListener } = await getPonderModule(); + const handler = vi.fn().mockResolvedValue(undefined); + const mockDb = vi.fn(); + const mockEvent = { + args: { node: "0x123", label: "0x456", owner: "0x789" }, + block: { number: 100n }, + transaction: { hash: "0xabc" }, + } as unknown as IndexingEngineEvent; + + addOnchainEventListener("Registry:Transfer" as EventNames, handler); + await getRegisteredCallback()({ + context: { db: mockDb } as unknown as Context, + event: mockEvent, + }); + + expect(handler).toHaveBeenCalledWith(expect.objectContaining({ event: mockEvent })); + }); + + it("supports multiple independent event registrations", async () => { + const { addOnchainEventListener } = await getPonderModule(); const handler1 = vi.fn().mockResolvedValue(undefined); const handler2 = vi.fn().mockResolvedValue(undefined); @@ -104,220 +142,234 @@ describe("addOnchainEventListener", () => { expect(mockPonderOn).toHaveBeenCalledTimes(2); - const [, callback1] = mockPonderOn.mock.calls[0]!; - const mockDb1 = vi.fn(); - const event1 = {} as IndexingEngineEvent; - await callback1({ - context: { db: mockDb1 } as unknown as Context, - event: event1, + // Trigger first handler + await getRegisteredCallback(0)({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, }); - expect(handler1).toHaveBeenCalledTimes(1); - expect(handler2).toHaveBeenCalledTimes(0); - - const [, callback2] = mockPonderOn.mock.calls[1]!; - const mockDb2 = vi.fn(); - const event2 = {} as IndexingEngineEvent; - await callback2({ - context: { db: mockDb2 } as unknown as Context, - event: event2, - }); - - expect(handler2).toHaveBeenCalledTimes(1); - }); - - it("passes the event argument through to the handler", async () => { - const testHandler = vi.fn().mockResolvedValue(undefined); - const mockDb = vi.fn(); - const mockEvent = { - args: { node: "0x123", label: "0x456" }, - } as unknown as IndexingEngineEvent; - - addOnchainEventListener("Resolver:AddrChanged" as EventNames, testHandler); + expect(handler2).not.toHaveBeenCalled(); - const [, callback] = mockPonderOn.mock.calls[0]!; - await callback({ - context: { db: mockDb } as unknown as Context, - event: mockEvent, + // Trigger second handler + await getRegisteredCallback(1)({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, }); - - expect(testHandler).toHaveBeenCalledWith(expect.objectContaining({ event: mockEvent })); + expect(handler2).toHaveBeenCalledTimes(1); }); }); describe("handler types", () => { - it("handles async handlers", async () => { + it("supports async handlers", async () => { + const { addOnchainEventListener } = await getPonderModule(); const asyncHandler = vi.fn().mockResolvedValue(undefined); - const mockDb = vi.fn(); - const mockContext = { db: mockDb } as unknown as Context; - const mockEvent = {} as IndexingEngineEvent; addOnchainEventListener("Resolver:AddrChanged" as EventNames, asyncHandler); - - const [, callback] = mockPonderOn.mock.calls[0]!; - await callback({ context: mockContext, event: mockEvent }); + await getRegisteredCallback()({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, + }); expect(asyncHandler).toHaveBeenCalled(); }); - it("handles sync handlers", async () => { + it("supports sync handlers", async () => { + const { addOnchainEventListener } = await getPonderModule(); const syncHandler = vi.fn(); - const mockDb = vi.fn(); - const mockContext = { db: mockDb } as unknown as Context; - const mockEvent = {} as IndexingEngineEvent; addOnchainEventListener("Resolver:AddrChanged" as EventNames, syncHandler); - - const [, callback] = mockPonderOn.mock.calls[0]!; - await callback({ context: mockContext, event: mockEvent }); + await getRegisteredCallback()({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, + }); expect(syncHandler).toHaveBeenCalled(); }); }); - describe("error handling", () => { - it("propagates errors from sync handlers", async () => { - const error = new Error("Handler failed"); + describe("error propagation", () => { + it("re-throws errors from sync handlers", async () => { + const { addOnchainEventListener } = await getPonderModule(); + const error = new Error("Sync handler failed"); const failingHandler = vi.fn(() => { throw error; }); - const mockDb = vi.fn(); - const mockContext = { db: mockDb } as unknown as Context; - const mockEvent = {} as IndexingEngineEvent; addOnchainEventListener("Resolver:AddrChanged" as EventNames, failingHandler); - const [, callback] = mockPonderOn.mock.calls[0]!; - await expect(callback({ context: mockContext, event: mockEvent })).rejects.toThrow( - "Handler failed", - ); + await expect( + getRegisteredCallback()({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, + }), + ).rejects.toThrow("Sync handler failed"); }); - it("propagates errors from async handlers", async () => { + it("re-throws errors from async handlers", async () => { + const { addOnchainEventListener } = await getPonderModule(); const error = new Error("Async handler failed"); const failingHandler = vi.fn().mockRejectedValue(error); - const mockDb = vi.fn(); - const mockContext = { db: mockDb } as unknown as Context; - const mockEvent = {} as IndexingEngineEvent; addOnchainEventListener("Resolver:AddrChanged" as EventNames, failingHandler); - const [, callback] = mockPonderOn.mock.calls[0]!; - await expect(callback({ context: mockContext, event: mockEvent })).rejects.toThrow( - "Async handler failed", - ); + await expect( + getRegisteredCallback()({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, + }), + ).rejects.toThrow("Async handler failed"); }); }); - describe("preconditions", () => { - it("waits for ENSRainbow to be ready before executing onchain event handlers", async () => { - const testHandler = vi.fn().mockResolvedValue(undefined); - const mockDb = vi.fn(); - const mockContext = { db: mockDb } as unknown as Context; - const mockEvent = {} as IndexingEngineEvent; - - addOnchainEventListener("Resolver:AddrChanged" as EventNames, testHandler); + describe("ENSRainbow preconditions (onchain events)", () => { + it("waits for ENSRainbow before executing the handler", async () => { + const { addOnchainEventListener } = await getPonderModule(); + const handler = vi.fn().mockResolvedValue(undefined); - const [, callback] = mockPonderOn.mock.calls[0]!; - await callback({ context: mockContext, event: mockEvent }); + addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler); + await getRegisteredCallback()({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, + }); - expect(mockWaitForEnsRainbow).toHaveBeenCalled(); - expect(testHandler).toHaveBeenCalled(); + expect(mockWaitForEnsRainbow).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalled(); }); - it("does not execute handler if ENSRainbow precondition fails", async () => { - const testHandler = vi.fn().mockResolvedValue(undefined); - const mockDb = vi.fn(); - const mockContext = { db: mockDb } as unknown as Context; - const mockEvent = {} as IndexingEngineEvent; - const preconditionError = new Error("ENSRainbow not ready"); + it("prevents handler execution if ENSRainbow is not ready", async () => { + const { addOnchainEventListener } = await getPonderModule(); + const handler = vi.fn().mockResolvedValue(undefined); + mockWaitForEnsRainbow.mockRejectedValue(new Error("ENSRainbow not ready")); - mockWaitForEnsRainbow.mockRejectedValue(preconditionError); + addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler); - addOnchainEventListener("Resolver:AddrChanged" as EventNames, testHandler); + await expect( + getRegisteredCallback()({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, + }), + ).rejects.toThrow("ENSRainbow not ready"); - const [, callback] = mockPonderOn.mock.calls[0]!; - await expect(callback({ context: mockContext, event: mockEvent })).rejects.toThrow( - "ENSRainbow not ready", - ); - expect(testHandler).not.toHaveBeenCalled(); + expect(handler).not.toHaveBeenCalled(); }); - it("does not throw error for setup events - they have no preconditions", async () => { - const testHandler = vi.fn().mockResolvedValue(undefined); - const mockDb = vi.fn(); - const mockContext = { db: mockDb } as unknown as Context; - const mockEvent = {} as IndexingEngineEvent; + it("calls waitForEnsRainbowToBeReady only once across multiple onchain events (idempotent)", async () => { + const { addOnchainEventListener } = await getPonderModule(); + const handler1 = vi.fn().mockResolvedValue(undefined); + const handler2 = vi.fn().mockResolvedValue(undefined); - addOnchainEventListener("Registry:setup" as EventNames, testHandler); + // Register two different onchain event listeners + addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler1); + addOnchainEventListener("Registry:Transfer" as EventNames, handler2); - const [, callback] = mockPonderOn.mock.calls[0]!; - // Should not throw and should call handler without waiting for ENSRainbow - await expect(callback({ context: mockContext, event: mockEvent })).resolves.toBeUndefined(); - expect(testHandler).toHaveBeenCalled(); - expect(mockWaitForEnsRainbow).not.toHaveBeenCalled(); - }); + // Trigger the first event handler + await getRegisteredCallback(0)({ + context: { db: vi.fn() } as unknown as Context, + event: { args: { a: "1" } } as unknown as IndexingEngineEvent, + }); + expect(mockWaitForEnsRainbow).toHaveBeenCalledTimes(1); - it("resolves ENSRainbow precondition before calling handler", async () => { - const testHandler = vi.fn().mockResolvedValue(undefined); - const mockDb = vi.fn(); - const mockContext = { db: mockDb } as unknown as Context; - const mockEvent = {} as IndexingEngineEvent; + // Trigger the second event handler + await getRegisteredCallback(1)({ + context: { db: vi.fn() } as unknown as Context, + event: { args: { a: "2" } } as unknown as IndexingEngineEvent, + }); + + // Should still only have been called once (idempotent behavior) + expect(mockWaitForEnsRainbow).toHaveBeenCalledTimes(1); + expect(handler1).toHaveBeenCalledTimes(1); + expect(handler2).toHaveBeenCalledTimes(1); + }); + it("resolves ENSRainbow before calling the handler", async () => { + const { addOnchainEventListener } = await getPonderModule(); + const handler = vi.fn().mockResolvedValue(undefined); let preconditionResolved = false; + mockWaitForEnsRainbow.mockImplementation(async () => { await new Promise((resolve) => setTimeout(resolve, 10)); preconditionResolved = true; }); - addOnchainEventListener("Resolver:AddrChanged" as EventNames, testHandler); - - const [, callback] = mockPonderOn.mock.calls[0]!; - await callback({ context: mockContext, event: mockEvent }); + addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler); + await getRegisteredCallback()({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, + }); expect(preconditionResolved).toBe(true); - expect(testHandler).toHaveBeenCalled(); + expect(handler).toHaveBeenCalled(); }); }); - describe("event type detection", () => { - it("correctly identifies onchain events by name", async () => { - const onchainHandler = vi.fn().mockResolvedValue(undefined); - const mockDb = vi.fn(); - const mockContext = { db: mockDb } as unknown as Context; - const mockEvent = {} as IndexingEngineEvent; + describe("setup events (no preconditions)", () => { + it("skips ENSRainbow wait for :setup events", async () => { + const { addOnchainEventListener } = await getPonderModule(); + const handler = vi.fn().mockResolvedValue(undefined); - addOnchainEventListener("ETHRegistry:NewOwner" as EventNames, onchainHandler); + addOnchainEventListener("Registry:setup" as EventNames, handler); + await getRegisteredCallback()({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, + }); - const [, callback] = mockPonderOn.mock.calls[0]!; - await callback({ context: mockContext, event: mockEvent }); + expect(mockWaitForEnsRainbow).not.toHaveBeenCalled(); + expect(handler).toHaveBeenCalled(); + }); - expect(mockWaitForEnsRainbow).toHaveBeenCalled(); - expect(onchainHandler).toHaveBeenCalled(); + it("handles various setup event name formats", async () => { + const { addOnchainEventListener } = await getPonderModule(); + const handler = vi.fn().mockResolvedValue(undefined); + const setupEvents = [ + "Registry:setup", + "PublicResolver:setup", + "ETHRegistrarController:setup", + ]; + + for (const eventName of setupEvents) { + vi.clearAllMocks(); + handler.mockClear(); + + addOnchainEventListener(eventName as EventNames, handler); + await getRegisteredCallback()({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, + }); + + expect(mockWaitForEnsRainbow).not.toHaveBeenCalled(); + expect(handler).toHaveBeenCalled(); + } }); + }); - it("correctly identifies setup events by :setup suffix and skips preconditions", async () => { + describe("event type detection", () => { + it("treats :setup suffix as setup event type", async () => { + const { addOnchainEventListener } = await getPonderModule(); const setupHandler = vi.fn().mockResolvedValue(undefined); - const mockDb = vi.fn(); - const mockContext = { db: mockDb } as unknown as Context; - const mockEvent = {} as IndexingEngineEvent; + const onchainHandler = vi.fn().mockResolvedValue(undefined); addOnchainEventListener("PublicResolver:setup" as EventNames, setupHandler); + addOnchainEventListener("PublicResolver:AddrChanged" as EventNames, onchainHandler); - const [, callback] = mockPonderOn.mock.calls[0]!; - await callback({ context: mockContext, event: mockEvent }); - - // Setup events should not call ENSRainbow preconditions + // Setup event - no ENSRainbow wait + await getRegisteredCallback(0)({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, + }); expect(mockWaitForEnsRainbow).not.toHaveBeenCalled(); expect(setupHandler).toHaveBeenCalled(); - }); - it("handles various onchain event name formats", async () => { - const testHandler = vi.fn().mockResolvedValue(undefined); - const mockDb = vi.fn(); - const mockContext = { db: mockDb } as unknown as Context; - const mockEvent = {} as IndexingEngineEvent; + // Onchain event - ENSRainbow wait required + await getRegisteredCallback(1)({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, + }); + expect(mockWaitForEnsRainbow).toHaveBeenCalledTimes(1); + expect(onchainHandler).toHaveBeenCalled(); + }); + it("treats all non-:setup events as onchain events", async () => { + const handler = vi.fn().mockResolvedValue(undefined); const onchainEvents = [ "Resolver:AddrChanged", "Registry:Transfer", @@ -327,13 +379,18 @@ describe("addOnchainEventListener", () => { for (const eventName of onchainEvents) { vi.clearAllMocks(); - addOnchainEventListener(eventName as EventNames, testHandler); + vi.resetModules(); + const { addOnchainEventListener: freshAddOnchainEventListener } = await getPonderModule(); + handler.mockClear(); - const [, callback] = mockPonderOn.mock.calls[0]!; - await callback({ context: mockContext, event: mockEvent }); + freshAddOnchainEventListener(eventName as EventNames, handler); + await getRegisteredCallback()({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, + }); expect(mockWaitForEnsRainbow).toHaveBeenCalled(); - expect(testHandler).toHaveBeenCalled(); + expect(handler).toHaveBeenCalled(); } }); }); From d51b2ea74e441fb34d19dd4336d3f4e9985ab9f9 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Tue, 31 Mar 2026 12:12:31 +0200 Subject: [PATCH 10/12] Update testing suite --- .../src/lib/indexing-engines/ponder.test.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts index 8de3cee43e..28d13674fd 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts @@ -281,6 +281,51 @@ describe("addOnchainEventListener", () => { expect(handler2).toHaveBeenCalledTimes(1); }); + it("calls waitForEnsRainbowToBeReady only once when two onchain callbacks fire concurrently before the readiness promise resolves", async () => { + const { addOnchainEventListener } = await getPonderModule(); + const handler1 = vi.fn().mockResolvedValue(undefined); + const handler2 = vi.fn().mockResolvedValue(undefined); + let resolveReadiness: (() => void) | undefined; + + // Create a promise that won't resolve until we manually trigger it + mockWaitForEnsRainbow.mockImplementation(() => { + return new Promise((resolve) => { + resolveReadiness = resolve; + }); + }); + + // Register two different onchain event listeners + addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler1); + addOnchainEventListener("Registry:Transfer" as EventNames, handler2); + + // Fire both handlers concurrently - neither should complete yet + const promise1 = getRegisteredCallback(0)({ + context: { db: vi.fn() } as unknown as Context, + event: { args: { a: "1" } } as unknown as IndexingEngineEvent, + }); + const promise2 = getRegisteredCallback(1)({ + context: { db: vi.fn() } as unknown as Context, + event: { args: { a: "2" } } as unknown as IndexingEngineEvent, + }); + + // Should only have been called once despite concurrent execution + expect(mockWaitForEnsRainbow).toHaveBeenCalledTimes(1); + + // Neither handler should have executed yet + expect(handler1).not.toHaveBeenCalled(); + expect(handler2).not.toHaveBeenCalled(); + + // Now resolve the readiness promise + resolveReadiness!(); + + // Wait for both handlers to complete + await Promise.all([promise1, promise2]); + + // Both handlers should have executed after resolution + expect(handler1).toHaveBeenCalledTimes(1); + expect(handler2).toHaveBeenCalledTimes(1); + }); + it("resolves ENSRainbow before calling the handler", async () => { const { addOnchainEventListener } = await getPonderModule(); const handler = vi.fn().mockResolvedValue(undefined); From 91308fbf38d94718d57dd690d2a702c7b9aff1b6 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 2 Apr 2026 17:10:57 +0200 Subject: [PATCH 11/12] Apply PR feedback --- .../src/lib/ensrainbow/singleton.ts | 13 +-- .../src/lib/indexing-engines/ponder.ts | 90 +++++++++---------- 2 files changed, 52 insertions(+), 51 deletions(-) diff --git a/apps/ensindexer/src/lib/ensrainbow/singleton.ts b/apps/ensindexer/src/lib/ensrainbow/singleton.ts index c1909e07a3..db2c167955 100644 --- a/apps/ensindexer/src/lib/ensrainbow/singleton.ts +++ b/apps/ensindexer/src/lib/ensrainbow/singleton.ts @@ -1,5 +1,6 @@ import config from "@/config"; +import { secondsToMilliseconds } from "date-fns"; import pRetry from "p-retry"; import { EnsRainbowApiClient } from "@ensnode/ensrainbow-sdk"; @@ -35,8 +36,9 @@ let waitForEnsRainbowToBeReadyPromise: Promise | undefined; * 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 - * a cold start scenario. We use retries for the ENSRainbow health check with - * an exponential backoff strategy to handle this. + * a cold start scenario. We use retries with a fixed interval between attempts + * for the ENSRainbow health check to allow for ample time for ENSRainbow to + * become ready. * * @throws When ENSRainbow fails to become ready after all configured retry attempts. * This error will trigger termination of the ENSIndexer process. @@ -49,11 +51,12 @@ export function waitForEnsRainbowToBeReady(): Promise { 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 + retries: 60, // This allows for a total of over 1 hour of retries with 1 minute between attempts. + minTimeout: secondsToMilliseconds(60), + maxTimeout: secondsToMilliseconds(60), onFailedAttempt: ({ error, attemptNumber, retriesLeft }) => { console.warn( - `Attempt ${attemptNumber} failed for the ENSRainbow health check at '${ensRainbowUrl}' (${error.message}). ${retriesLeft} retries left.`, + `Attempt ${attemptNumber} failed for the ENSRainbow health check at '${ensRainbowUrl}' (${error.message}). ${retriesLeft} retries left. This might be due to ENSRainbow having a cold start, which can take 30+ minutes.`, ); }, }) diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.ts index 48611523f2..ba03a6b376 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.ts @@ -129,40 +129,35 @@ function buildEventTypeId(eventName: EventNames): EventTypeId { } } -let preparedIndexingSetup = false; - /** * Prepare for executing the "setup" event handlers. * - * This function is idempotent and will only execute its logic once, even if - * called multiple times. This is to ensure that we affect the "hot path" of - * indexing as little as possible, since this function is called for - * every "setup" event. + * During Ponder startup, the "setup" event handlers are executed: + * - After Ponder completed database migrations for ENSIndexer Schema in ENSDb. + * - Before Ponder starts processing any onchain events for indexed chains. + * + * This function is useful to make sure ENSDb is ready for writes, for example, + * by ensuring all required Postgres extensions are installed, etc. */ -function prepareIndexingSetup(): void { - if (preparedIndexingSetup) { - return; - } - - preparedIndexingSetup = true; - +async function initializeIndexingSetup(): Promise { /** - * Setup event handlers should not have any *blocking* preconditions. This is because + * Setup event handlers should not have any *long-running* preconditions. This is because * Ponder populates the indexing metrics for all indexed chains only after all setup handlers have run. * ENSIndexer relies on these indexing metrics being immediately available on startup to build and * store the current Indexing Status in ENSDb. */ } -let preparedIndexingActivation = false; - /** * Prepare for executing the "onchain" event handlers. * - * This function is idempotent and will only execute its logic once, even if - * called multiple times. This is to ensure that we affect the "hot path" of - * indexing as little as possible, since this function is called for every - * "onchain" event. + * During Ponder startup, the "onchain" event handlers are executed + * after all "setup" event handlers have completed. + * + * This function is useful to make sure any long-running preconditions for + * onchain event handlers are met, for example, waiting for + * the ENSRainbow instance to be ready before processing any onchain events + * that require data from ENSRainbow. * * @example A single blocking precondition * ```ts @@ -177,34 +172,36 @@ let preparedIndexingActivation = false; * ]); * ``` */ -async function prepareIndexingActivation() { - if (preparedIndexingActivation) { - return; - } - - preparedIndexingActivation = true; - +async function initializeIndexingActivation(): Promise { await waitForEnsRainbowToBeReady(); } +let indexingSetupPromise: Promise | null = null; +let indexingActivationPromise: Promise | null = null; + /** * Execute any necessary preconditions before running an event handler * for a given event type. * * Some event handlers may have preconditions that need to be met before * they can run. + * + * This function is idempotent and will only execute its logic once, even if + * called multiple times. This is to ensure that we affect the "hot path" of + * indexing as little as possible, since this function is called for every + * "onchain" event. */ -async function eventHandlerPreconditions( - eventName: EventName, -): Promise { - const eventType = buildEventTypeId(eventName); - - switch (eventType) { - case EventTypeIds.Setup: - return prepareIndexingSetup(); - case EventTypeIds.Onchain: { - return prepareIndexingActivation(); - } +async function eventHandlerPreconditions(eventType: EventTypeId): Promise { + if (eventType === EventTypeIds.Setup && indexingSetupPromise === null) { + // Initialize the indexing setup just once. + indexingSetupPromise = initializeIndexingSetup(); + return await indexingSetupPromise; + } else if (eventType === EventTypeIds.Onchain && indexingActivationPromise === null) { + // Initialize the indexing activation just once in order to + // optimize the "hot path" of indexing onchain events, since these are + // much more frequent than setup events. + indexingActivationPromise = initializeIndexingActivation(); + return await indexingActivationPromise; } } @@ -223,12 +220,13 @@ export function addOnchainEventListener( eventName: EventName, eventHandler: (args: IndexingEngineEventHandlerArgs) => Promise | void, ) { - return ponder.on(eventName, async ({ context, event }) => - eventHandlerPreconditions(eventName).then(() => - eventHandler({ - context: buildIndexingEngineContext(context), - event, - }), - ), - ); + const eventType = buildEventTypeId(eventName); + + return ponder.on(eventName, async ({ context, event }) => { + await eventHandlerPreconditions(eventType); + await eventHandler({ + context: buildIndexingEngineContext(context), + event, + }); + }); } From b78f2cecef362b40959a2f10a8aa8f33bddf7611 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 2 Apr 2026 17:20:50 +0200 Subject: [PATCH 12/12] Apply AI PR feedback --- .../src/lib/indexing-engines/ponder.ts | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.ts index ba03a6b376..84996fdc67 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.ts @@ -192,16 +192,26 @@ let indexingActivationPromise: Promise | null = null; * "onchain" event. */ async function eventHandlerPreconditions(eventType: EventTypeId): Promise { - if (eventType === EventTypeIds.Setup && indexingSetupPromise === null) { - // Initialize the indexing setup just once. - indexingSetupPromise = initializeIndexingSetup(); - return await indexingSetupPromise; - } else if (eventType === EventTypeIds.Onchain && indexingActivationPromise === null) { - // Initialize the indexing activation just once in order to - // optimize the "hot path" of indexing onchain events, since these are - // much more frequent than setup events. - indexingActivationPromise = initializeIndexingActivation(); - return await indexingActivationPromise; + switch (eventType) { + case EventTypeIds.Setup: { + if (indexingSetupPromise === null) { + // Initialize the indexing setup just once. + indexingSetupPromise = initializeIndexingSetup(); + } + + return await indexingSetupPromise; + } + + case EventTypeIds.Onchain: { + if (indexingActivationPromise === null) { + // Initialize the indexing activation just once in order to + // optimize the "hot path" of indexing onchain events, since these are + // much more frequent than setup events. + indexingActivationPromise = initializeIndexingActivation(); + } + + return await indexingActivationPromise; + } } }