Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ef8a9c3
perf(ensv2): skip label healing when label already indexed
shrugs Apr 23, 2026
16abf3d
perf(ensv2): memoize ensureAccount within the indexer process
shrugs Apr 23, 2026
e6523a1
perf(protocol-acceleration): memoize node migration status
shrugs Apr 23, 2026
1895a9b
chore(ensindexer): log indexing throughput every minute
shrugs Apr 23, 2026
6ae1f92
chore: add @ensnode/ensindexer-perf-testing package
shrugs Apr 23, 2026
aba0e5b
Merge branch 'main' into fix/ensv2-indexing-slowness
shrugs Apr 23, 2026
9a114e5
fix: openapi-spec
shrugs Apr 23, 2026
7e480ac
fix: pnpm install new package
shrugs Apr 23, 2026
55a0916
chore(perf-testing): rename grafana datasource provisioning file
shrugs Apr 23, 2026
c8f9c57
fix(ensv2): restore hash-only → plaintext upgrade in RegistrarController
shrugs Apr 23, 2026
2ae4867
test(ensindexer): mock @/lib/logger in ponder.test.ts
shrugs Apr 23, 2026
638bdc5
style: tighten comments + biome format
shrugs Apr 23, 2026
0726714
Merge branch 'main' into fix/ensv2-indexing-slowness
shrugs Apr 23, 2026
5c1b621
fix(perf-testing): pin images, bind loopback, fix uid + panel overlap
shrugs Apr 23, 2026
a067a75
chore(perf-testing): extract wipe into wipe.sh
shrugs Apr 23, 2026
88edd41
docs(ensindexer): correct eventHandlerPreconditions comment
shrugs Apr 23, 2026
578b898
fix: typo
shrugs Apr 23, 2026
8301161
refactor(protocol-acceleration): fold nodeIsMigrated cache into a sin…
shrugs Apr 23, 2026
fb3639b
refactor: tighten memo pattern across ensureAccount + nodeIsMigrated
shrugs Apr 23, 2026
543dc1e
chore: rename module-local memo to `cache`
shrugs Apr 23, 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
3 changes: 2 additions & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
"@docs/ensnode",
"@docs/ensrainbow",
"@namehash/ens-referrals",
"@namehash/namehash-ui"
"@namehash/namehash-ui",
"@ensnode/ensindexer-perf-testing"
]
],
"updateInternalDependencies": "patch",
Expand Down
25 changes: 24 additions & 1 deletion apps/ensindexer/src/lib/ensv2/account-db-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,28 @@
import type { Address } from "enssdk";
import type { Address, NormalizedAddress } from "enssdk";

import { interpretAddress } from "@ensnode/ensnode-sdk";

import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-engines/ponder";

/**
* Process-local memo of addresses we have already upserted into `ensIndexerSchema.account`
* within this indexer process.
*
* ensureAccount is called from many handlers on every event, but the set of distinct accounts
* grows much more slowly than the event stream — the zero address, common controllers, and
* active registrants recur constantly. Measured against a benchmark of average handler duration
* over 10M events on mainnet (ensv2,protocol-acceleration, ENSRainbow stubbed), this memo
* deduplicated ~40% of account upserts (4.16M of ~10.4M) and cut wall-clock by ~5%.
*
* Safety:
* - The underlying insert is `onConflictDoNothing`, so repeat inserts are idempotent. The memo
* is purely an optimization and does not change semantics.
* - On process restart the Set resets. We will re-insert accounts we saw last time, but the
* insert is still idempotent, so this is still correct — it just costs one redundant DB op
* the first time each account is seen after a restart.
*/
const cache = new Set<NormalizedAddress>();

/**
* Ensures that the account identified by `address` exists.
* If `address` is the zeroAddress, no-op.
Expand All @@ -12,6 +31,10 @@ export async function ensureAccount(context: IndexingEngineContext, address: Add
const interpreted = interpretAddress(address);
if (interpreted === null) return;

// memoize the below operation by `interpreted`
if (cache.has(interpreted)) return;
cache.add(interpreted);

await context.ensDb
.insert(ensIndexerSchema.account)
.values({ id: interpreted })
Expand Down
17 changes: 11 additions & 6 deletions apps/ensindexer/src/lib/ensv2/label-db-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ import {
import { labelByLabelHash } from "@/lib/graphnode-helpers";
import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-engines/ponder";

/**
* Determines whether the Label identified by `labelHash` has already been indexed.
*/
export async function labelExists(context: IndexingEngineContext, labelHash: LabelHash) {
const existing = await context.ensDb.find(ensIndexerSchema.label, { labelHash });
return existing !== null;
}

