mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-27 16:02:05 +00:00
641 lines
23 KiB
JavaScript
641 lines
23 KiB
JavaScript
import WebSocket from 'ws';
|
|
import { Relay, useWebSocketImplementation } from 'nostr-tools/relay';
|
|
import { finalizeEvent, generateSecretKey } from 'nostr-tools/pure';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { performance } from 'node:perf_hooks';
|
|
import { KIND_APP_DATA, DEFAULT_RELAYS } from '../../src/constants.mjs';
|
|
import { ensureDir } from '../../src/utils.mjs';
|
|
|
|
useWebSocketImplementation(WebSocket);
|
|
|
|
const KIND_VIEW_EVENT = 1;
|
|
const KNOWN_PUBLIC_RELAY_HOSTS = new Set(DEFAULT_RELAYS.map((relay) => new URL(relay).hostname));
|
|
const RELAY_SAFETY_ACK_VALUE = 'LOAD_TEST_INFRA_CONFIRMED';
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const args = {};
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const raw = argv[i];
|
|
if (!raw.startsWith('--')) continue;
|
|
const token = raw.slice(2);
|
|
if (!token) continue;
|
|
const eqIndex = token.indexOf('=');
|
|
if (eqIndex >= 0) {
|
|
args[token.slice(0, eqIndex)] = token.slice(eqIndex + 1);
|
|
continue;
|
|
}
|
|
const next = argv[i + 1];
|
|
if (next && !next.startsWith('--')) {
|
|
args[token] = next;
|
|
i += 1;
|
|
} else {
|
|
args[token] = '1';
|
|
}
|
|
}
|
|
return args;
|
|
}
|
|
|
|
function getConfigValue(args, key, envKey, fallback) {
|
|
if (args[key] !== undefined) return args[key];
|
|
if (process.env[envKey] !== undefined) return process.env[envKey];
|
|
return fallback;
|
|
}
|
|
|
|
function parseIntStrict(value, name) {
|
|
if (!/^-?\d+$/.test(String(value))) {
|
|
throw new Error(`${name} must be an integer (got "${value}")`);
|
|
}
|
|
return Number.parseInt(value, 10);
|
|
}
|
|
|
|
function parseFloatStrict(value, name) {
|
|
if (!/^[-+]?\d+(\.\d+)?$/.test(String(value))) {
|
|
throw new Error(`${name} must be a number (got "${value}")`);
|
|
}
|
|
return Number.parseFloat(value);
|
|
}
|
|
|
|
function parseBoolean(value, fallback = false) {
|
|
if (value === undefined || value === null || value === '') return fallback;
|
|
const normalized = String(value).trim().toLowerCase();
|
|
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
|
|
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
|
|
return fallback;
|
|
}
|
|
|
|
function assertRange(value, name, min, max) {
|
|
if (value < min || value > max) {
|
|
throw new Error(`${name} out of range (${min}..${max}): ${value}`);
|
|
}
|
|
}
|
|
|
|
function isPrivateIpv4(hostname) {
|
|
const match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
if (!match) return false;
|
|
const parts = match.slice(1).map((part) => Number.parseInt(part, 10));
|
|
if (parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)) return false;
|
|
if (parts[0] === 10) return true;
|
|
if (parts[0] === 127) return true;
|
|
if (parts[0] === 192 && parts[1] === 168) return true;
|
|
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
|
|
if (parts[0] === 169 && parts[1] === 254) return true;
|
|
return false;
|
|
}
|
|
|
|
function isLocalOrPrivateHost(hostname) {
|
|
const lower = hostname.toLowerCase();
|
|
return lower === 'localhost'
|
|
|| lower === '::1'
|
|
|| lower.endsWith('.local')
|
|
|| isPrivateIpv4(lower);
|
|
}
|
|
|
|
function classifyError(error) {
|
|
const text = String(error?.message || error || '').toLowerCase();
|
|
if (text.includes('timeout')) return 'timeout';
|
|
if (text.includes('reject') || text.includes('invalid signature') || text.includes('validation')) return 'reject';
|
|
if (text.includes('closed') || text.includes('disconnect') || text.includes('econnreset')) return 'disconnect';
|
|
if (text.includes('refused') || text.includes('enotfound') || text.includes('etimedout')) return 'network';
|
|
return 'publish_error';
|
|
}
|
|
|
|
function xmur3(seed) {
|
|
let h = 1779033703 ^ seed.length;
|
|
for (let i = 0; i < seed.length; i += 1) {
|
|
h = Math.imul(h ^ seed.charCodeAt(i), 3432918353);
|
|
h = (h << 13) | (h >>> 19);
|
|
}
|
|
return () => {
|
|
h = Math.imul(h ^ (h >>> 16), 2246822507);
|
|
h = Math.imul(h ^ (h >>> 13), 3266489909);
|
|
h ^= h >>> 16;
|
|
return h >>> 0;
|
|
};
|
|
}
|
|
|
|
function mulberry32(seedInt) {
|
|
let seed = seedInt >>> 0;
|
|
return () => {
|
|
seed += 0x6d2b79f5;
|
|
let t = seed;
|
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
};
|
|
}
|
|
|
|
function percentile(samples, p) {
|
|
if (samples.length === 0) return 0;
|
|
const sorted = [...samples].sort((a, b) => a - b);
|
|
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
|
|
return sorted[index];
|
|
}
|
|
|
|
function summarizePercentiles(samples) {
|
|
if (samples.length === 0) {
|
|
return {
|
|
count: 0,
|
|
minMs: 0,
|
|
avgMs: 0,
|
|
p50Ms: 0,
|
|
p90Ms: 0,
|
|
p95Ms: 0,
|
|
p99Ms: 0,
|
|
maxMs: 0,
|
|
};
|
|
}
|
|
const minMs = samples.reduce((min, value) => Math.min(min, value), Number.POSITIVE_INFINITY);
|
|
const maxMs = samples.reduce((max, value) => Math.max(max, value), 0);
|
|
const sumMs = samples.reduce((sum, value) => sum + value, 0);
|
|
return {
|
|
count: samples.length,
|
|
minMs,
|
|
avgMs: sumMs / samples.length,
|
|
p50Ms: percentile(samples, 50),
|
|
p90Ms: percentile(samples, 90),
|
|
p95Ms: percentile(samples, 95),
|
|
p99Ms: percentile(samples, 99),
|
|
maxMs,
|
|
};
|
|
}
|
|
|
|
function withTimeout(promise, timeoutMs) {
|
|
let timeoutHandle = null;
|
|
return Promise.race([
|
|
promise,
|
|
new Promise((_, reject) => {
|
|
timeoutHandle = setTimeout(() => reject(new Error(`publish timeout after ${timeoutMs}ms`)), timeoutMs);
|
|
}),
|
|
]).finally(() => {
|
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
});
|
|
}
|
|
|
|
function buildViewEvent(secretKey, nowMs) {
|
|
return finalizeEvent({
|
|
kind: KIND_VIEW_EVENT,
|
|
created_at: Math.floor(nowMs / 1000),
|
|
tags: [
|
|
['t', 'torch-load-test'],
|
|
['t', 'view-event'],
|
|
],
|
|
content: JSON.stringify({
|
|
type: 'view_event',
|
|
source: 'load-test-harness',
|
|
viewed_at: new Date(nowMs).toISOString(),
|
|
}),
|
|
}, secretKey);
|
|
}
|
|
|
|
function buildMultipartMetadataEvent(secretKey, nowMs, rng, sequence, partsPerEvent) {
|
|
const videoId = `video-${Math.floor(rng() * 1000000).toString(16)}-${sequence}`;
|
|
const part = 1 + Math.floor(rng() * partsPerEvent);
|
|
const content = {
|
|
type: 'video_metadata_multipart',
|
|
source: 'load-test-harness',
|
|
schema_basis: 'nostr-app-data-kind-30078',
|
|
video_id: videoId,
|
|
part,
|
|
total_parts: partsPerEvent,
|
|
payload: {
|
|
title: `Load test clip ${sequence}`,
|
|
duration_sec: 30 + Math.floor(rng() * 600),
|
|
codecs: ['h264', 'aac'],
|
|
chunk_manifest: Array.from({ length: partsPerEvent }, (_, idx) => ({
|
|
part: idx + 1,
|
|
bytes: 500 + Math.floor(rng() * 2500),
|
|
})),
|
|
},
|
|
};
|
|
return finalizeEvent({
|
|
kind: KIND_APP_DATA,
|
|
created_at: Math.floor(nowMs / 1000),
|
|
tags: [
|
|
['t', 'torch-load-test'],
|
|
['t', 'video-metadata-multipart'],
|
|
['d', `${videoId}-${part}`],
|
|
],
|
|
content: JSON.stringify(content),
|
|
}, secretKey);
|
|
}
|
|
|
|
function summarizeRelay(relayUrl) {
|
|
const parsed = new URL(relayUrl);
|
|
return `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ''}`;
|
|
}
|
|
|
|
function redactRelayForReport(relayUrl) {
|
|
const parsed = new URL(relayUrl);
|
|
return {
|
|
protocol: parsed.protocol,
|
|
hostname: parsed.hostname,
|
|
port: parsed.port || '(default)',
|
|
};
|
|
}
|
|
|
|
function buildRemediation(report) {
|
|
const items = [];
|
|
const { results } = report;
|
|
if (results.backpressureDrops > 0) {
|
|
items.push('Reduce RATE_EPS or increase MAX_INFLIGHT only after confirming relay capacity; backpressure drops indicate local pipeline saturation.');
|
|
}
|
|
if ((results.errorBreakdown.timeout || 0) > 0) {
|
|
items.push('Investigate relay write acknowledgements and tune PUBLISH_TIMEOUT_MS; timeout errors were observed during publish acknowledgements.');
|
|
}
|
|
if ((results.errorBreakdown.disconnect || 0) > 0 || (results.errorBreakdown.network || 0) > 0) {
|
|
items.push('Check relay network stability (TCP resets, DNS, proxy paths) before raising client count.');
|
|
}
|
|
if (results.latency.p99Ms > 500) {
|
|
items.push('Profile relay-side event acceptance path; p99 publish latency exceeded 500ms.');
|
|
}
|
|
if (results.eventLoopLag.p95Ms > 50) {
|
|
items.push('Reduce local scheduling pressure (lower RATE_EPS or CLIENTS) to keep event loop lag under 50ms.');
|
|
}
|
|
if (results.resource.maxRssMb > 512) {
|
|
items.push('Cap MAX_CONNECTIONS or lower CLIENTS to reduce harness memory pressure (RSS exceeded 512MB).');
|
|
}
|
|
if (items.length === 0) {
|
|
items.push('No significant bottlenecks observed in this run; increase load gradually and repeat to find saturation point.');
|
|
}
|
|
return items.slice(0, 10);
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const relayUrlRaw = getConfigValue(args, 'relay-url', 'RELAY_URL', '');
|
|
const relayUrl = String(relayUrlRaw || '').trim();
|
|
if (!relayUrl) throw new Error('RELAY_URL (or --relay-url) is required.');
|
|
|
|
const parsedRelay = new URL(relayUrl);
|
|
if (!['ws:', 'wss:'].includes(parsedRelay.protocol)) {
|
|
throw new Error(`Unsupported relay protocol ${parsedRelay.protocol}. Use ws:// or wss://.`);
|
|
}
|
|
|
|
const allowDedicated = parseBoolean(getConfigValue(args, 'allow-dedicated-test-relay', 'ALLOW_DEDICATED_TEST_RELAY', '0'), false);
|
|
const relaySafetyAck = String(getConfigValue(args, 'relay-safety-ack', 'RELAY_SAFETY_ACK', '') || '').trim();
|
|
const host = parsedRelay.hostname.toLowerCase();
|
|
|
|
if (KNOWN_PUBLIC_RELAY_HOSTS.has(host)) {
|
|
throw new Error(`Refusing to target known public relay host: ${host}`);
|
|
}
|
|
|
|
if (!isLocalOrPrivateHost(host)) {
|
|
if (!allowDedicated || relaySafetyAck !== RELAY_SAFETY_ACK_VALUE) {
|
|
throw new Error(
|
|
`Refusing non-local relay host "${host}". Set ALLOW_DEDICATED_TEST_RELAY=1 and RELAY_SAFETY_ACK=${RELAY_SAFETY_ACK_VALUE} for dedicated test infrastructure.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const clients = parseIntStrict(getConfigValue(args, 'clients', 'CLIENTS', '1000'), 'CLIENTS');
|
|
const durationSec = parseIntStrict(getConfigValue(args, 'duration-sec', 'DURATION_SEC', '600'), 'DURATION_SEC');
|
|
const rateEps = parseFloatStrict(getConfigValue(args, 'rate-eps', 'RATE_EPS', '20'), 'RATE_EPS');
|
|
const mix = parseFloatStrict(getConfigValue(args, 'mix', 'MIX', '0.2'), 'MIX');
|
|
const seed = String(getConfigValue(args, 'seed', 'SEED', '') || '').trim();
|
|
const dryRun = parseBoolean(getConfigValue(args, 'dry-run', 'DRY_RUN', '1'), true);
|
|
const connectConcurrency = parseIntStrict(getConfigValue(args, 'connect-concurrency', 'CONNECT_CONCURRENCY', '25'), 'CONNECT_CONCURRENCY');
|
|
const maxConnections = parseIntStrict(getConfigValue(args, 'max-connections', 'MAX_CONNECTIONS', String(Math.min(clients, 250))), 'MAX_CONNECTIONS');
|
|
const maxInflight = parseIntStrict(getConfigValue(args, 'max-inflight', 'MAX_INFLIGHT', '300'), 'MAX_INFLIGHT');
|
|
const publishTimeoutMs = parseIntStrict(getConfigValue(args, 'publish-timeout-ms', 'PUBLISH_TIMEOUT_MS', '15000'), 'PUBLISH_TIMEOUT_MS');
|
|
const resourceSampleMs = parseIntStrict(getConfigValue(args, 'resource-sample-ms', 'RESOURCE_SAMPLE_MS', '5000'), 'RESOURCE_SAMPLE_MS');
|
|
const eventLoopSampleMs = parseIntStrict(getConfigValue(args, 'event-loop-sample-ms', 'EVENT_LOOP_SAMPLE_MS', '200'), 'EVENT_LOOP_SAMPLE_MS');
|
|
const metadataParts = parseIntStrict(getConfigValue(args, 'metadata-parts', 'METADATA_PARTS', '4'), 'METADATA_PARTS');
|
|
|
|
assertRange(clients, 'CLIENTS', 1, 100000);
|
|
assertRange(durationSec, 'DURATION_SEC', 1, 86400);
|
|
assertRange(rateEps, 'RATE_EPS', 0.1, 100000);
|
|
assertRange(mix, 'MIX', 0, 1);
|
|
assertRange(connectConcurrency, 'CONNECT_CONCURRENCY', 1, 1000);
|
|
assertRange(maxConnections, 'MAX_CONNECTIONS', 1, clients);
|
|
assertRange(maxInflight, 'MAX_INFLIGHT', 1, 100000);
|
|
assertRange(publishTimeoutMs, 'PUBLISH_TIMEOUT_MS', 100, 120000);
|
|
assertRange(resourceSampleMs, 'RESOURCE_SAMPLE_MS', 500, 120000);
|
|
assertRange(eventLoopSampleMs, 'EVENT_LOOP_SAMPLE_MS', 20, 2000);
|
|
assertRange(metadataParts, 'METADATA_PARTS', 2, 20);
|
|
|
|
const rng = seed ? mulberry32(xmur3(seed)()) : Math.random;
|
|
const reportDir = path.resolve(process.cwd(), 'reports', 'load-test');
|
|
ensureDir(reportDir);
|
|
|
|
const relaySummary = summarizeRelay(relayUrl);
|
|
console.log('Load test configuration');
|
|
console.log(` relay: ${relaySummary}`);
|
|
console.log(` clients: ${clients}`);
|
|
console.log(` durationSec: ${durationSec}`);
|
|
console.log(` rateEps: ${rateEps}`);
|
|
console.log(` mix(metadata): ${mix}`);
|
|
console.log(` seed: ${seed || '(random)'}`);
|
|
console.log(` dryRun: ${dryRun}`);
|
|
console.log(` maxConnections: ${maxConnections}`);
|
|
console.log(` connectConcurrency: ${connectConcurrency}`);
|
|
console.log(` maxInflight: ${maxInflight}`);
|
|
|
|
if (dryRun) {
|
|
console.log('DRY_RUN enabled: no relay connections or publish calls will be made.');
|
|
}
|
|
|
|
const clientKeys = Array.from({ length: clients }, () => generateSecretKey());
|
|
|
|
const stats = {
|
|
startedAtIso: new Date().toISOString(),
|
|
startedAtMs: Date.now(),
|
|
attempted: 0,
|
|
succeeded: 0,
|
|
failed: 0,
|
|
backpressureDrops: 0,
|
|
inFlightPeak: 0,
|
|
latenciesMs: [],
|
|
errorBreakdown: {},
|
|
stackSamples: {},
|
|
throughputBySecond: {},
|
|
resourceSnapshots: [],
|
|
eventLoopLagMs: [],
|
|
mode: dryRun ? 'dry-run' : 'live',
|
|
};
|
|
|
|
const cpuStart = process.cpuUsage();
|
|
let previousCpu = process.cpuUsage();
|
|
let previousCpuWallMs = Date.now();
|
|
|
|
const eventLoopTimer = setInterval(() => {
|
|
const expected = Date.now() + eventLoopSampleMs;
|
|
setTimeout(() => {
|
|
const lag = Math.max(0, Date.now() - expected);
|
|
stats.eventLoopLagMs.push(lag);
|
|
}, eventLoopSampleMs);
|
|
}, eventLoopSampleMs);
|
|
|
|
const resourceTimer = setInterval(() => {
|
|
const nowMs = Date.now();
|
|
const mem = process.memoryUsage();
|
|
const cpuDiff = process.cpuUsage(previousCpu);
|
|
const wallMs = Math.max(1, nowMs - previousCpuWallMs);
|
|
const cpuMs = (cpuDiff.user + cpuDiff.system) / 1000;
|
|
const cpuPercent = Number(((cpuMs / wallMs) * 100).toFixed(2));
|
|
previousCpu = process.cpuUsage();
|
|
previousCpuWallMs = nowMs;
|
|
stats.resourceSnapshots.push({
|
|
tSec: Math.floor((nowMs - stats.startedAtMs) / 1000),
|
|
rssMb: Number((mem.rss / (1024 * 1024)).toFixed(2)),
|
|
heapUsedMb: Number((mem.heapUsed / (1024 * 1024)).toFixed(2)),
|
|
heapTotalMb: Number((mem.heapTotal / (1024 * 1024)).toFixed(2)),
|
|
cpuPercent,
|
|
});
|
|
}, resourceSampleMs);
|
|
|
|
const recordAttempt = () => {
|
|
const sec = Math.floor((Date.now() - stats.startedAtMs) / 1000);
|
|
const bucket = stats.throughputBySecond[sec] || { attempted: 0, succeeded: 0, failed: 0, backpressureDrops: 0 };
|
|
bucket.attempted += 1;
|
|
stats.throughputBySecond[sec] = bucket;
|
|
};
|
|
|
|
const recordResult = (kind) => {
|
|
const sec = Math.floor((Date.now() - stats.startedAtMs) / 1000);
|
|
const bucket = stats.throughputBySecond[sec] || { attempted: 0, succeeded: 0, failed: 0, backpressureDrops: 0 };
|
|
bucket[kind] += 1;
|
|
stats.throughputBySecond[sec] = bucket;
|
|
};
|
|
|
|
const relays = [];
|
|
if (!dryRun) {
|
|
let nextConnectionIndex = 0;
|
|
const connectionErrors = [];
|
|
const workers = Array.from({ length: Math.min(connectConcurrency, maxConnections) }, () => (async () => {
|
|
while (nextConnectionIndex < maxConnections) {
|
|
const current = nextConnectionIndex;
|
|
nextConnectionIndex += 1;
|
|
try {
|
|
const relay = await Relay.connect(relayUrl);
|
|
relays[current] = relay;
|
|
} catch (error) {
|
|
connectionErrors.push(String(error?.message || error));
|
|
}
|
|
}
|
|
})());
|
|
|
|
await Promise.all(workers);
|
|
if (!relays.length) {
|
|
throw new Error(`Failed to establish any relay connections. errors=${connectionErrors.slice(0, 3).join(' | ') || '(none)'}`);
|
|
}
|
|
if (connectionErrors.length) {
|
|
stats.errorBreakdown.connection = connectionErrors.length;
|
|
stats.stackSamples.connection = connectionErrors.slice(0, 3);
|
|
}
|
|
console.log(`Established ${relays.length}/${maxConnections} relay connections.`);
|
|
}
|
|
|
|
const publishContext = {
|
|
inFlight: 0,
|
|
tasks: new Set(),
|
|
metadataSeq: 0,
|
|
};
|
|
|
|
async function publishOne(clientIndex, isMetadata) {
|
|
const nowMs = Date.now();
|
|
const secretKey = clientKeys[clientIndex];
|
|
const event = isMetadata
|
|
? buildMultipartMetadataEvent(secretKey, nowMs, rng, publishContext.metadataSeq += 1, metadataParts)
|
|
: buildViewEvent(secretKey, nowMs);
|
|
|
|
const started = performance.now();
|
|
try {
|
|
if (dryRun) {
|
|
const syntheticLatency = 5 + Math.floor(rng() * 20);
|
|
await sleep(syntheticLatency);
|
|
} else {
|
|
const relay = relays[clientIndex % relays.length];
|
|
await withTimeout(relay.publish(event), publishTimeoutMs);
|
|
}
|
|
const latencyMs = Number((performance.now() - started).toFixed(3));
|
|
stats.succeeded += 1;
|
|
stats.latenciesMs.push(latencyMs);
|
|
recordResult('succeeded');
|
|
} catch (error) {
|
|
stats.failed += 1;
|
|
const category = classifyError(error);
|
|
stats.errorBreakdown[category] = (stats.errorBreakdown[category] || 0) + 1;
|
|
const stack = String(error?.stack || error?.message || error);
|
|
const trimmed = stack.split('\n').slice(0, 3).join('\n');
|
|
if (!stats.stackSamples[category]) stats.stackSamples[category] = [];
|
|
if (stats.stackSamples[category].length < 3) {
|
|
stats.stackSamples[category].push(trimmed);
|
|
}
|
|
recordResult('failed');
|
|
}
|
|
}
|
|
|
|
const durationMs = durationSec * 1000;
|
|
const endAt = stats.startedAtMs + durationMs;
|
|
const paceMs = 1000 / rateEps;
|
|
let nextTick = Date.now();
|
|
|
|
while (Date.now() < endAt) {
|
|
if (Date.now() < nextTick) {
|
|
await sleep(Math.max(0, nextTick - Date.now()));
|
|
continue;
|
|
}
|
|
nextTick += paceMs;
|
|
|
|
stats.attempted += 1;
|
|
recordAttempt();
|
|
if (publishContext.inFlight >= maxInflight) {
|
|
stats.backpressureDrops += 1;
|
|
stats.failed += 1;
|
|
stats.errorBreakdown.backpressure = (stats.errorBreakdown.backpressure || 0) + 1;
|
|
recordResult('failed');
|
|
recordResult('backpressureDrops');
|
|
continue;
|
|
}
|
|
|
|
const clientIndex = Math.floor(rng() * clients);
|
|
const isMetadata = rng() < mix;
|
|
publishContext.inFlight += 1;
|
|
stats.inFlightPeak = Math.max(stats.inFlightPeak, publishContext.inFlight);
|
|
const task = publishOne(clientIndex, isMetadata).finally(() => {
|
|
publishContext.inFlight -= 1;
|
|
publishContext.tasks.delete(task);
|
|
});
|
|
publishContext.tasks.add(task);
|
|
}
|
|
|
|
await Promise.allSettled([...publishContext.tasks]);
|
|
|
|
clearInterval(eventLoopTimer);
|
|
clearInterval(resourceTimer);
|
|
for (const relay of relays) relay.close();
|
|
|
|
const completedAtMs = Date.now();
|
|
const completedAtIso = new Date(completedAtMs).toISOString();
|
|
const cpuTotal = process.cpuUsage(cpuStart);
|
|
const runDurationSec = Math.max(0.001, (completedAtMs - stats.startedAtMs) / 1000);
|
|
const latency = summarizePercentiles(stats.latenciesMs);
|
|
const eventLoopLag = summarizePercentiles(stats.eventLoopLagMs);
|
|
const maxRssMb = stats.resourceSnapshots.reduce((max, item) => Math.max(max, item.rssMb), 0);
|
|
const maxHeapMb = stats.resourceSnapshots.reduce((max, item) => Math.max(max, item.heapUsedMb), 0);
|
|
const avgCpuPercent = stats.resourceSnapshots.length
|
|
? stats.resourceSnapshots.reduce((sum, item) => sum + item.cpuPercent, 0) / stats.resourceSnapshots.length
|
|
: 0;
|
|
|
|
const throughputSeries = Object.entries(stats.throughputBySecond)
|
|
.map(([second, values]) => ({ second: Number(second), ...values }))
|
|
.sort((a, b) => a.second - b.second);
|
|
|
|
const report = {
|
|
generatedAt: completedAtIso,
|
|
config: {
|
|
relay: redactRelayForReport(relayUrl),
|
|
relaySummary,
|
|
dryRun,
|
|
clients,
|
|
durationSec,
|
|
rateEps,
|
|
mixMetadata: mix,
|
|
seed: seed || null,
|
|
connectConcurrency,
|
|
maxConnections,
|
|
maxInflight,
|
|
publishTimeoutMs,
|
|
metadataParts,
|
|
},
|
|
results: {
|
|
attempted: stats.attempted,
|
|
succeeded: stats.succeeded,
|
|
failed: stats.failed,
|
|
successRate: Number((stats.succeeded / Math.max(1, stats.attempted)).toFixed(4)),
|
|
throughputEps: Number((stats.succeeded / runDurationSec).toFixed(3)),
|
|
backpressureDrops: stats.backpressureDrops,
|
|
inFlightPeak: stats.inFlightPeak,
|
|
latency,
|
|
eventLoopLag: {
|
|
avgMs: Number(eventLoopLag.avgMs.toFixed(3)),
|
|
p95Ms: Number(eventLoopLag.p95Ms.toFixed(3)),
|
|
},
|
|
errorBreakdown: stats.errorBreakdown,
|
|
topStackTraces: stats.stackSamples,
|
|
resource: {
|
|
samples: stats.resourceSnapshots,
|
|
sampleCount: stats.resourceSnapshots.length,
|
|
maxRssMb,
|
|
maxHeapUsedMb: maxHeapMb,
|
|
avgCpuPercent: Number(avgCpuPercent.toFixed(2)),
|
|
cpuUserMs: Number((cpuTotal.user / 1000).toFixed(3)),
|
|
cpuSystemMs: Number((cpuTotal.system / 1000).toFixed(3)),
|
|
},
|
|
throughputSeries,
|
|
hotFunctions: 'not measured',
|
|
startedAtIso: stats.startedAtIso,
|
|
completedAtIso,
|
|
runDurationSec: Number(runDurationSec.toFixed(3)),
|
|
},
|
|
};
|
|
|
|
report.remediation = buildRemediation(report);
|
|
|
|
const dateStr = new Date().toISOString().slice(0, 10);
|
|
const jsonPath = path.join(reportDir, `load-report-${dateStr}.json`);
|
|
const markdownPath = path.join(reportDir, `load-test-report-${dateStr}.md`);
|
|
fs.writeFileSync(jsonPath, JSON.stringify(report, null, 2));
|
|
|
|
const markdown = [
|
|
`# Load Test Report - ${dateStr}`,
|
|
'',
|
|
'## Safety',
|
|
'- Does not target public relays.',
|
|
'- Requires explicit `RELAY_URL`.',
|
|
`- Relay target: ${relaySummary}`,
|
|
`- Mode: ${dryRun ? 'dry-run (no network sends)' : 'live publish test'}`,
|
|
'',
|
|
'## Configuration',
|
|
`- Clients: ${clients}`,
|
|
`- Duration: ${durationSec}s`,
|
|
`- Rate: ${rateEps} events/sec`,
|
|
`- Metadata mix: ${mix}`,
|
|
`- Max connections: ${maxConnections}`,
|
|
`- Max in-flight publishes: ${maxInflight}`,
|
|
`- Seed: ${seed || '(random)'}`,
|
|
'',
|
|
'## Summary',
|
|
`- Attempted: ${report.results.attempted}`,
|
|
`- Succeeded: ${report.results.succeeded}`,
|
|
`- Failed: ${report.results.failed}`,
|
|
`- Throughput: ${report.results.throughputEps} events/sec`,
|
|
`- Success rate: ${(report.results.successRate * 100).toFixed(2)}%`,
|
|
'',
|
|
'## Latency',
|
|
`- p50: ${report.results.latency.p50Ms.toFixed(3)} ms`,
|
|
`- p90: ${report.results.latency.p90Ms.toFixed(3)} ms`,
|
|
`- p95: ${report.results.latency.p95Ms.toFixed(3)} ms`,
|
|
`- p99: ${report.results.latency.p99Ms.toFixed(3)} ms`,
|
|
'',
|
|
'## Event Loop Lag',
|
|
`- Avg: ${report.results.eventLoopLag.avgMs} ms`,
|
|
`- p95: ${report.results.eventLoopLag.p95Ms} ms`,
|
|
'',
|
|
'## Error Taxonomy',
|
|
`- ${Object.entries(report.results.errorBreakdown).map(([key, value]) => `${key}: ${value}`).join(', ') || 'none'}`,
|
|
'',
|
|
'## Hot Functions',
|
|
'- not measured',
|
|
'',
|
|
'## Prioritized Remediation',
|
|
...report.remediation.map((item, index) => `${index + 1}. ${item}`),
|
|
'',
|
|
].join('\n');
|
|
|
|
fs.writeFileSync(markdownPath, markdown);
|
|
|
|
console.log(`JSON report written: ${jsonPath}`);
|
|
console.log(`Markdown report written: ${markdownPath}`);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(`Load test harness failed: ${error?.stack || error}`);
|
|
process.exit(1);
|
|
});
|