Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
4b125d0
Introduce `IndexingMetadataContext` data model to ENSNode SDK
tk-o Apr 24, 2026
dcad533
Create `init*` functions for running event handlers preconditions
tk-o Apr 24, 2026
a5a8bd6
Execute `migrateEnsNodeSchema` from inside of `initIndexingSetup` fun…
tk-o Apr 24, 2026
c57c2ac
Use `EnsIndexerStackInfo` for `stackInfo` field in `IndexingMetadataC…
tk-o Apr 24, 2026
a28b0de
Add `getIndexingMetadataContext` method to `EnsDbReader` class
tk-o Apr 24, 2026
e91bd13
Add `upsertIndexingMetadataContext` method to `EnsDbWriter` class
tk-o Apr 24, 2026
8892dd5
Update `EnsDbWriterWorker` class to serve a new limited role
tk-o Apr 24, 2026
ee1190e
Fix ponder build issue
tk-o Apr 24, 2026
4954e21
Fix typos
tk-o Apr 24, 2026
2f8532e
Implement full tasks sequence for `initIndexingOnchainEvents`
tk-o Apr 25, 2026
361e99d
Merge `initIndexingSetup` function into `initIndexingOnchainEvents`
tk-o Apr 25, 2026
7595a41
Update unit tests
tk-o Apr 25, 2026
1902bfb
Use `getIndexingMetadataContext` for all ENSNode Metadata reads from …
tk-o Apr 25, 2026
6bda3d6
Merge remote-tracking branch 'origin/main' into feat/indexing-metadat…
tk-o Apr 25, 2026
e3ddda0
Simplify `EnsDbReader` class and `EnsDbWriter` class
tk-o Apr 25, 2026
4684fb4
Fix tests
tk-o Apr 25, 2026
1ff960e
Apply AI PR feedback
tk-o Apr 26, 2026
41060d0
Simplify `initIndexingOnchainEvents` logic
tk-o Apr 26, 2026
e1d6d04
Update unit tests
tk-o Apr 26, 2026
98e6c45
Simplify `initIndexingOnchainEvents` function
tk-o Apr 26, 2026
eebe386
Simplify logic in ENSIndexer HTTP endpoints
tk-o Apr 26, 2026
11711ca
Improve naming
tk-o Apr 26, 2026
89c974a
Make indexing status cache and stack info cache for ENSApi to use the…
tk-o Apr 27, 2026
292ed35
Merge remote-tracking branch 'origin/main' into feat/indexing-metadat…
tk-o Apr 27, 2026
159c4ff
Improve code docs
tk-o Apr 27, 2026
b36418b
Create a mock file for config.schema.ts
tk-o Apr 27, 2026
f3355ef
Integrate ENSDb health check and readiness check into `initIndexingOn…
tk-o Apr 27, 2026
40961af
Update code docs
tk-o Apr 27, 2026
6279dea
Apply AI PR feedback
tk-o Apr 27, 2026
43ef45b
Merge remote-tracking branch 'origin/main' into feat/indexing-metadat…
tk-o Apr 27, 2026
ad1564f
Apply AI PR feedback
tk-o Apr 28, 2026
110927d
Add changeset
tk-o Apr 28, 2026
bcd81fe
Merge remote-tracking branch 'origin/main' into feat/indexing-metadat…
tk-o Apr 28, 2026
698191f
Apply AI PR feedback
tk-o Apr 28, 2026
725901f
Fix re-exports
tk-o Apr 29, 2026
55ae265
Merge remote-tracking branch 'origin/main' into feat/indexing-metadat…
tk-o Apr 29, 2026
84b52e1
Create ENSRainbow readiness logic inline in ENSIndexer
tk-o Apr 29, 2026
5cc44ea
Fix typo
tk-o Apr 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 0 additions & 17 deletions apps/ensindexer/ponder/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hmm. Should we rename this? It seems strange to call it an "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();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Several lines below I see:

// use ENSNode HTTP API at /api
app.route("/api", ensNodeApi);

Please see related feedback above on the terminology in the comment