/**
* Ensures that the LiteralLabel `label` is interpreted and upserted into the Label rainbow table.
*/
Expand All @@ -24,14 +32,11 @@ export async function ensureLabel(context: IndexingEngineContext, label: Literal
}

/**
* Ensures that the LabelHash `labelHash` is available in the Label rainbow table, attempting an
* ENSRainbow heal if this is the first time it has been encountered.
* Ensures that the LabelHash `labelHash` is available in the Label rainbow table, also attempting
* an ENSRainbow heal. To avoid duplicate ENSRainbow healing requests, callers must gate this
* function on {@link labelExists} returning false.
*/
Comment thread
shrugs marked this conversation as resolved.
export async function ensureUnknownLabel(context: IndexingEngineContext, labelHash: LabelHash) {
// do nothing for existing labels, they're either healed or we don't know them
const exists = await context.ensDb.find(ensIndexerSchema.label, { labelHash });
if (exists) return;

// attempt ENSRainbow heal
const healedLabel = await labelByLabelHash(labelHash);

Expand Down
2 changes: 2 additions & 0 deletions apps/ensindexer/src/lib/indexing-engines/ponder.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { Context, EventNames } from "ponder:registry";
import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest";

import "@/lib/__test__/mockLogger";

import type { IndexingEngineContext, IndexingEngineEvent } from "./ponder";

const { mockPonderOn } = vi.hoisted(() => ({ mockPonderOn: vi.fn() }));
Expand Down
38 changes: 34 additions & 4 deletions apps/ensindexer/src/lib/indexing-engines/ponder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from "ponder:registry";

import { waitForEnsRainbowToBeReady } from "@/lib/ensrainbow/singleton";
import { logger } from "@/lib/logger";

/**
* Context passed to event handlers registered with
Expand Down Expand Up @@ -179,19 +180,48 @@ async function initializeIndexingActivation(): Promise<void> {
let indexingSetupPromise: Promise<void> | null = null;
let indexingActivationPromise: Promise<void> | null = null;

// Cumulative events-per-second tracking across the process lifetime. Logged at most
// once per minute. Overhead is one Date.now() and a counter increment per event.
const EPS_LOG_INTERVAL_MS = 60_000;
let epsTotalEvents = 0;
let epsStartTime: number | null = null;
let epsLastLogTime = 0;

function recordEventForEps(): void {
const now = Date.now();
if (epsStartTime === null) {
epsStartTime = now;
epsLastLogTime = now;
}
epsTotalEvents++;
if (now - epsLastLogTime <= EPS_LOG_INTERVAL_MS) return;
const durationSec = (now - epsStartTime) / 1000;
logger.info({
msg: "Indexing throughput",
events: epsTotalEvents,
durationSec: Number(durationSec.toFixed(1)),
eps: Number((epsTotalEvents / durationSec).toFixed(2)),
});
epsLastLogTime = now;
}
Comment thread
shrugs marked this conversation as resolved.

Comment thread
shrugs marked this conversation as resolved.
/**
* Execute any necessary preconditions before running an event handler
* for a given event type.
*
* Some event handlers may have preconditions that need to be met before
* they can run.
*
* This function is idempotent and will only execute its logic once, even if
* called multiple times. This is to ensure that we affect the "hot path" of
* indexing as little as possible, since this function is called for every
* "onchain" event.
* The Setup and Onchain preconditions are memoized and execute their logic only
* once per process, regardless of how often this function is called — essential
* because it's invoked for every indexed event. EPS accounting via
* {@link recordEventForEps} runs on every call, but its hot-path cost is a
* single Date.now() and a counter increment; structured logging is emitted at
* most once per {@link EPS_LOG_INTERVAL_MS}.
*/
async function eventHandlerPreconditions(eventType: EventTypeId): Promise<void> {
recordEventForEps();

Comment thread
shrugs marked this conversation as resolved.
Comment thread
shrugs marked this conversation as resolved.
switch (eventType) {
case EventTypeIds.Setup: {
if (indexingSetupPromise === null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-eng

const ensRootChainId = getENSRootChainId(config.namespace);

/**
* Process-local cache of node migration status.
*
* `nodeIsMigrated` is called as a precondition on every ENSv1RegistryOld event handler (NewOwner,
* Transfer, NewTTL, NewResolver).
*
* Restart-safe: the Map repopulates via DB reads on cache miss.
*/
const cache = new Map<Node, boolean>();

/**
* Returns whether the `node` has migrated to the new Registry contract.
*/
Expand All @@ -18,8 +28,14 @@ export async function nodeIsMigrated(context: IndexingEngineContext, node: Node)
);
}

// memoize the below operation by `node`
const cached = cache.get(node);
if (cached !== undefined) return cached;

const record = await context.ensDb.find(ensIndexerSchema.migratedNode, { node });
return !!record;
const isMigrated = record !== null;
cache.set(node, isMigrated);
return isMigrated;
}

/**
Expand All @@ -32,5 +48,9 @@ export async function migrateNode(context: IndexingEngineContext, node: Node) {
);
}

// memoize the below operation by `node`
if (cache.get(node) === true) return;
cache.set(node, true);

await context.ensDb.insert(ensIndexerSchema.migratedNode).values({ node }).onConflictDoNothing();
}
38 changes: 21 additions & 17 deletions apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { getENSRootChainId, interpretAddress, PluginName } from "@ensnode/ensnod

import { materializeENSv1DomainEffectiveOwner } from "@/lib/ensv2/domain-db-helpers";
import { ensureDomainEvent } from "@/lib/ensv2/event-db-helpers";
import { ensureLabel, ensureUnknownLabel } from "@/lib/ensv2/label-db-helpers";
import { ensureLabel, ensureUnknownLabel, labelExists } from "@/lib/ensv2/label-db-helpers";
import { healAddrReverseSubnameLabel } from "@/lib/heal-addr-reverse-subname-label";
import {
addOnchainEventListener,
Expand Down Expand Up @@ -58,22 +58,26 @@ export default function () {
const domainId = makeENSv1DomainId(node);
const parentId = makeENSv1DomainId(parentNode);

// If this is a direct subname of addr.reverse, we have 100% on-chain label discovery.
//
// Note: Per ENSIP-19, only the ENS Root chain may record primary names under the `addr.reverse`
// subname. Also per ENSIP-19 no Reverse Names need exist in (shadow)Registries on non-root
// chains, so we explicitly only support Root chain addr.reverse-based Reverse Names: ENSIP-19
// CoinType-specific Reverse Names (ex: [address].[coinType].reverse) don't actually exist in
// the ENS Registry: wildcard resolution is used, so this NewOwner event will never be emitted
// with a domain created as a child of a Coin-Type specific Reverse Node (ex: [coinType].reverse).
if (
parentNode === ADDR_REVERSE_NODE &&
context.chain.id === getENSRootChainId(config.namespace)
) {
const label = await healAddrReverseSubnameLabel(context, event, labelHash);
await ensureLabel(context, label);
} else {
await ensureUnknownLabel(context, labelHash);
// only attempt to heal label if it doesn't already exist
Comment thread
vercel[bot] marked this conversation as resolved.
const exists = await labelExists(context, labelHash);
if (!exists) {
// If this is a direct subname of addr.reverse, we have 100% on-chain label discovery.
//
// Note: Per ENSIP-19, only the ENS Root chain may record primary names under the `addr.reverse`
// subname. Also per ENSIP-19 no Reverse Names need exist in (shadow)Registries on non-root
// chains, so we explicitly only support Root chain addr.reverse-based Reverse Names: ENSIP-19
// CoinType-specific Reverse Names (ex: [address].[coinType].reverse) don't actually exist in
// the ENS Registry: wildcard resolution is used, so this NewOwner event will never be emitted
// with a domain created as a child of a Coin-Type specific Reverse Node (ex: [coinType].reverse).
if (
parentNode === ADDR_REVERSE_NODE &&
context.chain.id === getENSRootChainId(config.namespace)
) {
const label = await healAddrReverseSubnameLabel(context, event, labelHash);
await ensureLabel(context, label);
} else {
Comment thread
shrugs marked this conversation as resolved.
await ensureUnknownLabel(context, labelHash);
}
}
Comment thread
shrugs marked this conversation as resolved.

// upsert domain
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
import { type EncodedReferrer, PluginName, toJson } from "@ensnode/ensnode-sdk";

import { ensureDomainEvent } from "@/lib/ensv2/event-db-helpers";
import { ensureLabel, ensureUnknownLabel } from "@/lib/ensv2/label-db-helpers";
import { ensureLabel, ensureUnknownLabel, labelExists } from "@/lib/ensv2/label-db-helpers";
import { getLatestRegistration, getLatestRenewal } from "@/lib/ensv2/registration-db-helpers";
import { getThisAccountId } from "@/lib/get-this-account-id";
import {
Expand Down Expand Up @@ -63,11 +63,13 @@ export default function () {
);
}

// ensure label
// if the contract emitted a healed label, ensure that it is indexed
if (label !== undefined) {
await ensureLabel(context, label);
} else {
await ensureUnknownLabel(context, labelHash);
// otherwise, attempt a heal if not exists
const exists = await labelExists(context, labelHash);
if (!exists) await ensureUnknownLabel(context, labelHash);
}

// update registration's base/premium
Expand Down Expand Up @@ -103,12 +105,13 @@ export default function () {
);
}

// ensure label
// NOTE: technically not necessary, as should be ensured by NameRegistered, but we include here anyway
// if the contract emitted a healed label, ensure that it is indexed
if (label !== undefined) {
await ensureLabel(context, label);
} else {
await ensureUnknownLabel(context, labelHash);
// otherwise, attempt a heal if not exists
const exists = await labelExists(context, labelHash);
if (!exists) await ensureUnknownLabel(context, labelHash);
}

const controller = getThisAccountId(context, event);
Expand Down
33 changes: 33 additions & 0 deletions packages/ensindexer-perf-testing/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# @ensnode/ensindexer-perf-testing

Local Prometheus + Grafana bundle for benchmarking ENSIndexer throughput.

## What's in the box

- **Prometheus** on `http://localhost:9090`, scraping `host.docker.internal:42069/metrics` every 5s (6h retention, admin API enabled).
- **Grafana** on `http://localhost:3001` (anonymous admin, no login) with a pre-provisioned Prometheus datasource and a **Ponder / ensindexer** dashboard.

Dashboard panels are tuned for indexer perf work:

- Top handlers by share of wall-clock time (`rate(ponder_indexing_function_duration_sum[1m]) / 1000`)
- Handler p95 duration (top 15)
- Events/sec per event and total
- Total events per handler (bar gauge)
- Synced block + historical blocks/sec per chain
- RPC req/s + p95 duration per chain/method
- Node event-loop lag p99, Postgres queue size, DB store queries/sec

## Usage

From this package's directory:

```bash
pnpm up # start prometheus + grafana
pnpm down # stop and remove containers
pnpm logs # tail container logs
pnpm wipe # purge prometheus series (useful between benchmark runs)
```

Then start the indexer in another terminal (`pnpm -F ensindexer dev`) and open the dashboard at <http://localhost:3001/d/ensindexer>.

The scrape target is `host.docker.internal:42069` — on macOS that resolves to the host via the `host-gateway` declaration in the compose file. On Linux hosts you may need Docker 20.10+ for the same behavior.
31 changes: 31 additions & 0 deletions packages/ensindexer-perf-testing/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
services:
prometheus:
image: prom/prometheus:v2.55.1
container_name: ensnode-prometheus
# bound to loopback to avoid exposing publicly
ports:
- "127.0.0.1:9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.retention.time=6h
- --web.enable-admin-api
Comment thread
shrugs marked this conversation as resolved.
extra_hosts:
- "host.docker.internal:host-gateway"

grafana:
image: grafana/grafana:11.3.0
container_name: ensnode-grafana
# bound to loopback to avoid exposing publicly
ports:
- "127.0.0.1:3001:3000"
environment:
GF_AUTH_ANONYMOUS_ENABLED: "true"
GF_AUTH_ANONYMOUS_ORG_ROLE: Admin
GF_AUTH_DISABLE_LOGIN_FORM: "true"
volumes:
- ./grafana/provisioning:/etc/grafana/provisioning:ro
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
depends_on:
- prometheus
Loading
Loading