Files
bitvid/scripts/agent/load-test.mjs
T
google-labs-jules[bot] 7bc85f1b99 feat: add load test script with safety guardrails
Implements `scripts/agent/load-test.mjs` to simulate N clients publishing
video and view events to a relay. Includes:
- Latency and throughput metrics.
- Signing time profiling.
- Safety guardrails to prevent accidental stress testing of public relays.
- A lightweight local relay (`scripts/agent/simple-relay.mjs`) for testing.

Outputs a report to `artifacts/load-report-YYYYMMDD.json`.
2026-01-18 19:24:01 +00:00

307 lines
9.7 KiB
JavaScript

import WebSocket from 'ws';
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools';
import { writeFile, mkdir } from 'fs/promises';
import path from 'path';
import { performance } from 'perf_hooks';
// --- Configuration ---
const ARGS = process.argv.slice(2);
const getConfig = (key, defaultVal) => {
const idx = ARGS.indexOf(`--${key}`);
return idx !== -1 ? ARGS[idx + 1] : defaultVal;
};
const hasFlag = (flag) => ARGS.includes(`--${flag}`);
const CONFIG = {
clients: parseInt(getConfig('clients', '100'), 10),
duration: parseInt(getConfig('duration', '60'), 10), // seconds
relay: getConfig('relay', 'ws://localhost:8008'),
rate: parseInt(getConfig('rate', '50'), 10), // target events per second (global)
unsafe: hasFlag('unsafe'),
outputDir: 'artifacts',
};
// --- Safety Check ---
function isSafeRelay(url) {
if (CONFIG.unsafe) return true;
try {
const u = new URL(url);
if (u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '[::1]') return true;
// Check for private IPs (10.x, 192.168.x, 172.16-31.x) could be added here,
// but for now localhost is the strict guardrail.
return false;
} catch (e) {
return false;
}
}
if (!isSafeRelay(CONFIG.relay)) {
console.error(`\n[FATAL] Guardrail triggered: Attempting to load test a non-local relay (${CONFIG.relay}) without --unsafe flag.`);
console.error("Please target a local relay or use --unsafe if you are sure you own the infrastructure.\n");
process.exit(1);
}
// --- Helpers ---
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const NOTE_TYPES = {
VIDEO_POST: 30078,
VIEW_EVENT: 30079
};
// Simplified Event Builders based on js/nostrEventSchemas.js
function buildVideoPostEvent(signer, contentData) {
const created_at = Math.floor(Date.now() / 1000);
const event = {
kind: NOTE_TYPES.VIDEO_POST,
created_at,
tags: [
['t', 'video'],
['d', `video-${created_at}-${Math.random().toString(36).slice(2)}`]
],
content: JSON.stringify(contentData),
pubkey: getPublicKey(signer.privateKey),
};
return finalizeEvent(event, signer.privateKey);
}
function buildViewEvent(signer, videoId) {
const created_at = Math.floor(Date.now() / 1000);
const event = {
kind: NOTE_TYPES.VIEW_EVENT,
created_at,
tags: [
['t', 'view'],
['e', videoId],
['d', `view-${created_at}-${Math.random().toString(36).slice(2)}`] // Dedupe tag
],
content: '',
pubkey: getPublicKey(signer.privateKey),
};
return finalizeEvent(event, signer.privateKey);
}
// --- Client Class ---
class LoadClient {
constructor(relayUrl, id) {
this.id = id;
this.relayUrl = relayUrl;
this.privateKey = generateSecretKey();
this.pubkey = getPublicKey(this.privateKey);
this.ws = null;
this.connected = false;
this.pending = new Map(); // eventId -> startTime
}
connect() {
return new Promise((resolve, reject) => {
this.ws = new WebSocket(this.relayUrl);
this.ws.on('open', () => {
this.connected = true;
resolve();
});
this.ws.on('error', (err) => {
// console.error(`Client ${this.id} error:`, err.message);
reject(err);
});
this.ws.on('message', (data) => this.handleMessage(data));
this.ws.on('close', () => {
this.connected = false;
});
});
}
handleMessage(data) {
try {
const msg = JSON.parse(data);
if (msg[0] === 'OK') {
const eventId = msg[1];
const success = msg[2];
const message = msg[3];
if (this.pending.has(eventId)) {
const startTime = this.pending.get(eventId);
const latency = Date.now() - startTime;
this.pending.delete(eventId);
stats.recordAck(latency, success, message);
}
}
} catch (e) {
console.error('Error parsing message:', e);
}
}
publish(event) {
if (!this.connected) return;
this.pending.set(event.id, Date.now());
this.ws.send(JSON.stringify(['EVENT', event]));
stats.recordSent();
}
close() {
if (this.ws) this.ws.close();
}
}
// --- Metrics ---
const stats = {
startTime: Date.now(),
sent: 0,
acked: 0,
failed: 0,
latencies: [],
signingTimes: [],
errors: [],
startCpu: process.cpuUsage(),
recordSent() {
this.sent++;
},
recordAck(latency, success, message) {
if (success) {
this.acked++;
this.latencies.push(latency);
} else {
this.failed++;
this.errors.push(message);
}
},
recordSigningTime(ms) {
this.signingTimes.push(ms);
},
getSummary() {
this.latencies.sort((a, b) => a - b);
this.signingTimes.sort((a, b) => a - b);
const calcStats = (arr) => {
const p50 = arr[Math.floor(arr.length * 0.5)] || 0;
const p95 = arr[Math.floor(arr.length * 0.95)] || 0;
const p99 = arr[Math.floor(arr.length * 0.99)] || 0;
const avg = arr.reduce((a, b) => a + b, 0) / (arr.length || 1);
return { avg: avg.toFixed(2), p50: p50.toFixed(2), p95: p95.toFixed(2), p99: p99.toFixed(2) };
};
const durationSec = (Date.now() - this.startTime) / 1000;
const latencyStats = calcStats(this.latencies);
const signingStats = calcStats(this.signingTimes);
const cpuUsage = process.cpuUsage(this.startCpu);
const memUsage = process.memoryUsage();
return {
config: CONFIG,
durationSec: durationSec.toFixed(2),
events: {
sent: this.sent,
acked: this.acked,
failed: this.failed,
},
throughput: {
sentPerSec: (this.sent / durationSec).toFixed(2),
ackedPerSec: (this.acked / durationSec).toFixed(2),
},
latencyMs: latencyStats,
signingTimeMs: signingStats,
resources: {
cpuUserSec: (cpuUsage.user / 1e6).toFixed(2),
cpuSystemSec: (cpuUsage.system / 1e6).toFixed(2),
memoryRssMb: (memUsage.rss / 1024 / 1024).toFixed(2),
memoryHeapMb: (memUsage.heapUsed / 1024 / 1024).toFixed(2)
},
errors: this.errors.slice(0, 10), // Sample top 10
hotFunctions: [
{ name: "finalizeEvent (signing)", avgMs: signingStats.avg }
],
bottlenecks: signingStats.avg > 100 ? ["Cryptographic operations (signing)"] : []
};
}
};
// --- Main ---
async function run() {
console.log(`Starting load test with ${CONFIG.clients} clients against ${CONFIG.relay}`);
console.log(`Target rate: ${CONFIG.rate} events/sec`);
console.log(`Duration: ${CONFIG.duration} seconds`);
const clients = [];
for (let i = 0; i < CONFIG.clients; i++) {
clients.push(new LoadClient(CONFIG.relay, i));
}
console.log('Connecting clients...');
await Promise.all(clients.map(c => c.connect().catch(e => null)));
const connectedCount = clients.filter(c => c.connected).length;
console.log(`Connected ${connectedCount}/${CONFIG.clients} clients.`);
if (connectedCount === 0) {
console.error("No clients connected. Aborting.");
process.exit(1);
}
const endTime = Date.now() + (CONFIG.duration * 1000);
const intervalMs = 1000 / CONFIG.rate; // Delay between events to match rate
console.log('Starting load generation...');
// Simple loop to maintain rate
let running = true;
const loop = async () => {
while (running && Date.now() < endTime) {
const startLoop = Date.now();
// Pick random client
const client = clients[Math.floor(Math.random() * clients.length)];
// 10% Video Post, 90% View Event
const isVideo = Math.random() < 0.1;
let event;
const signStart = performance.now();
if (isVideo) {
const content = {
title: `Load Test Video ${Date.now()}`,
description: "Load test content",
videoRootId: `root-${Date.now()}`,
version: 3
};
event = buildVideoPostEvent(client, content);
} else {
event = buildViewEvent(client, "some-video-id");
}
const signEnd = performance.now();
stats.recordSigningTime(signEnd - signStart);
client.publish(event);
const elapsed = Date.now() - startLoop;
const wait = Math.max(0, intervalMs - elapsed);
await delay(wait);
}
};
await loop();
running = false;
console.log('Load generation finished. Waiting for pending ACKs (5s)...');
await delay(5000);
clients.forEach(c => c.close());
const summary = stats.getSummary();
console.log('--- Load Test Report ---');
console.log(JSON.stringify(summary, null, 2));
await mkdir(CONFIG.outputDir, { recursive: true });
// Use fixed date format YYYYMMDD for PR requirements
const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, '');
const filename = path.join(CONFIG.outputDir, `load-report-${dateStr}.json`);
await writeFile(filename, JSON.stringify(summary, null, 2));
console.log(`Report saved to ${filename}`);
}
run().catch(console.error);