Skip to content

Commit 16ecad1

Browse files
authored
perf(ensv2): dedupe addr.reverse label-heal + perf testing infra (#1989)
1 parent deaad6a commit 16ecad1

18 files changed

Lines changed: 400 additions & 35 deletions

File tree

.changeset/config.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@
2727
"@docs/ensnode",
2828
"@docs/ensrainbow",
2929
"@namehash/ens-referrals",
30-
"@namehash/namehash-ui"
30+
"@namehash/namehash-ui",
31+
"@ensnode/ensindexer-perf-testing"
3132
]
3233
],
3334
"updateInternalDependencies": "patch",

.changeset/lovely-rooms-count.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@ensnode/ensindexer-perf-testing": minor
3+
---
4+
5+
Introduces the ENSIndexer Performance Testing package for running a local Prometheus x Grafana stack against an ENSIndexer instance.

.changeset/social-emus-sleep.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"ensindexer": patch
3+
---
4+
5+
ENSIndexer's ensv2 plugin now avoids attempting to heal addr.reverse subnames if they've already been healed.

apps/ensindexer/src/lib/ensv2/label-db-helpers.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ import {
1010
import { labelByLabelHash } from "@/lib/graphnode-helpers";
1111
import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-engines/ponder";
1212

13+
/**
14+
* Determines whether the Label identified by `labelHash` has already been indexed.
15+
*/
16+
export async function labelExists(context: IndexingEngineContext, labelHash: LabelHash) {
17+
const existing = await context.ensDb.find(ensIndexerSchema.label, { labelHash });
18+
return existing !== null;
19+
}
20+
1321
/**
1422
* Ensures that the LiteralLabel `label` is interpreted and upserted into the Label rainbow table.
1523
*/
@@ -24,14 +32,11 @@ export async function ensureLabel(context: IndexingEngineContext, label: Literal
2432
}
2533

2634
/**
27-
* Ensures that the LabelHash `labelHash` is available in the Label rainbow table, attempting an
28-
* ENSRainbow heal if this is the first time it has been encountered.
35+
* Ensures that the LabelHash `labelHash` is available in the Label rainbow table, also attempting
36+
* an ENSRainbow heal. To avoid duplicate ENSRainbow healing requests, callers must gate this
37+
* function on {@link labelExists} returning false.
2938
*/
3039
export async function ensureUnknownLabel(context: IndexingEngineContext, labelHash: LabelHash) {
31-
// do nothing for existing labels, they're either healed or we don't know them
32-
const exists = await context.ensDb.find(ensIndexerSchema.label, { labelHash });
33-
if (exists) return;
34-
3540
// attempt ENSRainbow heal
3641
const healedLabel = await labelByLabelHash(labelHash);
3742

apps/ensindexer/src/lib/indexing-engines/ponder.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { Context, EventNames } from "ponder:registry";
22
import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest";
33

4+
import "@/lib/__test__/mockLogger";
5+
46
import type { IndexingEngineContext, IndexingEngineEvent } from "./ponder";
57

68
const { mockPonderOn } = vi.hoisted(() => ({ mockPonderOn: vi.fn() }));

apps/ensindexer/src/lib/indexing-engines/ponder.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from "ponder:registry";
1717

1818
import { waitForEnsRainbowToBeReady } from "@/lib/ensrainbow/singleton";
19+
import { logger } from "@/lib/logger";
1920

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

183+
// Cumulative events-per-second tracking across the process lifetime. Logged at most
184+
// once per minute. Overhead is one Date.now() and a counter increment per event.
185+
const EPS_LOG_INTERVAL_MS = 60_000;
186+
let epsTotalEvents = 0;
187+
let epsStartTime: number | null = null;
188+
let epsLastLogTime = 0;
189+
190+
function recordEventForEps(): void {
191+
const now = Date.now();
192+
if (epsStartTime === null) {
193+
epsStartTime = now;
194+
epsLastLogTime = now;
195+
}
196+
epsTotalEvents++;
197+
if (now - epsLastLogTime <= EPS_LOG_INTERVAL_MS) return;
198+
const durationSec = (now - epsStartTime) / 1000;
199+
logger.info({
200+
msg: "Indexing throughput",
201+
events: epsTotalEvents,
202+
durationSec: Number(durationSec.toFixed(1)),
203+
eps: Number((epsTotalEvents / durationSec).toFixed(2)),
204+
});
205+
epsLastLogTime = now;
206+
}
207+
182208
/**
183209
* Execute any necessary preconditions before running an event handler
184210
* for a given event type.
185211
*
186212
* Some event handlers may have preconditions that need to be met before
187213
* they can run.
188214
*
189-
* This function is idempotent and will only execute its logic once, even if
190-
* called multiple times. This is to ensure that we affect the "hot path" of
191-
* indexing as little as possible, since this function is called for every
192-
* "onchain" event.
215+
* The Setup and Onchain preconditions are memoized and execute their logic only
216+
* once per process, regardless of how often this function is called — essential
217+
* because it's invoked for every indexed event. EPS accounting via
218+
* {@link recordEventForEps} runs on every call, but its hot-path cost is a
219+
* single Date.now() and a counter increment; structured logging is emitted at
220+
* most once per {@link EPS_LOG_INTERVAL_MS}.
193221
*/
194222
async function eventHandlerPreconditions(eventType: EventTypeId): Promise<void> {
223+
recordEventForEps();
224+
195225
switch (eventType) {
196226
case EventTypeIds.Setup: {
197227
if (indexingSetupPromise === null) {

apps/ensindexer/src/lib/protocol-acceleration/registry-migration-status.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export async function nodeIsMigrated(context: IndexingEngineContext, node: Node)
1919
}
2020

2121
const record = await context.ensDb.find(ensIndexerSchema.migratedNode, { node });
22-
return !!record;
22+
return record !== null;
2323
}
2424

2525
/**

apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { getENSRootChainId, interpretAddress, PluginName } from "@ensnode/ensnod
1515

1616
import { materializeENSv1DomainEffectiveOwner } from "@/lib/ensv2/domain-db-helpers";
1717
import { ensureDomainEvent } from "@/lib/ensv2/event-db-helpers";
18-
import { ensureLabel, ensureUnknownLabel } from "@/lib/ensv2/label-db-helpers";
18+
import { ensureLabel, ensureUnknownLabel, labelExists } from "@/lib/ensv2/label-db-helpers";
1919
import { healAddrReverseSubnameLabel } from "@/lib/heal-addr-reverse-subname-label";
2020
import {
2121
addOnchainEventListener,
@@ -58,22 +58,26 @@ export default function () {
5858
const domainId = makeENSv1DomainId(node);
5959
const parentId = makeENSv1DomainId(parentNode);
6060

61-
// If this is a direct subname of addr.reverse, we have 100% on-chain label discovery.
62-
//
63-
// Note: Per ENSIP-19, only the ENS Root chain may record primary names under the `addr.reverse`
64-
// subname. Also per ENSIP-19 no Reverse Names need exist in (shadow)Registries on non-root
65-
// chains, so we explicitly only support Root chain addr.reverse-based Reverse Names: ENSIP-19
66-
// CoinType-specific Reverse Names (ex: [address].[coinType].reverse) don't actually exist in
67-
// the ENS Registry: wildcard resolution is used, so this NewOwner event will never be emitted
68-
// with a domain created as a child of a Coin-Type specific Reverse Node (ex: [coinType].reverse).
69-
if (
70-
parentNode === ADDR_REVERSE_NODE &&
71-
context.chain.id === getENSRootChainId(config.namespace)
72-
) {
73-
const label = await healAddrReverseSubnameLabel(context, event, labelHash);
74-
await ensureLabel(context, label);
75-
} else {
76-
await ensureUnknownLabel(context, labelHash);
61+
// only attempt to heal label if it doesn't already exist
62+
const exists = await labelExists(context, labelHash);
63+
if (!exists) {
64+
// If this is a direct subname of addr.reverse, we have 100% on-chain label discovery.
65+
//
66+
// Note: Per ENSIP-19, only the ENS Root chain may record primary names under the `addr.reverse`
67+
// subname. Also per ENSIP-19 no Reverse Names need exist in (shadow)Registries on non-root
68+
// chains, so we explicitly only support Root chain addr.reverse-based Reverse Names: ENSIP-19
69+
// CoinType-specific Reverse Names (ex: [address].[coinType].reverse) don't actually exist in
70+
// the ENS Registry: wildcard resolution is used, so this NewOwner event will never be emitted
71+
// with a domain created as a child of a Coin-Type specific Reverse Node (ex: [coinType].reverse).
72+
if (
73+
parentNode === ADDR_REVERSE_NODE &&
74+
context.chain.id === getENSRootChainId(config.namespace)
75+
) {
76+
const label = await healAddrReverseSubnameLabel(context, event, labelHash);
77+
await ensureLabel(context, label);
78+
} else {
79+
await ensureUnknownLabel(context, labelHash);
80+
}
7781
}
7882

7983
// upsert domain

apps/ensindexer/src/plugins/ensv2/handlers/ensv1/RegistrarController.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
import { type EncodedReferrer, PluginName, toJson } from "@ensnode/ensnode-sdk";
1313

1414
import { ensureDomainEvent } from "@/lib/ensv2/event-db-helpers";
15-
import { ensureLabel, ensureUnknownLabel } from "@/lib/ensv2/label-db-helpers";
15+
import { ensureLabel, ensureUnknownLabel, labelExists } from "@/lib/ensv2/label-db-helpers";
1616
import { getLatestRegistration, getLatestRenewal } from "@/lib/ensv2/registration-db-helpers";
1717
import { getThisAccountId } from "@/lib/get-this-account-id";
1818
import {
@@ -63,11 +63,13 @@ export default function () {
6363
);
6464
}
6565

66-
// ensure label
66+
// if the contract emitted a healed label, ensure that it is indexed
6767
if (label !== undefined) {
6868
await ensureLabel(context, label);
6969
} else {
70-
await ensureUnknownLabel(context, labelHash);
70+
// otherwise, attempt a heal if not exists
71+
const exists = await labelExists(context, labelHash);
72+
if (!exists) await ensureUnknownLabel(context, labelHash);
7173
}
7274

7375
// update registration's base/premium
@@ -103,12 +105,13 @@ export default function () {
103105
);
104106
}
105107

106-
// ensure label
107-
// NOTE: technically not necessary, as should be ensured by NameRegistered, but we include here anyway
108+
// if the contract emitted a healed label, ensure that it is indexed
108109
if (label !== undefined) {
109110
await ensureLabel(context, label);
110111
} else {
111-
await ensureUnknownLabel(context, labelHash);
112+
// otherwise, attempt a heal if not exists
113+
const exists = await labelExists(context, labelHash);
114+
if (!exists) await ensureUnknownLabel(context, labelHash);
112115
}
113116

114117
const controller = getThisAccountId(context, event);
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# @ensnode/ensindexer-perf-testing
2+
3+
Local Prometheus + Grafana bundle for benchmarking ENSIndexer throughput.
4+
5+
## What's in the box
6+
7+
- **Prometheus** on `http://localhost:9090`, scraping `host.docker.internal:42069/metrics` every 5s (6h retention, admin API enabled).
8+
- **Grafana** on `http://localhost:3001` (anonymous admin, no login) with a pre-provisioned Prometheus datasource and a **Ponder / ensindexer** dashboard.
9+
10+
Dashboard panels are tuned for indexer perf work:
11+
12+
- Top handlers by share of wall-clock time (`rate(ponder_indexing_function_duration_sum[1m]) / 1000`)
13+
- Handler p95 duration (top 15)
14+
- Events/sec per event and total
15+
- Total events per handler (bar gauge)
16+
- Synced block + historical blocks/sec per chain
17+
- RPC req/s + p95 duration per chain/method
18+
- Node event-loop lag p99, Postgres queue size, DB store queries/sec
19+
20+
## Usage
21+
22+
From this package's directory:
23+
24+
```bash
25+
pnpm up # start prometheus + grafana
26+
pnpm down # stop and remove containers
27+
pnpm logs # tail container logs
28+
pnpm wipe # purge prometheus series (useful between benchmark runs)
29+
```
30+
31+
Then start the indexer in another terminal (`pnpm -F ensindexer dev`) and open the dashboard at <http://localhost:3001/d/ensindexer>.
32+
33+
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.

0 commit comments

Comments
 (0)