Skip to content

Commit b8d30a2

Browse files
dzhelezovclaude
andcommitted
fix(portal): review round — G2 propagation, typed-token row accounting, INV-9 assert
- portal-discovery: a predecessor extension's failure now PROPAGATES (await earlier without swallowing) so a successor can never confirm coverage over an unscanned gap (B1/G2 — silent factory-child loss reproduced in review). - portal.ts: per-fetch RowToken accounting (S1 — a stale evicted fetch can't register/free rows against its replacement); coveredTo recorded per cache entry; INV-9 runtime assert at request build; INV-12 stash assert now strict-mode-only (production keeps pre-refactor overwrite semantics + debug log, S2); refreshPortalHead retry via client (injectable sleep, S5k). - portal-client: finalizedHeadRetry; countRows includes headers (S5g); dead NoProgressError guard removed — progress is by construction (B4c). - portal-errors: NoProgressError deleted; InvariantViolation message points at INVARIANTS.md + PORTAL_CHECKS escape hatch (S5b). - portal/config.ts restored (repo-side compat-harness utility — B2); PUBLISHING.md stale sentence fixed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent cafbb5f commit b8d30a2

5 files changed

Lines changed: 94 additions & 48 deletions

File tree

PUBLISHING.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ field per chain that routes the historical backfill through SQD Portal (realtime
1111
number, so a plain mirror (`0.16.6`) can't be re-published after a bad build. Each release is
1212
published with `--tag latest`, so `npm i @subsquid/ponder` resolves it (npm doesn't auto-pick a
1313
prerelease otherwise); an exact build can be pinned as `@subsquid/ponder@0.16.6-sqd.1`.
14-
- **We don't hand-maintain a fork.** This repo holds only the **Portal layer** (`portal/`): two modules
15-
(`portal.ts`, `portal-transform.ts`) + a per-version `wiring/<ver>.patch` (the 4 one-line touch-points)
16-
+ `config.ts` (`withPortal`). That's the entire diff against upstream.
14+
- **We don't hand-maintain a fork.** This repo holds only the **Portal layer** (`portal/`): the
15+
`portal-*.ts` modules (an invariant-first functional core behind the `portal.ts` shell — see
16+
`portal/INVARIANTS.md`) + a per-version `wiring/<ver>.patch` (the 4 one-line touch-points). That's
17+
the entire diff against upstream. (`portal/config.ts` is a repo-side compat-harness utility — not
18+
part of the published build.)
1719
- **The fork is generated.** `scripts/sync-upstream.sh <ver>` clones `ponder@<ver>`, applies the layer,
1820
renames the package, and builds — producing the publishable package. Tracking a new ponder version is
1921
"author one small patch + run the script", not "merge a fork".

portal/portal-client.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
* real network or timers.
1616
*/
1717
import {
18-
NoProgressError,
1918
PortalDatasetStartError,
2019
PortalHttpError,
2120
PortalQueryTooLargeError,
@@ -74,9 +73,12 @@ const stripFields = (q: PortalQuery, dropped: Set<string>): PortalQuery => {
7473
return { ...q, fields };
7574
};
7675

76+
// Row accounting is CONSERVATIVE (INV-7): each block counts its header too, so header-only batches
77+
// (block-interval includeAllBlocks scans, whose retained headers are real buffered memory) register
78+
// against the budget instead of registering ~0.
7779
const countRows = (blocks: RawBlock[]): number => {
7880
let n = 0;
79-
for (const b of blocks) n += (b.logs?.length ?? 0) + (b.transactions?.length ?? 0) + (b.traces?.length ?? 0);
81+
for (const b of blocks) n += 1 + (b.logs?.length ?? 0) + (b.transactions?.length ?? 0) + (b.traces?.length ?? 0);
8082
return n;
8183
};
8284

@@ -90,6 +92,8 @@ export type StreamOpts = {
9092
export interface PortalClient {
9193
/** GET /finalized-head → the finalized block number (undefined on any failure). */
9294
finalizedHead(): Promise<number | undefined>;
95+
/** finalizedHead() with a bounded retry (backoff via the injectable sleep). */
96+
finalizedHeadRetry(attempts?: number): Promise<number | undefined>;
9397
/** POST /finalized-stream and yield parsed NDJSON batches over [from, to]. */
9498
stream(query: PortalQuery, from: number, to: number, opts?: StreamOpts): AsyncGenerator<RawBlock[]>;
9599
}
@@ -215,7 +219,9 @@ export function createPortalClient(deps: PortalClientDeps): PortalClient {
215219
if (batch === "done") return;
216220
if (onRows) onRows(countRows(batch.blocks));
217221
yield batch.blocks;
218-
if (batch.last < cursor) throw new NoProgressError(cursor);
222+
// Progress by construction (INV-13): fetchBatch initialises `last = cursor` and only ever raises
223+
// it, so `cursor = last + 1 ≥ cursor + 1` — the cursor strictly advances on every yielded batch,
224+
// and a 204 terminates. No runtime guard is needed (and none could ever fire).
219225
cursor = batch.last + 1;
220226
}
221227
}
@@ -228,5 +234,16 @@ export function createPortalClient(deps: PortalClientDeps): PortalClient {
228234
return undefined;
229235
};
230236

231-
return { finalizedHead, stream };
237+
// The head probe is cheap and load-bearing for the finality-gap decision, so callers retry it.
238+
// `sleepImpl` injection keeps the retry cadence testable (no real timers in unit tests).
239+
const finalizedHeadRetry = async (attempts = 3): Promise<number | undefined> => {
240+
for (let attempt = 0; attempt < attempts; attempt++) {
241+
const h = await finalizedHead();
242+
if (h !== undefined) return h;
243+
await sleep(200 * (attempt + 1));
244+
}
245+
return undefined;
246+
};
247+
248+
return { finalizedHead, finalizedHeadRetry, stream };
232249
}

portal/portal-discovery.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,16 @@ export function createDiscovery(deps: DiscoveryDeps): Discovery {
110110
through = to; // optimistic advance so concurrent ensures dedup onto one scan
111111
status = "scanning";
112112
const p = (async () => {
113-
await earlier.catch(() => {}); // G2: a prior failure doesn't poison this extension
113+
// G2/INV-3: a predecessor extension's failure MUST propagate. This extension only scans
114+
// [through+1..to] on the assumption the predecessor covered everything below it; swallowing the
115+
// failure and then confirming `to` would certify coverage over the predecessor's unscanned gap —
116+
// permanently losing any child created inside it. By rethrowing, this extension rejects BEFORE
117+
// scanning, its catch below rolls `through` back to `confirmed`, and the awaiting data chunks
118+
// reject (G1-evicted) → a later interval replans contiguously from confirmed+1 / the floor.
119+
await earlier;
114120
stats.discChunks += windows.length;
115121
await Promise.all(windows.map(([lo, hi]) => scanWindow(lo, hi)));
116-
confirmed = to; // INV-3: advance the confirmed watermark ONLY on success
122+
confirmed = to; // INV-3: advance the confirmed watermark ONLY on success (never past a gap)
117123
status = "idle";
118124
})();
119125
inflight = p;

portal/portal-errors.ts

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -86,16 +86,6 @@ export class PortalQueryTooLargeError extends Error {
8686
}
8787
}
8888

89-
/** The stream failed to advance past the cursor (a Portal that returned a lower `last`). */
90-
export class NoProgressError extends Error {
91-
readonly cursor: number;
92-
constructor(cursor: number) {
93-
super(`Portal no progress @ ${cursor}`);
94-
this.name = "NoProgressError";
95-
this.cursor = cursor;
96-
}
97-
}
98-
9989
/**
10090
* A violated runtime invariant (see portal-invariant.ts + INVARIANTS.md). The repo's
10191
* philosophy: a loud crash beats silent corruption. Carries the invariant `id` and a
@@ -105,7 +95,10 @@ export class InvariantViolation extends Error {
10595
readonly id: string;
10696
readonly context: Record<string, unknown> | undefined;
10797
constructor(id: string, msg: string, context?: Record<string, unknown>) {
108-
super(`Invariant ${id} violated: ${msg}${context ? ` — ${safeJson(context)}` : ""}`);
98+
super(
99+
`Invariant ${id} violated: ${msg}${context ? ` — ${safeJson(context)}` : ""} ` +
100+
`(see portal/INVARIANTS.md ${id}; set PORTAL_CHECKS=off to bypass runtime checks)`,
101+
);
109102
this.name = "InvariantViolation";
110103
this.id = id;
111104
this.context = context;

portal/portal.ts

Lines changed: 56 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,17 @@ export const createPortalHistoricalSync = (args: CreateHistoricalSyncParameters)
6565
const discovery = createDiscovery({ client, childAddresses: args.childAddresses, factories: spec.factories, discoveryWindows: cfg.discoveryWindows, stats });
6666

6767
// ── mutable shell state ──────────────────────────────────────────────────────────────────────────
68-
const dataCache = new Map<number, Promise<ChunkData>>(); // idx → chunk (INV-1: filter-complete)
69-
const chunkSpecId = new Map<number, symbol>(); // idx → fetch-spec identity (INV-1)
70-
const chunkRows = new Map<number, number>(); // idx → registered rows (freed on evict/reject)
71-
const stash = new Map<string, StashEntry>(); // interval → block-data, consumed by syncBlockData
72-
const delegated = new Set<string>(); // interval keys routed to RPC (finality gap)
68+
// One cache entry per chunk idx. `token` keys the row accounting to THIS fetch (not the idx): a stale
69+
// in-flight fetch evicted and replaced at the same idx can neither register rows into nor free rows
70+
// from the replacement's budget — its own token is freed exactly once (idempotent). `coveredTo` is the
71+
// upper bound this fetch actually covered (head-clamped at fetch time), revalidated on every hit
72+
// (INV-13: a chunk truncated at a then-lower finalized head is refetched, never served stale).
73+
type RowToken = { rows: number; freed: boolean };
74+
type CacheEntry = { promise: Promise<ChunkData>; specId: symbol; coveredTo: number; token: RowToken };
75+
const dataCache = new Map<number, CacheEntry>();
76+
const freeToken = (t: RowToken): void => { if (!t.freed) { t.freed = true; gate.freeRows(t.rows); } };
77+
const stash = new Map<string, StashEntry>(); // interval → block-data, consumed by syncBlockData
78+
const delegated = new Set<string>(); // interval keys routed to RPC (finality gap)
7379
let chunkBlocks = cfg.chunkBlocks;
7480
let chunkSizeP: Promise<void> | undefined;
7581
let discStartIdx: number | undefined; // factory-deploy chunk = discovery floor (C4: clamps DOWNWARD)
@@ -89,11 +95,8 @@ export const createPortalHistoricalSync = (args: CreateHistoricalSyncParameters)
8995

9096
const refreshPortalHead = async (): Promise<number | undefined> => {
9197
if (cfg.finalizedHead !== undefined) return (portalHead = cfg.finalizedHead);
92-
for (let attempt = 0; attempt < 3; attempt++) {
93-
const h = await client.finalizedHead();
94-
if (h !== undefined) return (portalHead = h);
95-
await new Promise((r) => setTimeout(r, 200 * (attempt + 1)));
96-
}
98+
const h = await client.finalizedHeadRetry(3); // retry lives in the client (injectable sleep)
99+
if (h !== undefined) return (portalHead = h);
97100
return portalHead; // may be a kept-prior value, or undefined if never probed successfully
98101
};
99102

@@ -106,21 +109,35 @@ export const createPortalHistoricalSync = (args: CreateHistoricalSyncParameters)
106109
})());
107110

108111
// ── one data chunk: gated on discovery-through-this-chunk, then the source streams ──────────────────
109-
const dataChunk = (idx: number): Promise<ChunkData> => {
112+
// `needTo` = the highest block the CALLER requires this chunk to cover (interval end / current grid
113+
// end). A cached entry is served only when its fetch actually covered that far (INV-13).
114+
const dataChunk = (idx: number, needTo: number): Promise<ChunkData> => {
110115
const cached = dataCache.get(idx);
111116
if (cached) {
112-
stats.cacheHits++;
113-
invariant("INV-1", chunkSpecId.get(idx) === spec.id, "cached chunk built under a different fetch-spec", () => ({ idx }));
114-
return cached;
117+
invariant("INV-1", cached.specId === spec.id, "cached chunk built under a different fetch-spec", () => ({ idx }));
118+
if (cached.coveredTo >= needTo) { stats.cacheHits++; return cached.promise; }
119+
// Head-boundary staleness (INV-13, bug found relative to main): this chunk was fetched while the
120+
// Portal head sat inside its grid range, so it was truncated at the then-head. The head has since
121+
// advanced past what the caller needs — serving the cache would silently omit (fetchTimeHead,
122+
// needTo]. Evict + refetch the whole chunk under the current head.
123+
dataCache.delete(idx);
124+
freeToken(cached.token);
125+
log.debug({ service: "portal", msg: `Portal ${chain.name}: chunk ${idx} covered to ${cached.coveredTo} < needed ${needTo} (head advanced) → refetch` });
115126
}
127+
const [from, to] = chunkRange(idx, chunkBlocks, spec.backfillStart, dataEnd());
128+
// INV-9: a Portal data request never targets past the known finalized head (an explicit backfill
129+
// toBlock may exceed it by configuration — the delegation branch already guards served intervals).
130+
invariant("INV-9", portalHead === undefined || to <= portalHead || spec.backfillEnd !== undefined, "data request targets past the Portal finalized head", () => ({ idx, to, portalHead }));
131+
const token: RowToken = { rows: 0, freed: false };
132+
// G3: register rows as batches ARRIVE. Guarded by the token so a stale stream that outlives its
133+
// eviction cannot register orphaned rows (S1: accounting is per-fetch, not per-idx).
134+
const onRows = (n: number): void => { if (token.freed) return; token.rows += n; gate.addRows(n); };
116135
const p = (async (): Promise<ChunkData> => {
117-
const [from, to] = chunkRange(idx, chunkBlocks, spec.backfillStart, dataEnd());
118136
await discovery.ensure(to, { chunkBlocks, endHint: spec.backfillEnd ?? portalHead ?? to }); // children ≤ this chunk are known
119137
invariant("INV-3", spec.factories.length === 0 || discStartIdx === undefined || discovery.through() >= to, "data fetch under a stale discovery watermark", () => ({ idx, through: discovery.through(), to }));
120138
stats.dataChunks++;
121139
const cd = createChunkData();
122140
const neededMissing = new Set<string>();
123-
const onRows = (n: number): void => { chunkRows.set(idx, (chunkRows.get(idx) ?? 0) + n); gate.addRows(n); }; // G3: register as batches arrive
124141

125142
const lq = spec.logQuery();
126143
if (lq) for await (const blocks of client.stream(lq, from, to, { neededMissing, onRows })) {
@@ -158,20 +175,22 @@ export const createPortalHistoricalSync = (args: CreateHistoricalSyncParameters)
158175
}
159176
return cd;
160177
})();
161-
chunkSpecId.set(idx, spec.id);
162-
dataCache.set(idx, p);
163-
// G1 (INV-13): a rejected chunk promise is evicted immediately (and its registered rows freed) so a
164-
// later interval RETRIES rather than replaying the cached rejection forever.
178+
const entry: CacheEntry = { promise: p, specId: spec.id, coveredTo: to, token };
179+
dataCache.set(idx, entry);
180+
// G1 (INV-13): a rejected chunk promise is evicted immediately (and its registered rows freed — via
181+
// ITS token, exactly once) so a later interval RETRIES rather than replaying the cached rejection.
165182
p.catch(() => {
166-
if (dataCache.get(idx) === p) { dataCache.delete(idx); chunkSpecId.delete(idx); }
167-
gate.freeRows(chunkRows.get(idx) ?? 0); chunkRows.delete(idx);
183+
if (dataCache.get(idx) === entry) dataCache.delete(idx);
184+
freeToken(token);
168185
});
169186
return p;
170187
};
171188

172189
const evictBehind = (intervalStart: number): void => {
173190
for (const i of evictionPlan(dataCache.keys(), chunkBlocks, intervalStart)) {
174-
dataCache.delete(i); chunkSpecId.delete(i); gate.freeRows(chunkRows.get(i) ?? 0); chunkRows.delete(i);
191+
const entry = dataCache.get(i)!;
192+
dataCache.delete(i);
193+
freeToken(entry.token);
175194
}
176195
};
177196

@@ -199,8 +218,8 @@ export const createPortalHistoricalSync = (args: CreateHistoricalSyncParameters)
199218
const capped = traceSafeChunkBlocks(chunkBlocks, spec.needTraces || spec.needBlocks, cfg.traceChunkBlocks);
200219
if (capped !== chunkBlocks) {
201220
chunkBlocks = capped;
202-
dataCache.clear(); chunkSpecId.clear();
203-
for (const r of chunkRows.values()) gate.freeRows(r); chunkRows.clear();
221+
for (const entry of dataCache.values()) freeToken(entry.token);
222+
dataCache.clear();
204223
discStartIdx = undefined; discovery.reset();
205224
log.debug({ service: "portal", msg: `Portal ${chain.name}: dense sources → chunkBlocks capped to ${chunkBlocks} (grid reset)` });
206225
}
@@ -215,11 +234,16 @@ export const createPortalHistoricalSync = (args: CreateHistoricalSyncParameters)
215234
const startIdx = idxOf(interval[0], chunkBlocks), endIdx = idxOf(interval[1], chunkBlocks);
216235
const idxs: number[] = [];
217236
for (let i = startIdx; i <= endIdx; i++) idxs.push(i);
218-
const data = await Promise.all(idxs.map(dataChunk));
237+
// needTo per chunk: the interval's requirement clamped to the chunk's current grid end — a cached
238+
// entry truncated at an older, lower head fails the coveredTo check and is refetched (INV-13).
239+
const needToOf = (i: number): number => Math.min(chunkRange(i, chunkBlocks, spec.backfillStart, dataEnd())[1], interval[1]);
240+
const data = await Promise.all(idxs.map((i) => dataChunk(i, needToOf(i))));
219241

220242
// PARALLEL read-ahead: prefetch ahead (never past the backfill end) — depth bounded by the shared
221243
// memory budget, not a fixed count (always lead-1; deeper only while unsaturated).
222-
for (const d of readAheadPlan(endIdx, chunkBlocks, dataEnd(), cfg.readahead, gate.saturated())) void dataChunk(d).catch(() => {});
244+
for (const d of readAheadPlan(endIdx, chunkBlocks, dataEnd(), cfg.readahead, gate.saturated())) {
245+
void dataChunk(d, chunkRange(d, chunkBlocks, spec.backfillStart, dataEnd())[1]).catch(() => {});
246+
}
223247

224248
const tXform = Date.now(); // decode/transform time: Portal NDJSON → Ponder Sync* shapes
225249
const assembled = assembleRange(data, interval, spec, args.childAddresses);
@@ -228,7 +252,11 @@ export const createPortalHistoricalSync = (args: CreateHistoricalSyncParameters)
228252
evictBehind(interval[0]); // free chunks fully behind the cursor + their memory budget
229253

230254
await syncStore.insertLogs({ logs: assembled.logs, chainId: chain.id });
231-
invariant("INV-12", !stash.has(ikey(interval)) && !delegated.has(ikey(interval)), "stash entry created twice / for a delegated interval", () => ({ key: ikey(interval) }));
255+
// INV-12: a stash entry is created then consumed exactly once. An upstream retry can legitimately
256+
// re-issue a range, so production (`on`) keeps the pre-refactor OVERWRITE semantics with a debug
257+
// log; `strict` (tests/CI) makes the double-set loud.
258+
invariantStrict("INV-12", () => !stash.has(ikey(interval)) && !delegated.has(ikey(interval)), "stash entry created twice / for a delegated interval", () => ({ key: ikey(interval) }));
259+
if (stash.has(ikey(interval))) log.debug({ service: "portal", msg: `Portal ${chain.name} [${interval[0]},${interval[1]}]: stash entry overwritten (upstream range retry)` });
232260
stash.set(ikey(interval), { blocks: assembled.blocks, txs: assembled.txs, receipts: assembled.receipts, traces: assembled.traces, closest: assembled.closest });
233261

234262
log.debug({ service: "portal", msg: `Portal ${chain.name} [${interval[0]},${interval[1]}]: ${assembled.logs.length} logs (dataChunks=${stats.dataChunks} discChunks=${stats.discChunks} http=${stats.http} hits=${stats.cacheHits} inflight=${stats.maxInflight} err=${stats.errors})` });

0 commit comments

Comments
 (0)