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
64 changes: 64 additions & 0 deletions apps/api/src/cache-control.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Per-route Cache-Control policy. Most read endpoints serve stable data
// (block headers, finalized txs, address history) but every consumer
// (scan, faucet, dapps) re-fetches the same row hundreds of times per
// minute. A short shared cache at the edge — Caddy + browser — absorbs
// the duplication without hiding live data updates.
//
// Conventions per response shape:
// - live-tip data (chain/info, latest blocks): 2 s. One block-time
// window so users still see new blocks within a tick.
// - finalized objects with a cursor (specific block / tx): 5 min +
// immutable, since block N or tx H never changes once mined.
// - paginated lists keyed off the latest tip (address/txs, whale/tx,
// contracts/recent): 10–30 s. Long enough to dedupe burst traffic,
// short enough the next request lands in roughly real time.
// - aggregate stats (daily counts, validator scores): 30–60 s. The
// materialised view / 5-min in-memory cache already absorbs query
// cost; HTTP cache layer just stops the round-trip.
//
// Routes are free to set their own Cache-Control inside the handler —
// the plugin only fills in a default when none is set, never overrides.

import type { FastifyInstance } from "fastify";

const POLICIES: Array<[RegExp, string]> = [
[/^\/health$/, "no-store"],
[/^\/chain\/info$/, "public, max-age=2"],
[/^\/blocks$/, "public, max-age=2"],
// Specific block / tx are immutable once finalized; SAFE_LAG (5 by
// default) keeps the indexer behind tip so we never cache an
// unfinalized object. immutable hint lets browsers skip revalidation.
[/^\/blocks\/[0-9]+$/, "public, max-age=300, immutable"],
[/^\/tx\/0x[0-9a-f]+$/i, "public, max-age=300, immutable"],
[/^\/address\/0x[0-9a-f]+\/(txs|transfers)/i, "public, max-age=10"],
[/^\/address\/0x[0-9a-f]+$/i, "public, max-age=10"],
[/^\/stats\/daily$/, "public, max-age=60"],
[/^\/accounts\/active/, "public, max-age=30"],
[/^\/contracts\/recent/, "public, max-age=10"],
[/^\/contracts\/pioneers/, "public, max-age=300, immutable"],
[/^\/contracts\/stats/, "public, max-age=30"],
[/^\/whale\/tx/, "public, max-age=10"],
[/^\/validators$/, "public, max-age=30"],
[/^\/epochs$/, "public, max-age=60"],
[/^\/tokens$/, "public, max-age=30"],
[/^\/tokens\/0x[0-9a-f]+\/holders/i, "public, max-age=30"],
];

export function registerCacheControl(app: FastifyInstance) {
app.addHook("onSend", async (req, reply, payload) => {
// Skip errors — 4xx/5xx should always revalidate.
if (reply.statusCode >= 400) return payload;
// Don't override an explicit policy set by the route handler.
if (reply.getHeader("cache-control")) return payload;
for (const [pattern, value] of POLICIES) {
if (pattern.test(req.url.split("?")[0])) {
reply.header("Cache-Control", value);
// Explicit Vary so a future Accept-based variant doesn't
// collide in shared caches.
reply.header("Vary", "Accept, Accept-Encoding");
break;
}
}
return payload;
});
}
5 changes: 5 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { registerNativeRoutes } from "./routes/native.js";
import { registerEtherscanCompat } from "./routes/etherscan.js";
import { registerHealthRoutes } from "./routes/health.js";
import { registerCoinblastRoutes } from "./routes/coinblast.js";
import { registerCacheControl } from "./cache-control.js";

const PORT = Number(process.env.API_PORT ?? 8081);
const HOST = process.env.API_HOST ?? "0.0.0.0";
Expand Down Expand Up @@ -46,6 +47,10 @@ async function main() {
const db = createDb(DB_URL);
const chain = new SentrixClient({ network: NETWORK });

// Cache-Control hook before routes so any explicit per-route header
// wins (the hook checks for an existing value before setting).
registerCacheControl(app);

registerHealthRoutes(app, { db, chain, network: NETWORK });
registerNativeRoutes(app, { db, chain });
registerEtherscanCompat(app, { db, chain });
Expand Down
11 changes: 10 additions & 1 deletion packages/chain/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,16 @@ export class SentrixClient {
cfg.wsUrl ??
chain.rpcUrls.default.webSocket?.[0];

this.http = createPublicClient({ chain, transport: http(httpUrl) });
// batch:true coalesces concurrent JSON-RPC calls into a single HTTP
// request (default window 0 ms = same micro-task). Indexer backfill
// fires bursts of getBlock + getLogs + getTransaction per block; with
// batching enabled these collapse from N round-trips to 1 per block,
// cutting tail latency under load and freeing the public LB to serve
// other consumers. wait: 0 keeps single-call latency unchanged.
this.http = createPublicClient({
chain,
transport: http(httpUrl, { batch: { batchSize: 100, wait: 0 } }),
});
this.ws = wsUrl
? createPublicClient({ chain, transport: webSocket(wsUrl) })
: this.http;
Expand Down
14 changes: 14 additions & 0 deletions packages/db/drizzle/0004_workable_zeigeist.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- Composite indexes for paginated address-history queries.
--
-- Drizzle wraps a migration in a transaction, so we use plain `CREATE
-- INDEX IF NOT EXISTS` rather than `CONCURRENTLY` (the latter can't run
-- inside a transaction). On a quiescent indexer this is fine. On a
-- write-active production indexer with multi-million-row tables, the
-- operator should pre-create each index manually first via psql with
-- `CREATE INDEX CONCURRENTLY IF NOT EXISTS …` to avoid blocking writes;
-- the IF NOT EXISTS guard then makes this migration a no-op when run.

CREATE INDEX IF NOT EXISTS "transfers_from_block_idx" ON "token_transfers" USING btree ("from_addr","block_height");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "transfers_to_block_idx" ON "token_transfers" USING btree ("to_addr","block_height");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "txs_from_block_idx" ON "transactions" USING btree ("from_addr","block_height");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "txs_to_block_idx" ON "transactions" USING btree ("to_addr","block_height");
Loading
Loading