diff --git a/artifacts/load-report-2026-01-17.json b/artifacts/load-report-2026-01-17.json new file mode 100644 index 00000000..59be98a3 --- /dev/null +++ b/artifacts/load-report-2026-01-17.json @@ -0,0 +1,39 @@ +{ + "config": { + "relayUrl": "ws://127.0.0.1:3333", + "numClients": 100, + "durationSec": 10, + "eventsPerSec": 50, + "reportFile": "artifacts/load-report-2026-01-17.json", + "useInternalRelay": false + }, + "metrics": { + "durationActual": 10.393, + "totalSent": 505, + "totalReceived": 443, + "errors": 0, + "throughput": { + "sentPerSec": 48.590397382853844, + "receivedPerSec": 42.624843644760894, + "bytesSentPerSec": 66310.88232464159, + "bytesReceivedPerSec": 598279.8999326469 + }, + "latency": { + "avgRoundtripMs": 12.214446952595937, + "avgPubAckMs": 12.590361445783133, + "p95RoundtripMs": 24, + "p99RoundtripMs": 35 + }, + "messageTypes": { + "OK": 505, + "EOSE": 10, + "EVENT": 4430 + }, + "resourceUsage": { + "user": 3681110, + "system": 245713 + } + }, + "bottlenecks": [], + "remediation": [] +} \ No newline at end of file diff --git a/artifacts/load-report.json b/artifacts/load-report.json new file mode 100644 index 00000000..59be98a3 --- /dev/null +++ b/artifacts/load-report.json @@ -0,0 +1,39 @@ +{ + "config": { + "relayUrl": "ws://127.0.0.1:3333", + "numClients": 100, + "durationSec": 10, + "eventsPerSec": 50, + "reportFile": "artifacts/load-report-2026-01-17.json", + "useInternalRelay": false + }, + "metrics": { + "durationActual": 10.393, + "totalSent": 505, + "totalReceived": 443, + "errors": 0, + "throughput": { + "sentPerSec": 48.590397382853844, + "receivedPerSec": 42.624843644760894, + "bytesSentPerSec": 66310.88232464159, + "bytesReceivedPerSec": 598279.8999326469 + }, + "latency": { + "avgRoundtripMs": 12.214446952595937, + "avgPubAckMs": 12.590361445783133, + "p95RoundtripMs": 24, + "p99RoundtripMs": 35 + }, + "messageTypes": { + "OK": 505, + "EOSE": 10, + "EVENT": 4430 + }, + "resourceUsage": { + "user": 3681110, + "system": 245713 + } + }, + "bottlenecks": [], + "remediation": [] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 1e006742..069334c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "@floating-ui/dom": "^1.7.4", "path": "^0.12.7", - "webtorrent": "^1.9.7" + "webtorrent": "^1.9.7", + "ws": "^8.19.0" }, "devDependencies": { "@playwright/test": "^1.57.0", @@ -6344,10 +6345,9 @@ } }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "dev": true, + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index 2cfd9afd..2208b210 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "dependencies": { "@floating-ui/dom": "^1.7.4", "path": "^0.12.7", - "webtorrent": "^1.9.7" + "webtorrent": "^1.9.7", + "ws": "^8.19.0" } } diff --git a/scripts/agent/load-test.mjs b/scripts/agent/load-test.mjs new file mode 100644 index 00000000..f5838c6f --- /dev/null +++ b/scripts/agent/load-test.mjs @@ -0,0 +1,330 @@ +import { WebSocket } from 'ws'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools'; +import { spawn } from 'node:child_process'; +import { writeFile, mkdir } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; + +globalThis.WebSocket = WebSocket; + +const CONFIG = { + relayUrl: process.env.RELAY_URL || 'ws://127.0.0.1:3333', + numClients: parseInt(process.env.NUM_CLIENTS || '1000', 10), + durationSec: parseInt(process.env.DURATION_SEC || '60', 10), + eventsPerSec: parseInt(process.env.RATE || '20', 10), + reportFile: process.env.REPORT_FILE || `artifacts/load-report-${new Date().toISOString().split('T')[0]}.json`, + useInternalRelay: !process.env.RELAY_URL +}; + +function isLocalRelay(url) { + if (!url) return true; + try { + const u = new URL(url); + const host = u.hostname; + if (host === 'localhost' || host === '127.0.0.1' || host === '[::1]') return true; + if (host.startsWith('192.168.') || host.startsWith('10.') || host.match(/^172\.(1[6-9]|2\d|3[0-1])\./)) return true; + return false; + } catch { return false; } +} + +// Metrics +const stats = { + startTime: Date.now(), + sent: 0, + received: 0, + errors: 0, + latencies: [], // Roundtrip (ms) + pubAckLatencies: [], // Publish -> OK (ms) + bytesSent: 0, + bytesReceived: 0, + msgTypeCounts: {} +}; + +// Clients +const clients = []; +let relayProcess = null; + +async function startRelay() { + if (!CONFIG.useInternalRelay) return; + + console.log('Starting internal relay...'); + const relayScript = resolve('scripts/agent/simple-relay.mjs'); + + relayProcess = spawn('node', [relayScript], { + stdio: 'inherit', + env: { ...process.env, PORT: '3333' } + }); + + // Wait for it to be ready (dumb wait) + await new Promise(r => setTimeout(r, 2000)); +} + +function createClient(index) { + const sk = generateSecretKey(); + const pk = getPublicKey(sk); + + return new Promise((resolve, reject) => { + // Add timeout + const timeout = setTimeout(() => { + reject(new Error(`Client ${index} connection timeout`)); + try { ws.close(); } catch {} + }, 5000); + + const ws = new WebSocket(CONFIG.relayUrl); + + const client = { + id: index, + ws, + sk, + pk, + subs: new Map(), + pendingPublishes: new Map() // eventId -> startTime + }; + + ws.on('open', () => { + clearTimeout(timeout); + resolve(client); + }); + + ws.on('error', (err) => { + // console.error(`Client ${index} error:`, err.message); + stats.errors++; + if (ws.readyState === WebSocket.CONNECTING) reject(err); + }); + + ws.on('message', (data) => { + const size = data.length; + stats.bytesReceived += size; + + try { + const msg = JSON.parse(data.toString()); + const type = msg[0]; + + stats.msgTypeCounts[type] = (stats.msgTypeCounts[type] || 0) + 1; + + if (type === 'OK') { + const [_, eventId, success, info] = msg; + if (client.pendingPublishes.has(eventId)) { + const start = client.pendingPublishes.get(eventId); + const latency = Date.now() - start; + stats.pubAckLatencies.push(latency); + client.pendingPublishes.delete(eventId); + } + if (!success) { + stats.errors++; + // console.warn(`Publish failed for ${eventId}: ${info}`); + } + } else if (type === 'EVENT') { + const [_, subId, event] = msg; + // Calculate roundtrip if we tracked this event + // We can attach a 'created_at' but that's in seconds. + // We'll rely on a local map of sent events if we want precise RT, + // or we can embed a high-res timestamp in the content if we want. + // Let's use a global map for roundtrip tracking since any client might receive it. + + if (pendingRoundtrips.has(event.id)) { + const start = pendingRoundtrips.get(event.id); + const latency = Date.now() - start; + stats.latencies.push(latency); + stats.received++; + pendingRoundtrips.delete(event.id); // Only count first receipt? Or Average? + // To avoid memory leak, we delete. For load test, first receipt is good enough proxy for propagation. + } + } + } catch (e) { + stats.errors++; + } + }); + }); +} + +const pendingRoundtrips = new Map(); // eventId -> startTime + +async function runTest() { + if (!CONFIG.useInternalRelay && !isLocalRelay(CONFIG.relayUrl) && !process.env.FORCE_PUBLIC) { + console.error("Error: Configured relay appears to be public. Use FORCE_PUBLIC=1 to override."); + process.exit(1); + } + + await startRelay(); + + console.log(`Connecting ${CONFIG.numClients} clients to ${CONFIG.relayUrl}...`); + + // Batch connections to avoid connection storm + const BATCH_SIZE = 50; + for (let i = 0; i < CONFIG.numClients; i += BATCH_SIZE) { + const batchPromises = []; + for (let j = 0; j < BATCH_SIZE && i + j < CONFIG.numClients; j++) { + batchPromises.push(createClient(i + j)); + } + try { + const batch = await Promise.all(batchPromises); + clients.push(...batch); + if ((i + BATCH_SIZE) % 500 === 0) console.log(`Connected ${clients.length}/${CONFIG.numClients}`); + } catch (e) { + console.error('Failed to connect clients:', e); + process.exit(1); + } + } + + console.log('Clients connected. Subscribing...'); + + // Subscribe all clients to everything (or a subset to avoid explosion) + // If 1000 clients subscribe to everything, 1 publish = 1000 receives. + // 10 events/sec = 10,000 messages/sec. That's a lot. + // Let's have clients subscribe to a global filter but maybe we only have a subset of *listening* clients? + // Or simpler: Each client subscribes to itself, and we publish to random clients. + // But prompt says "simulate N clients ... connecting to the relay". + // And "Publish ... mixture ...". + // To measure roundtrip, someone needs to receive. + // Let's have 10% of clients be "listeners" that subscribe to a wildcard (or specific kinds). + + const listenerCount = Math.max(1, Math.floor(CONFIG.numClients * 0.1)); + console.log(`Setting up ${listenerCount} listeners...`); + + for (let i = 0; i < listenerCount; i++) { + const client = clients[i]; + const subId = 'sub1'; + // Subscribe to the kinds we publish + const req = ['REQ', subId, { kinds: [30078, 30079] }]; + client.ws.send(JSON.stringify(req)); + } + + console.log(`Starting load generation for ${CONFIG.durationSec} seconds...`); + + // Cleanup stale roundtrips + const cleanupInterval = setInterval(() => { + const now = Date.now(); + for (const [id, start] of pendingRoundtrips) { + if (now - start > 10000) pendingRoundtrips.delete(id); + } + }, 5000); + cleanupInterval.unref(); + + // Better loop + let running = true; + const sleep = (ms) => new Promise(r => setTimeout(r, ms)); + + const runLoop = async () => { + while (running) { + const startTick = Date.now(); + + // How many to send this tick (simulating 100ms ticks) + const batchSize = Math.ceil(CONFIG.eventsPerSec / 10); + + for (let k = 0; k < batchSize; k++) { + const client = clients[Math.floor(Math.random() * clients.length)]; + const isLarge = Math.random() > 0.5; + + const kind = isLarge ? 30078 : 30079; + const content = isLarge + ? JSON.stringify({ version: 3, title: "Load Test Video", description: "x".repeat(2000) }) + : "view"; + + const event = { + kind, + created_at: Math.floor(Date.now() / 1000), + tags: [['t', isLarge ? 'video' : 'view']], + content, + pubkey: client.pk, + }; + + try { + const signedEvent = finalizeEvent(event, client.sk); + + client.pendingPublishes.set(signedEvent.id, Date.now()); + pendingRoundtrips.set(signedEvent.id, Date.now()); + + const msg = JSON.stringify(['EVENT', signedEvent]); + client.ws.send(msg); + stats.sent++; + stats.bytesSent += msg.length; + } catch(e) { + stats.errors++; + } + } + + const elapsed = Date.now() - startTick; + const wait = Math.max(0, 100 - elapsed); + await sleep(wait); + } + }; + + const loopPromise = runLoop(); + + // Wait for duration + await new Promise(r => setTimeout(r, CONFIG.durationSec * 1000)); + running = false; + await loopPromise; + + console.log('Test finished. Disconnecting...'); + + // Cleanup + clients.forEach(c => c.ws.close()); + if (relayProcess) { + relayProcess.kill(); + } + + // Report + await generateReport(); +} + +async function generateReport() { + const duration = (Date.now() - stats.startTime) / 1000; + + // Stats + const avgLatency = stats.latencies.reduce((a, b) => a + b, 0) / (stats.latencies.length || 1); + const avgPubAck = stats.pubAckLatencies.reduce((a, b) => a + b, 0) / (stats.pubAckLatencies.length || 1); + + const report = { + config: CONFIG, + metrics: { + durationActual: duration, + totalSent: stats.sent, + totalReceived: stats.received, + errors: stats.errors, + throughput: { + sentPerSec: stats.sent / duration, + receivedPerSec: stats.received / duration, + bytesSentPerSec: stats.bytesSent / duration, + bytesReceivedPerSec: stats.bytesReceived / duration + }, + latency: { + avgRoundtripMs: avgLatency, + avgPubAckMs: avgPubAck, + p95RoundtripMs: percentile(stats.latencies, 95), + p99RoundtripMs: percentile(stats.latencies, 99) + }, + messageTypes: stats.msgTypeCounts, + resourceUsage: process.cpuUsage() + }, + bottlenecks: [], // Placeholder + remediation: [] // Placeholder + }; + + // Basic analysis + if (avgLatency > 1000) { + report.bottlenecks.push("High average latency (>1s)"); + report.remediation.push("Check relay CPU or network bandwidth"); + } + if (stats.errors > 0) { + report.bottlenecks.push(`Observed ${stats.errors} errors`); + } + + // Ensure artifacts dir exists (created in previous step, but safe to check) + const reportPath = resolve(CONFIG.reportFile); + await mkdir(dirname(reportPath), { recursive: true }); + + writeFile(reportPath, JSON.stringify(report, null, 2)) + .then(() => console.log(`Report written to ${reportPath}`)) + .catch(e => console.error('Failed to write report:', e)); +} + +function percentile(arr, p) { + if (arr.length === 0) return 0; + arr.sort((a, b) => a - b); + const index = Math.floor((p / 100) * arr.length); + return arr[index]; +} + +runTest().catch(console.error); diff --git a/scripts/agent/simple-relay.mjs b/scripts/agent/simple-relay.mjs new file mode 100644 index 00000000..048d129b --- /dev/null +++ b/scripts/agent/simple-relay.mjs @@ -0,0 +1,91 @@ +import { WebSocketServer } from 'ws'; +import { matchFilters } from 'nostr-tools'; + +const PORT = process.env.PORT || 3333; +const wss = new WebSocketServer({ port: PORT }); + +// In-memory event storage +const events = []; + +// Map> +const subscriptions = new Map(); + +console.log(`Simple Relay running on port ${PORT}`); + +wss.on('connection', (ws) => { + subscriptions.set(ws, new Map()); + + ws.on('message', (message) => { + try { + const raw = message.toString(); // Ensure string + let data; + try { + data = JSON.parse(raw); + } catch (e) { + // Ignore malformed JSON + return; + } + + if (!Array.isArray(data)) return; + + const [type, ...payload] = data; + + if (type === 'EVENT') { + const event = payload[0]; + if (!event || !event.id) return; + + // Dedup + if (events.some(e => e.id === event.id)) { + ws.send(JSON.stringify(['OK', event.id, true, 'duplicate'])); + return; + } + + events.push(event); + ws.send(JSON.stringify(['OK', event.id, true, ''])); + + // Broadcast + for (const [client, clientSubs] of subscriptions) { + if (client.readyState !== 1) continue; // 1 = OPEN + for (const [subId, filters] of clientSubs) { + if (matchFilters(filters, event)) { + client.send(JSON.stringify(['EVENT', subId, event])); + } + } + } + + } else if (type === 'REQ') { + const subId = payload[0]; + const filters = payload.slice(1); + + subscriptions.get(ws).set(subId, filters); + + // Send stored events + for (const event of events) { + if (matchFilters(filters, event)) { + ws.send(JSON.stringify(['EVENT', subId, event])); + } + } + ws.send(JSON.stringify(['EOSE', subId])); + + } else if (type === 'CLOSE') { + const subId = payload[0]; + subscriptions.get(ws).delete(subId); + } + } catch (e) { + console.error('Relay error:', e); + } + }); + + ws.on('close', () => { + subscriptions.delete(ws); + }); + + ws.on('error', (err) => { + console.error('Client error:', err); + }); +}); + +process.on('SIGINT', () => { + wss.close(); + process.exit(); +});