Skip to content

Commit d890aaf

Browse files
mo4islonaclaude
andcommitted
fix(portal): close silent-gap paths (backfill floor + stream realtime), rebased onto the invariant re-architecture
Re-applies three silent-gap fixes from the pre-rearchitecture branch onto the new module layout (portal.ts monolith → functional core + shell). Each ships a regression test; the full Portal suite stays green (138 tests). - backfill floor (finding 3, portal-filters.ts): an undefined fromBlock is genesis (`filter.fromBlock ?? 0`), so if ANY source omits it the floor MUST be 0. compileFetchSpec's Math.min over the DEFINED-only fromBlocks let a bounded source clamp every chunk fetch past an unbounded source's history, marking that prefix synced over a silent gap. - reconnect on child discovery (finding 4, portal-realtime*.ts): stream mode filters /stream server-side, so a child discovered after a connection opens can't reach it. streamHotBlocks now snapshots a logsRevision at open and re-opens with the widened filter the moment it advances (bumped in the wire on each child); ndjsonLines cancels its reader on early break so the old connection closes. - refuse unsupported stream mode (finding 5, portal-realtime-wire.ts): stream mode emits only log-bearing block events, so a non-log source (trace/transfer/ transaction/block) or a receipt-requiring log source would be silently skipped while its intervals finalized as cached. assertStreamModeSupported() fails loud at startup instead. Findings 6 (finalized-head probe) and 7 (unknown-parent gap) are intentionally dropped: the re-architecture (#6) made the opposite, conservative choice on both (passthrough / clear-window) as documented decisions — raise separately rather than override here. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 28dcb79 commit d890aaf

7 files changed

Lines changed: 230 additions & 20 deletions

portal/portal-client.ts

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -48,25 +48,33 @@ export async function* ndjsonLines(
4848
const reader = body.getReader();
4949
const dec = new TextDecoder();
5050
let buf = '';
51-
for (;;) {
52-
const { done, value } = await reader.read();
53-
if (done) break;
54-
55-
if (value) {
56-
onBytes?.(value.byteLength);
57-
buf += dec.decode(value, { stream: true });
58-
}
51+
// A consumer may `break` early (the realtime stream re-opens the moment the logs filter revision bumps —
52+
// finding 4); cancel the reader in `finally` so the underlying response body is closed rather than left
53+
// locked+open. Fire-and-forget (NOT awaited): on a fully-drained stream it's a no-op, and awaiting the
54+
// socket teardown on every historical fetch would add needless latency to the hot path.
55+
try {
5956
for (;;) {
60-
const nl = buf.indexOf('\n');
61-
if (nl < 0) break;
57+
const { done, value } = await reader.read();
58+
if (done) break;
6259

63-
const line = buf.slice(0, nl);
64-
buf = buf.slice(nl + 1);
65-
if (line) yield line;
60+
if (value) {
61+
onBytes?.(value.byteLength);
62+
buf += dec.decode(value, { stream: true });
63+
}
64+
for (;;) {
65+
const nl = buf.indexOf('\n');
66+
if (nl < 0) break;
67+
68+
const line = buf.slice(0, nl);
69+
buf = buf.slice(nl + 1);
70+
if (line) yield line;
71+
}
6672
}
73+
buf += dec.decode();
74+
if (buf) yield buf;
75+
} finally {
76+
void reader.cancel().catch(() => {});
6777
}
68-
buf += dec.decode();
69-
if (buf) yield buf;
7078
}
7179

7280
// Portal reports a missing COLUMN in a plural TABLE; map back to the field key we requested.

portal/portal-filters.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,26 @@ test('INV-1 support: compileFetchSpec is deterministic in structure and frozen',
194194
expect(a.logQuery()).toEqual(b.logQuery());
195195
});
196196