// set the X-ENSIndexer-Version header to the current version
Expand Down
174 changes: 19 additions & 155 deletions apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import pRetry from "p-retry";
import type { EnsDbWriter } from "@ensnode/ensdb-sdk";
import {
buildCrossChainIndexingStatusSnapshotOmnichain,
buildIndexingMetadataContextInitialized,
type CrossChainIndexingStatusSnapshot,
type EnsIndexerPublicConfig,
Comment thread
tk-o marked this conversation as resolved.
Outdated
IndexingMetadataContextStatusCodes,
OmnichainIndexingStatusIds,
type OmnichainIndexingStatusSnapshot,
validateEnsIndexerPublicConfigCompatibility,
Expand Down Expand Up @@ -97,32 +99,9 @@ export class EnsDbWriterWorker {
throw new Error("EnsDbWriterWorker is already running");
}
Comment thread
tk-o marked this conversation as resolved.

// 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.
// Task 1: recurring upsert of Indexing Metadata Context into ENSDb.
this.indexingStatusInterval = setInterval(
() => this.upsertIndexingStatusSnapshot(),
() => this.upsertIndexingMetadataContext(),
secondsToMilliseconds(INDEXING_STATUS_RECORD_UPDATE_INTERVAL),
);
}
Comment thread
tk-o marked this conversation as resolved.
Expand All @@ -146,156 +125,41 @@ 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<EnsIndexerPublicConfig> {
/**
* 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.
*
* This method is called by the scheduler at regular intervals.
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

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

The JSDoc for this method still says it upserts an "Indexing Status Snapshot", but the method now upserts the full indexing metadata context. Please update the comment to reflect what is actually written to ENSDb.

Suggested change
* Upsert the current Indexing Status Snapshot into ENSDb.
*
* This method is called by the scheduler at regular intervals.
* Upsert the current Indexing Metadata Context into ENSDb.
*
* This method is called by the scheduler at regular intervals to refresh
* the indexing metadata context written to ENSDb.

Copilot uses AI. Check for mistakes.
* Errors are logged but not thrown, to keep the worker running.
Comment thread
tk-o marked this conversation as resolved.
Outdated
*/
Comment thread
tk-o marked this conversation as resolved.
private async upsertIndexingStatusSnapshot(): Promise<void> {
private async upsertIndexingMetadataContext(): Promise<void> {
try {
// get system timestamp for the current iteration
const snapshotTime = getUnixTime(new Date());
const indexingMetadataContext = await this.ensDbClient.getIndexingMetadataContext();

const omnichainSnapshot = await this.getValidatedIndexingStatusSnapshot();
if (indexingMetadataContext.statusCode === IndexingMetadataContextStatusCodes.Uninitialized) {
throw new Error(
`Cannot upsert Indexing Status Snapshot into ENSDb because Indexing Metadata Context should be be initialized first`,
Comment thread
tk-o marked this conversation as resolved.
Outdated
Comment thread
tk-o marked this conversation as resolved.
Outdated
);
}

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

const crossChainSnapshot = buildCrossChainIndexingStatusSnapshotOmnichain(
omnichainSnapshot,
snapshotTime,
const updatedIndexingMetadataContext = buildIndexingMetadataContextInitialized(
buildCrossChainIndexingStatusSnapshotOmnichain(omnichainSnapshot, snapshotTime),
indexingMetadataContext.stackInfo,
);

await this.ensDbClient.upsertIndexingStatusSnapshot(crossChainSnapshot);
await this.ensDbClient.upsertIndexingMetadataContext(updatedIndexingMetadataContext);
} catch (error) {
logger.error({
msg: "Failed to upsert indexing status snapshot",
msg: "Failed to upsert indexing metadata context",
error,
module: "EnsDbWriterWorker",
});
// 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<OmnichainIndexingStatusSnapshot> {
const omnichainSnapshot = await this.indexingStatusBuilder.getOmnichainIndexingStatusSnapshot();

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

return omnichainSnapshot;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { getUnixTime } from "date-fns/fp/getUnixTime";

import {
buildCrossChainIndexingStatusSnapshotOmnichain,
buildEnsIndexerStackInfo,
buildIndexingMetadataContextInitialized,
IndexingMetadataContextStatusCodes,
OmnichainIndexingStatusIds,
} from "@ensnode/ensnode-sdk";

import { ensDbClient } from "@/lib/ensdb/singleton";
import { startEnsDbWriterWorker } from "@/lib/ensdb-writer-worker/singleton";
import { ensRainbowClient, waitForEnsRainbowToBeReady } from "@/lib/ensrainbow/singleton";
import { indexingStatusBuilder } from "@/lib/indexing-status-builder/singleton";
import { publicConfigBuilder } from "@/lib/public-config-builder/singleton";

/**
* 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(),
* ]);
* ```
*/
export async function initIndexingOnchainEvents(): Promise<void> {
try {
const indexingMetadataContext = await ensDbClient.getIndexingMetadataContext();
console.log("Indexing Metadata Context:", indexingMetadataContext);
Comment thread
tk-o marked this conversation as resolved.
Outdated
const indexingStatus = await indexingStatusBuilder.getOmnichainIndexingStatusSnapshot();
const ensIndexerPublicConfig = await publicConfigBuilder.getPublicConfig();
const ensDbPublicConfig = await ensDbClient.buildEnsDbPublicConfig();

Comment thread
tk-o marked this conversation as resolved.
Outdated
if (indexingMetadataContext.statusCode === IndexingMetadataContextStatusCodes.Uninitialized) {
// Invariant: indexing status must be "unstarted"
if (indexingStatus.omnichainStatus !== OmnichainIndexingStatusIds.Unstarted) {
throw new Error(
`Invariant violation: expected omnichain indexing status to be "unstarted" when initializing indexing of onchain events for the first time, but got "${indexingStatus.omnichainStatus}" instead`,
);
}
} else {
// if (ensIndexerPublicConfig.ensIndexerBuildId !== indexingMetadataContext.stackInfo.ensIndexer.ensIndexerBuildId) {
// TODO: store the `ensIndexerPublicConfig` object in ENSDb so `indexingMetadataContext.stackInfo.ensIndexer` is updated
// }
}
Comment thread
tk-o marked this conversation as resolved.

await waitForEnsRainbowToBeReady();

const ensRainbowPublicConfig = await ensRainbowClient.config();
const now = getUnixTime(new Date());
const updatedIndexingMetadataContext = buildIndexingMetadataContextInitialized(
buildCrossChainIndexingStatusSnapshotOmnichain(indexingStatus, now),
buildEnsIndexerStackInfo(ensDbPublicConfig, ensIndexerPublicConfig, ensRainbowPublicConfig),
);

// TODO: check ENSRainbow compatibility
if (
ensRainbowPublicConfig.serverLabelSet.labelSetId <
ensIndexerPublicConfig.clientLabelSet.labelSetId
) {
throw new Error(
`ENSRainbow instance is not compatible with the current ENSIndexer instance: ENSRainbow serverLabelSetId (${ensRainbowPublicConfig.serverLabelSet.labelSetId}) is less than ENSIndexer clientLabelSetId (${ensIndexerPublicConfig.clientLabelSet.labelSetId})`,
);
}

await ensDbClient.upsertIndexingMetadataContext(updatedIndexingMetadataContext);

// TODO: start Indexing Status Sync worker
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could you please add a follow-up issue to our GitHub for this and add a link to it in a comment here? Thanks

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

// It will be responsible for keeping the indexing status stored within Indexing Metadata Context record in ENSDb up to date
// await indexingStatusSyncWorker.start();
startEnsDbWriterWorker();
Comment thread
tk-o marked this conversation as resolved.
Outdated
Comment thread
tk-o marked this conversation as resolved.
} catch (error) {
// If any error happens during the execution of the preconditions for 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.
console.error("Failed to execute preconditions for onchain events:", error);
process.exit(1);
Comment thread
tk-o marked this conversation as resolved.
Outdated
}
Comment thread
tk-o marked this conversation as resolved.
Outdated
}
53 changes: 53 additions & 0 deletions apps/ensindexer/src/lib/indexing-engines/init-indexing-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* This module defines the initialization logic for the setup handlers of
* the Ponder indexing engine executed in an ENSIndexer instance.
*
* Setup 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.
* 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.
*/

import { logger } from "@/lib/logger";

/**
* Initialize indexing setup
*
* Runs once per ENSIndexer instance lifetime to initialize indexing setup.
*
* Since multiple ENSIndexer instances may run concurrently against the same
* ENSDb instance, this function MUST BE idempotent and race-condition-safe.
*
* Completion of this function unblocks the following sequence of events
* during ENSIndexer startup:
* 1. "setup" event handlers execute
* 2. Ponder Checkpoints initialize
* 3. IndexingStatusBuilder can build OmnichainIndexingStatusSnapshot
* via LocalPonderClient (which queries the Ponder HTTP API)
*
* @throws Error if any precondition is not satisfied.
*/
export async function initIndexingSetup(): Promise<void> {
const { migrateEnsNodeSchema } = await import("@/lib/ensdb/migrate-ensnode-schema");
Comment thread
tk-o marked this conversation as resolved.
Outdated
// Ensure 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",
Comment thread
tk-o marked this conversation as resolved.
Outdated
Comment thread
vercel[bot] marked this conversation as resolved.
Outdated
Comment thread
tk-o marked this conversation as resolved.
Outdated
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
process.exitCode = 1;
throw error;
});
}
Comment thread
tk-o marked this conversation as resolved.
Outdated
Loading
Loading