Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion indexer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ For Render or other hosted environments:
start mode, and Render's native file-watcher limit can otherwise abort startup with
`EMFILE: too many open files, watch '/app'`.
- Keep `PONDER_ETH_GET_LOGS_BLOCK_RANGE` large enough for catch-up. The Render blueprint defaults to `10000`. A tiny range such as `10` makes a million-block historical sync require hundreds of thousands of `eth_getLogs` batches and will blow Alchemy CUPS. If the provider rejects the window, the process logs a one-shot `[commonality-indexer] eth_getLogs failed because the RPC rejected the block range or response size` line with the env to change; lower to `1000` then `10`.
- Hosted chains poll every `PONDER_POLL_INTERVAL_MS` (default `4000`). Hardhat stays at 100ms.
- Hosted chains poll every `PONDER_POLL_INTERVAL_MS` (default `4000`). Hardhat stays at 100ms. On Base Sepolia the process still ticks that often, but while nobody is reading it replays the last `eth_getBlockByNumber("latest")` for `INDEXER_IDLE_HEAD_INTERVAL_MS` (default 30s) instead of calling Alchemy. A GraphQL or `/api` read, or `POST /api/indexer-wake` after a submitted transaction, passes polls through for `INDEXER_IDLE_HEAD_WAKE_MS` (default 60s). Health checks and `/api/project-read-demand` do not wake it. Set `INDEXER_IDLE_HEAD_CACHE=0` to disable, or `=1` to enable on mainnet.
- Keep `DATABASE_SCHEMA` stable (`commonality_base_sepolia_v6` on testnet). Renaming it drops the event cache and replays history against the RPC. `scripts/smoke-check-render.mjs` fails if the name changes unless `INDEXER_ALLOW_SCHEMA_BUMP=1`. Code deploys (`ponder start` + `PONDER_EXPERIMENTAL_DB=platform` + the persistent disk for stop-before-start) reuse the same schema. The `events` table is append-only raw logs; new handlers and extra contract addresses in `INDEXER_DEPLOYMENT_MANIFEST` do not need a wipe.

### RPC budget (Alchemy monthly CU)
Expand Down
2 changes: 1 addition & 1 deletion indexer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"serve": "ponder serve",
"lint": "eslint .",
"typecheck": "tsc --noEmit && npm run check-abis",
"test": "node --import tsx --test selectPonderScript.test.mjs src/rpc/ethGetLogsRangeGuard.test.ts src/rpc/monthlyCapacity.test.ts src/api/projectReadDemand.test.ts src/indexing/contractCapabilities.test.ts src/indexing/conceptspaceConfigGraph.test.ts",
"test": "node --import tsx --test selectPonderScript.test.mjs src/rpc/ethGetLogsRangeGuard.test.ts src/rpc/monthlyCapacity.test.ts src/rpc/idleHeadCache.test.ts src/api/projectReadDemand.test.ts src/indexing/contractCapabilities.test.ts src/indexing/conceptspaceConfigGraph.test.ts",
"build": "tsc",
"check-abis": "tsx scripts/sync-abis.ts --check",
"clean": "rm -rf .ponder",
Expand Down
14 changes: 14 additions & 0 deletions indexer/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { client, graphql } from "ponder";
import { and, desc, eq, gte, lte, or } from "ponder";
import { getAddress, isAddress, type Hex } from "viem";
import { fundingIndexerRoutesEnabled } from "../indexing/contractCapabilities";
import { noteIndexerWake, requestWakesIndexer } from "../rpc/idleHeadCache";
import { isBareContractLogQuery, projectReadDemandReport, recordUnindexedProjectLogRequest } from "./projectReadDemand";

