Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 51 additions & 9 deletions scripts/graphql-bench.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down
5 changes: 4 additions & 1 deletion src/lib/graphql-benchmark/index.ts
Original file line number Diff line number Diff line change
@@ -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';
155 changes: 155 additions & 0 deletions src/lib/graphql-benchmark/mobile-account-event-query.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
147 changes: 147 additions & 0 deletions src/lib/graphql-benchmark/mobile-account-event-query.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
if (options.accountIds.length < 1) {
throw new Error('accountIds must not be empty');
}
const variables: Record<string, unknown> = {};
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;
}
Loading
Loading