Skip to content

Commit 38a6c3b

Browse files
dzhelezovclaude
andcommitted
fix(portal): port main's PR #2 (INV-15 child persistence) + PR #5 frontier extend into the architecture
Rebase follow-ups onto current main: - INV-15 (PR #2 a600fc9, ported): portal-discovery queues newly-discovered children inside the min-merge guard (store-preloaded children re-discover at prev ≤ bn and are never re-queued) and exposes takePendingInRange/restorePending; the portal.ts shell flushes the interval-scoped set via syncStore.insertChildAddresses inside the SAME syncBlockRangeData whose transaction marks the factory interval cached. A failed flush restores the queue and fails the interval loud. All six of PR #2's regression tests pass against this architecture unchanged. - INV-13 frontier extend (open PR #5's semantics, adopted — supersedes the evict+refetch approach): each cache entry records coveredTo; a hit past it streams ONLY the newly-finalized tail via the shared runStreams (append-only merge, rows accounted through the same per-fetch token), with discovery re-driven through the tail; a failed extend evicts the whole entry (never leaves the optimistic high-water — the G2 lesson). Fully-finalized/bounded chunks keep coveredTo == desiredTo → clean hit, zero behavior change. - portal-realtime.ts: INV-10 O(1) parentHash/number link tripwire on every append. - portal-assemble.ts: vacuous INV-2 per-row assert removed (enforced by construction behind the single inRange predicate); dead chunkRowCount deleted. - portal-client.ts: dead NoProgressError guard removed (progress by construction); finalizedHeadRetry (injectable sleep); countRows includes headers. - realtime.ts: fix the pre-existing noAssignInExpressions lint ERROR (summarize's byKey bucket init) — the only semantic-file edit outside the layer rewrite. - CLAUDE.md style compliance (single var declarators, no assignment-in-expression, braced control bodies) + repo-Biome (2.5.2, single-quote) formatting throughout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b8d30a2 commit 38a6c3b

15 files changed

Lines changed: 1811 additions & 577 deletions

portal/portal-assemble.ts

Lines changed: 161 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -12,68 +12,72 @@
1212
* • closest includes trace-only and tx-only blocks; computed by a LOOP (not Math.max(...spread), which
1313
* RangeErrors on ~100k+ keys — was C9).
1414
*/
15+
16+
import type { Address } from 'viem';
1517
import type {
1618
SyncBlock,
1719
SyncBlockHeader,
1820
SyncLog,
1921
SyncTrace,
2022
SyncTransaction,
2123
SyncTransactionReceipt,
22-
} from "@/internal/types.js";
24+
} from '@/internal/types.js';
2325
import {
2426
isAddressFactory,
2527
isAddressMatched,
2628
isTraceFilterMatched,
2729
isTransactionFilterMatched,
2830
isTransferFilterMatched,
29-
} from "@/runtime/filter.js";
30-
import type { Interval } from "@/utils/interval.js";
31-
import type { Address } from "viem";
32-
import type { FetchSpec, ChildAddresses } from "./portal-filters.js";
33-
import { invariant, invariantStrict } from "./portal-invariant.js";
31+
} from '@/runtime/filter.js';
32+
import type { Interval } from '@/utils/interval.js';
33+
import type { ChildAddresses, FetchSpec } from './portal-filters.js';
34+
import { invariantStrict } from './portal-invariant.js';
3435
import {
36+
cmpTraceAddr,
37+
hx,
38+
parityToCallFrame,
3539
type RawHeader,
3640
type RawLog,
3741
type RawTrace,
3842
type RawTx,
39-
cmpTraceAddr,
40-
hx,
41-
parityToCallFrame,
4243
toSyncBlockHeader,
4344
toSyncLog,
4445
toSyncReceipt,
4546
toSyncTransaction,
46-
} from "./portal-transform.js";
47+
} from './portal-transform.js';
4748

