Files
WorthPath/apps/api/scripts/performance.ts
T

254 lines
9.1 KiB
TypeScript

import 'dotenv/config';
import { PrismaClient, Prisma } from '@prisma/client';
import { randomUUID, randomBytes, createHash } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import { history, overview, totals, trend } from '../src/calculation';
import { currentPositions, historyPage, trendData, currentRates } from '../src/queries';
import { today, toBusinessDate } from '../src/validation';
import { writeFileSync } from 'node:fs';
// Dedicated random users only; no existing user's rows are read or changed.
const phase = process.argv[2] || 'after';
const db = new PrismaClient({
transactionOptions: { timeout: 120000, maxWait: 10000 },
log: [{ emit: 'event', level: 'query' }],
});
let events: { query: string; params: string; duration: number }[] = [];
db.$on('query', (e) => events.push(e));
const results: unknown[] = [];
async function measure(name: string, work: () => Promise<unknown>) {
console.log('Measuring ' + name);
const times: number[] = [];
let bytes = 0,
queries = 0;
for (let i = 0; i < 3; i++) {
events = [];
const start = performance.now();
const value = await work();
times.push(performance.now() - start);
bytes = Buffer.byteLength(JSON.stringify(value));
queries = events.length;
}
return { name, medianMs: Number(times.sort((a, b) => a - b)[1].toFixed(2)), bytes, queries };
}
async function plan(event: (typeof events)[number]) {
const params = JSON.parse(event.params).map((p: unknown) =>
typeof p === 'string' && /^\d{4}-\d\d-\d\d[ T]\d\d:/.test(p) ? new Date(p) : p,
);
try {
return await db.$queryRawUnsafe('EXPLAIN ANALYZE ' + event.query, ...params);
} catch {
return await db.$queryRawUnsafe('EXPLAIN ' + event.query, ...params);
}
}
async function run() {
for (const [accounts, count] of [
[1, 10000],
[1, 100000],
[10, 10000],
]) {
const user = await db.user.create({
data: { username: 'wp_perf_' + randomUUID(), passwordHash: 'not-a-login-hash' },
});
try {
const ids: string[] = [];
const end = today(),
start = new Date(+new Date(end) - 89 * 86400000).toISOString().slice(0, 10);
for (let a = 0; a < accounts; a++) {
const p = await db.position.create({
data: {
userId: user.id,
kind: 'account',
side: 'asset',
name: 'benchmark',
category: 'bank',
currency: 'CNY',
notes: '',
},
});
ids.push(p.id);
for (let offset = 0; offset < count; offset += 1000) {
await db.revision.createMany({
data: Array.from({ length: Math.min(1000, count - offset) }, (_, n) => ({
positionId: p.id,
amount: String(offset + n + 1),
notes: '',
reason: 'balance',
effectiveDate: new Date(
+toBusinessDate(start) + Math.floor(((offset + n) * 89) / (count - 1)) * 86400000,
),
})),
});
}
}
const token = randomBytes(32).toString('hex');
await db.session.create({
data: {
id: createHash('sha256').update(token).digest('hex'),
userId: user.id,
expiresAt: new Date(Date.now() + 3600000),
},
});
const httpBase = process.env.TEST_API_URL || 'http://127.0.0.1:33101/api';
const http = async (path: string, base = httpBase) => {
const response = await fetch(base + path, { headers: { Cookie: 'wp_session=' + token } });
if (!response.ok) throw Error('HTTP benchmark status ' + response.status);
return response.json();
};
const oldList = () =>
db.$transaction(async (tx) => {
const rows = await tx.position.findMany({
where: { userId: user.id },
include: {
revisions: { orderBy: [{ effectiveDate: 'asc' }, { sequence: 'asc' }] },
outgoing: true,
},
});
return rows.map((p) => ({
...p,
userId: undefined,
amount: p.revisions.at(-1)?.amount.toString() || '0',
history: history(p),
}));
});
const oldOverview = () =>
db.$transaction(
async (tx) => {
const rows = await tx.position.findMany({
where: { userId: user.id },
include: { revisions: true },
});
return overview(rows, [], 'CNY', end);
},
{ timeout: 120000 },
);
const baseline = [
await measure('old positions', oldList),
await measure('old overview', oldOverview),
];
const optimized = [
await measure('new positions', () =>
db.$transaction(async (tx) => {
const rows = await currentPositions(tx, user.id, false);
const rates = await currentRates(tx, user.id, ['CNY'], 'CNY', end);
const amounts = new Map(totals(rows, rates, 'CNY', end).items.map((p) => [p.id, p]));
return rows.map(({ revisions, userId, ...p }) => ({
...p,
amount: revisions[0]?.amount.toString() || '0',
converted: amounts.get(p.id)!.converted,
rateDate: amounts.get(p.id)!.rateDate,
}));
}),
),
await measure('new current overview', () =>
db.$transaction(async (tx) =>
totals(await currentPositions(tx, user.id, false), [], 'CNY', end),
),
),
await measure('new history page', () =>
db.$transaction((tx) => historyPage(tx, user.id, false, { limit: 50 })),
),
await measure('new trend', () =>
db.$transaction(
async (tx) => {
const data = await trendData(tx, user.id, false, start, end);
return trend(data.positions, data.rates, 'CNY', start, end);
},
{ timeout: 30000 },
),
),
];
events = [];
await db.position.findMany({
where: { userId: user.id },
include: {
revisions: { orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }], take: 1 },
},
});
const nestedSql = events.filter((e) => e.query.startsWith('SELECT')).map((e) => e.query);
events = [];
await currentPositions(db, user.id, false);
const latestEvent = events.find((e) => e.query.includes('SELECT r.*'))!;
const latestPlan = await plan(latestEvent);
events = [];
await historyPage(db, user.id, false, { limit: 50 });
const pageEvent = events.find((e) => e.query.includes('AS beforeAmount'))!;
const candidateEvents = events.filter(
(e) =>
e.query.includes('Revision') &&
!e.query.includes('beforeAmount') &&
e.query.startsWith('SELECT'),
);
const pagePlan = await plan(pageEvent);
const candidatePlans = await Promise.all(candidateEvents.map(plan));
const first = await historyPage(db, user.id, false, { limit: 50 });
const second = await historyPage(db, user.id, false, { limit: 50, cursor: first.nextCursor });
const deep = await historyPage(db, user.id, false, { limit: 50, to: start });
if (
first.items.some((a) => second.items.some((b) => a.id === b.id)) ||
deep.items.some((r) => r.delta !== '1')
)
throw Error('Pagination boundary mismatch');
const httpResults = [
await measure('HTTP positions', () => http('/positions?kind=account')),
await measure('HTTP overview', () => http('/overview')),
await measure('HTTP history', () => http('/history?limit=50')),
await measure('HTTP trend', () => http('/trend?from=' + start + '&to=' + end)),
];
const oldHttpBase = process.env.BASELINE_API_URL;
const baselineHttp = oldHttpBase
? [
await measure('old HTTP positions', () => http('/positions', oldHttpBase)),
await measure('old HTTP overview', () => http('/overview', oldHttpBase)),
]
: [];
results.push({
accounts,
revisionsPerAccount: count,
baseline,
optimized,
httpResults,
baselineHttp,
nestedSql,
latestPlan,
pagePlan,
candidatePlans,
});
writeFileSync(
`../../docs/performance-${phase}.json`,
JSON.stringify(
{ phase, node: process.version, results },
(_k, v) => (typeof v === 'bigint' ? v.toString() : v),
2,
),
);
console.log(
JSON.stringify({ phase, accounts, count, baseline, optimized, httpResults, baselineHttp }),
);
} finally {
await db.user.delete({ where: { id: user.id } });
}
}
writeFileSync(
`../../docs/performance-${phase}.json`,
JSON.stringify(
{ phase, node: process.version, results },
(_k, v) => (typeof v === 'bigint' ? v.toString() : v),
2,
),
);
}
run()
.catch((e) => {
console.error(
'Benchmark failed: ' +
(e.code || e.constructor.name) +
' ' +
String(e.message)
.replace(/mysql:\/\/[^\s]+/g, '[redacted]')
.slice(-600),
);
process.exitCode = 1;
})
.finally(() => db.$disconnect());