Skip to content

Commit e833963

Browse files
authored
Avoid refetching empty live RPC ranges (#220)
## Summary Fix repeated live RPC range scans after emitting empty accepted blocks. When live filtering reaches the current head with no matching data, the stream emits an empty accepted block but intentionally does not advance `state.cursor`. Subsequent live scans were still starting from `cursor + 1`, so sparse filters could repeatedly refetch previously scanned empty blocks as the head advanced. This changes live range fetching to resume from `max(cursor, lastEmptyBlockNumber) + 1`, treats `lastEmptyBlockNumber === head` as being at head, and clears the empty-block marker on reorg invalidation. ## Testing - Added a protocol test covering empty accepted live blocks as the head advances. - Not run locally: dependencies are not installed in this checkout, so `vitest` is unavailable.
2 parents aa40eac + 3d285f9 commit e833963

3 files changed

Lines changed: 225 additions & 7 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "Avoid refetching empty live RPC ranges",
4+
"packageName": "@apibara/protocol",
5+
"email": "francesco@ceccon.me",
6+
"dependentChangeType": "patch"
7+
}

packages/protocol/src/rpc/data-stream.ts

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -285,12 +285,9 @@ async function* produceLiveBlocks<TFilter, TBlock>(
285285
if (result.status === "reorg") {
286286
const { cursor } = result;
287287
// Only handle reorgs if they involve blocks already processed.
288-
if (
289-
cursor.orderKey < state.cursor.orderKey ||
290-
(state.lastEmptyBlockNumber !== undefined &&
291-
cursor.orderKey < state.lastEmptyBlockNumber)
292-
) {
288+
if (shouldInvalidateProcessedLiveData(state, cursor)) {
293289
state.cursor = cursor;
290+
state.lastEmptyBlockNumber = undefined;
294291

295292
yield {
296293
_tag: "invalidate",
@@ -412,8 +409,9 @@ async function* waitForHeadChange<TBlock>(
412409
case "reorg": {
413410
const { cursor } = result;
414411
// Only handle reorgs if they involve blocks already processed.
415-
if (cursor.orderKey < state.cursor.orderKey) {
412+
if (shouldInvalidateProcessedLiveData(state, cursor)) {
416413
state.cursor = cursor;
414+
state.lastEmptyBlockNumber = undefined;
417415

418416
yield {
419417
_tag: "invalidate",
@@ -464,7 +462,32 @@ function shouldRefreshHead(state: State<unknown, unknown>): boolean {
464462

465463
function isAtHead(state: State<unknown, unknown>): boolean {
466464
const head = state.chainTracker.head();
467-
return state.cursor.orderKey === head.orderKey;
465+
return (
466+
state.cursor.orderKey === head.orderKey ||
467+
state.lastEmptyBlockNumber === head.orderKey
468+
);
469+
}
470+
471+
function shouldInvalidateProcessedLiveData(
472+
state: State<unknown, unknown>,
473+
cursor: Cursor,
474+
): boolean {
475+
return (
476+
cursor.orderKey < state.cursor.orderKey ||
477+
(state.lastEmptyBlockNumber !== undefined &&
478+
cursor.orderKey < state.lastEmptyBlockNumber)
479+
);
480+
}
481+
482+
function lastProcessedLiveBlock(state: State<unknown, unknown>): bigint {
483+
if (
484+
state.lastEmptyBlockNumber !== undefined &&
485+
state.lastEmptyBlockNumber > state.cursor.orderKey
486+
) {
487+
return state.lastEmptyBlockNumber;
488+
}
489+
490+
return state.cursor.orderKey;
468491
}
469492

470493
function sleep(duration: number): Promise<void> {
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { Bytes, Cursor } from "../src/common";
3+
import { RpcClient } from "../src/rpc/client";
4+
import {
5+
type BlockInfo,
6+
type FetchBlockByHashArgs,
7+
type FetchBlockByHashResult,
8+
type FetchBlockRangeArgs,
9+
type FetchBlockRangeResult,
10+
type FetchCursorArgs,
11+
type FetchCursorRangeArgs,
12+
RpcStreamConfig,
13+
} from "../src/rpc/config";
14+
15+
type TestBlock = {
16+
blockNumber: bigint;
17+
};
18+
19+
class EmptyLiveHeadConfig extends RpcStreamConfig<string, TestBlock> {
20+
fetchBlockRangeCalls: FetchBlockRangeArgs<string>[] = [];
21+
headBlock = 2n;
22+
23+
headRefreshIntervalMs(): number {
24+
return 10;
25+
}
26+
27+
finalizedRefreshIntervalMs(): number {
28+
return 10_000;
29+
}
30+
31+
validateFilter() {
32+
return { valid: true as const };
33+
}
34+
35+
async fetchCursor(args: FetchCursorArgs): Promise<BlockInfo | null> {
36+
if (args.blockTag === "latest") {
37+
return blockInfo(this.headBlock);
38+
}
39+
40+
if (args.blockTag === "finalized") {
41+
return blockInfo(1n);
42+
}
43+
44+
if (args.blockNumber !== undefined) {
45+
return blockInfo(args.blockNumber);
46+
}
47+
48+
return blockInfo(blockNumberFromHash(args.blockHash ?? "0x0"));
49+
}
50+
51+
async fetchCursorRange({
52+
startBlockNumber,
53+
endBlockNumber,
54+
}: FetchCursorRangeArgs): Promise<BlockInfo[]> {
55+
const blocks: BlockInfo[] = [];
56+
for (
57+
let blockNumber = startBlockNumber;
58+
blockNumber <= endBlockNumber;
59+
blockNumber++
60+
) {
61+
blocks.push(blockInfo(blockNumber));
62+
}
63+
return blocks;
64+
}
65+
66+
async fetchBlockRange(
67+
args: FetchBlockRangeArgs<string>,
68+
): Promise<FetchBlockRangeResult<TestBlock>> {
69+
this.fetchBlockRangeCalls.push(args);
70+
await sleep(1);
71+
72+
return {
73+
startBlock: args.startBlock,
74+
endBlock: args.maxBlock,
75+
data: [],
76+
};
77+
}
78+
79+
async fetchHeaderByHash({
80+
blockHash,
81+
}: FetchBlockByHashArgs<string>): Promise<FetchBlockByHashResult<TestBlock>> {
82+
const blockNumber = blockNumberFromHash(blockHash);
83+
const info = blockInfo(blockNumber);
84+
85+
return {
86+
blockInfo: info,
87+
data: {
88+
cursor: cursorForBlock(blockNumber - 1n),
89+
endCursor: cursorForBlock(blockNumber)!,
90+
block: { blockNumber },
91+
},
92+
};
93+
}
94+
}
95+
96+
describe("RpcDataStream", () => {
97+
it("does not refetch empty accepted blocks as the live head advances", async () => {
98+
const config = new EmptyLiveHeadConfig();
99+
const client = new RpcClient(config);
100+
const stream = client.streamData({
101+
finality: "accepted",
102+
filter: ["logs"],
103+
startingCursor: { orderKey: 1n },
104+
});
105+
const iterator = stream[Symbol.asyncIterator]();
106+
107+
try {
108+
const first = await iterator.next();
109+
110+
expect(first.done).toBe(false);
111+
expect(first.value).toMatchObject({
112+
_tag: "data",
113+
data: {
114+
endCursor: { orderKey: 2n },
115+
finality: "accepted",
116+
production: "live",
117+
},
118+
});
119+
expect(config.fetchBlockRangeCalls).toHaveLength(1);
120+
expect(config.fetchBlockRangeCalls[0]).toMatchObject({
121+
startBlock: 2n,
122+
maxBlock: 2n,
123+
});
124+
125+
config.headBlock = 3n;
126+
const second = await withTimeout(iterator.next(), 1_000);
127+
128+
expect(second.done).toBe(false);
129+
expect(second.value).toMatchObject({
130+
_tag: "data",
131+
data: {
132+
endCursor: { orderKey: 3n },
133+
finality: "accepted",
134+
production: "live",
135+
},
136+
});
137+
expect(config.fetchBlockRangeCalls).toHaveLength(2);
138+
expect(config.fetchBlockRangeCalls[1]).toMatchObject({
139+
startBlock: 2n,
140+
maxBlock: 3n,
141+
});
142+
await iterator.return?.();
143+
} finally {
144+
await iterator.return?.();
145+
}
146+
});
147+
});
148+
149+
function blockInfo(blockNumber: bigint): BlockInfo {
150+
return {
151+
blockNumber,
152+
blockHash: blockHash(blockNumber),
153+
parentBlockHash: blockHash(blockNumber - 1n),
154+
};
155+
}
156+
157+
function cursorForBlock(blockNumber: bigint): Cursor | undefined {
158+
if (blockNumber < 0n) {
159+
return undefined;
160+
}
161+
162+
return {
163+
orderKey: blockNumber,
164+
uniqueKey: blockHash(blockNumber),
165+
};
166+
}
167+
168+
function blockHash(blockNumber: bigint): Bytes {
169+
const value = blockNumber < 0n ? 0n : blockNumber;
170+
return `0x${value.toString(16).padStart(64, "0")}`;
171+
}
172+
173+
function blockNumberFromHash(hash: Bytes): bigint {
174+
return BigInt(hash);
175+
}
176+
177+
function sleep(ms: number): Promise<void> {
178+
return new Promise((resolve) => setTimeout(resolve, ms));
179+
}
180+
181+
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
182+
return Promise.race([
183+
promise,
184+
sleep(ms).then(() => {
185+
throw new Error(`Timed out after ${ms}ms`);
186+
}),
187+
]);
188+
}

0 commit comments

Comments
 (0)