|
#!/usr/bin/env node |
|
|
|
const amqp = require('amqplib'); |
|
const Redis = require('ioredis'); |
|
const SuperJSON = require('superjson').default; |
|
const { randomUUID } = require('crypto'); |
|
|
|
const count = Number.parseInt(process.argv[2] || '1000', 10); |
|
const rabbitUrl = |
|
process.env.BENCH_RABBIT_URL || |
|
'amqp://guest:guest@localhost:5672'; |
|
const conductorUrl = |
|
process.env.BENCH_CONDUCTOR_URL || 'http://host.docker.internal:3002'; |
|
const redisHost = process.env.BENCH_REDIS_HOST || 'host.docker.internal'; |
|
const redisPort = Number.parseInt(process.env.BENCH_REDIS_PORT || '6381', 10); |
|
const exchange = 'matching:commands'; |
|
const queue = 'matching:commands.p0'; |
|
const eventExchanges = [ |
|
'messages:order:create', |
|
'messages:order:delete', |
|
'messages:deal:create', |
|
]; |
|
|
|
if (!Number.isInteger(count) || count <= 0 || count % 2 !== 0) { |
|
throw new Error('Command count must be a positive even integer'); |
|
} |
|
|
|
const percentile = (sorted, fraction) => |
|
sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * fraction))]; |
|
|
|
async function matchingKeys(redis) { |
|
let cursor = '0'; |
|
const keys = []; |
|
do { |
|
const response = await redis.scan( |
|
cursor, |
|
'MATCH', |
|
'matching:*', |
|
'COUNT', |
|
1000, |
|
); |
|
cursor = response[0]; |
|
keys.push(...response[1]); |
|
} while (cursor !== '0'); |
|
return keys; |
|
} |
|
|
|
function command(index, submittedAt) { |
|
const commandId = randomUUID(); |
|
const side = index % 2 === 0 ? 'bid' : 'ask'; |
|
return { |
|
schemaVersion: 1, |
|
commandId, |
|
correlationId: commandId, |
|
type: 'order.place', |
|
submittedAt, |
|
pairId: 1, |
|
partition: 0, |
|
payload: { |
|
id: `perf-${commandId}`, |
|
userId: 1, |
|
quoteVolume: '0', |
|
quoteDecimals: 8, |
|
amountDecimals: 4, |
|
priceDecimals: 2, |
|
side, |
|
type: 'limit', |
|
pairId: 1, |
|
timestamp: Date.now() + index, |
|
price: '100', |
|
role: 'taker', |
|
volume: '1', |
|
volumeTotal: '1', |
|
}, |
|
}; |
|
} |
|
|
|
async function main() { |
|
const redis = new Redis({ |
|
host: redisHost, |
|
port: redisPort, |
|
maxRetriesPerRequest: 2, |
|
}); |
|
const staleKeys = await matchingKeys(redis); |
|
if (staleKeys.length) await redis.del(...staleKeys); |
|
|
|
const before = await (await fetch(`${conductorUrl}/metrics`)).json(); |
|
const connection = await amqp.connect(rabbitUrl); |
|
const channel = await connection.createConfirmChannel(); |
|
await channel.assertExchange(exchange, 'direct', { durable: true }); |
|
await channel.assertQueue(queue, { |
|
durable: true, |
|
arguments: { 'x-single-active-consumer': true }, |
|
}); |
|
await channel.bindQueue(queue, exchange, queue); |
|
for (const eventExchange of eventExchanges) { |
|
const sinkQueue = `performance:${eventExchange}`; |
|
await channel.assertExchange(eventExchange, 'direct', { durable: true }); |
|
await channel.assertQueue(sinkQueue, { durable: true }); |
|
await channel.bindQueue(sinkQueue, eventExchange, eventExchange); |
|
await channel.purgeQueue(sinkQueue); |
|
} |
|
|
|
const submittedAt = new Date().toISOString(); |
|
const start = process.hrtime.bigint(); |
|
const publishLatencies = []; |
|
for (let index = 0; index < count; index += 1) { |
|
const publishStart = process.hrtime.bigint(); |
|
const writable = channel.publish( |
|
exchange, |
|
queue, |
|
Buffer.from(SuperJSON.stringify(command(index, submittedAt))), |
|
{ persistent: true, contentType: 'application/json' }, |
|
); |
|
publishLatencies.push(Number(process.hrtime.bigint() - publishStart) / 1e6); |
|
if (!writable) |
|
await new Promise((resolve) => channel.once('drain', resolve)); |
|
} |
|
await channel.waitForConfirms(); |
|
const confirmed = process.hrtime.bigint(); |
|
|
|
const target = before.matching_commands_processed_total + count; |
|
let metrics; |
|
const deadline = Date.now() + 120_000; |
|
do { |
|
metrics = await (await fetch(`${conductorUrl}/metrics`)).json(); |
|
if ( |
|
metrics.matching_command_failures_total > |
|
before.matching_command_failures_total |
|
) { |
|
throw new Error( |
|
`Conductor reported ${ |
|
metrics.matching_command_failures_total - |
|
before.matching_command_failures_total |
|
} failures`, |
|
); |
|
} |
|
if (metrics.matching_commands_processed_total >= target) break; |
|
if (Date.now() >= deadline) |
|
throw new Error( |
|
`Timed out at ${ |
|
metrics.matching_commands_processed_total - |
|
before.matching_commands_processed_total |
|
}/${count}`, |
|
); |
|
await new Promise((resolve) => setTimeout(resolve, 20)); |
|
} while (true); |
|
|
|
if (process.env.BENCH_WAIT_OUTBOX === '1') { |
|
do { |
|
if ((await redis.xlen('matching:{matching-p0}:events')) === 0) break; |
|
if (Date.now() >= deadline) |
|
throw new Error('Timed out waiting for Outbox'); |
|
await new Promise((resolve) => setTimeout(resolve, 20)); |
|
} while (true); |
|
} |
|
|
|
const end = process.hrtime.bigint(); |
|
const totalMs = Number(end - start) / 1e6; |
|
const confirmMs = Number(confirmed - start) / 1e6; |
|
publishLatencies.sort((left, right) => left - right); |
|
const streamLength = await redis.xlen('matching:{matching-p0}:events'); |
|
const askDepth = await redis.zcard('matching:{matching-p0}:book:1:ask:rates'); |
|
const bidDepth = await redis.zcard('matching:{matching-p0}:book:1:bid:rates'); |
|
|
|
console.log( |
|
JSON.stringify( |
|
{ |
|
commands: count, |
|
seconds: totalMs / 1000, |
|
commandsPerSecond: count / (totalMs / 1000), |
|
brokerConfirmMs: confirmMs, |
|
publishCallMs: { |
|
p50: percentile(publishLatencies, 0.5), |
|
p95: percentile(publishLatencies, 0.95), |
|
p99: percentile(publishLatencies, 0.99), |
|
max: publishLatencies[publishLatencies.length - 1], |
|
}, |
|
commandFailures: |
|
metrics.matching_command_failures_total - |
|
before.matching_command_failures_total, |
|
eventsCreated: |
|
metrics.matching_events_created_total - |
|
before.matching_events_created_total, |
|
streamLength, |
|
remainingBookDepth: askDepth + bidDepth, |
|
}, |
|
null, |
|
2, |
|
), |
|
); |
|
|
|
await channel.close(); |
|
await connection.close(); |
|
await redis.quit(); |
|
} |
|
|
|
main().catch((error) => { |
|
console.error(error); |
|
process.exitCode = 1; |
|
}); |