4849
/** The per-chunk buffered wire data assembled from the Portal streams (built by the shell's dataChunk). */
4950
export type ChunkData = {
5051
headers: Map<number, RawHeader>;
5152
logs: Map<number, RawLog[]>;
5253
txs: Map<number, RawTx[]>;
5354
// for trace/transfer sources: full block + all its traces + its txs, by block number
54-
traceBlocks: Map<number, { header: RawHeader; traces: RawTrace[]; txs: RawTx[] }>;
55+
traceBlocks: Map<
56+
number,
57+
{ header: RawHeader; traces: RawTrace[]; txs: RawTx[] }
58+
>;
5559
// for block-interval sources: headers of blocks matching a BlockFilter (interval/offset)
5660
blockHeaders: Map<number, RawHeader>;
5761
// for account transaction sources: blocks + their from/to-matched txs, by block number
5862
txBlocks: Map<number, { header: RawHeader; txs: RawTx[] }>;
5963
};
6064

6165
export const createChunkData = (): ChunkData => ({
62-
headers: new Map(), logs: new Map(), txs: new Map(), traceBlocks: new Map(), blockHeaders: new Map(), txBlocks: new Map(),
66+
headers: new Map(),
67+
logs: new Map(),
68+
txs: new Map(),
69+
traceBlocks: new Map(),
70+
blockHeaders: new Map(),
71+
txBlocks: new Map(),
6372
});
6473

65-
/** Count the buffered records held by a chunk (for the row budget). */
66-
export const chunkRowCount = (cd: ChunkData): number => {
67-
let rc = cd.blockHeaders.size;
68-
for (const a of cd.logs.values()) rc += a.length;
69-
for (const a of cd.txs.values()) rc += a.length;
70-
for (const b of cd.traceBlocks.values()) rc += b.traces.length + b.txs.length;
71-
for (const b of cd.txBlocks.values()) rc += b.txs.length;
72-
return rc;
73-
};
74-
7574
/** A geth-style CallFrame produced by parityToCallFrame (index = its DFS rank). */
76-
type CallFrame = { index: number; from: string; to?: string; type: string } & Record<string, unknown>;
75+
type CallFrame = {
76+
index: number;
77+
from: string;
78+
to?: string;
79+
type: string;
80+
} & Record<string, unknown>;
7781
export type RankedTrace = { frame: CallFrame; index: number };
7882

