mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
- Executed load-test-agent task. - Created `scripts/agent/load-test.mjs` for relay benchmarking. - Generated sample load report `artifacts/load-report-20260221.json`. - Added completion log `docs/agents/task-logs/daily/2026-02-21_load-test-agent_completed.md`. Co-authored-by: PR0M3TH3AN <[email protected]>
279 lines
7.8 KiB
JavaScript
279 lines
7.8 KiB
JavaScript
import WebSocket from 'ws';
|
|
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools';
|
|
import { buildVideoPostEvent, buildViewEvent, NOTE_TYPES } from '../../js/nostrEventSchemas.js';
|
|
import { writeFileSync, mkdirSync } from 'fs';
|
|
import { dirname, join } from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
|
// Configuration
|
|
const config = {
|
|
relayUrl: process.env.RELAY_URL,
|
|
clients: parseInt(process.env.CLIENTS || '1000', 10),
|
|
durationSec: parseInt(process.env.DURATION_SEC || '600', 10),
|
|
rateEps: parseFloat(process.env.RATE_EPS || '10'),
|
|
mix: process.env.MIX || 'video:0.1,view:0.9',
|
|
seed: process.env.SEED, // Not fully implementing deterministic seed for now, using Math.random
|
|
dryRun: process.env.DRY_RUN === '1',
|
|
};
|
|
|
|
if (!config.relayUrl) {
|
|
console.error('Error: RELAY_URL environment variable is required.');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Check for public relays to avoid accidents
|
|
const PUBLIC_RELAY_KEYWORDS = ['damus', 'primal', 'nostr.land', 'nos.lol'];
|
|
if (PUBLIC_RELAY_KEYWORDS.some(k => config.relayUrl.includes(k)) && !process.env.FORCE_PUBLIC) {
|
|
console.error('Error: RELAY_URL appears to be a public relay. Aborting for safety.');
|
|
console.error('Set FORCE_PUBLIC=1 if you really mean to do this (not recommended).');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Parse Mix
|
|
const mix = {};
|
|
config.mix.split(',').forEach(part => {
|
|
const [type, ratio] = part.split(':');
|
|
mix[type] = parseFloat(ratio);
|
|
});
|
|
|
|
// Normalize mix
|
|
const totalRatio = Object.values(mix).reduce((a, b) => a + b, 0);
|
|
for (const key in mix) {
|
|
mix[key] /= totalRatio;
|
|
}
|
|
|
|
console.log('Load Test Configuration:', JSON.stringify(config, null, 2));
|
|
console.log('Event Mix:', mix);
|
|
|
|
// Metrics
|
|
const metrics = {
|
|
startTime: Date.now(),
|
|
sent: 0,
|
|
success: 0,
|
|
failed: 0,
|
|
latencies: [], // in ms
|
|
errors: {},
|
|
};
|
|
|
|
function recordError(code) {
|
|
metrics.errors[code] = (metrics.errors[code] || 0) + 1;
|
|
}
|
|
|
|
// Client Class
|
|
class LoadClient {
|
|
constructor(id, relayUrl) {
|
|
this.id = id;
|
|
this.relayUrl = relayUrl;
|
|
this.ws = null;
|
|
this.connected = false;
|
|
this.sk = generateSecretKey();
|
|
this.pk = getPublicKey(this.sk);
|
|
this.pending = new Map(); // eventId -> startTime
|
|
}
|
|
|
|
connect() {
|
|
return new Promise((resolve, reject) => {
|
|
if (config.dryRun) {
|
|
this.connected = true;
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
this.ws = new WebSocket(this.relayUrl);
|
|
|
|
this.ws.on('open', () => {
|
|
this.connected = true;
|
|
resolve();
|
|
});
|
|
|
|
this.ws.on('message', (data) => {
|
|
try {
|
|
const msg = JSON.parse(data);
|
|
// ["OK", eventId, true, "saved"]
|
|
if (Array.isArray(msg) && msg[0] === 'OK') {
|
|
const eventId = msg[1];
|
|
const accepted = msg[2];
|
|
const reason = msg[3];
|
|
|
|
if (this.pending.has(eventId)) {
|
|
const start = this.pending.get(eventId);
|
|
this.pending.delete(eventId);
|
|
const latency = Date.now() - start;
|
|
|
|
if (accepted) {
|
|
metrics.success++;
|
|
metrics.latencies.push(latency);
|
|
} else {
|
|
metrics.failed++;
|
|
recordError(reason || 'rejected');
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
recordError('json_parse_error');
|
|
}
|
|
});
|
|
|
|
this.ws.on('error', (err) => {
|
|
recordError('ws_error');
|
|
// console.error(`Client ${this.id} error:`, err.message);
|
|
});
|
|
|
|
this.ws.on('close', () => {
|
|
this.connected = false;
|
|
});
|
|
});
|
|
}
|
|
|
|
sendEvent(event) {
|
|
if (config.dryRun) {
|
|
metrics.success++;
|
|
return;
|
|
}
|
|
|
|
if (!this.connected) return;
|
|
|
|
// Sign event
|
|
const signedEvent = finalizeEvent(event, this.sk);
|
|
|
|
this.pending.set(signedEvent.id, Date.now());
|
|
this.ws.send(JSON.stringify(['EVENT', signedEvent]));
|
|
metrics.sent++;
|
|
}
|
|
|
|
close() {
|
|
if (this.ws) {
|
|
this.ws.terminate();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Main Execution
|
|
async function run() {
|
|
console.log(`Initializing ${config.clients} clients...`);
|
|
const clients = [];
|
|
|
|
// Create clients
|
|
for (let i = 0; i < config.clients; i++) {
|
|
clients.push(new LoadClient(i, config.relayUrl));
|
|
}
|
|
|
|
// Connect clients in batches to avoid OS limits/thundering herd
|
|
const BATCH_SIZE = 50;
|
|
for (let i = 0; i < clients.length; i += BATCH_SIZE) {
|
|
const batch = clients.slice(i, i + BATCH_SIZE);
|
|
await Promise.all(batch.map(c => c.connect().catch(e => recordError('connect_error'))));
|
|
if (i % 500 === 0 && i > 0) console.log(`Connected ${i} clients...`);
|
|
}
|
|
console.log(`All ${config.clients} clients initialized.`);
|
|
|
|
// Event Loop
|
|
const intervalMs = 1000 / config.rateEps;
|
|
const endTime = Date.now() + (config.durationSec * 1000);
|
|
|
|
console.log(`Starting load test for ${config.durationSec} seconds at ${config.rateEps} EPS...`);
|
|
|
|
const loop = setInterval(() => {
|
|
if (Date.now() >= endTime) {
|
|
clearInterval(loop);
|
|
finish();
|
|
return;
|
|
}
|
|
|
|
// Pick a random connected client
|
|
const client = clients[Math.floor(Math.random() * clients.length)];
|
|
if (!client || !client.connected) return;
|
|
|
|
// Determine event type
|
|
const rand = Math.random();
|
|
let type = 'view';
|
|
let cumulative = 0;
|
|
for (const [t, r] of Object.entries(mix)) {
|
|
cumulative += r;
|
|
if (rand < cumulative) {
|
|
type = t;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Build event
|
|
let event;
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
if (type === 'video') {
|
|
event = buildVideoPostEvent({
|
|
pubkey: client.pk, // Will be overridden by finalizeEvent but good for builder
|
|
created_at: now,
|
|
dTagValue: `load-test-${client.id}-${Date.now()}`,
|
|
content: {
|
|
version: 3,
|
|
title: `Load Test Video ${Date.now()}`,
|
|
videoRootId: `load-test-${client.id}-${Date.now()}`,
|
|
url: `https://example.com/video-${Date.now()}.mp4`,
|
|
mode: 'dev'
|
|
}
|
|
});
|
|
} else {
|
|
// View event
|
|
event = buildViewEvent({
|
|
pubkey: client.pk,
|
|
created_at: now,
|
|
content: JSON.stringify({ eventId: 'test-event-id', duration: 10 }),
|
|
includeSessionTag: true
|
|
});
|
|
}
|
|
|
|
// Client signs and sends
|
|
client.sendEvent(event);
|
|
|
|
}, intervalMs);
|
|
|
|
function finish() {
|
|
console.log('Test finished. Closing clients...');
|
|
clients.forEach(c => c.close());
|
|
|
|
// Calculate stats
|
|
const duration = (Date.now() - metrics.startTime) / 1000;
|
|
metrics.latencies.sort((a, b) => a - b);
|
|
|
|
const p50 = metrics.latencies[Math.floor(metrics.latencies.length * 0.5)] || 0;
|
|
const p90 = metrics.latencies[Math.floor(metrics.latencies.length * 0.9)] || 0;
|
|
const p95 = metrics.latencies[Math.floor(metrics.latencies.length * 0.95)] || 0;
|
|
const p99 = metrics.latencies[Math.floor(metrics.latencies.length * 0.99)] || 0;
|
|
|
|
const report = {
|
|
config: { ...config, relayUrl: config.relayUrl.replace(/:\/\/[^@]+@/, '://***@') }, // Redact auth if present
|
|
summary: {
|
|
duration,
|
|
totalSent: metrics.sent,
|
|
totalSuccess: metrics.success,
|
|
totalFailed: metrics.failed,
|
|
throughputEps: metrics.success / duration,
|
|
latency: { p50, p90, p95, p99 }
|
|
},
|
|
errors: metrics.errors
|
|
};
|
|
|
|
console.log('Report:', JSON.stringify(report, null, 2));
|
|
|
|
// Save report
|
|
const artifactsDir = join(process.cwd(), 'artifacts');
|
|
// Ensure artifacts dir exists (mkdirSync recursive)
|
|
mkdirSync(artifactsDir, { recursive: true });
|
|
|
|
const dateStr = new Date().toISOString().split('T')[0].replace(/-/g, '');
|
|
const filename = `load-report-${dateStr}.json`;
|
|
const filepath = join(artifactsDir, filename);
|
|
|
|
writeFileSync(filepath, JSON.stringify(report, null, 2));
|
|
console.log(`Saved report to ${filepath}`);
|
|
}
|
|
}
|
|
|
|
run().catch(err => {
|
|
console.error('Fatal error:', err);
|
|
process.exit(1);
|
|
});
|