/**
Expand Down Expand Up @@ -80,6 +81,19 @@ function publicationPointer(event: { blockNumber: bigint; transactionHash: strin

const app = new Hono();

app.use("*", async (c, next) => {
if (requestWakesIndexer(c.req.method, c.req.path)) noteIndexerWake();
await next();
});

app.post("/api/indexer-wake", (c) => {
const until = noteIndexerWake();
return c.json({
ok: true,
fastPollingUntil: until > 0 ? new Date(until).toISOString() : null,
});
});

// Expose SQL client for direct queries (all tables)
app.use("/sql/*", client({ db, schema }));

Expand Down
2 changes: 2 additions & 0 deletions indexer/src/indexing/ponderEnv.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { http } from "viem";
import { installEthGetLogsRangeGuard } from "../rpc/ethGetLogsRangeGuard";
import { idleHeadCacheEnabled, installIdleHeadCache } from "../rpc/idleHeadCache";
import { installMonthlyCapacityGuard } from "../rpc/monthlyCapacity";
import { INDEXER_CHAIN_IDS, type IndexerChainName } from "../utils/chain";

Expand Down Expand Up @@ -222,4 +223,5 @@ export function installHostedRpcGuards(context: IndexerDeploymentContext): void
: context.ethGetLogsBlockRange;
installEthGetLogsRangeGuard({ configuredRange });
installMonthlyCapacityGuard();
if (idleHeadCacheEnabled(context.chain)) installIdleHeadCache();
}
137 changes: 137 additions & 0 deletions indexer/src/rpc/idleHeadCache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
createIdleHeadCache,
idleHeadCacheEnabled,
requestWakesIndexer,
} from "./idleHeadCache";

const head = (id: string, number = "0x10") =>
JSON.stringify({ jsonrpc: "2.0", id, result: { number, hash: "0xabc" } });

test("idle cache is on for base sepolia unless disabled", () => {
assert.equal(idleHeadCacheEnabled("base-sepolia", {}), true);
assert.equal(idleHeadCacheEnabled("base-sepolia", { INDEXER_IDLE_HEAD_CACHE: "0" }), false);
assert.equal(idleHeadCacheEnabled("mainnet", {}), false);
assert.equal(idleHeadCacheEnabled("mainnet", { INDEXER_IDLE_HEAD_CACHE: "1" }), true);
});

test("client reads and the wake endpoint count; health and demand do not", () => {
assert.equal(requestWakesIndexer("POST", "/graphql"), true);
assert.equal(requestWakesIndexer("GET", "/api/events"), true);
assert.equal(requestWakesIndexer("POST", "/api/indexer-wake"), true);
assert.equal(requestWakesIndexer("GET", "/api/indexer-wake"), false);
assert.equal(requestWakesIndexer("GET", "/health"), false);
assert.equal(requestWakesIndexer("GET", "/ready"), false);
assert.equal(requestWakesIndexer("GET", "/api/project-read-demand"), false);
});

test("replays the latest block while idle and refreshes after the interval", async () => {
let clock = 1_000;
const calls: string[] = [];
const fetchImpl: typeof fetch = async (_input, init) => {
const body = String(init?.body);
calls.push(body);
const id = JSON.parse(body).id;
return new Response(head(id, calls.length === 1 ? "0x10" : "0x11"), { status: 200 });
};
const cache = createIdleHeadCache({ idleIntervalMs: 30_000, wakeMs: 60_000, now: () => clock, fetchImpl });
const restore = cache.install();
try {
const first = await fetch("https://rpc.example", {
method: "POST",
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_getBlockByNumber", params: ["latest", true] }),
});
assert.equal(JSON.parse(await first.text()).result.number, "0x10");

clock = 10_000;
const second = await fetch("https://rpc.example", {
method: "POST",
body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "eth_getBlockByNumber", params: ["latest", true] }),
});
const replayed = JSON.parse(await second.text());
assert.equal(replayed.id, 2);
assert.equal(replayed.result.number, "0x10");
assert.equal(calls.length, 1);

clock = 31_000;
const third = await fetch("https://rpc.example", {
method: "POST",
body: JSON.stringify({ jsonrpc: "2.0", id: 3, method: "eth_getBlockByNumber", params: ["latest", true] }),
});
assert.equal(JSON.parse(await third.text()).result.number, "0x11");
assert.equal(calls.length, 2);
} finally {
restore();
}
});

test("a wake lets the next poll through, then idle caching resumes", async () => {
let clock = 5_000;
let upstream = 0;
const fetchImpl: typeof fetch = async () => {
upstream += 1;
return new Response(head(String(upstream), `0x${upstream.toString(16)}`), { status: 200 });
};
const cache = createIdleHeadCache({ idleIntervalMs: 30_000, wakeMs: 60_000, now: () => clock, fetchImpl });
const restore = cache.install();
const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_getBlockByNumber", params: ["latest", true] });
try {
await fetch("https://rpc.example", { method: "POST", body });
clock = 6_000;
await fetch("https://rpc.example", { method: "POST", body });
assert.equal(upstream, 1);

cache.noteWake();
clock = 7_000;
await fetch("https://rpc.example", { method: "POST", body });
assert.equal(upstream, 2);

clock = 66_000;
await fetch("https://rpc.example", { method: "POST", body });
assert.equal(upstream, 3);

clock = 80_000;
await fetch("https://rpc.example", { method: "POST", body });
assert.equal(upstream, 3);
} finally {
restore();
}
});

test("does not cache logs, historical blocks, or errors", async () => {
let upstream = 0;
const fetchImpl: typeof fetch = async (_input, init) => {
upstream += 1;
const method = JSON.parse(String(init?.body)).method;
if (method === "eth_getLogs") return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: [] }), { status: 200 });
return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, error: { code: -32000, message: "nope" } }), { status: 200 });
};
const cache = createIdleHeadCache({ now: () => 1_000, fetchImpl });
const restore = cache.install();
try {
await fetch("https://rpc.example", {
method: "POST",
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_getLogs", params: [{}] }),
});
await fetch("https://rpc.example", {
method: "POST",
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_getLogs", params: [{}] }),
});
await fetch("https://rpc.example", {
method: "POST",
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_getBlockByNumber", params: ["0x10", false] }),
});
await fetch("https://rpc.example", {
method: "POST",
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_getBlockByNumber", params: ["latest", true] }),
});
await fetch("https://rpc.example", {
method: "POST",
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_getBlockByNumber", params: ["latest", true] }),
});
assert.equal(upstream, 5);
} finally {
restore();
}
});
Loading
Loading