From 8102ba627d345952bd3be51104eb9a1012a95b4b Mon Sep 17 00:00:00 2001 From: Beast Date: Thu, 23 Apr 2026 14:41:58 +0800 Subject: [PATCH] feat: add queries benchmark script --- package.json | 3 +- scripts/graphql-bench.ts | 35 ++ src/lib/graphql-benchmark/bootstrap.ts | 244 +++++++++++++ src/lib/graphql-benchmark/index.ts | 8 + src/lib/graphql-benchmark/registry.ts | 380 +++++++++++++++++++++ src/lib/graphql-benchmark/run.ts | 94 +++++ src/lib/graphql-benchmark/types.ts | 33 ++ src/routeTree.gen.ts | 22 ++ src/routes/dev/graphql-benchmark/index.tsx | 204 +++++++++++ 9 files changed, 1022 insertions(+), 1 deletion(-) create mode 100644 scripts/graphql-bench.ts create mode 100644 src/lib/graphql-benchmark/bootstrap.ts create mode 100644 src/lib/graphql-benchmark/index.ts create mode 100644 src/lib/graphql-benchmark/registry.ts create mode 100644 src/lib/graphql-benchmark/run.ts create mode 100644 src/lib/graphql-benchmark/types.ts create mode 100644 src/routes/dev/graphql-benchmark/index.tsx diff --git a/package.json b/package.json index fc64247..0b07200 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "format": "prettier . --write", "prepare": "husky", "gen-assets": "svgr --template svgr-template.cjs --index-template svgr-index-template.cjs --icon --title-prop --typescript --jsx-runtime automatic --replace-attr-values currentColor=currentColor,#FFEF00=currentColor --filename-case pascal -d src/assets assets", - "gql:compile": "graphql-codegen" + "gql:compile": "graphql-codegen", + "bench:graphql": "bun scripts/graphql-bench.ts" }, "lint-staged": { "*.{ts,js,tsx,jsx}": "npm run format" diff --git a/scripts/graphql-bench.ts b/scripts/graphql-bench.ts new file mode 100644 index 0000000..802b946 --- /dev/null +++ b/scripts/graphql-bench.ts @@ -0,0 +1,35 @@ +import { runGraphqlBenchmarks } from '../src/lib/graphql-benchmark/run'; + +const defaultUrl = 'https://subsquid.quantus.com/blue/graphql'; +const endpoint = process.env.GRAPHQL_BENCH_URL ?? defaultUrl; + +async function main() { + // eslint-disable-next-line no-console + console.error(`GraphQL bench → ${endpoint}\n`); + const { results, bootstrapContext } = await runGraphqlBenchmarks({ + endpoint + }); + // eslint-disable-next-line no-console + console.log('Bootstrap context:', JSON.stringify(bootstrapContext, null, 2)); + // eslint-disable-next-line no-console + console.log('\nResults (slowest first):'); + for (const r of results) { + if (r.skipped) { + // eslint-disable-next-line no-console + console.log(` ${r.name} SKIPPED ${r.skipReason ?? ''}`); + } else { + // eslint-disable-next-line no-console + console.log( + ` ${r.name} ${r.durationMs}ms bytes=${r.responseBytes ?? '—'} ${r.errorMessage ?? 'OK'}` + ); + } + } + const hasFailure = results.some((r) => !r.skipped && r.errorMessage); + process.exit(hasFailure ? 1 : 0); +} + +main().catch((e) => { + // eslint-disable-next-line no-console + console.error(e); + process.exit(1); +}); diff --git a/src/lib/graphql-benchmark/bootstrap.ts b/src/lib/graphql-benchmark/bootstrap.ts new file mode 100644 index 0000000..5001ae6 --- /dev/null +++ b/src/lib/graphql-benchmark/bootstrap.ts @@ -0,0 +1,244 @@ +import type { ApolloClient, NormalizedCacheObject } from '@apollo/client'; + +import { + AccountOrderByInput, + BlockOrderByInput, + CancelledReversibleTransferOrderByInput, + ErrorEventOrderByInput, + ExecutedReversibleTransferOrderByInput, + GetAccountsDocument, + GetBlockByIdDocument, + GetCancelledReversibleTransactionsDocument, + GetErrorEventsDocument, + GetExecutedReversibleTransactionsDocument, + GetHighSecuritySetsDocument, + GetRecentBlocksDocument, + GetRecentMinerRewardsDocument, + GetRecentTransactionsDocument, + GetScheduledReversibleTransactionsDocument, + GetWormholeExtrinsicsDocument, + HighSecuritySetOrderByInput, + MinerRewardOrderByInput, + ScheduledReversibleTransferOrderByInput, + TransferOrderByInput, + WormholeExtrinsicOrderByInput +} from '@/__generated__/graphql'; +import { QUERY_DEFAULT_LIMIT } from '@/constants/query-default-limit'; +import { QUERY_UNIFIED_LIMIT } from '@/constants/query-unified-limit'; + +import type { GraphqlBenchmarkContext } from './types'; + +async function safeQuery(run: () => Promise): Promise { + try { + return await run(); + } catch { + return undefined; + } +} + +export async function loadGraphqlBenchmarkContext( + client: ApolloClient +): Promise { + const ctx: GraphqlBenchmarkContext = {}; + + const blocks = await safeQuery(() => + client.query({ + query: GetRecentBlocksDocument, + variables: { + orderBy: BlockOrderByInput.TimestampDesc, + limit: 1, + offset: 0 + } + }) + ); + const firstBlock = blocks?.data?.blocks?.[0]; + if (firstBlock) { + ctx.blockHeight = firstBlock.height; + ctx.blockHash = firstBlock.hash; + } + + const accounts = await safeQuery(() => + client.query({ + query: GetAccountsDocument, + variables: { + orderBy: AccountOrderByInput.IdAsc, + limit: 1, + offset: 0 + } + }) + ); + const firstAccount = accounts?.data?.accounts?.[0]; + if (firstAccount) { + ctx.accountId = firstAccount.id; + } + + const transfers = await safeQuery(() => + client.query({ + query: GetRecentTransactionsDocument, + variables: { + orderBy: TransferOrderByInput.TimestampDesc, + limit: 1, + offset: 0, + where: { extrinsic_isNull: false } + } + }) + ); + const firstTx = transfers?.data?.transactions?.[0]; + if (firstTx?.extrinsic?.id) { + ctx.extrinsicHash = firstTx.extrinsic.id; + } + + const scheduled = await safeQuery(() => + client.query({ + query: GetScheduledReversibleTransactionsDocument, + variables: { + orderBy: ScheduledReversibleTransferOrderByInput.TimestampDesc, + limit: 1, + offset: 0 + } + }) + ); + const firstSched = scheduled?.data?.scheduledReversibleTransactions?.[0]; + if (firstSched?.txId) { + ctx.scheduledTxId = firstSched.txId; + } + + const executed = await safeQuery(() => + client.query({ + query: GetExecutedReversibleTransactionsDocument, + variables: { + orderBy: ExecutedReversibleTransferOrderByInput.TimestampDesc, + limit: 1, + offset: 0 + } + }) + ); + const firstEx = executed?.data?.executedReversibleTransactions?.[0]; + if (firstEx?.txId) { + ctx.executedTxId = firstEx.txId; + } + + const cancelled = await safeQuery(() => + client.query({ + query: GetCancelledReversibleTransactionsDocument, + variables: { + orderBy: CancelledReversibleTransferOrderByInput.TimestampDesc, + limit: 1, + offset: 0 + } + }) + ); + const firstCanc = cancelled?.data?.cancelledReversibleTransactions?.[0]; + if (firstCanc?.txId) { + ctx.cancelledTxId = firstCanc.txId; + } + + const worm = await safeQuery(() => + client.query({ + query: GetWormholeExtrinsicsDocument, + variables: { + orderBy: [WormholeExtrinsicOrderByInput.TimestampDesc], + limit: 1, + offset: 0 + } + }) + ); + const firstWorm = worm?.data?.wormholeExtrinsics?.[0]; + if (firstWorm?.id) { + ctx.wormholeExtrinsicId = firstWorm.id; + } + + const errEvents = await safeQuery(() => + client.query({ + query: GetErrorEventsDocument, + variables: { + orderBy: ErrorEventOrderByInput.TimestampDesc, + limit: QUERY_DEFAULT_LIMIT, + offset: 0 + } + }) + ); + const errWithEx = errEvents?.data?.errorEvents?.find((e) => e.extrinsic?.id); + if (errWithEx?.extrinsic?.id) { + ctx.errorExtrinsicHash = errWithEx.extrinsic.id; + } + + const hss = await safeQuery(() => + client.query({ + query: GetHighSecuritySetsDocument, + variables: { + orderBy: HighSecuritySetOrderByInput.TimestampDesc, + limit: QUERY_DEFAULT_LIMIT, + offset: 0 + } + }) + ); + const hssWithEx = hss?.data?.highSecuritySets?.find((h) => h.extrinsic?.id); + if (hssWithEx?.extrinsic?.id) { + ctx.highSecurityExtrinsicHash = hssWithEx.extrinsic.id; + } + + const miners = await safeQuery(() => + client.query({ + query: GetRecentMinerRewardsDocument, + variables: { + orderBy: MinerRewardOrderByInput.TimestampDesc, + limit: 1 + } + }) + ); + const firstMr = miners?.data?.minerRewards?.[0]; + if (firstMr?.block?.hash) { + ctx.minerBlockHash = firstMr.block.hash; + } + + const sampleHeight = ctx.blockHeight; + const sampleHash = ctx.blockHash; + if (sampleHeight != null && sampleHash != null) { + const blockDetail = await safeQuery(() => + client.query({ + query: GetBlockByIdDocument, + variables: { + height: sampleHeight, + hash: sampleHash, + limit: QUERY_UNIFIED_LIMIT + } + }) + ); + const r = blockDetail?.data; + if (r) { + if (!ctx.scheduledTxId) { + const id = r.scheduledReversibleTransactions?.edges?.[0]?.node?.txId; + if (id) { + ctx.scheduledTxId = id; + } + } + if (!ctx.executedTxId) { + const id = r.executedReversibleTransactions?.edges?.[0]?.node?.txId; + if (id) { + ctx.executedTxId = id; + } + } + if (!ctx.cancelledTxId) { + const id = r.cancelledReversibleTransactions?.edges?.[0]?.node?.txId; + if (id) { + ctx.cancelledTxId = id; + } + } + if (!ctx.errorExtrinsicHash) { + const h = r.errorEvents?.edges?.[0]?.node?.extrinsic?.id; + if (h) { + ctx.errorExtrinsicHash = h; + } + } + if (!ctx.highSecurityExtrinsicHash) { + const h = r.highSecuritySets?.edges?.[0]?.node?.extrinsic?.id; + if (h) { + ctx.highSecurityExtrinsicHash = h; + } + } + } + } + + return ctx; +} diff --git a/src/lib/graphql-benchmark/index.ts b/src/lib/graphql-benchmark/index.ts new file mode 100644 index 0000000..fb674b8 --- /dev/null +++ b/src/lib/graphql-benchmark/index.ts @@ -0,0 +1,8 @@ +export { loadGraphqlBenchmarkContext } from './bootstrap'; +export { graphqlBenchmarkRegistry } from './registry'; +export { createBenchmarkApolloClient, runGraphqlBenchmarks } from './run'; +export type { + GraphqlBenchmarkContext, + GraphqlBenchmarkRegistryEntry, + GraphqlBenchmarkRow +} from './types'; diff --git a/src/lib/graphql-benchmark/registry.ts b/src/lib/graphql-benchmark/registry.ts new file mode 100644 index 0000000..e14c031 --- /dev/null +++ b/src/lib/graphql-benchmark/registry.ts @@ -0,0 +1,380 @@ +import { endOfToday } from 'date-fns/endOfToday'; +import { startOfToday } from 'date-fns/startOfToday'; +import { subDays } from 'date-fns/subDays'; + +import { + GetAccountByIdDocument, + GetAccountsDocument, + GetAccountsStatsDocument, + GetBlockByIdDocument, + GetBlocksDocument, + GetCancelledReversibleTransactionByTxIdDocument, + GetCancelledReversibleTransactionsDocument, + GetCancelledReversibleTransactionsStatsDocument, + GetDepositPoolStatsDocument, + GetErrorEventByHashDocument, + GetErrorEventsDocument, + GetErrorEventsStatsDocument, + GetExecutedReversibleTransactionByTxIdDocument, + GetExecutedReversibleTransactionsDocument, + GetExecutedReversibleTransactionsStatsDocument, + GetExtrinsicByHashDocument, + GetHighSecuritySetByHashDocument, + GetHighSecuritySetsDocument, + GetHighSecuritySetsStatsDocument, + GetMinerLeaderboardDocument, + GetMinerRewardByHashDocument, + GetMinerRewardsDocument, + GetMinerRewardsStatsDocument, + GetRecentBlocksDocument, + GetRecentCancelledReversibleTransactionsDocument, + GetRecentErrorEventsDocument, + GetRecentExecutedReversibleTransactionsDocument, + GetRecentHighSecuritySetsDocument, + GetRecentMinerRewardsDocument, + GetRecentScheduledReversibleTransactionsDocument, + GetRecentTransactionsDocument, + GetScheduledReversibleTransactionByTxIdDocument, + GetScheduledReversibleTransactionsDocument, + GetScheduledReversibleTransactionsStatsDocument, + GetStatusDocument, + GetTransactionsDocument, + GetTransactionsStatsDocument, + GetWormholeExtrinsicByIdDocument, + GetWormholeExtrinsicsDocument, + SearchAllDocument +} from '@/__generated__/graphql'; +import { QUERY_DEFAULT_LIMIT } from '@/constants/query-default-limit'; +import { QUERY_RECENT_LIMIT } from '@/constants/query-recent-limit'; +import { BLOCK_SORTS } from '@/constants/query-sorts'; +import { ACCOUNT_SORTS } from '@/constants/query-sorts/accounts'; +import { ERROR_EVENT_SORTS } from '@/constants/query-sorts/errors'; +import { HIGH_SECURITY_SET_SORTS } from '@/constants/query-sorts/high-security-sets'; +import { + CANCELLED_REVERSIBLE_TRANSACTION_SORTS, + EXECUTED_REVERSIBLE_TRANSACTION_SORTS, + SCHEDULED_REVERSIBLE_TRANSACTION_SORTS +} from '@/constants/query-sorts/reversible-transactions'; +import { TRANSACTION_SORTS } from '@/constants/query-sorts/transactions'; +import { WORMHOLE_EXTRINSIC_SORTS } from '@/constants/query-sorts/wormhole'; +import { QUERY_UNIFIED_LIMIT } from '@/constants/query-unified-limit'; +import { SEARCH_PREVIEW_RESULTS_LIMIT } from '@/constants/search-preview-results-limit'; + +import type { GraphqlBenchmarkRegistryEntry } from './types'; + +function statusDates() { + const beginningDate = new Date(0).toISOString(); + const todayDate = startOfToday().toISOString(); + const endDate = endOfToday().toISOString(); + return { beginningDate, todayDate, endDate }; +} + +function accountStatsDates() { + return { + startDate: subDays(startOfToday(), 7).toISOString(), + endDate: endOfToday().toISOString() + }; +} + +function statsDay() { + const startDate = startOfToday().toISOString(); + const endDate = endOfToday().toISOString(); + return { startDate, endDate }; +} + +export const graphqlBenchmarkRegistry: GraphqlBenchmarkRegistryEntry[] = [ + { + name: 'GetAccounts', + document: GetAccountsDocument, + getVariables: () => ({ + orderBy: ACCOUNT_SORTS.id.DESC, + limit: QUERY_DEFAULT_LIMIT, + offset: 0 + }) + }, + { + name: 'GetAccountById', + document: GetAccountByIdDocument, + getVariables: (ctx) => + ctx.accountId ? { id: ctx.accountId, limit: QUERY_UNIFIED_LIMIT } : null + }, + { + name: 'GetAccountsStats', + document: GetAccountsStatsDocument, + getVariables: () => accountStatsDates() + }, + { + name: 'GetBlocks', + document: GetBlocksDocument, + getVariables: () => ({ + orderBy: BLOCK_SORTS.timestamp.DESC, + limit: QUERY_DEFAULT_LIMIT, + offset: 0 + }) + }, + { + name: 'GetRecentBlocks', + document: GetRecentBlocksDocument, + getVariables: () => ({ + orderBy: BLOCK_SORTS.timestamp.DESC, + limit: QUERY_RECENT_LIMIT + }) + }, + { + name: 'GetBlockById', + document: GetBlockByIdDocument, + getVariables: (ctx) => + ctx.blockHeight != null && ctx.blockHash != null + ? { + height: ctx.blockHeight, + hash: ctx.blockHash, + limit: QUERY_UNIFIED_LIMIT + } + : null + }, + { + name: 'GetCancelledReversibleTransactions', + document: GetCancelledReversibleTransactionsDocument, + getVariables: () => ({ + orderBy: CANCELLED_REVERSIBLE_TRANSACTION_SORTS.timestamp.DESC, + limit: QUERY_DEFAULT_LIMIT, + offset: 0 + }) + }, + { + name: 'GetRecentCancelledReversibleTransactions', + document: GetRecentCancelledReversibleTransactionsDocument, + getVariables: () => ({ + orderBy: CANCELLED_REVERSIBLE_TRANSACTION_SORTS.timestamp.DESC, + limit: QUERY_RECENT_LIMIT + }) + }, + { + name: 'GetCancelledReversibleTransactionsStats', + document: GetCancelledReversibleTransactionsStatsDocument, + getVariables: () => statsDay() + }, + { + name: 'GetCancelledReversibleTransactionByTxId', + document: GetCancelledReversibleTransactionByTxIdDocument, + getVariables: (ctx) => + ctx.cancelledTxId ? { txId: ctx.cancelledTxId } : null + }, + { + name: 'GetStatus', + document: GetStatusDocument, + getVariables: () => statusDates() + }, + { + name: 'GetErrorEvents', + document: GetErrorEventsDocument, + getVariables: () => ({ + orderBy: ERROR_EVENT_SORTS.timestamp.DESC, + limit: QUERY_DEFAULT_LIMIT, + offset: 0 + }) + }, + { + name: 'GetRecentErrorEvents', + document: GetRecentErrorEventsDocument, + getVariables: () => ({ + orderBy: ERROR_EVENT_SORTS.timestamp.DESC, + limit: QUERY_RECENT_LIMIT + }) + }, + { + name: 'GetErrorEventsStats', + document: GetErrorEventsStatsDocument, + getVariables: () => statsDay() + }, + { + name: 'GetErrorEventByHash', + document: GetErrorEventByHashDocument, + getVariables: (ctx) => { + const hash = ctx.errorExtrinsicHash ?? ctx.extrinsicHash; + return hash ? { hash } : null; + } + }, + { + name: 'GetExecutedReversibleTransactions', + document: GetExecutedReversibleTransactionsDocument, + getVariables: () => ({ + orderBy: EXECUTED_REVERSIBLE_TRANSACTION_SORTS.timestamp.DESC, + limit: QUERY_DEFAULT_LIMIT, + offset: 0 + }) + }, + { + name: 'GetRecentExecutedReversibleTransactions', + document: GetRecentExecutedReversibleTransactionsDocument, + getVariables: () => ({ + orderBy: EXECUTED_REVERSIBLE_TRANSACTION_SORTS.timestamp.DESC, + limit: QUERY_RECENT_LIMIT + }) + }, + { + name: 'GetExecutedReversibleTransactionsStats', + document: GetExecutedReversibleTransactionsStatsDocument, + getVariables: () => statsDay() + }, + { + name: 'GetExecutedReversibleTransactionByTxId', + document: GetExecutedReversibleTransactionByTxIdDocument, + getVariables: (ctx) => + ctx.executedTxId ? { txId: ctx.executedTxId } : null + }, + { + name: 'GetHighSecuritySets', + document: GetHighSecuritySetsDocument, + getVariables: () => ({ + orderBy: HIGH_SECURITY_SET_SORTS.timestamp.DESC, + limit: QUERY_DEFAULT_LIMIT, + offset: 0 + }) + }, + { + name: 'GetRecentHighSecuritySets', + document: GetRecentHighSecuritySetsDocument, + getVariables: () => ({ + orderBy: HIGH_SECURITY_SET_SORTS.timestamp.DESC, + limit: QUERY_RECENT_LIMIT, + where: {} + }) + }, + { + name: 'GetHighSecuritySetsStats', + document: GetHighSecuritySetsStatsDocument, + getVariables: () => statsDay() + }, + { + name: 'GetHighSecuritySetByHash', + document: GetHighSecuritySetByHashDocument, + getVariables: (ctx) => { + const hash = ctx.highSecurityExtrinsicHash ?? ctx.extrinsicHash; + return hash ? { hash } : null; + } + }, + { + name: 'GetMinerLeaderboard', + document: GetMinerLeaderboardDocument, + getVariables: () => ({ + limit: QUERY_DEFAULT_LIMIT, + offset: 0 + }) + }, + { + name: 'GetMinerRewards', + document: GetMinerRewardsDocument, + getVariables: () => ({ + orderBy: TRANSACTION_SORTS.timestamp.DESC, + limit: QUERY_DEFAULT_LIMIT, + offset: 0 + }) + }, + { + name: 'GetRecentMinerRewards', + document: GetRecentMinerRewardsDocument, + getVariables: () => ({ + orderBy: TRANSACTION_SORTS.timestamp.DESC, + limit: QUERY_RECENT_LIMIT + }) + }, + { + name: 'GetMinerRewardsStats', + document: GetMinerRewardsStatsDocument, + getVariables: () => statsDay() + }, + { + name: 'GetMinerRewardByHash', + document: GetMinerRewardByHashDocument, + getVariables: (ctx) => + ctx.minerBlockHash ? { hash: ctx.minerBlockHash } : null + }, + { + name: 'GetScheduledReversibleTransactions', + document: GetScheduledReversibleTransactionsDocument, + getVariables: () => ({ + orderBy: SCHEDULED_REVERSIBLE_TRANSACTION_SORTS.timestamp.DESC, + limit: QUERY_DEFAULT_LIMIT, + offset: 0 + }) + }, + { + name: 'GetRecentScheduledReversibleTransactions', + document: GetRecentScheduledReversibleTransactionsDocument, + getVariables: () => ({ + orderBy: SCHEDULED_REVERSIBLE_TRANSACTION_SORTS.timestamp.DESC, + limit: QUERY_RECENT_LIMIT + }) + }, + { + name: 'GetScheduledReversibleTransactionsStats', + document: GetScheduledReversibleTransactionsStatsDocument, + getVariables: () => statsDay() + }, + { + name: 'GetScheduledReversibleTransactionByTxId', + document: GetScheduledReversibleTransactionByTxIdDocument, + getVariables: (ctx) => + ctx.scheduledTxId ? { txId: ctx.scheduledTxId } : null + }, + { + name: 'SearchAll', + document: SearchAllDocument, + getVariables: () => ({ + keyword: '0x', + keyword_number: -1, + limit: SEARCH_PREVIEW_RESULTS_LIMIT + }) + }, + { + name: 'GetTransactions', + document: GetTransactionsDocument, + getVariables: () => ({ + orderBy: TRANSACTION_SORTS.timestamp.DESC, + limit: QUERY_DEFAULT_LIMIT, + offset: 0, + where: { extrinsic_isNull: false } + }) + }, + { + name: 'GetRecentTransactions', + document: GetRecentTransactionsDocument, + getVariables: () => ({ + orderBy: TRANSACTION_SORTS.timestamp.DESC, + limit: QUERY_RECENT_LIMIT, + where: { extrinsic_isNull: false } + }) + }, + { + name: 'GetTransactionsStats', + document: GetTransactionsStatsDocument, + getVariables: () => statsDay() + }, + { + name: 'GetExtrinsicByHash', + document: GetExtrinsicByHashDocument, + getVariables: (ctx) => + ctx.extrinsicHash ? { hash: ctx.extrinsicHash } : null + }, + { + name: 'GetWormholeExtrinsics', + document: GetWormholeExtrinsicsDocument, + getVariables: () => ({ + orderBy: [WORMHOLE_EXTRINSIC_SORTS.timestamp.DESC], + limit: 25, + offset: 0 + }) + }, + { + name: 'GetWormholeExtrinsicById', + document: GetWormholeExtrinsicByIdDocument, + getVariables: (ctx) => + ctx.wormholeExtrinsicId ? { id: ctx.wormholeExtrinsicId } : null + }, + { + name: 'GetDepositPoolStats', + document: GetDepositPoolStatsDocument, + getVariables: () => ({}) + } +]; diff --git a/src/lib/graphql-benchmark/run.ts b/src/lib/graphql-benchmark/run.ts new file mode 100644 index 0000000..2f8f09a --- /dev/null +++ b/src/lib/graphql-benchmark/run.ts @@ -0,0 +1,94 @@ +import { + ApolloClient, + createHttpLink, + InMemoryCache, + type NormalizedCacheObject +} from '@apollo/client'; + +import { loadGraphqlBenchmarkContext } from './bootstrap'; +import { graphqlBenchmarkRegistry } from './registry'; +import type { GraphqlBenchmarkContext, GraphqlBenchmarkRow } from './types'; + +export function createBenchmarkApolloClient(uri: string) { + return new ApolloClient({ + link: createHttpLink({ uri }), + cache: new InMemoryCache(), + defaultOptions: { + query: { + fetchPolicy: 'no-cache' + } + } + }); +} + +function responseByteLength(data: unknown): number { + try { + return new TextEncoder().encode(JSON.stringify(data)).length; + } catch { + return 0; + } +} + +export async function runGraphqlBenchmarks(options: { + endpoint: string; + signal?: AbortSignal; + onProgress?: (name: string) => void; +}): Promise<{ + bootstrapContext: GraphqlBenchmarkContext; + results: GraphqlBenchmarkRow[]; +}> { + const { endpoint, signal, onProgress } = options; + const client = createBenchmarkApolloClient(endpoint); + + const bootstrapContext = await loadGraphqlBenchmarkContext(client); + const results: GraphqlBenchmarkRow[] = []; + + const queryContext = signal + ? { fetchOptions: { signal } as RequestInit } + : undefined; + + /* eslint-disable no-await-in-loop -- benchmarks run strictly sequentially */ + for (const entry of graphqlBenchmarkRegistry) { + onProgress?.(entry.name); + const variables = entry.getVariables(bootstrapContext); + if (variables === null) { + results.push({ + name: entry.name, + durationMs: 0, + skipped: true, + skipReason: 'No sample id from bootstrap for this operation' + }); + continue; + } + + const t0 = performance.now(); + try { + const { data, errors } = await client.query({ + query: entry.document, + variables, + context: queryContext + }); + const t1 = performance.now(); + const errorMessage = errors?.map((e) => e.message).join('; '); + results.push({ + name: entry.name, + durationMs: Math.round((t1 - t0) * 100) / 100, + responseBytes: responseByteLength(data), + errorMessage: errorMessage || undefined + }); + } catch (e) { + const t1 = performance.now(); + const message = e instanceof Error ? e.message : String(e); + results.push({ + name: entry.name, + durationMs: Math.round((t1 - t0) * 100) / 100, + errorMessage: message + }); + } + } + /* eslint-enable no-await-in-loop */ + + results.sort((a, b) => b.durationMs - a.durationMs); + + return { bootstrapContext, results }; +} diff --git a/src/lib/graphql-benchmark/types.ts b/src/lib/graphql-benchmark/types.ts new file mode 100644 index 0000000..8d73c54 --- /dev/null +++ b/src/lib/graphql-benchmark/types.ts @@ -0,0 +1,33 @@ +import type { DocumentNode } from 'graphql'; + +/** Sample ids from bootstrap queries; all optional. */ +export type GraphqlBenchmarkContext = { + blockHeight?: number; + blockHash?: string; + accountId?: string; + extrinsicHash?: string; + scheduledTxId?: string; + executedTxId?: string; + cancelledTxId?: string; + wormholeExtrinsicId?: string; + errorExtrinsicHash?: string; + highSecurityExtrinsicHash?: string; + minerBlockHash?: string; +}; + +export type GraphqlBenchmarkRegistryEntry = { + name: string; + document: DocumentNode; + getVariables: ( + ctx: GraphqlBenchmarkContext + ) => Record | null; +}; + +export type GraphqlBenchmarkRow = { + name: string; + durationMs: number; + responseBytes?: number; + skipped?: boolean; + skipReason?: string; + errorMessage?: string; +}; diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index ee0df04..ce7a893 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -31,6 +31,7 @@ import { Route as ErrorsIdRouteImport } from './routes/errors/$id' import { Route as CancelledReversibleTransactionsTxIdRouteImport } from './routes/cancelled-reversible-transactions/$txId' import { Route as BlocksIdRouteImport } from './routes/blocks/$id' import { Route as AccountsIdRouteImport } from './routes/accounts/$id' +import { Route as DevGraphqlBenchmarkIndexRouteImport } from './routes/dev/graphql-benchmark/index' const IndexRoute = IndexRouteImport.update({ id: '/', @@ -150,6 +151,12 @@ const AccountsIdRoute = AccountsIdRouteImport.update({ path: '/accounts/$id', getParentRoute: () => rootRouteImport, } as any) +const DevGraphqlBenchmarkIndexRoute = + DevGraphqlBenchmarkIndexRouteImport.update({ + id: '/dev/graphql-benchmark/', + path: '/dev/graphql-benchmark/', + getParentRoute: () => rootRouteImport, + } as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -174,6 +181,7 @@ export interface FileRoutesByFullPath { '/miner-rewards': typeof MinerRewardsIndexRoute '/scheduled-reversible-transactions': typeof ScheduledReversibleTransactionsIndexRoute '/wormhole': typeof WormholeIndexRoute + '/dev/graphql-benchmark': typeof DevGraphqlBenchmarkIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -198,6 +206,7 @@ export interface FileRoutesByTo { '/miner-rewards': typeof MinerRewardsIndexRoute '/scheduled-reversible-transactions': typeof ScheduledReversibleTransactionsIndexRoute '/wormhole': typeof WormholeIndexRoute + '/dev/graphql-benchmark': typeof DevGraphqlBenchmarkIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -223,6 +232,7 @@ export interface FileRoutesById { '/miner-rewards/': typeof MinerRewardsIndexRoute '/scheduled-reversible-transactions/': typeof ScheduledReversibleTransactionsIndexRoute '/wormhole/': typeof WormholeIndexRoute + '/dev/graphql-benchmark/': typeof DevGraphqlBenchmarkIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -249,6 +259,7 @@ export interface FileRouteTypes { | '/miner-rewards' | '/scheduled-reversible-transactions' | '/wormhole' + | '/dev/graphql-benchmark' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -273,6 +284,7 @@ export interface FileRouteTypes { | '/miner-rewards' | '/scheduled-reversible-transactions' | '/wormhole' + | '/dev/graphql-benchmark' id: | '__root__' | '/' @@ -297,6 +309,7 @@ export interface FileRouteTypes { | '/miner-rewards/' | '/scheduled-reversible-transactions/' | '/wormhole/' + | '/dev/graphql-benchmark/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -322,6 +335,7 @@ export interface RootRouteChildren { MinerRewardsIndexRoute: typeof MinerRewardsIndexRoute ScheduledReversibleTransactionsIndexRoute: typeof ScheduledReversibleTransactionsIndexRoute WormholeIndexRoute: typeof WormholeIndexRoute + DevGraphqlBenchmarkIndexRoute: typeof DevGraphqlBenchmarkIndexRoute } declare module '@tanstack/react-router' { @@ -480,6 +494,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AccountsIdRouteImport parentRoute: typeof rootRouteImport } + '/dev/graphql-benchmark/': { + id: '/dev/graphql-benchmark/' + path: '/dev/graphql-benchmark' + fullPath: '/dev/graphql-benchmark' + preLoaderRoute: typeof DevGraphqlBenchmarkIndexRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -512,6 +533,7 @@ const rootRouteChildren: RootRouteChildren = { ScheduledReversibleTransactionsIndexRoute: ScheduledReversibleTransactionsIndexRoute, WormholeIndexRoute: WormholeIndexRoute, + DevGraphqlBenchmarkIndexRoute: DevGraphqlBenchmarkIndexRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/dev/graphql-benchmark/index.tsx b/src/routes/dev/graphql-benchmark/index.tsx new file mode 100644 index 0000000..4785dc2 --- /dev/null +++ b/src/routes/dev/graphql-benchmark/index.tsx @@ -0,0 +1,204 @@ +import { createFileRoute, notFound } from '@tanstack/react-router'; +import * as React from 'react'; + +import { useNetwork } from '@/components/common/network-provider/network-provider'; +import { Button } from '@/components/ui/button'; +import { ContentContainer } from '@/components/ui/content-container'; +import { SectionContainer } from '@/components/ui/section-container'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from '@/components/ui/table'; +import { runGraphqlBenchmarks } from '@/lib/graphql-benchmark/run'; +import type { GraphqlBenchmarkRow } from '@/lib/graphql-benchmark/types'; +import { cn } from '@/lib/utils'; + +export const Route = createFileRoute('/dev/graphql-benchmark/')({ + beforeLoad: () => { + if (!import.meta.env.DEV) { + throw notFound(); + } + }, + component: GraphqlBenchmarkPage +}); + +function resultsToCsv(rows: GraphqlBenchmarkRow[]) { + const header = [ + 'name', + 'durationMs', + 'responseBytes', + 'skipped', + 'skipReason', + 'errorMessage' + ]; + const lines = [ + header.join(','), + ...rows.map((r) => + [ + JSON.stringify(r.name), + r.durationMs, + r.responseBytes ?? '', + r.skipped ? '1' : '0', + r.skipReason ? JSON.stringify(r.skipReason) : '', + r.errorMessage ? JSON.stringify(r.errorMessage) : '' + ].join(',') + ) + ]; + return lines.join('\n'); +} + +function GraphqlBenchmarkPage() { + const { networkUrl } = useNetwork(); + const [running, setRunning] = React.useState(false); + const [progress, setProgress] = React.useState(null); + const [error, setError] = React.useState(null); + const [rows, setRows] = React.useState(null); + const [lastEndpoint, setLastEndpoint] = React.useState(null); + + const onRun = async () => { + setRunning(true); + setError(null); + setProgress(null); + setRows(null); + setLastEndpoint(networkUrl); + try { + const { results } = await runGraphqlBenchmarks({ + endpoint: networkUrl, + onProgress: (name) => setProgress(name) + }); + setRows(results); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setRunning(false); + setProgress(null); + } + }; + + const copyJson = () => { + if (!rows) return; + navigator.clipboard + .writeText( + JSON.stringify( + { + endpoint: lastEndpoint, + at: new Date().toISOString(), + results: rows + }, + null, + 2 + ) + ) + .catch(() => {}); + }; + + const copyCsv = () => { + if (!rows) return; + navigator.clipboard.writeText(resultsToCsv(rows)).catch(() => {}); + }; + + const statusContent = (r: GraphqlBenchmarkRow) => { + if (r.skipped) { + return ( + Skipped: {r.skipReason} + ); + } + if (r.errorMessage) { + return {r.errorMessage}; + } + return 'OK'; + }; + + return ( + + +
+

GraphQL benchmarks

+

+ Development only. Runs every explorer operation sequentially against + the selected network ( + {networkUrl} + ), slowest first. +

+
+ +
+ + + + {progress ? ( + {progress}… + ) : null} +
+ + {error ? ( +

+ {error} +

+ ) : null} + + {rows ? ( + + + + Operation + Duration (ms) + Response (bytes) + Status + + + + {rows.map((r) => ( + + {r.name} + + {r.skipped ? '—' : r.durationMs} + + + {r.responseBytes != null ? r.responseBytes : '—'} + + {statusContent(r)} + + ))} + +
+ ) : null} +
+
+ ); +}