Skip to content

Commit 9347883

Browse files
dzhelezovclaude
andcommitted
feat(realtime): Portal-native realtime core — /stream hot-blocks → ponder RealtimeSyncEvents
The RPC finality-gap fetch stalls for dense chains under multichain load (single-thread global ordering blocks on the slowest chain). This serves [portal-finalized → tip] from the Portal /stream (fork-aware hot-blocks) instead: `includeAllBlocks` gives a consecutive parentHash chain (reorg tracking) + the euler logs in ONE request, reusing portal-transform for the Portal→ponder conversion. portal-realtime.ts: - reconcile() / takeFinalized(): pure reorg + finalize reconciliation (append / duplicate / reorg off any common ancestor / gap; finalized split) — the delicate part, fully unit-tested. - streamHotBlocks(): continuous /stream reader (reconnect-from-cursor, 204 re-poll). - portalRealtimeEvents(): emits block / reorg / finalize events (header+logs via transform; finalize from the /finalized-head poll). 8 tests (37 total), incl. a mock-stream integration test with a re-streamed fork → reorg. NEXT (wiring): swap this into runtime/realtime.ts getRealtimeEventGenerator when a chain has a Portal realtime source (+ skip the RPC finality-gap fallback) behind a PORTAL_REALTIME flag, then Soak B (A/B). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3eca01a commit 9347883

3 files changed

Lines changed: 310 additions & 2 deletions

File tree

