Skip to content

Commit 97653a7

Browse files
dzhelezovclaude
andcommitted
fix: keep every Portal request under MAX_RAW_QUERY_SIZE (256KB) — merge log filters + fail loud
Root cause (from sqd-network transport/src/protocol.rs): the Portal 400 "Query is too large" checks the raw request BODY size against MAX_RAW_QUERY_SIZE = 256*1024, NOT range/data — so my earlier range-bisect was treating the wrong variable (reverted). The 24-event EVault superset blew the body up because ponder emits one filter per event, and the fork repeated the full child-address list once per event (24 × ~265 addrs ≈ 270KB). - mergeLogRequests(): fold log requests sharing an address set + topic1..3 into one, unioning topic0 → identical results, ~N× smaller body. Euler's worst (eth: 897 children × 24 topics) ≈ 41KB, 6× under. - Uniform proactive guard in fetchBatch (the single POST choke point) covers EVERY request type (logs/traces/txs/discovery): a body over 256KB throws an explicit error naming the size + the driver (log vs tx from/to address counts) instead of an opaque Portal 400. tx from/to sets can't be safely address-split, so those fail loud by design. - 2 regression tests (merge folds N filters → 1; over-limit body fails loud). 24/24. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 62c87d6 commit 97653a7

3 files changed

Lines changed: 103 additions & 11 deletions

File tree

harness/euler-multichain/README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,15 @@ is the production shape of an Euler indexer and a stress test of the `@subsquid/
66
shared Portal endpoint, one database, fifteen chains backfilling concurrently.
77

88
## What it indexes
9-
- The `eVaultFactory` (`GenericFactory``ProxyCreated` → child EVaults) per chain.
10-
- The 6 EVault events — `Deposit, Withdraw, Borrow, Repay, Liquidate, VaultStatus` — log-based,
11-
exactly how the real euler-subgraph indexes (no receipts; `event.transaction.hash` for IDs).
9+
- The `eVaultFactory` (`GenericFactory``ProxyCreated` → child EVaults) per chain, with start blocks
10+
taken from **Euler's own subgraph config** (`euler-xyz/euler-subgraph`) and every factory address
11+
cross-verified against `euler-xyz/euler-interfaces` `CoreAddresses.json`.
12+
- The **full 24-event EVault superset** (`Deposit/Withdraw/Borrow/Repay/Liquidate/Transfer/Approval/
13+
VaultStatus/InterestAccrued/DebtSocialized/PullDebt/ConvertFees/BalanceForwarderStatus/EVaultCreated`
14+
+ all `GovSet*`), from the euler-interfaces ABI. Euler runs **both** ponder and subgraphs in prod and
15+
the public subgraph indexes only a subset (`Transfer/Borrow/Repay`), so a superset is the faithful
16+
apples-to-apples target. Log-based, no receipts; `event.transaction.hash` for IDs.
17+
Vault discovery is cross-checked against Euler's live Goldsky subgraph per chain (see REPORT.md).
1218

1319
## Chains (15)
1420
`ethereum · binance · unichain · polygon · monad · sonic · tac · hyperliquid · base · plasma ·

