-
Notifications
You must be signed in to change notification settings - Fork 196
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(store-sync): add support for live sync from indexer #3226
Changes from 21 commits
3ff11bf
2306fb7
4c1167f
f1808fd
581afbc
6898ae1
94ebfc5
8880207
f0472cd
11be850
11e9799
a9bd1b0
326d46c
8eb688e
cd2ea8c
c7abd73
b7f5744
04d7355
90e3fa3
bb795fe
d9f509f
c835909
a12cb15
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"@latticexyz/store-sync": patch | ||
--- | ||
|
||
Added support for streaming logs from the indexer. |
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -27,12 +27,15 @@ import { | |||||||||||||||||||||||||||||||||||||||
combineLatest, | ||||||||||||||||||||||||||||||||||||||||
scan, | ||||||||||||||||||||||||||||||||||||||||
mergeMap, | ||||||||||||||||||||||||||||||||||||||||
throwError, | ||||||||||||||||||||||||||||||||||||||||
} from "rxjs"; | ||||||||||||||||||||||||||||||||||||||||
import { debug as parentDebug } from "./debug"; | ||||||||||||||||||||||||||||||||||||||||
import { SyncStep } from "./SyncStep"; | ||||||||||||||||||||||||||||||||||||||||
import { bigIntMax, chunk, isDefined, waitForIdle } from "@latticexyz/common/utils"; | ||||||||||||||||||||||||||||||||||||||||
import { getSnapshot } from "./getSnapshot"; | ||||||||||||||||||||||||||||||||||||||||
import { fromEventSource } from "./fromEventSource"; | ||||||||||||||||||||||||||||||||||||||||
import { fetchAndStoreLogs } from "./fetchAndStoreLogs"; | ||||||||||||||||||||||||||||||||||||||||
import { isLogsApiResponse } from "./isLogsApiResponse"; | ||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||
const debug = parentDebug.extend("createStoreSync"); | ||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||
|
@@ -61,7 +64,7 @@ export async function createStoreSync({ | |||||||||||||||||||||||||||||||||||||||
maxBlockRange, | ||||||||||||||||||||||||||||||||||||||||
initialState, | ||||||||||||||||||||||||||||||||||||||||
initialBlockLogs, | ||||||||||||||||||||||||||||||||||||||||
indexerUrl, | ||||||||||||||||||||||||||||||||||||||||
indexerUrl: indexerUrlInput, | ||||||||||||||||||||||||||||||||||||||||
}: CreateStoreSyncOptions): Promise<SyncResult> { | ||||||||||||||||||||||||||||||||||||||||
const filters: SyncFilter[] = | ||||||||||||||||||||||||||||||||||||||||
initialFilters.length || tableIds.length | ||||||||||||||||||||||||||||||||||||||||
|
@@ -78,9 +81,17 @@ export async function createStoreSync({ | |||||||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||||||||
: undefined; | ||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||
const initialBlockLogs$ = defer(async (): Promise<StorageAdapterBlock | undefined> => { | ||||||||||||||||||||||||||||||||||||||||
const chainId = publicClient.chain?.id ?? (await publicClient.getChainId()); | ||||||||||||||||||||||||||||||||||||||||
const indexerUrl = | ||||||||||||||||||||||||||||||||||||||||
indexerUrlInput !== false | ||||||||||||||||||||||||||||||||||||||||
? indexerUrlInput ?? | ||||||||||||||||||||||||||||||||||||||||
(publicClient.chain && "indexerUrl" in publicClient.chain && typeof publicClient.chain.indexerUrl === "string" | ||||||||||||||||||||||||||||||||||||||||
? publicClient.chain.indexerUrl | ||||||||||||||||||||||||||||||||||||||||
: undefined) | ||||||||||||||||||||||||||||||||||||||||
: undefined; | ||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||
const chainId = publicClient.chain?.id ?? (await publicClient.getChainId()); | ||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||
const initialBlockLogs$ = defer(async (): Promise<StorageAdapterBlock | undefined> => { | ||||||||||||||||||||||||||||||||||||||||
onProgress?.({ | ||||||||||||||||||||||||||||||||||||||||
step: SyncStep.SNAPSHOT, | ||||||||||||||||||||||||||||||||||||||||
percentage: 0, | ||||||||||||||||||||||||||||||||||||||||
|
@@ -95,15 +106,7 @@ export async function createStoreSync({ | |||||||||||||||||||||||||||||||||||||||
filters, | ||||||||||||||||||||||||||||||||||||||||
initialState, | ||||||||||||||||||||||||||||||||||||||||
initialBlockLogs, | ||||||||||||||||||||||||||||||||||||||||
indexerUrl: | ||||||||||||||||||||||||||||||||||||||||
indexerUrl !== false | ||||||||||||||||||||||||||||||||||||||||
? indexerUrl ?? | ||||||||||||||||||||||||||||||||||||||||
(publicClient.chain && | ||||||||||||||||||||||||||||||||||||||||
"indexerUrl" in publicClient.chain && | ||||||||||||||||||||||||||||||||||||||||
typeof publicClient.chain.indexerUrl === "string" | ||||||||||||||||||||||||||||||||||||||||
? publicClient.chain.indexerUrl | ||||||||||||||||||||||||||||||||||||||||
: undefined) | ||||||||||||||||||||||||||||||||||||||||
: undefined, | ||||||||||||||||||||||||||||||||||||||||
indexerUrl, | ||||||||||||||||||||||||||||||||||||||||
}); | ||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||
onProgress?.({ | ||||||||||||||||||||||||||||||||||||||||
|
@@ -199,7 +202,34 @@ export async function createStoreSync({ | |||||||||||||||||||||||||||||||||||||||
let endBlock: bigint | null = null; | ||||||||||||||||||||||||||||||||||||||||
let lastBlockNumberProcessed: bigint | null = null; | ||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||
const storedBlock$ = combineLatest([startBlock$, latestBlockNumber$]).pipe( | ||||||||||||||||||||||||||||||||||||||||
const storedIndexerLogs$ = indexerUrl | ||||||||||||||||||||||||||||||||||||||||
? startBlock$.pipe( | ||||||||||||||||||||||||||||||||||||||||
mergeMap((startBlock) => { | ||||||||||||||||||||||||||||||||||||||||
const url = new URL( | ||||||||||||||||||||||||||||||||||||||||
`api/logs-live?${new URLSearchParams({ | ||||||||||||||||||||||||||||||||||||||||
input: JSON.stringify({ chainId, address, filters }), | ||||||||||||||||||||||||||||||||||||||||
block_num: startBlock.toString(), | ||||||||||||||||||||||||||||||||||||||||
include_tx_hash: "true", | ||||||||||||||||||||||||||||||||||||||||
})}`, | ||||||||||||||||||||||||||||||||||||||||
indexerUrl, | ||||||||||||||||||||||||||||||||||||||||
); | ||||||||||||||||||||||||||||||||||||||||
return fromEventSource<string>(url); | ||||||||||||||||||||||||||||||||||||||||
}), | ||||||||||||||||||||||||||||||||||||||||
map((messageEvent) => { | ||||||||||||||||||||||||||||||||||||||||
const data = JSON.parse(messageEvent.data); | ||||||||||||||||||||||||||||||||||||||||
if (!isLogsApiResponse(data)) { | ||||||||||||||||||||||||||||||||||||||||
throw new Error("Received unexpected from indexer:" + messageEvent.data); | ||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||
return { ...data, blockNumber: BigInt(data.blockNumber) }; | ||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. not blocking but might be nice to move these lines into a arktype can help a lot with this pattern of parsing+validating+strong types but can save that for a later improvement
mud/packages/world/ts/node/buildSystemsManifest.ts Lines 13 to 30 in 111bb1b
|
||||||||||||||||||||||||||||||||||||||||
}), | ||||||||||||||||||||||||||||||||||||||||
concatMap(async (block) => { | ||||||||||||||||||||||||||||||||||||||||
await storageAdapter(block); | ||||||||||||||||||||||||||||||||||||||||
return block; | ||||||||||||||||||||||||||||||||||||||||
}), | ||||||||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||||||||
: throwError(() => new Error("No indexer URL provided")); | ||||||||||||||||||||||||||||||||||||||||
holic marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||
const storedRpcLogs$ = combineLatest([startBlock$, latestBlockNumber$]).pipe( | ||||||||||||||||||||||||||||||||||||||||
map(([startBlock, endBlock]) => ({ startBlock, endBlock })), | ||||||||||||||||||||||||||||||||||||||||
tap((range) => { | ||||||||||||||||||||||||||||||||||||||||
startBlock = range.startBlock; | ||||||||||||||||||||||||||||||||||||||||
|
@@ -215,13 +245,21 @@ export async function createStoreSync({ | |||||||||||||||||||||||||||||||||||||||
? bigIntMax(range.startBlock, lastBlockNumberProcessed + 1n) | ||||||||||||||||||||||||||||||||||||||||
: range.startBlock, | ||||||||||||||||||||||||||||||||||||||||
toBlock: range.endBlock, | ||||||||||||||||||||||||||||||||||||||||
storageAdapter, | ||||||||||||||||||||||||||||||||||||||||
logFilter, | ||||||||||||||||||||||||||||||||||||||||
storageAdapter, | ||||||||||||||||||||||||||||||||||||||||
}); | ||||||||||||||||||||||||||||||||||||||||
holic marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||
return from(storedBlocks); | ||||||||||||||||||||||||||||||||||||||||
}), | ||||||||||||||||||||||||||||||||||||||||
tap(({ blockNumber, logs }) => { | ||||||||||||||||||||||||||||||||||||||||
); | ||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||
const storedBlock$ = storedIndexerLogs$.pipe( | ||||||||||||||||||||||||||||||||||||||||
catchError((e) => { | ||||||||||||||||||||||||||||||||||||||||
debug("failed to stream logs from indexer:", e.message); | ||||||||||||||||||||||||||||||||||||||||
debug("falling back to streaming logs from RPC"); | ||||||||||||||||||||||||||||||||||||||||
return storedRpcLogs$; | ||||||||||||||||||||||||||||||||||||||||
}), | ||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. have we tested the fallback behavior here? any way to make sure e2e tests cover this case like they do for the previous indexers? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The fallback behavior already kicks in if no indexer is provided or the existing indexer doesn't support this API, so in that sense it's covered by the existing e2e tests |
||||||||||||||||||||||||||||||||||||||||
tap(async ({ logs, blockNumber }) => { | ||||||||||||||||||||||||||||||||||||||||
debug("stored", logs.length, "logs for block", blockNumber); | ||||||||||||||||||||||||||||||||||||||||
lastBlockNumberProcessed = blockNumber; | ||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||
|
Original file line number | Diff line number | Diff line change | ||||||
---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,10 @@ | ||||||||
import { Observable } from "rxjs"; | ||||||||
|
||||||||
export function fromEventSource<T>(url: string | URL): Observable<MessageEvent<T>> { | ||||||||
return new Observable<MessageEvent>((subscriber) => { | ||||||||
const eventSource = new EventSource(url); | ||||||||
eventSource.onmessage = (ev): void => subscriber.next(ev); | ||||||||
eventSource.onerror = (): void => subscriber.error(new Error("Event source failed" + new URL(url).origin)); | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||||
return () => eventSource.close(); | ||||||||
}); | ||||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
import { StorageAdapterBlock } from "./common"; | ||
|
||
export type LogsApiResponse = Omit<StorageAdapterBlock, "blockNumber"> & { blockNumber: string }; | ||
|
||
export function isLogsApiResponse( | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
data: any, | ||
): data is LogsApiResponse { | ||
return data && typeof data.blockNumber === "string" && Array.isArray(data.logs); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
small nit: I tend to name these "initial"