portal/portal-realtime.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { expect, test } from "vitest";
2+
import { reconcile, takeFinalized, portalRealtimeEvents, type Light } from "./portal-realtime.js";
3+
4+
const L = (number: number, hash: string, parentHash: string): Light => ({ number, hash, parentHash, timestamp: number });
5+
6+
test("reconcile: append extends the tip (and the empty chain)", () => {
7+
expect(reconcile([], L(10, "a", "z"))).toEqual({ kind: "append" });
8+
expect(reconcile([L(10, "a", "z"), L(11, "b", "a")], L(12, "c", "b"))).toEqual({ kind: "append" });
9+
});
10+
11+
test("reconcile: duplicate tip is idempotent (re-delivery)", () => {
12+
expect(reconcile([L(10, "a", "z"), L(11, "b", "a")], L(11, "b", "a"))).toEqual({ kind: "duplicate" });
13+
});
14+
15+
test("reconcile: reorg forks off an earlier common ancestor, reorged blocks after it", () => {
16+
const chain = [L(10, "a", "z"), L(11, "b", "a"), L(12, "c", "b")];
17+
const r = reconcile(chain, L(11, "b2", "a")); // 11' whose parent is block 10 (a)
18+
expect(r.kind).toBe("reorg");
19+
if (r.kind === "reorg") {
20+
expect(r.commonAncestor.hash).toBe("a");
21+
expect(r.reorgedBlocks.map((b) => b.hash)).toEqual(["b", "c"]);
22+
}
23+
});
24+
25+
test("reconcile: deep-fork reorg to the base", () => {
26+
const chain = [L(10, "a", "z"), L(11, "b", "a"), L(12, "c", "b")];
27+
const r = reconcile(chain, L(13, "d2", "a")); // parent jumps back to block 10
28+
expect(r.kind).toBe("reorg");
29+
if (r.kind === "reorg") expect(r.reorgedBlocks.map((b) => b.hash)).toEqual(["b", "c"]);
30+
});
31+
32+
test("reconcile: gap when the parent is unknown (beyond our window)", () => {
33+
expect(reconcile([L(10, "a", "z"), L(11, "b", "a")], L(20, "x", "unknown"))).toEqual({ kind: "gap" });
34+
});
35+
36+
test("takeFinalized: splits the chain at the finalized number", () => {
37+
const chain = [L(10, "a", "z"), L(11, "b", "a"), L(12, "c", "b")];
38+
const { finalizedTip, remaining } = takeFinalized(chain, 11);
39+
expect(finalizedTip?.hash).toBe("b");
40+
expect(remaining.map((b) => b.number)).toEqual([12]);
41+
expect(takeFinalized(chain, 9).finalizedTip).toBeUndefined();
42+
});
43+
44+
// mock /stream: a Response whose body streams the NDJSON batches once, then 204 (→ caller aborts)
45+
function mockFetch(batches: any[], onExhausted?: () => void) {
46+
let served = false;
47+
return (async () => {
48+
if (served) { onExhausted?.(); return { status: 204, ok: false, body: null }; }
49+
served = true;
50+
const lines = batches.map((b) => JSON.stringify(b) + "\n").join("");
51+
const body = new ReadableStream({ start(c) { c.enqueue(new TextEncoder().encode(lines)); c.close(); } });
52+
return { status: 200, ok: true, body };
53+
}) as any;
54+
}
55+
56+
test("portalRealtimeEvents: streams block events (header + logs) and emits finalize from the head poll", async () => {
57+
const batches = [
58+
{ header: { number: 100, hash: "h100", parentHash: "h99", timestamp: 1000 }, logs: [] },
59+
{ header: { number: 101, hash: "h101", parentHash: "h100", timestamp: 1012 },
60+
logs: [{ address: "0xVaULT", topics: ["0xabc"], data: "0x", logIndex: 0, transactionHash: "0xtx", transactionIndex: 0 }] },
61+
];
62+
const ac = new AbortController();
63+
const events: any[] = [];
64+
for await (const e of portalRealtimeEvents({
65+
portalUrl: "http://portal", headers: {}, fromBlock: 100, logs: [],
66+
fetchImpl: mockFetch(batches, () => ac.abort()), signal: ac.signal,
67+
finalizedHead: async () => 100, finalizePollMs: 0,
68+
})) {
69+
events.push(e);
70+
}
71+
const blocks = events.filter((e) => e.type === "block");
72+
expect(blocks.length).toBe(2);
73+
expect(blocks[0].hasMatchedFilter).toBe(false); // block 100 has no logs
74+
expect(blocks[1].hasMatchedFilter).toBe(true); // block 101 has a euler log
75+
expect(blocks[1].logs[0].address).toBe("0xvault"); // transform lowercases
76+
expect(blocks[1].block.number).toBe("0x65"); // 101 → hex
77+
expect(events.some((e) => e.type === "finalize" && e.block.number === 100)).toBe(true);
78+
});
79+
80+
test("portalRealtimeEvents: a re-streamed fork emits a reorg to the common ancestor", async () => {
81+
const batches = [
82+
{ header: { number: 10, hash: "a", parentHash: "z", timestamp: 10 }, logs: [] },
83+
{ header: { number: 11, hash: "b", parentHash: "a", timestamp: 11 }, logs: [] },
84+
{ header: { number: 11, hash: "b2", parentHash: "a", timestamp: 11 }, logs: [] }, // fork off block 10
85+
];
86+
const ac = new AbortController();
87+
const events: any[] = [];
88+
for await (const e of portalRealtimeEvents({
89+
portalUrl: "http://portal", headers: {}, fromBlock: 10, logs: [],
90+
fetchImpl: mockFetch(batches, () => ac.abort()), signal: ac.signal,
91+
finalizedHead: async () => 0, finalizePollMs: 999999,
92+
})) {
93+
events.push(e);
94+
}
95+
const reorg = events.find((e) => e.type === "reorg");
96+
expect(reorg).toBeDefined();
97+
expect(reorg.block.hash).toBe("a"); // common ancestor = block 10
98+
expect(reorg.reorgedBlocks.map((b: Light) => b.hash)).toEqual(["b"]);
99+
});

