From ef8a9c32d21c44379b07c8859bcef5dbf47f8b80 Mon Sep 17 00:00:00 2001 From: shrugs Date: Wed, 22 Apr 2026 21:41:34 -0500 Subject: [PATCH 01/18] perf(ensv2): skip label healing when label already indexed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ensv2 plugin re-ran expensive label healing on every NewOwner / NameRegistered / NameRenewed event, including repeats for the same labelHash. For addr.reverse subnames this was especially costly — each repeat fired both eth_getTransactionReceipt and debug_traceTransaction against the ENS Root chain, even though the label had already been discovered. Gate the heal path on a cheap labelExists lookup at each call site (subgraph already had the equivalent guard via `domain.name === null`). ensureUnknownLabel no longer duplicates the existence check since all callers now check first. Bench on mainnet (10-min windows, ENSRainbow stubbed): pre: 1.54M events, 48k RPCs (23.9k traces) post: 2.20M events, 25k RPCs (12.3k traces) Per-event handler cost for ENSv1Registry:NewOwner dropped 57%, BaseRegistrar:NameRegistered dropped 62%. --- .../src/lib/ensv2/label-db-helpers.ts | 17 ++++++--- .../ensv2/handlers/ensv1/ENSv1Registry.ts | 38 ++++++++++--------- .../handlers/ensv1/RegistrarController.ts | 28 ++++++++------ 3 files changed, 49 insertions(+), 34 deletions(-) diff --git a/apps/ensindexer/src/lib/ensv2/label-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/label-db-helpers.ts index 9495500b8..fb33f9bdf 100644 --- a/apps/ensindexer/src/lib/ensv2/label-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/label-db-helpers.ts @@ -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. */ @@ -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 request, callers must conditionally call + * this function based on the result of {@link ensureLabel}. */ 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); diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts index cfa08dd33..bfc0d1b45 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts @@ -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, @@ -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 + 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 { + await ensureUnknownLabel(context, labelHash); + } } // upsert domain diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/RegistrarController.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/RegistrarController.ts index a94df08e7..804113c1b 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/RegistrarController.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/RegistrarController.ts @@ -12,7 +12,7 @@ import { import { type EncodedReferrer, PluginName } 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 { @@ -64,11 +64,14 @@ export default function () { ); } - // ensure label - if (label !== undefined) { - await ensureLabel(context, label); - } else { - await ensureUnknownLabel(context, labelHash); + // ensure label if exists + const exists = await labelExists(context, labelHash); + if (!exists) { + if (label !== undefined) { + await ensureLabel(context, label); + } else { + await ensureUnknownLabel(context, labelHash); + } } // update registration's base/premium @@ -104,12 +107,15 @@ export default function () { ); } - // ensure label + // ensure label if exists // NOTE: technically not necessary, as should be ensured by NameRegistered, but we include here anyway - if (label !== undefined) { - await ensureLabel(context, label); - } else { - await ensureUnknownLabel(context, labelHash); + const exists = await labelExists(context, labelHash); + if (!exists) { + if (label !== undefined) { + await ensureLabel(context, label); + } else { + await ensureUnknownLabel(context, labelHash); + } } const controller = getThisAccountId(context, event); From 16abf3d9a86abda85820490c3c7c3f88de77add4 Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 23 Apr 2026 10:54:54 -0500 Subject: [PATCH 02/18] perf(ensv2): memoize ensureAccount within the indexer process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. A process-local Set short-circuits repeat upserts. Safe because the insert is onConflictDoNothing: repeats are idempotent and the memo is purely an optimization. On process restart the Set resets, which just costs one redundant (idempotent) DB op the first time each account is seen again. Benchmark (average handler duration over 10M events on mainnet, ensv2,protocol-acceleration, ENSRainbow stubbed): no cache: 2193.0s wall, 4,560 eps, 40.83M DB ops cached: 2088.8s wall, 4,787 eps, 36.67M DB ops (-4.16M inserts) ensv2/ENSv1RegistryOld:NewOwner per-event cost dropped ~16% (0.178ms to 0.150ms); cache hit rate ~40%; wall-clock ~5% faster overall. --- .../src/lib/ensv2/account-db-helpers.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/apps/ensindexer/src/lib/ensv2/account-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/account-db-helpers.ts index 0209813ac..a90879e65 100644 --- a/apps/ensindexer/src/lib/ensv2/account-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/account-db-helpers.ts @@ -4,6 +4,25 @@ import { interpretAddress } from "@ensnode/ensnode-sdk"; import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-engines/ponder"; +/** + * Process-local memo of account ids 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 ensuredAccounts = new Set(); + /** * Ensures that the account identified by `address` exists. * If `address` is the zeroAddress, no-op. @@ -12,8 +31,12 @@ export async function ensureAccount(context: IndexingEngineContext, address: Add const interpreted = interpretAddress(address); if (interpreted === null) return; + if (ensuredAccounts.has(interpreted)) return; + await context.ensDb .insert(ensIndexerSchema.account) .values({ id: interpreted }) .onConflictDoNothing(); + + ensuredAccounts.add(interpreted); } From e6523a1d13090e5e79bd143f8cc1dff686680947 Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 23 Apr 2026 11:14:21 -0500 Subject: [PATCH 03/18] perf(protocol-acceleration): memoize node migration status nodeIsMigrated is called as a precondition on every ENSv1RegistryOld event handler (NewOwner, Transfer, NewTTL, NewResolver) in the ensv2 plugin plus protocol-acceleration's own registry handler. At scale that is millions of PK lookups against migratedNode during a backfill. Cache both results (migrated + not-migrated) in process-local Sets. migrateNode updates both sets in lockstep so a cached "not migrated" answer is invalidated the instant a node transitions. Bidirectional caching is required because during historical backfill most domains are in the not-migrated state at read time (the migration event for them occurs later in the event stream), so a one-sided "migrated"-only cache wouldn't help the hot path. Safety: - migratedNode is append-only, so "migrated" cache entries never become stale. - Restart-safe: both sets repopulate from DB on cache miss. Benchmark (average handler duration over 1M events on mainnet, ensv2,protocol-acceleration, ENSRainbow stubbed): baseline: 255.8s wall, 3,909 eps, 1.31M find ops memo: 227.6s wall, 4,394 eps, 898k find ops (-31% finds) +12% throughput. protocol-acceleration/ENSv1RegistryOld:NewResolver per-event cost dropped ~20%, Resolver:AddrChanged dropped ~16%. --- .../registry-migration-status.ts | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/apps/ensindexer/src/lib/protocol-acceleration/registry-migration-status.ts b/apps/ensindexer/src/lib/protocol-acceleration/registry-migration-status.ts index 7d653124a..2f40122b6 100644 --- a/apps/ensindexer/src/lib/protocol-acceleration/registry-migration-status.ts +++ b/apps/ensindexer/src/lib/protocol-acceleration/registry-migration-status.ts @@ -8,6 +8,23 @@ 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) plus PA's registry handler. At scale that is millions of PK + * lookups against `migratedNode` over a backfill. The underlying state is process-stable because + * `migratedNode` is append-only (once inserted, always present) and all writes go through + * `migrateNode` below, which updates the cache in lockstep. + * + * Safety: + * - Restart-safe: both sets repopulate via DB reads on cache miss after a restart. + * - Correctness: `migrateNode` adds to `migratedNodes` and removes from `nonMigratedNodes` so a + * cached "not migrated" result is invalidated when migration happens within the same process. + */ +const migratedNodes = new Set(); +const nonMigratedNodes = new Set(); + /** * Returns whether the `node` has migrated to the new Registry contract. */ @@ -18,8 +35,16 @@ export async function nodeIsMigrated(context: IndexingEngineContext, node: Node) ); } + if (migratedNodes.has(node)) return true; + if (nonMigratedNodes.has(node)) return false; + const record = await context.ensDb.find(ensIndexerSchema.migratedNode, { node }); - return !!record; + if (record) { + migratedNodes.add(node); + return true; + } + nonMigratedNodes.add(node); + return false; } /** @@ -33,4 +58,6 @@ export async function migrateNode(context: IndexingEngineContext, node: Node) { } await context.ensDb.insert(ensIndexerSchema.migratedNode).values({ node }).onConflictDoNothing(); + migratedNodes.add(node); + nonMigratedNodes.delete(node); } From 1895a9b9e63778d92bf4a8fcb94de231ea96a688 Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 23 Apr 2026 11:22:01 -0500 Subject: [PATCH 04/18] chore(ensindexer): log indexing throughput every minute Emit a structured log line from eventHandlerPreconditions reporting cumulative events dispatched, elapsed time, and events-per-second at most once every 60s. Overhead is one Date.now() and a counter increment per event. Useful as a zero-setup throughput signal during perf work alongside the Ponder /metrics endpoint. --- .../src/lib/indexing-engines/ponder.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.ts index 84996fdc6..85a23906a 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.ts @@ -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 @@ -179,6 +180,31 @@ async function initializeIndexingActivation(): Promise { let indexingSetupPromise: Promise | null = null; let indexingActivationPromise: Promise | 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; +} + /** * Execute any necessary preconditions before running an event handler * for a given event type. @@ -192,6 +218,8 @@ let indexingActivationPromise: Promise | null = null; * "onchain" event. */ async function eventHandlerPreconditions(eventType: EventTypeId): Promise { + recordEventForEps(); + switch (eventType) { case EventTypeIds.Setup: { if (indexingSetupPromise === null) { From 6ae1f92ece5ead6e46a6c236f99f678931278f15 Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 23 Apr 2026 11:22:01 -0500 Subject: [PATCH 05/18] chore: add @ensnode/ensindexer-perf-testing package Local Prometheus + Grafana bundle for benchmarking ENSIndexer. Scrapes the indexer's /metrics endpoint (port 42069) every 5s and provisions a Grafana dashboard at /d/ensindexer tuned for perf work: top handlers by wall-clock share, p95 durations, events/sec, sync block + blocks/sec per chain, RPC req/s + latency, event-loop lag, Postgres queue size, and DB store queries/sec. Runs via docker compose; see the package README for usage. --- packages/ensindexer-perf-testing/README.md | 33 ++++ .../docker-compose.yml | 29 +++ .../grafana/dashboards/ponder.json | 186 ++++++++++++++++++ .../provisioning/dashboards/dashboards.yml | 8 + .../provisioning/datasources/prometheus.yml | 8 + packages/ensindexer-perf-testing/package.json | 16 ++ .../ensindexer-perf-testing/prometheus.yml | 8 + 7 files changed, 288 insertions(+) create mode 100644 packages/ensindexer-perf-testing/README.md create mode 100644 packages/ensindexer-perf-testing/docker-compose.yml create mode 100644 packages/ensindexer-perf-testing/grafana/dashboards/ponder.json create mode 100644 packages/ensindexer-perf-testing/grafana/provisioning/dashboards/dashboards.yml create mode 100644 packages/ensindexer-perf-testing/grafana/provisioning/datasources/prometheus.yml create mode 100644 packages/ensindexer-perf-testing/package.json create mode 100644 packages/ensindexer-perf-testing/prometheus.yml diff --git a/packages/ensindexer-perf-testing/README.md b/packages/ensindexer-perf-testing/README.md new file mode 100644 index 000000000..049a9f7ff --- /dev/null +++ b/packages/ensindexer-perf-testing/README.md @@ -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 . + +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. diff --git a/packages/ensindexer-perf-testing/docker-compose.yml b/packages/ensindexer-perf-testing/docker-compose.yml new file mode 100644 index 000000000..7b121e779 --- /dev/null +++ b/packages/ensindexer-perf-testing/docker-compose.yml @@ -0,0 +1,29 @@ +services: + prometheus: + image: prom/prometheus:latest + container_name: ensnode-prometheus + ports: + - "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 + extra_hosts: + - "host.docker.internal:host-gateway" + + grafana: + image: grafana/grafana:latest + container_name: ensnode-grafana + ports: + - "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 diff --git a/packages/ensindexer-perf-testing/grafana/dashboards/ponder.json b/packages/ensindexer-perf-testing/grafana/dashboards/ponder.json new file mode 100644 index 000000000..b469246bc --- /dev/null +++ b/packages/ensindexer-perf-testing/grafana/dashboards/ponder.json @@ -0,0 +1,186 @@ +{ + "title": "Ponder / ensindexer", + "uid": "ponder-ensindexer", + "schemaVersion": 39, + "version": 1, + "refresh": "5s", + "time": { "from": "now-15m", "to": "now" }, + "tags": ["ponder", "ensindexer"], + "templating": { "list": [] }, + "panels": [ + { + "id": 1, + "title": "Top handlers by share of wall-clock time (ms handler time per second)", + "type": "timeseries", + "gridPos": { "h": 10, "w": 24, "x": 0, "y": 0 }, + "targets": [ + { + "expr": "topk(15, rate(ponder_indexing_function_duration_sum[1m]) / 1000)", + "legendFormat": "{{event}}", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "min": 0, + "custom": { "drawStyle": "line", "lineWidth": 1, "fillOpacity": 10 } + } + }, + "options": { "legend": { "displayMode": "table", "placement": "right", "calcs": ["mean", "max"] } } + }, + { + "id": 2, + "title": "Handler p95 duration (top 15, ms)", + "type": "timeseries", + "gridPos": { "h": 10, "w": 24, "x": 0, "y": 10 }, + "targets": [ + { + "expr": "topk(15, histogram_quantile(0.95, sum by (le, event) (rate(ponder_indexing_function_duration_bucket[1m]))))", + "legendFormat": "{{event}}", + "refId": "A" + } + ], + "fieldConfig": { "defaults": { "unit": "ms" } }, + "options": { "legend": { "displayMode": "table", "placement": "right", "calcs": ["mean", "max"] } } + }, + { + "id": 3, + "title": "Events/sec by event (top 15)", + "type": "timeseries", + "gridPos": { "h": 10, "w": 12, "x": 0, "y": 20 }, + "targets": [ + { + "expr": "topk(15, rate(ponder_indexing_completed_events[1m]))", + "legendFormat": "{{event}}", + "refId": "A" + } + ], + "fieldConfig": { "defaults": { "unit": "ops" } }, + "options": { "legend": { "displayMode": "table", "placement": "right", "calcs": ["mean"] } } + }, + { + "id": 4, + "title": "Total events/sec", + "type": "timeseries", + "gridPos": { "h": 10, "w": 12, "x": 12, "y": 20 }, + "targets": [ + { + "expr": "sum(rate(ponder_indexing_completed_events[30s]))", + "legendFormat": "events/sec", + "refId": "A" + } + ], + "fieldConfig": { "defaults": { "unit": "ops" } } + }, + { + "id": 12, + "title": "Total events per handler", + "type": "bargauge", + "gridPos": { "h": 10, "w": 24, "x": 0, "y": 28 }, + "targets": [ + { + "expr": "topk(25, ponder_indexing_completed_events)", + "legendFormat": "{{event}}", + "refId": "A", + "instant": true + } + ], + "fieldConfig": { + "defaults": { + "unit": "short", + "color": { "mode": "continuous-GrYlRd" }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } + } + }, + "options": { + "orientation": "horizontal", + "displayMode": "gradient", + "showUnfilled": true, + "valueMode": "color", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } + } + }, + { + "id": 5, + "title": "Synced block per chain", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 38 }, + "targets": [ + { "expr": "ponder_sync_block", "legendFormat": "chain {{chain}}", "refId": "A" } + ] + }, + { + "id": 6, + "title": "Blocks/sec per chain (historical)", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 38 }, + "targets": [ + { "expr": "rate(ponder_historical_completed_blocks[1m])", "legendFormat": "chain {{chain}}", "refId": "A" } + ], + "fieldConfig": { "defaults": { "unit": "ops" } } + }, + { + "id": 7, + "title": "RPC requests/sec by chain + method", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 46 }, + "targets": [ + { + "expr": "sum by (chain, method) (rate(ponder_indexing_rpc_requests_total[1m]))", + "legendFormat": "{{chain}}/{{method}}", + "refId": "A" + } + ], + "fieldConfig": { "defaults": { "unit": "ops" } } + }, + { + "id": 8, + "title": "RPC request duration p95 (ms)", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 46 }, + "targets": [ + { + "expr": "histogram_quantile(0.95, sum by (le, chain, method) (rate(ponder_rpc_request_duration_bucket[1m])))", + "legendFormat": "{{chain}}/{{method}}", + "refId": "A" + } + ], + "fieldConfig": { "defaults": { "unit": "ms" } } + }, + { + "id": 9, + "title": "Event loop lag p99", + "type": "timeseries", + "gridPos": { "h": 6, "w": 8, "x": 0, "y": 54 }, + "targets": [ + { "expr": "nodejs_eventloop_lag_p99_seconds", "legendFormat": "p99", "refId": "A" }, + { "expr": "nodejs_eventloop_lag_mean_seconds", "legendFormat": "mean", "refId": "B" } + ], + "fieldConfig": { "defaults": { "unit": "s" } } + }, + { + "id": 10, + "title": "Postgres query queue size", + "type": "timeseries", + "gridPos": { "h": 6, "w": 8, "x": 8, "y": 54 }, + "targets": [ + { "expr": "ponder_postgres_query_queue_size", "legendFormat": "queue", "refId": "A" } + ] + }, + { + "id": 11, + "title": "DB store queries/sec", + "type": "timeseries", + "gridPos": { "h": 6, "w": 8, "x": 16, "y": 54 }, + "targets": [ + { + "expr": "sum by (method) (rate(ponder_indexing_store_queries_total[1m]))", + "legendFormat": "{{method}}", + "refId": "A" + } + ], + "fieldConfig": { "defaults": { "unit": "ops" } } + } + ] +} diff --git a/packages/ensindexer-perf-testing/grafana/provisioning/dashboards/dashboards.yml b/packages/ensindexer-perf-testing/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 000000000..61369f2fc --- /dev/null +++ b/packages/ensindexer-perf-testing/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,8 @@ +apiVersion: 1 +providers: + - name: ensnode-local + folder: ENSNode + type: file + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards diff --git a/packages/ensindexer-perf-testing/grafana/provisioning/datasources/prometheus.yml b/packages/ensindexer-perf-testing/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 000000000..c9f4f3a9b --- /dev/null +++ b/packages/ensindexer-perf-testing/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,8 @@ +apiVersion: 1 +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: true diff --git a/packages/ensindexer-perf-testing/package.json b/packages/ensindexer-perf-testing/package.json new file mode 100644 index 000000000..4899e8691 --- /dev/null +++ b/packages/ensindexer-perf-testing/package.json @@ -0,0 +1,16 @@ +{ + "name": "@ensnode/ensindexer-perf-testing", + "private": true, + "version": "1.10.0", + "description": "Local Prometheus + Grafana bundle for benchmarking ENSIndexer throughput.", + "license": "MIT", + "scripts": { + "up": "docker compose up -d", + "down": "docker compose down", + "logs": "docker compose logs -f", + "wipe": "curl -sf -X POST 'http://localhost:9090/api/v1/admin/tsdb/delete_series?match%5B%5D=%7B__name__%3D~%22.%2B%22%7D' && curl -sf -X POST http://localhost:9090/api/v1/admin/tsdb/clean_tombstones", + "lint": "biome check --write .", + "lint:ci": "biome ci" + }, + "devDependencies": {} +} diff --git a/packages/ensindexer-perf-testing/prometheus.yml b/packages/ensindexer-perf-testing/prometheus.yml new file mode 100644 index 000000000..f963eaf48 --- /dev/null +++ b/packages/ensindexer-perf-testing/prometheus.yml @@ -0,0 +1,8 @@ +global: + scrape_interval: 5s + evaluation_interval: 5s + +scrape_configs: + - job_name: ensindexer + static_configs: + - targets: ["host.docker.internal:42069"] From 9a114e5470aaf7883803055282ac4a4177957e67 Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 23 Apr 2026 11:30:13 -0500 Subject: [PATCH 06/18] fix: openapi-spec --- docs/ensnode.io/ensapi-openapi.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ensnode.io/ensapi-openapi.json b/docs/ensnode.io/ensapi-openapi.json index 2e27b8e27..9676b3c7a 100644 --- a/docs/ensnode.io/ensapi-openapi.json +++ b/docs/ensnode.io/ensapi-openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "ENSApi APIs", - "version": "1.10.0", + "version": "1.10.1", "description": "APIs for ENS resolution, navigating the ENS nameforest, and metadata about an ENSNode" }, "servers": [ From 7e480acdac09bfbe6163b9645c8eeed507882fff Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 23 Apr 2026 11:30:19 -0500 Subject: [PATCH 07/18] fix: pnpm install new package --- pnpm-lock.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b1eaa0da..00accd594 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -946,6 +946,8 @@ importers: specifier: 'catalog:' version: 4.0.5(@types/debug@4.1.12)(@types/node@24.10.9)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.3) + packages/ensindexer-perf-testing: {} + packages/enskit: dependencies: '@urql/core': From 55a0916be5d3682678e46e40652812dc5dfed162 Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 23 Apr 2026 11:32:22 -0500 Subject: [PATCH 08/18] chore(perf-testing): rename grafana datasource provisioning file Grafana loads any *.yml under provisioning/datasources/, but editors matching by filename try to validate prometheus.yml against the Prometheus config JSON schema, which has no `datasources` key, and surface a spurious "Property datasources is not allowed" warning. Renaming to datasources.yml (Grafana's own naming convention) dodges the schema collision. --- .../provisioning/datasources/{prometheus.yml => datasources.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/ensindexer-perf-testing/grafana/provisioning/datasources/{prometheus.yml => datasources.yml} (100%) diff --git a/packages/ensindexer-perf-testing/grafana/provisioning/datasources/prometheus.yml b/packages/ensindexer-perf-testing/grafana/provisioning/datasources/datasources.yml similarity index 100% rename from packages/ensindexer-perf-testing/grafana/provisioning/datasources/prometheus.yml rename to packages/ensindexer-perf-testing/grafana/provisioning/datasources/datasources.yml From c8f9c5734459259afb9323c54609b4178c929754 Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 23 Apr 2026 11:38:47 -0500 Subject: [PATCH 09/18] =?UTF-8?q?fix(ensv2):=20restore=20hash-only=20?= =?UTF-8?q?=E2=86=92=20plaintext=20upgrade=20in=20RegistrarController?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per @greptile-apps review on #1989: the labelExists gate added in ef8a9c32 suppresses the onConflictDoUpdate upgrade path when ENSRainbow is unavailable. Flow: ENSv1Registry:NewOwner fires first and, if ENSRainbow can't heal, ensureUnknownLabel writes a hash-only row. When RegistrarController:NameRegistered subsequently arrives with the emitted plaintext `label`, the gate short-circuits the ensureLabel call — the hash-only row is never upgraded. Restructure both handleNameRegisteredByController and handleNameRenewedByController so that: - if plaintext `label` is emitted, always call ensureLabel (upgrade path via onConflictDoUpdate is essential) - otherwise, gate ensureUnknownLabel on !labelExists Also fixes the {@link} reference in ensureUnknownLabel's JSDoc to point at labelExists instead of ensureLabel (flagged by the same review). --- .../src/lib/ensv2/label-db-helpers.ts | 4 +-- .../handlers/ensv1/RegistrarController.ts | 33 ++++++++++--------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/apps/ensindexer/src/lib/ensv2/label-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/label-db-helpers.ts index fb33f9bdf..fd3917cfe 100644 --- a/apps/ensindexer/src/lib/ensv2/label-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/label-db-helpers.ts @@ -33,8 +33,8 @@ export async function ensureLabel(context: IndexingEngineContext, label: Literal /** * Ensures that the LabelHash `labelHash` is available in the Label rainbow table, also attempting - * an ENSRainbow heal. To avoid duplicate ENSRainbow healing request, callers must conditionally call - * this function based on the result of {@link ensureLabel}. + * an ENSRainbow heal. To avoid duplicate ENSRainbow healing requests, callers must gate this + * function on {@link labelExists} returning false. */ export async function ensureUnknownLabel(context: IndexingEngineContext, labelHash: LabelHash) { // attempt ENSRainbow heal diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/RegistrarController.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/RegistrarController.ts index 804113c1b..d2e3e5e03 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/RegistrarController.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/RegistrarController.ts @@ -64,14 +64,15 @@ export default function () { ); } - // ensure label if exists - const exists = await labelExists(context, labelHash); - if (!exists) { - if (label !== undefined) { - await ensureLabel(context, label); - } else { - await ensureUnknownLabel(context, labelHash); - } + // If the controller emitted the plaintext `label`, always call ensureLabel: its + // onConflictDoUpdate upgrades a pre-existing hash-only row (written earlier by + // ensureUnknownLabel when ENSRainbow couldn't heal) to the plaintext value. + // Only call ensureUnknownLabel when plaintext isn't available and we haven't + // seen this labelHash yet. + if (label !== undefined) { + await ensureLabel(context, label); + } else if (!(await labelExists(context, labelHash))) { + await ensureUnknownLabel(context, labelHash); } // update registration's base/premium @@ -107,15 +108,15 @@ export default function () { ); } - // ensure label if exists + // Same pattern as handleNameRegisteredByController: if plaintext is emitted, + // always ensureLabel so any hash-only row left by a prior ensureUnknownLabel + // is upgraded via onConflictDoUpdate. Otherwise only attempt a heal if the + // label hasn't been seen before. // NOTE: technically not necessary, as should be ensured by NameRegistered, but we include here anyway - const exists = await labelExists(context, labelHash); - if (!exists) { - if (label !== undefined) { - await ensureLabel(context, label); - } else { - await ensureUnknownLabel(context, labelHash); - } + if (label !== undefined) { + await ensureLabel(context, label); + } else if (!(await labelExists(context, labelHash))) { + await ensureUnknownLabel(context, labelHash); } const controller = getThisAccountId(context, event); From 2ae4867b477e082e27af9ed52e297668058b7212 Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 23 Apr 2026 11:40:32 -0500 Subject: [PATCH 10/18] test(ensindexer): mock @/lib/logger in ponder.test.ts The EPS logging added in 1895a9b9 made ponder.ts import the app logger, which in turn pulls in local-ponder-context and asserts the PONDER_COMMON runtime global. Tests don't set that global, so every test in ponder.test.ts failed at import time. Mock @/lib/logger alongside the existing vi.mock entries; unit tests pass again (all 18 ensindexer files, 201 tests). --- apps/ensindexer/src/lib/indexing-engines/ponder.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts index 28d13674f..f506f918a 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts @@ -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() })); From 638bdc5320dc8528d3dd8d415707c680c2bd981b Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 23 Apr 2026 11:43:39 -0500 Subject: [PATCH 11/18] style: tighten comments + biome format --- .../handlers/ensv1/RegistrarController.ts | 24 ++++++++----------- .../grafana/dashboards/ponder.json | 18 +++++++++----- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/RegistrarController.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/RegistrarController.ts index d2e3e5e03..5a9b48d9c 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/RegistrarController.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/RegistrarController.ts @@ -64,15 +64,13 @@ export default function () { ); } - // If the controller emitted the plaintext `label`, always call ensureLabel: its - // onConflictDoUpdate upgrades a pre-existing hash-only row (written earlier by - // ensureUnknownLabel when ENSRainbow couldn't heal) to the plaintext value. - // Only call ensureUnknownLabel when plaintext isn't available and we haven't - // seen this labelHash yet. + // if the contract emitted a healed label, ensure that it is indexed if (label !== undefined) { await ensureLabel(context, label); - } else if (!(await labelExists(context, labelHash))) { - await ensureUnknownLabel(context, labelHash); + } else { + // otherwise, attempt a heal if not exists + const exists = await labelExists(context, labelHash); + if (!exists) await ensureUnknownLabel(context, labelHash); } // update registration's base/premium @@ -108,15 +106,13 @@ export default function () { ); } - // Same pattern as handleNameRegisteredByController: if plaintext is emitted, - // always ensureLabel so any hash-only row left by a prior ensureUnknownLabel - // is upgraded via onConflictDoUpdate. Otherwise only attempt a heal if the - // label hasn't been seen before. - // 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 if (!(await labelExists(context, labelHash))) { - await ensureUnknownLabel(context, labelHash); + } else { + // otherwise, attempt a heal if not exists + const exists = await labelExists(context, labelHash); + if (!exists) await ensureUnknownLabel(context, labelHash); } const controller = getThisAccountId(context, event); diff --git a/packages/ensindexer-perf-testing/grafana/dashboards/ponder.json b/packages/ensindexer-perf-testing/grafana/dashboards/ponder.json index b469246bc..1d654f720 100644 --- a/packages/ensindexer-perf-testing/grafana/dashboards/ponder.json +++ b/packages/ensindexer-perf-testing/grafana/dashboards/ponder.json @@ -27,7 +27,9 @@ "custom": { "drawStyle": "line", "lineWidth": 1, "fillOpacity": 10 } } }, - "options": { "legend": { "displayMode": "table", "placement": "right", "calcs": ["mean", "max"] } } + "options": { + "legend": { "displayMode": "table", "placement": "right", "calcs": ["mean", "max"] } + } }, { "id": 2, @@ -42,7 +44,9 @@ } ], "fieldConfig": { "defaults": { "unit": "ms" } }, - "options": { "legend": { "displayMode": "table", "placement": "right", "calcs": ["mean", "max"] } } + "options": { + "legend": { "displayMode": "table", "placement": "right", "calcs": ["mean", "max"] } + } }, { "id": 3, @@ -106,9 +110,7 @@ "title": "Synced block per chain", "type": "timeseries", "gridPos": { "h": 8, "w": 12, "x": 0, "y": 38 }, - "targets": [ - { "expr": "ponder_sync_block", "legendFormat": "chain {{chain}}", "refId": "A" } - ] + "targets": [{ "expr": "ponder_sync_block", "legendFormat": "chain {{chain}}", "refId": "A" }] }, { "id": 6, @@ -116,7 +118,11 @@ "type": "timeseries", "gridPos": { "h": 8, "w": 12, "x": 12, "y": 38 }, "targets": [ - { "expr": "rate(ponder_historical_completed_blocks[1m])", "legendFormat": "chain {{chain}}", "refId": "A" } + { + "expr": "rate(ponder_historical_completed_blocks[1m])", + "legendFormat": "chain {{chain}}", + "refId": "A" + } ], "fieldConfig": { "defaults": { "unit": "ops" } } }, From 5c1b6212c1432ed987b004af3efe9d2066439ca4 Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 23 Apr 2026 12:01:50 -0500 Subject: [PATCH 12/18] fix(perf-testing): pin images, bind loopback, fix uid + panel overlap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR #1989 review comments: - Pin prom/prometheus and grafana/grafana to exact versions for reproducible perf runs (copilot). - Bind both containers to 127.0.0.1 — the stack enables anonymous Admin + --web.enable-admin-api, which must not be exposed on shared/remote hosts (copilot). - Dashboard uid changed from "ponder-ensindexer" to "ensindexer" so /d/ensindexer (documented in README) actually resolves (coderabbit). - Fix panel 12 grid position — panels 3/4 (y=20..30) overlapped panel 12 (y=28..38); shifted panel 12 and everything below by 2 rows (coderabbit). - Replace `curl -sf` in the `wipe` script with `curl --fail --show-error` so failures surface instead of silently no-op'ing (coderabbit). --- .../ensindexer-perf-testing/docker-compose.yml | 13 +++++++++---- .../grafana/dashboards/ponder.json | 18 +++++++++--------- packages/ensindexer-perf-testing/package.json | 2 +- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/packages/ensindexer-perf-testing/docker-compose.yml b/packages/ensindexer-perf-testing/docker-compose.yml index 7b121e779..6726de08a 100644 --- a/packages/ensindexer-perf-testing/docker-compose.yml +++ b/packages/ensindexer-perf-testing/docker-compose.yml @@ -1,9 +1,12 @@ services: prometheus: - image: prom/prometheus:latest + # Pinned for reproducible perf runs; update deliberately after validating a newer release. + image: prom/prometheus:v2.55.1 container_name: ensnode-prometheus + # Bound to loopback: the stack ships anonymous admin + --web.enable-admin-api (required for + # the `pnpm wipe` script) so must not be exposed on shared/remote hosts. ports: - - "9090:9090" + - "127.0.0.1:9090:9090" volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro command: @@ -14,10 +17,12 @@ services: - "host.docker.internal:host-gateway" grafana: - image: grafana/grafana:latest + # Pinned for reproducible perf runs; update deliberately after validating a newer release. + image: grafana/grafana:11.3.0 container_name: ensnode-grafana + # Bound to loopback: anonymous Admin role is enabled for zero-friction local editing. ports: - - "3001:3000" + - "127.0.0.1:3001:3000" environment: GF_AUTH_ANONYMOUS_ENABLED: "true" GF_AUTH_ANONYMOUS_ORG_ROLE: Admin diff --git a/packages/ensindexer-perf-testing/grafana/dashboards/ponder.json b/packages/ensindexer-perf-testing/grafana/dashboards/ponder.json index 1d654f720..ad88942d2 100644 --- a/packages/ensindexer-perf-testing/grafana/dashboards/ponder.json +++ b/packages/ensindexer-perf-testing/grafana/dashboards/ponder.json @@ -1,6 +1,6 @@ { "title": "Ponder / ensindexer", - "uid": "ponder-ensindexer", + "uid": "ensindexer", "schemaVersion": 39, "version": 1, "refresh": "5s", @@ -81,7 +81,7 @@ "id": 12, "title": "Total events per handler", "type": "bargauge", - "gridPos": { "h": 10, "w": 24, "x": 0, "y": 28 }, + "gridPos": { "h": 10, "w": 24, "x": 0, "y": 30 }, "targets": [ { "expr": "topk(25, ponder_indexing_completed_events)", @@ -109,14 +109,14 @@ "id": 5, "title": "Synced block per chain", "type": "timeseries", - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 38 }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 40 }, "targets": [{ "expr": "ponder_sync_block", "legendFormat": "chain {{chain}}", "refId": "A" }] }, { "id": 6, "title": "Blocks/sec per chain (historical)", "type": "timeseries", - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 38 }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 40 }, "targets": [ { "expr": "rate(ponder_historical_completed_blocks[1m])", @@ -130,7 +130,7 @@ "id": 7, "title": "RPC requests/sec by chain + method", "type": "timeseries", - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 46 }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 48 }, "targets": [ { "expr": "sum by (chain, method) (rate(ponder_indexing_rpc_requests_total[1m]))", @@ -144,7 +144,7 @@ "id": 8, "title": "RPC request duration p95 (ms)", "type": "timeseries", - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 46 }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 48 }, "targets": [ { "expr": "histogram_quantile(0.95, sum by (le, chain, method) (rate(ponder_rpc_request_duration_bucket[1m])))", @@ -158,7 +158,7 @@ "id": 9, "title": "Event loop lag p99", "type": "timeseries", - "gridPos": { "h": 6, "w": 8, "x": 0, "y": 54 }, + "gridPos": { "h": 6, "w": 8, "x": 0, "y": 56 }, "targets": [ { "expr": "nodejs_eventloop_lag_p99_seconds", "legendFormat": "p99", "refId": "A" }, { "expr": "nodejs_eventloop_lag_mean_seconds", "legendFormat": "mean", "refId": "B" } @@ -169,7 +169,7 @@ "id": 10, "title": "Postgres query queue size", "type": "timeseries", - "gridPos": { "h": 6, "w": 8, "x": 8, "y": 54 }, + "gridPos": { "h": 6, "w": 8, "x": 8, "y": 56 }, "targets": [ { "expr": "ponder_postgres_query_queue_size", "legendFormat": "queue", "refId": "A" } ] @@ -178,7 +178,7 @@ "id": 11, "title": "DB store queries/sec", "type": "timeseries", - "gridPos": { "h": 6, "w": 8, "x": 16, "y": 54 }, + "gridPos": { "h": 6, "w": 8, "x": 16, "y": 56 }, "targets": [ { "expr": "sum by (method) (rate(ponder_indexing_store_queries_total[1m]))", diff --git a/packages/ensindexer-perf-testing/package.json b/packages/ensindexer-perf-testing/package.json index 4899e8691..772161e96 100644 --- a/packages/ensindexer-perf-testing/package.json +++ b/packages/ensindexer-perf-testing/package.json @@ -8,7 +8,7 @@ "up": "docker compose up -d", "down": "docker compose down", "logs": "docker compose logs -f", - "wipe": "curl -sf -X POST 'http://localhost:9090/api/v1/admin/tsdb/delete_series?match%5B%5D=%7B__name__%3D~%22.%2B%22%7D' && curl -sf -X POST http://localhost:9090/api/v1/admin/tsdb/clean_tombstones", + "wipe": "curl --fail --show-error -X POST 'http://localhost:9090/api/v1/admin/tsdb/delete_series?match%5B%5D=%7B__name__%3D~%22.%2B%22%7D' && curl --fail --show-error -X POST http://localhost:9090/api/v1/admin/tsdb/clean_tombstones", "lint": "biome check --write .", "lint:ci": "biome ci" }, From a067a75c90e6a94182a572089a0524055c8f7062 Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 23 Apr 2026 12:08:19 -0500 Subject: [PATCH 13/18] chore(perf-testing): extract wipe into wipe.sh Moves the curl sequence out of the package.json script field into a proper shell script with `set -euo pipefail` and a PROM_URL override. Keeps package.json readable and gives the operator a file to tweak. --- packages/ensindexer-perf-testing/docker-compose.yml | 7 ++----- packages/ensindexer-perf-testing/package.json | 2 +- packages/ensindexer-perf-testing/wipe.sh | 12 ++++++++++++ 3 files changed, 15 insertions(+), 6 deletions(-) create mode 100755 packages/ensindexer-perf-testing/wipe.sh diff --git a/packages/ensindexer-perf-testing/docker-compose.yml b/packages/ensindexer-perf-testing/docker-compose.yml index 6726de08a..c0ca6c779 100644 --- a/packages/ensindexer-perf-testing/docker-compose.yml +++ b/packages/ensindexer-perf-testing/docker-compose.yml @@ -1,10 +1,8 @@ services: prometheus: - # Pinned for reproducible perf runs; update deliberately after validating a newer release. image: prom/prometheus:v2.55.1 container_name: ensnode-prometheus - # Bound to loopback: the stack ships anonymous admin + --web.enable-admin-api (required for - # the `pnpm wipe` script) so must not be exposed on shared/remote hosts. + # bound to loopback to avoid exposing publically ports: - "127.0.0.1:9090:9090" volumes: @@ -17,10 +15,9 @@ services: - "host.docker.internal:host-gateway" grafana: - # Pinned for reproducible perf runs; update deliberately after validating a newer release. image: grafana/grafana:11.3.0 container_name: ensnode-grafana - # Bound to loopback: anonymous Admin role is enabled for zero-friction local editing. + # bound to loopback to avoid exposing publically ports: - "127.0.0.1:3001:3000" environment: diff --git a/packages/ensindexer-perf-testing/package.json b/packages/ensindexer-perf-testing/package.json index 772161e96..c248eea45 100644 --- a/packages/ensindexer-perf-testing/package.json +++ b/packages/ensindexer-perf-testing/package.json @@ -8,7 +8,7 @@ "up": "docker compose up -d", "down": "docker compose down", "logs": "docker compose logs -f", - "wipe": "curl --fail --show-error -X POST 'http://localhost:9090/api/v1/admin/tsdb/delete_series?match%5B%5D=%7B__name__%3D~%22.%2B%22%7D' && curl --fail --show-error -X POST http://localhost:9090/api/v1/admin/tsdb/clean_tombstones", + "wipe": "./wipe.sh", "lint": "biome check --write .", "lint:ci": "biome ci" }, diff --git a/packages/ensindexer-perf-testing/wipe.sh b/packages/ensindexer-perf-testing/wipe.sh new file mode 100755 index 000000000..9c6f2e3d6 --- /dev/null +++ b/packages/ensindexer-perf-testing/wipe.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Clear all series from the local Prometheus instance. Requires prometheus to be +# running with --web.enable-admin-api (set in docker-compose.yml). +set -euo pipefail + +PROM_URL="${PROM_URL:-http://localhost:9090}" + +curl --fail --show-error -X POST \ + "${PROM_URL}/api/v1/admin/tsdb/delete_series?match%5B%5D=%7B__name__%3D~%22.%2B%22%7D" + +curl --fail --show-error -X POST \ + "${PROM_URL}/api/v1/admin/tsdb/clean_tombstones" From 88edd41fdc2a44bde6b5251d98b1da864ac6bfa7 Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 23 Apr 2026 12:29:36 -0500 Subject: [PATCH 14/18] docs(ensindexer): correct eventHandlerPreconditions comment The docstring claimed "will only execute its logic once" which was accurate for the Setup/Onchain preconditions but not for the EPS accounting call added alongside. Clarify that the memoization applies to the preconditions while EPS accounting runs per-event with minimal (Date.now + increment) overhead. Addresses PR #1989 review comments from copilot and vercel. --- apps/ensindexer/src/lib/indexing-engines/ponder.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.ts index 85a23906a..567824273 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.ts @@ -212,10 +212,12 @@ function recordEventForEps(): void { * 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 { recordEventForEps(); From 578b898987629637478412f22940c0ae560453cd Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 23 Apr 2026 12:37:40 -0500 Subject: [PATCH 15/18] fix: typo --- packages/ensindexer-perf-testing/docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ensindexer-perf-testing/docker-compose.yml b/packages/ensindexer-perf-testing/docker-compose.yml index c0ca6c779..90219d1d7 100644 --- a/packages/ensindexer-perf-testing/docker-compose.yml +++ b/packages/ensindexer-perf-testing/docker-compose.yml @@ -2,7 +2,7 @@ services: prometheus: image: prom/prometheus:v2.55.1 container_name: ensnode-prometheus - # bound to loopback to avoid exposing publically + # bound to loopback to avoid exposing publicly ports: - "127.0.0.1:9090:9090" volumes: @@ -17,7 +17,7 @@ services: grafana: image: grafana/grafana:11.3.0 container_name: ensnode-grafana - # bound to loopback to avoid exposing publically + # bound to loopback to avoid exposing publicly ports: - "127.0.0.1:3001:3000" environment: From 83011616817e4716db8cff474105511081795645 Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 23 Apr 2026 13:01:34 -0500 Subject: [PATCH 16/18] refactor(protocol-acceleration): fold nodeIsMigrated cache into a single Map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the two Sets (migratedNodes + nonMigratedNodes) with a single Map. One structure, one lookup, no risk of the two sets drifting out of sync — migrateNode is just set(node, true), no companion delete needed. Semantics and restart behavior are unchanged. --- .../registry-migration-status.ts | 30 +++++++------------ 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/apps/ensindexer/src/lib/protocol-acceleration/registry-migration-status.ts b/apps/ensindexer/src/lib/protocol-acceleration/registry-migration-status.ts index 2f40122b6..309f1ee32 100644 --- a/apps/ensindexer/src/lib/protocol-acceleration/registry-migration-status.ts +++ b/apps/ensindexer/src/lib/protocol-acceleration/registry-migration-status.ts @@ -13,17 +13,13 @@ const ensRootChainId = getENSRootChainId(config.namespace); * * `nodeIsMigrated` is called as a precondition on every ENSv1RegistryOld event handler (NewOwner, * Transfer, NewTTL, NewResolver) plus PA's registry handler. At scale that is millions of PK - * lookups against `migratedNode` over a backfill. The underlying state is process-stable because - * `migratedNode` is append-only (once inserted, always present) and all writes go through - * `migrateNode` below, which updates the cache in lockstep. + * lookups against `migratedNode` over a backfill. `migratedNode` is append-only (once inserted, + * always present) and all writes go through `migrateNode` below, which updates the cache in + * lockstep — so a cached entry is never stale. * - * Safety: - * - Restart-safe: both sets repopulate via DB reads on cache miss after a restart. - * - Correctness: `migrateNode` adds to `migratedNodes` and removes from `nonMigratedNodes` so a - * cached "not migrated" result is invalidated when migration happens within the same process. + * Restart-safe: the Map repopulates via DB reads on cache miss. */ -const migratedNodes = new Set(); -const nonMigratedNodes = new Set(); +const migrationStatus = new Map(); /** * Returns whether the `node` has migrated to the new Registry contract. @@ -35,16 +31,13 @@ export async function nodeIsMigrated(context: IndexingEngineContext, node: Node) ); } - if (migratedNodes.has(node)) return true; - if (nonMigratedNodes.has(node)) return false; + const cached = migrationStatus.get(node); + if (cached !== undefined) return cached; const record = await context.ensDb.find(ensIndexerSchema.migratedNode, { node }); - if (record) { - migratedNodes.add(node); - return true; - } - nonMigratedNodes.add(node); - return false; + const isMigrated = record !== null; + migrationStatus.set(node, isMigrated); + return isMigrated; } /** @@ -58,6 +51,5 @@ export async function migrateNode(context: IndexingEngineContext, node: Node) { } await context.ensDb.insert(ensIndexerSchema.migratedNode).values({ node }).onConflictDoNothing(); - migratedNodes.add(node); - nonMigratedNodes.delete(node); + migrationStatus.set(node, true); } From fb3639bb086f74de9aabea3c0eb38f59e2bc7a1c Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 23 Apr 2026 13:05:04 -0500 Subject: [PATCH 17/18] refactor: tighten memo pattern across ensureAccount + nodeIsMigrated Apply the same structure in both helpers: set the cache entry before the DB write and short-circuit eagerly when the cache already knows the answer, so repeat calls skip the DB round-trip entirely. Use `=== true` in migrateNode for explicit handling of the three-state Map (skip only on confirmed-migrated). --- apps/ensindexer/src/lib/ensv2/account-db-helpers.ts | 4 ++-- .../registry-migration-status.ts | 11 ++++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/ensindexer/src/lib/ensv2/account-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/account-db-helpers.ts index a90879e65..55b27af9c 100644 --- a/apps/ensindexer/src/lib/ensv2/account-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/account-db-helpers.ts @@ -31,12 +31,12 @@ export async function ensureAccount(context: IndexingEngineContext, address: Add const interpreted = interpretAddress(address); if (interpreted === null) return; + // memoize the below operation by `interpreted` if (ensuredAccounts.has(interpreted)) return; + ensuredAccounts.add(interpreted); await context.ensDb .insert(ensIndexerSchema.account) .values({ id: interpreted }) .onConflictDoNothing(); - - ensuredAccounts.add(interpreted); } diff --git a/apps/ensindexer/src/lib/protocol-acceleration/registry-migration-status.ts b/apps/ensindexer/src/lib/protocol-acceleration/registry-migration-status.ts index 309f1ee32..7b779ff0b 100644 --- a/apps/ensindexer/src/lib/protocol-acceleration/registry-migration-status.ts +++ b/apps/ensindexer/src/lib/protocol-acceleration/registry-migration-status.ts @@ -12,10 +12,7 @@ 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) plus PA's registry handler. At scale that is millions of PK - * lookups against `migratedNode` over a backfill. `migratedNode` is append-only (once inserted, - * always present) and all writes go through `migrateNode` below, which updates the cache in - * lockstep — so a cached entry is never stale. + * Transfer, NewTTL, NewResolver). At scale that is millions of lookups against `migratedNode`. * * Restart-safe: the Map repopulates via DB reads on cache miss. */ @@ -31,6 +28,7 @@ export async function nodeIsMigrated(context: IndexingEngineContext, node: Node) ); } + // memoize the below operation by `node` const cached = migrationStatus.get(node); if (cached !== undefined) return cached; @@ -50,6 +48,9 @@ export async function migrateNode(context: IndexingEngineContext, node: Node) { ); } - await context.ensDb.insert(ensIndexerSchema.migratedNode).values({ node }).onConflictDoNothing(); + // memoize the below operation by `node` + if (migrationStatus.get(node) === true) return; migrationStatus.set(node, true); + + await context.ensDb.insert(ensIndexerSchema.migratedNode).values({ node }).onConflictDoNothing(); } From 543dc1ed009cf93189d7bd9de0545b5b68e97aae Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 23 Apr 2026 13:09:18 -0500 Subject: [PATCH 18/18] chore: rename module-local memo to `cache` --- .changeset/config.json | 3 ++- apps/ensindexer/src/lib/ensv2/account-db-helpers.ts | 10 +++++----- .../registry-migration-status.ts | 12 ++++++------ 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/.changeset/config.json b/.changeset/config.json index 612922ccb..64f132b82 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -27,7 +27,8 @@ "@docs/ensnode", "@docs/ensrainbow", "@namehash/ens-referrals", - "@namehash/namehash-ui" + "@namehash/namehash-ui", + "@ensnode/ensindexer-perf-testing" ] ], "updateInternalDependencies": "patch", diff --git a/apps/ensindexer/src/lib/ensv2/account-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/account-db-helpers.ts index 55b27af9c..833c9ed60 100644 --- a/apps/ensindexer/src/lib/ensv2/account-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/account-db-helpers.ts @@ -1,11 +1,11 @@ -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 account ids we have already upserted into `ensIndexerSchema.account` + * 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 @@ -21,7 +21,7 @@ import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-eng * 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 ensuredAccounts = new Set(); +const cache = new Set(); /** * Ensures that the account identified by `address` exists. @@ -32,8 +32,8 @@ export async function ensureAccount(context: IndexingEngineContext, address: Add if (interpreted === null) return; // memoize the below operation by `interpreted` - if (ensuredAccounts.has(interpreted)) return; - ensuredAccounts.add(interpreted); + if (cache.has(interpreted)) return; + cache.add(interpreted); await context.ensDb .insert(ensIndexerSchema.account) diff --git a/apps/ensindexer/src/lib/protocol-acceleration/registry-migration-status.ts b/apps/ensindexer/src/lib/protocol-acceleration/registry-migration-status.ts index 7b779ff0b..cffd4e63d 100644 --- a/apps/ensindexer/src/lib/protocol-acceleration/registry-migration-status.ts +++ b/apps/ensindexer/src/lib/protocol-acceleration/registry-migration-status.ts @@ -12,11 +12,11 @@ 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). At scale that is millions of lookups against `migratedNode`. + * Transfer, NewTTL, NewResolver). * * Restart-safe: the Map repopulates via DB reads on cache miss. */ -const migrationStatus = new Map(); +const cache = new Map(); /** * Returns whether the `node` has migrated to the new Registry contract. @@ -29,12 +29,12 @@ export async function nodeIsMigrated(context: IndexingEngineContext, node: Node) } // memoize the below operation by `node` - const cached = migrationStatus.get(node); + const cached = cache.get(node); if (cached !== undefined) return cached; const record = await context.ensDb.find(ensIndexerSchema.migratedNode, { node }); const isMigrated = record !== null; - migrationStatus.set(node, isMigrated); + cache.set(node, isMigrated); return isMigrated; } @@ -49,8 +49,8 @@ export async function migrateNode(context: IndexingEngineContext, node: Node) { } // memoize the below operation by `node` - if (migrationStatus.get(node) === true) return; - migrationStatus.set(node, true); + if (cache.get(node) === true) return; + cache.set(node, true); await context.ensDb.insert(ensIndexerSchema.migratedNode).values({ node }).onConflictDoNothing(); }