diff --git a/scripts/graphql-bench.ts b/scripts/graphql-bench.ts index a03642a..14437c7 100644 --- a/scripts/graphql-bench.ts +++ b/scripts/graphql-bench.ts @@ -1,31 +1,73 @@ import { runGraphqlBenchmarks } from '../src/lib/graphql-benchmark/run'; +import { graphqlBenchmarkRunFailed } from '../src/lib/graphql-benchmark/suite-failure'; +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`); - const { results, bootstrapContext } = await runGraphqlBenchmarks({ - endpoint - }); + console.error(`GraphQL bench [${suite}] samples=${samples} → ${endpoint}\n`); + const { results, bootstrapContext, bootstrapRequestFailures } = + await runGraphqlBenchmarks({ + endpoint, + suite, + samples + }); // eslint-disable-next-line no-console console.log('Bootstrap context:', JSON.stringify(bootstrapContext, null, 2)); + if (bootstrapRequestFailures.length > 0) { + // eslint-disable-next-line no-console + console.log('\nBootstrap request failures:'); + for (const failure of bootstrapRequestFailures) { + // eslint-disable-next-line no-console + console.log(` ${failure}`); + } + } // 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'}` ); } } - const hasFailure = results.some((r) => !r.skipped && r.errorMessage); - process.exit(hasFailure ? 1 : 0); + process.exit( + graphqlBenchmarkRunFailed(results, bootstrapRequestFailures) ? 1 : 0 + ); } main().catch((e) => { 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-account-event-query.test.ts b/src/lib/graphql-benchmark/mobile-account-event-query.test.ts new file mode 100644 index 0000000..787e4a2 --- /dev/null +++ b/src/lib/graphql-benchmark/mobile-account-event-query.test.ts @@ -0,0 +1,155 @@ +import { + accountEventPageVariables, + buildAccountEventsQuery, + buildScheduledReversibleTransfersQuery +} from './mobile-account-event-query'; + +const selection = '\n id'; + +describe('account-event query builder (quantus-apps@11e035a3)', () => { + it('filters each account with _eq and orders by the composite index', () => { + const all = buildAccountEventsQuery({ + filter: 'all', + withCursor: false, + accountCount: 1, + selection + }); + const send = buildAccountEventsQuery({ + filter: 'send', + withCursor: false, + accountCount: 1, + selection + }); + const receive = buildAccountEventsQuery({ + filter: 'receive', + withCursor: false, + accountCount: 1, + selection + }); + + expect(all).toContain( + 'where: {_and: [{account_id: {_eq: $account0}}, {scheduled_reversible_transfer_id: {_is_null: true}}]}' + ); + expect(all).toContain( + 'order_by: [{account_id: desc}, {timestamp: desc}, {id: desc}]' + ); + expect(all).not.toContain('_in'); + expect(all).not.toContain('offset'); + expect(send).toContain(', {outgoing: {_eq: true}}'); + expect(send).not.toContain('incoming'); + expect(send).toContain( + 'order_by: [{account_id: desc}, {outgoing: desc}, {timestamp: desc}, {id: desc}]' + ); + expect(receive).toContain(', {incoming: {_eq: true}}'); + expect(receive).not.toContain('outgoing'); + expect(receive).toContain( + 'order_by: [{account_id: desc}, {incoming: desc}, {timestamp: desc}, {id: desc}]' + ); + }); + + it('emits one alias and one variable per account', () => { + const twoAccounts = buildAccountEventsQuery({ + filter: 'all', + withCursor: false, + accountCount: 2, + selection + }); + + expect(twoAccounts).toContain( + 'query AccountEvents($account0: String!, $account1: String!, $limit: Int!)' + ); + expect(twoAccounts).toContain('events0: account_event('); + expect(twoAccounts).toContain('events1: account_event('); + expect(twoAccounts).toContain('account_id: {_eq: $account0}'); + expect(twoAccounts).toContain('account_id: {_eq: $account1}'); + expect(twoAccounts).not.toContain('events2:'); + }); + + it('adds the keyset predicate only on the cursor variant', () => { + const first = buildAccountEventsQuery({ + filter: 'all', + withCursor: false, + accountCount: 1, + selection + }); + const after = buildAccountEventsQuery({ + filter: 'all', + withCursor: true, + accountCount: 1, + selection + }); + + expect(first).not.toContain('$cursorTimestamp'); + expect(after).toContain( + 'query AccountEvents($account0: String!, $limit: Int!, $cursorTimestamp: timestamptz!, $cursorId: String!)' + ); + expect(after).toContain('timestamp: {_lte: $cursorTimestamp}'); + expect(after).toContain( + '_not: {timestamp: {_eq: $cursorTimestamp}, id: {_gte: $cursorId}}' + ); + }); + + it('uses the same per-account shape for scheduled transfers', () => { + const send = buildScheduledReversibleTransfersQuery({ + filter: 'send', + withCursor: false, + accountCount: 1, + selection + }); + const after = buildScheduledReversibleTransfersQuery({ + filter: 'receive', + withCursor: true, + accountCount: 2, + selection + }); + + expect(send).toContain( + 'query ScheduledReversibleTransfersByAccounts($account0: String!, $limit: Int!, $after: timestamptz!)' + ); + expect(send).toContain('account_id: {_eq: $account0}'); + expect(send).toContain(', {outgoing: {_eq: true}}'); + expect(send).toContain( + '{scheduledReversibleTransfer: {scheduled_at: {_gt: $after}}}' + ); + expect(send).toContain( + 'order_by: [{account_id: desc}, {outgoing: desc}, {timestamp: desc}, {id: desc}]' + ); + expect(send).not.toContain('from_id'); + expect(after).toContain('events1: account_event('); + expect(after).toContain(', {incoming: {_eq: true}}'); + expect(after).toContain('timestamp: {_lte: $cursorTimestamp}'); + expect(after).toContain( + '_not: {timestamp: {_eq: $cursorTimestamp}, id: {_gte: $cursorId}}' + ); + }); + + it('binds one variable per account, plus the optional keyset', () => { + expect( + accountEventPageVariables({ + accountIds: ['qz-a', 'qz-b'], + limit: 21, + cursor: { timestamp: 't1', id: 'x' } + }) + ).toEqual({ + account0: 'qz-a', + account1: 'qz-b', + limit: 21, + cursorTimestamp: 't1', + cursorId: 'x' + }); + }); + + it('rejects an empty account list', () => { + expect(() => + buildAccountEventsQuery({ + filter: 'all', + withCursor: false, + accountCount: 0, + selection + }) + ).toThrow('at least one account'); + expect(() => + accountEventPageVariables({ accountIds: [], limit: 21 }) + ).toThrow('must not be empty'); + }); +}); diff --git a/src/lib/graphql-benchmark/mobile-account-event-query.ts b/src/lib/graphql-benchmark/mobile-account-event-query.ts new file mode 100644 index 0000000..8951efc --- /dev/null +++ b/src/lib/graphql-benchmark/mobile-account-event-query.ts @@ -0,0 +1,147 @@ +/** + * Account-event documents shaped like quantus-apps main @ 11e035a3 + * `ChainHistoryService.buildAccountEventsQuery` and + * `buildScheduledReversibleTransfersQuery`. + * + * One aliased `account_event` selection per wallet account, each filtered + * with `account_id: {_eq}`. Hasura renders `_in` as `= ANY(array)`, which + * Postgres will not walk as an ordered range on the composite index. + * `order_by` leads with `account_id` and, for send / receive, the direction + * column, so the sort matches that index prefix. + */ + +export type AccountEventFilter = 'all' | 'send' | 'receive'; + +/** + * Wallet sizes past a single account. + * 2 is a new software wallet (primary account plus its encrypted companion) + * and the count the SDK test builds. 8 is a larger wallet, enough aliased + * selections for fan-out cost to show up in the timings. + */ +export const MOBILE_HISTORY_FANOUT_COUNTS = [2, 8] as const; + +export const MOBILE_HISTORY_ACCOUNT_SAMPLE = Math.max( + ...MOBILE_HISTORY_FANOUT_COUNTS +); + +const CURSOR_VARIABLES = ', $cursorTimestamp: timestamptz!, $cursorId: String!'; +const CURSOR_PREDICATE = + '{timestamp: {_lte: $cursorTimestamp}}, {_not: {timestamp: {_eq: $cursorTimestamp}, id: {_gte: $cursorId}}}'; + +function requireAccountCount(accountCount: number) { + if (!Number.isInteger(accountCount) || accountCount < 1) { + throw new Error( + `accountCount must query at least one account, got ${String(accountCount)}` + ); + } +} + +function accountVariables(accountCount: number) { + requireAccountCount(accountCount); + return Array.from( + { length: accountCount }, + (_, index) => `$account${index}: String!` + ).join(', '); +} + +function directionOrder(filter: AccountEventFilter) { + if (filter === 'send') return '{outgoing: desc}, '; + if (filter === 'receive') return '{incoming: desc}, '; + return ''; +} + +function directionPredicate(filter: AccountEventFilter) { + if (filter === 'send') return ', {outgoing: {_eq: true}}'; + if (filter === 'receive') return ', {incoming: {_eq: true}}'; + return ''; +} + +export function accountEventOrder(filter: AccountEventFilter) { + return `order_by: [{account_id: desc}, ${directionOrder(filter)}{timestamp: desc}, {id: desc}]`; +} + +function accountEventDocument(options: { + operationName: string; + extraVariables: string; + filter: AccountEventFilter; + withCursor: boolean; + accountCount: number; + whereClause: (index: number) => string; + selection: string; +}) { + const variables = `${accountVariables(options.accountCount)}, $limit: Int!${options.extraVariables}${options.withCursor ? CURSOR_VARIABLES : ''}`; + const selections = Array.from( + { length: options.accountCount }, + (_, index) => { + return ` + events${index}: account_event(limit: $limit, where: ${options.whereClause(index)}, ${accountEventOrder(options.filter)}) { +${options.selection} + }`; + } + ).join('\n'); + + return ` +query ${options.operationName}(${variables}) { +${selections} +} +`; +} + +export function buildAccountEventsQuery(options: { + filter: AccountEventFilter; + withCursor: boolean; + accountCount: number; + selection: string; +}) { + const { filter, withCursor, accountCount, selection } = options; + return accountEventDocument({ + operationName: 'AccountEvents', + extraVariables: '', + filter, + withCursor, + accountCount, + selection, + whereClause: (index) => + `{_and: [{account_id: {_eq: $account${index}}}, {scheduled_reversible_transfer_id: {_is_null: true}}${directionPredicate(filter)}${withCursor ? `, ${CURSOR_PREDICATE}` : ''}]}` + }); +} + +export function buildScheduledReversibleTransfersQuery(options: { + filter: AccountEventFilter; + withCursor: boolean; + accountCount: number; + selection: string; +}) { + const { filter, withCursor, accountCount, selection } = options; + return accountEventDocument({ + operationName: 'ScheduledReversibleTransfersByAccounts', + extraVariables: ', $after: timestamptz!', + filter, + withCursor, + accountCount, + selection, + whereClause: (index) => + `{_and: [{account_id: {_eq: $account${index}}}, {scheduled_reversible_transfer_id: {_is_null: false}}${directionPredicate(filter)}, {scheduledReversibleTransfer: {scheduled_at: {_gt: $after}}}${withCursor ? `, ${CURSOR_PREDICATE}` : ''}]}` + }); +} + +/** Variables for one page: `$account0`..`$accountN-1`, `$limit`, optional keyset. */ +export function accountEventPageVariables(options: { + accountIds: readonly string[]; + limit: number; + cursor?: { timestamp: string; id: string }; +}): Record { + if (options.accountIds.length < 1) { + throw new Error('accountIds must not be empty'); + } + const variables: Record = {}; + options.accountIds.forEach((id, index) => { + variables[`account${index}`] = id; + }); + variables.limit = options.limit; + if (options.cursor) { + variables.cursorTimestamp = options.cursor.timestamp; + variables.cursorId = options.cursor.id; + } + return variables; +} diff --git a/src/lib/graphql-benchmark/mobile-account-event-shape.test.ts b/src/lib/graphql-benchmark/mobile-account-event-shape.test.ts new file mode 100644 index 0000000..53ed4c6 --- /dev/null +++ b/src/lib/graphql-benchmark/mobile-account-event-shape.test.ts @@ -0,0 +1,217 @@ +import { print } from 'graphql'; + +import { + AccountEventsAllAfterDocument, + AccountEventsAllDocument, + AccountEventsReceiveDocument, + AccountEventsSendDocument, + ScheduledReversibleAllAfterDocument, + ScheduledReversibleReceiveDocument, + ScheduledReversibleSendDocument +} from './mobile-queries'; +import { mobileGraphqlBenchmarkRegistry } from './mobile-registry'; + +/** + * Shape locked to quantus-apps main @ 11e035a3 + * `ChainHistoryService.buildAccountEventsQuery` / + * `buildScheduledReversibleTransfersQuery`. + * Hasura renders `_in` as `= ANY(array)`, which will not walk the + * `(account_id[, incoming|outgoing], timestamp, id)` index. + */ + +function printed(document: Parameters[0]) { + return print(document); +} + +function squash(source: string) { + return source.replace(/\s+/g, ''); +} + +describe('mobile account-event documents match quantus-apps@11e035a3', () => { + it('uses one _eq alias and the composite order for a single account', () => { + const all = printed(AccountEventsAllDocument); + const flat = squash(all); + + expect(flat).toContain('events0:account_event'); + expect(flat).toContain('$account0:String!'); + expect(flat).toContain('account_id:{_eq:$account0}'); + expect(flat).toContain( + 'order_by:[{account_id:desc},{timestamp:desc},{id:desc}]' + ); + expect(all).not.toContain('$accounts'); + expect(all).not.toContain('_in'); + expect(all).not.toContain('offset'); + expect(all).not.toContain('events1'); + expect(all).toContain('minerReward'); + }); + + it('puts the direction column in send and receive order_by', () => { + const send = printed(AccountEventsSendDocument); + const receive = printed(AccountEventsReceiveDocument); + + expect(squash(send)).toContain('outgoing:{_eq:true}'); + expect(squash(send)).toContain( + 'order_by:[{account_id:desc},{outgoing:desc},{timestamp:desc},{id:desc}]' + ); + expect(send).not.toContain('incoming'); + expect(send).not.toContain('minerReward'); + expect(squash(receive)).toContain('incoming:{_eq:true}'); + expect(squash(receive)).toContain( + 'order_by:[{account_id:desc},{incoming:desc},{timestamp:desc},{id:desc}]' + ); + expect(receive).not.toContain('outgoing'); + expect(receive).toContain('minerReward'); + }); + + it('keeps the keyset predicate on the cursor variant', () => { + const after = squash(printed(AccountEventsAllAfterDocument)); + + expect(after).toContain('$cursorTimestamp:timestamptz!'); + expect(after).toContain('$cursorId:String!'); + expect(after).toContain('timestamp:{_lte:$cursorTimestamp}'); + expect(after).toContain( + '_not:{timestamp:{_eq:$cursorTimestamp},id:{_gte:$cursorId}}' + ); + }); + + it('uses the same per-account shape for scheduled transfers', () => { + const send = squash(printed(ScheduledReversibleSendDocument)); + const receive = squash(printed(ScheduledReversibleReceiveDocument)); + const receiveAfter = squash(printed(ScheduledReversibleAllAfterDocument)); + + expect(send).toContain('account_id:{_eq:$account0}'); + expect(send).toContain('outgoing:{_eq:true}'); + expect(send).toContain( + 'scheduledReversibleTransfer:{scheduled_at:{_gt:$after}}' + ); + expect(send).toContain( + 'order_by:[{account_id:desc},{outgoing:desc},{timestamp:desc},{id:desc}]' + ); + expect(receive).toContain('incoming:{_eq:true}'); + expect(receiveAfter).toContain('$cursorTimestamp:timestamptz!'); + expect(receiveAfter).not.toContain('_in'); + }); + + it('binds account0 for one account and keeps the miner case on that account', () => { + const entry = mobileGraphqlBenchmarkRegistry.find( + (item) => item.name === 'AccountEvents.all' + ); + const miner = mobileGraphqlBenchmarkRegistry.find( + (item) => item.name === 'AccountEvents.all.miner' + ); + + expect( + entry?.getVariables({ + busyAccountId: 'busy' + }) + ).toEqual({ + account0: 'busy', + limit: 21 + }); + expect( + miner?.getVariables({ + busyAccountId: 'busy', + minerAccountId: 'miner' + }) + ).toEqual({ + account0: 'miner', + limit: 21 + }); + }); + + it('exercises multi-account fan-out', () => { + const names = mobileGraphqlBenchmarkRegistry.map((item) => item.name); + + expect(names).toContain('AccountEvents.all.n2'); + expect(names).toContain('AccountEvents.all.n8'); + expect(names).toContain('AccountEvents.send.n2'); + expect(names).toContain('ScheduledReversible.all.n8'); + + const fanout = mobileGraphqlBenchmarkRegistry.find( + (item) => item.name === 'AccountEvents.all.n2' + ); + const flat = fanout ? squash(printed(fanout.document)) : ''; + + expect(flat).toContain('events0:account_event'); + expect(flat).toContain('events1:account_event'); + expect(flat).toContain('$account1:String!'); + expect(flat).not.toContain('events2:'); + expect( + fanout?.getVariables({ + walletAccountIds: ['w0', 'w1', 'w2'] + }) + ).toEqual({ + account0: 'w0', + account1: 'w1', + limit: 21 + }); + expect( + fanout?.getVariables({ + walletAccountIds: ['only-one'] + }) + ).toBeNull(); + + const ids = ['a0', 'a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'a7']; + const send = mobileGraphqlBenchmarkRegistry.find( + (item) => item.name === 'AccountEvents.send.n8' + ); + expect(send?.getVariables({ walletAccountIds: ids })).toEqual({ + account0: 'a0', + account1: 'a1', + account2: 'a2', + account3: 'a3', + account4: 'a4', + account5: 'a5', + account6: 'a6', + account7: 'a7', + limit: 21 + }); + expect(squash(printed(send!.document))).toContain( + 'order_by:[{account_id:desc},{outgoing:desc},{timestamp:desc},{id:desc}]' + ); + expect(squash(printed(send!.document))).not.toContain('events8:'); + + const scheduled = mobileGraphqlBenchmarkRegistry.find( + (item) => item.name === 'ScheduledReversible.all.n2' + ); + const scheduledVars = scheduled?.getVariables({ + walletAccountIds: ['w0', 'w1'] + }); + expect(scheduledVars).toMatchObject({ + account0: 'w0', + account1: 'w1', + limit: 21 + }); + expect(typeof scheduledVars?.after).toBe('string'); + + const after = mobileGraphqlBenchmarkRegistry.find( + (item) => item.name === 'AccountEvents.all.after.n2' + ); + expect( + after?.getVariables({ + walletAccountIds: ['w0', 'w1'], + cursorTimestamp: 't', + cursorId: 'c' + }) + ).toEqual({ + account0: 'w0', + account1: 'w1', + limit: 21, + cursorTimestamp: 't', + cursorId: 'c' + }); + }); + + it('leaves the legacy contrast query on an accounts array', () => { + const legacy = mobileGraphqlBenchmarkRegistry.find( + (item) => item.name === 'LEGACY.AccountEvents.all' + ); + + expect(printed(legacy!.document)).toContain('_in'); + expect(legacy?.getVariables({ busyAccountId: 'busy' })).toEqual({ + accounts: ['busy'], + limit: 21, + offset: 0 + }); + }); +}); diff --git a/src/lib/graphql-benchmark/mobile-bootstrap.test.ts b/src/lib/graphql-benchmark/mobile-bootstrap.test.ts new file mode 100644 index 0000000..fe42abf --- /dev/null +++ b/src/lib/graphql-benchmark/mobile-bootstrap.test.ts @@ -0,0 +1,205 @@ +import { + type ApolloClient, + ApolloError, + type NormalizedCacheObject +} from '@apollo/client'; + +import { loadMobileBenchmarkContext } from './mobile-bootstrap'; + +function clientWith(query: (...args: never[]) => Promise) { + return { query } as unknown as ApolloClient; +} + +describe('loadMobileBenchmarkContext', () => { + it('reports a request failure when the endpoint is unreachable', async () => { + const query = jest.fn(async () => { + throw new ApolloError({ + networkError: new Error('connect ECONNREFUSED 127.0.0.1:1') + }); + }); + + const { context, requestFailures } = await loadMobileBenchmarkContext( + clientWith(query) + ); + + expect(context).toEqual({}); + expect(requestFailures).toEqual([ + 'BusyAccounts: connect ECONNREFUSED 127.0.0.1:1' + ]); + expect(query).toHaveBeenCalledTimes(1); + }); + + it('keeps querying after an HTTP error from a reached endpoint', async () => { + const query = jest.fn(async () => { + throw new ApolloError({ + networkError: Object.assign( + new Error('Response not successful: Received status code 400'), + { statusCode: 400 } + ) + }); + }); + + const { requestFailures } = await loadMobileBenchmarkContext( + clientWith(query) + ); + + expect(query.mock.calls.length).toBeGreaterThan(1); + expect(requestFailures.length).toBe(query.mock.calls.length); + expect(requestFailures[0]).toContain('status code 400'); + }); + + it('keeps an empty successful response as optional data', async () => { + const query = jest.fn(async () => ({ + data: { account_stats: [], account: [], transfer: [] } + })); + + const { context, requestFailures } = await loadMobileBenchmarkContext( + clientWith(query) + ); + + expect(requestFailures).toEqual([]); + expect(context.busyAccountId).toBeUndefined(); + expect(context.accountId).toBeUndefined(); + }); + + it('keeps sample ids when a bootstrap query succeeds', async () => { + let calls = 0; + const query = jest.fn(async () => { + calls += 1; + if (calls === 1) { + return { + data: { + account_stats: [ + { + id: 'qz-busy', + total_immediate_transfers: 9, + total_mined_blocks: 1 + } + ] + } + }; + } + return { data: {} }; + }); + + const { context, requestFailures } = await loadMobileBenchmarkContext( + clientWith(query) + ); + + expect(requestFailures).toEqual([]); + expect(context.busyAccountId).toBe('qz-busy'); + expect(context.accountId).toBe('qz-busy'); + expect(context.walletAccountIds).toEqual(['qz-busy']); + }); + + it('records GraphQL errors and still queries later operations', async () => { + const query = jest.fn(async () => { + throw new ApolloError({ + graphQLErrors: [{ message: 'field "account_stats" not found' }] + }); + }); + + const { context, requestFailures } = await loadMobileBenchmarkContext( + clientWith(query) + ); + + expect(query.mock.calls.length).toBeGreaterThan(1); + expect(context.busyAccountId).toBeUndefined(); + expect(requestFailures[0]).toBe( + 'BusyAccounts: field "account_stats" not found' + ); + expect( + requestFailures.every((failure) => failure.includes('not found')) + ).toBe(true); + }); + + it('records resolved GraphQL errors without dropping returned ids', async () => { + let calls = 0; + const query = jest.fn(async () => { + calls += 1; + if (calls === 1) { + return { + data: { + account_stats: [{ id: 'qz-busy', total_immediate_transfers: 3 }] + }, + errors: [{ message: 'partial account_stats' }] + }; + } + return { data: {} }; + }); + + const { context, requestFailures } = await loadMobileBenchmarkContext( + clientWith(query) + ); + + expect(context.busyAccountId).toBe('qz-busy'); + expect(requestFailures[0]).toBe('BusyAccounts: partial account_stats'); + }); + + it('propagates caller abort and stops querying', async () => { + const controller = new AbortController(); + const query = jest.fn(async () => { + controller.abort(); + throw new Error('The operation was aborted.'); + }); + + await expect( + loadMobileBenchmarkContext(clientWith(query), { + signal: controller.signal, + timeoutMs: 5_000 + }) + ).rejects.toThrow(/aborted/i); + expect(query).toHaveBeenCalledTimes(1); + }); + + it('rejects when the caller already aborted', async () => { + const controller = new AbortController(); + controller.abort(new Error('benchmark aborted')); + const query = jest.fn(async () => ({ data: {} })); + + await expect( + loadMobileBenchmarkContext(clientWith(query), { + signal: controller.signal + }) + ).rejects.toThrow('benchmark aborted'); + expect(query).not.toHaveBeenCalled(); + }); + + it('applies the per-query timeout', async () => { + const query = jest.fn( + (options: { context?: { fetchOptions?: { signal?: AbortSignal } } }) => + new Promise((_resolve, reject) => { + const signal = options.context?.fetchOptions?.signal; + if (!signal) { + reject(new Error('missing abort signal')); + return; + } + if (signal.aborted) { + reject( + new DOMException('The operation was aborted.', 'AbortError') + ); + return; + } + signal.addEventListener('abort', () => { + reject( + new DOMException('The operation was aborted.', 'AbortError') + ); + }); + }) + ); + + const { context, requestFailures } = await loadMobileBenchmarkContext( + clientWith(query as (...args: never[]) => Promise), + { timeoutMs: 20 } + ); + + expect(context).toEqual({}); + expect(requestFailures.length).toBeGreaterThan(1); + expect( + requestFailures.every((failure) => + failure.endsWith('timed out after 20ms') + ) + ).toBe(true); + expect(requestFailures[0]).toBe('BusyAccounts: timed out after 20ms'); + }); +}); diff --git a/src/lib/graphql-benchmark/mobile-bootstrap.ts b/src/lib/graphql-benchmark/mobile-bootstrap.ts new file mode 100644 index 0000000..f1ac4d0 --- /dev/null +++ b/src/lib/graphql-benchmark/mobile-bootstrap.ts @@ -0,0 +1,593 @@ +import { + type ApolloClient, + gql, + type NormalizedCacheObject +} from '@apollo/client'; + +import { MOBILE_HISTORY_ACCOUNT_SAMPLE } from './mobile-account-event-query'; +import { + BENCHMARK_QUERY_TIMEOUT_MS, + createBenchmarkQuerySignal +} from './query-signal'; +import type { GraphqlBenchmarkContext } from './types'; + +const HISTORY_LOOKAHEAD = 21; +const WORMHOLE_PAGE = 300; +const DISCOVERY_BATCH = 20; +const NULLIFIER_BATCH = 300; + +export type MobileBenchmarkLoad = { + context: GraphqlBenchmarkContext; + requestFailures: string[]; +}; + +function fetchSignalContext(signal: AbortSignal) { + return { + context: { + fetchOptions: { signal } as RequestInit + } + }; +} + +function throwIfAborted(signal?: AbortSignal): void { + if (!signal?.aborted) return; + const { reason } = signal; + if (reason instanceof Error) throw reason; + throw new Error( + typeof reason === 'string' && reason.length > 0 + ? reason + : 'benchmark aborted' + ); +} + +function errorMessages(errors: unknown): string | undefined { + if (!Array.isArray(errors) || errors.length === 0) return undefined; + return errors + .map((error) => { + if (typeof error === 'object' && error !== null && 'message' in error) { + const { message } = error as { message?: unknown }; + if (typeof message === 'string' && message.length > 0) return message; + } + return 'GraphQL error'; + }) + .join('; '); +} + +function networkErrorOf(error: unknown): unknown { + if ( + typeof error !== 'object' || + error === null || + !('networkError' in error) + ) { + return undefined; + } + const { networkError } = error as { networkError?: unknown }; + return networkError ?? undefined; +} + +function isHttpStatusError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + typeof (error as { statusCode?: unknown }).statusCode === 'number' + ); +} + +/** Connection failures repeat on every query. HTTP and GraphQL errors do not. */ +function isUnreachable(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false; + if (errorMessages((error as { graphQLErrors?: unknown }).graphQLErrors)) { + return false; + } + const networkError = networkErrorOf(error); + if (networkError) return !isHttpStatusError(networkError); + return error instanceof TypeError; +} + +function requestFailureText( + error: unknown, + timedOut: boolean, + timeoutMs: number +): string { + if (timedOut) return `timed out after ${timeoutMs}ms`; + const graphqlMessage = + typeof error === 'object' && error !== null + ? errorMessages((error as { graphQLErrors?: unknown }).graphQLErrors) + : undefined; + if (graphqlMessage) return graphqlMessage; + const networkError = networkErrorOf(error); + if (networkError instanceof Error && networkError.message) { + return networkError.message; + } + if (error instanceof Error && error.message) return error.message; + return String(error); +} + +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, + options?: { timeoutMs?: number; signal?: AbortSignal } +): Promise { + const ctx: GraphqlBenchmarkContext = {}; + const requestFailures: string[] = []; + const timeoutMs = options?.timeoutMs ?? BENCHMARK_QUERY_TIMEOUT_MS; + const parentSignal = options?.signal; + let stopUnreachable = false; + + async function safeQuery( + label: string, + run: (signal: AbortSignal) => Promise + ): Promise { + throwIfAborted(parentSignal); + if (stopUnreachable) return undefined; + + const timed = createBenchmarkQuerySignal(timeoutMs, parentSignal); + try { + const result = await run(timed.signal); + const resolvedErrors = errorMessages( + (result as { errors?: unknown } | undefined)?.errors + ); + if (resolvedErrors) requestFailures.push(`${label}: ${resolvedErrors}`); + return result; + } catch (error) { + if (parentSignal?.aborted) { + throw error instanceof Error ? error : new Error(String(error)); + } + const timedOut = timed.signal.aborted; + requestFailures.push( + `${label}: ${requestFailureText(error, timedOut, timeoutMs)}` + ); + if (!timedOut && isUnreachable(error)) stopUnreachable = true; + return undefined; + } finally { + timed.cleanup(); + } + } + + const busy = await safeQuery('BusyAccounts', (signal) => + client.query({ + query: gql` + query BusyAccounts($limit: Int!) { + account_stats( + limit: $limit + order_by: { total_immediate_transfers: desc } + ) { + id + total_immediate_transfers + total_mined_blocks + } + } + `, + variables: { limit: MOBILE_HISTORY_ACCOUNT_SAMPLE }, + ...fetchSignalContext(signal) + }) + ); + const busyRows = (busy?.data?.account_stats ?? []) as Array<{ + id?: string; + total_immediate_transfers?: unknown; + }>; + const walletAccountIds = busyRows + .map((row) => row.id) + .filter((id): id is string => Boolean(id)); + const busiest = busyRows.find((row) => row.id); + if (busiest?.id) { + ctx.walletAccountIds = walletAccountIds; + ctx.busyAccountId = busiest.id; + ctx.busyImmediateTransfers = asNumber(busiest.total_immediate_transfers); + ctx.accountId = busiest.id; + } + + const miner = await safeQuery('MinerAccount', (signal) => + client.query({ + query: gql` + query MinerAccount { + account_stats(limit: 1, order_by: { total_mined_blocks: desc }) { + id + total_mined_blocks + } + } + `, + ...fetchSignalContext(signal) + }) + ); + 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('DiscoveryIds', (signal) => + client.query({ + query: gql` + query DiscoveryIds($limit: Int!) { + account(limit: $limit, order_by: { id: desc }) { + id + } + } + `, + variables: { limit: DISCOVERY_BATCH }, + ...fetchSignalContext(signal) + }) + ); + 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('HistoryCursor', (signal) => + 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 }, + ...fetchSignalContext(signal) + }) + ); + 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('HistoryDeepCursor', (signal) => + 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] }, + ...fetchSignalContext(signal) + }) + ); + 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('MinerHistoryCursor', (signal) => + 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 + }, + ...fetchSignalContext(signal) + }) + ); + 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('SampleTransfer', (signal) => + 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 + } + } + } + `, + ...fetchSignalContext(signal) + }) + ); + 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('SampleScheduled', (signal) => + client.query({ + query: gql` + query SampleScheduled { + scheduled_reversible_transfer( + limit: 1 + order_by: { timestamp: desc } + ) { + from { + id + } + to { + id + } + amount + block { + height + } + extrinsic { + id + } + } + } + `, + ...fetchSignalContext(signal) + }) + ); + 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('SampleExecuted', (signal) => + client.query({ + query: gql` + query SampleExecuted { + executed_reversible_transfer( + limit: 1 + order_by: { timestamp: desc } + ) { + tx_id + } + } + `, + ...fetchSignalContext(signal) + }) + ); + ctx.executedTxId = asString( + executed?.data?.executed_reversible_transfer?.[0]?.tx_id + ); + + const wormhole = await safeQuery('SampleWormholeRecipient', (signal) => + client.query({ + query: gql` + query SampleWormholeRecipient { + transfer( + limit: 1 + where: { leaf_index: { _gt: "0" } } + order_by: { transfer_count: desc } + ) { + to_id + } + } + `, + ...fetchSignalContext(signal) + }) + ); + ctx.wormholeToId = asString(wormhole?.data?.transfer?.[0]?.to_id); + + if (ctx.wormholeToId) { + const page = await safeQuery('WormholeCursor', (signal) => + 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 }, + ...fetchSignalContext(signal) + }) + ); + 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('SampleNullifiers', (signal) => + client.query({ + query: gql` + query SampleNullifiers($limit: Int!) { + wormhole_nullifier(limit: $limit, order_by: { timestamp: desc }) { + nullifier_hash + } + } + `, + variables: { limit: NULLIFIER_BATCH }, + ...fetchSignalContext(signal) + }) + ); + 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('SampleMultisig', (signal) => + client.query({ + query: gql` + query SampleMultisig { + multisig(limit: 1, order_by: { timestamp: desc }) { + id + signers + } + } + `, + ...fetchSignalContext(signal) + }) + ); + 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('SampleProposal', (signal) => + client.query({ + query: gql` + query SampleProposal { + multisig_proposal(limit: 1, order_by: { updated_at: desc }) { + proposal_id + multisig_id + } + } + `, + ...fetchSignalContext(signal) + }) + ); + 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('SampleProposalCreated', (signal) => + client.query({ + query: gql` + query SampleProposalCreated { + multisig_proposal_created(limit: 1, order_by: { timestamp: desc }) { + extrinsic { + id + } + } + } + `, + ...fetchSignalContext(signal) + }) + ); + ctx.proposalCreatedHash = asString( + created?.data?.multisig_proposal_created?.[0]?.extrinsic?.id + ); + + const approved = await safeQuery('SampleSignerApproved', (signal) => + client.query({ + query: gql` + query SampleSignerApproved { + multisig_signer_approved(limit: 1, order_by: { timestamp: desc }) { + extrinsic { + id + } + } + } + `, + ...fetchSignalContext(signal) + }) + ); + ctx.signerApprovedHash = asString( + approved?.data?.multisig_signer_approved?.[0]?.extrinsic?.id + ); + + const executedMs = await safeQuery('SampleProposalExecuted', (signal) => + client.query({ + query: gql` + query SampleProposalExecuted { + executed_multisig_proposal(limit: 1, order_by: { timestamp: desc }) { + extrinsic { + id + } + } + } + `, + ...fetchSignalContext(signal) + }) + ); + ctx.executedProposalHash = asString( + executedMs?.data?.executed_multisig_proposal?.[0]?.extrinsic?.id + ); + + const cancelled = await safeQuery('SampleProposalCancelled', (signal) => + client.query({ + query: gql` + query SampleProposalCancelled { + cancelled_multisig_proposal(limit: 1, order_by: { timestamp: desc }) { + extrinsic { + id + } + } + } + `, + ...fetchSignalContext(signal) + }) + ); + ctx.cancelledProposalHash = asString( + cancelled?.data?.cancelled_multisig_proposal?.[0]?.extrinsic?.id + ); + + return { context: ctx, requestFailures }; +} diff --git a/src/lib/graphql-benchmark/mobile-queries.ts b/src/lib/graphql-benchmark/mobile-queries.ts new file mode 100644 index 0000000..5aa6f30 --- /dev/null +++ b/src/lib/graphql-benchmark/mobile-queries.ts @@ -0,0 +1,776 @@ +import { gql } from '@apollo/client'; + +import { + buildAccountEventsQuery, + buildScheduledReversibleTransfersQuery +} from './mobile-account-event-query'; + +/** 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 SCHEDULED_REVERSIBLE_SELECTION = ` + id + timestamp + scheduledReversibleTransfer { + id + amount + timestamp + from { id } + to { id } + txId: tx_id + scheduledAt: scheduled_at + block { height hash } + extrinsic { id } + }`; + +export function accountEventsDocument( + filter: 'all' | 'send' | 'receive', + withCursor: boolean, + accountCount: number +) { + const minerReward = filter === 'send' ? '' : MINER_REWARD_FIELD; + return gql( + buildAccountEventsQuery({ + filter, + withCursor, + accountCount, + selection: `${ACCOUNT_EVENT_CORE}${minerReward}${MULTISIG_ACCOUNT_EVENT_FIELDS}` + }) + ); +} + +export function scheduledReversibleDocument( + filter: 'all' | 'send' | 'receive', + withCursor: boolean, + accountCount: number +) { + return gql( + buildScheduledReversibleTransfersQuery({ + filter, + withCursor, + accountCount, + selection: SCHEDULED_REVERSIBLE_SELECTION + }) + ); +} + +export const AccountsQueryDocument = gql` + query AccountsQuery($ids: [String!]) { + accounts: account(where: { id: { _in: $ids } }) { + id + } + } +`; + +export const AccountEventsAllDocument = accountEventsDocument('all', false, 1); +export const AccountEventsSendDocument = accountEventsDocument( + 'send', + false, + 1 +); +export const AccountEventsReceiveDocument = accountEventsDocument( + 'receive', + false, + 1 +); +export const AccountEventsAllAfterDocument = accountEventsDocument( + 'all', + true, + 1 +); +export const AccountEventsSendAfterDocument = accountEventsDocument( + 'send', + true, + 1 +); +export const AccountEventsReceiveAfterDocument = accountEventsDocument( + 'receive', + true, + 1 +); + +export const ScheduledReversibleAllDocument = scheduledReversibleDocument( + 'all', + false, + 1 +); +export const ScheduledReversibleSendDocument = scheduledReversibleDocument( + 'send', + false, + 1 +); +export const ScheduledReversibleReceiveDocument = scheduledReversibleDocument( + 'receive', + false, + 1 +); +export const ScheduledReversibleAllAfterDocument = scheduledReversibleDocument( + 'all', + true, + 1 +); + +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..ee67210 --- /dev/null +++ b/src/lib/graphql-benchmark/mobile-registry.ts @@ -0,0 +1,549 @@ +import { + accountEventPageVariables, + MOBILE_HISTORY_FANOUT_COUNTS +} from './mobile-account-event-query'; +import { + AccountEventsAllAfterDocument, + AccountEventsAllDocument, + accountEventsDocument, + AccountEventsReceiveAfterDocument, + AccountEventsReceiveDocument, + AccountEventsSendAfterDocument, + AccountEventsSendDocument, + AccountsQueryDocument, + DiscoverMultisigsDocument, + ExecutedReversibleTransferByTxIdDocument, + LegacyAccountEventsAllDocument, + LegacyAccountEventsSendDocument, + LegacyTransfersToAddressesDocument, + MultisigByPkDocument, + MultisigOpenProposalsDocument, + MultisigPastProposalsDocument, + MultisigProposalDocument, + ScheduledReversibleAllAfterDocument, + ScheduledReversibleAllDocument, + scheduledReversibleDocument, + ScheduledReversibleReceiveDocument, + ScheduledReversibleSendDocument, + SearchByExtrinsicHashReversibleDocument, + SearchByExtrinsicHashTransferDocument, + SearchCancelledByExtrinsicHashDocument, + SearchExecutedByExtrinsicHashDocument, + SearchPendingReversibleDocument, + SearchPendingTransferDocument, + SearchPendingTransferScalarsDocument, + SearchProposalCreatedByExtrinsicHashDocument, + SearchSignerApprovedByExtrinsicHashDocument, + SpentNullifiersDocument, + TestnetStatsDocument, + TransfersToAddressesAfterDocument, + TransfersToAddressesDocument +} from './mobile-queries'; +import type { + GraphqlBenchmarkContext, + GraphqlBenchmarkRegistryEntry +} from './types'; + +const HISTORY_LIMIT = 21; +const WORMHOLE_LIMIT = 300; + +function pendingSinceIso() { + return new Date(Date.now() - 2 * 60 * 1000).toISOString(); +} + +function oneAccount(accountId: string | undefined): string[] | undefined { + return accountId ? [accountId] : undefined; +} + +function historyVars( + accountIds: string[] | undefined, + cursor?: { timestamp?: string; id?: string } +): Record | null { + if (!accountIds?.length) return null; + const timestamp = cursor?.timestamp; + const id = cursor?.id; + return accountEventPageVariables({ + accountIds, + limit: HISTORY_LIMIT, + ...(timestamp && id ? { cursor: { timestamp, id } } : {}) + }); +} + +function scheduledVars( + accountIds: string[] | undefined, + cursor?: { timestamp?: string; id?: string } +): Record | null { + const base = historyVars(accountIds, cursor); + if (!base) return null; + return { ...base, after: pendingSinceIso() }; +} + +type HistoryFilter = 'all' | 'send' | 'receive'; + +const FANOUT_SPECS: Array<{ + name: string; + filter: HistoryFilter; + withCursor: boolean; + scheduled: boolean; +}> = [ + { + name: 'AccountEvents.all', + filter: 'all', + withCursor: false, + scheduled: false + }, + { + name: 'AccountEvents.send', + filter: 'send', + withCursor: false, + scheduled: false + }, + { + name: 'AccountEvents.receive', + filter: 'receive', + withCursor: false, + scheduled: false + }, + { + name: 'AccountEvents.all.after', + filter: 'all', + withCursor: true, + scheduled: false + }, + { + name: 'ScheduledReversible.all', + filter: 'all', + withCursor: false, + scheduled: true + }, + { + name: 'ScheduledReversible.send', + filter: 'send', + withCursor: false, + scheduled: true + }, + { + name: 'ScheduledReversible.receive', + filter: 'receive', + withCursor: false, + scheduled: true + }, + { + name: 'ScheduledReversible.all.after', + filter: 'all', + withCursor: true, + scheduled: true + } +]; + +function fanoutHistoryEntries(): GraphqlBenchmarkRegistryEntry[] { + return MOBILE_HISTORY_FANOUT_COUNTS.flatMap((count) => + FANOUT_SPECS.map((spec) => { + const document = spec.scheduled + ? scheduledReversibleDocument(spec.filter, spec.withCursor, count) + : accountEventsDocument(spec.filter, spec.withCursor, count); + return entry(`${spec.name}.n${count}`, 'history', document, (ctx) => { + const ids = ctx.walletAccountIds; + if (!ids || ids.length < count) return null; + const accounts = ids.slice(0, count); + const cursor = spec.withCursor + ? { timestamp: ctx.cursorTimestamp, id: ctx.cursorId } + : undefined; + return spec.scheduled + ? scheduledVars(accounts, cursor) + : historyVars(accounts, cursor); + }); + }) + ); +} + +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(oneAccount(ctx.busyAccountId)) + ), + entry('AccountEvents.send', 'history', AccountEventsSendDocument, (ctx) => + historyVars(oneAccount(ctx.busyAccountId)) + ), + entry( + 'AccountEvents.receive', + 'history', + AccountEventsReceiveDocument, + (ctx) => historyVars(oneAccount(ctx.busyAccountId)) + ), + entry( + 'AccountEvents.all.after', + 'history', + AccountEventsAllAfterDocument, + (ctx) => + historyVars(oneAccount(ctx.busyAccountId), { + timestamp: ctx.cursorTimestamp, + id: ctx.cursorId + }) + ), + entry( + 'AccountEvents.send.after', + 'history', + AccountEventsSendAfterDocument, + (ctx) => + historyVars(oneAccount(ctx.busyAccountId), { + timestamp: ctx.cursorTimestamp, + id: ctx.cursorId + }) + ), + entry( + 'AccountEvents.receive.after', + 'history', + AccountEventsReceiveAfterDocument, + (ctx) => + historyVars(oneAccount(ctx.busyAccountId), { + timestamp: ctx.cursorTimestamp, + id: ctx.cursorId + }) + ), + entry('AccountEvents.all.miner', 'history', AccountEventsAllDocument, (ctx) => + historyVars(oneAccount(ctx.minerAccountId)) + ), + entry( + 'AccountEvents.receive.miner', + 'history', + AccountEventsReceiveDocument, + (ctx) => historyVars(oneAccount(ctx.minerAccountId)) + ), + entry( + 'AccountEvents.all.miner.after', + 'history', + AccountEventsAllAfterDocument, + (ctx) => + historyVars(oneAccount(ctx.minerAccountId), { + timestamp: ctx.minerCursorTimestamp, + id: ctx.minerCursorId + }) + ), + entry( + 'AccountEvents.all.deep', + 'history', + AccountEventsAllAfterDocument, + (ctx) => + historyVars(oneAccount(ctx.busyAccountId), { + timestamp: ctx.deepCursorTimestamp, + id: ctx.deepCursorId + }) + ), + + entry( + 'ScheduledReversible.all', + 'history', + ScheduledReversibleAllDocument, + (ctx) => scheduledVars(oneAccount(ctx.busyAccountId)) + ), + entry( + 'ScheduledReversible.send', + 'history', + ScheduledReversibleSendDocument, + (ctx) => scheduledVars(oneAccount(ctx.busyAccountId)) + ), + entry( + 'ScheduledReversible.receive', + 'history', + ScheduledReversibleReceiveDocument, + (ctx) => scheduledVars(oneAccount(ctx.busyAccountId)) + ), + entry( + 'ScheduledReversible.all.after', + 'history', + ScheduledReversibleAllAfterDocument, + (ctx) => + scheduledVars(oneAccount(ctx.busyAccountId), { + timestamp: ctx.cursorTimestamp, + id: ctx.cursorId + }) + ), + + ...fanoutHistoryEntries(), + + 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/query-signal.ts b/src/lib/graphql-benchmark/query-signal.ts new file mode 100644 index 0000000..58da412 --- /dev/null +++ b/src/lib/graphql-benchmark/query-signal.ts @@ -0,0 +1,18 @@ +export const BENCHMARK_QUERY_TIMEOUT_MS = 30_000; + +export function createBenchmarkQuerySignal( + 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); + } + }; +} diff --git a/src/lib/graphql-benchmark/row-count.test.ts b/src/lib/graphql-benchmark/row-count.test.ts new file mode 100644 index 0000000..6fb2f8f --- /dev/null +++ b/src/lib/graphql-benchmark/row-count.test.ts @@ -0,0 +1,25 @@ +import { benchmarkRowCount } from './row-count'; + +describe('benchmarkRowCount', () => { + it('returns undefined when the payload has no arrays', () => { + expect(benchmarkRowCount(undefined)).toBeUndefined(); + expect(benchmarkRowCount(null)).toBeUndefined(); + expect(benchmarkRowCount({ id: 'x' })).toBeUndefined(); + }); + + it('counts the first top-level array for a single selection', () => { + expect(benchmarkRowCount({ accountEvents: [1, 2], meta: { n: 1 } })).toBe( + 2 + ); + }); + + it('sums every account alias', () => { + expect( + benchmarkRowCount({ + events0: [1], + events1: [2, 3], + events2: [] + }) + ).toBe(3); + }); +}); diff --git a/src/lib/graphql-benchmark/row-count.ts b/src/lib/graphql-benchmark/row-count.ts new file mode 100644 index 0000000..1c0a1a3 --- /dev/null +++ b/src/lib/graphql-benchmark/row-count.ts @@ -0,0 +1,22 @@ +/** Row count for a benchmark response. + * + * History documents return one array per account alias (`events0`..). Those + * are summed. Every other operation keeps the first top-level array. + */ +export function benchmarkRowCount(data: unknown): number | undefined { + if (!data || typeof data !== 'object') return undefined; + const record = data as Record; + const aliased = Object.entries(record).filter( + ([key, value]) => /^events\d+$/.test(key) && Array.isArray(value) + ); + if (aliased.length > 0) { + return aliased.reduce( + (sum, [, value]) => sum + (value as unknown[]).length, + 0 + ); + } + const firstArray = Object.values(record).find((value) => + Array.isArray(value) + ); + return Array.isArray(firstArray) ? firstArray.length : undefined; +} diff --git a/src/lib/graphql-benchmark/run.ts b/src/lib/graphql-benchmark/run.ts index 2f8f09a..cec82a7 100644 --- a/src/lib/graphql-benchmark/run.ts +++ b/src/lib/graphql-benchmark/run.ts @@ -6,8 +6,20 @@ import { } from '@apollo/client'; import { loadGraphqlBenchmarkContext } from './bootstrap'; +import { loadMobileBenchmarkContext } from './mobile-bootstrap'; +import { mobileGraphqlBenchmarkRegistry } from './mobile-registry'; +import { + BENCHMARK_QUERY_TIMEOUT_MS, + createBenchmarkQuerySignal +} from './query-signal'; import { graphqlBenchmarkRegistry } from './registry'; -import type { GraphqlBenchmarkContext, GraphqlBenchmarkRow } from './types'; +import { benchmarkRowCount } from './row-count'; +import type { + GraphqlBenchmarkContext, + GraphqlBenchmarkRegistryEntry, + GraphqlBenchmarkRow, + GraphqlBenchmarkSuite +} from './types'; export function createBenchmarkApolloClient(uri: string) { return new ApolloClient({ @@ -29,31 +41,70 @@ function responseByteLength(data: unknown): number { } } +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]!; +} + export async function runGraphqlBenchmarks(options: { endpoint: string; + suite?: GraphqlBenchmarkSuite; + samples?: number; + warmup?: boolean; + timeoutMs?: number; signal?: AbortSignal; onProgress?: (name: string) => void; }): Promise<{ bootstrapContext: GraphqlBenchmarkContext; + bootstrapRequestFailures: string[]; results: GraphqlBenchmarkRow[]; }> { - const { endpoint, signal, onProgress } = options; + const { + endpoint, + suite = 'explorer', + samples = 1, + warmup = samples > 1, + timeoutMs = BENCHMARK_QUERY_TIMEOUT_MS, + signal, + onProgress + } = options; const client = createBenchmarkApolloClient(endpoint); + const registry: GraphqlBenchmarkRegistryEntry[] = + suite === 'mobile' + ? mobileGraphqlBenchmarkRegistry + : graphqlBenchmarkRegistry; - const bootstrapContext = await loadGraphqlBenchmarkContext(client); + let bootstrapContext: GraphqlBenchmarkContext; + let bootstrapRequestFailures: string[] = []; + if (suite === 'mobile') { + const loaded = await loadMobileBenchmarkContext(client, { + timeoutMs, + signal + }); + bootstrapContext = loaded.context; + bootstrapRequestFailures = loaded.requestFailures; + } else { + 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) { + 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,34 +112,60 @@ 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 = createBenchmarkQuerySignal(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 = benchmarkRowCount(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 */ results.sort((a, b) => b.durationMs - a.durationMs); - return { bootstrapContext, results }; + return { bootstrapContext, bootstrapRequestFailures, results }; } diff --git a/src/lib/graphql-benchmark/suite-failure.test.ts b/src/lib/graphql-benchmark/suite-failure.test.ts new file mode 100644 index 0000000..e230250 --- /dev/null +++ b/src/lib/graphql-benchmark/suite-failure.test.ts @@ -0,0 +1,36 @@ +import { graphqlBenchmarkRunFailed } from './suite-failure'; + +describe('graphqlBenchmarkRunFailed', () => { + it('fails when bootstrap request failures leave every operation skipped', () => { + expect( + graphqlBenchmarkRunFailed( + [{ skipped: true }, { skipped: true, errorMessage: 'hidden' }], + ['BusyAccounts: connect ECONNREFUSED 127.0.0.1:1'] + ) + ).toBe(true); + }); + + it('does not fail when every operation is skipped for missing optional data', () => { + expect( + graphqlBenchmarkRunFailed([{ skipped: true }, { skipped: true }], []) + ).toBe(false); + }); + + it('does not fail a suite that ran when an optional bootstrap query failed', () => { + expect( + graphqlBenchmarkRunFailed( + [{ skipped: true }, { skipped: false }], + ['SampleNullifiers: field "wormhole_nullifier" not found'] + ) + ).toBe(false); + }); + + it('fails when an executed operation reports an error', () => { + expect( + graphqlBenchmarkRunFailed( + [{ skipped: true }, { errorMessage: 'timed out after 30000ms' }], + [] + ) + ).toBe(true); + }); +}); diff --git a/src/lib/graphql-benchmark/suite-failure.ts b/src/lib/graphql-benchmark/suite-failure.ts new file mode 100644 index 0000000..6ff4fe2 --- /dev/null +++ b/src/lib/graphql-benchmark/suite-failure.ts @@ -0,0 +1,15 @@ +import type { GraphqlBenchmarkRow } from './types'; + +/** + * Executed operation errors fail the run. + * Bootstrap request failures fail the run only when every operation was skipped. + * Skips caused by empty optional data do not. + */ +export function graphqlBenchmarkRunFailed( + results: readonly Pick[], + bootstrapRequestFailures: readonly string[] +): boolean { + const ran = results.some((row) => !row.skipped); + if (!ran && bootstrapRequestFailures.length > 0) return true; + return results.some((row) => !row.skipped && Boolean(row.errorMessage)); +} diff --git a/src/lib/graphql-benchmark/types.ts b/src/lib/graphql-benchmark/types.ts index 8d73c54..0a424e6 100644 --- a/src/lib/graphql-benchmark/types.ts +++ b/src/lib/graphql-benchmark/types.ts @@ -13,11 +13,49 @@ export type GraphqlBenchmarkContext = { errorExtrinsicHash?: string; highSecurityExtrinsicHash?: string; minerBlockHash?: string; + /** Account with the most immediate transfers (wallet-history worst case). */ + busyAccountId?: string; + busyImmediateTransfers?: number; + /** Busiest accounts, longest history first, for multi-account fan-out. */ + walletAccountIds?: string[]; + /** 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 +64,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..6927eb9 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,12 +79,20 @@ function GraphqlBenchmarkPage() { setProgress(null); setRows(null); setLastEndpoint(networkUrl); + setLastSuite(suite); try { - const { results } = await runGraphqlBenchmarks({ + const { results, bootstrapRequestFailures } = await runGraphqlBenchmarks({ endpoint: networkUrl, + suite, + samples: suite === 'mobile' ? 5 : 1, onProgress: (name) => setProgress(name) }); setRows(results); + if (bootstrapRequestFailures.length > 0) { + setError( + `Bootstrap request failed: ${bootstrapRequestFailures.join('; ')}` + ); + } } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { @@ -86,6 +108,7 @@ function GraphqlBenchmarkPage() { JSON.stringify( { endpoint: lastEndpoint, + suite: lastSuite, at: new Date().toISOString(), results: rows }, @@ -113,20 +136,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.' + : ''}

+ +