Skip to content

Commit 458dc8c

Browse files
mo4islonaclaude
andauthored
fix(portal): wave-4 review — five medium fixes (store parity, monotonic head, schema-degradation bounds, bounded realtime probe) (#44)
* fix(portal): wave-4 review — five medium fixes (log re-match parity, monotonic head, per-field schema bound, stream block-field degradation, bounded realtime probe) Five fixes from a four-lens validation review of the portal layer, each mutation-verified (reverting the fix hunk fails the named regression test): 1. assembleRange re-matches logs per-filter (isLogFilterMatched + isAddressMatched) — factory pre-creation logs and a bounded filter's out-of-range logs no longer diverge the sync store from the RPC path (INV-6/INV-2); a dropped log's parent tx/block no longer rides in. 2. The cached Portal head is monotonic (LB replicas answer stale-LOW), and stream mode's known-head-below-interval branch is FATAL instead of returning [] — closing the reopened G4/C11 silent-gap class (INV-9). 3. stream()'s schema-degradation bound keys on the table-qualified fieldKey, not the bare column name — a dataset missing the same column in two tables (logs_bloom in blocks AND transactions) no longer crash-loops. 4. /stream 400 degradation covers droppable block.* fields, not just transaction.* — completing review B3 (a mix_hash-less dataset no longer refuses stream mode after backfilling fine). 5. portalFinalizedHead delegates to the shared bounded probe (probeFinalizedHead, issue #14/PR #16 hardening) — a hung /finalized-head can no longer freeze finalize emission or startup; deletes the duplicated weak probe. Suite green on 0.16.6 and 0.15.17 grafts (197 tests, 7 new). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(portal): wave-4 review round 2 — matched-log needed-field gate + #47 INVARIANTS candor Addresses the CHANGES_REQUESTED on #44 (two narrow deltas). 2 (medium) — the needed-field fatal counted raw `cd.logs.size`, but the new log re-match (INV-6 store parity) can drop every log a chunk added (a factory child's pre-creation logs / a bounded filter's out-of-range logs). A tail of all-dropped logs over a dataset missing a needed field armed a false `neededMissing` fatal → G1 evict → crash-loop, for data the indexer never keeps — the exact class of #20's trace/transfer residual, created here by the new re-match boundary. `runStreams` now counts only logs surviving `logMatched` toward the growth check (trace/tx/block sources keep the raw map-size delta; the #20 trace/transfer residual is unchanged). New `buildRawLogMatcher` reuses `buildMatchers` + `toSyncLog`, so the seam count and `assembleRange` agree by construction over the same discovery-complete childAddresses. Mutation-verified: reverting the seam to the raw `cd.logs.size` delta fails exactly the new `portal.test.ts` regression. 1 (INVARIANTS candor) — reworded the INV-9 row + the wave-4 monotonic bullet: the `max`-keep guarantees only that the cached head never exceeds the true finalized head; range completeness against the *serving* replica is not verified (a phantom-coverage hole pre-existing on main per PR #15 FIX 1, widened to process-lifetime by max-keeping). Both now reference #47; the client-side range-completeness guard is deferred there pending one empirical check of Portal 204 semantics. INV-13 residual note updated (logs now matched-precise; trace/transfer the remaining #20 slice). Gates: 0.16.6 + 0.15.17 `--test` green (198 each, +1 test); biome clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 37892f8 commit 458dc8c

12 files changed

Lines changed: 737 additions & 169 deletions

portal/INVARIANTS.md

Lines changed: 13 additions & 3 deletions
Large diffs are not rendered by default.

portal/portal-assemble.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,89 @@ test('a tx matched by BOTH a log filter and a tx filter keeps one tx row AND get
167167
expect((out.receipts[0] as any).transactionHash).toBe('0xBOTH');
168168
});
169169

170+
// ── log re-match: parity with the RPC path's per-filter matching (wave 4) ───────────────────────────
171+
172+
test("wave 4: a factory child's PRE-CREATION log is excluded — and its parent tx/block with it (parity with isAddressMatched)", () => {
173+
// The Portal log request carries the child address with NO per-address creation floor, so a child that
174+
// emitted filter-matching logs BEFORE its creation event comes back "matched". Upstream's RPC path
175+
// stores a child's logs only from its creation block on (isAddressMatched: creation ≤ blockNumber);
176+
// skipping the re-match stored the pre-creation log + its tx + its block — an INV-6 store divergence.
177+
const CHILD = '0x000000000000000000000000000000000c0ffee1';
178+
const factory: any = {
179+
id: 'f1',
180+
type: 'log',
181+
chainId: 1,
182+
sourceId: 's',
183+
address: '0x00000000000000000000000000000000000fac70',
184+
eventSelector: '0xsel',
185+
childAddressLocation: 'topic1',
186+
fromBlock: undefined,
187+
toBlock: undefined,
188+
};
189+
const factoryLogFilter: any = { ...logFilter, address: factory };
190+
const childAddresses = new Map([['f1', new Map([[CHILD, 100]])]]); // created at block 100
191+
const cd = createChunkData();
192+
cd.headers.set(50, header(50));
193+
cd.logs.set(50, [rawLog(CHILD, '0xtxPRE')]); // emitted BEFORE creation → must be dropped
194+
cd.txs.set(50, [rawTx('0xtxPRE')]);
195+
cd.headers.set(150, header(150));
196+
cd.logs.set(150, [rawLog(CHILD, '0xtxPOST')]); // after creation → kept
197+
cd.txs.set(150, [rawTx('0xtxPOST')]);
198+
const spec = compileFetchSpec(
199+
[{ filter: factoryLogFilter }],
200+
childAddresses as any,
201+
);
202+
const out = assembleRange([cd], [0, 200], spec, childAddresses as any);
203+
expect(out.logs).toHaveLength(1);
204+
expect(Number(BigInt((out.logs[0] as any).blockNumber))).toBe(150);
205+
expect(out.txs).toHaveLength(1); // the dropped log's parent tx must not ride in
206+
expect((out.txs[0] as any).hash).toBe('0xtxPOST');
207+
expect(out.blocks.map((b) => Number(BigInt((b as any).number)))).toEqual([
208+
150,
209+
]); // block 50 held ONLY the dropped log → not stored
210+
});
211+
212+
test("wave 4: a bounded filter's below-fromBlock log is excluded even when another filter's chunk fetched it (per-filter range re-match)", () => {
213+
// Chunk fetches are bounded by the SPEC-global backfillStart/dataEnd, so with filter A unbounded and
214+
// filter B fromBlock:200, chunks below 200 are fetched with B's topics too (merged server request).
215+
// Upstream's isLogFilterMatched rejects a B-matching log below 200; without the re-match it was
216+
// stored and returned as matched — an INV-6 store divergence.
217+
const filterA: any = { ...logFilter, topic0: '0xaaaa' }; // unbounded
218+
const filterB: any = { ...logFilter, topic0: '0xbbbb', fromBlock: 200 };
219+
const logWithTopic = (topic0: string, tx: string) => ({
220+
...rawLog('0xV', tx),
221+
topics: [topic0],
222+
});
223+
const cd = createChunkData();
224+
cd.headers.set(100, header(100));
225+
// block 100: an A log (kept) and a B log BELOW B's fromBlock (dropped)
226+
cd.logs.set(100, [
227+
logWithTopic('0xaaaa', '0xtxA100'),
228+
logWithTopic('0xbbbb', '0xtxB100'),
229+
]);
230+
cd.txs.set(100, [rawTx('0xtxA100'), rawTx('0xtxB100')]);
231+
cd.headers.set(250, header(250));
232+
cd.logs.set(250, [logWithTopic('0xbbbb', '0xtxB250')]); // in B's range → kept
233+
cd.txs.set(250, [rawTx('0xtxB250')]);
234+
const spec = compileFetchSpec(
235+
[{ filter: filterA }, { filter: filterB }],
236+
new Map(),
237+
);
238+
const out = assembleRange([cd], [0, 300], spec, new Map());
239+
const keptBlocks = out.logs.map((l) =>
240+
Number(BigInt((l as any).blockNumber)),
241+
);
242+
expect(keptBlocks.sort((x, y) => x - y)).toEqual([100, 250]);
243+
expect(out.logs.map((l) => (l as any).topics[0]).sort()).toEqual([
244+
'0xaaaa',
245+
'0xbbbb',
246+
]); // the below-range B log at 100 is gone
247+
expect(out.txs.map((t) => (t as any).hash).sort()).toEqual([
248+
'0xtxA100',
249+
'0xtxB250',
250+
]); // 0xtxB100 (parent of the dropped log) must not be inserted
251+
});
252+
170253
// ── INV-5: trace ranking = index in the cmpTraceAddr-sorted full list ────────────────────────────────
171254

172255
const arbTraceAddr = fc.array(fc.integer({ min: 0, max: 3 }), { maxLength: 4 });

portal/portal-assemble.ts

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import type {
2525
import {
2626
isAddressFactory,
2727
isAddressMatched,
28+
isLogFilterMatched,
2829
isTraceFilterMatched,
2930
isTransactionFilterMatched,
3031
isTransferFilterMatched,
@@ -105,6 +106,7 @@ export function rankTraces(traces: RawTrace[]): RankedTrace[] {
105106
}
106107

107108
type Matchers = {
109+
logMatched: (log: SyncLog, bn: number) => boolean;
108110
traceMatched: (frame: CallFrame, bn: number) => boolean;
109111
txFilterMatched: (tx: SyncTransaction, bn: number) => boolean;
110112
};
@@ -125,6 +127,19 @@ const buildMatchers = (
125127
childAddresses: childAddresses.get((filterAddr as { id: string }).id)!,
126128
});
127129
return {
130+
// Log re-match (wave 4 review): the Portal's server-side log filter is the MERGED request set with no
131+
// per-filter fromBlock/toBlock and no per-child creation floor, so it legitimately returns (a) a
132+
// factory child's logs from BEFORE the child's creation block and (b) a bounded filter's logs outside
133+
// its own range when another filter's chunk fetched them. Upstream's RPC path re-matches every log
134+
// (isLogFilterMatched + isAddressMatched) before insertion; skipping that here stored/returned rows
135+
// the RPC path excludes — an INV-6 store divergence. buildEvents re-filters, so no wrong events fired,
136+
// but the sync store must be byte-identical across both paths.
137+
logMatched: (log, bn) =>
138+
spec.logFilters.some(
139+
(f) =>
140+
isLogFilterMatched({ filter: f, log }) &&
141+
factoryAddrOk(f.address, log.address, bn),
142+
),
128143
traceMatched: (frame, bn) => {
129144
const blk = { number: BigInt(bn) } as never;
130145
for (const f of spec.transferFilters)
@@ -165,6 +180,24 @@ const buildMatchers = (
165180
};
166181
};
167182

183+
/**
184+
* Stream-seam parity for `runStreams`' needed-field growth check (wave-4 log re-match). The Portal's
185+
* server-side log filter over-returns rows that assembly then DROPS — a factory child's PRE-CREATION logs
186+
* and a bounded filter's OUT-OF-RANGE logs (see `logMatched` above) — so counting raw returned logs toward
187+
* "matched data this call added" would arm the needed-field fatal for data the indexer never keeps → G1
188+
* evict → crash-loop. This returns a predicate over RAW logs that mirrors `assembleRange`'s per-log
189+
* re-match EXACTLY (same `buildMatchers` + `toSyncLog`, over the same discovery-complete `childAddresses`),
190+
* so the seam count and assembly agree by construction.
191+
*/
192+
export const buildRawLogMatcher = (
193+
spec: FetchSpec,
194+
childAddresses: ChildAddresses,
195+
): ((raw: RawLog, hdr: RawHeader, bn: number) => boolean) => {
196+
const { logMatched } = buildMatchers(spec, childAddresses);
197+
198+
return (raw, hdr, bn) => logMatched(toSyncLog(raw, hdr), bn);
199+
};
200+
168201
/** Per-chunk trace assembly: full-tree ranking then client-side filtering (INV-5). `onReceipt` (FIX 4)
169202
* is invoked with each MATCHED trace's parent transaction so the caller can emit a receipt when a
170203
* trace/transfer filter has `hasTransactionReceipt` — the log/tx branches never see these txs. */
@@ -260,20 +293,34 @@ export function assembleRange(
260293
// INV-2 is enforced BY CONSTRUCTION here: every emitting branch below sits behind the single
261294
// `inRange` predicate (there is deliberately no redundant per-row assert — it could never fire),
262295
// and the exactness property is proven against a brute-force model in portal-assemble.test.ts.
296+
// Each log is re-matched against the ACTUAL log filters (see logMatched above); a dropped log's
297+
// parent tx/block must not ride in either, so txs are keyed to the KEPT logs' transactionHash.
263298
for (const cd of chunks)
264299
for (const [bn, hdr] of cd.headers) {
265300
if (!inRange(bn)) continue;
266-
const logs = cd.logs.get(bn) ?? [];
267-
if (logs.length) {
268-
blocksByNumber.set(bn, toSyncBlockHeader(hdr));
269-
for (const raw of logs) syncLogs.push(toSyncLog(raw, hdr));
270-
for (const tx of cd.txs.get(bn) ?? [])
271-
if (tx.hash && !seenTx.has(tx.hash)) {
272-
seenTx.add(tx.hash);
273-
syncTxs.push(toSyncTransaction(tx, hdr));
274-
if (spec.needReceipts) pushReceipt(tx, hdr);
275-
}
301+
302+
const rawLogs = cd.logs.get(bn) ?? [];
303+
if (rawLogs.length === 0) continue;
304+
305+
const keptTxHashes = new Set<string>();
306+
let keptAny = false;
307+
for (const raw of rawLogs) {
308+
const log = toSyncLog(raw, hdr);
309+
if (!matchers.logMatched(log, bn)) continue;
310+
311+
keptAny = true;
312+
syncLogs.push(log);
313+
if (raw.transactionHash) keptTxHashes.add(raw.transactionHash);
276314
}
315+
if (!keptAny) continue;
316+
317+
blocksByNumber.set(bn, toSyncBlockHeader(hdr));
318+
for (const tx of cd.txs.get(bn) ?? [])
319+
if (tx.hash && keptTxHashes.has(tx.hash) && !seenTx.has(tx.hash)) {
320+
seenTx.add(tx.hash);
321+
syncTxs.push(toSyncTransaction(tx, hdr));
322+
if (spec.needReceipts) pushReceipt(tx, hdr);
323+
}
277324
}
278325

279326
// block-interval sources: ensure each matched block is in the blocks table (cd.blockHeaders already

portal/portal-client.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,46 @@ test('field degradation: a NEEDED field (logsBloom) missing is recorded in neede
212212
expect([...neededMissing]).toEqual(['transaction.logsBloom (logs_bloom)']);
213213
});
214214

215+
test('field degradation: the SAME column missing in TWO tables drops each independently (bound keyed per-FIELD, not per-column — wave 4)', async () => {
216+
// A dataset lacking logs_bloom in BOTH blocks and transactions (the Monad-style shape FIX 3 exists
217+
// for; every query projects block.logsBloom, and the tx query always projects RECEIPT_FIELDS'
218+
// logsBloom). The "dropping didn't help → real error" bound used to key on the BARE column name, so
219+
// the second table's 400 hit the bound and threw a fatal PortalSchemaFieldError → the chunk rejected,
220+
// was G1-evicted, refetched, and failed identically: a deterministic crash-loop. Keying per
221+
// table-qualified field drops each table's column independently while keeping the bound for a field
222+
// whose OWN drop didn't fix its own 400.
223+
const bodies: any[] = [];
224+
const client = mk({
225+
fetchImpl: (async (_u: string, init: any) => {
226+
const q = JSON.parse(init.body);
227+
bodies.push(q);
228+
if (q.fields?.block?.logsBloom !== undefined)
229+
return badRes("column 'logs_bloom' is not found in 'blocks'");
230+
if (q.fields?.transaction?.logsBloom !== undefined)
231+
return badRes("column 'logs_bloom' is not found in 'transactions'");
232+
return ndjsonRes([{ header: { number: 10 } }]);
233+
}) as any,
234+
});
235+
const neededMissing = new Set<string>();
236+
const q: PortalQuery = {
237+
type: 'evm',
238+
fields: {
239+
block: { logsBloom: true, number: true },
240+
transaction: { logsBloom: true, hash: true },
241+
},
242+
};
243+
const out = await collect(client.stream(q, 0, 10, { neededMissing })); // no throw — the old code threw here
244+
expect(out).toHaveLength(1);
245+
const last = bodies[bodies.length - 1];
246+
expect(last.fields.block.logsBloom).toBeUndefined(); // first drop
247+
expect(last.fields.transaction.logsBloom).toBeUndefined(); // second drop (used to be the fatal)
248+
expect(last.fields.block.number).toBe(true); // other fields kept (locality)
249+
expect(last.fields.transaction.hash).toBe(true);
250+
// both are NEEDED (non-droppable) fields → each recorded for the FIX-3 seam to judge, not silent
251+
expect(neededMissing.has('block.logsBloom (logs_bloom)')).toBe(true);
252+
expect(neededMissing.has('transaction.logsBloom (logs_bloom)')).toBe(true);
253+
});
254+
215255
test('dataset-start 400 clamps the cursor forward, not a crash', async () => {
216256
let clampedFrom = -1;
217257
const client = mk({

0 commit comments

Comments
 (0)