diff --git a/.changeset/sharp-moons-shave.md b/.changeset/sharp-moons-shave.md new file mode 100644 index 0000000000..d5e8ba7328 --- /dev/null +++ b/.changeset/sharp-moons-shave.md @@ -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. diff --git a/apps/ensindexer/src/lib/ensraibow-api-client.ts b/apps/ensindexer/src/lib/ensraibow-api-client.ts deleted file mode 100644 index 877943b439..0000000000 --- a/apps/ensindexer/src/lib/ensraibow-api-client.ts +++ /dev/null @@ -1,21 +0,0 @@ -import config from "@/config"; - -import { type EnsRainbow, EnsRainbowApiClient } from "@ensnode/ensrainbow-sdk"; - -export function getENSRainbowApiClient(): EnsRainbow.ApiClient { - const ensRainbowApiClient = new EnsRainbowApiClient({ - endpointUrl: config.ensRainbowUrl, - labelSet: config.labelSet, - }); - - 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; -} diff --git a/apps/ensindexer/src/lib/ensrainbow/singleton.ts b/apps/ensindexer/src/lib/ensrainbow/singleton.ts new file mode 100644 index 0000000000..db2c167955 --- /dev/null +++ b/apps/ensindexer/src/lib/ensrainbow/singleton.ts @@ -0,0 +1,76 @@ +import config from "@/config"; + +import { secondsToMilliseconds } from "date-fns"; +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.`, + ); +} + +/** + * 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 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. + */ +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: 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. This might be due to ENSRainbow having a cold start, which can take 30+ minutes.`, + ); + }, + }) + .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 the failed health check of a critical dependency + throw new Error(errorMessage, { + cause: error instanceof Error ? error : undefined, + }); + }); + + return waitForEnsRainbowToBeReadyPromise; +} 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 ({ mockPonderOn: vi.fn() })); +const mockWaitForEnsRainbow = vi.hoisted(() => vi.fn()); + vi.mock("ponder:registry", () => ({ ponder: { on: (...args: unknown[]) => mockPonderOn(...args), @@ -19,175 +17,426 @@ vi.mock("ponder:schema", () => ({ ensIndexerSchema: {}, })); +vi.mock("@/lib/ensrainbow/singleton", () => ({ + waitForEnsRainbowToBeReady: mockWaitForEnsRainbow, +})); + 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"); + } + + // 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; + } - addOnchainEventListener("Resolver:AddrChanged" as EventNames, testHandler); + 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", () => { - 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]!; - 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", () => { - const testHandler = vi.fn(); + 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; + const mockEvent = { args: { node: "0x123" } } as unknown as IndexingEngineEvent; - addOnchainEventListener("Resolver:AddrChanged" as EventNames, testHandler); + addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler); + await getRegisteredCallback()({ context: mockContext, event: mockEvent }); - const [, callback] = mockPonderOn.mock.calls[0]!; - callback({ 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", () => { - const handler1 = vi.fn(); - const handler2 = vi.fn(); + 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); addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler1); addOnchainEventListener("Resolver:NameChanged" as EventNames, handler2); expect(mockPonderOn).toHaveBeenCalledTimes(2); - const [, callback1] = mockPonderOn.mock.calls[0]!; - const mockDb1 = vi.fn(); - const event1 = {} as IndexingEngineEvent; - 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; - callback2({ context: { db: mockDb2 } as unknown as Context, event: event2 }); + expect(handler2).not.toHaveBeenCalled(); + // Trigger second handler + await getRegisteredCallback(1)({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, + }); expect(handler2).toHaveBeenCalledTimes(1); }); - - it("passes the event argument through to the handler", () => { - const testHandler = vi.fn(); - const mockDb = vi.fn(); - const mockEvent = { - args: { node: "0x123", label: "0x456" }, - } as unknown as IndexingEngineEvent; - - addOnchainEventListener("Resolver:AddrChanged" as EventNames, testHandler); - - const [, callback] = mockPonderOn.mock.calls[0]!; - callback({ context: { db: mockDb } as unknown as Context, event: mockEvent }); - - expect(testHandler).toHaveBeenCalledWith(expect.objectContaining({ event: mockEvent })); - }); }); 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", () => { + 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]!; - 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", () => { - 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]!; - expect(() => callback({ context: mockContext, event: mockEvent })).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("ENSRainbow preconditions (onchain events)", () => { + it("waits for ENSRainbow before executing the handler", async () => { + const { addOnchainEventListener } = await getPonderModule(); + const handler = vi.fn().mockResolvedValue(undefined); + + addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler); + await getRegisteredCallback()({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, + }); + + expect(mockWaitForEnsRainbow).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalled(); + }); + + 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")); + + addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler); + + await expect( + getRegisteredCallback()({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, + }), + ).rejects.toThrow("ENSRainbow not ready"); + + expect(handler).not.toHaveBeenCalled(); + }); + + 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); + + // Register two different onchain event listeners + addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler1); + addOnchainEventListener("Registry:Transfer" as EventNames, handler2); + + // 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); + + // 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("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); + let preconditionResolved = false; + + mockWaitForEnsRainbow.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + preconditionResolved = true; + }); + + addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler); + await getRegisteredCallback()({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, + }); + + expect(preconditionResolved).toBe(true); + expect(handler).toHaveBeenCalled(); + }); + }); + + describe("setup events (no preconditions)", () => { + it("skips ENSRainbow wait for :setup events", async () => { + const { addOnchainEventListener } = await getPonderModule(); + const handler = vi.fn().mockResolvedValue(undefined); + + addOnchainEventListener("Registry:setup" as EventNames, handler); + await getRegisteredCallback()({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, + }); + + expect(mockWaitForEnsRainbow).not.toHaveBeenCalled(); + expect(handler).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(); + } + }); + }); + + describe("event type detection", () => { + it("treats :setup suffix as setup event type", async () => { + const { addOnchainEventListener } = await getPonderModule(); + const setupHandler = vi.fn().mockResolvedValue(undefined); + const onchainHandler = vi.fn().mockResolvedValue(undefined); + + addOnchainEventListener("PublicResolver:setup" as EventNames, setupHandler); + addOnchainEventListener("PublicResolver:AddrChanged" as EventNames, onchainHandler); + + // 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(); + + // 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", + "ETHRegistry:NewResolver", + "BaseRegistrar:NameRegistered", + ]; + + for (const eventName of onchainEvents) { + vi.clearAllMocks(); + vi.resetModules(); + const { addOnchainEventListener: freshAddOnchainEventListener } = await getPonderModule(); + handler.mockClear(); + + freshAddOnchainEventListener(eventName as EventNames, handler); + await getRegisteredCallback()({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, + }); + + expect(mockWaitForEnsRainbow).toHaveBeenCalled(); + expect(handler).toHaveBeenCalled(); + } }); }); }); diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.ts index fca836a2be..84996fdc67 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,128 @@ function buildIndexingEngineContext( }; } +/** + * Event type IDs for indexing handlers. + */ +const EventTypeIds = { + /** + * Setup 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 + * 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; + } +} + +/** + * Prepare for executing the "setup" event handlers. + * + * 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. + */ +async function initializeIndexingSetup(): Promise { + /** + * 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. + */ +} + +/** + * Prepare for executing the "onchain" event handlers. + * + * 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 + * await waitForEnsRainbowToBeReady(); + * ``` + * + * @example Multiple blocking preconditions + * ```ts + * await Promise.all([ + * waitForEnsRainbowToBeReady(), + * waitForAnotherPrecondition(), + * ]); + * ``` + */ +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(eventType: EventTypeId): Promise { + 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; + } + } +} + /** * A thin wrapper around `ponder.on` that allows us to: * - Provide custom context to event handlers. @@ -106,10 +230,13 @@ export function addOnchainEventListener( eventName: EventName, eventHandler: (args: IndexingEngineEventHandlerArgs) => Promise | void, ) { - return ponder.on(eventName, ({ context, event }) => - eventHandler({ + const eventType = buildEventTypeId(eventName); + + return ponder.on(eventName, async ({ context, event }) => { + await eventHandlerPreconditions(eventType); + await eventHandler({ context: buildIndexingEngineContext(context), event, - }), - ); + }); + }); } diff --git a/apps/ensindexer/src/lib/public-config-builder/singleton.ts b/apps/ensindexer/src/lib/public-config-builder/singleton.ts index 18ccfe402f..606fa7ca85 100644 --- a/apps/ensindexer/src/lib/public-config-builder/singleton.ts +++ b/apps/ensindexer/src/lib/public-config-builder/singleton.ts @@ -1,6 +1,4 @@ -import { getENSRainbowApiClient } from "@/lib/ensraibow-api-client"; +import { ensRainbowClient } from "@/lib/ensrainbow/singleton"; import { PublicConfigBuilder } from "@/lib/public-config-builder/public-config-builder"; -const ensRainbowClient = getENSRainbowApiClient(); - export const publicConfigBuilder = new PublicConfigBuilder(ensRainbowClient);