diff --git a/apps/ensapi/src/cache/indexing-status.cache.ts b/apps/ensapi/src/cache/indexing-status.cache.ts index 8ae9562f29..907c88bdd2 100644 --- a/apps/ensapi/src/cache/indexing-status.cache.ts +++ b/apps/ensapi/src/cache/indexing-status.cache.ts @@ -1,5 +1,9 @@ import { EnsNodeMetadataKeys } from "@ensnode/ensdb-sdk"; -import { type CrossChainIndexingStatusSnapshot, SWRCache } from "@ensnode/ensnode-sdk"; +import { + type CrossChainIndexingStatusSnapshot, + IndexingMetadataContextStatusCodes, + SWRCache, +} from "@ensnode/ensnode-sdk"; import { ensDbClient } from "@/lib/ensdb/singleton"; import { lazyProxy } from "@/lib/lazy"; @@ -16,31 +20,33 @@ export const indexingStatusCache = lazyProxy({ fn: async (_cachedResult) => ensDbClient - .getIndexingStatusSnapshot() // get the latest indexing status snapshot - .then((snapshot) => { - if (snapshot === undefined) { - // An indexing status snapshot has not been found in ENSDb yet. + .getIndexingMetadataContext() // get the latest indexing status snapshot + .then((indexingMetadataContext) => { + if ( + indexingMetadataContext.statusCode !== IndexingMetadataContextStatusCodes.Initialized + ) { + // The Indexing Metadata Context has not been initialized in ENSDb yet. // This might happen during application startup, i.e. when ENSDb // has not yet been populated with the first snapshot. // Therefore, throw an error to trigger the subsequent `.catch` handler. - throw new Error("Indexing Status snapshot not found in ENSDb yet."); + throw new Error("Indexing Metadata Context was uninitialized in ENSDb."); } // The indexing status snapshot has been fetched and successfully validated for caching. // Therefore, return it so that this current invocation of `readCache` will: // - Replace the currently cached value (if any) with this new value. // - Return this non-null value. - return snapshot; + return indexingMetadataContext.indexingStatus; }) .catch((error) => { - // Either the indexing status snapshot fetch failed, or the indexing status snapshot was not found in ENSDb yet. + // Indexing Metadata Context was uninitialized in ENSDb. // Therefore, throw an error so that this current invocation of `readCache` will: // - Reject the newly fetched response (if any) such that it won't be cached. // - Return the most recently cached value from prior invocations, or `null` if no prior invocation successfully cached a value. logger.error( error, - `Error occurred while loading Indexing Status snapshot record from ENSNode Metadata table in ENSDb. ` + - `Where clause applied: ("ensIndexerSchemaName" = "${ensDbClient.ensIndexerSchemaName}", "key" = "${EnsNodeMetadataKeys.EnsIndexerIndexingStatus}"). ` + + `Error occurred while loading Indexing Metadata Context record from ENSNode Metadata table in ENSDb. ` + + `Where clause applied: ("ensIndexerSchemaName" = "${ensDbClient.ensIndexerSchemaName}", "key" = "${EnsNodeMetadataKeys.IndexingMetadataContext}"). ` + `The cached indexing status snapshot (if any) will not be updated.`, ); throw error; diff --git a/apps/ensapi/src/config/config.schema.test.ts b/apps/ensapi/src/config/config.schema.test.ts index 4f1e9493c1..72fe560659 100644 --- a/apps/ensapi/src/config/config.schema.test.ts +++ b/apps/ensapi/src/config/config.schema.test.ts @@ -2,12 +2,111 @@ import packageJson from "@/../package.json" with { type: "json" }; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { type ENSIndexerPublicConfig, PluginName } from "@ensnode/ensnode-sdk"; +import { + ChainIndexingStatusIds, + CrossChainIndexingStrategyIds, + deserializeIndexingMetadataContext, + type EnsRainbowPublicConfig, + type IndexingMetadataContextInitialized, + IndexingMetadataContextStatusCodes, + OmnichainIndexingStatusIds, + PluginName, + RangeTypeIds, + type SerializedCrossChainIndexingStatusSnapshot, + type SerializedEnsDbPublicConfig, + type SerializedEnsIndexerPublicConfig, + type SerializedEnsIndexerStackInfo, + type SerializedIndexingMetadataContextInitialized, +} from "@ensnode/ensnode-sdk"; import type { RpcConfig } from "@ensnode/ensnode-sdk/internal"; +import { ensApiVersionInfo } from "@/lib/version-info"; + +const VALID_RPC_URL = "https://eth-sepolia.g.alchemy.com/v2/1234"; + +const ENSDB_PUBLIC_CONFIG = { + versionInfo: { + postgresql: "17.4", + }, +} satisfies SerializedEnsDbPublicConfig; + +const ENSINDEXER_PUBLIC_CONFIG = { + namespace: "mainnet", + ensIndexerSchemaName: "ensindexer_0", + ensRainbowPublicConfig: { + serverLabelSet: { labelSetId: "subgraph", highestLabelSetVersion: 0 }, + versionInfo: { + ensRainbow: packageJson.version, + }, + }, + indexedChainIds: [1], + isSubgraphCompatible: false, + clientLabelSet: { labelSetId: "subgraph", labelSetVersion: 0 }, + plugins: [PluginName.Subgraph], + versionInfo: { + ensDb: packageJson.version, + ensIndexer: packageJson.version, + ensNormalize: ensApiVersionInfo.ensNormalize, + ponder: "0.8.0", + }, +} satisfies SerializedEnsIndexerPublicConfig; + +const ENSRAINBOW_PUBLIC_CONFIG = { + serverLabelSet: { labelSetId: "subgraph", highestLabelSetVersion: 0 }, + versionInfo: { + ensRainbow: packageJson.version, + }, +} satisfies EnsRainbowPublicConfig; + +const INDEXING_STATUS = { + strategy: CrossChainIndexingStrategyIds.Omnichain, + slowestChainIndexingCursor: 1777147427, + snapshotTime: 1777147440, + omnichainSnapshot: { + omnichainStatus: OmnichainIndexingStatusIds.Following, + chains: { + "1": { + chainStatus: ChainIndexingStatusIds.Following, + config: { + rangeType: RangeTypeIds.LeftBounded, + startBlock: { + timestamp: 1489165544, + number: 3327417, + }, + }, + latestIndexedBlock: { + timestamp: 1777147427, + number: 24959286, + }, + latestKnownBlock: { + timestamp: 1777147427, + number: 24959286, + }, + }, + }, + omnichainIndexingCursor: 1777147427, + }, +} satisfies SerializedCrossChainIndexingStatusSnapshot; + +const ENSINDEXER_STACK_INFO = { + ensDb: ENSDB_PUBLIC_CONFIG, + ensIndexer: ENSINDEXER_PUBLIC_CONFIG, + ensRainbow: ENSRAINBOW_PUBLIC_CONFIG, +} satisfies SerializedEnsIndexerStackInfo; + +const INDEXING_METADATA_CONTEXT = { + statusCode: IndexingMetadataContextStatusCodes.Initialized, + indexingStatus: INDEXING_STATUS, + stackInfo: ENSINDEXER_STACK_INFO, +} satisfies SerializedIndexingMetadataContextInitialized; + +const indexingMetadataContextInitialized = deserializeIndexingMetadataContext( + INDEXING_METADATA_CONTEXT, +) as IndexingMetadataContextInitialized; + vi.mock("@/lib/ensdb/singleton", () => ({ ensDbClient: { - getEnsIndexerPublicConfig: vi.fn(async () => ENSINDEXER_PUBLIC_CONFIG), + getIndexingMetadataContext: vi.fn(async () => indexingMetadataContextInitialized), }, })); @@ -22,7 +121,6 @@ import { buildConfigFromEnvironment, buildEnsApiPublicConfig } from "@/config/co import { ENSApi_DEFAULT_PORT } from "@/config/defaults"; import type { EnsApiEnvironment } from "@/config/environment"; import logger from "@/lib/logger"; -import { ensApiVersionInfo } from "@/lib/version-info"; vi.mock("@/lib/logger", () => ({ default: { @@ -31,44 +129,23 @@ vi.mock("@/lib/logger", () => ({ }, })); -const VALID_RPC_URL = "https://eth-sepolia.g.alchemy.com/v2/1234"; - const BASE_ENV = { ENSDB_URL: "postgresql://user:password@localhost:5432/mydb", + ENSINDEXER_SCHEMA_NAME: "ensindexer_0", RPC_URL_1: VALID_RPC_URL, } satisfies EnsApiEnvironment; -const ENSINDEXER_PUBLIC_CONFIG = { - namespace: "mainnet", - ensIndexerSchemaName: "ensindexer_0", - ensRainbowPublicConfig: { - serverLabelSet: { labelSetId: "subgraph", highestLabelSetVersion: 0 }, - versionInfo: { - ensRainbow: packageJson.version, - }, - }, - indexedChainIds: new Set([1]), - isSubgraphCompatible: false, - clientLabelSet: { labelSetId: "subgraph", labelSetVersion: 0 }, - plugins: [PluginName.Subgraph], - versionInfo: { - ensDb: packageJson.version, - ensIndexer: packageJson.version, - ensNormalize: ensApiVersionInfo.ensNormalize, - ponder: "0.8.0", - }, -} satisfies ENSIndexerPublicConfig; - describe("buildConfigFromEnvironment", () => { it("returns a valid config object using environment variables", async () => { + const { ensIndexer: ensIndexerPublicConfig } = indexingMetadataContextInitialized.stackInfo; await expect(buildConfigFromEnvironment(BASE_ENV)).resolves.toStrictEqual({ port: ENSApi_DEFAULT_PORT, ensDbUrl: BASE_ENV.ENSDB_URL, + ensIndexerSchemaName: BASE_ENV.ENSINDEXER_SCHEMA_NAME, theGraphApiKey: undefined, - ensIndexerPublicConfig: ENSINDEXER_PUBLIC_CONFIG, - namespace: ENSINDEXER_PUBLIC_CONFIG.namespace, - ensIndexerSchemaName: ENSINDEXER_PUBLIC_CONFIG.ensIndexerSchemaName, + ensIndexerPublicConfig, + namespace: ensIndexerPublicConfig.namespace, rpcConfigs: new Map([ [ 1, @@ -153,12 +230,13 @@ describe("buildConfigFromEnvironment", () => { describe("buildEnsApiPublicConfig", () => { it("returns a valid ENSApi public config with correct structure", () => { - const mockConfig = { + const { ensIndexer: ensIndexerPublicConfig } = indexingMetadataContextInitialized.stackInfo; + const ensApiConfig = { port: ENSApi_DEFAULT_PORT, ensDbUrl: BASE_ENV.ENSDB_URL, - ensIndexerPublicConfig: ENSINDEXER_PUBLIC_CONFIG, - namespace: ENSINDEXER_PUBLIC_CONFIG.namespace, - ensIndexerSchemaName: ENSINDEXER_PUBLIC_CONFIG.ensIndexerSchemaName, + ensIndexerSchemaName: BASE_ENV.ENSINDEXER_SCHEMA_NAME, + ensIndexerPublicConfig, + namespace: ensIndexerPublicConfig.namespace, rpcConfigs: new Map([ [ 1, @@ -171,7 +249,7 @@ describe("buildEnsApiPublicConfig", () => { referralProgramEditionConfigSetUrl: undefined, }; - const result = buildEnsApiPublicConfig(mockConfig); + const result = buildEnsApiPublicConfig(ensApiConfig); expect(result).toStrictEqual({ versionInfo: ensApiVersionInfo, @@ -179,44 +257,46 @@ describe("buildEnsApiPublicConfig", () => { canFallback: false, reason: "not-subgraph-compatible", }, - ensIndexerPublicConfig: ENSINDEXER_PUBLIC_CONFIG, + ensIndexerPublicConfig, }); }); it("preserves the complete ENSIndexer public config structure", () => { - const mockConfig = { + const { ensIndexer: ensIndexerPublicConfig } = indexingMetadataContextInitialized.stackInfo; + const ensApiConfig = { port: ENSApi_DEFAULT_PORT, ensDbUrl: BASE_ENV.ENSDB_URL, - ensIndexerPublicConfig: ENSINDEXER_PUBLIC_CONFIG, - namespace: ENSINDEXER_PUBLIC_CONFIG.namespace, - ensIndexerSchemaName: ENSINDEXER_PUBLIC_CONFIG.ensIndexerSchemaName, + ensIndexerSchemaName: BASE_ENV.ENSINDEXER_SCHEMA_NAME, + ensIndexerPublicConfig, + namespace: ensIndexerPublicConfig.namespace, rpcConfigs: new Map(), referralProgramEditionConfigSetUrl: undefined, }; - const result = buildEnsApiPublicConfig(mockConfig); + const result = buildEnsApiPublicConfig(ensApiConfig); // Verify that all ENSIndexer public config fields are preserved - expect(result.ensIndexerPublicConfig).toStrictEqual(ENSINDEXER_PUBLIC_CONFIG); + expect(result.ensIndexerPublicConfig).toStrictEqual(ensIndexerPublicConfig); }); it("includes the theGraphFallback and redacts api key", () => { - const mockConfig = { + const { ensIndexer: ensIndexerPublicConfig } = indexingMetadataContextInitialized.stackInfo; + const ensApiConfig = { port: ENSApi_DEFAULT_PORT, ensDbUrl: BASE_ENV.ENSDB_URL, + ensIndexerSchemaName: BASE_ENV.ENSINDEXER_SCHEMA_NAME, ensIndexerPublicConfig: { - ...ENSINDEXER_PUBLIC_CONFIG, + ...ensIndexerPublicConfig, plugins: ["subgraph"], isSubgraphCompatible: true, }, - namespace: ENSINDEXER_PUBLIC_CONFIG.namespace, - ensIndexerSchemaName: ENSINDEXER_PUBLIC_CONFIG.ensIndexerSchemaName, + namespace: ensIndexerPublicConfig.namespace, rpcConfigs: new Map(), referralProgramEditionConfigSetUrl: undefined, theGraphApiKey: "secret-api-key", }; - const result = buildEnsApiPublicConfig(mockConfig); + const result = buildEnsApiPublicConfig(ensApiConfig); expect(result.theGraphFallback.canFallback).toBe(true); // discriminate the type... diff --git a/apps/ensapi/src/config/config.schema.ts b/apps/ensapi/src/config/config.schema.ts index 17576766f2..a1ecb7c370 100644 --- a/apps/ensapi/src/config/config.schema.ts +++ b/apps/ensapi/src/config/config.schema.ts @@ -1,7 +1,7 @@ import pRetry from "p-retry"; import { prettifyError, ZodError, z } from "zod/v4"; -import type { EnsApiPublicConfig } from "@ensnode/ensnode-sdk"; +import { type EnsApiPublicConfig, IndexingMetadataContextStatusCodes } from "@ensnode/ensnode-sdk"; import { buildRpcConfigsFromEnv, canFallbackToTheGraph, @@ -70,13 +70,13 @@ export async function buildConfigFromEnvironment(env: EnsApiEnvironment): Promis // https://github.com/namehash/ensnode/issues/1806 const ensIndexerPublicConfig = await pRetry( async () => { - const config = await ensDbClient.getEnsIndexerPublicConfig(); + const indexingMetadataContext = await ensDbClient.getIndexingMetadataContext(); - if (!config) { - throw new Error("ENSIndexer Public Config not yet available in ENSDb."); + if (indexingMetadataContext.statusCode !== IndexingMetadataContextStatusCodes.Initialized) { + throw new Error("Indexing metadata context is uninitialized in ENSDb."); } - return config; + return indexingMetadataContext.stackInfo.ensIndexer; }, { retries: 13, // This allows for a total of over 1 hour of retries with the exponential backoff strategy diff --git a/apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts b/apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts index b4d3c9cb3e..f65817e085 100644 --- a/apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts +++ b/apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts @@ -6,6 +6,7 @@ import { EnsIndexerIndexingStatusResponseCodes, type EnsIndexerIndexingStatusResponseError, type EnsIndexerIndexingStatusResponseOk, + IndexingMetadataContextStatusCodes, serializeEnsIndexerIndexingStatusResponse, serializeEnsIndexerPublicConfig, } from "@ensnode/ensnode-sdk"; @@ -17,21 +18,21 @@ const app = new Hono(); // include ENSIndexer Public Config endpoint app.get("/config", async (c) => { - const publicConfig = await ensDbClient.getEnsIndexerPublicConfig(); + const indexingMetadataContext = await ensDbClient.getIndexingMetadataContext(); // Invariant: the public config is guaranteed to be available in ENSDb after // application startup. - if (typeof publicConfig === "undefined") { + if (indexingMetadataContext.statusCode !== IndexingMetadataContextStatusCodes.Initialized) { throw new Error("Unreachable: ENSIndexer Public Config is not available in ENSDb"); } // respond with the serialized public config object - return c.json(serializeEnsIndexerPublicConfig(publicConfig)); + return c.json(serializeEnsIndexerPublicConfig(indexingMetadataContext.stackInfo.ensIndexer)); }); app.get("/indexing-status", async (c) => { try { - const crossChainSnapshot = await ensDbClient.getIndexingStatusSnapshot(); + const indexingMetadataContext = await ensDbClient.getIndexingMetadataContext(); // Invariant: the Indexing Status Snapshot is expected to be available in // ENSDb shortly after application startup. There is a possibility that @@ -39,10 +40,11 @@ app.get("/indexing-status", async (c) => { // i.e. when ENSDb has not yet been populated with the first snapshot. // In this case, we treat the snapshot as unavailable and respond with // an error response. - if (typeof crossChainSnapshot === "undefined") { + if (indexingMetadataContext.statusCode !== IndexingMetadataContextStatusCodes.Initialized) { throw new Error("ENSDb does not contain an Indexing Status Snapshot"); } + const crossChainSnapshot = indexingMetadataContext.indexingStatus; const projectedAt = getUnixTime(new Date()); const realtimeProjection = createRealtimeIndexingStatusProjection( crossChainSnapshot, diff --git a/apps/ensindexer/ponder/src/api/index.ts b/apps/ensindexer/ponder/src/api/index.ts index c00d161881..a714ce9e2b 100644 --- a/apps/ensindexer/ponder/src/api/index.ts +++ b/apps/ensindexer/ponder/src/api/index.ts @@ -5,27 +5,10 @@ import { cors } from "hono/cors"; import type { ErrorResponse } from "@ensnode/ensnode-sdk"; -import { migrateEnsNodeSchema } from "@/lib/ensdb/migrate-ensnode-schema"; -import { startEnsDbWriterWorker } from "@/lib/ensdb-writer-worker/singleton"; import { logger } from "@/lib/logger"; import ensNodeApi from "./handlers/ensnode-api"; -// Before starting the ENSDb Writer Worker, we need to ensure that -// the ENSNode Schema in ENSDb is up to date by running any pending migrations. -await migrateEnsNodeSchema().catch((error) => { - logger.error({ - msg: "Failed to initialize ENSNode metadata", - error, - module: "ponder-api", - }); - process.exitCode = 1; - throw error; -}); - -// The entry point for the ENSDb Writer Worker. -startEnsDbWriterWorker(); - const app = new Hono(); // set the X-ENSIndexer-Version header to the current version diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.mock.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.mock.ts index 50da45a6ff..30b9f4814b 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.mock.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.mock.ts @@ -2,85 +2,63 @@ import { vi } from "vitest"; import type { EnsDbWriter } from "@ensnode/ensdb-sdk"; import { + ChainIndexingStatusIds, type CrossChainIndexingStatusSnapshot, CrossChainIndexingStrategyIds, - ENSNamespaceIds, - type EnsIndexerPublicConfig, - type EnsIndexerVersionInfo, - type EnsRainbowPublicConfig, + type IndexingMetadataContextInitialized, + IndexingMetadataContextStatusCodes, OmnichainIndexingStatusIds, type OmnichainIndexingStatusSnapshot, - PluginName, + RangeTypeIds, } from "@ensnode/ensnode-sdk"; -import type { LocalPonderClient } from "@ensnode/ponder-sdk"; import { EnsDbWriterWorker } from "@/lib/ensdb-writer-worker/ensdb-writer-worker"; -import type { IndexingStatusBuilder } from "@/lib/indexing-status-builder"; -import type { PublicConfigBuilder } from "@/lib/public-config-builder"; +import type { IndexingMetadataContextBuilder } from "@/lib/indexing-metadata-context-builder/indexing-metadata-context-builder"; -// Test fixture for EnsRainbowPublicConfig -export const mockEnsRainbowPublicConfig: EnsRainbowPublicConfig = { - serverLabelSet: { labelSetId: "subgraph", highestLabelSetVersion: 0 }, - versionInfo: { - ensRainbow: "1.0.0", - }, -}; +// Test fixtures for IndexingMetadataContext objects -// Test fixture for EnsIndexerVersionInfo -export const mockVersionInfo: EnsIndexerVersionInfo = { - ponder: "0.9.0", - ensDb: "1.0.0", - ensIndexer: "1.0.0", - ensNormalize: "1.10.0", -}; - -// Test fixture for EnsIndexerPublicConfig -export const mockPublicConfig: EnsIndexerPublicConfig = { - ensIndexerSchemaName: "ensindexer_0", - clientLabelSet: { labelSetId: "subgraph", labelSetVersion: 0 }, - ensRainbowPublicConfig: mockEnsRainbowPublicConfig, - indexedChainIds: new Set([1, 8453]), - isSubgraphCompatible: true, - namespace: ENSNamespaceIds.Mainnet, - plugins: [PluginName.Subgraph], - versionInfo: mockVersionInfo, -}; - -// Helper to create mock objects with consistent typing -export function createMockEnsDbWriter( - overrides: Partial> = {}, -): EnsDbWriter { +export function createMockCrossChainSnapshot( + overrides: Partial = {}, +): CrossChainIndexingStatusSnapshot { return { - ...baseEnsDbWriter(), + strategy: CrossChainIndexingStrategyIds.Omnichain, + slowestChainIndexingCursor: 100, + snapshotTime: 200, + omnichainSnapshot: { + omnichainStatus: OmnichainIndexingStatusIds.Following, + omnichainIndexingCursor: 100, + chains: new Map([ + [ + 1, + { + chainStatus: ChainIndexingStatusIds.Following, + latestIndexedBlock: { timestamp: 100, number: 100 }, + latestKnownBlock: { timestamp: 200, number: 200 }, + config: { + rangeType: RangeTypeIds.LeftBounded, + startBlock: { timestamp: 0, number: 0 }, + }, + }, + ], + ]), + }, ...overrides, - } as unknown as EnsDbWriter; -} - -export function baseEnsDbWriter() { - return { - getEnsDbVersion: vi.fn().mockResolvedValue(undefined), - getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(undefined), - getIndexingStatusSnapshot: vi.fn().mockResolvedValue(undefined), - upsertEnsDbVersion: vi.fn().mockResolvedValue(undefined), - upsertEnsIndexerPublicConfig: vi.fn().mockResolvedValue(undefined), - upsertIndexingStatusSnapshot: vi.fn().mockResolvedValue(undefined), }; } -export function createMockPublicConfigBuilder( - resolvedConfig: EnsIndexerPublicConfig = mockPublicConfig, -): PublicConfigBuilder { - return { - getPublicConfig: vi.fn().mockResolvedValue(resolvedConfig), - } as unknown as PublicConfigBuilder; -} - -export function createMockIndexingStatusBuilder( - resolvedSnapshot: OmnichainIndexingStatusSnapshot = createMockOmnichainSnapshot(), -): IndexingStatusBuilder { +export function createMockIndexingMetadataContextInitialized( + overrides: Partial = {}, +): IndexingMetadataContextInitialized { return { - getOmnichainIndexingStatusSnapshot: vi.fn().mockResolvedValue(resolvedSnapshot), - } as unknown as IndexingStatusBuilder; + statusCode: IndexingMetadataContextStatusCodes.Initialized, + indexingStatus: createMockCrossChainSnapshot(), + stackInfo: { + ensDb: { versionInfo: { postgresql: "17.4" } }, + ensIndexer: {} as any, + ensRainbow: {} as any, + }, + ...overrides, + }; } export function createMockOmnichainSnapshot( @@ -94,48 +72,32 @@ export function createMockOmnichainSnapshot( }; } -export function createMockCrossChainSnapshot( - overrides: Partial = {}, -): CrossChainIndexingStatusSnapshot { +export function createMockEnsDbWriter( + overrides: Partial> = {}, +): EnsDbWriter { return { - strategy: CrossChainIndexingStrategyIds.Omnichain, - slowestChainIndexingCursor: 100, - snapshotTime: 200, - omnichainSnapshot: createMockOmnichainSnapshot(), + upsertIndexingMetadataContext: vi.fn().mockResolvedValue(undefined), ...overrides, - }; + } as unknown as EnsDbWriter; } -export function createMockLocalPonderClient( - overrides: { isInDevMode?: boolean } = {}, -): LocalPonderClient { - const isInDevMode = overrides.isInDevMode ?? false; - +export function createMockIndexingMetadataContextBuilder( + resolvedContext: IndexingMetadataContextInitialized = createMockIndexingMetadataContextInitialized(), +): IndexingMetadataContextBuilder { return { - isInDevMode, - } as unknown as LocalPonderClient; + getIndexingMetadataContext: vi.fn().mockResolvedValue(resolvedContext), + } as unknown as IndexingMetadataContextBuilder; } export function createMockEnsDbWriterWorker( overrides: { ensDbClient?: EnsDbWriter; - publicConfigBuilder?: PublicConfigBuilder; - indexingStatusBuilder?: IndexingStatusBuilder; - isInDevMode?: boolean; + indexingMetadataContextBuilder?: IndexingMetadataContextBuilder; } = {}, ) { const ensDbClient = overrides.ensDbClient ?? createMockEnsDbWriter(); - const publicConfigBuilder = overrides.publicConfigBuilder ?? createMockPublicConfigBuilder(); - const indexingStatusBuilder = - overrides.indexingStatusBuilder ?? createMockIndexingStatusBuilder(); - const localPonderClient = createMockLocalPonderClient({ - isInDevMode: overrides.isInDevMode ?? false, - }); + const indexingMetadataContextBuilder = + overrides.indexingMetadataContextBuilder ?? createMockIndexingMetadataContextBuilder(); - return new EnsDbWriterWorker( - ensDbClient, - publicConfigBuilder, - indexingStatusBuilder, - localPonderClient, - ); + return new EnsDbWriterWorker(ensDbClient, indexingMetadataContextBuilder); } diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts index ba0f0bee5b..c7f6e2e986 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts @@ -1,40 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - buildCrossChainIndexingStatusSnapshotOmnichain, - OmnichainIndexingStatusIds, - validateEnsIndexerPublicConfigCompatibility, -} from "@ensnode/ensnode-sdk"; - import "@/lib/__test__/mockLogger"; -import type { IndexingStatusBuilder } from "@/lib/indexing-status-builder/indexing-status-builder"; -import type { PublicConfigBuilder } from "@/lib/public-config-builder/public-config-builder"; - import { - createMockCrossChainSnapshot, createMockEnsDbWriter, createMockEnsDbWriterWorker, - createMockIndexingStatusBuilder, - createMockOmnichainSnapshot, - createMockPublicConfigBuilder, - mockPublicConfig, + createMockIndexingMetadataContextBuilder, + createMockIndexingMetadataContextInitialized, } from "./ensdb-writer-worker.mock"; -vi.mock("@ensnode/ensnode-sdk", async () => { - const actual = await vi.importActual("@ensnode/ensnode-sdk"); - - return { - ...actual, - validateEnsIndexerPublicConfigCompatibility: vi.fn(), - buildCrossChainIndexingStatusSnapshotOmnichain: vi.fn(), - }; -}); - -vi.mock("p-retry", () => ({ - default: vi.fn((fn) => fn()), -})); - describe("EnsDbWriterWorker", () => { beforeEach(() => { vi.useFakeTimers(); @@ -46,87 +20,26 @@ describe("EnsDbWriterWorker", () => { }); describe("run() - worker initialization", () => { - it("upserts version, config, and starts interval for indexing status snapshots", async () => { + it("starts the interval for updating indexing metadata context", async () => { // arrange - const omnichainSnapshot = createMockOmnichainSnapshot(); - const snapshot = createMockCrossChainSnapshot({ omnichainSnapshot }); - vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(snapshot); + const context = createMockIndexingMetadataContextInitialized(); + const indexingMetadataContextBuilder = createMockIndexingMetadataContextBuilder(context); const ensDbClient = createMockEnsDbWriter(); const worker = createMockEnsDbWriterWorker({ ensDbClient, - indexingStatusBuilder: createMockIndexingStatusBuilder(omnichainSnapshot), + indexingMetadataContextBuilder, }); // act await worker.run(); - // assert - verify initial upserts happened - expect(ensDbClient.upsertEnsDbVersion).toHaveBeenCalledWith( - mockPublicConfig.versionInfo.ensDb, - ); - expect(ensDbClient.upsertEnsIndexerPublicConfig).toHaveBeenCalledWith(mockPublicConfig); - // advance time to trigger interval await vi.advanceTimersByTimeAsync(1000); - // assert - snapshot should be upserted - expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenCalledWith(snapshot); - expect(buildCrossChainIndexingStatusSnapshotOmnichain).toHaveBeenCalledWith( - omnichainSnapshot, - expect.any(Number), - ); - - // cleanup - worker.stop(); - }); - - it("throws when stored config is incompatible", async () => { - // arrange - vi.mocked(validateEnsIndexerPublicConfigCompatibility).mockImplementation(() => { - throw new Error("incompatible"); - }); - - const ensDbClient = createMockEnsDbWriter({ - getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(mockPublicConfig), - }); - const worker = createMockEnsDbWriterWorker({ - ensDbClient, - publicConfigBuilder: createMockPublicConfigBuilder(mockPublicConfig), - }); - - // act & assert - await expect(worker.run()).rejects.toThrow("incompatible"); - expect(ensDbClient.upsertEnsDbVersion).not.toHaveBeenCalled(); - }); - - it("skips config validation when in dev mode", async () => { - // arrange - vi.mocked(validateEnsIndexerPublicConfigCompatibility).mockImplementation(() => { - throw new Error("incompatible"); - }); - - const snapshot = createMockCrossChainSnapshot(); - vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(snapshot); - - const ensDbClient = createMockEnsDbWriter({ - getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(mockPublicConfig), - }); - const worker = createMockEnsDbWriterWorker({ - ensDbClient, - publicConfigBuilder: createMockPublicConfigBuilder(mockPublicConfig), - isInDevMode: true, - }); - - // act - should not throw even though configs are incompatible - await worker.run(); - - // assert - validation should not have been called - expect(validateEnsIndexerPublicConfigCompatibility).not.toHaveBeenCalled(); - expect(ensDbClient.upsertEnsDbVersion).toHaveBeenCalledWith( - mockPublicConfig.versionInfo.ensDb, - ); - expect(ensDbClient.upsertEnsIndexerPublicConfig).toHaveBeenCalledWith(mockPublicConfig); + // assert - worker delegates to indexingMetadataContextBuilder + expect(indexingMetadataContextBuilder.getIndexingMetadataContext).toHaveBeenCalled(); + expect(ensDbClient.upsertIndexingMetadataContext).toHaveBeenCalledWith(context); // cleanup worker.stop(); @@ -145,90 +58,20 @@ describe("EnsDbWriterWorker", () => { // cleanup worker.stop(); }); - - it("throws error when config fetch fails", async () => { - // arrange - const publicConfigBuilder = { - getPublicConfig: vi.fn().mockRejectedValue(new Error("Network failure")), - } as unknown as PublicConfigBuilder; - const ensDbClient = createMockEnsDbWriter(); - const worker = createMockEnsDbWriterWorker({ ensDbClient, publicConfigBuilder }); - - // act & assert - await expect(worker.run()).rejects.toThrow("Network failure"); - expect(publicConfigBuilder.getPublicConfig).toHaveBeenCalledTimes(1); - expect(ensDbClient.upsertEnsDbVersion).not.toHaveBeenCalled(); - }); - - it("throws error when stored config fetch fails", async () => { - // arrange - const ensDbClient = createMockEnsDbWriter({ - getEnsIndexerPublicConfig: vi.fn().mockRejectedValue(new Error("Database connection lost")), - }); - const worker = createMockEnsDbWriterWorker({ ensDbClient }); - - // act & assert - await expect(worker.run()).rejects.toThrow("Database connection lost"); - expect(ensDbClient.upsertEnsDbVersion).not.toHaveBeenCalled(); - }); - - it("fetches stored and in-memory configs concurrently", async () => { - // arrange - vi.mocked(validateEnsIndexerPublicConfigCompatibility).mockImplementation(() => {}); - - const ensDbClient = createMockEnsDbWriter({ - getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(mockPublicConfig), - }); - const publicConfigBuilder = createMockPublicConfigBuilder(mockPublicConfig); - const worker = createMockEnsDbWriterWorker({ - ensDbClient, - publicConfigBuilder, - }); - - // act - await worker.run(); - - // assert - both should have been called (concurrent execution via Promise.all) - expect(ensDbClient.getEnsIndexerPublicConfig).toHaveBeenCalledTimes(1); - expect(publicConfigBuilder.getPublicConfig).toHaveBeenCalledTimes(1); - - // cleanup - worker.stop(); - }); - - it("calls pRetry for config fetch with retry logic", async () => { - // arrange - pRetry is mocked to call fn directly - const snapshot = createMockCrossChainSnapshot(); - vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(snapshot); - - const ensDbClient = createMockEnsDbWriter(); - const publicConfigBuilder = createMockPublicConfigBuilder(); - const worker = createMockEnsDbWriterWorker({ ensDbClient, publicConfigBuilder }); - - // act - await worker.run(); - - // assert - config should be called once (pRetry is mocked) - expect(publicConfigBuilder.getPublicConfig).toHaveBeenCalledTimes(1); - expect(ensDbClient.upsertEnsIndexerPublicConfig).toHaveBeenCalledWith(mockPublicConfig); - - // cleanup - worker.stop(); - }); }); describe("stop() - worker termination", () => { it("stops the interval when stop() is called", async () => { // arrange - const upsertIndexingStatusSnapshot = vi.fn().mockResolvedValue(undefined); - const ensDbClient = createMockEnsDbWriter({ upsertIndexingStatusSnapshot }); + const upsertIndexingMetadataContext = vi.fn().mockResolvedValue(undefined); + const ensDbClient = createMockEnsDbWriter({ upsertIndexingMetadataContext }); const worker = createMockEnsDbWriterWorker({ ensDbClient }); // act await worker.run(); await vi.advanceTimersByTimeAsync(1000); - const callCountBeforeStop = upsertIndexingStatusSnapshot.mock.calls.length; + const callCountBeforeStop = upsertIndexingMetadataContext.mock.calls.length; worker.stop(); @@ -236,7 +79,7 @@ describe("EnsDbWriterWorker", () => { await vi.advanceTimersByTimeAsync(2000); // assert - no more calls after stop - expect(upsertIndexingStatusSnapshot).toHaveBeenCalledTimes(callCountBeforeStop); + expect(upsertIndexingMetadataContext).toHaveBeenCalledTimes(callCountBeforeStop); }); }); @@ -262,103 +105,146 @@ describe("EnsDbWriterWorker", () => { }); }); - describe("interval behavior - snapshot upserts", () => { - it("continues upserting after snapshot validation errors", async () => { + describe("interval behavior - updateIndexingMetadataContext", () => { + it("calls getIndexingMetadataContext and upserts on each tick", async () => { // arrange - const unstartedSnapshot = createMockOmnichainSnapshot({ - omnichainStatus: OmnichainIndexingStatusIds.Unstarted, - }); - const validSnapshot = createMockOmnichainSnapshot({ - omnichainIndexingCursor: 200, - }); - const crossChainSnapshot = createMockCrossChainSnapshot({ - slowestChainIndexingCursor: 200, - snapshotTime: 300, - omnichainSnapshot: validSnapshot, - }); + const context1 = createMockIndexingMetadataContextInitialized(); + const context2 = createMockIndexingMetadataContextInitialized(); - vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(crossChainSnapshot); + const indexingMetadataContextBuilder = createMockIndexingMetadataContextBuilder(context1); + (indexingMetadataContextBuilder.getIndexingMetadataContext as any) + .mockResolvedValueOnce(context1) + .mockResolvedValueOnce(context2); const ensDbClient = createMockEnsDbWriter(); - const indexingStatusBuilder = { - getOmnichainIndexingStatusSnapshot: vi - .fn() - .mockResolvedValueOnce(unstartedSnapshot) - .mockResolvedValueOnce(validSnapshot), - } as unknown as IndexingStatusBuilder; - const worker = createMockEnsDbWriterWorker({ ensDbClient, indexingStatusBuilder }); + const worker = createMockEnsDbWriterWorker({ + ensDbClient, + indexingMetadataContextBuilder, + }); - // act - run returns immediately + // act await worker.run(); - // first interval tick - should error but not throw + // first tick await vi.advanceTimersByTimeAsync(1000); + expect(indexingMetadataContextBuilder.getIndexingMetadataContext).toHaveBeenCalledTimes(1); + expect(ensDbClient.upsertIndexingMetadataContext).toHaveBeenCalledWith(context1); - // second interval tick - should succeed + // second tick await vi.advanceTimersByTimeAsync(1000); - - // assert - expect(indexingStatusBuilder.getOmnichainIndexingStatusSnapshot).toHaveBeenCalledTimes(2); - expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenCalledTimes(1); - expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenCalledWith(crossChainSnapshot); + expect(indexingMetadataContextBuilder.getIndexingMetadataContext).toHaveBeenCalledTimes(2); + expect(ensDbClient.upsertIndexingMetadataContext).toHaveBeenCalledWith(context2); // cleanup worker.stop(); }); - it("recovers from errors and continues upserting snapshots", async () => { + it("recovers from getIndexingMetadataContext errors between ticks", async () => { // arrange - const snapshot1 = createMockOmnichainSnapshot({ omnichainIndexingCursor: 100 }); - const snapshot2 = createMockOmnichainSnapshot({ omnichainIndexingCursor: 200 }); + const context = createMockIndexingMetadataContextInitialized(); + const indexingMetadataContextBuilder = createMockIndexingMetadataContextBuilder(context); + (indexingMetadataContextBuilder.getIndexingMetadataContext as any) + .mockResolvedValueOnce(context) + .mockRejectedValueOnce(new Error("Builder error")) + .mockResolvedValueOnce(context); - const crossChainSnapshot1 = createMockCrossChainSnapshot({ - slowestChainIndexingCursor: 100, - snapshotTime: 1000, - omnichainSnapshot: snapshot1, - }); - const crossChainSnapshot2 = createMockCrossChainSnapshot({ - slowestChainIndexingCursor: 200, - snapshotTime: 2000, - omnichainSnapshot: snapshot2, + const ensDbClient = createMockEnsDbWriter(); + const worker = createMockEnsDbWriterWorker({ + ensDbClient, + indexingMetadataContextBuilder, }); - vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain) - .mockReturnValueOnce(crossChainSnapshot1) - .mockReturnValueOnce(crossChainSnapshot2) - .mockReturnValueOnce(crossChainSnapshot2); + // act + await worker.run(); + + // first tick - succeeds + await vi.advanceTimersByTimeAsync(1000); + expect(ensDbClient.upsertIndexingMetadataContext).toHaveBeenCalledTimes(1); + + // second tick - builder error, the catch block rethrows + // setInterval keeps running despite the unhandled rejection + const handler = vi.fn(); + process.on("unhandledRejection", handler); + await vi.advanceTimersByTimeAsync(1000); + process.removeListener("unhandledRejection", handler); + expect(handler).toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(ensDbClient.upsertIndexingMetadataContext).toHaveBeenCalledTimes(1); // no new upsert + + // third tick - succeeds again + await vi.advanceTimersByTimeAsync(1000); + expect(ensDbClient.upsertIndexingMetadataContext).toHaveBeenCalledTimes(2); + + // cleanup + worker.stop(); + }); + + it("recovers from upsertIndexingMetadataContext errors between ticks", async () => { + // arrange + const context = createMockIndexingMetadataContextInitialized(); + const indexingMetadataContextBuilder = createMockIndexingMetadataContextBuilder(context); const ensDbClient = createMockEnsDbWriter({ - upsertIndexingStatusSnapshot: vi + upsertIndexingMetadataContext: vi .fn() .mockResolvedValueOnce(undefined) .mockRejectedValueOnce(new Error("DB error")) .mockResolvedValueOnce(undefined), }); - const indexingStatusBuilder = { - getOmnichainIndexingStatusSnapshot: vi - .fn() - .mockResolvedValueOnce(snapshot1) - .mockResolvedValueOnce(snapshot2) - .mockResolvedValueOnce(snapshot2), - } as unknown as IndexingStatusBuilder; - const worker = createMockEnsDbWriterWorker({ ensDbClient, indexingStatusBuilder }); + + const worker = createMockEnsDbWriterWorker({ + ensDbClient, + indexingMetadataContextBuilder, + }); // act await worker.run(); // first tick - succeeds await vi.advanceTimersByTimeAsync(1000); - expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenCalledWith(crossChainSnapshot1); + expect(ensDbClient.upsertIndexingMetadataContext).toHaveBeenCalledTimes(1); - // second tick - fails with DB error, but continues + // second tick - DB error, the catch block rethrows + const handler = vi.fn(); + process.on("unhandledRejection", handler); await vi.advanceTimersByTimeAsync(1000); - expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenLastCalledWith( - crossChainSnapshot2, - ); + process.removeListener("unhandledRejection", handler); + expect(handler).toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(ensDbClient.upsertIndexingMetadataContext).toHaveBeenCalledTimes(2); // third tick - succeeds again await vi.advanceTimersByTimeAsync(1000); - expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenCalledTimes(3); + expect(ensDbClient.upsertIndexingMetadataContext).toHaveBeenCalledTimes(3); + + // cleanup + worker.stop(); + }); + + it("sets process.exitCode on error", async () => { + // arrange + const indexingMetadataContextBuilder = createMockIndexingMetadataContextBuilder(); + (indexingMetadataContextBuilder.getIndexingMetadataContext as any).mockRejectedValue( + new Error("Fatal error"), + ); + + const worker = createMockEnsDbWriterWorker({ indexingMetadataContextBuilder }); + + // reset exitCode before test + process.exitCode = undefined; + + // act - suppress unhandled rejection from the setInterval callback + const handler = vi.fn(); + process.on("unhandledRejection", handler); + + await worker.run(); + await vi.advanceTimersByTimeAsync(1000); + + process.removeListener("unhandledRejection", handler); + + // assert + expect(handler).toHaveBeenCalled(); + expect(process.exitCode).toBe(1); // cleanup worker.stop(); diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts index 38aa6d12de..e042c1bfdd 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts @@ -1,21 +1,10 @@ -import { getUnixTime, secondsToMilliseconds } from "date-fns"; +import { secondsToMilliseconds } from "date-fns"; import type { Duration } from "enssdk"; -import pRetry from "p-retry"; import type { EnsDbWriter } from "@ensnode/ensdb-sdk"; -import { - buildCrossChainIndexingStatusSnapshotOmnichain, - type CrossChainIndexingStatusSnapshot, - type EnsIndexerPublicConfig, - OmnichainIndexingStatusIds, - type OmnichainIndexingStatusSnapshot, - validateEnsIndexerPublicConfigCompatibility, -} from "@ensnode/ensnode-sdk"; -import type { LocalPonderClient } from "@ensnode/ponder-sdk"; -import type { IndexingStatusBuilder } from "@/lib/indexing-status-builder/indexing-status-builder"; +import type { IndexingMetadataContextBuilder } from "@/lib/indexing-metadata-context-builder/indexing-metadata-context-builder"; import { logger } from "@/lib/logger"; -import type { PublicConfigBuilder } from "@/lib/public-config-builder/public-config-builder"; /** * Interval in seconds between two consecutive attempts to upsert @@ -26,10 +15,8 @@ const INDEXING_STATUS_RECORD_UPDATE_INTERVAL: Duration = 1; /** * ENSDb Writer Worker * - * A worker responsible for writing ENSIndexer-related metadata into ENSDb, including: - * - ENSDb version - * - ENSIndexer Public Config - * - ENSIndexer Indexing Status Snapshots + * A worker responsible for writing the current {@link CrossChainIndexingStatusSnapshot} into + * the {@link IndexingMetadataContext} record in ENSDb. */ export class EnsDbWriterWorker { /** @@ -43,53 +30,29 @@ export class EnsDbWriterWorker { private ensDbClient: EnsDbWriter; /** - * Indexing Status Builder instance used by the worker to read ENSIndexer Indexing Status. + * Indexing Metadata Context Builder instance used by the worker to read {@link IndexingMetadataContext}. */ - private indexingStatusBuilder: IndexingStatusBuilder; - - /** - * ENSIndexer Public Config Builder instance used by the worker to read ENSIndexer Public Config. - */ - private publicConfigBuilder: PublicConfigBuilder; - - /** - * Local Ponder Client instance - * - * Used to get local Ponder app command. - */ - private localPonderClient: LocalPonderClient; + private indexingMetadataContextBuilder: IndexingMetadataContextBuilder; /** * @param ensDbClient ENSDb Writer instance used by the worker to interact with ENSDb. - * @param publicConfigBuilder ENSIndexer Public Config Builder instance used by the worker to read ENSIndexer Public Config. - * @param indexingStatusBuilder Indexing Status Builder instance used by the worker to read ENSIndexer Indexing Status. - * @param localPonderClient Local Ponder Client instance, used to get local Ponder app command. + * @param indexingMetadataContextBuilder Indexing Metadata Context Builder instance used by the worker to read {@link IndexingMetadataContext}. */ constructor( ensDbClient: EnsDbWriter, - publicConfigBuilder: PublicConfigBuilder, - indexingStatusBuilder: IndexingStatusBuilder, - localPonderClient: LocalPonderClient, + indexingMetadataContextBuilder: IndexingMetadataContextBuilder, ) { this.ensDbClient = ensDbClient; - this.publicConfigBuilder = publicConfigBuilder; - this.indexingStatusBuilder = indexingStatusBuilder; - this.localPonderClient = localPonderClient; + this.indexingMetadataContextBuilder = indexingMetadataContextBuilder; } /** * Run the ENSDb Writer Worker * - * The worker performs the following tasks: - * 1) A single attempt to upsert ENSDb version into ENSDb. - * 2) A single attempt to upsert serialized representation of - * {@link EnsIndexerPublicConfig} into ENSDb. - * 3) A recurring attempt to upsert serialized representation of - * {@link CrossChainIndexingStatusSnapshot} into ENSDb. + * The worker performs a recurring upsert of + * the {@link IndexingMetadataContext} record into ENSDb. * - * @throws Error if the worker is already running, or - * if the in-memory ENSIndexer Public Config could not be fetched, or - * if the in-memory ENSIndexer Public Config is incompatible with the stored config in ENSDb. + * @throws Error if the worker is already running. */ public async run(): Promise { // Do not allow multiple concurrent runs of the worker @@ -97,32 +60,9 @@ export class EnsDbWriterWorker { throw new Error("EnsDbWriterWorker is already running"); } - // Fetch data required for task 1 and task 2. - const inMemoryConfig = await this.getValidatedEnsIndexerPublicConfig(); - - // Task 1: upsert ENSDb version into ENSDb. - logger.debug({ msg: "Upserting ENSDb version", module: "EnsDbWriterWorker" }); - await this.ensDbClient.upsertEnsDbVersion(inMemoryConfig.versionInfo.ensDb); - logger.info({ - msg: "Upserted ENSDb version", - ensDbVersion: inMemoryConfig.versionInfo.ensDb, - module: "EnsDbWriterWorker", - }); - - // Task 2: upsert of EnsIndexerPublicConfig into ENSDb. - logger.debug({ - msg: "Upserting ENSIndexer public config", - module: "EnsDbWriterWorker", - }); - await this.ensDbClient.upsertEnsIndexerPublicConfig(inMemoryConfig); - logger.info({ - msg: "Upserted ENSIndexer public config", - module: "EnsDbWriterWorker", - }); - - // Task 3: recurring upsert of Indexing Status Snapshot into ENSDb. + // Recurring update of the Indexing Metadata Context record in ENSDb. this.indexingStatusInterval = setInterval( - () => this.upsertIndexingStatusSnapshot(), + () => this.updateIndexingMetadataContext(), secondsToMilliseconds(INDEXING_STATUS_RECORD_UPDATE_INTERVAL), ); } @@ -147,155 +87,29 @@ export class EnsDbWriterWorker { } /** - * Get validated ENSIndexer Public Config object for the ENSDb Writer Worker. - * - * The function retrieves the ENSIndexer Public Config object from both: - * - stored config in ENSDb, if available, and - * - in-memory config from ENSIndexer Client. - * - * If a stored config exists **and** the local Ponder app is **not** in dev - * mode, the in-memory config is validated for compatibility against the - * stored one. Validation is skipped if the local Ponder app is in dev mode, - * allowing to override the stored config in ENSDb with the current in-memory - * config, without having to keep them compatible. - * - * @returns The in-memory config when validation passes or no stored config - * exists. - * @throws Error if either fetch fails, or if the in-memory config is - * incompatible with the stored config. - */ - private async getValidatedEnsIndexerPublicConfig(): Promise { - /** - * Fetch the in-memory config with retries, to handle potential transient errors - * in the ENSIndexer Public Config Builder (e.g. due to network issues). - * If the fetch fails after the defined number of retries, the error - * will be thrown and the worker will not start, as the ENSIndexer Public Config - * is a critical dependency for the worker's tasks. - */ - const configFetchRetries = 3; - - logger.debug({ - msg: "Fetching ENSIndexer public config", - retries: configFetchRetries, - module: "EnsDbWriterWorker", - }); - - const inMemoryConfigPromise = pRetry(() => this.publicConfigBuilder.getPublicConfig(), { - retries: configFetchRetries, - onFailedAttempt: ({ attemptNumber, retriesLeft }) => { - logger.warn({ - msg: "Config fetch attempt failed", - attempt: attemptNumber, - retriesLeft, - module: "EnsDbWriterWorker", - }); - }, - }); - - let storedConfig: EnsIndexerPublicConfig | undefined; - let inMemoryConfig: EnsIndexerPublicConfig; - - try { - [storedConfig, inMemoryConfig] = await Promise.all([ - this.ensDbClient.getEnsIndexerPublicConfig(), - inMemoryConfigPromise, - ]); - logger.info({ - msg: "Fetched ENSIndexer public config", - module: "EnsDbWriterWorker", - config: inMemoryConfig, - }); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - - logger.error({ - msg: "Failed to fetch ENSIndexer public config", - error, - module: "EnsDbWriterWorker", - }); - - // Throw the error to terminate the ENSIndexer process due to failed fetch of critical dependency - throw new Error(errorMessage, { - cause: error, - }); - } - - // Validate in-memory config object compatibility with the stored one, - // if the stored one is available. - // The validation is skipped if the local Ponder app is running in dev mode. - // This is to improve the development experience during ENSIndexer - // development, by allowing to override the stored config in ENSDb with - // the current in-memory config, without having to keep them compatible. - if (storedConfig && !this.localPonderClient.isInDevMode) { - try { - validateEnsIndexerPublicConfigCompatibility(storedConfig, inMemoryConfig); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - - logger.error({ - msg: "In-memory config incompatible with stored config", - error, - module: "EnsDbWriterWorker", - }); - - // Throw the error to terminate the ENSIndexer process due to - // found config incompatibility - throw new Error(errorMessage, { - cause: error, - }); - } - } - - return inMemoryConfig; - } - - /** - * Upsert the current Indexing Status Snapshot into ENSDb. + * Update the current Indexing Status Snapshot into ENSDb. * * This method is called by the scheduler at regular intervals. * Errors are logged but not thrown, to keep the worker running. */ - private async upsertIndexingStatusSnapshot(): Promise { + private async updateIndexingMetadataContext(): Promise { try { - // get system timestamp for the current iteration - const snapshotTime = getUnixTime(new Date()); - - const omnichainSnapshot = await this.getValidatedIndexingStatusSnapshot(); - - const crossChainSnapshot = buildCrossChainIndexingStatusSnapshotOmnichain( - omnichainSnapshot, - snapshotTime, - ); + const indexingMetadataContext = + await this.indexingMetadataContextBuilder.getIndexingMetadataContext(); - await this.ensDbClient.upsertIndexingStatusSnapshot(crossChainSnapshot); + await this.ensDbClient.upsertIndexingMetadataContext(indexingMetadataContext); } catch (error) { + // If any error happens during the update of indexing metadata context record in ENSDb, + // we want to log the error and exit the process with a non-zero exit code, + // since this is a critical failure that prevents the ENSIndexer instance from functioning properly. logger.error({ - msg: "Failed to upsert indexing status snapshot", - error, + msg: "Failed to update indexing metadata context record in ENSDb", module: "EnsDbWriterWorker", + error, }); - // Do not throw the error, as failure to retrieve the Indexing Status - // should not cause the ENSDb Writer Worker to stop functioning. - } - } - - /** - * Get validated Omnichain Indexing Status Snapshot - * - * @returns Validated Omnichain Indexing Status Snapshot. - * @throws Error if the Omnichain Indexing Status is not in expected status yet. - */ - private async getValidatedIndexingStatusSnapshot(): Promise { - const omnichainSnapshot = await this.indexingStatusBuilder.getOmnichainIndexingStatusSnapshot(); - // It only makes sense to write Indexing Status Snapshots into ENSDb once - // the indexing process has started, as before that there is no meaningful - // status to record. - // Invariant: the Omnichain Status must indicate that indexing has started already. - if (omnichainSnapshot.omnichainStatus === OmnichainIndexingStatusIds.Unstarted) { - throw new Error("Omnichain Status must not be 'Unstarted'."); + process.exitCode = 1; + throw error; } - - return omnichainSnapshot; } } diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts index 22fd6a5e9b..66e10d9039 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts @@ -1,8 +1,6 @@ import { ensDbClient } from "@/lib/ensdb/singleton"; -import { indexingStatusBuilder } from "@/lib/indexing-status-builder/singleton"; -import { localPonderClient } from "@/lib/local-ponder-client"; +import { indexingMetadataContextBuilder } from "@/lib/indexing-metadata-context-builder/singleton"; import { logger } from "@/lib/logger"; -import { publicConfigBuilder } from "@/lib/public-config-builder/singleton"; import { EnsDbWriterWorker } from "./ensdb-writer-worker"; @@ -22,12 +20,7 @@ export function startEnsDbWriterWorker() { throw new Error("EnsDbWriterWorker has already been initialized"); } - ensDbWriterWorker = new EnsDbWriterWorker( - ensDbClient, - publicConfigBuilder, - indexingStatusBuilder, - localPonderClient, - ); + ensDbWriterWorker = new EnsDbWriterWorker(ensDbClient, indexingMetadataContextBuilder); ensDbWriterWorker .run() diff --git a/apps/ensindexer/src/lib/ensrainbow/singleton.ts b/apps/ensindexer/src/lib/ensrainbow/singleton.ts index 331d62e6a9..5c4ba97d4a 100644 --- a/apps/ensindexer/src/lib/ensrainbow/singleton.ts +++ b/apps/ensindexer/src/lib/ensrainbow/singleton.ts @@ -24,6 +24,69 @@ export const ensRainbowClient = new EnsRainbowApiClient({ clientLabelSet, }); +/** + * Cached promise for waiting for ENSRainbow to be healthy. + * + * This ensures that multiple concurrent calls to + * {@link waitForEnsRainbowToBeHealthy} will share the same underlying promise + * in order to use the same retry sequence. + */ +let waitForEnsRainbowToBeHealthyPromise: Promise | undefined; + +/** + * Wait for ENSRainbow to be healthy + * + * Blocks execution until the ENSRainbow instance is healthy. That is, + * the ENSRainbow instance is responsive and able to serve basic requests successfully. + * + * We need to wait for ENSRainbow to be healthy before attempting to fetch + * the {@link EnsRainbowPublicConfig} from ENSRainbow. + * + * @throws When ENSRainbow fails to become healthy after all configured retry attempts. + * This error will trigger termination of the ENSIndexer process. + */ +export function waitForEnsRainbowToBeHealthy(): Promise { + if (waitForEnsRainbowToBeHealthyPromise) { + return waitForEnsRainbowToBeHealthyPromise; + } + + logger.info({ + msg: `Waiting for ENSRainbow instance to be healthy`, + ensRainbowInstance: ensRainbowUrl.href, + }); + + waitForEnsRainbowToBeHealthyPromise = pRetry(async () => ensRainbowClient.health(), { + retries: 3, + onFailedAttempt: ({ attemptNumber, retriesLeft }) => { + logger.warn({ + msg: `ENSRainbow health check failed`, + attempt: attemptNumber, + retriesLeft, + ensRainbowInstance: ensRainbowUrl.href, + advice: `This might be a transient issue after ENSNode deployment. If this persists, it might indicate an issue with the ENSRainbow instance or connectivity to it.`, + }); + }, + }) + .then(() => { + logger.info({ + msg: `ENSRainbow instance is healthy`, + ensRainbowInstance: ensRainbowUrl.href, + }); + }) + .catch((error) => { + logger.error({ + msg: `ENSRainbow health check failed after multiple attempts`, + error, + ensRainbowInstance: ensRainbowUrl.href, + }); + + // Throw the error to terminate the ENSIndexer process due to the failed health check of a critical dependency + throw error; + }); + + return waitForEnsRainbowToBeHealthyPromise; +} + /** * Cached promise for waiting for ENSRainbow to be ready. * @@ -60,12 +123,11 @@ export function waitForEnsRainbowToBeReady(): Promise { 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 }) => { + onFailedAttempt: ({ attemptNumber, retriesLeft }) => { logger.warn({ msg: `ENSRainbow health check failed`, attempt: attemptNumber, retriesLeft, - error: retriesLeft === 0 ? error : undefined, ensRainbowInstance: ensRainbowUrl.href, advice: `This might be due to ENSRainbow having a cold start, which can take 30+ minutes.`, }); @@ -78,8 +140,6 @@ export function waitForEnsRainbowToBeReady(): Promise { }); }) .catch((error) => { - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - logger.error({ msg: `ENSRainbow health check failed after multiple attempts`, error, @@ -87,9 +147,7 @@ export function waitForEnsRainbowToBeReady(): Promise { }); // 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, - }); + throw error; }); return waitForEnsRainbowToBeReadyPromise; diff --git a/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts b/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts new file mode 100644 index 0000000000..bfe6e2bfc6 --- /dev/null +++ b/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts @@ -0,0 +1,120 @@ +/** + * This module defines the initialization logic for the onchain event handlers of + * the Ponder indexing engine executed in an ENSIndexer instance. + * + * Onchain event handlers are executed by Ponder once per ENSIndexer instance lifetime, + * at the start of the omnichain indexing process. + * + * ENSIndexer startup sequence executed by Ponder: + * 1. Connect to the database and initialize required database objects. + * 2. Start the omnichain indexing process. + * 3. Check whether Ponder Checkpoints are already initialized. + * 4. If not: + * a) Execute setup handlers, if any were registered. + * b) Initialize Ponder Checkpoints. + * 5. a) Make Ponder HTTP API usable. + * 5. b) Start executing "onchain" event handlers. + * + * Step 4 is skipped on ENSIndexer instance restart if Ponder Checkpoints were + * already initialized in a previous run. Also, step 4 a) is skipped if + * no setup handlers were registered. Therefore, we don't implement any init + * logic for setup handlers. Instead, to guarantee that any necessary initialization logic + * is executed each time the ENSIndexer instance starts, we implement the init indexing onchain events logic + * in this module, which is executed in step 5 b) and is guaranteed to be executed on every ENSIndexer instance startup, + * regardless of the state of Ponder Checkpoints or whether any setup handlers were registered. + */ + +import { migrateEnsNodeSchema } from "@/lib/ensdb/migrate-ensnode-schema"; +import { ensDbClient } from "@/lib/ensdb/singleton"; +import { startEnsDbWriterWorker } from "@/lib/ensdb-writer-worker/singleton"; +import { + waitForEnsRainbowToBeHealthy, + waitForEnsRainbowToBeReady, +} from "@/lib/ensrainbow/singleton"; +import { indexingMetadataContextBuilder } from "@/lib/indexing-metadata-context-builder/singleton"; +import { logger } from "@/lib/logger"; + +async function upsertIndexingMetadataContextRecord(): Promise { + const indexingMetadataContext = await indexingMetadataContextBuilder.getIndexingMetadataContext(); + + logger.info({ + msg: `Upserting Indexing Metadata Context Initialized`, + }); + logger.debug({ + msg: `Indexing Metadata Context`, + indexingStatus: indexingMetadataContext.indexingStatus, + stackInfo: indexingMetadataContext.stackInfo, + }); + + await ensDbClient.upsertIndexingMetadataContext(indexingMetadataContext); + + logger.info({ + msg: `Successfully upserted Indexing Metadata Context Initialized`, + }); +} + +/** + * 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(), + * ]); + * ``` + * + * Goals of this function: + * 1. Make ENSDb instance "ready" for ENSDb clients to use. + */ +export async function initIndexingOnchainEvents(): Promise { + try { + // TODO: wait for ENSDb instance to be healthy before running any queries against it. + + // Ensure the ENSNode Schema in ENSDb is up to date by running any pending migrations. + await migrateEnsNodeSchema(); + + // Before calling `ensRainbowClient.config()`, we want to make sure that + // the ENSRainbow instance is healthy and ready to serve requests. + // This is a quick check, as we expect the ENSRainbow instance to be healthy + // by the time ENSIndexer instance executes `initIndexingOnchainEvents`. + await waitForEnsRainbowToBeHealthy(); + + // Upsert the Indexing Metadata Context record into ENSDb + await upsertIndexingMetadataContextRecord(); + + // Before starting to process onchain events, we want to make sure that + // ENSRainbow is ready to serve the "heal" requests. + await waitForEnsRainbowToBeReady(); + + // TODO: start Indexing Status Sync worker + // It will be responsible for keeping the indexing status stored within Indexing Metadata Context record in ENSDb up to date + // await indexingStatusSyncWorker.start(); + startEnsDbWriterWorker(); + } catch (error) { + // If any error happens during the initialization of indexing of onchain events, + // we want to log the error and exit the process with a non-zero exit code, + // since this is a critical failure that prevents the ENSIndexer instance from functioning properly. + logger.error({ + msg: "Failed to initialize the onchain events indexing", + module: "init-indexing-onchain-events", + error, + }); + + process.exitCode = 1; + throw error; + } +} diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts index 28d13674fd..d24a9c975a 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts @@ -5,7 +5,26 @@ import type { IndexingEngineContext, IndexingEngineEvent } from "./ponder"; const { mockPonderOn } = vi.hoisted(() => ({ mockPonderOn: vi.fn() })); -const mockWaitForEnsRainbow = vi.hoisted(() => vi.fn()); +const { mockInitIndexingOnchainEvents } = vi.hoisted(() => ({ + mockInitIndexingOnchainEvents: vi.fn(), +})); + +// Set up PONDER_COMMON global before any imports that depend on it +vi.hoisted(() => { + (globalThis as any).PONDER_COMMON = { + options: { + command: "start", + port: 42069, + }, + logger: { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + }; +}); vi.mock("ponder:registry", () => ({ ponder: { @@ -17,14 +36,14 @@ vi.mock("ponder:schema", () => ({ ensIndexerSchema: {}, })); -vi.mock("@/lib/ensrainbow/singleton", () => ({ - waitForEnsRainbowToBeReady: mockWaitForEnsRainbow, +vi.mock("./init-indexing-onchain-events", () => ({ + initIndexingOnchainEvents: mockInitIndexingOnchainEvents, })); describe("addOnchainEventListener", () => { beforeEach(async () => { vi.clearAllMocks(); - mockWaitForEnsRainbow.mockResolvedValue(undefined); + mockInitIndexingOnchainEvents.mockResolvedValue(undefined); // Reset module state to test idempotent behavior correctly vi.resetModules(); }); @@ -221,8 +240,8 @@ describe("addOnchainEventListener", () => { }); }); - describe("ENSRainbow preconditions (onchain events)", () => { - it("waits for ENSRainbow before executing the handler", async () => { + describe("onchain event preconditions", () => { + it("runs onchain event initialization before executing the handler", async () => { const { addOnchainEventListener } = await getPonderModule(); const handler = vi.fn().mockResolvedValue(undefined); @@ -232,14 +251,16 @@ describe("addOnchainEventListener", () => { event: {} as IndexingEngineEvent, }); - expect(mockWaitForEnsRainbow).toHaveBeenCalledTimes(1); + expect(mockInitIndexingOnchainEvents).toHaveBeenCalledTimes(1); expect(handler).toHaveBeenCalled(); }); - it("prevents handler execution if ENSRainbow is not ready", async () => { + it("prevents handler execution if onchain event initialization fails", async () => { const { addOnchainEventListener } = await getPonderModule(); const handler = vi.fn().mockResolvedValue(undefined); - mockWaitForEnsRainbow.mockRejectedValue(new Error("ENSRainbow not ready")); + mockInitIndexingOnchainEvents.mockRejectedValue( + new Error("Onchain event initialization failed"), + ); addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler); @@ -248,12 +269,12 @@ describe("addOnchainEventListener", () => { context: { db: vi.fn() } as unknown as Context, event: {} as IndexingEngineEvent, }), - ).rejects.toThrow("ENSRainbow not ready"); + ).rejects.toThrow("Onchain event initialization failed"); expect(handler).not.toHaveBeenCalled(); }); - it("calls waitForEnsRainbowToBeReady only once across multiple onchain events (idempotent)", async () => { + it("calls initIndexingOnchainEvents only once across multiple onchain events (idempotent)", async () => { const { addOnchainEventListener } = await getPonderModule(); const handler1 = vi.fn().mockResolvedValue(undefined); const handler2 = vi.fn().mockResolvedValue(undefined); @@ -267,7 +288,7 @@ describe("addOnchainEventListener", () => { context: { db: vi.fn() } as unknown as Context, event: { args: { a: "1" } } as unknown as IndexingEngineEvent, }); - expect(mockWaitForEnsRainbow).toHaveBeenCalledTimes(1); + expect(mockInitIndexingOnchainEvents).toHaveBeenCalledTimes(1); // Trigger the second event handler await getRegisteredCallback(1)({ @@ -276,19 +297,19 @@ describe("addOnchainEventListener", () => { }); // Should still only have been called once (idempotent behavior) - expect(mockWaitForEnsRainbow).toHaveBeenCalledTimes(1); + expect(mockInitIndexingOnchainEvents).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 () => { + it("calls initIndexingOnchainEvents only once when two onchain callbacks fire concurrently before the initialization 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(() => { + mockInitIndexingOnchainEvents.mockImplementation(() => { return new Promise((resolve) => { resolveReadiness = resolve; }); @@ -308,8 +329,8 @@ describe("addOnchainEventListener", () => { event: { args: { a: "2" } } as unknown as IndexingEngineEvent, }); - // Should only have been called once despite concurrent execution - expect(mockWaitForEnsRainbow).toHaveBeenCalledTimes(1); + // Allow the dynamic import to settle before asserting + await vi.waitFor(() => expect(mockInitIndexingOnchainEvents).toHaveBeenCalledTimes(1)); // Neither handler should have executed yet expect(handler1).not.toHaveBeenCalled(); @@ -326,12 +347,12 @@ describe("addOnchainEventListener", () => { expect(handler2).toHaveBeenCalledTimes(1); }); - it("resolves ENSRainbow before calling the handler", async () => { + it("resolves onchain event initialization before calling the handler", async () => { const { addOnchainEventListener } = await getPonderModule(); const handler = vi.fn().mockResolvedValue(undefined); let preconditionResolved = false; - mockWaitForEnsRainbow.mockImplementation(async () => { + mockInitIndexingOnchainEvents.mockImplementation(async () => { await new Promise((resolve) => setTimeout(resolve, 10)); preconditionResolved = true; }); @@ -348,7 +369,7 @@ describe("addOnchainEventListener", () => { }); describe("setup events (no preconditions)", () => { - it("skips ENSRainbow wait for :setup events", async () => { + it("skips onchain event initialization for :setup events", async () => { const { addOnchainEventListener } = await getPonderModule(); const handler = vi.fn().mockResolvedValue(undefined); @@ -358,7 +379,7 @@ describe("addOnchainEventListener", () => { event: {} as IndexingEngineEvent, }); - expect(mockWaitForEnsRainbow).not.toHaveBeenCalled(); + expect(mockInitIndexingOnchainEvents).not.toHaveBeenCalled(); expect(handler).toHaveBeenCalled(); }); @@ -381,7 +402,7 @@ describe("addOnchainEventListener", () => { event: {} as IndexingEngineEvent, }); - expect(mockWaitForEnsRainbow).not.toHaveBeenCalled(); + expect(mockInitIndexingOnchainEvents).not.toHaveBeenCalled(); expect(handler).toHaveBeenCalled(); } }); @@ -396,20 +417,20 @@ describe("addOnchainEventListener", () => { addOnchainEventListener("PublicResolver:setup" as EventNames, setupHandler); addOnchainEventListener("PublicResolver:AddrChanged" as EventNames, onchainHandler); - // Setup event - no ENSRainbow wait + // Setup event - no onchain event initialization await getRegisteredCallback(0)({ context: { db: vi.fn() } as unknown as Context, event: {} as IndexingEngineEvent, }); - expect(mockWaitForEnsRainbow).not.toHaveBeenCalled(); + expect(mockInitIndexingOnchainEvents).not.toHaveBeenCalled(); expect(setupHandler).toHaveBeenCalled(); - // Onchain event - ENSRainbow wait required + // Onchain event - initialization required await getRegisteredCallback(1)({ context: { db: vi.fn() } as unknown as Context, event: {} as IndexingEngineEvent, }); - expect(mockWaitForEnsRainbow).toHaveBeenCalledTimes(1); + expect(mockInitIndexingOnchainEvents).toHaveBeenCalledTimes(1); expect(onchainHandler).toHaveBeenCalled(); }); @@ -434,7 +455,7 @@ describe("addOnchainEventListener", () => { event: {} as IndexingEngineEvent, }); - expect(mockWaitForEnsRainbow).toHaveBeenCalled(); + expect(mockInitIndexingOnchainEvents).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 84996fdc67..1726860d2c 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.ts @@ -15,8 +15,6 @@ import { ponder, } from "ponder:registry"; -import { waitForEnsRainbowToBeReady } from "@/lib/ensrainbow/singleton"; - /** * Context passed to event handlers registered with * {@link addOnchainEventListener}. @@ -113,7 +111,7 @@ const EventTypeIds = { * * Driven by an onchain event emitted by an indexed contract. */ - Onchain: "Onchain", + OnchainEvent: "OnchainEvent", } as const; /** @@ -125,59 +123,11 @@ function buildEventTypeId(eventName: EventNames): EventTypeId { if (eventName.endsWith(":setup")) { return EventTypeIds.Setup; } else { - return EventTypeIds.Onchain; + return EventTypeIds.OnchainEvent; } } -/** - * 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; +let indexingOnchainEventsPromise: Promise | null = null; /** * Execute any necessary preconditions before running an event handler @@ -194,23 +144,31 @@ let indexingActivationPromise: Promise | null = null; 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; + // For some ENSIndexer instances, the setup handlers are not defined at all, + // for example, if the ENSIndexer instance has only the `ensv2` plugin activated. + // In this case, some important logic, such as running migrations for ENSNode Schema + // in ENSDb, would not be executed at all, which would cause the ENSIndexer instance + // to not work properly. Therefore, all logic required to be executed before + // indexing of onchain events should be executed in initIndexingOnchainEvents function. + return; } - 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(); + case EventTypeIds.OnchainEvent: { + if (indexingOnchainEventsPromise === null) { + // We need to work around the Ponder limitation for importing modules, + // since Ponder would not allow us to use static imports for modules + // that internally rely on `ponder:api`. Using dynamic imports solves + // this issue. + indexingOnchainEventsPromise = import("./init-indexing-onchain-events").then( + ({ initIndexingOnchainEvents }) => + // Init the indexing of "onchain" events just once in order to + // optimize the indexing "hot path", since these events are much + // more frequent than setup events. + initIndexingOnchainEvents(), + ); } - return await indexingActivationPromise; + return await indexingOnchainEventsPromise; } } } diff --git a/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.test.ts b/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.test.ts new file mode 100644 index 0000000000..d8bed80c7b --- /dev/null +++ b/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.test.ts @@ -0,0 +1,298 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { EnsDbReader } from "@ensnode/ensdb-sdk"; +import { + buildCrossChainIndexingStatusSnapshotOmnichain, + buildIndexingMetadataContextInitialized, + type CrossChainIndexingStatusSnapshot, + type EnsIndexerStackInfo, + type IndexingMetadataContext, + type IndexingMetadataContextInitialized, + IndexingMetadataContextStatusCodes, + OmnichainIndexingStatusIds, + type OmnichainIndexingStatusSnapshot, + validateEnsIndexerPublicConfigCompatibility, +} from "@ensnode/ensnode-sdk"; +import type { LocalPonderClient } from "@ensnode/ponder-sdk"; + +import "@/lib/__test__/mockLogger"; + +import type { IndexingStatusBuilder } from "@/lib/indexing-status-builder/indexing-status-builder"; +import type { StackInfoBuilder } from "@/lib/stack-info-builder/stack-info-builder"; + +import { IndexingMetadataContextBuilder } from "./indexing-metadata-context-builder"; + +vi.mock("@ensnode/ensnode-sdk", async () => { + const actual = await vi.importActual("@ensnode/ensnode-sdk"); + + return { + ...actual, + buildCrossChainIndexingStatusSnapshotOmnichain: vi.fn(), + buildIndexingMetadataContextInitialized: vi.fn(), + validateEnsIndexerPublicConfigCompatibility: vi.fn(), + }; +}); + +const omnichainSnapshotUnstarted: OmnichainIndexingStatusSnapshot = { + omnichainStatus: OmnichainIndexingStatusIds.Unstarted, + omnichainIndexingCursor: 0, + chains: new Map(), +}; + +const omnichainSnapshotFollowing: OmnichainIndexingStatusSnapshot = { + omnichainStatus: OmnichainIndexingStatusIds.Following, + omnichainIndexingCursor: 100, + chains: new Map(), +}; + +const crossChainSnapshot: CrossChainIndexingStatusSnapshot = { + strategy: "omnichain" as any, + slowestChainIndexingCursor: 100, + snapshotTime: 200, + omnichainSnapshot: omnichainSnapshotFollowing, +}; + +const stackInfo: EnsIndexerStackInfo = { + ensDb: { versionInfo: { postgresql: "17.4" } }, + ensIndexer: {} as any, + ensRainbow: {} as any, +}; + +const indexingMetadataContextInitialized: IndexingMetadataContextInitialized = { + statusCode: IndexingMetadataContextStatusCodes.Initialized, + indexingStatus: crossChainSnapshot, + stackInfo, +}; + +const indexingMetadataContextUninitialized: IndexingMetadataContext = { + statusCode: IndexingMetadataContextStatusCodes.Uninitialized, +}; + +function createMockEnsDbReader( + overrides: Partial> = {}, +): EnsDbReader { + return { + getIndexingMetadataContext: vi.fn().mockResolvedValue(indexingMetadataContextUninitialized), + ...overrides, + } as unknown as EnsDbReader; +} + +function createMockIndexingStatusBuilder( + resolvedSnapshot: OmnichainIndexingStatusSnapshot = omnichainSnapshotUnstarted, +): IndexingStatusBuilder { + return { + getOmnichainIndexingStatusSnapshot: vi.fn().mockResolvedValue(resolvedSnapshot), + } as unknown as IndexingStatusBuilder; +} + +function createMockStackInfoBuilder( + resolvedStackInfo: EnsIndexerStackInfo = stackInfo, +): StackInfoBuilder { + return { + getStackInfo: vi.fn().mockResolvedValue(resolvedStackInfo), + } as unknown as StackInfoBuilder; +} + +function createMockLocalPonderClient(options: { isInDevMode?: boolean } = {}): LocalPonderClient { + return { + isInDevMode: options.isInDevMode ?? false, + } as unknown as LocalPonderClient; +} + +function createIndexingMetadataContextBuilder( + overrides: { + ensDbClient?: EnsDbReader; + indexingStatusBuilder?: IndexingStatusBuilder; + stackInfoBuilder?: StackInfoBuilder; + localPonderClient?: LocalPonderClient; + } = {}, +): IndexingMetadataContextBuilder { + return new IndexingMetadataContextBuilder( + overrides.ensDbClient ?? createMockEnsDbReader(), + overrides.indexingStatusBuilder ?? createMockIndexingStatusBuilder(), + overrides.stackInfoBuilder ?? createMockStackInfoBuilder(), + overrides.localPonderClient ?? createMockLocalPonderClient(), + ); +} + +describe("IndexingMetadataContextBuilder", () => { + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(crossChainSnapshot); + vi.mocked(buildIndexingMetadataContextInitialized).mockReturnValue( + indexingMetadataContextInitialized as IndexingMetadataContextInitialized, + ); + }); + + describe("getIndexingMetadataContext()", () => { + describe("when stored context is Uninitialized", () => { + it("builds and returns initialized context with fresh snapshot time", async () => { + const ensDbClient = createMockEnsDbReader(); + const indexingStatusBuilder = createMockIndexingStatusBuilder(omnichainSnapshotUnstarted); + const stackInfoBuilder = createMockStackInfoBuilder(); + + const builder = createIndexingMetadataContextBuilder({ + ensDbClient, + indexingStatusBuilder, + stackInfoBuilder, + }); + const result = await builder.getIndexingMetadataContext(); + + expect(ensDbClient.getIndexingMetadataContext).toHaveBeenCalledOnce(); + expect(indexingStatusBuilder.getOmnichainIndexingStatusSnapshot).toHaveBeenCalledOnce(); + expect(stackInfoBuilder.getStackInfo).toHaveBeenCalledOnce(); + expect(buildCrossChainIndexingStatusSnapshotOmnichain).toHaveBeenCalledWith( + omnichainSnapshotUnstarted, + expect.any(Number), + ); + expect(buildIndexingMetadataContextInitialized).toHaveBeenCalledWith( + crossChainSnapshot, + stackInfo, + ); + expect(result).toBe(indexingMetadataContextInitialized); + }); + + it("throws when indexing status is not unstarted", async () => { + const indexingStatusBuilder = createMockIndexingStatusBuilder(omnichainSnapshotFollowing); + + const builder = createIndexingMetadataContextBuilder({ + indexingStatusBuilder, + }); + + await expect(builder.getIndexingMetadataContext()).rejects.toThrow( + /Omnichain indexing status must be "unstarted"/, + ); + }); + }); + + describe("when stored context is Initialized", () => { + it("validates compatibility when not in dev mode", async () => { + const ensDbClient = createMockEnsDbReader({ + getIndexingMetadataContext: vi.fn().mockResolvedValue(indexingMetadataContextInitialized), + }); + const indexingStatusBuilder = createMockIndexingStatusBuilder(omnichainSnapshotFollowing); + const stackInfoBuilder = createMockStackInfoBuilder(); + const localPonderClient = createMockLocalPonderClient({ isInDevMode: false }); + + const builder = createIndexingMetadataContextBuilder({ + ensDbClient, + indexingStatusBuilder, + stackInfoBuilder, + localPonderClient, + }); + const result = await builder.getIndexingMetadataContext(); + + expect(validateEnsIndexerPublicConfigCompatibility).toHaveBeenCalledWith( + (indexingMetadataContextInitialized as IndexingMetadataContextInitialized).stackInfo + .ensIndexer, + stackInfo.ensIndexer, + ); + expect(buildIndexingMetadataContextInitialized).toHaveBeenCalledWith( + crossChainSnapshot, + stackInfo, + ); + expect(result).toBe(indexingMetadataContextInitialized); + }); + + it("skips compatibility validation when in dev mode", async () => { + const ensDbClient = createMockEnsDbReader({ + getIndexingMetadataContext: vi.fn().mockResolvedValue(indexingMetadataContextInitialized), + }); + const indexingStatusBuilder = createMockIndexingStatusBuilder(omnichainSnapshotFollowing); + const stackInfoBuilder = createMockStackInfoBuilder(); + const localPonderClient = createMockLocalPonderClient({ isInDevMode: true }); + + const builder = createIndexingMetadataContextBuilder({ + ensDbClient, + indexingStatusBuilder, + stackInfoBuilder, + localPonderClient, + }); + const result = await builder.getIndexingMetadataContext(); + + // Compatibility validation should NOT be called in dev mode + expect(validateEnsIndexerPublicConfigCompatibility).not.toHaveBeenCalled(); + expect(buildIndexingMetadataContextInitialized).toHaveBeenCalledWith( + crossChainSnapshot, + stackInfo, + ); + expect(result).toBe(indexingMetadataContextInitialized); + }); + + it("throws when stored and in-memory configs are incompatible (not in dev mode)", async () => { + vi.mocked(validateEnsIndexerPublicConfigCompatibility).mockImplementation(() => { + throw new Error("Incompatible ENSIndexer config"); + }); + + const ensDbClient = createMockEnsDbReader({ + getIndexingMetadataContext: vi.fn().mockResolvedValue(indexingMetadataContextInitialized), + }); + + const builder = createIndexingMetadataContextBuilder({ + ensDbClient, + indexingStatusBuilder: createMockIndexingStatusBuilder(omnichainSnapshotFollowing), + localPonderClient: createMockLocalPonderClient({ isInDevMode: false }), + }); + + await expect(builder.getIndexingMetadataContext()).rejects.toThrow( + "Incompatible ENSIndexer config", + ); + }); + + it("does not throw on incompatible configs when in dev mode", async () => { + vi.mocked(validateEnsIndexerPublicConfigCompatibility).mockImplementation(() => { + throw new Error("Incompatible ENSIndexer config"); + }); + + const ensDbClient = createMockEnsDbReader({ + getIndexingMetadataContext: vi.fn().mockResolvedValue(indexingMetadataContextInitialized), + }); + + const builder = createIndexingMetadataContextBuilder({ + ensDbClient, + indexingStatusBuilder: createMockIndexingStatusBuilder(omnichainSnapshotFollowing), + localPonderClient: createMockLocalPonderClient({ isInDevMode: true }), + }); + + await expect(builder.getIndexingMetadataContext()).resolves.toBeDefined(); + }); + }); + + it("fetches all three data sources in parallel", async () => { + const resolveOrder: string[] = []; + const ensDbClient = createMockEnsDbReader({ + getIndexingMetadataContext: vi.fn().mockImplementation(async () => { + await new Promise((r) => setTimeout(r, 10)); + resolveOrder.push("ensDb"); + return indexingMetadataContextUninitialized; + }), + }); + const indexingStatusBuilder = createMockIndexingStatusBuilder(omnichainSnapshotUnstarted); + (indexingStatusBuilder.getOmnichainIndexingStatusSnapshot as any) = vi + .fn() + .mockImplementation(async () => { + resolveOrder.push("indexingStatus"); + return omnichainSnapshotUnstarted; + }); + const stackInfoBuilder = createMockStackInfoBuilder(); + (stackInfoBuilder.getStackInfo as any) = vi.fn().mockImplementation(async () => { + resolveOrder.push("stackInfo"); + return stackInfo; + }); + + const builder = createIndexingMetadataContextBuilder({ + ensDbClient, + indexingStatusBuilder, + stackInfoBuilder, + }); + await builder.getIndexingMetadataContext(); + + // All three should have been called (ordering is not deterministic for parallel) + expect(resolveOrder).toHaveLength(3); + expect(resolveOrder).toContain("ensDb"); + expect(resolveOrder).toContain("indexingStatus"); + expect(resolveOrder).toContain("stackInfo"); + }); + }); +}); diff --git a/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.ts b/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.ts new file mode 100644 index 0000000000..1a4254f80f --- /dev/null +++ b/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.ts @@ -0,0 +1,115 @@ +import { getUnixTime } from "date-fns"; + +import type { EnsDbReader } from "@ensnode/ensdb-sdk"; +import { + buildCrossChainIndexingStatusSnapshotOmnichain, + buildIndexingMetadataContextInitialized, + type EnsIndexerStackInfo, + type IndexingMetadataContextInitialized, + IndexingMetadataContextStatusCodes, + OmnichainIndexingStatusIds, + type OmnichainIndexingStatusSnapshot, + validateEnsIndexerPublicConfigCompatibility, +} from "@ensnode/ensnode-sdk"; +import type { LocalPonderClient } from "@ensnode/ponder-sdk"; + +import type { IndexingStatusBuilder } from "@/lib/indexing-status-builder/indexing-status-builder"; +import { logger } from "@/lib/logger"; +import type { StackInfoBuilder } from "@/lib/stack-info-builder/stack-info-builder"; + +function invariant_indexingStatusIsUnstartedForIndexingMetadataContextUninitialized( + inMemoryIndexingStatusSnapshot: OmnichainIndexingStatusSnapshot, +): void { + // Invariant: indexing status must be "unstarted" when the indexing metadata context is uninitialized, + // since we haven't started processing any onchain events yet + if (inMemoryIndexingStatusSnapshot.omnichainStatus !== OmnichainIndexingStatusIds.Unstarted) { + throw new Error( + `Omnichain indexing status must be "unstarted" for "uninitialized" Indexing Metadata Context. Provided omnichain indexing status "${inMemoryIndexingStatusSnapshot.omnichainStatus}".`, + ); + } +} + +function invariant_ensIndexerPublicConfigIsCompatibleWithStackInfo( + storedEnsIndexerStackInfo: EnsIndexerStackInfo, + inMemoryEnsIndexerStackInfo: EnsIndexerStackInfo, +): void { + const { ensIndexer: storedEnsIndexerPublicConfig } = storedEnsIndexerStackInfo; + const { ensIndexer: inMemoryEnsIndexerPublicConfig } = inMemoryEnsIndexerStackInfo; + + validateEnsIndexerPublicConfigCompatibility( + storedEnsIndexerPublicConfig, + inMemoryEnsIndexerPublicConfig, + ); +} + +export class IndexingMetadataContextBuilder { + constructor( + private readonly ensDbClient: EnsDbReader, + private readonly indexingStatusBuilder: IndexingStatusBuilder, + private readonly stackInfoBuilder: StackInfoBuilder, + private readonly localPonderClient: LocalPonderClient, + ) {} + + /** + * Get the current {@link IndexingMetadataContextInitialized} object. + * + * Expected to be called while writing an {@link IndexingMetadataContextInitialized} record into ENSDb + */ + async getIndexingMetadataContext(): Promise { + const [ + inMemoryIndexingStatusSnapshot, + inMemoryEnsIndexerStackInfo, + storedIndexingMetadataContext, + ] = await Promise.all([ + this.indexingStatusBuilder.getOmnichainIndexingStatusSnapshot(), + this.stackInfoBuilder.getStackInfo(), + this.ensDbClient.getIndexingMetadataContext(), + ]); + + // Build the {@link CrossChainIndexingStatusSnapshot} with the current snapshot time. + // This is important to make sure the `snapshotTime` is always up to date in + // the indexing status snapshot stored within the Indexing Metadata Context record in ENSDb. + const now = getUnixTime(new Date()); + const crossChainIndexingStatusSnapshot = buildCrossChainIndexingStatusSnapshotOmnichain( + inMemoryIndexingStatusSnapshot, + now, + ); + + const inMemoryIndexingMetadataContext = buildIndexingMetadataContextInitialized( + crossChainIndexingStatusSnapshot, + inMemoryEnsIndexerStackInfo, + ); + + if ( + storedIndexingMetadataContext.statusCode === IndexingMetadataContextStatusCodes.Uninitialized + ) { + logger.info({ msg: `Indexing Metadata Context is "uninitialized"` }); + + invariant_indexingStatusIsUnstartedForIndexingMetadataContextUninitialized( + inMemoryIndexingStatusSnapshot, + ); + } else { + logger.info({ msg: `Indexing Metadata Context is "initialized"` }); + logger.debug({ + msg: `Indexing Metadata Context`, + indexingStatus: storedIndexingMetadataContext.indexingStatus, + stackInfo: storedIndexingMetadataContext.stackInfo, + }); + + // Validate in-memory config object compatibility with the stored one, + // if the stored one is available. + // The validation is skipped if the local Ponder app is running in dev mode. + // This is to improve the development experience during ENSIndexer + // development, by allowing to override the stored config in ENSDb with + // the current in-memory config, without having to keep them compatible. + if (!this.localPonderClient.isInDevMode) { + invariant_ensIndexerPublicConfigIsCompatibleWithStackInfo( + storedIndexingMetadataContext.stackInfo, + inMemoryEnsIndexerStackInfo, + ); + } + } + + return inMemoryIndexingMetadataContext; + } +} diff --git a/apps/ensindexer/src/lib/indexing-metadata-context-builder/singleton.ts b/apps/ensindexer/src/lib/indexing-metadata-context-builder/singleton.ts new file mode 100644 index 0000000000..31299cd55f --- /dev/null +++ b/apps/ensindexer/src/lib/indexing-metadata-context-builder/singleton.ts @@ -0,0 +1,15 @@ +import { ensDbClient } from "@/lib/ensdb/singleton"; +import { IndexingMetadataContextBuilder } from "@/lib/indexing-metadata-context-builder/indexing-metadata-context-builder"; +import { indexingStatusBuilder } from "@/lib/indexing-status-builder/singleton"; +import { localPonderClient } from "@/lib/local-ponder-client"; +import { stackInfoBuilder } from "@/lib/stack-info-builder/singleton"; + +/** + * Singleton {@link IndexingMetadataContextBuilder} instance to use across ENSIndexer modules. + */ +export const indexingMetadataContextBuilder = new IndexingMetadataContextBuilder( + ensDbClient, + indexingStatusBuilder, + stackInfoBuilder, + localPonderClient, +); diff --git a/apps/ensindexer/src/lib/stack-info-builder/singleton.ts b/apps/ensindexer/src/lib/stack-info-builder/singleton.ts new file mode 100644 index 0000000000..2b924c0589 --- /dev/null +++ b/apps/ensindexer/src/lib/stack-info-builder/singleton.ts @@ -0,0 +1,13 @@ +import { ensDbClient } from "@/lib/ensdb/singleton"; +import { ensRainbowClient } from "@/lib/ensrainbow/singleton"; +import { publicConfigBuilder } from "@/lib/public-config-builder/singleton"; +import { StackInfoBuilder } from "@/lib/stack-info-builder/stack-info-builder"; + +/** + * Singleton {@link StackInfoBuilder} instance to use across ENSIndexer modules. + */ +export const stackInfoBuilder = new StackInfoBuilder( + ensDbClient, + ensRainbowClient, + publicConfigBuilder, +); diff --git a/apps/ensindexer/src/lib/stack-info-builder/stack-info-builder.test.ts b/apps/ensindexer/src/lib/stack-info-builder/stack-info-builder.test.ts new file mode 100644 index 0000000000..259233d199 --- /dev/null +++ b/apps/ensindexer/src/lib/stack-info-builder/stack-info-builder.test.ts @@ -0,0 +1,188 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { EnsDbReader } from "@ensnode/ensdb-sdk"; +import { + buildEnsIndexerStackInfo, + type EnsIndexerPublicConfig, + type EnsIndexerStackInfo, +} from "@ensnode/ensnode-sdk"; +import type { EnsRainbowApiClient } from "@ensnode/ensrainbow-sdk/client"; + +import type { PublicConfigBuilder } from "@/lib/public-config-builder"; + +import { StackInfoBuilder } from "./stack-info-builder"; + +vi.mock("@ensnode/ensnode-sdk", async () => { + const actual = await vi.importActual("@ensnode/ensnode-sdk"); + + return { + ...actual, + buildEnsIndexerStackInfo: vi.fn(), + }; +}); + +const mockEnsDbPublicConfig = { + versionInfo: { postgresql: "17.4" }, +}; + +const mockEnsIndexerPublicConfig = { + ensIndexerSchemaName: "ensindexer_0", + ensRainbowPublicConfig: { + serverLabelSet: { labelSetId: "subgraph", highestLabelSetVersion: 0 }, + versionInfo: { ensRainbow: "1.9.0" }, + }, + clientLabelSet: { labelSetId: "subgraph", labelSetVersion: 0 }, + indexedChainIds: new Set([1]), + isSubgraphCompatible: true, + namespace: "mainnet", + plugins: [], + versionInfo: { + ponder: "0.11.0", + ensDb: "1.0.0", + ensIndexer: "1.0.0", + ensNormalize: "1.0.0", + }, +} satisfies EnsIndexerPublicConfig; + +const mockEnsRainbowPublicConfig = { + serverLabelSet: { labelSetId: "subgraph", highestLabelSetVersion: 0 }, + versionInfo: { ensRainbow: "1.9.0" }, +}; + +const mockStackInfo = { + ensDb: mockEnsDbPublicConfig, + ensIndexer: mockEnsIndexerPublicConfig, + ensRainbow: mockEnsRainbowPublicConfig, +} satisfies EnsIndexerStackInfo; + +function createMockEnsDbReader( + overrides: Partial> = {}, +): EnsDbReader { + return { + buildEnsDbPublicConfig: vi.fn().mockResolvedValue(mockEnsDbPublicConfig), + ...overrides, + } as unknown as EnsDbReader; +} + +function createMockEnsRainbowClient( + overrides: Partial> = {}, +): EnsRainbowApiClient { + return { + config: vi.fn().mockResolvedValue(mockEnsRainbowPublicConfig), + ...overrides, + } as unknown as EnsRainbowApiClient; +} + +function createMockPublicConfigBuilder( + overrides: Partial> = {}, +): PublicConfigBuilder { + return { + getPublicConfig: vi.fn().mockResolvedValue(mockEnsIndexerPublicConfig), + ...overrides, + } as unknown as PublicConfigBuilder; +} + +describe("StackInfoBuilder", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getStackInfo()", () => { + it("builds stack info from ensDb, ensIndexer, and ensRainbow public configs", async () => { + vi.mocked(buildEnsIndexerStackInfo).mockReturnValue(mockStackInfo); + + const ensDbClient = createMockEnsDbReader(); + const ensRainbowClient = createMockEnsRainbowClient(); + const publicConfigBuilder = createMockPublicConfigBuilder(); + + const builder = new StackInfoBuilder(ensDbClient, ensRainbowClient, publicConfigBuilder); + const result = await builder.getStackInfo(); + + expect(ensDbClient.buildEnsDbPublicConfig).toHaveBeenCalledOnce(); + expect(publicConfigBuilder.getPublicConfig).toHaveBeenCalledOnce(); + expect(ensRainbowClient.config).toHaveBeenCalledOnce(); + expect(buildEnsIndexerStackInfo).toHaveBeenCalledWith( + mockEnsDbPublicConfig, + mockEnsIndexerPublicConfig, + mockEnsRainbowPublicConfig, + ); + expect(result).toBe(mockStackInfo); + }); + + it("caches stack info and returns the same value on subsequent calls", async () => { + vi.mocked(buildEnsIndexerStackInfo).mockReturnValue(mockStackInfo); + + const ensDbClient = createMockEnsDbReader(); + const ensRainbowClient = createMockEnsRainbowClient(); + const publicConfigBuilder = createMockPublicConfigBuilder(); + + const builder = new StackInfoBuilder(ensDbClient, ensRainbowClient, publicConfigBuilder); + + const result1 = await builder.getStackInfo(); + const result2 = await builder.getStackInfo(); + + expect(result1).toBe(result2); + // Underlying dependencies should only be called once due to caching + expect(ensDbClient.buildEnsDbPublicConfig).toHaveBeenCalledOnce(); + expect(publicConfigBuilder.getPublicConfig).toHaveBeenCalledOnce(); + expect(ensRainbowClient.config).toHaveBeenCalledOnce(); + expect(buildEnsIndexerStackInfo).toHaveBeenCalledOnce(); + }); + + it("throws when buildEnsIndexerStackInfo throws", async () => { + vi.mocked(buildEnsIndexerStackInfo).mockImplementation(() => { + throw new Error("Stack info validation failed"); + }); + + const builder = new StackInfoBuilder( + createMockEnsDbReader(), + createMockEnsRainbowClient(), + createMockPublicConfigBuilder(), + ); + + await expect(builder.getStackInfo()).rejects.toThrow("Stack info validation failed"); + }); + + it("propagates errors from ensDb client", async () => { + const ensDbClient = createMockEnsDbReader({ + buildEnsDbPublicConfig: vi.fn().mockRejectedValue(new Error("ENSDB connection failed")), + }); + + const builder = new StackInfoBuilder( + ensDbClient, + createMockEnsRainbowClient(), + createMockPublicConfigBuilder(), + ); + + await expect(builder.getStackInfo()).rejects.toThrow("ENSDB connection failed"); + }); + + it("propagates errors from ensRainbow client", async () => { + const ensRainbowClient = createMockEnsRainbowClient({ + config: vi.fn().mockRejectedValue(new Error("ENSRainbow not available")), + }); + + const builder = new StackInfoBuilder( + createMockEnsDbReader(), + ensRainbowClient, + createMockPublicConfigBuilder(), + ); + + await expect(builder.getStackInfo()).rejects.toThrow("ENSRainbow not available"); + }); + + it("propagates errors from public config builder", async () => { + const publicConfigBuilder = createMockPublicConfigBuilder({ + getPublicConfig: vi.fn().mockRejectedValue(new Error("Config retrieval failed")), + }); + + const builder = new StackInfoBuilder( + createMockEnsDbReader(), + createMockEnsRainbowClient(), + publicConfigBuilder, + ); + + await expect(builder.getStackInfo()).rejects.toThrow("Config retrieval failed"); + }); + }); +}); diff --git a/apps/ensindexer/src/lib/stack-info-builder/stack-info-builder.ts b/apps/ensindexer/src/lib/stack-info-builder/stack-info-builder.ts new file mode 100644 index 0000000000..c70c7566dc --- /dev/null +++ b/apps/ensindexer/src/lib/stack-info-builder/stack-info-builder.ts @@ -0,0 +1,46 @@ +import type { EnsDbReader } from "@ensnode/ensdb-sdk"; +import { buildEnsIndexerStackInfo, type EnsIndexerStackInfo } from "@ensnode/ensnode-sdk"; +import type { EnsRainbowApiClient } from "@ensnode/ensrainbow-sdk/client"; + +import type { PublicConfigBuilder } from "@/lib/public-config-builder"; + +export class StackInfoBuilder { + /** + * Immutable {@link EnsIndexerStackInfo} + * + * The cached {@link EnsIndexerStackInfo} object, which is built and validated + * on the first call to `getStackInfo()`, and returned as-is on subsequent calls. + */ + private immutableStackInfo: EnsIndexerStackInfo | undefined; + + constructor( + private readonly ensDbClient: EnsDbReader, + private readonly ensRainbowClient: EnsRainbowApiClient, + private readonly publicConfigBuilder: PublicConfigBuilder, + ) {} + + /** + * Get ENSIndexer Stack Info + * + * Note: ENSIndexer Stack Info is cached after the first call, so + * subsequent calls will return the cached version without rebuilding it. + * + * @throws if the built ENSIndexer Stack Info does not conform to + * the expected schema + */ + async getStackInfo(): Promise { + if (typeof this.immutableStackInfo === "undefined") { + const ensDbPublicConfig = await this.ensDbClient.buildEnsDbPublicConfig(); + const ensIndexerPublicConfig = await this.publicConfigBuilder.getPublicConfig(); + const ensRainbowPublicConfig = await this.ensRainbowClient.config(); + + this.immutableStackInfo = buildEnsIndexerStackInfo( + ensDbPublicConfig, + ensIndexerPublicConfig, + ensRainbowPublicConfig, + ); + } + + return this.immutableStackInfo; + } +} diff --git a/packages/ensdb-sdk/src/client/ensdb-reader.test.ts b/packages/ensdb-sdk/src/client/ensdb-reader.test.ts index 3a488c4a86..f60ad260ba 100644 --- a/packages/ensdb-sdk/src/client/ensdb-reader.test.ts +++ b/packages/ensdb-sdk/src/client/ensdb-reader.test.ts @@ -1,17 +1,23 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { + buildEnsIndexerStackInfo, + buildIndexingMetadataContextInitialized, + buildIndexingMetadataContextUninitialized, deserializeCrossChainIndexingStatusSnapshot, - serializeEnsIndexerPublicConfig, + deserializeIndexingMetadataContext, + type EnsDbPublicConfig, + serializeIndexingMetadataContext, } from "@ensnode/ensnode-sdk"; import * as ensDbClientMock from "./ensdb-client.mock"; import { EnsDbReader } from "./ensdb-reader"; +const executeMock = vi.fn(); const whereMock = vi.fn(async () => [] as Array<{ value: unknown }>); const fromMock = vi.fn(() => ({ where: whereMock })); const selectMock = vi.fn(() => ({ from: fromMock })); -const drizzleClientMock = { select: selectMock } as any; +const drizzleClientMock = { select: selectMock, execute: executeMock } as any; vi.mock("drizzle-orm/node-postgres", () => ({ drizzle: vi.fn(() => drizzleClientMock), @@ -29,59 +35,125 @@ describe("EnsDbReader", () => { whereMock.mockClear(); fromMock.mockClear(); selectMock.mockClear(); + executeMock.mockClear(); }); - describe("getEnsDbVersion", () => { - it("returns undefined when no record exists", async () => { - const ensDbClient = createEnsDbReader(); - const { ensNodeSchema } = ensDbClient; + describe("getters", () => { + it("returns the ensDb drizzle client", () => { + const ensDbReader = createEnsDbReader(); + expect(ensDbReader.ensDb).toBe(drizzleClientMock); + }); - await expect(ensDbClient.getEnsDbVersion()).resolves.toBeUndefined(); + it("returns the ensIndexerSchema", () => { + const ensDbReader = createEnsDbReader(); + expect(ensDbReader.ensIndexerSchema).toBeDefined(); + }); - expect(selectMock).toHaveBeenCalledTimes(1); - expect(fromMock).toHaveBeenCalledWith(ensNodeSchema.metadata); + it("returns the ensIndexerSchemaName", () => { + const ensDbReader = createEnsDbReader(); + expect(ensDbReader.ensIndexerSchemaName).toBe(ensDbClientMock.ensIndexerSchemaName); }); - it("returns value when one record exists", async () => { - selectResult.current = [{ value: "0.1.0" }]; + it("returns the ensNodeSchema", () => { + const ensDbReader = createEnsDbReader(); + expect(ensDbReader.ensNodeSchema).toBeDefined(); + }); + }); - await expect(createEnsDbReader().getEnsDbVersion()).resolves.toBe("0.1.0"); + describe("buildEnsDbPublicConfig", () => { + it("returns version info with the postgresql version", async () => { + executeMock.mockResolvedValueOnce({ + rows: [ + { + version: "PostgreSQL 17.4 (Ubuntu 17.4-0ubuntu0.22.04.1) on x86_64-pc-linux-gnu", + }, + ], + }); + + const result = await createEnsDbReader().buildEnsDbPublicConfig(); + + expect(result).toStrictEqual({ + versionInfo: { + postgresql: "17.4", + }, + } satisfies EnsDbPublicConfig); + expect(executeMock).toHaveBeenCalledWith("SELECT version();"); }); - // This scenario should be impossible due to the primary key constraint on - // the ('ensIndexerSchemaName', 'key') columns of the 'ensnode_metadata' table. - it("throws when multiple records exist", async () => { - selectResult.current = [{ value: "0.1.0" }, { value: "0.1.1" }]; + it("throws when execute returns no rows", async () => { + executeMock.mockResolvedValueOnce({ rows: [] }); - await expect(createEnsDbReader().getEnsDbVersion()).rejects.toThrowError(/ensdb_version/i); + await expect(createEnsDbReader().buildEnsDbPublicConfig()).rejects.toThrow( + /Failed to get PostgreSQL version/, + ); }); - }); - describe("getEnsIndexerPublicConfig", () => { - it("returns undefined when no record exists", async () => { - await expect(createEnsDbReader().getEnsIndexerPublicConfig()).resolves.toBeUndefined(); + it("throws when execute returns an invalid version string", async () => { + executeMock.mockResolvedValueOnce({ + rows: [{ version: "invalid version string" }], + }); + + await expect(createEnsDbReader().buildEnsDbPublicConfig()).rejects.toThrow( + /Failed to get PostgreSQL version/, + ); }); - it("deserializes the stored config", async () => { - const serializedConfig = serializeEnsIndexerPublicConfig(ensDbClientMock.publicConfig); - selectResult.current = [{ value: serializedConfig }]; + it("propagates errors from execute", async () => { + executeMock.mockRejectedValueOnce(new Error("Connection refused")); - await expect(createEnsDbReader().getEnsIndexerPublicConfig()).resolves.toStrictEqual( - ensDbClientMock.publicConfig, + await expect(createEnsDbReader().buildEnsDbPublicConfig()).rejects.toThrow( + "Connection refused", ); }); }); - describe("getIndexingStatusSnapshot", () => { - it("deserializes the stored indexing status snapshot", async () => { - selectResult.current = [{ value: ensDbClientMock.serializedSnapshot }]; + describe("getIndexingMetadataContext", () => { + it("returns an uninitialized context when no record exists", async () => { + const ensDbReader = createEnsDbReader(); + const { ensNodeSchema } = ensDbReader; + + const result = await ensDbReader.getIndexingMetadataContext(); - const expected = deserializeCrossChainIndexingStatusSnapshot( + expect(result).toStrictEqual(buildIndexingMetadataContextUninitialized()); + expect(selectMock).toHaveBeenCalledTimes(1); + expect(fromMock).toHaveBeenCalledWith(ensNodeSchema.metadata); + expect(whereMock).toHaveBeenCalled(); + }); + + it("returns the deserialized initialized context when one record exists", async () => { + const indexingStatus = deserializeCrossChainIndexingStatusSnapshot( ensDbClientMock.serializedSnapshot, ); + const ensDbPublicConfig: EnsDbPublicConfig = { + versionInfo: { postgresql: "17.4" }, + }; + const ensRainbowPublicConfig = { + serverLabelSet: { labelSetId: "subgraph", highestLabelSetVersion: 0 }, + versionInfo: { ensRainbow: "1.9.0" }, + }; + const stackInfo = buildEnsIndexerStackInfo( + ensDbPublicConfig, + ensDbClientMock.publicConfig, + ensRainbowPublicConfig, + ); + const context = buildIndexingMetadataContextInitialized(indexingStatus, stackInfo); + const serialized = serializeIndexingMetadataContext(context); + + selectResult.current = [{ value: serialized }]; + + const result = await createEnsDbReader().getIndexingMetadataContext(); + + const expected = deserializeIndexingMetadataContext(serialized); + expect(result).toStrictEqual(expected); + }); + + // This scenario should be impossible due to the primary key constraint on + // the ('ensIndexerSchemaName', 'key') columns of the 'ensnode_metadata' table. + it("throws when multiple records exist", async () => { + selectResult.current = [{ value: "value1" }, { value: "value2" }]; - await expect(createEnsDbReader().getIndexingStatusSnapshot()).resolves.toStrictEqual( - expected, + await expect(createEnsDbReader().getIndexingMetadataContext()).rejects.toThrow( + /There must be exactly one ENSNodeMetadata record/, ); }); }); diff --git a/packages/ensdb-sdk/src/client/ensdb-reader.ts b/packages/ensdb-sdk/src/client/ensdb-reader.ts index 49d61d3ee1..eb03dc5f46 100644 --- a/packages/ensdb-sdk/src/client/ensdb-reader.ts +++ b/packages/ensdb-sdk/src/client/ensdb-reader.ts @@ -1,12 +1,11 @@ import { and, eq } from "drizzle-orm/sql"; import { - type CrossChainIndexingStatusSnapshot, - deserializeCrossChainIndexingStatusSnapshot, - deserializeEnsIndexerPublicConfig, + buildIndexingMetadataContextUninitialized, + deserializeIndexingMetadataContext, type EnsDbPublicConfig, type EnsDbVersionInfo, - type EnsIndexerPublicConfig, + type IndexingMetadataContext, } from "@ensnode/ensnode-sdk"; import { @@ -20,9 +19,7 @@ import { parsePgVersionInfo } from "../lib/parse-pg-version-info"; import { EnsNodeMetadataKeys } from "./ensnode-metadata"; import type { SerializedEnsNodeMetadata, - SerializedEnsNodeMetadataEnsDbVersion, - SerializedEnsNodeMetadataEnsIndexerIndexingStatus, - SerializedEnsNodeMetadataEnsIndexerPublicConfig, + SerializedEnsNodeMetadataIndexingMetadataContext, } from "./serialize/ensnode-metadata"; /** @@ -129,36 +126,6 @@ export class EnsDbReader< return this._ensNodeSchema; } - /** - * Get ENSDb Version - * - * @returns the existing record, or `undefined`. - */ - async getEnsDbVersion(): Promise { - const record = await this.getEnsNodeMetadata({ - key: EnsNodeMetadataKeys.EnsDbVersion, - }); - - return record; - } - - /** - * Get ENSIndexer Public Config - * - * @returns the existing record, or `undefined`. - */ - async getEnsIndexerPublicConfig(): Promise { - const record = await this.getEnsNodeMetadata({ - key: EnsNodeMetadataKeys.EnsIndexerPublicConfig, - }); - - if (!record) { - return undefined; - } - - return deserializeEnsIndexerPublicConfig(record); - } - /** * Build ENSDb Public Config */ @@ -171,22 +138,20 @@ export class EnsDbReader< } /** - * Get Indexing Status Snapshot + * Get Indexing Metadata Context * - * @returns the existing record, or `undefined`. + * @returns the initialized record, or a default uninitialized one if no record exists in ENSDb. */ - async getIndexingStatusSnapshot(): Promise { - const record = await this.getEnsNodeMetadata( - { - key: EnsNodeMetadataKeys.EnsIndexerIndexingStatus, - }, - ); + async getIndexingMetadataContext(): Promise { + const record = await this.getEnsNodeMetadata({ + key: EnsNodeMetadataKeys.IndexingMetadataContext, + }); if (!record) { - return undefined; + return buildIndexingMetadataContextUninitialized(); } - return deserializeCrossChainIndexingStatusSnapshot(record); + return deserializeIndexingMetadataContext(record); } /** diff --git a/packages/ensdb-sdk/src/client/ensdb-writer.test.ts b/packages/ensdb-sdk/src/client/ensdb-writer.test.ts index 51e7ccf754..d3769d3299 100644 --- a/packages/ensdb-sdk/src/client/ensdb-writer.test.ts +++ b/packages/ensdb-sdk/src/client/ensdb-writer.test.ts @@ -2,9 +2,10 @@ import { migrate } from "drizzle-orm/node-postgres/migrator"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { + buildEnsIndexerStackInfo, + buildIndexingMetadataContextInitialized, deserializeCrossChainIndexingStatusSnapshot, - serializeCrossChainIndexingStatusSnapshot, - serializeEnsIndexerPublicConfig, + serializeIndexingMetadataContext, } from "@ensnode/ensnode-sdk"; import * as ensDbClientMock from "./ensdb-client.mock"; @@ -32,59 +33,46 @@ describe("EnsDbWriter", () => { vi.mocked(migrate).mockClear(); }); - describe("upsertEnsDbVersion", () => { - it("writes the database version metadata", async () => { - const ensDbClient = createEnsDbWriter(); - const { ensNodeSchema } = ensDbClient; + describe("upsertIndexingMetadataContext", () => { + it("serializes and writes the indexing metadata context", async () => { + const ensDbWriter = createEnsDbWriter(); + const { ensNodeSchema } = ensDbWriter; - await ensDbClient.upsertEnsDbVersion("0.2.0"); - - expect(insertMock).toHaveBeenCalledWith(ensNodeSchema.metadata); - expect(valuesMock).toHaveBeenCalledWith({ - ensIndexerSchemaName: ensDbClientMock.ensIndexerSchemaName, - key: EnsNodeMetadataKeys.EnsDbVersion, - value: "0.2.0", - }); - expect(onConflictDoUpdateMock).toHaveBeenCalledWith({ - target: [ensNodeSchema.metadata.ensIndexerSchemaName, ensNodeSchema.metadata.key], - set: { value: "0.2.0" }, - }); - }); - }); - - describe("upsertEnsIndexerPublicConfig", () => { - it("serializes and writes the public config", async () => { - const expectedValue = serializeEnsIndexerPublicConfig(ensDbClientMock.publicConfig); - - await createEnsDbWriter().upsertEnsIndexerPublicConfig(ensDbClientMock.publicConfig); - - expect(valuesMock).toHaveBeenCalledWith({ - ensIndexerSchemaName: ensDbClientMock.ensIndexerSchemaName, - key: EnsNodeMetadataKeys.EnsIndexerPublicConfig, - value: expectedValue, - }); - }); - }); - - describe("upsertIndexingStatusSnapshot", () => { - it("serializes and writes the indexing status snapshot", async () => { - const snapshot = deserializeCrossChainIndexingStatusSnapshot( + const indexingStatus = deserializeCrossChainIndexingStatusSnapshot( ensDbClientMock.serializedSnapshot, ); - const expectedValue = serializeCrossChainIndexingStatusSnapshot(snapshot); + const ensDbPublicConfig = { + versionInfo: { postgresql: "17.4" }, + }; + const ensRainbowPublicConfig = { + serverLabelSet: { labelSetId: "subgraph", highestLabelSetVersion: 0 }, + versionInfo: { ensRainbow: "1.9.0" }, + }; + const stackInfo = buildEnsIndexerStackInfo( + ensDbPublicConfig, + ensDbClientMock.publicConfig, + ensRainbowPublicConfig, + ); + const context = buildIndexingMetadataContextInitialized(indexingStatus, stackInfo); + const expectedValue = serializeIndexingMetadataContext(context); - await createEnsDbWriter().upsertIndexingStatusSnapshot(snapshot); + await ensDbWriter.upsertIndexingMetadataContext(context); + expect(insertMock).toHaveBeenCalledWith(ensNodeSchema.metadata); expect(valuesMock).toHaveBeenCalledWith({ ensIndexerSchemaName: ensDbClientMock.ensIndexerSchemaName, - key: EnsNodeMetadataKeys.EnsIndexerIndexingStatus, + key: EnsNodeMetadataKeys.IndexingMetadataContext, value: expectedValue, }); + expect(onConflictDoUpdateMock).toHaveBeenCalledWith({ + target: [ensNodeSchema.metadata.ensIndexerSchemaName, ensNodeSchema.metadata.key], + set: { value: expectedValue }, + }); }); }); describe("migrateEnsNodeSchema", () => { - it("calls drizzle-orm migrateEnsNodeSchema with the correct parameters", async () => { + it("calls drizzle-orm migrate with the correct parameters", async () => { const migrationsDirPath = "/path/to/migrations"; await createEnsDbWriter().migrateEnsNodeSchema(migrationsDirPath); @@ -95,7 +83,7 @@ describe("EnsDbWriter", () => { }); }); - it("propagates errors from the migrateEnsNodeSchema function", async () => { + it("propagates errors from the migrate function", async () => { const migrationsDirPath = "/path/to/migrations"; vi.mocked(migrate).mockRejectedValueOnce(new Error("Migration failed")); diff --git a/packages/ensdb-sdk/src/client/ensdb-writer.ts b/packages/ensdb-sdk/src/client/ensdb-writer.ts index 2ebbd9e08a..92008792f7 100644 --- a/packages/ensdb-sdk/src/client/ensdb-writer.ts +++ b/packages/ensdb-sdk/src/client/ensdb-writer.ts @@ -1,10 +1,8 @@ import { migrate } from "drizzle-orm/node-postgres/migrator"; import { - type CrossChainIndexingStatusSnapshot, - type EnsIndexerPublicConfig, - serializeCrossChainIndexingStatusSnapshot, - serializeEnsIndexerPublicConfig, + type IndexingMetadataContextInitialized, + serializeIndexingMetadataContext, } from "@ensnode/ensnode-sdk"; import { EnsDbReader } from "./ensdb-reader"; @@ -34,42 +32,16 @@ export class EnsDbWriter extends EnsDbReader { } /** - * Upsert ENSDb Version + * Upsert Indexing Metadata Context Initialized * * @throws when upsert operation failed. */ - async upsertEnsDbVersion(ensDbVersion: string): Promise { - await this.upsertEnsNodeMetadata({ - key: EnsNodeMetadataKeys.EnsDbVersion, - value: ensDbVersion, - }); - } - - /** - * Upsert ENSIndexer Public Config - * - * @throws when upsert operation failed. - */ - async upsertEnsIndexerPublicConfig( - ensIndexerPublicConfig: EnsIndexerPublicConfig, - ): Promise { - await this.upsertEnsNodeMetadata({ - key: EnsNodeMetadataKeys.EnsIndexerPublicConfig, - value: serializeEnsIndexerPublicConfig(ensIndexerPublicConfig), - }); - } - - /** - * Upsert Indexing Status Snapshot - * - * @throws when upsert operation failed. - */ - async upsertIndexingStatusSnapshot( - indexingStatus: CrossChainIndexingStatusSnapshot, + async upsertIndexingMetadataContext( + indexingMetadataContext: IndexingMetadataContextInitialized, ): Promise { await this.upsertEnsNodeMetadata({ - key: EnsNodeMetadataKeys.EnsIndexerIndexingStatus, - value: serializeCrossChainIndexingStatusSnapshot(indexingStatus), + key: EnsNodeMetadataKeys.IndexingMetadataContext, + value: serializeIndexingMetadataContext(indexingMetadataContext), }); } diff --git a/packages/ensdb-sdk/src/client/ensnode-metadata.ts b/packages/ensdb-sdk/src/client/ensnode-metadata.ts index bdb35c4069..9b0a1a2d50 100644 --- a/packages/ensdb-sdk/src/client/ensnode-metadata.ts +++ b/packages/ensdb-sdk/src/client/ensnode-metadata.ts @@ -1,40 +1,31 @@ -import type { - CrossChainIndexingStatusSnapshot, - EnsIndexerPublicConfig, -} from "@ensnode/ensnode-sdk"; +import type { IndexingMetadataContextInitialized } from "@ensnode/ensnode-sdk"; /** * Keys used to distinguish records in `ensnode_metadata` table in the ENSDb. */ export const EnsNodeMetadataKeys = { - EnsDbVersion: "ensdb_version", - EnsIndexerPublicConfig: "ensindexer_public_config", - EnsIndexerIndexingStatus: "ensindexer_indexing_status", + IndexingMetadataContext: "indexing_metadata_context", } as const; export type EnsNodeMetadataKey = (typeof EnsNodeMetadataKeys)[keyof typeof EnsNodeMetadataKeys]; -export interface EnsNodeMetadataEnsDbVersion { - key: typeof EnsNodeMetadataKeys.EnsDbVersion; - value: string; -} - -export interface EnsNodeMetadataEnsIndexerPublicConfig { - key: typeof EnsNodeMetadataKeys.EnsIndexerPublicConfig; - value: EnsIndexerPublicConfig; -} - -export interface EnsNodeMetadataEnsIndexerIndexingStatus { - key: typeof EnsNodeMetadataKeys.EnsIndexerIndexingStatus; - value: CrossChainIndexingStatusSnapshot; +/** + * ENSNode Metadata record for Indexing Metadata Context + * + * This record is used to store the Indexing Metadata Context in + * ENSNode Metadata table for each ENSIndexer instance. + */ +export interface EnsNodeMetadataIndexingMetadataContext { + key: typeof EnsNodeMetadataKeys.IndexingMetadataContext; + value: IndexingMetadataContextInitialized; } /** * ENSNode Metadata * - * Union type gathering all variants of ENSNode Metadata. + * Type alias for ENSNode Metadata records, + * currently only includes the record for Indexing Metadata Context, + * but can be extended in the future to include more types of + * ENSNode Metadata records as needed. */ -export type EnsNodeMetadata = - | EnsNodeMetadataEnsDbVersion - | EnsNodeMetadataEnsIndexerPublicConfig - | EnsNodeMetadataEnsIndexerIndexingStatus; +export type EnsNodeMetadata = EnsNodeMetadataIndexingMetadataContext; diff --git a/packages/ensdb-sdk/src/client/serialize/ensnode-metadata.ts b/packages/ensdb-sdk/src/client/serialize/ensnode-metadata.ts index cae7fcdd34..aeacf63b4d 100644 --- a/packages/ensdb-sdk/src/client/serialize/ensnode-metadata.ts +++ b/packages/ensdb-sdk/src/client/serialize/ensnode-metadata.ts @@ -1,41 +1,13 @@ -import type { - SerializedCrossChainIndexingStatusSnapshot, - SerializedEnsIndexerPublicConfig, -} from "@ensnode/ensnode-sdk"; +import type { SerializedIndexingMetadataContextInitialized } from "@ensnode/ensnode-sdk"; -import type { - EnsNodeMetadata, - EnsNodeMetadataEnsDbVersion, - EnsNodeMetadataEnsIndexerIndexingStatus, - EnsNodeMetadataEnsIndexerPublicConfig, - EnsNodeMetadataKeys, -} from "../ensnode-metadata"; +import type { EnsNodeMetadata, EnsNodeMetadataKeys } from "../ensnode-metadata"; -/** - * Serialized representation of {@link EnsNodeMetadataEnsDbVersion}. - */ -export type SerializedEnsNodeMetadataEnsDbVersion = EnsNodeMetadataEnsDbVersion; - -/** - * Serialized representation of {@link EnsNodeMetadataEnsIndexerPublicConfig}. - */ -export interface SerializedEnsNodeMetadataEnsIndexerPublicConfig { - key: typeof EnsNodeMetadataKeys.EnsIndexerPublicConfig; - value: SerializedEnsIndexerPublicConfig; -} - -/** - * Serialized representation of {@link EnsNodeMetadataEnsIndexerIndexingStatus}. - */ -export interface SerializedEnsNodeMetadataEnsIndexerIndexingStatus { - key: typeof EnsNodeMetadataKeys.EnsIndexerIndexingStatus; - value: SerializedCrossChainIndexingStatusSnapshot; +export interface SerializedEnsNodeMetadataIndexingMetadataContext { + key: typeof EnsNodeMetadataKeys.IndexingMetadataContext; + value: SerializedIndexingMetadataContextInitialized; } /** * Serialized representation of {@link EnsNodeMetadata} */ -export type SerializedEnsNodeMetadata = - | SerializedEnsNodeMetadataEnsDbVersion - | SerializedEnsNodeMetadataEnsIndexerPublicConfig - | SerializedEnsNodeMetadataEnsIndexerIndexingStatus; +export type SerializedEnsNodeMetadata = SerializedEnsNodeMetadataIndexingMetadataContext; diff --git a/packages/ensnode-sdk/src/ensnode/index.ts b/packages/ensnode-sdk/src/ensnode/index.ts index 227ed19cdb..ece02b5ce5 100644 --- a/packages/ensnode-sdk/src/ensnode/index.ts +++ b/packages/ensnode-sdk/src/ensnode/index.ts @@ -5,3 +5,4 @@ export { } from "./client"; export * from "./client-error"; export * from "./deployments"; +export * from "./metadata"; diff --git a/packages/ensnode-sdk/src/ensnode/metadata/deserialize/indexing-metadata-context.ts b/packages/ensnode-sdk/src/ensnode/metadata/deserialize/indexing-metadata-context.ts new file mode 100644 index 0000000000..2ada8c6bec --- /dev/null +++ b/packages/ensnode-sdk/src/ensnode/metadata/deserialize/indexing-metadata-context.ts @@ -0,0 +1,73 @@ +import { prettifyError } from "zod/v4"; + +import { buildUnvalidatedCrossChainIndexingStatusSnapshot } from "../../../indexing-status"; +import type { Unvalidated } from "../../../shared/types"; +import { buildUnvalidatedEnsIndexerStackInfo } from "../../../stack-info"; +import { + type IndexingMetadataContext, + type IndexingMetadataContextInitialized, + IndexingMetadataContextStatusCodes, +} from "../indexing-metadata-context"; +import type { + SerializedIndexingMetadataContext, + SerializedIndexingMetadataContextInitialized, +} from "../serialize/indexing-metadata-context"; +import { + makeIndexingMetadataContextSchema, + makeSerializedIndexingMetadataContextSchema, +} from "../zod-schemas/indexing-metadata-context"; + +/** + * Builds an unvalidated {@link IndexingMetadataContextInitialized} object. + */ +function buildUnvalidatedIndexingMetadataContextInitialized( + serializedIndexingMetadataContext: SerializedIndexingMetadataContextInitialized, +): Unvalidated { + return { + statusCode: serializedIndexingMetadataContext.statusCode, + indexingStatus: buildUnvalidatedCrossChainIndexingStatusSnapshot( + serializedIndexingMetadataContext.indexingStatus, + ), + stackInfo: buildUnvalidatedEnsIndexerStackInfo(serializedIndexingMetadataContext.stackInfo), + }; +} + +/** + * Builds an unvalidated {@link IndexingMetadataContext} object to be + * validated with {@link makeIndexingMetadataContextSchema}. + * + * @param serializedIndexingMetadataContext - The serialized indexing metadata context to build from. + */ +function buildUnvalidatedIndexingMetadataContext( + serializedIndexingMetadataContext: SerializedIndexingMetadataContext, +): Unvalidated { + switch (serializedIndexingMetadataContext.statusCode) { + case IndexingMetadataContextStatusCodes.Uninitialized: + return serializedIndexingMetadataContext; + + case IndexingMetadataContextStatusCodes.Initialized: + return buildUnvalidatedIndexingMetadataContextInitialized(serializedIndexingMetadataContext); + } +} + +/** + * Deserialize a serialized {@link IndexingMetadataContext} object. + */ +export function deserializeIndexingMetadataContext( + serializedIndexingMetadataContext: Unvalidated, + valueLabel?: string, +): IndexingMetadataContext { + const label = valueLabel ?? "IndexingMetadataContext"; + + const parsed = makeSerializedIndexingMetadataContextSchema(label) + .transform(buildUnvalidatedIndexingMetadataContext) + .pipe(makeIndexingMetadataContextSchema(label)) + .safeParse(serializedIndexingMetadataContext); + + if (parsed.error) { + throw new Error( + `Cannot deserialize IndexingMetadataContext:\n${prettifyError(parsed.error)}\n`, + ); + } + return parsed.data; +} diff --git a/packages/ensnode-sdk/src/ensnode/metadata/index.ts b/packages/ensnode-sdk/src/ensnode/metadata/index.ts new file mode 100644 index 0000000000..483ae202d8 --- /dev/null +++ b/packages/ensnode-sdk/src/ensnode/metadata/index.ts @@ -0,0 +1,4 @@ +export * from "./deserialize/indexing-metadata-context"; +export * from "./indexing-metadata-context"; +export * from "./serialize/indexing-metadata-context"; +export * from "./validate/indexing-metadata-context"; diff --git a/packages/ensnode-sdk/src/ensnode/metadata/indexing-metadata-context.ts b/packages/ensnode-sdk/src/ensnode/metadata/indexing-metadata-context.ts new file mode 100644 index 0000000000..457a9cd2ce --- /dev/null +++ b/packages/ensnode-sdk/src/ensnode/metadata/indexing-metadata-context.ts @@ -0,0 +1,72 @@ +import type { CrossChainIndexingStatusSnapshot } from "../../indexing-status"; +import type { EnsIndexerStackInfo } from "../../stack-info"; +import { validateIndexingMetadataContextInitialized } from "./validate/indexing-metadata-context"; + +/** + * A status code for an indexing metadata context + */ +export const IndexingMetadataContextStatusCodes = { + /** + * Represents that no indexing metadata context has been initialized + * for the ENSIndexer Schema Name in the ENSNode Metadata table in ENSDb. + */ + Uninitialized: "uninitialized", + + /** + * Represents that the indexing metadata context has been initialized + * for the ENSIndexer Schema Name in the ENSNode Metadata table in ENSDb. + */ + Initialized: "initialized", +} as const; + +/** + * The derived string union of possible {@link IndexingMetadataContextStatusCodes}. + */ +export type IndexingMetadataContextStatusCode = + (typeof IndexingMetadataContextStatusCodes)[keyof typeof IndexingMetadataContextStatusCodes]; + +export interface IndexingMetadataContextUninitialized { + statusCode: typeof IndexingMetadataContextStatusCodes.Uninitialized; +} + +export interface IndexingMetadataContextInitialized { + statusCode: typeof IndexingMetadataContextStatusCodes.Initialized; + indexingStatus: CrossChainIndexingStatusSnapshot; + stackInfo: EnsIndexerStackInfo; +} + +/** + * Indexing Metadata Context + * + * Use the {@link IndexingMetadataContext.statusCode} field to determine + * the specific type interpretation at runtime. + */ +export type IndexingMetadataContext = + | IndexingMetadataContextUninitialized + | IndexingMetadataContextInitialized; + +/** + * Build an {@link IndexingMetadataContextUninitialized} object. + */ +export function buildIndexingMetadataContextUninitialized(): IndexingMetadataContextUninitialized { + return { + statusCode: IndexingMetadataContextStatusCodes.Uninitialized, + }; +} + +/** + * Build an {@link IndexingMetadataContextInitialized} object. + * + * @throws Error if the provided parameters do not satisfy the validation + * criteria for an {@link IndexingMetadataContextInitialized} object. + */ +export function buildIndexingMetadataContextInitialized( + indexingStatus: CrossChainIndexingStatusSnapshot, + stackInfo: EnsIndexerStackInfo, +): IndexingMetadataContextInitialized { + return validateIndexingMetadataContextInitialized({ + statusCode: IndexingMetadataContextStatusCodes.Initialized, + indexingStatus, + stackInfo, + }); +} diff --git a/packages/ensnode-sdk/src/ensnode/metadata/serialize/indexing-metadata-context.ts b/packages/ensnode-sdk/src/ensnode/metadata/serialize/indexing-metadata-context.ts new file mode 100644 index 0000000000..cde334e7a9 --- /dev/null +++ b/packages/ensnode-sdk/src/ensnode/metadata/serialize/indexing-metadata-context.ts @@ -0,0 +1,66 @@ +import { + type SerializedCrossChainIndexingStatusSnapshot, + serializeCrossChainIndexingStatusSnapshot, +} from "../../../indexing-status/serialize/cross-chain-indexing-status-snapshot"; +import { + type SerializedEnsIndexerStackInfo, + serializeEnsIndexerStackInfo, +} from "../../../stack-info/serialize/ensindexer-stack-info"; +import { + type IndexingMetadataContext, + type IndexingMetadataContextInitialized, + IndexingMetadataContextStatusCodes, + type IndexingMetadataContextUninitialized, +} from "../indexing-metadata-context"; + +/** + * Serialized representation of an {@link IndexingMetadataContextUninitialized}. + */ +export type SerializedIndexingMetadataContextUninitialized = IndexingMetadataContextUninitialized; + +/** + * Serialized representation of an {@link IndexingMetadataContextInitialized}. + */ +export interface SerializedIndexingMetadataContextInitialized + extends Omit { + indexingStatus: SerializedCrossChainIndexingStatusSnapshot; + stackInfo: SerializedEnsIndexerStackInfo; +} + +/** + * Serialized representation of an {@link IndexingMetadataContext}. + * + * Use the {@link SerializedIndexingMetadataContext.statusCode} field to + * determine the specific type interpretation at runtime. + */ +export type SerializedIndexingMetadataContext = + | SerializedIndexingMetadataContextUninitialized + | SerializedIndexingMetadataContextInitialized; + +export function serializeIndexingMetadataContextInitialized( + context: IndexingMetadataContextInitialized, +): SerializedIndexingMetadataContextInitialized { + const { statusCode, indexingStatus, stackInfo } = context; + return { + statusCode, + indexingStatus: serializeCrossChainIndexingStatusSnapshot(indexingStatus), + stackInfo: serializeEnsIndexerStackInfo(stackInfo), + }; +} + +export function serializeIndexingMetadataContext( + context: IndexingMetadataContextUninitialized, +): SerializedIndexingMetadataContextUninitialized; +export function serializeIndexingMetadataContext( + context: IndexingMetadataContextInitialized, +): SerializedIndexingMetadataContextInitialized; +export function serializeIndexingMetadataContext( + context: IndexingMetadataContext, +): SerializedIndexingMetadataContext { + switch (context.statusCode) { + case IndexingMetadataContextStatusCodes.Uninitialized: + return context; + case IndexingMetadataContextStatusCodes.Initialized: + return serializeIndexingMetadataContextInitialized(context); + } +} diff --git a/packages/ensnode-sdk/src/ensnode/metadata/validate/indexing-metadata-context.ts b/packages/ensnode-sdk/src/ensnode/metadata/validate/indexing-metadata-context.ts new file mode 100644 index 0000000000..691c97c8ad --- /dev/null +++ b/packages/ensnode-sdk/src/ensnode/metadata/validate/indexing-metadata-context.ts @@ -0,0 +1,24 @@ +import { prettifyError } from "zod/v4"; + +import type { Unvalidated } from "../../../shared/types"; +import type { IndexingMetadataContextInitialized } from "../indexing-metadata-context"; +import { makeIndexingMetadataContextInitializedSchema } from "../zod-schemas/indexing-metadata-context"; + +/** + * Validate a maybe {@link IndexingMetadataContextInitialized} object. + */ +export function validateIndexingMetadataContextInitialized( + maybeIndexingMetadataContext: Unvalidated, + valueLabel?: string, +): IndexingMetadataContextInitialized { + const result = makeIndexingMetadataContextInitializedSchema(valueLabel).safeParse( + maybeIndexingMetadataContext, + ); + + if (result.error) { + throw new Error( + `Cannot validate IndexingMetadataContextInitialized:\n${prettifyError(result.error)}\n`, + ); + } + return result.data; +} diff --git a/packages/ensnode-sdk/src/ensnode/metadata/zod-schemas/indexing-metadata-context.ts b/packages/ensnode-sdk/src/ensnode/metadata/zod-schemas/indexing-metadata-context.ts new file mode 100644 index 0000000000..25bead23bd --- /dev/null +++ b/packages/ensnode-sdk/src/ensnode/metadata/zod-schemas/indexing-metadata-context.ts @@ -0,0 +1,58 @@ +import { z } from "zod/v4"; + +import { + makeCrossChainIndexingStatusSnapshotSchema, + makeSerializedCrossChainIndexingStatusSnapshotSchema, +} from "../../../indexing-status/zod-schema/cross-chain-indexing-status-snapshot"; +import { + makeEnsIndexerStackInfoSchema, + makeSerializedEnsIndexerStackInfoSchema, +} from "../../../stack-info/zod-schemas/ensindexer-stack-info"; +import { IndexingMetadataContextStatusCodes } from "../indexing-metadata-context"; + +const makeSerializedIndexingMetadataContextUninitializedSchema = (_valueLabel?: string) => { + return z.object({ + statusCode: z.literal(IndexingMetadataContextStatusCodes.Uninitialized), + }); +}; + +export const makeSerializedIndexingMetadataContextInitializedSchema = (valueLabel?: string) => { + const label = valueLabel ?? "SerializedIndexingMetadataContextInitialized"; + + return z.object({ + statusCode: z.literal(IndexingMetadataContextStatusCodes.Initialized), + indexingStatus: makeSerializedCrossChainIndexingStatusSnapshotSchema(`${label}.indexingStatus`), + stackInfo: makeSerializedEnsIndexerStackInfoSchema(`${label}.stackInfo`), + }); +}; + +export const makeSerializedIndexingMetadataContextSchema = (valueLabel?: string) => { + const label = valueLabel ?? "SerializedIndexingMetadataContext"; + + return z.discriminatedUnion("statusCode", [ + makeSerializedIndexingMetadataContextUninitializedSchema(label), + makeSerializedIndexingMetadataContextInitializedSchema(label), + ]); +}; + +const makeIndexingMetadataContextUninitializedSchema = + makeSerializedIndexingMetadataContextUninitializedSchema; + +export const makeIndexingMetadataContextInitializedSchema = (valueLabel?: string) => { + const label = valueLabel ?? "IndexingMetadataContextInitialized"; + + return z.object({ + statusCode: z.literal(IndexingMetadataContextStatusCodes.Initialized), + indexingStatus: makeCrossChainIndexingStatusSnapshotSchema(`${label}.indexingStatus`), + stackInfo: makeEnsIndexerStackInfoSchema(`${label}.stackInfo`), + }); +}; + +export const makeIndexingMetadataContextSchema = (valueLabel?: string) => { + const label = valueLabel ?? "IndexingMetadataContext"; + + return z.discriminatedUnion("statusCode", [ + makeIndexingMetadataContextUninitializedSchema(label), + makeIndexingMetadataContextInitializedSchema(label), + ]); +}; diff --git a/packages/integration-test-env/src/orchestrator.ts b/packages/integration-test-env/src/orchestrator.ts index 17e2a72d58..c8d5f9c179 100644 --- a/packages/integration-test-env/src/orchestrator.ts +++ b/packages/integration-test-env/src/orchestrator.ts @@ -39,7 +39,10 @@ import { } from "testcontainers"; import { ENSNamespaceIds } from "@ensnode/datasources"; -import { OmnichainIndexingStatusIds } from "@ensnode/ensnode-sdk"; +import { + IndexingMetadataContextStatusCodes, + OmnichainIndexingStatusIds, +} from "@ensnode/ensnode-sdk"; const MONOREPO_ROOT = resolve(import.meta.dirname, "../../.."); const DOCKER_DIR = resolve(MONOREPO_ROOT, "docker"); @@ -199,9 +202,10 @@ async function pollIndexingStatus( while (Date.now() - start < timeoutMs) { checkAborted(); try { - const snapshot = await ensDbClient.getIndexingStatusSnapshot(); - if (snapshot !== undefined) { - const omnichainStatus = snapshot.omnichainSnapshot.omnichainStatus; + const indexingMetadataContext = await ensDbClient.getIndexingMetadataContext(); + + if (indexingMetadataContext.statusCode === IndexingMetadataContextStatusCodes.Initialized) { + const { omnichainStatus } = indexingMetadataContext.indexingStatus.omnichainSnapshot; log(`Omnichain status: ${omnichainStatus}`); if ( omnichainStatus === OmnichainIndexingStatusIds.Following ||