7983
/**
@@ -83,16 +87,18 @@ export type RankedTrace = { frame: CallFrame; index: number };
8387
* filter-local one.
8488
*/
8589
export function rankTraces(traces: RawTrace[]): RankedTrace[] {
86-
const sorted = [...traces].sort((x, y) => cmpTraceAddr(x.traceAddress ?? [], y.traceAddress ?? []));
90+
const sorted = [...traces].sort((x, y) =>
91+
cmpTraceAddr(x.traceAddress ?? [], y.traceAddress ?? []),
92+
);
8793
const out: RankedTrace[] = [];
8894
sorted.forEach((t, i) => {
8995
const frame = parityToCallFrame(t, i) as CallFrame | undefined;
9096
if (frame) out.push({ frame, index: i });
9197
});
9298
invariantStrict(
93-
"INV-5",
99+
'INV-5',
94100
() => out.every((r, i) => i === 0 || r.index > out[i - 1]!.index),
95-
"trace ranks not strictly increasing",
101+
'trace ranks not strictly increasing',
96102
() => ({ ranks: out.map((r) => r.index) }),
97103
);
98104
return out;
@@ -103,19 +109,59 @@ type Matchers = {
103109
txFilterMatched: (tx: SyncTransaction, bn: number) => boolean;
104110
};
105111

106-
const buildMatchers = (spec: FetchSpec, childAddresses: ChildAddresses): Matchers => {
107-
const factoryAddrOk = (filterAddr: unknown, addr: string | undefined, bn: number): boolean =>
112+
const buildMatchers = (
113+
spec: FetchSpec,
114+
childAddresses: ChildAddresses,
115+
): Matchers => {
116+
const factoryAddrOk = (
117+
filterAddr: unknown,
118+
addr: string | undefined,
119+
bn: number,
120+
): boolean =>
108121
!isAddressFactory(filterAddr as never) ||
109-
isAddressMatched({ address: addr as Address, blockNumber: bn, childAddresses: childAddresses.get((filterAddr as { id: string }).id)! });
122+
isAddressMatched({
123+
address: addr as Address,
124+
blockNumber: bn,
125+
childAddresses: childAddresses.get((filterAddr as { id: string }).id)!,
126+
});
110127
return {
111128
traceMatched: (frame, bn) => {
112129
const blk = { number: BigInt(bn) } as never;
113-
for (const f of spec.transferFilters) if (isTransferFilterMatched({ filter: f, trace: frame as never, block: blk }) && factoryAddrOk(f.fromAddress, frame.from, bn) && factoryAddrOk(f.toAddress, frame.to, bn)) return true;
114-
for (const f of spec.traceFilters) if (isTraceFilterMatched({ filter: f, trace: frame as never, block: blk }) && factoryAddrOk(f.fromAddress, frame.from, bn) && factoryAddrOk(f.toAddress, frame.to, bn)) return true;
130+
for (const f of spec.transferFilters)
131+
if (
132+
isTransferFilterMatched({
133+
filter: f,
134+
trace: frame as never,
135+
block: blk,
136+
}) &&
137+
factoryAddrOk(f.fromAddress, frame.from, bn) &&
138+
factoryAddrOk(f.toAddress, frame.to, bn)
139+
)
140+
return true;
141+
for (const f of spec.traceFilters)
142+
if (
143+
isTraceFilterMatched({
144+
filter: f,
145+
trace: frame as never,
146+
block: blk,
147+
}) &&
148+
factoryAddrOk(f.fromAddress, frame.from, bn) &&
149+
factoryAddrOk(f.toAddress, frame.to, bn)
150+
)
151+
return true;
115152
return false;
116153
},
117154
txFilterMatched: (tx, bn) =>
118-
spec.transactionFilters.some((f) => isTransactionFilterMatched({ filter: f, transaction: tx }) && factoryAddrOk(f.fromAddress, tx.from, bn) && factoryAddrOk(f.toAddress, (tx.to ?? undefined) as string | undefined, bn)),
155+
spec.transactionFilters.some(
156+
(f) =>
157+
isTransactionFilterMatched({ filter: f, transaction: tx }) &&
158+
factoryAddrOk(f.fromAddress, tx.from, bn) &&
159+
factoryAddrOk(
160+
f.toAddress,
161+
(tx.to ?? undefined) as string | undefined,
162+
bn,
163+
),
164+
),
119165
};
120166
};
121167

@@ -126,7 +172,11 @@ const buildTraces = (
126172
hi: number,
127173
matchers: Matchers,
128174
): { trace: SyncTrace; block: SyncBlock; transaction: SyncTransaction }[] => {
129-
const out: { trace: SyncTrace; block: SyncBlock; transaction: SyncTransaction }[] = [];
175+
const out: {
176+
trace: SyncTrace;
177+
block: SyncBlock;
178+
transaction: SyncTransaction;
179+
}[] = [];
130180
for (const [bn, tb] of cd.traceBlocks) {
131181
if (bn < lo || bn > hi || !tb.traces?.length) continue;
132182
const block = toSyncBlockHeader(tb.header) as unknown as SyncBlock; // encodeTrace only reads block.number
@@ -135,15 +185,25 @@ const buildTraces = (
135185
const byTx = new Map<number, RawTrace[]>();
136186
// callTracer has no block-reward frames; skip reward/no-tx traces so `?? 0` can't fold them into tx 0
137187
// and shift its DFS ranks (now that we fetch the full, unfiltered trace set).
138-
for (const t of tb.traces) { if (t.transactionIndex == null || t.type === "reward") continue; const k = t.transactionIndex; if (!byTx.has(k)) byTx.set(k, []); byTx.get(k)!.push(t); }
188+
for (const t of tb.traces) {
189+
if (t.transactionIndex == null || t.type === 'reward') continue;
190+
const k = t.transactionIndex;
191+
if (!byTx.has(k)) byTx.set(k, []);
192+
byTx.get(k)!.push(t);
193+
}
139194
for (const [txIndex, traces] of byTx) {
140195
const rawTx = txByIdx.get(txIndex);
141196
for (const { frame } of rankTraces(traces)) {
142197
if (!matchers.traceMatched(frame, bn)) continue;
143198
out.push({
144-
trace: { trace: frame, transactionHash: rawTx?.hash } as unknown as SyncTrace,
199+
trace: {
200+
trace: frame,
201+
transactionHash: rawTx?.hash,
202+
} as unknown as SyncTrace,
145203
block,
146-
transaction: rawTx ? toSyncTransaction(rawTx, tb.header) : ({ transactionIndex: hx(txIndex) } as unknown as SyncTransaction),
204+
transaction: rawTx
205+
? toSyncTransaction(rawTx, tb.header)
206+
: ({ transactionIndex: hx(txIndex) } as unknown as SyncTransaction),
147207
});
148208
}
149209
}
@@ -156,7 +216,11 @@ export type AssembledRange = {
156216
blocks: SyncBlockHeader[];
157217
txs: SyncTransaction[];
158218
receipts: SyncTransactionReceipt[];
159-
traces: { trace: SyncTrace; block: SyncBlock; transaction: SyncTransaction }[];
219+
traces: {
220+
trace: SyncTrace;
221+
block: SyncBlock;
222+
transaction: SyncTransaction;
223+
}[];
160224
closest: SyncBlock | undefined;
161225
};
162226

@@ -180,50 +244,79 @@ export function assembleRange(
180244
const syncReceipts: SyncTransactionReceipt[] = [];
181245
const seenTx = new Set<string>();
182246

183-
for (const cd of chunks) for (const [bn, hdr] of cd.headers) {
184-
if (!inRange(bn)) continue;
185-
const logs = cd.logs.get(bn) ?? [];
186-
if (logs.length) {
187-
invariant("INV-2", inRange(bn), "assembled log block outside interval", () => ({ bn, lo, hi }));
188-
blocksByNumber.set(bn, toSyncBlockHeader(hdr));
189-
for (const raw of logs) syncLogs.push(toSyncLog(raw, hdr));
190-
for (const tx of cd.txs.get(bn) ?? []) if (tx.hash && !seenTx.has(tx.hash)) {
191-
seenTx.add(tx.hash);
192-
syncTxs.push(toSyncTransaction(tx, hdr));
193-
if (spec.needReceipts) syncReceipts.push(toSyncReceipt(tx, hdr));
247+
// INV-2 is enforced BY CONSTRUCTION here: every emitting branch below sits behind the single
248+
// `inRange` predicate (there is deliberately no redundant per-row assert — it could never fire),
249+
// and the exactness property is proven against a brute-force model in portal-assemble.test.ts.
250+
for (const cd of chunks)
251+
for (const [bn, hdr] of cd.headers) {
252+
if (!inRange(bn)) continue;
253+
const logs = cd.logs.get(bn) ?? [];
254+
if (logs.length) {
255+
blocksByNumber.set(bn, toSyncBlockHeader(hdr));
256+
for (const raw of logs) syncLogs.push(toSyncLog(raw, hdr));
257+
for (const tx of cd.txs.get(bn) ?? [])
258+
if (tx.hash && !seenTx.has(tx.hash)) {
259+
seenTx.add(tx.hash);
260+
syncTxs.push(toSyncTransaction(tx, hdr));
261+
if (spec.needReceipts) syncReceipts.push(toSyncReceipt(tx, hdr));
262+
}
194263
}
195264
}
196-
}
197265

198266
// block-interval sources: ensure each matched block is in the blocks table (cd.blockHeaders already
199267
// holds ONLY the BlockFilter-matched headers — the shell filters at fetch time to avoid buffering the
200268
// whole includeAllBlocks scan).
201-
if (spec.needBlocks) for (const cd of chunks) for (const [bn, hdr] of cd.blockHeaders) {
202-
if (inRange(bn) && !blocksByNumber.has(bn)) blocksByNumber.set(bn, toSyncBlockHeader(hdr));
203-
}
269+
if (spec.needBlocks)
270+
for (const cd of chunks)
271+
for (const [bn, hdr] of cd.blockHeaders) {
272+
if (inRange(bn) && !blocksByNumber.has(bn))
273+
blocksByNumber.set(bn, toSyncBlockHeader(hdr));
274+
}
204275

205276
// account transaction sources: re-match Portal's from/to-filtered txs (+ factory + range), insert tx/receipt/block
206-
if (spec.needTxFilter) for (const cd of chunks) for (const [bn, tb] of cd.txBlocks) {
207-
if (!inRange(bn)) continue;
208-
for (const raw of tb.txs) {
209-
if (raw.hash && seenTx.has(raw.hash)) continue;
210-
const tx = toSyncTransaction(raw, tb.header);
211-
if (!matchers.txFilterMatched(tx, bn)) continue;
212-
if (raw.hash) seenTx.add(raw.hash);
213-
blocksByNumber.set(bn, toSyncBlockHeader(tb.header));
214-
syncTxs.push(tx);
215-
if (spec.needReceipts) syncReceipts.push(toSyncReceipt(raw, tb.header));
216-
}
217-
}
277+
if (spec.needTxFilter)
278+
for (const cd of chunks)
279+
for (const [bn, tb] of cd.txBlocks) {
280+
if (!inRange(bn)) continue;
281+
for (const raw of tb.txs) {
282+
if (raw.hash && seenTx.has(raw.hash)) continue;
283+
const tx = toSyncTransaction(raw, tb.header);
284+
if (!matchers.txFilterMatched(tx, bn)) continue;
285+
if (raw.hash) seenTx.add(raw.hash);
286+
blocksByNumber.set(bn, toSyncBlockHeader(tb.header));
287+
syncTxs.push(tx);
288+
if (spec.needReceipts)
289+
syncReceipts.push(toSyncReceipt(raw, tb.header));
290+
}
291+
}
218292

219-
const traces = spec.needTraces ? chunks.flatMap((cd) => buildTraces(cd, lo, hi, matchers)) : [];
293+
const traces = spec.needTraces
294+
? chunks.flatMap((cd) => buildTraces(cd, lo, hi, matchers))
295+
: [];
220296

221297
// C9: highest block with data — a LOOP (Math.max(...spread) RangeErrors on ~100k+ keys) — INCLUDING
222298
// trace-only blocks so `closest` doesn't understate the synced tip.
223299
let closest: SyncBlock | undefined;
224300
let maxBn = -1;
225-
for (const [bn, hdr] of blocksByNumber) if (bn > maxBn) { maxBn = bn; closest = hdr as unknown as SyncBlock; }
226-
for (const t of traces) { const bn = Number((t.block as { number: unknown }).number); if (bn > maxBn) { maxBn = bn; closest = t.block; } }
301+
for (const [bn, hdr] of blocksByNumber)
302+
if (bn > maxBn) {
303+
maxBn = bn;
304+
closest = hdr as unknown as SyncBlock;
305+
}
306+
for (const t of traces) {
307+
const bn = Number((t.block as { number: unknown }).number);
308+
if (bn > maxBn) {
309+
maxBn = bn;
310+
closest = t.block;
311+
}
312+
}
227313

228-
return { logs: syncLogs, blocks: [...blocksByNumber.values()], txs: syncTxs, receipts: syncReceipts, traces, closest };
314+
return {
315+
logs: syncLogs,
316+
blocks: [...blocksByNumber.values()],
317+
txs: syncTxs,
318+
receipts: syncReceipts,
319+
traces,
320+
closest,
321+
};
229322
}

portal/portal-chunks.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export const evictionPlan = (
8383
intervalStart: number,
8484
): number[] => {
8585
const out: number[] = [];
86-
for (const idx of cachedIdxs) if ((idx + 1) * chunkBlocks <= intervalStart) out.push(idx);
86+
for (const idx of cachedIdxs)
87+
if ((idx + 1) * chunkBlocks <= intervalStart) out.push(idx);
8788
return out;
8889
};

0 commit comments

Comments
 (0)