portal/portal-realtime.ts

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
/**
2+
* Portal-native realtime — serve `[portal-finalized-head → tip]` from the Portal `/stream` (fork-aware
3+
* hot-blocks) instead of RPC.
4+
*
5+
* WHY: in realtime mode the fork currently backfills `[deploy → portal-finalized-head]` from the Portal,
6+
* then fetches `[portal-finalized-head → chain-finalized-head]` over RPC (the "finality gap") and runs
7+
* ponder's RPC RealtimeSync for the tip. For dense chains under multichain load that RPC gap-fetch stalls
8+
* — ponder's single-thread global ordering blocks on the slowest chain's gap. The Portal already holds
9+
* these hot-blocks; streaming them removes the RPC gap at its source.
10+
*
11+
* HOW: `/stream` with `includeAllBlocks:true` yields EVERY block header (a consecutive parentHash chain,
12+
* for reorg tracking) plus the filtered euler logs, in one request. We reconcile each block into a local
13+
* unfinalized chain and emit ponder's own `RealtimeSyncEvent`s (`block` / `reorg` / `finalize`) reusing
14+
* `portal-transform` — so the downstream indexing + checkpointing in runtime/realtime.ts is UNCHANGED.
15+
*
16+
* The reorg/finalize reconciliation is pure + unit-tested; the `/stream` read and `/finalized-head` poll
17+
* are the I/O shell.
18+
*/
19+
import type { SyncBlockHeader, SyncLog } from "@/internal/types.js";
20+
import { hexToNumber, type Hex } from "viem";
21+
import { hx, toSyncBlockHeader, toSyncLog, type RawHeader } from "./portal-transform.js";
22+
23+
// ─────────────────────────────── pure reorg / finalize core (unit-tested) ───────────────────────────────
24+
25+
/** The minimum a block needs for chain reconciliation. `number` is a decimal string (Portal-native). */
26+
export type Light = { number: number; hash: string; parentHash: string; timestamp: number };
27+
28+
export type Reconcile =
29+
| { kind: "append" } // extends the tip (normal case)
30+
| { kind: "duplicate" } // already the tip (idempotent re-delivery)
31+
| { kind: "reorg"; commonAncestor: Light; reorgedBlocks: Light[] } // forks off an earlier block
32+
| { kind: "gap" }; // parent is unknown (beyond our window / a skipped block) → caller must re-sync
33+
34+
/**
35+
* Reconcile a newly-streamed block against the local unfinalized chain (oldest→newest, linked
36+
* parentHash→hash). Pure. With `includeAllBlocks` the happy path is always `append`; a reorg re-streams
37+
* from the fork point, so the new block's `parentHash` matches an EARLIER block — everything after that
38+
* common ancestor is reorged.
39+
*/
40+
export function reconcile(unfinalized: Light[], next: Light): Reconcile {
41+
if (unfinalized.length === 0) return { kind: "append" };
42+
const tip = unfinalized[unfinalized.length - 1]!;
43+
if (next.hash === tip.hash) return { kind: "duplicate" };
44+
if (next.parentHash === tip.hash) return { kind: "append" };
45+
const idx = unfinalized.findIndex((b) => b.hash === next.parentHash);
46+
if (idx === -1) return { kind: "gap" };
47+
return { kind: "reorg", commonAncestor: unfinalized[idx]!, reorgedBlocks: unfinalized.slice(idx + 1) };
48+
}
49+
50+
/** Split the unfinalized chain at a newly-finalized block number. Pure. */
51+
export function takeFinalized(
52+
unfinalized: Light[],
53+
finalizedNumber: number,
54+
): { finalizedTip: Light | undefined; remaining: Light[] } {
55+
let finalizedTip: Light | undefined;
56+
const remaining: Light[] = [];
57+
for (const b of unfinalized) {
58+
if (b.number <= finalizedNumber) finalizedTip = b;
59+
else remaining.push(b);
60+
}
61+
return { finalizedTip, remaining };
62+
}
63+
64+
export const toLight = (h: RawHeader): Light => ({
65+
number: h.number,
66+
hash: h.hash,
67+
parentHash: h.parentHash,
68+
timestamp: h.timestamp,
69+
});
70+
71+
// ─────────────────────────────── /stream I/O shell ───────────────────────────────
72+
73+
export type PortalRealtimeArgs = {
74+
portalUrl: string;
75+
headers: Record<string, string>;
76+
/** first block to stream (exclusive of already-indexed finalized head): syncProgress.finalized.number + 1 */
77+
fromBlock: number;
78+
/** euler log filters (address/topics), already merged — passed straight into the Portal query */
79+
logs: Array<Record<string, unknown>>;
80+
/** the block header fields ponder needs */
81+
blockFields?: Record<string, boolean>;
82+
logFields?: Record<string, boolean>;
83+
signal?: AbortSignal;
84+
fetchImpl?: typeof fetch;
85+
};
86+
87+
/**
88+
* Stream fork-aware hot-blocks with `includeAllBlocks` (every header + filtered logs). Yields raw Portal
89+
* batch objects `{ header, logs }`. Re-opens the stream from the last seen block on disconnect so it runs
90+
* continuously; a reorg simply re-streams from the fork point (reconcile() detects it).
91+
*/
92+
export async function* streamHotBlocks(
93+
args: PortalRealtimeArgs,
94+
): AsyncGenerator<{ header: RawHeader; logs: any[] }> {
95+
const fetchImpl = args.fetchImpl ?? fetch;
96+
let cursor = args.fromBlock;
97+
for (;;) {
98+
if (args.signal?.aborted) return;
99+
const body = JSON.stringify({
100+
type: "evm",
101+
fromBlock: cursor,
102+
includeAllBlocks: true,
103+
fields: {
104+
block: args.blockFields ?? { number: true, hash: true, parentHash: true, timestamp: true },
105+
log: args.logFields ?? { address: true, topics: true, data: true, logIndex: true, transactionHash: true, transactionIndex: true },
106+
},
107+
logs: args.logs,
108+
});
109+
let res: Response;
110+
try {
111+
res = await fetchImpl(`${args.portalUrl}/stream`, { method: "POST", headers: { "content-type": "application/json", ...args.headers }, body, signal: args.signal });
112+
} catch {
113+
await sleep(1000, args.signal);
114+
continue;
115+
}
116+
if (res.status === 204 || !res.body) { await sleep(500, args.signal); continue; } // no hot data yet; re-poll
117+
if (!res.ok) { await sleep(1000, args.signal); continue; }
118+
const reader = res.body.getReader();
119+
const dec = new TextDecoder();
120+
let buf = "";
121+
try {
122+
for (;;) {
123+
const { done, value } = await reader.read();
124+
if (done) break;
125+
buf += dec.decode(value, { stream: true });
126+
let nl: number;
127+
while ((nl = buf.indexOf("\n")) >= 0) {
128+
const line = buf.slice(0, nl);
129+
buf = buf.slice(nl + 1);
130+
if (!line) continue;
131+
const batch = JSON.parse(line);
132+
if (batch?.header?.number != null) {
133+
cursor = batch.header.number + 1; // resume past this block on reconnect
134+
yield { header: batch.header, logs: batch.logs ?? [] };
135+
}
136+
}
137+
}
138+
} catch {
139+
/* stream cut — reconnect from cursor */
140+
}
141+
await sleep(200, args.signal);
142+
}
143+
}
144+
145+
const sleep = (ms: number, signal?: AbortSignal) =>
146+
new Promise<void>((resolve) => {
147+
if (signal?.aborted) return resolve();
148+
const t = setTimeout(resolve, ms);
149+
signal?.addEventListener("abort", () => { clearTimeout(t); resolve(); }, { once: true });
150+
});
151+
152+
// ─────────────────────────────── event producer ───────────────────────────────
153+
// Emits ponder RealtimeSyncEvent-shaped objects. `finalizedHead()` polls the Portal finalized head so we
154+
// can emit `finalize` events (the RPC RealtimeSync derives finality from confirmations; the Portal tells
155+
// us directly). NB: the exact ponder BlockWithEventData construction (transactions/receipts/traces/
156+
// childAddresses) is completed at the wiring step in runtime/realtime.ts, which already has the factory
157+
// child-address maps + buildEvents; here we surface the block header + logs + reorg/finalize control flow.
158+
159+
export type PortalRealtimeEvent =
160+
| { type: "block"; block: SyncBlockHeader; logs: SyncLog[]; hasMatchedFilter: boolean }
161+
| { type: "reorg"; block: Light; reorgedBlocks: Light[] }
162+
| { type: "finalize"; block: Light };
163+
164+
export async function* portalRealtimeEvents(
165+
args: PortalRealtimeArgs & { finalizedHead: () => Promise<number | undefined>; finalizePollMs?: number },
166+
): AsyncGenerator<PortalRealtimeEvent> {
167+
const unfinalized: Light[] = [];
168+
let lastFinalizePoll = 0;
169+
const pollMs = args.finalizePollMs ?? 4000;
170+
171+
for await (const { header, logs } of streamHotBlocks(args)) {
172+
const light = toLight(header);
173+
const r = reconcile(unfinalized, light);
174+
if (r.kind === "duplicate") continue;
175+
if (r.kind === "gap") {
176+
// parent unknown: our window is behind a deeper reorg. Drop to a resync by clearing and continuing
177+
// from this block (the finalized floor still protects already-committed data).
178+
unfinalized.length = 0;
179+
} else if (r.kind === "reorg") {
180+
// trim the local chain and surface the rollback to the common ancestor
181+
while (unfinalized.length && unfinalized[unfinalized.length - 1]!.number > r.commonAncestor.number) unfinalized.pop();
182+
yield { type: "reorg", block: r.commonAncestor, reorgedBlocks: r.reorgedBlocks };
183+
}
184+
unfinalized.push(light);
185+
186+
const block = toSyncBlockHeader(header);
187+
const syncLogs = logs.map((l) => toSyncLog(l, header));
188+
yield { type: "block", block, logs: syncLogs, hasMatchedFilter: syncLogs.length > 0 };
189+
190+
// finalize on a cadence (cheap head probe), not every block
191+
const now = Date.now();
192+
if (now - lastFinalizePoll >= pollMs) {
193+
lastFinalizePoll = now;
194+
const fh = await args.finalizedHead().catch(() => undefined);
195+
if (fh !== undefined) {
196+
const { finalizedTip, remaining } = takeFinalized(unfinalized, fh);
197+
if (finalizedTip) {
198+
unfinalized.length = 0;
199+
unfinalized.push(...remaining);
200+
yield { type: "finalize", block: finalizedTip };
201+
}
202+
}
203+
}
204+
}
205+
}
206+
207+
export { hx as _hx };
208+
export type { SyncBlockHeader, SyncLog, Hex };
209+
export { hexToNumber };