portal/portal.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,3 +376,55 @@ test("regression (C6): deep matched trace is stored at its FULL-tree pre-order i
376376
expect((inserted[0].trace.trace.to as string)?.toLowerCase()).toBe(TARGET);
377377
} finally { srv.close(); delete process.env.PORTAL_FINALIZED_HEAD; }
378378
});
379+
380+
test("merge: N same-address event filters collapse to ONE log request (unioned topic0) — keeps body small", async () => {
381+
// Ponder emits one filter per event; a 24-event EVault would otherwise repeat the child-address
382+
// list 24× in one body and blow past MAX_RAW_QUERY_SIZE. mergeLogRequests must fold them into one.
383+
const ADDR = "0x" + "cc".repeat(20);
384+
const topic0s = Array.from({ length: 6 }, (_, i) => "0x" + (i + 1).toString(16).padStart(64, "0"));
385+
const dataQueries: any[] = [];
386+
const srv = http.createServer((req, res) => {
387+
let body = ""; req.on("data", (c) => { body += c; });
388+
req.on("end", () => { const q = body ? JSON.parse(body) : {}; if (q.logs) dataQueries.push(q); res.writeHead(204).end(); });
389+
});
390+
const p = await new Promise<number>((r) => srv.listen(0, () => r((srv.address() as AddressInfo).port)));
391+
try {
392+
const filters = topic0s.map((t, i) => ({
393+
type: "log", chainId: 1, sourceId: `evault:e${i}`, address: ADDR, topic0: t,
394+
topic1: null, topic2: null, topic3: null, fromBlock: 0, toBlock: 100, hasTransactionReceipt: false, include: [],
395+
}));
396+
const sync = createPortalHistoricalSync({
397+
common: { logger: { debug() {}, info() {}, warn() {}, error() {}, trace() {} } },
398+
chain: { id: 1, name: "mainnet", portal: `http://localhost:${p}` },
399+
childAddresses: new Map(), eventCallbacks: filters.map((f) => ({ filter: f })),
400+
} as any);
401+
await sync.syncBlockRangeData({
402+
interval: [0, 100], requiredIntervals: filters.map((f) => ({ interval: [0, 100], filter: f })),
403+
requiredFactoryIntervals: [], syncStore: { insertLogs() {}, insertBlocks() {}, insertTransactions() {}, insertTransactionReceipts() {}, insertTraces() {} },
404+
} as any);
405+
const merged = dataQueries.find((q) => (q.logs?.[0]?.topic0?.length ?? 0) > 1);
406+
expect(merged).toBeDefined();
407+
expect(merged.logs).toHaveLength(1); // ONE request, not 6
408+
expect(new Set(merged.logs[0].topic0.map((x: string) => x.toLowerCase())).size).toBe(6); // all selectors unioned
409+
} finally { srv.close(); }
410+
});
411+
412+
test("guard: an over-limit request body fails loud with the explicit size driver (never a silent Portal 400)", async () => {
413+
// 12k filter addresses → batched bodies sum > 256KB MAX_RAW_QUERY_SIZE. The proactive guard must
414+
// throw a clear, actionable error BEFORE the POST, not let the Portal reject it opaquely.
415+
const addrs = Array.from({ length: 12000 }, (_, i) => "0x" + i.toString(16).padStart(40, "0"));
416+
const srv = http.createServer((req, res) => { let b = ""; req.on("data", (c) => { b += c; }); req.on("end", () => res.writeHead(204).end()); });
417+
const p = await new Promise<number>((r) => srv.listen(0, () => r((srv.address() as AddressInfo).port)));
418+
try {
419+
const filter = { type: "log", chainId: 1, sourceId: "big", address: addrs, topic0: "0x" + "11".repeat(32), topic1: null, topic2: null, topic3: null, fromBlock: 0, toBlock: 100, hasTransactionReceipt: false, include: [] };
420+
const sync = createPortalHistoricalSync({
421+
common: { logger: { debug() {}, info() {}, warn() {}, error() {}, trace() {} } },
422+
chain: { id: 1, name: "mainnet", portal: `http://localhost:${p}` },
423+
childAddresses: new Map(), eventCallbacks: [{ filter }],
424+
} as any);
425+
await expect(sync.syncBlockRangeData({
426+
interval: [0, 100], requiredIntervals: [{ interval: [0, 100], filter }], requiredFactoryIntervals: [],
427+
syncStore: { insertLogs() {}, insertBlocks() {}, insertTransactions() {}, insertTransactionReceipts() {}, insertTraces() {} },
428+
} as any)).rejects.toThrow(/exceeds MAX_RAW_QUERY_SIZE/);
429+
} finally { srv.close(); }
430+
});

portal/portal.ts

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ type ChunkData = {
7272
};
7373

