From 05db33bb762fec520fa3168ebd73e2b276091e0f Mon Sep 17 00:00:00 2001 From: Beast Date: Mon, 14 Sep 2026 18:57:10 +0800 Subject: [PATCH 1/4] feat: add mobile benchmark --- scripts/graphql-bench.ts | 41 +- src/lib/graphql-benchmark/index.ts | 5 +- src/lib/graphql-benchmark/mobile-bootstrap.ts | 444 ++++++++++ src/lib/graphql-benchmark/mobile-queries.ts | 762 ++++++++++++++++++ src/lib/graphql-benchmark/mobile-registry.ts | 458 +++++++++++ src/lib/graphql-benchmark/run.ts | 146 +++- src/lib/graphql-benchmark/types.ts | 41 + src/routes/dev/graphql-benchmark/index.tsx | 75 +- 8 files changed, 1930 insertions(+), 42 deletions(-) create mode 100644 src/lib/graphql-benchmark/mobile-bootstrap.ts create mode 100644 src/lib/graphql-benchmark/mobile-queries.ts create mode 100644 src/lib/graphql-benchmark/mobile-registry.ts diff --git a/scripts/graphql-bench.ts b/scripts/graphql-bench.ts index a03642a..aa3c387 100644 --- a/scripts/graphql-bench.ts +++ b/scripts/graphql-bench.ts @@ -1,26 +1,57 @@ import { runGraphqlBenchmarks } from '../src/lib/graphql-benchmark/run'; +import type { GraphqlBenchmarkSuite } from '../src/lib/graphql-benchmark/types'; -const defaultUrl = 'https://sub2.quantus.com/v1/graphql'; +const args = process.argv.slice(2); + +function argValue(flag: string) { + const prefix = `${flag}=`; + const match = args.find((arg) => arg.startsWith(prefix)); + if (match) return match.slice(prefix.length); + const index = args.indexOf(flag); + if (index >= 0) return args[index + 1]; + return undefined; +} + +const suite = (argValue('--suite') ?? 'explorer') as GraphqlBenchmarkSuite; +if (suite !== 'explorer' && suite !== 'mobile') { + // eslint-disable-next-line no-console + console.error(`Unknown suite "${suite}". Use explorer or mobile.`); + process.exit(1); +} + +const defaultUrl = + suite === 'mobile' + ? 'https://sqm.quantus.com/v1/graphql' + : 'https://sub2.quantus.com/v1/graphql'; const endpoint = process.env.GRAPHQL_BENCH_URL ?? defaultUrl; +const samples = Number(argValue('--samples') ?? (suite === 'mobile' ? 5 : 1)); async function main() { // eslint-disable-next-line no-console - console.error(`GraphQL bench → ${endpoint}\n`); + console.error(`GraphQL bench [${suite}] samples=${samples} → ${endpoint}\n`); const { results, bootstrapContext } = await runGraphqlBenchmarks({ - endpoint + endpoint, + suite, + samples }); // 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) { + const group = r.group ? `[${r.group}] ` : ''; if (r.skipped) { // eslint-disable-next-line no-console - console.log(` ${r.name} SKIPPED ${r.skipReason ?? ''}`); + console.log(` ${group}${r.name} SKIPPED ${r.skipReason ?? ''}`); } else { + const spread = + r.minMs != null && r.maxMs != null + ? ` min=${r.minMs} max=${r.maxMs}` + : ''; + const rows = r.rowCount != null ? ` rows=${r.rowCount}` : ''; // eslint-disable-next-line no-console console.log( - ` ${r.name} ${r.durationMs}ms bytes=${r.responseBytes ?? '—'} ${r.errorMessage ?? 'OK'}` + ` ${group}${r.name} ${r.durationMs}ms${spread} bytes=${r.responseBytes ?? '—'}${rows} ${r.errorMessage ?? 'OK'}` ); } } diff --git a/src/lib/graphql-benchmark/index.ts b/src/lib/graphql-benchmark/index.ts index fb674b8..09d1a15 100644 --- a/src/lib/graphql-benchmark/index.ts +++ b/src/lib/graphql-benchmark/index.ts @@ -1,8 +1,11 @@ export { loadGraphqlBenchmarkContext } from './bootstrap'; +export { loadMobileBenchmarkContext } from './mobile-bootstrap'; export { graphqlBenchmarkRegistry } from './registry'; +export { mobileGraphqlBenchmarkRegistry } from './mobile-registry'; export { createBenchmarkApolloClient, runGraphqlBenchmarks } from './run'; export type { GraphqlBenchmarkContext, GraphqlBenchmarkRegistryEntry, - GraphqlBenchmarkRow + GraphqlBenchmarkRow, + GraphqlBenchmarkSuite } from './types'; diff --git a/src/lib/graphql-benchmark/mobile-bootstrap.ts b/src/lib/graphql-benchmark/mobile-bootstrap.ts new file mode 100644 index 0000000..2454c4b --- /dev/null +++ b/src/lib/graphql-benchmark/mobile-bootstrap.ts @@ -0,0 +1,444 @@ +import { + gql, + type ApolloClient, + type NormalizedCacheObject +} from '@apollo/client'; + +import type { GraphqlBenchmarkContext } from './types'; + +const HISTORY_LOOKAHEAD = 21; +const WORMHOLE_PAGE = 300; +const DISCOVERY_BATCH = 20; +const NULLIFIER_BATCH = 300; + +async function safeQuery(run: () => Promise): Promise { + try { + return await run(); + } catch { + return undefined; + } +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function asNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) + ? value + : undefined; +} + +export async function loadMobileBenchmarkContext( + client: ApolloClient +): Promise { + const ctx: GraphqlBenchmarkContext = {}; + + const busy = await safeQuery(() => + client.query({ + query: gql` + query BusyAccount { + account_stats( + limit: 1 + order_by: { total_immediate_transfers: desc } + ) { + id + total_immediate_transfers + total_mined_blocks + } + } + ` + }) + ); + const busyRow = busy?.data?.account_stats?.[0]; + if (busyRow?.id) { + ctx.busyAccountId = busyRow.id; + ctx.busyImmediateTransfers = asNumber(busyRow.total_immediate_transfers); + ctx.accountId = busyRow.id; + } + + const miner = await safeQuery(() => + client.query({ + query: gql` + query MinerAccount { + account_stats(limit: 1, order_by: { total_mined_blocks: desc }) { + id + total_mined_blocks + } + } + ` + }) + ); + const minerRow = miner?.data?.account_stats?.[0]; + if (minerRow?.id) { + ctx.minerAccountId = minerRow.id; + ctx.minerMinedBlocks = asNumber(minerRow.total_mined_blocks); + } + + const discovery = await safeQuery(() => + client.query({ + query: gql` + query DiscoveryIds($limit: Int!) { + account(limit: $limit, order_by: { id: desc }) { + id + } + } + `, + variables: { limit: DISCOVERY_BATCH } + }) + ); + const discoveryIds = (discovery?.data?.account ?? []) + .map((row: { id?: string }) => row.id) + .filter((id: string | undefined): id is string => Boolean(id)); + if (discoveryIds.length > 0) { + ctx.discoveryAccountIds = [ + ...discoveryIds.slice(0, 10), + ...Array.from({ length: 10 }, (_, i) => `qz-bench-missing-${i}`) + ]; + } + + if (ctx.busyAccountId) { + const page = await safeQuery(() => + client.query({ + query: gql` + query HistoryCursor($accounts: [String!]!, $limit: Int!) { + account_event( + limit: $limit + where: { + _and: [ + { account_id: { _in: $accounts } } + { scheduled_reversible_transfer_id: { _is_null: true } } + ] + } + order_by: [{ timestamp: desc }, { id: desc }] + ) { + id + timestamp + } + } + `, + variables: { accounts: [ctx.busyAccountId], limit: HISTORY_LOOKAHEAD } + }) + ); + const rows = page?.data?.account_event ?? []; + const cursorRow = rows[Math.min(19, rows.length - 1)]; + if (cursorRow?.id && cursorRow?.timestamp) { + ctx.cursorId = cursorRow.id; + ctx.cursorTimestamp = cursorRow.timestamp; + } + + const deep = await safeQuery(() => + client.query({ + query: gql` + query HistoryDeepCursor($accounts: [String!]!) { + account_event( + limit: 1 + offset: 2000 + where: { + _and: [ + { account_id: { _in: $accounts } } + { scheduled_reversible_transfer_id: { _is_null: true } } + ] + } + order_by: [{ timestamp: desc }, { id: desc }] + ) { + id + timestamp + } + } + `, + variables: { accounts: [ctx.busyAccountId] } + }) + ); + const deepRow = deep?.data?.account_event?.[0]; + if (deepRow?.id && deepRow?.timestamp) { + ctx.deepCursorId = deepRow.id; + ctx.deepCursorTimestamp = deepRow.timestamp; + } + } + + if (ctx.minerAccountId) { + const page = await safeQuery(() => + client.query({ + query: gql` + query MinerHistoryCursor($accounts: [String!]!, $limit: Int!) { + account_event( + limit: $limit + where: { + _and: [ + { account_id: { _in: $accounts } } + { scheduled_reversible_transfer_id: { _is_null: true } } + ] + } + order_by: [{ timestamp: desc }, { id: desc }] + ) { + id + timestamp + } + } + `, + variables: { accounts: [ctx.minerAccountId], limit: HISTORY_LOOKAHEAD } + }) + ); + const rows = page?.data?.account_event ?? []; + const cursorRow = rows[Math.min(19, rows.length - 1)]; + if (cursorRow?.id && cursorRow?.timestamp) { + ctx.minerCursorId = cursorRow.id; + ctx.minerCursorTimestamp = cursorRow.timestamp; + } + } + + const transfer = await safeQuery(() => + client.query({ + query: gql` + query SampleTransfer { + transfer( + limit: 1 + where: { extrinsic_id: { _is_null: false } } + order_by: { timestamp: desc } + ) { + from_id + to_id + amount + block_height + extrinsic { + id + } + } + } + ` + }) + ); + const tx = transfer?.data?.transfer?.[0]; + if (tx) { + ctx.pendingFrom = asString(tx.from_id); + ctx.pendingTo = asString(tx.to_id); + ctx.pendingAmount = tx.amount != null ? String(tx.amount) : undefined; + ctx.pendingBlockHeight = asNumber(tx.block_height); + ctx.extrinsicHash = asString(tx.extrinsic?.id); + } + + const scheduled = await safeQuery(() => + client.query({ + query: gql` + query SampleScheduled { + scheduled_reversible_transfer( + limit: 1 + order_by: { timestamp: desc } + ) { + from { + id + } + to { + id + } + amount + block { + height + } + extrinsic { + id + } + } + } + ` + }) + ); + const sched = scheduled?.data?.scheduled_reversible_transfer?.[0]; + if (sched) { + ctx.pendingReversibleFrom = asString(sched.from?.id); + ctx.pendingReversibleTo = asString(sched.to?.id); + ctx.pendingReversibleAmount = + sched.amount != null ? String(sched.amount) : undefined; + ctx.pendingReversibleBlockHeight = asNumber(sched.block?.height); + ctx.scheduledExtrinsicHash = asString(sched.extrinsic?.id); + } + + const executed = await safeQuery(() => + client.query({ + query: gql` + query SampleExecuted { + executed_reversible_transfer( + limit: 1 + order_by: { timestamp: desc } + ) { + tx_id + } + } + ` + }) + ); + ctx.executedTxId = asString( + executed?.data?.executed_reversible_transfer?.[0]?.tx_id + ); + + const wormhole = await safeQuery(() => + client.query({ + query: gql` + query SampleWormholeRecipient { + transfer( + limit: 1 + where: { leaf_index: { _gt: "0" } } + order_by: { transfer_count: desc } + ) { + to_id + } + } + ` + }) + ); + ctx.wormholeToId = asString(wormhole?.data?.transfer?.[0]?.to_id); + + if (ctx.wormholeToId) { + const page = await safeQuery(() => + client.query({ + query: gql` + query WormholeCursor($tos: [String!]!, $limit: Int!) { + transfer( + where: { to_id: { _in: $tos }, block_height: { _gt: 0 } } + order_by: [{ block_height: asc }, { id: asc }] + limit: $limit + ) { + id + block_height + } + } + `, + variables: { tos: [ctx.wormholeToId], limit: WORMHOLE_PAGE } + }) + ); + const rows = page?.data?.transfer ?? []; + const last = rows[rows.length - 1]; + if (last?.id) { + ctx.wormholeCursorId = last.id; + ctx.wormholeCursorHeight = asNumber(last.block_height); + } + } + + const nullifiers = await safeQuery(() => + client.query({ + query: gql` + query SampleNullifiers($limit: Int!) { + wormhole_nullifier(limit: $limit, order_by: { timestamp: desc }) { + nullifier_hash + } + } + `, + variables: { limit: NULLIFIER_BATCH } + }) + ); + const hashes = (nullifiers?.data?.wormhole_nullifier ?? []) + .map((row: { nullifier_hash?: string }) => row.nullifier_hash) + .filter((hash: string | undefined): hash is string => Boolean(hash)); + if (hashes.length > 0) { + ctx.nullifierHashes = hashes; + } + + const multisig = await safeQuery(() => + client.query({ + query: gql` + query SampleMultisig { + multisig(limit: 1, order_by: { timestamp: desc }) { + id + signers + } + } + ` + }) + ); + const ms = multisig?.data?.multisig?.[0]; + if (ms?.id) { + ctx.multisigId = ms.id; + ctx.multisigSignerIds = Array.isArray(ms.signers) + ? ms.signers.filter((id: unknown): id is string => typeof id === 'string') + : []; + } + + const proposal = await safeQuery(() => + client.query({ + query: gql` + query SampleProposal { + multisig_proposal(limit: 1, order_by: { updated_at: desc }) { + proposal_id + multisig_id + } + } + ` + }) + ); + const pr = proposal?.data?.multisig_proposal?.[0]; + if (pr?.multisig_id) { + ctx.multisigId = ctx.multisigId ?? pr.multisig_id; + ctx.proposalId = asNumber(pr.proposal_id); + } + + const created = await safeQuery(() => + client.query({ + query: gql` + query SampleProposalCreated { + multisig_proposal_created(limit: 1, order_by: { timestamp: desc }) { + extrinsic { + id + } + } + } + ` + }) + ); + ctx.proposalCreatedHash = asString( + created?.data?.multisig_proposal_created?.[0]?.extrinsic?.id + ); + + const approved = await safeQuery(() => + client.query({ + query: gql` + query SampleSignerApproved { + multisig_signer_approved(limit: 1, order_by: { timestamp: desc }) { + extrinsic { + id + } + } + } + ` + }) + ); + ctx.signerApprovedHash = asString( + approved?.data?.multisig_signer_approved?.[0]?.extrinsic?.id + ); + + const executedMs = await safeQuery(() => + client.query({ + query: gql` + query SampleProposalExecuted { + executed_multisig_proposal(limit: 1, order_by: { timestamp: desc }) { + extrinsic { + id + } + } + } + ` + }) + ); + ctx.executedProposalHash = asString( + executedMs?.data?.executed_multisig_proposal?.[0]?.extrinsic?.id + ); + + const cancelled = await safeQuery(() => + client.query({ + query: gql` + query SampleProposalCancelled { + cancelled_multisig_proposal(limit: 1, order_by: { timestamp: desc }) { + extrinsic { + id + } + } + } + ` + }) + ); + ctx.cancelledProposalHash = asString( + cancelled?.data?.cancelled_multisig_proposal?.[0]?.extrinsic?.id + ); + + return ctx; +} diff --git a/src/lib/graphql-benchmark/mobile-queries.ts b/src/lib/graphql-benchmark/mobile-queries.ts new file mode 100644 index 0000000..e448a00 --- /dev/null +++ b/src/lib/graphql-benchmark/mobile-queries.ts @@ -0,0 +1,762 @@ +import { gql } from '@apollo/client'; + +/** Field selections copied from `quantus_sdk` Dart query strings. */ + +const MULTISIG_PROPOSAL_FIELDS = ` + id + proposal_id + created_at + updated_at + pallet + call + call_raw + transfer_amount + status + expiry_block + deposit + burned_pallet_fee + creation_network_fee + approvals + decode_error + proposer { + id + } + transferTo { + id + } + multisig { + id + threshold + signers + nonce + } + createdAtBlock { + height + hash + } + createdExtrinsic { + id + }`; + +const MULTISIG_INDEXER_FIELDS = ` + id + timestamp + threshold + nonce + signers + fee + creator { + id + } + block { + height + hash + } + extrinsic { + id + }`; + +const ACCOUNT_EVENT_CORE = ` + id + timestamp + transfer { + id + amount + timestamp + from { id } + to { id } + block { height hash } + extrinsic { id } + fee + executedBy { txId: tx_id } + } + executedReversibleTransfer { + block { height hash } + txId: tx_id + timestamp + id + scheduledTransfer { + amount + from { id } + to { id } + scheduledAt: scheduled_at + } + } + cancelledReversibleTransfer { + block { height hash } + txId: tx_id + timestamp + id + extrinsic { id } + scheduledTransfer { + amount + from { id } + to { id } + scheduledAt: scheduled_at + } + }`; + +const MINER_REWARD_FIELD = ` + minerReward { + id + reward + timestamp + miner { id } + block { height hash } + }`; + +const MULTISIG_ACCOUNT_EVENT_FIELDS = ` + multisig { +${MULTISIG_INDEXER_FIELDS} + } + multisigProposalCreated { + id + fee + deposit + burned_pallet_fee + timestamp + block { height hash } + extrinsic { id } + proposal { +${MULTISIG_PROPOSAL_FIELDS} + } + } + multisigSignerApproved { + id + fee + approvals_count + timestamp + block { height hash } + extrinsic { id } + approver { id } + proposal { +${MULTISIG_PROPOSAL_FIELDS} + } + } + executedMultisigProposal { + id + fee + result + approvers + timestamp + block { height hash } + extrinsic { + id + signer { id } + } + proposal { +${MULTISIG_PROPOSAL_FIELDS} + } + } + cancelledMultisigProposal { + id + fee + timestamp + block { height hash } + extrinsic { id } + cancelledBy { id } + proposal { +${MULTISIG_PROPOSAL_FIELDS} + } + }`; + +const ACCOUNT_EVENT_ORDER = 'order_by: [{timestamp: desc}, {id: desc}]'; +const CURSOR_VARS = ', $cursorTimestamp: timestamptz!, $cursorId: String!'; +const CURSOR_PRED = + '{timestamp: {_lte: $cursorTimestamp}}, {_not: {timestamp: {_eq: $cursorTimestamp}, id: {_gte: $cursorId}}}'; + +function directionPredicate(filter: 'all' | 'send' | 'receive') { + if (filter === 'send') return ', {outgoing: {_eq: true}}'; + if (filter === 'receive') return ', {incoming: {_eq: true}}'; + return ''; +} + +function accountEventsDocument( + filter: 'all' | 'send' | 'receive', + withCursor: boolean +) { + const minerReward = filter === 'send' ? '' : MINER_REWARD_FIELD; + const where = `{_and: [{account_id: {_in: $accounts}}, {scheduled_reversible_transfer_id: {_is_null: true}}${directionPredicate(filter)}${withCursor ? `, ${CURSOR_PRED}` : ''}]}`; + return gql(` +query AccountEvents($accounts: [String!]!, $limit: Int!${withCursor ? CURSOR_VARS : ''}) { + accountEvents: account_event(limit: $limit, where: ${where}, ${ACCOUNT_EVENT_ORDER}) { +${ACCOUNT_EVENT_CORE}${minerReward}${MULTISIG_ACCOUNT_EVENT_FIELDS} + } +} +`); +} + +function scheduledReversibleDocument( + filter: 'all' | 'send' | 'receive', + withCursor: boolean +) { + const where = `{_and: [{account_id: {_in: $accounts}}, {scheduled_reversible_transfer_id: {_is_null: false}}${directionPredicate(filter)}, {scheduledReversibleTransfer: {scheduled_at: {_gt: $after}}}${withCursor ? `, ${CURSOR_PRED}` : ''}]}`; + return gql(` +query ScheduledReversibleTransfersByAccounts($accounts: [String!]!, $limit: Int!, $after: timestamptz!${withCursor ? CURSOR_VARS : ''}) { + accountEvents: account_event(limit: $limit, where: ${where}, ${ACCOUNT_EVENT_ORDER}) { + id + timestamp + scheduledReversibleTransfer { + id + amount + timestamp + from { id } + to { id } + txId: tx_id + scheduledAt: scheduled_at + block { height hash } + extrinsic { id } + } + } +} +`); +} + +export const AccountsQueryDocument = gql` + query AccountsQuery($ids: [String!]) { + accounts: account(where: { id: { _in: $ids } }) { + id + } + } +`; + +export const AccountEventsAllDocument = accountEventsDocument('all', false); +export const AccountEventsSendDocument = accountEventsDocument('send', false); +export const AccountEventsReceiveDocument = accountEventsDocument( + 'receive', + false +); +export const AccountEventsAllAfterDocument = accountEventsDocument('all', true); +export const AccountEventsSendAfterDocument = accountEventsDocument( + 'send', + true +); +export const AccountEventsReceiveAfterDocument = accountEventsDocument( + 'receive', + true +); + +export const ScheduledReversibleAllDocument = scheduledReversibleDocument( + 'all', + false +); +export const ScheduledReversibleSendDocument = scheduledReversibleDocument( + 'send', + false +); +export const ScheduledReversibleReceiveDocument = scheduledReversibleDocument( + 'receive', + false +); +export const ScheduledReversibleAllAfterDocument = scheduledReversibleDocument( + 'all', + true +); + +export const ExecutedReversibleTransferByTxIdDocument = gql` + query ExecutedReversibleTransferByTxId($txId: String!) { + executedReversibleTransfers: executed_reversible_transfer( + where: { tx_id: { _eq: $txId } } + ) { + block { + height + hash + } + txId: tx_id + timestamp + id + scheduledTransfer { + amount + from { + id + } + to { + id + } + scheduledAt: scheduled_at + } + } + } +`; + +export const SearchPendingTransferDocument = gql` + query SearchPendingTransaction( + $from: String! + $to: String! + $amount: numeric! + $blockHeightAfter: Int! + ) { + events: event( + limit: 1 + where: { + transfer: { + from: { id: { _eq: $from } } + to: { id: { _eq: $to } } + amount: { _eq: $amount } + extrinsic: { id: { _is_null: false } } + block: { height: { _gt: $blockHeightAfter } } + } + } + order_by: { timestamp: desc } + ) { + id + timestamp + extrinsic { + id + } + transfer { + id + amount + timestamp + from { + id + } + to { + id + } + block { + height + hash + } + extrinsic { + id + } + fee + } + } + } +`; + +export const SearchPendingReversibleDocument = gql` + query SearchPendingTransaction( + $from: String! + $to: String! + $amount: numeric! + $blockHeightAfter: Int! + ) { + events: event( + limit: 1 + where: { + scheduledReversibleTransfer: { + from: { id: { _eq: $from } } + to: { id: { _eq: $to } } + amount: { _eq: $amount } + extrinsic: { id: { _is_null: false } } + block: { height: { _gt: $blockHeightAfter } } + } + } + order_by: { timestamp: desc } + ) { + id + timestamp + extrinsic { + id + } + scheduledReversibleTransfer { + id + amount + timestamp + from { + id + } + to { + id + } + txId: tx_id + scheduledAt: scheduled_at + block { + height + hash + } + extrinsic { + id + } + } + } + } +`; + +export const SearchByExtrinsicHashTransferDocument = gql` + query SearchByExtrinsicHash($extrinsicHash: String!) { + events: event( + limit: 1 + where: { transfer: { extrinsic: { id: { _eq: $extrinsicHash } } } } + order_by: { timestamp: desc } + ) { + id + timestamp + extrinsic { + id + } + transfer { + id + amount + timestamp + from { + id + } + to { + id + } + block { + height + hash + } + extrinsic { + id + } + fee + } + } + } +`; + +export const SearchByExtrinsicHashReversibleDocument = gql` + query SearchByExtrinsicHash($extrinsicHash: String!) { + events: event( + limit: 1 + where: { + scheduledReversibleTransfer: { + extrinsic: { id: { _eq: $extrinsicHash } } + } + } + order_by: { timestamp: desc } + ) { + id + timestamp + extrinsic { + id + } + scheduledReversibleTransfer { + id + amount + timestamp + from { + id + } + to { + id + } + txId: tx_id + scheduledAt: scheduled_at + block { + height + hash + } + extrinsic { + id + } + } + } + } +`; + +export const SearchProposalCreatedByExtrinsicHashDocument = gql(` +query SearchProposalCreatedByExtrinsicHash($extrinsicHash: String!) { + accountEvents: account_event( + limit: 1 + where: {multisigProposalCreated: {extrinsic: {id: {_eq: $extrinsicHash}}}} + order_by: {timestamp: desc} + ) { + id + timestamp + ${MULTISIG_ACCOUNT_EVENT_FIELDS} + } +} +`); + +export const SearchSignerApprovedByExtrinsicHashDocument = gql(` +query SearchSignerApprovedByExtrinsicHash($extrinsicHash: String!) { + accountEvents: account_event( + limit: 1 + where: {multisigSignerApproved: {extrinsic: {id: {_eq: $extrinsicHash}}}} + order_by: {timestamp: desc} + ) { + id + timestamp + ${MULTISIG_ACCOUNT_EVENT_FIELDS} + } +} +`); + +export const SearchExecutedByExtrinsicHashDocument = gql(` +query SearchExecutedByExtrinsicHash($extrinsicHash: String!) { + accountEvents: account_event( + limit: 1 + where: {executedMultisigProposal: {extrinsic: {id: {_eq: $extrinsicHash}}}} + order_by: {timestamp: desc} + ) { + id + timestamp + ${MULTISIG_ACCOUNT_EVENT_FIELDS} + } +} +`); + +export const SearchCancelledByExtrinsicHashDocument = gql(` +query SearchCancelledByExtrinsicHash($extrinsicHash: String!) { + accountEvents: account_event( + limit: 1 + where: {cancelledMultisigProposal: {extrinsic: {id: {_eq: $extrinsicHash}}}} + order_by: {timestamp: desc} + ) { + id + timestamp + ${MULTISIG_ACCOUNT_EVENT_FIELDS} + } +} +`); + +const WORMHOLE_TRANSFER_SELECTION = ` + id + blockHeight: block_height + fromId: from_id + toId: to_id + amount + toHash: to_hash + leafIndex: leaf_index + transferCount: transfer_count`; + +export const TransfersToAddressesDocument = gql(` +query TransfersToAddresses($tos: [String!]!, $limit: Int!, $afterBlock: Int!) { + transfers: transfer( + where: { to_id: {_in: $tos}, block_height: {_gt: $afterBlock} } + order_by: [{block_height: asc}, {id: asc}] + limit: $limit + ) { +${WORMHOLE_TRANSFER_SELECTION} + } +} +`); + +export const TransfersToAddressesAfterDocument = gql(` +query TransfersToAddressesAfter($tos: [String!]!, $limit: Int!, $cursorHeight: Int!, $cursorId: String!) { + transfers: transfer( + where: { + to_id: {_in: $tos} + block_height: {_gte: $cursorHeight} + _not: {block_height: {_eq: $cursorHeight}, id: {_lte: $cursorId}} + } + order_by: [{block_height: asc}, {id: asc}] + limit: $limit + ) { +${WORMHOLE_TRANSFER_SELECTION} + } +} +`); + +export const SpentNullifiersDocument = gql` + query SpentNullifiers($hashes: [String!]!) { + wormholeNullifiers: wormhole_nullifier( + where: { nullifier_hash: { _in: $hashes } } + limit: 1000 + ) { + nullifierHash: nullifier_hash + block { + height + } + } + } +`; + +export const MultisigByPkDocument = gql` + query MultisigByPk($id: String!) { + multisig_by_pk(id: $id) { + id + timestamp + threshold + nonce + signers + fee + creator { + id + } + block { + height + hash + } + extrinsic { + id + pallet + call + } + } + } +`; + +export const DiscoverMultisigsDocument = gql` + query DiscoverMultisigs($where: multisig_bool_exp!) { + multisig(where: $where) { + id + timestamp + threshold + nonce + signers + fee + creator { + id + } + block { + height + hash + } + } + } +`; + +export const MultisigOpenProposalsDocument = gql(` +query MultisigOpenProposals($multisigId: String!) { + multisig_proposal( + where: {_and: [{multisig_id: {_eq: $multisigId}}, {status: {_in: [ACTIVE, APPROVED]}}]}, + order_by: {updated_at: desc} + ) { +${MULTISIG_PROPOSAL_FIELDS} + } +} +`); + +export const MultisigPastProposalsDocument = gql(` +query MultisigPastProposals($multisigId: String!) { + multisig_proposal( + where: {_and: [{multisig_id: {_eq: $multisigId}}, {status: {_in: [EXECUTED, CANCELLED, REMOVED]}}]}, + order_by: {updated_at: desc} + ) { +${MULTISIG_PROPOSAL_FIELDS} + } +} +`); + +export const MultisigProposalDocument = gql(` +query MultisigProposal($multisigId: String!, $proposalId: Int!) { + multisig_proposal( + where: {_and: [{multisig_id: {_eq: $multisigId}}, {proposal_id: {_eq: $proposalId}}]}, + limit: 1 + ) { +${MULTISIG_PROPOSAL_FIELDS} + } +} +`); + +export const SearchPendingTransferScalarsDocument = gql` + query SearchPendingTransferScalars( + $from: String! + $to: String! + $amount: numeric! + $blockHeightAfter: Int! + ) { + transfers: transfer( + limit: 1 + where: { + from_id: { _eq: $from } + to_id: { _eq: $to } + amount: { _eq: $amount } + extrinsic_id: { _is_null: false } + block_height: { _gt: $blockHeightAfter } + } + order_by: { timestamp: desc } + ) { + id + amount + timestamp + from_id + to_id + block_height + extrinsic_id + fee + } + } +`; + +export const TestnetStatsDocument = gql` + query TestnetStats($ids: [String!]!) { + stats: account_stats(where: { id: { _in: $ids } }) { + id + total_mined_blocks + } + } +`; + +/** Pre-optimization shapes, used only as a contrast. */ + +const LEGACY_TRANSFER_GUARD = `{_or: [{transfer_id: {_is_null: true}}, {transfer: {extrinsic_id: {_is_null: false}}}]}`; +const LEGACY_MULTISIG_SEND_CLAUSE = `{multisig_id: {_is_null: false}}, {multisig_proposal_created_id: {_is_null: false}}, {multisig_signer_approved_id: {_is_null: false}}, {executed_multisig_proposal_id: {_is_null: false}}, {cancelled_multisig_proposal_id: {_is_null: false}}`; + +export const LegacyAccountEventsAllDocument = gql(` +query LegacyAccountEventsAll($accounts: [String!]!, $limit: Int!, $offset: Int!) { + accountEvents: account_event( + limit: $limit + offset: $offset + where: { + _and: [ + {account_id: {_in: $accounts}} + {scheduled_reversible_transfer_id: {_is_null: true}} + ${LEGACY_TRANSFER_GUARD} + ] + } + order_by: {timestamp: desc} + ) { +${ACCOUNT_EVENT_CORE}${MINER_REWARD_FIELD}${MULTISIG_ACCOUNT_EVENT_FIELDS} + } +} +`); + +export const LegacyAccountEventsSendDocument = gql(` +query LegacyAccountEventsSend($accounts: [String!]!, $limit: Int!, $offset: Int!) { + accountEvents: account_event( + limit: $limit + offset: $offset + where: { + _and: [ + {account_id: {_in: $accounts}} + {scheduled_reversible_transfer_id: {_is_null: true}} + ${LEGACY_TRANSFER_GUARD} + { + _or: [ + {transfer: {from_id: {_in: $accounts}}} + {executedReversibleTransfer: {scheduledTransfer: {from_id: {_in: $accounts}}}} + {cancelledReversibleTransfer: {scheduledTransfer: {from_id: {_in: $accounts}}}} + ${LEGACY_MULTISIG_SEND_CLAUSE} + ] + } + ] + } + order_by: {timestamp: desc} + ) { +${ACCOUNT_EVENT_CORE}${MULTISIG_ACCOUNT_EVENT_FIELDS} + } +} +`); + +export const LegacyTransfersToAddressesDocument = gql` + query LegacyTransfersToAddresses( + $tos: [String!]! + $limit: Int! + $offset: Int! + $afterBlock: Int + ) { + transfers: transfer( + where: { + to: { id: { _in: $tos } } + block: { height: { _gt: $afterBlock } } + } + order_by: [{ block: { height: asc } }, { id: asc }] + limit: $limit + offset: $offset + ) { + id + block { + height + } + from { + id + } + to { + id + } + amount + toHash: to_hash + leafIndex: leaf_index + transferCount: transfer_count + } + } +`; diff --git a/src/lib/graphql-benchmark/mobile-registry.ts b/src/lib/graphql-benchmark/mobile-registry.ts new file mode 100644 index 0000000..3e47a19 --- /dev/null +++ b/src/lib/graphql-benchmark/mobile-registry.ts @@ -0,0 +1,458 @@ +import type { + GraphqlBenchmarkContext, + GraphqlBenchmarkRegistryEntry +} from './types'; +import { + AccountEventsAllAfterDocument, + AccountEventsAllDocument, + AccountEventsReceiveAfterDocument, + AccountEventsReceiveDocument, + AccountEventsSendAfterDocument, + AccountEventsSendDocument, + AccountsQueryDocument, + DiscoverMultisigsDocument, + ExecutedReversibleTransferByTxIdDocument, + LegacyAccountEventsAllDocument, + LegacyAccountEventsSendDocument, + LegacyTransfersToAddressesDocument, + MultisigByPkDocument, + MultisigOpenProposalsDocument, + MultisigPastProposalsDocument, + MultisigProposalDocument, + ScheduledReversibleAllAfterDocument, + ScheduledReversibleAllDocument, + ScheduledReversibleReceiveDocument, + ScheduledReversibleSendDocument, + SearchByExtrinsicHashReversibleDocument, + SearchByExtrinsicHashTransferDocument, + SearchCancelledByExtrinsicHashDocument, + SearchExecutedByExtrinsicHashDocument, + SearchPendingReversibleDocument, + SearchPendingTransferDocument, + SearchPendingTransferScalarsDocument, + SearchProposalCreatedByExtrinsicHashDocument, + SearchSignerApprovedByExtrinsicHashDocument, + SpentNullifiersDocument, + TestnetStatsDocument, + TransfersToAddressesAfterDocument, + TransfersToAddressesDocument +} from './mobile-queries'; + +const HISTORY_LIMIT = 21; +const WORMHOLE_LIMIT = 300; + +function pendingSinceIso() { + return new Date(Date.now() - 2 * 60 * 1000).toISOString(); +} + +function historyVars( + accountId: string | undefined, + cursor?: { timestamp?: string; id?: string } +): Record | null { + if (!accountId) return null; + return { + accounts: [accountId], + limit: HISTORY_LIMIT, + ...(cursor?.timestamp && cursor.id + ? { cursorTimestamp: cursor.timestamp, cursorId: cursor.id } + : {}) + }; +} + +function scheduledVars( + accountId: string | undefined, + cursor?: { timestamp?: string; id?: string } +): Record | null { + const base = historyVars(accountId, cursor); + if (!base) return null; + return { ...base, after: pendingSinceIso() }; +} + +function discoverWhere(accountIds: string[]) { + if (accountIds.length === 1) { + return { signers: { _contains: [accountIds[0]] } }; + } + return { + _or: accountIds.map((id) => ({ signers: { _contains: [id] } })) + }; +} + +function entry( + name: string, + group: string, + document: GraphqlBenchmarkRegistryEntry['document'], + getVariables: GraphqlBenchmarkRegistryEntry['getVariables'] +): GraphqlBenchmarkRegistryEntry { + return { name, group, document, getVariables }; +} + +export const mobileGraphqlBenchmarkRegistry: GraphqlBenchmarkRegistryEntry[] = [ + entry('AccountsQuery', 'discovery', AccountsQueryDocument, (ctx) => + ctx.discoveryAccountIds ? { ids: ctx.discoveryAccountIds } : null + ), + entry('TestnetStats', 'discovery', TestnetStatsDocument, (ctx) => + ctx.discoveryAccountIds ? { ids: ctx.discoveryAccountIds } : null + ), + + entry('AccountEvents.all', 'history', AccountEventsAllDocument, (ctx) => + historyVars(ctx.busyAccountId) + ), + entry('AccountEvents.send', 'history', AccountEventsSendDocument, (ctx) => + historyVars(ctx.busyAccountId) + ), + entry( + 'AccountEvents.receive', + 'history', + AccountEventsReceiveDocument, + (ctx) => historyVars(ctx.busyAccountId) + ), + entry( + 'AccountEvents.all.after', + 'history', + AccountEventsAllAfterDocument, + (ctx) => + historyVars(ctx.busyAccountId, { + timestamp: ctx.cursorTimestamp, + id: ctx.cursorId + }) + ), + entry( + 'AccountEvents.send.after', + 'history', + AccountEventsSendAfterDocument, + (ctx) => + historyVars(ctx.busyAccountId, { + timestamp: ctx.cursorTimestamp, + id: ctx.cursorId + }) + ), + entry( + 'AccountEvents.receive.after', + 'history', + AccountEventsReceiveAfterDocument, + (ctx) => + historyVars(ctx.busyAccountId, { + timestamp: ctx.cursorTimestamp, + id: ctx.cursorId + }) + ), + entry('AccountEvents.all.miner', 'history', AccountEventsAllDocument, (ctx) => + historyVars(ctx.minerAccountId) + ), + entry( + 'AccountEvents.receive.miner', + 'history', + AccountEventsReceiveDocument, + (ctx) => historyVars(ctx.minerAccountId) + ), + entry( + 'AccountEvents.all.miner.after', + 'history', + AccountEventsAllAfterDocument, + (ctx) => + historyVars(ctx.minerAccountId, { + timestamp: ctx.minerCursorTimestamp, + id: ctx.minerCursorId + }) + ), + entry( + 'AccountEvents.all.deep', + 'history', + AccountEventsAllAfterDocument, + (ctx) => + historyVars(ctx.busyAccountId, { + timestamp: ctx.deepCursorTimestamp, + id: ctx.deepCursorId + }) + ), + + entry( + 'ScheduledReversible.all', + 'history', + ScheduledReversibleAllDocument, + (ctx) => scheduledVars(ctx.busyAccountId) + ), + entry( + 'ScheduledReversible.send', + 'history', + ScheduledReversibleSendDocument, + (ctx) => scheduledVars(ctx.busyAccountId) + ), + entry( + 'ScheduledReversible.receive', + 'history', + ScheduledReversibleReceiveDocument, + (ctx) => scheduledVars(ctx.busyAccountId) + ), + entry( + 'ScheduledReversible.all.after', + 'history', + ScheduledReversibleAllAfterDocument, + (ctx) => + scheduledVars(ctx.busyAccountId, { + timestamp: ctx.cursorTimestamp, + id: ctx.cursorId + }) + ), + + entry( + 'ExecutedReversibleByTxId', + 'search', + ExecutedReversibleTransferByTxIdDocument, + (ctx) => (ctx.executedTxId ? { txId: ctx.executedTxId } : null) + ), + entry( + 'SearchPending.transfer', + 'search', + SearchPendingTransferDocument, + (ctx) => + ctx.pendingFrom && + ctx.pendingTo && + ctx.pendingAmount != null && + ctx.pendingBlockHeight != null + ? { + from: ctx.pendingFrom, + to: ctx.pendingTo, + amount: ctx.pendingAmount, + blockHeightAfter: Math.max(0, ctx.pendingBlockHeight - 1) + } + : null + ), + entry( + 'ALT.SearchPending.transfer.scalars', + 'search', + SearchPendingTransferScalarsDocument, + (ctx) => + ctx.pendingFrom && + ctx.pendingTo && + ctx.pendingAmount != null && + ctx.pendingBlockHeight != null + ? { + from: ctx.pendingFrom, + to: ctx.pendingTo, + amount: ctx.pendingAmount, + blockHeightAfter: Math.max(0, ctx.pendingBlockHeight - 1) + } + : null + ), + entry( + 'SearchPending.reversible', + 'search', + SearchPendingReversibleDocument, + (ctx) => + ctx.pendingReversibleFrom && + ctx.pendingReversibleTo && + ctx.pendingReversibleAmount != null && + ctx.pendingReversibleBlockHeight != null + ? { + from: ctx.pendingReversibleFrom, + to: ctx.pendingReversibleTo, + amount: ctx.pendingReversibleAmount, + blockHeightAfter: Math.max(0, ctx.pendingReversibleBlockHeight - 1) + } + : null + ), + entry( + 'SearchByExtrinsicHash.transfer', + 'search', + SearchByExtrinsicHashTransferDocument, + (ctx) => (ctx.extrinsicHash ? { extrinsicHash: ctx.extrinsicHash } : null) + ), + entry( + 'SearchByExtrinsicHash.reversible', + 'search', + SearchByExtrinsicHashReversibleDocument, + (ctx) => + ctx.scheduledExtrinsicHash + ? { extrinsicHash: ctx.scheduledExtrinsicHash } + : null + ), + entry( + 'SearchProposalCreatedByHash', + 'search', + SearchProposalCreatedByExtrinsicHashDocument, + (ctx) => + ctx.proposalCreatedHash + ? { extrinsicHash: ctx.proposalCreatedHash } + : null + ), + entry( + 'SearchSignerApprovedByHash', + 'search', + SearchSignerApprovedByExtrinsicHashDocument, + (ctx) => + ctx.signerApprovedHash ? { extrinsicHash: ctx.signerApprovedHash } : null + ), + entry( + 'SearchExecutedByHash', + 'search', + SearchExecutedByExtrinsicHashDocument, + (ctx) => + ctx.executedProposalHash + ? { extrinsicHash: ctx.executedProposalHash } + : null + ), + entry( + 'SearchCancelledByHash', + 'search', + SearchCancelledByExtrinsicHashDocument, + (ctx) => + ctx.cancelledProposalHash + ? { extrinsicHash: ctx.cancelledProposalHash } + : null + ), + + entry( + 'TransfersToAddresses', + 'wormhole', + TransfersToAddressesDocument, + (ctx) => + ctx.wormholeToId + ? { tos: [ctx.wormholeToId], limit: WORMHOLE_LIMIT, afterBlock: 0 } + : null + ), + entry( + 'TransfersToAddresses.after', + 'wormhole', + TransfersToAddressesAfterDocument, + (ctx) => + ctx.wormholeToId && + ctx.wormholeCursorHeight != null && + ctx.wormholeCursorId + ? { + tos: [ctx.wormholeToId], + limit: WORMHOLE_LIMIT, + cursorHeight: ctx.wormholeCursorHeight, + cursorId: ctx.wormholeCursorId + } + : null + ), + entry('SpentNullifiers', 'wormhole', SpentNullifiersDocument, (ctx) => + ctx.nullifierHashes ? { hashes: ctx.nullifierHashes } : null + ), + + entry('MultisigByPk', 'multisig', MultisigByPkDocument, (ctx) => + ctx.multisigId ? { id: ctx.multisigId } : null + ), + entry( + 'DiscoverMultisigs.one', + 'multisig', + DiscoverMultisigsDocument, + (ctx) => { + const id = ctx.multisigSignerIds?.[0]; + return id ? { where: discoverWhere([id]) } : null; + } + ), + entry( + 'DiscoverMultisigs.many', + 'multisig', + DiscoverMultisigsDocument, + (ctx) => { + const ids = ctx.multisigSignerIds?.slice(0, 3); + return ids && ids.length > 0 ? { where: discoverWhere(ids) } : null; + } + ), + entry( + 'MultisigOpenProposals', + 'multisig', + MultisigOpenProposalsDocument, + (ctx) => (ctx.multisigId ? { multisigId: ctx.multisigId } : null) + ), + entry( + 'MultisigPastProposals', + 'multisig', + MultisigPastProposalsDocument, + (ctx) => (ctx.multisigId ? { multisigId: ctx.multisigId } : null) + ), + entry('MultisigProposal', 'multisig', MultisigProposalDocument, (ctx) => + ctx.multisigId && ctx.proposalId != null + ? { multisigId: ctx.multisigId, proposalId: ctx.proposalId } + : null + ), + + entry( + 'LEGACY.AccountEvents.all', + 'legacy', + LegacyAccountEventsAllDocument, + (ctx) => + ctx.busyAccountId + ? { accounts: [ctx.busyAccountId], limit: HISTORY_LIMIT, offset: 0 } + : null + ), + entry( + 'LEGACY.AccountEvents.all.offset20', + 'legacy', + LegacyAccountEventsAllDocument, + (ctx) => + ctx.busyAccountId + ? { accounts: [ctx.busyAccountId], limit: HISTORY_LIMIT, offset: 20 } + : null + ), + entry( + 'LEGACY.AccountEvents.all.offset2000', + 'legacy', + LegacyAccountEventsAllDocument, + (ctx) => + ctx.busyAccountId + ? { accounts: [ctx.busyAccountId], limit: HISTORY_LIMIT, offset: 2000 } + : null + ), + entry( + 'LEGACY.AccountEvents.send', + 'legacy', + LegacyAccountEventsSendDocument, + (ctx) => + ctx.busyAccountId + ? { accounts: [ctx.busyAccountId], limit: HISTORY_LIMIT, offset: 0 } + : null + ), + entry( + 'LEGACY.AccountEvents.all.miner', + 'legacy', + LegacyAccountEventsAllDocument, + (ctx) => + ctx.minerAccountId + ? { accounts: [ctx.minerAccountId], limit: HISTORY_LIMIT, offset: 0 } + : null + ), + entry( + 'LEGACY.TransfersToAddresses', + 'legacy', + LegacyTransfersToAddressesDocument, + (ctx) => + ctx.wormholeToId + ? { + tos: [ctx.wormholeToId], + limit: WORMHOLE_LIMIT, + offset: 0, + afterBlock: 0 + } + : null + ), + entry( + 'LEGACY.TransfersToAddresses.offset300', + 'legacy', + LegacyTransfersToAddressesDocument, + (ctx) => + ctx.wormholeToId + ? { + tos: [ctx.wormholeToId], + limit: WORMHOLE_LIMIT, + offset: 300, + afterBlock: 0 + } + : null + ) +]; + +export function describeMobileContext(ctx: GraphqlBenchmarkContext) { + return { + busyAccountId: ctx.busyAccountId, + busyImmediateTransfers: ctx.busyImmediateTransfers, + minerAccountId: ctx.minerAccountId, + minerMinedBlocks: ctx.minerMinedBlocks, + deepCursorId: ctx.deepCursorId, + wormholeToId: ctx.wormholeToId, + multisigId: ctx.multisigId + }; +} diff --git a/src/lib/graphql-benchmark/run.ts b/src/lib/graphql-benchmark/run.ts index 2f8f09a..aee368d 100644 --- a/src/lib/graphql-benchmark/run.ts +++ b/src/lib/graphql-benchmark/run.ts @@ -6,8 +6,15 @@ import { } from '@apollo/client'; import { loadGraphqlBenchmarkContext } from './bootstrap'; +import { loadMobileBenchmarkContext } from './mobile-bootstrap'; +import { mobileGraphqlBenchmarkRegistry } from './mobile-registry'; import { graphqlBenchmarkRegistry } from './registry'; -import type { GraphqlBenchmarkContext, GraphqlBenchmarkRow } from './types'; +import type { + GraphqlBenchmarkContext, + GraphqlBenchmarkRegistryEntry, + GraphqlBenchmarkRow, + GraphqlBenchmarkSuite +} from './types'; export function createBenchmarkApolloClient(uri: string) { return new ApolloClient({ @@ -29,31 +36,82 @@ function responseByteLength(data: unknown): number { } } +function firstArrayLength(data: unknown): number | undefined { + if (!data || typeof data !== 'object') return undefined; + const values = Object.values(data as Record); + const firstArray = values.find((value) => Array.isArray(value)); + return Array.isArray(firstArray) ? firstArray.length : undefined; +} + +function roundMs(value: number) { + return Math.round(value * 100) / 100; +} + +function median(values: number[]) { + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + if (sorted.length === 0) return 0; + if (sorted.length % 2 === 0) { + return (sorted[mid - 1]! + sorted[mid]!) / 2; + } + return sorted[mid]!; +} + +function timeoutSignal(timeoutMs: number, parent?: AbortSignal) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + const onParentAbort = () => controller.abort(); + parent?.addEventListener('abort', onParentAbort); + return { + signal: controller.signal, + cleanup: () => { + clearTimeout(timer); + parent?.removeEventListener('abort', onParentAbort); + } + }; +} + export async function runGraphqlBenchmarks(options: { endpoint: string; + suite?: GraphqlBenchmarkSuite; + samples?: number; + warmup?: boolean; + timeoutMs?: number; signal?: AbortSignal; onProgress?: (name: string) => void; }): Promise<{ bootstrapContext: GraphqlBenchmarkContext; results: GraphqlBenchmarkRow[]; }> { - const { endpoint, signal, onProgress } = options; + const { + endpoint, + suite = 'explorer', + samples = 1, + warmup = samples > 1, + timeoutMs = 30_000, + signal, + onProgress + } = options; const client = createBenchmarkApolloClient(endpoint); + const registry: GraphqlBenchmarkRegistryEntry[] = + suite === 'mobile' + ? mobileGraphqlBenchmarkRegistry + : graphqlBenchmarkRegistry; - const bootstrapContext = await loadGraphqlBenchmarkContext(client); + const bootstrapContext = + suite === 'mobile' + ? await loadMobileBenchmarkContext(client) + : 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) { + for (const entry of registry) { onProgress?.(entry.name); const variables = entry.getVariables(bootstrapContext); if (variables === null) { results.push({ name: entry.name, + group: entry.group, durationMs: 0, skipped: true, skipReason: 'No sample id from bootstrap for this operation' @@ -61,30 +119,56 @@ export async function runGraphqlBenchmarks(options: { 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 - }); + const timed: number[] = []; + let responseBytes: number | undefined; + let rowCount: number | undefined; + let errorMessage: string | undefined; + const runs = samples + (warmup ? 1 : 0); + + for (let i = 0; i < runs; i += 1) { + const timedOut = timeoutSignal(timeoutMs, signal); + const t0 = performance.now(); + try { + const { data, errors } = await client.query({ + query: entry.document, + variables, + context: { fetchOptions: { signal: timedOut.signal } as RequestInit } + }); + const t1 = performance.now(); + const elapsed = roundMs(t1 - t0); + if (!warmup || i > 0) timed.push(elapsed); + responseBytes = responseByteLength(data); + rowCount = firstArrayLength(data); + if (errors?.length) { + errorMessage = errors.map((e) => e.message).join('; '); + break; + } + } catch (e) { + const t1 = performance.now(); + const elapsed = roundMs(t1 - t0); + if (!warmup || i > 0) timed.push(elapsed); + if (timedOut.signal.aborted && !signal?.aborted) { + errorMessage = `timed out after ${timeoutMs}ms`; + } else { + errorMessage = e instanceof Error ? e.message : String(e); + } + break; + } finally { + timedOut.cleanup(); + } } + + results.push({ + name: entry.name, + group: entry.group, + durationMs: timed.length ? roundMs(median(timed)) : 0, + samplesMs: timed.length ? timed : undefined, + minMs: timed.length ? Math.min(...timed) : undefined, + maxMs: timed.length ? Math.max(...timed) : undefined, + responseBytes, + rowCount, + errorMessage + }); } /* eslint-enable no-await-in-loop */ diff --git a/src/lib/graphql-benchmark/types.ts b/src/lib/graphql-benchmark/types.ts index 8d73c54..d295fda 100644 --- a/src/lib/graphql-benchmark/types.ts +++ b/src/lib/graphql-benchmark/types.ts @@ -13,11 +13,47 @@ export type GraphqlBenchmarkContext = { errorExtrinsicHash?: string; highSecurityExtrinsicHash?: string; minerBlockHash?: string; + /** Account with the most immediate transfers (wallet-history worst case). */ + busyAccountId?: string; + busyImmediateTransfers?: number; + /** Account with the most mined blocks (receive/all feed worst case). */ + minerAccountId?: string; + minerMinedBlocks?: number; + discoveryAccountIds?: string[]; + cursorTimestamp?: string; + cursorId?: string; + deepCursorTimestamp?: string; + deepCursorId?: string; + minerCursorTimestamp?: string; + minerCursorId?: string; + pendingFrom?: string; + pendingTo?: string; + pendingAmount?: string; + pendingBlockHeight?: number; + pendingReversibleFrom?: string; + pendingReversibleTo?: string; + pendingReversibleAmount?: string; + pendingReversibleBlockHeight?: number; + scheduledExtrinsicHash?: string; + wormholeToId?: string; + wormholeCursorHeight?: number; + wormholeCursorId?: string; + nullifierHashes?: string[]; + multisigId?: string; + multisigSignerIds?: string[]; + proposalId?: number; + proposalCreatedHash?: string; + signerApprovedHash?: string; + executedProposalHash?: string; + cancelledProposalHash?: string; }; +export type GraphqlBenchmarkSuite = 'explorer' | 'mobile'; + export type GraphqlBenchmarkRegistryEntry = { name: string; document: DocumentNode; + group?: string; getVariables: ( ctx: GraphqlBenchmarkContext ) => Record | null; @@ -26,7 +62,12 @@ export type GraphqlBenchmarkRegistryEntry = { export type GraphqlBenchmarkRow = { name: string; durationMs: number; + samplesMs?: number[]; + minMs?: number; + maxMs?: number; responseBytes?: number; + rowCount?: number; + group?: string; skipped?: boolean; skipReason?: string; errorMessage?: string; diff --git a/src/routes/dev/graphql-benchmark/index.tsx b/src/routes/dev/graphql-benchmark/index.tsx index 4785dc2..6c07a62 100644 --- a/src/routes/dev/graphql-benchmark/index.tsx +++ b/src/routes/dev/graphql-benchmark/index.tsx @@ -14,7 +14,10 @@ import { TableRow } from '@/components/ui/table'; import { runGraphqlBenchmarks } from '@/lib/graphql-benchmark/run'; -import type { GraphqlBenchmarkRow } from '@/lib/graphql-benchmark/types'; +import type { + GraphqlBenchmarkRow, + GraphqlBenchmarkSuite +} from '@/lib/graphql-benchmark/types'; import { cn } from '@/lib/utils'; export const Route = createFileRoute('/dev/graphql-benchmark/')({ @@ -28,9 +31,13 @@ export const Route = createFileRoute('/dev/graphql-benchmark/')({ function resultsToCsv(rows: GraphqlBenchmarkRow[]) { const header = [ + 'group', 'name', 'durationMs', + 'minMs', + 'maxMs', 'responseBytes', + 'rowCount', 'skipped', 'skipReason', 'errorMessage' @@ -39,9 +46,13 @@ function resultsToCsv(rows: GraphqlBenchmarkRow[]) { header.join(','), ...rows.map((r) => [ + JSON.stringify(r.group ?? ''), JSON.stringify(r.name), r.durationMs, + r.minMs ?? '', + r.maxMs ?? '', r.responseBytes ?? '', + r.rowCount ?? '', r.skipped ? '1' : '0', r.skipReason ? JSON.stringify(r.skipReason) : '', r.errorMessage ? JSON.stringify(r.errorMessage) : '' @@ -53,11 +64,14 @@ function resultsToCsv(rows: GraphqlBenchmarkRow[]) { function GraphqlBenchmarkPage() { const { networkUrl } = useNetwork(); + const [suite, setSuite] = React.useState('explorer'); 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 [lastSuite, setLastSuite] = + React.useState(null); const onRun = async () => { setRunning(true); @@ -65,9 +79,12 @@ function GraphqlBenchmarkPage() { setProgress(null); setRows(null); setLastEndpoint(networkUrl); + setLastSuite(suite); try { const { results } = await runGraphqlBenchmarks({ endpoint: networkUrl, + suite, + samples: suite === 'mobile' ? 5 : 1, onProgress: (name) => setProgress(name) }); setRows(results); @@ -86,6 +103,7 @@ function GraphqlBenchmarkPage() { JSON.stringify( { endpoint: lastEndpoint, + suite: lastSuite, at: new Date().toISOString(), results: rows }, @@ -113,20 +131,41 @@ function GraphqlBenchmarkPage() { return 'OK'; }; + const showGroup = rows?.some((r) => r.group) ?? false; + return (
-

GraphQL benchmarks

-

- Development only. Runs every explorer operation sequentially against +

GraphQL benchmarks

+

+ Development only. Runs every {suite} operation sequentially against the selected network ( {networkUrl} ), slowest first. + {suite === 'mobile' + ? ' Mobile suite uses the wallet SDK query shapes, plus legacy contrasts.' + : ''}

+ +