scripts/sync-upstream.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ rm -rf "$WORK"; git clone --quiet --depth 1 --branch "ponder@$VER" https://githu
2323
CORE="$WORK/packages/core"
2424
SYNC="$CORE/src/sync-historical"
2525
echo "▶ applying Portal layer"
26-
cp "$ROOT/portal/portal.ts" "$ROOT/portal/portal-transform.ts" "$ROOT/portal/realtime.ts" "$SYNC/"
27-
cp "$ROOT/portal/portal-transform.test.ts" "$ROOT/portal/portal.test.ts" "$ROOT/portal/realtime.test.ts" "$SYNC/" 2>/dev/null || true
26+
cp "$ROOT/portal/portal.ts" "$ROOT/portal/portal-transform.ts" "$ROOT/portal/realtime.ts" "$ROOT/portal/portal-realtime.ts" "$SYNC/"
27+
cp "$ROOT/portal/portal-transform.test.ts" "$ROOT/portal/portal.test.ts" "$ROOT/portal/realtime.test.ts" "$ROOT/portal/portal-realtime.test.ts" "$SYNC/" 2>/dev/null || true
2828
mkdir -p "$SYNC/__fixtures__"; cp "$ROOT/portal/__fixtures__/"*.json "$SYNC/__fixtures__/"
2929
cp "$ROOT/portal/vite.portal.config.ts" "$CORE/"
3030
( cd "$WORK" && git apply --verbose "$WIRING" )

0 commit comments

Comments
 (0)