197+
// ── finding 3: backfillStart floor never over-clamps an unbounded source ───────────────────────────
198+
199+
test('finding 3: an unbounded source (fromBlock undefined) forces backfillStart 0, not Math.min over the bounded ones', () => {
200+
// one bounded source at 15M, one unbounded (no fromBlock ⇒ genesis). The floor MUST be 0 — clamping to
201+
// 15_000_000 would mark the unbounded source's whole [0, 15M) history synced over a silent gap.
202+
const cbs = [
203+
{ filter: logFilter({ fromBlock: 15_000_000 }) },
204+
{ filter: logFilter({ fromBlock: undefined }) },
205+
];
206+
expect(compileFetchSpec(cbs, new Map()).backfillStart).toBe(0);
207+
});
208+
209+
test('finding 3: when EVERY source is bounded, backfillStart is the earliest fromBlock', () => {
210+
const cbs = [
211+
{ filter: logFilter({ fromBlock: 15_000_000 }) },
212+
{ filter: logFilter({ fromBlock: 8_000_000 }) },
213+
];
214+
expect(compileFetchSpec(cbs, new Map()).backfillStart).toBe(8_000_000);
215+
});
216+
197217
test('INV-5 support: the trace request carries NO trace filter (fetch-all)', () => {
198218
const trace: any = {
199219
type: 'trace',

portal/portal-filters.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -356,10 +356,16 @@ export function compileFetchSpec(
356356

357357
// the chain's actual backfill window, from the filters — used to bound chunk fetches so a bounded
358358
// backfill (or the tail) never over-fetches. Fully automatic; no client tuning.
359-
const froms = fs
360-
.map((f) => f.fromBlock)
361-
.filter((b): b is number => b != null);
362-
const backfillStart = froms.length ? Math.min(...froms) : 0;
359+
// A source with NO fromBlock starts at genesis (the runtime reads `filter.fromBlock ?? 0`); if ANY
360+
// source omits it the floor MUST be 0. Taking Math.min over only the DEFINED fromBlocks would clamp
361+
// every chunk fetch past the earliest DEFINED start and mark the [0, min) prefix synced over a silent
362+
// gap — losing the whole history of the unbounded source. (finding 3 / C10)
363+
const froms = fs.map((f) => f.fromBlock);
364+
const definedFroms = froms.filter((b): b is number => b != null);
365+
const backfillStart =
366+
froms.some((b) => b == null) || definedFroms.length === 0
367+
? 0
368+
: Math.min(...definedFroms);
363369
const tos = fs.map((f) => f.toBlock);
364370
const backfillEnd =
365371
tos.length && tos.every((t) => t != null)

portal/portal-realtime-wire.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
} from '@/internal/types.js';
99
import type { PortalRealtimeEvent } from './portal-realtime.js';
1010
import {
11+
assertStreamModeSupported,
1112
buildPortalLogRequests,
1213
clampFinalizedToPortalHead,
1314
discoverChildAddresses,
@@ -90,6 +91,33 @@ test("isPortalRealtime: only when a chain has a Portal source AND the flag is 's
9091
expect(isPortalRealtime({ portal: 'http://p' })).toBe(false); // unset → A-path
9192
});
9293

94+
// ─────────────────────────────── stream-mode capability gate (finding 5) ───────────────────────────────
95+
96+
test('assertStreamModeSupported: log-only sources are accepted', () => {
97+
expect(() =>
98+
assertStreamModeSupported([{ filter: logFilter() }], 'mainnet'),
99+
).not.toThrow();
100+
});
101+
102+
test('assertStreamModeSupported: a non-log source is refused — it would be silently skipped while marked synced (finding 5)', () => {
103+
const trace = { type: 'trace' } as any;
104+
expect(() =>
105+
assertStreamModeSupported(
106+
[{ filter: logFilter() }, { filter: trace }],
107+
'mainnet',
108+
),
109+
).toThrow(/only log sources, but this chain has trace/);
110+
});
111+
112+
test('assertStreamModeSupported: a log source that needs transaction receipts is refused (finding 5)', () => {
113+
expect(() =>
114+
assertStreamModeSupported(
115+
[{ filter: logFilter({ hasTransactionReceipt: true }) }],
116+
'mainnet',
117+
),
118+
).toThrow(/transaction receipts/);
119+
});
120+
93121
// ─────────────────────────────── light-block conversion ───────────────────────────────
94122

95123
test('lightToLightBlock: Portal decimal number/timestamp → ponder hex, hashes passthrough', () => {

portal/portal-realtime-wire.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import type {
2626
EventCallback,
2727
Factory,
2828
FactoryId,
29+
Filter,
2930
LightBlock,
3031
SyncLog,
3132
} from '@/internal/types.js';
@@ -224,6 +225,37 @@ export function toRealtimeSyncEvent(
224225
}
225226
}
226227

228+
// ─────────────────────────────── stream-mode capability gate ───────────────────────────────
229+
230+
/**
231+
* Stream mode only emits `block` events carrying LOGS — no transactions, receipts, or traces (see
232+
* `toRealtimeSyncEvent`). So a non-log source (trace/transfer/transaction/block filter) would receive NO
233+
* realtime events, yet ponder still finalizes its intervals as cached → a permanent, SILENT gap. Likewise
234+
* a log source that requested transaction receipts (`hasTransactionReceipt`) can't be served. Refuse to
235+
* start rather than corrupt. The historical Portal backfill supports every source type up to the finalized
236+
* head, so this rejects only PORTAL_REALTIME=stream — not the chain. (finding 5)
237+
*/
238+
export function assertStreamModeSupported(
239+
eventCallbacks: { filter: Filter }[],
240+
chainName: string,
241+
): void {
242+
const filters = eventCallbacks.map((e) => e.filter);
243+
const nonLog = [
244+
...new Set(filters.filter((f) => f.type !== 'log').map((f) => f.type)),
245+
].sort();
246+
if (nonLog.length > 0)
247+
throw new Error(
248+
`Portal ${chainName}: PORTAL_REALTIME=stream serves only log sources, but this chain has ${nonLog.join(', ')} source(s). Realtime would silently skip their events while marking the range synced. Use the default RPC realtime (unset PORTAL_REALTIME) or remove the non-log sources.`,
249+
);
250+
251+
const needsReceipts = filters.some((f) => f.hasTransactionReceipt === true);
252+
253+
if (needsReceipts)
254+
throw new Error(
255+
`Portal ${chainName}: PORTAL_REALTIME=stream cannot serve transaction receipts (a log source sets hasTransactionReceipt), so realtime would drop them. Use the default RPC realtime (unset PORTAL_REALTIME).`,
256+
);
257+
}
258+
227259
// ─────────────────────────────── drop-in realtime generator ───────────────────────────────
228260

229261
/**
@@ -243,6 +275,10 @@ export async function* getPortalRealtimeEventGenerator(params: {
243275
}) {
244276
const { common, chain, eventCallbacks, syncProgress, childAddresses } =
245277
params;
278+
// Fail loud BEFORE streaming if this chain has sources stream mode can't serve (non-log / receipt-
279+
// requiring) — else their events are silently skipped while their intervals are marked synced. (finding 5)
280+
assertStreamModeSupported(eventCallbacks, chain.name);
281+
246282
const portalUrl = cleanUrl(chain.portal!);
247283
const headers = portalHeaders();
248284
const factories = uniqueFactories(eventCallbacks);
@@ -256,6 +292,9 @@ export async function* getPortalRealtimeEventGenerator(params: {
256292
// Mutable: rebuilt (in place) whenever a new child is discovered so the next stream reconnection filters
257293
// the new child's logs too (portal-realtime.ts re-reads this array when it re-opens the stream).
258294
const logs = buildPortalLogRequests(eventCallbacks, childAddresses);
295+
// Bumps on every `logs` rebuild; streamHotBlocks watches it and re-opens the /stream the moment it
296+
// advances so the widened server-side filter takes effect on the next block. (finding 4)
297+
let logsRevision = 0;
259298

260299
let childCount = 0;
261300
for (const [, m] of childAddresses) childCount += m.size;
@@ -278,6 +317,7 @@ export async function* getPortalRealtimeEventGenerator(params: {
278317
logs,
279318
blockFields: BLOCK_FIELDS,
280319
logFields: LOG_FIELDS,
320+
getLogsRevision: () => logsRevision,
281321
finalizedHead: () =>
282322
portalFinalizedHead(portalUrl, headers, params.fetchImpl),
283323
finalizePollMs: params.finalizePollMs,
@@ -306,6 +346,7 @@ export async function* getPortalRealtimeEventGenerator(params: {
306346
const next = buildPortalLogRequests(eventCallbacks, childAddresses);
307347
logs.length = 0;
308348
logs.push(...next); // mutate in place — picked up on the next `/stream` reconnection
349+
logsRevision++; // force streamHotBlocks to re-open with the widened filter now (finding 4)
309350
}
310351

311352
yield { chain, event: toRealtimeSyncEvent(ev, discovered) };

portal/portal-realtime.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
type Light,
44
portalRealtimeEvents,
55
reconcile,
6+
streamHotBlocks,
67
takeFinalized,
78
} from './portal-realtime.js';
89

@@ -162,3 +163,92 @@ test('portalRealtimeEvents: a re-streamed fork emits a reorg to the common ances
162163
expect(reorg.block.hash).toBe('a'); // common ancestor = block 10
163164
expect(reorg.reorgedBlocks.map((b: Light) => b.hash)).toEqual(['b']);
164165
});
166+
167+
test('streamHotBlocks: re-opens the /stream with the widened filter the moment the logs revision advances (finding 4)', async () => {
168+
const enc = new TextEncoder();
169+
const streamOf = (block: any, close: boolean) =>
170+
new ReadableStream({
171+
start(c) {
172+
c.enqueue(enc.encode(`${JSON.stringify(block)}\n`));
173+
if (close) c.close(); // else leave open — only the revision change tears it down
174+
},
175+
});
176+
const bodies: any[] = [];
177+
let conn = 0;
178+
const fetchImpl = (async (_url: string, init: any) => {
179+
bodies.push(JSON.parse(init.body));
180+
conn += 1;
181+
if (conn === 1)
182+
// connection 1: deliver block 100, then stay OPEN (no more data) so nothing but a rev change reopens it
183+
return {
184+
status: 200,
185+
ok: true,
186+
body: streamOf(
187+
{
188+
header: {
189+
number: 100,
190+
hash: 'h100',
191+
parentHash: 'h99',
192+
timestamp: 1,
193+
},
194+
logs: [],
195+
},
196+
false,
197+
),
198+
};
199+
if (conn === 2)
200+
// connection 2 (after the reopen): deliver block 101 and close
201+
return {
202+
status: 200,
203+
ok: true,
204+
body: streamOf(
205+
{
206+
header: {
207+
number: 101,
208+
hash: 'h101',
209+
parentHash: 'h100',
210+
timestamp: 2,
211+
},
212+
logs: [],
213+
},
214+
true,
215+
),
216+
};
217+
return { status: 204, ok: false, body: null };
218+
}) as any;
219+
220+
const logs: any[] = [{ address: ['0xfactory'], topic0: ['0xproxycreated'] }];
221+
let rev = 0;
222+
const ac = new AbortController();
223+
const gen = streamHotBlocks({
224+
portalUrl: 'http://portal',
225+
headers: {},
226+
fromBlock: 100,
227+
logs,
228+
getLogsRevision: () => rev,
229+
fetchImpl,
230+
signal: ac.signal,
231+
});
232+
233+
const first = await gen.next(); // block 100 from connection 1
234+
expect(first.value?.header.number).toBe(100);
235+
expect(bodies).toHaveLength(1);
236+
expect(bodies[0].fromBlock).toBe(100);
237+
238+
// a newly-discovered factory child widens the filter and bumps the revision
239+
logs.length = 0;
240+
logs.push({
241+
address: ['0xfactory', '0xnewchild'],
242+
topic0: ['0xproxycreated'],
243+
});
244+
rev = 1;
245+
246+
const second = await gen.next(); // rev advanced → connection 1 torn down, reopen resumes from cursor 101
247+
expect(second.value?.header.number).toBe(101);
248+
expect(bodies).toHaveLength(2);
249+
expect(bodies[1].fromBlock).toBe(101); // resumed PAST block 100 (no re-delivery / spurious reorg)
250+
expect(bodies[1].logs[0].address).toContain('0xnewchild'); // reopened with the widened filter
251+
252+
await gen.return(undefined); // stop the generator
253+
ac.abort();
254+
});

portal/portal-realtime.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,14 @@ export type PortalRealtimeArgs = {
9797
/** the block header fields ponder needs */
9898
blockFields?: Record<string, boolean>;
9999
logFields?: Record<string, boolean>;
100+
/**
101+
* Current logs-filter revision. The Portal filters `/stream` SERVER-side, so a `logs` change after a
102+
* connection opens (a newly-discovered factory child) can't reach THAT connection. streamHotBlocks
103+
* snapshots this at open and re-opens with the widened filter the moment it advances, so the child's
104+
* logs are caught on the next block instead of only after an unrelated reconnect. Absent ⇒ constant 0
105+
* (never reopens). (finding 4)
106+
*/
107+
getLogsRevision?: () => number;
100108
signal?: AbortSignal;
101109
fetchImpl?: typeof fetch;
102110
};
@@ -155,18 +163,27 @@ export async function* streamHotBlocks(
155163
await sleep(1000, args.signal);
156164
continue;
157165
}
166+
// Snapshot the filter revision at open; if it advances while streaming (a child was discovered), break
167+
// to re-open with the widened server-side filter NOW rather than waiting for an unrelated reconnect.
168+
// Breaking early cancels the ndjsonLines reader (its finally), closing this connection. (finding 4)
169+
const openedRev = args.getLogsRevision?.() ?? 0;
170+
let reopen = false;
158171
try {
159172
for await (const line of ndjsonLines(res.body)) {
160173
const batch = JSON.parse(line);
161174
if (batch?.header?.number != null) {
162175
cursor = batch.header.number + 1; // resume past this block on reconnect
163176
yield { header: batch.header, logs: batch.logs ?? [] };
177+
if ((args.getLogsRevision?.() ?? 0) !== openedRev) {
178+
reopen = true;
179+
break;
180+
}
164181
}
165182
}
166183
} catch {
167184
/* stream cut — reconnect from cursor */
168185
}
169-
await sleep(200, args.signal);
186+
if (!reopen) await sleep(200, args.signal); // filter changed ⇒ re-open immediately, no backoff
170187
}
171188
}
172189

0 commit comments

Comments
 (0)