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 0209813ac..833c9ed60 100644 --- a/apps/ensindexer/src/lib/ensv2/account-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/account-db-helpers.ts @@ -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(); + /** * Ensures that the account identified by `address` exists. * If `address` is the zeroAddress, no-op. @@ -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 }) diff --git a/apps/ensindexer/src/lib/ensv2/label-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/label-db-helpers.ts index 9495500b8..fd3917cfe 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 requests, callers must gate this + * function on {@link labelExists} returning false. */ 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/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() })); diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.ts index 84996fdc6..567824273 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. @@ -186,12 +212,16 @@ let indexingActivationPromise: Promise | null = null; * 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(); + switch (eventType) { case EventTypeIds.Setup: { if (indexingSetupPromise === null) { 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..cffd4e63d 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,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(); + /** * Returns whether the `node` has migrated to the new Registry contract. */ @@ -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; } /** @@ -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(); } 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 fb8d09552..1ea839df4 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, 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 { @@ -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 @@ -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); 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..90219d1d7 --- /dev/null +++ b/packages/ensindexer-perf-testing/docker-compose.yml @@ -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 + 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 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..ad88942d2 --- /dev/null +++ b/packages/ensindexer-perf-testing/grafana/dashboards/ponder.json @@ -0,0 +1,192 @@ +{ + "title": "Ponder / ensindexer", + "uid": "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": 30 }, + "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": 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": 40 }, + "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": 48 }, + "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": 48 }, + "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": 56 }, + "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": 56 }, + "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": 56 }, + "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/datasources.yml b/packages/ensindexer-perf-testing/grafana/provisioning/datasources/datasources.yml new file mode 100644 index 000000000..c9f4f3a9b --- /dev/null +++ b/packages/ensindexer-perf-testing/grafana/provisioning/datasources/datasources.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..c248eea45 --- /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": "./wipe.sh", + "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"] 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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6ed891c9..452912f84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -937,6 +937,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':