Skip to content

Commit 91837e3

Browse files
authored
refactor(store-sync): migrate to isomorphic-ws (#3762)
1 parent 41e1038 commit 91837e3

6 files changed

Lines changed: 343 additions & 164 deletions

File tree

.changeset/gentle-yaks-dress.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@latticexyz/store-sync": patch
3+
---
4+
5+
The preconfirmed logs stream is now using `isomorphic-ws` for more control over error handling and reconnection logic.

packages/store-sync/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
"debug": "^4.3.4",
9393
"drizzle-orm": "^0.28.5",
9494
"fast-deep-equal": "^3.1.3",
95+
"isomorphic-ws": "^5.0.0",
9596
"kysely": "^0.26.3",
9697
"postgres": "^3.3.5",
9798
"rxjs": "7.5.5",

packages/store-sync/playground/index.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { redstone as redstoneBase } from "@latticexyz/common/chains";
1+
import { redstone as redstoneBase, garnet as garnetBase } from "@latticexyz/common/chains";
22
import { createStoreSync } from "../src/createStoreSync";
3+
// import { watchLogs } from "../src/watchLogs";
34
import { createPublicClient, http } from "viem";
45

56
const redstone = {
@@ -10,16 +11,29 @@ const redstone = {
1011
},
1112
};
1213

13-
const client = createPublicClient({ chain: redstone, transport: http() });
14+
const garnet = {
15+
...garnetBase,
16+
rpcUrls: {
17+
...garnetBase.rpcUrls,
18+
wiresaw: { webSocket: ["wss://wiresaw.garnetchain.com"], http: ["https://wiresaw.garnetchain.com"] },
19+
},
20+
};
21+
22+
const chains = { redstone, garnet };
23+
24+
const chain = chains.redstone;
25+
const address = "0x253eb85B3C953bFE3827CC14a151262482E7189C"; // REDSTONE
26+
// const address: "0x300f19AD7a0D7ec3D7fC09ad0D34425C24ffF08F", // GARNET blockNumber: 19302351
27+
28+
const client = createPublicClient({ chain, transport: http() });
1429
const latestBlock = await client.getBlockNumber();
1530
console.log("latestBlock", latestBlock);
16-
1731
const result = await createStoreSync({
1832
internal_clientOptions: {
19-
chain: redstone,
33+
chain,
2034
validateBlockRange: true,
2135
},
22-
address: "0x253eb85B3C953bFE3827CC14a151262482E7189C",
36+
address,
2337
initialBlockLogs: {
2438
blockNumber: latestBlock - 1000n,
2539
logs: [],
@@ -33,3 +47,13 @@ const result = await createStoreSync({
3347
// Start sync
3448
result.storedBlockLogs$.subscribe();
3549
result.latestBlockNumber$.subscribe((blockNumber) => console.log("latestBlockNumber", blockNumber));
50+
51+
// const { logs$ } = watchLogs({
52+
// url: "wss://wiresaw.redstonechain.com",
53+
// fromBlock: latestBlock - 100n,
54+
// address: "0x253eb85B3C953bFE3827CC14a151262482E7189C",
55+
// });
56+
57+
// logs$.subscribe((update) => {
58+
// console.log("got update", { blockNumber: update.blockNumber, logs: update.logs.length });
59+
// });

packages/store-sync/src/createPreconfirmedBlockStream.ts

Lines changed: 97 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
merge,
1515
filter,
1616
startWith,
17-
delay,
17+
timer,
1818
} from "rxjs";
1919
import { StorageAdapterBlock, StoreEventsLog, SyncFilter } from "./common";
2020
import { watchLogs } from "./watchLogs";
@@ -24,9 +24,11 @@ import { isLogsApiResponse } from "./indexer-client/isLogsApiResponse";
2424
import { toStorageAdapterBlock } from "./indexer-client/toStorageAdapterBlock";
2525
import { fetchAndStoreLogs } from "./fetchAndStoreLogs";
2626
import { storeEventsAbi } from "@latticexyz/store";
27-
import { bigIntMax, isDefined } from "@latticexyz/common/utils";
27+
import { bigIntMax, groupBy, isDefined } from "@latticexyz/common/utils";
2828
import { getRpcClient, GetRpcClientOptions } from "@latticexyz/block-logs-stream";
29-
import { debug } from "./debug";
29+
import { debug as parentDebug } from "./debug";
30+
31+
const debug = parentDebug.extend("createPreconfirmedBlockStream");
3032

3133
type PreconfirmedBlockStreamOptions = GetRpcClientOptions & {
3234
fromBlock: bigint;
@@ -43,19 +45,23 @@ export function createPreconfirmedBlockStream(opts: PreconfirmedBlockStreamOptio
4345
const recreatePreconfirmedStream$ = new Subject<void>();
4446
const recreateLatestStream$ = new Subject<void>();
4547

46-
let restartBlockNumber = opts.fromBlock;
48+
let processedLatestBlockNumber = opts.fromBlock - 1n;
4749
let initialCatchUpBlockNumber: bigint | undefined = undefined;
48-
getRpcClient(opts)
49-
.request({ method: "eth_blockNumber" })
50-
.then((blockNumber) => {
51-
console.log("initial catch up block number", BigInt(blockNumber));
52-
initialCatchUpBlockNumber = BigInt(blockNumber);
53-
});
5450

5551
const latestBlock$ = recreateLatestStream$.pipe(
5652
startWith(undefined),
53+
tap(() => {
54+
debug("initializing latest block stream");
55+
initialCatchUpBlockNumber = undefined;
56+
getRpcClient(opts)
57+
.request({ method: "eth_blockNumber" })
58+
.then((blockNumber) => {
59+
debug("initial catch up block number", BigInt(blockNumber));
60+
initialCatchUpBlockNumber = BigInt(blockNumber);
61+
});
62+
}),
5763
switchMap(() =>
58-
createLatestBlockStream({ ...opts, fromBlock: restartBlockNumber }).pipe(
64+
createLatestBlockStream({ ...opts, fromBlock: processedLatestBlockNumber + 1n }).pipe(
5965
catchError((e) => {
6066
debug("Error in latest block stream, recreating", e);
6167
recreateLatestStream$.next();
@@ -65,22 +71,25 @@ export function createPreconfirmedBlockStream(opts: PreconfirmedBlockStreamOptio
6571
),
6672
);
6773

68-
let processedBlockLogs: { [blockNumber: string]: { [logIndex: number]: boolean } } = {};
74+
let preconfirmedTransactionLogs: { [txHash: string]: Partial<StoreEventsLog>[] } = {};
6975
let preconfirmedLogsState: "initializing" | "initialized" | "waiting" = "waiting";
7076
let attempt = 0;
71-
const preconfirmedLogs$ = recreatePreconfirmedStream$.pipe(
77+
const preconfirmedBlockLogs$ = recreatePreconfirmedStream$.pipe(
7278
tap(() => {
73-
debug(`initializing preconfirmed logs stream in ${attempt * 500}ms`);
79+
if (attempt !== 0) debug(`waiting ${attempt * 500}ms before initializing preconfirmed logs stream`);
7480
preconfirmedLogsState = "initializing";
75-
processedBlockLogs = {};
81+
preconfirmedTransactionLogs = {};
82+
}),
83+
switchMap(() => timer(attempt * 500)),
84+
tap(() => {
85+
debug(`initializing preconfirmed logs stream`);
86+
attempt++;
7687
}),
77-
delay(attempt * 500),
78-
tap(() => attempt++),
7988
switchMap(() =>
8089
watchLogs({
8190
...opts,
8291
url: opts.preconfirmedLogsUrl,
83-
fromBlock: restartBlockNumber,
92+
fromBlock: processedLatestBlockNumber + 1n,
8493
}).logs$.pipe(
8594
catchError((e) => {
8695
debug("Error in preconfirmed logs stream, recreating", e);
@@ -90,36 +99,82 @@ export function createPreconfirmedBlockStream(opts: PreconfirmedBlockStreamOptio
9099
),
91100
),
92101
filter((block): block is StorageAdapterBlock => block != null),
102+
filter((block) => {
103+
if (initialCatchUpBlockNumber == null || block.blockNumber <= initialCatchUpBlockNumber) {
104+
debug(
105+
"skipping preconfirmed block",
106+
block.blockNumber,
107+
"before initial catch up block",
108+
initialCatchUpBlockNumber,
109+
);
110+
return false;
111+
}
112+
const isProcessedBlock = block.blockNumber <= processedLatestBlockNumber;
113+
if (isProcessedBlock) debug("skipping already processed block in preconfirmed stream", block.blockNumber);
114+
return !isProcessedBlock;
115+
}),
93116
tap((block) => {
94117
debug("preconfirmed block", block.blockNumber, "with", block.logs.length, "logs");
95118
preconfirmedLogsState = "initialized";
96119
attempt = 0;
97-
restartBlockNumber = block.blockNumber;
98-
const seenLogs = (processedBlockLogs[String(block.blockNumber)] ??= {});
99120
block.logs.forEach((log) => {
100-
seenLogs[log.logIndex!] = true;
121+
const txHash = log.transactionHash;
122+
if (txHash == null) {
123+
debug("unexpected null transaction hash", log);
124+
return;
125+
}
126+
preconfirmedTransactionLogs[txHash] ??= [];
127+
preconfirmedTransactionLogs[txHash].push(log);
101128
});
102-
debug("got preconfirmed block", block.blockNumber, "with", block.logs.length, "logs");
103129
}),
104130
);
105131

106-
const missingLogs$ = latestBlock$.pipe(
132+
const latestBlockLogs$ = latestBlock$.pipe(
107133
map((block) => {
108-
const missingBlock = processedBlockLogs[String(block.blockNumber)] == null;
109-
const seenLogs = processedBlockLogs[String(block.blockNumber)] ?? {};
110-
const missingLogs = block.logs.filter((log) => !seenLogs[log.logIndex!]);
111-
delete processedBlockLogs[String(block.blockNumber)];
112-
restartBlockNumber = block.blockNumber + 1n;
134+
processedLatestBlockNumber = block.blockNumber;
135+
136+
const mismatchingTransactions: string[] = [];
137+
if (preconfirmedLogsState === "initialized") {
138+
const logsByTransaction = groupBy(
139+
block.logs.filter((log) => log.transactionHash) as StoreEventsLog[],
140+
(log) => log.transactionHash,
141+
);
142+
for (const [txHash, latestLogs] of logsByTransaction.entries()) {
143+
const preconfirmedLogs = preconfirmedTransactionLogs[txHash];
144+
delete preconfirmedTransactionLogs[txHash];
145+
146+
if (!preconfirmedLogs || preconfirmedLogs.length !== latestLogs.length) {
147+
debug(
148+
"found mismatching transaction",
149+
JSON.stringify(
150+
{
151+
txHash,
152+
numPreconfirmedLogs: preconfirmedLogs?.length,
153+
numLatestLogs: latestLogs.length,
154+
missingLogs: latestLogs.filter(
155+
(log) => !preconfirmedLogs.find((preconfirmedLog) => log.logIndex === preconfirmedLog.logIndex),
156+
),
157+
},
158+
(_, value) => (typeof value === "bigint" ? value.toString() : value),
159+
2,
160+
),
161+
);
162+
mismatchingTransactions.push(txHash);
163+
}
164+
}
165+
}
113166

114167
debug(
115168
"got latest block",
116169
block.blockNumber,
117170
"with",
118171
block.logs.length,
119-
"logs (",
120-
missingBlock ? "missing block," : "block seen,",
121-
`${missingLogs.length} new logs`,
122-
")",
172+
"logs",
173+
preconfirmedLogsState === "initialized"
174+
? `(${
175+
mismatchingTransactions.length ? mismatchingTransactions.length + " txs mismatching" : "all preconfirmed"
176+
})`
177+
: "",
123178
);
124179

125180
if (preconfirmedLogsState === "waiting") {
@@ -141,24 +196,25 @@ export function createPreconfirmedBlockStream(opts: PreconfirmedBlockStreamOptio
141196
return block;
142197
}
143198

144-
// If the preconfirmed logs stream is initialized but there are missing logs, recreate it and pass the block through.
145-
// Pass all logs from this block, not just the missing ones, to make sure they appear in the right order.
146-
if (preconfirmedLogsState === "initialized" && (missingLogs.length > 0 || missingBlock)) {
147-
debug("missing logs found in latest block", block.blockNumber, "recreating preconfirmed stream", {
148-
missingLogs: missingLogs.length,
149-
missingBlock,
150-
});
199+
// If the preconfirmed logs stream is initialized but there are mismatching logs, recreate it and pass the block through.
200+
// Pass all logs from this block, not just the mismatching ones, to make sure they appear in the right order.
201+
if (preconfirmedLogsState === "initialized" && mismatchingTransactions.length > 0) {
202+
debug("mismatching transactions found in latest block", block.blockNumber, "recreating preconfirmed stream");
151203
recreatePreconfirmedStream$.next();
152204
return block;
153205
}
154206

155-
debug("no missing logs found in latest block", block.blockNumber, "not recreating preconfirmed stream");
207+
debug(
208+
"no mismatching transactions found in latest block",
209+
block.blockNumber,
210+
"not recreating preconfirmed stream",
211+
);
156212
return;
157213
}),
158214
filter(isDefined),
159215
);
160216

161-
return merge(preconfirmedLogs$, missingLogs$);
217+
return merge(preconfirmedBlockLogs$, latestBlockLogs$);
162218
}
163219

164220
// TODO: refactor to reduce duplication with indexer/rpc stream in `createStoreSync.ts`

0 commit comments

Comments
 (0)