7474
const PORTAL_MAX_ADDRESSES = 1000;
75+
// Portal rejects any request whose raw body exceeds this (sqd-network transport/src/protocol.rs:
76+
// `MAX_RAW_QUERY_SIZE = 256 * 1024`) with 400 "Query is too large". The body is dominated by filter
77+
// address lists (factory children in log/tx filters). We keep under it by merging per-event log
78+
// filters + batching addresses; a body that still overflows fails loud (see fetchBatch).
79+
const MAX_RAW_QUERY_SIZE = 256 * 1024;
7580
const CHUNK_BLOCKS = Number(process.env.PORTAL_CHUNK_BLOCKS ?? 500_000);
7681
const READAHEAD = Number(process.env.PORTAL_READAHEAD ?? 6);
7782
// The Portal fans ONE stream request out across up to `buffer_size` chunk-workers concurrently
@@ -217,6 +222,20 @@ export const createPortalHistoricalSync = (
217222

218223
// one POST+drain; returns blocks or "done" (204); throws (with .retryAfterMs on 503-class).
219224
async function fetchBatch(body: string, cursor: number): Promise<{ blocks: { header: RawHeader; logs?: any[]; transactions?: any[]; traces?: any[] }[]; last: number } | "done"> {
225+
// Proactive, uniform size guard — covers EVERY request type (logs/traces/txs/discovery) at the one
226+
// POST choke point. A body over MAX_RAW_QUERY_SIZE would 400; surface it explicitly with the real
227+
// driver instead. Euler's worst (eth: 897 children × 24 topics) is ~41KB, so this never fires here;
228+
// it protects indexers with pathological filtered-address counts (esp. unbatched tx from/to sets).
229+
if (body.length > MAX_RAW_QUERY_SIZE) {
230+
const q = (() => { try { return JSON.parse(body); } catch { return {}; } })();
231+
const nLog = (q.logs ?? []).reduce((s: number, r: any) => s + (r.address?.length ?? 0), 0);
232+
const nTx = (q.transactions ?? []).reduce((s: number, r: any) => s + (r.from?.length ?? 0) + (r.to?.length ?? 0), 0);
233+
throw new Error(
234+
`Portal request body ${(body.length / 1024).toFixed(1)}KB exceeds MAX_RAW_QUERY_SIZE ${MAX_RAW_QUERY_SIZE / 1024}KB @ ${cursor}. ` +
235+
`Filter addresses in this request: ${nLog} log + ${nTx} tx(from/to). ` +
236+
`Log filters are already merged+batched (PORTAL_MAX_ADDRESSES=${PORTAL_MAX_ADDRESSES}); if this is a tx filter, its from/to set is too large to fit one request and cannot be safely split — narrow the filter.`,
237+
);
238+
}
220239
const tAcq = Date.now(); await portalGate.acquire(); stats.gateWaitMs += Date.now() - tAcq; // gate-wait = concurrency back-pressure
221240
const tFetch = Date.now();
222241
stats.inflight++; stats.maxInflight = Math.max(stats.maxInflight, stats.inflight);
@@ -304,17 +323,15 @@ export const createPortalHistoricalSync = (
304323
while (cursor <= to) {
305324
let attempt = 0;
306325
let batch: Awaited<ReturnType<typeof fetchBatch>> | undefined;
307-
let hi = to; // per-cursor upper bound; bisected down on "query too large", reset each advance
308326
while (batch === undefined) {
309-
const body = JSON.stringify({ ...stripFields(query, dropped), fromBlock: cursor, toBlock: hi });
327+
const body = JSON.stringify({ ...stripFields(query, dropped), fromBlock: cursor, toBlock: to });
310328
try { batch = await fetchBatch(body, cursor); }
311329
catch (err: any) {
312330
if (err?.tooLarge) {
313-
// dense window exceeded the Portal's query estimate → halve it and retry. Continuation
314-
// then covers [hi+1, to] as usual, so no data is skipped.
315-
if (hi <= cursor) throw err; // a single block still too large ⇒ a real limit, surface it
316-
hi = cursor + Math.floor((hi - cursor) / 2);
317-
continue;
331+
// Portal caps request BYTES (MAX_RAW_QUERY_SIZE), not range — so bisecting blocks can't
332+
// help. mergeLogRequests already de-dups addresses across event filters; if a body still
333+
// exceeds the cap the address batch itself is too big → fail loud with the actual lever.
334+
throw new Error(`Portal query body exceeds MAX_RAW_QUERY_SIZE even after merging event filters — lower PORTAL_MAX_ADDRESSES (currently ${PORTAL_MAX_ADDRESSES}) to shrink the address batch. @ ${cursor}`);
318335
}
319336
if (err?.datasetStartsAt !== undefined) {
320337
// dataset begins after this chunk's start (doesn't reach genesis) → skip the missing
@@ -367,6 +384,23 @@ export const createPortalHistoricalSync = (
367384
return out;
368385
}
369386

387+
// Ponder emits ONE filter per event, so an N-event contract (e.g. the 24-event EVault) produces N
388+
// log requests that each repeat the SAME (possibly large) child-address list with a different
389+
// topic0. Concatenated into one query body they can exceed the Portal's raw query-size limit
390+
// (400 "Query is too large" — it caps request BYTES, not range). Collapse requests that share the
391+
// same address set + topic1..3 into one, unioning topic0 — identical result set, ~N× smaller body.
392+
function mergeLogRequests(reqs: PortalLogRequest[]): PortalLogRequest[] {
393+
const groups = new Map<string, PortalLogRequest>();
394+
for (const r of reqs) {
395+
const key = JSON.stringify([r.address ? [...r.address].sort() : null, r.topic1 ?? null, r.topic2 ?? null, r.topic3 ?? null]);
396+
const g = groups.get(key);
397+
if (!g) { groups.set(key, { ...r, topic0: r.topic0 ? [...new Set(r.topic0)] : undefined }); continue; }
398+
if (g.topic0 === undefined || r.topic0 === undefined) g.topic0 = undefined; // one wants ALL topic0 → keep the broadest
399+
else { const s = new Set(g.topic0); for (const t of r.topic0) s.add(t); g.topic0 = [...s]; }
400+
}
401+
return [...groups.values()];
402+
}
403+
370404
// FILTER/PROJECTION STRATEGY (max Portal leverage): every row filter is pushed to
371405
// Portal's native server-side filters — logs by address+topics (logRequestsFor),
372406
// traces by callTo/callFrom/callSighash (tracePortalRequests), account txs by from/to
@@ -504,7 +538,7 @@ export const createPortalHistoricalSync = (
504538
await ensureDiscoveredThrough(idx, factories); // correctness: children ≤ this chunk are known
505539
stats.dataChunks++;
506540
const [from, to] = chunkRange(idx);
507-
const logRequests = filters.flatMap((f) => logRequestsFor(f)).map((r) => ({ ...r, transaction: true }));
541+
const logRequests = mergeLogRequests(filters.flatMap((f) => logRequestsFor(f))).map((r) => ({ ...r, transaction: true }));
508542
const data: ChunkData = { headers: new Map(), logs: new Map(), txs: new Map(), traceBlocks: new Map(), blockHeaders: new Map(), txBlocks: new Map() };
509543
const neededMissing = new Set<string>(); // needed fields the dataset lacked on THIS chunk
510544
if (logRequests.length > 0) {

0 commit comments

Comments
 (0)