mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-10-27 07:51:52 +00:00
feat: implement load-test-agent harness and report
- Create scripts/agent/load-test.mjs with nostr-tools and ws. - Generate load test report in reports/load-test/. - Verify with dry run. - Scheduler cycle completion log for load-test-agent. Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
1 parent
470093d9ad
commit
1dfabdcd66
4 files changed
+226
-138
No files matched your search
+167
-138
@@ -1,172 +1,201 @@
|
||||
import { Relay } from 'nostr-tools/relay';
|
||||
import WebSocket from 'ws';
|
||||
import { useWebSocketImplementation } from 'nostr-tools/relay';
|
||||
import { SimplePool } from 'nostr-tools/pool';
|
||||
import { generateSecretKey, finalizeEvent } from 'nostr-tools/pure';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { ensureDir } from '../../src/utils.mjs';
|
||||
|
||||
// --- Configuration ---
|
||||
useWebSocketImplementation(WebSocket);
|
||||
|
||||
const RELAY_URL = process.env.RELAY_URL;
|
||||
const CLIENTS = parseInt(process.env.CLIENTS || '1000', 10);
|
||||
const DURATION_SEC = parseInt(process.env.DURATION_SEC || '600', 10);
|
||||
const RATE_EPS = parseInt(process.env.RATE_EPS || '10', 10);
|
||||
const MIX = parseFloat(process.env.MIX || '0.9'); // 90% view events, 10% metadata
|
||||
const DRY_RUN = process.env.DRY_RUN === '1';
|
||||
const REPORT_DIR = 'artifacts';
|
||||
const CLIENTS = parseInt(process.env.CLIENTS || '10', 10);
|
||||
const DURATION_SEC = parseInt(process.env.DURATION_SEC || '10', 10);
|
||||
const RATE_EPS = parseInt(process.env.RATE_EPS || '5', 10);
|
||||
const MIX = parseFloat(process.env.MIX || '0.1'); // 10% metadata
|
||||
const DRY_RUN = process.env.DRY_RUN !== '0'; // Default to true for safety
|
||||
|
||||
// --- Helpers ---
|
||||
const KIND_TEXT = 1;
|
||||
const KIND_METADATA = 30078; // App Data as proxy for metadata
|
||||
|
||||
// const nowUnix = () => Math.floor(Date.now() / 1000);
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
console.log('Load Test Configuration:');
|
||||
console.log(` RELAY_URL: ${RELAY_URL || '(none)'}`);
|
||||
console.log(` CLIENTS: ${CLIENTS}`);
|
||||
console.log(` DURATION_SEC: ${DURATION_SEC}`);
|
||||
console.log(` RATE_EPS: ${RATE_EPS}`);
|
||||
console.log(` MIX: ${MIX}`);
|
||||
console.log(` DRY_RUN: ${DRY_RUN}`);
|
||||
|
||||
function ensureDir(dir) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
if (!RELAY_URL && !DRY_RUN) {
|
||||
console.error('ERROR: RELAY_URL is required unless DRY_RUN=1');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// --- Logic ---
|
||||
const stats = {
|
||||
totalAttempts: 0,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
latencySum: 0,
|
||||
latencyMin: Infinity,
|
||||
latencyMax: 0,
|
||||
errors: {},
|
||||
startTime: Date.now(),
|
||||
endTime: 0
|
||||
};
|
||||
|
||||
async function runLoadTest() {
|
||||
console.log(`Starting Load Test Agent...`);
|
||||
console.log(`Config: CLIENTS=${CLIENTS}, DURATION=${DURATION_SEC}s, RATE=${RATE_EPS}eps, MIX=${MIX}, DRY_RUN=${DRY_RUN}`);
|
||||
async function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
if (!RELAY_URL && !DRY_RUN) {
|
||||
console.error('ERROR: RELAY_URL environment variable is required (unless DRY_RUN=1).');
|
||||
process.exit(1);
|
||||
function generateEvent(sk) {
|
||||
const isMetadata = Math.random() < MIX;
|
||||
const kind = isMetadata ? KIND_METADATA : KIND_TEXT;
|
||||
const content = isMetadata
|
||||
? JSON.stringify({ type: 'video-metadata', title: 'Load Test Video', duration: 120, tags: ['test', 'load'] })
|
||||
: `Load test view event ${Date.now()}`;
|
||||
|
||||
const tags = [['t', 'load-test']];
|
||||
if (isMetadata) {
|
||||
tags.push(['d', `load-test-${Date.now()}-${Math.random()}`]);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const endTime = startTime + (DURATION_SEC * 1000);
|
||||
return finalizeEvent({
|
||||
kind,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
content,
|
||||
}, sk);
|
||||
}
|
||||
|
||||
let totalEventsAttempted = 0;
|
||||
let totalEventsSuccess = 0;
|
||||
let totalEventsFailed = 0;
|
||||
let latencies = [];
|
||||
let errors = {};
|
||||
async function main() {
|
||||
const reportDir = 'reports/load-test';
|
||||
ensureDir(reportDir);
|
||||
|
||||
if (DRY_RUN) {
|
||||
console.log('DRY RUN MODE: Simulating load without network calls.');
|
||||
|
||||
while (Date.now() < endTime) {
|
||||
// Simulate rate
|
||||
const batchSize = Math.ceil(RATE_EPS / 10); // Check 10 times per second
|
||||
for (let i = 0; i < batchSize; i++) {
|
||||
totalEventsAttempted++;
|
||||
// Simulate random success/failure and latency
|
||||
const isSuccess = Math.random() > 0.01; // 1% failure
|
||||
if (isSuccess) {
|
||||
totalEventsSuccess++;
|
||||
latencies.push(Math.floor(Math.random() * 50) + 10); // 10-60ms
|
||||
} else {
|
||||
totalEventsFailed++;
|
||||
const errType = Math.random() > 0.5 ? 'Timeout' : 'ConnectionRefused';
|
||||
errors[errType] = (errors[errType] || 0) + 1;
|
||||
}
|
||||
}
|
||||
await sleep(100);
|
||||
console.log('Starting DRY RUN...');
|
||||
const start = Date.now();
|
||||
let eventsGenerated = 0;
|
||||
// Simple simulation loop
|
||||
while (Date.now() - start < DURATION_SEC * 1000) {
|
||||
eventsGenerated++;
|
||||
if (eventsGenerated % RATE_EPS === 0) await sleep(1000);
|
||||
}
|
||||
stats.totalAttempts = eventsGenerated;
|
||||
stats.success = eventsGenerated;
|
||||
stats.endTime = Date.now();
|
||||
stats.latencySum = eventsGenerated * 10; // Fake 10ms latency
|
||||
stats.latencyMin = 5;
|
||||
stats.latencyMax = 15;
|
||||
|
||||
} else {
|
||||
console.log(`Connecting to ${RELAY_URL} with ${CLIENTS} simulated clients (implementation pending, using simplified single-connection for initial harness)...`);
|
||||
|
||||
// For this initial version, we will use a single connection to validate the harness structure
|
||||
// as properly simulating 1000 clients requires more complex setup (workers/etc) which is out of scope for "Minimal footprint".
|
||||
// We will simulate the *rate* but maybe not full connection concurrency in this first pass if strictly constrained.
|
||||
// However, the prompt asks for "Create N clients".
|
||||
// Let's try to create a few clients to prove we can.
|
||||
|
||||
const activeRelays = [];
|
||||
let relay;
|
||||
try {
|
||||
relay = await Relay.connect(RELAY_URL);
|
||||
activeRelays.push(relay);
|
||||
console.log(`Connected to ${RELAY_URL}`);
|
||||
} catch (err) {
|
||||
console.error(`Failed to connect to ${RELAY_URL}:`, err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Generate ephemeral key for signing
|
||||
const sk = generateSecretKey();
|
||||
// const pk = getPublicKey(sk); // Unused for now
|
||||
|
||||
// Load Loop
|
||||
while (Date.now() < endTime) {
|
||||
const batchStart = Date.now();
|
||||
const batchSize = Math.ceil(RATE_EPS / 10);
|
||||
|
||||
const promises = [];
|
||||
for (let i=0; i<batchSize; i++) {
|
||||
totalEventsAttempted++;
|
||||
|
||||
const eventTemplate = {
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: `Load test event ${totalEventsAttempted}`,
|
||||
};
|
||||
|
||||
const event = finalizeEvent(eventTemplate, sk);
|
||||
|
||||
promises.push((async () => {
|
||||
const start = Date.now();
|
||||
try {
|
||||
await relay.publish(event);
|
||||
const latency = Date.now() - start;
|
||||
totalEventsSuccess++;
|
||||
latencies.push(latency);
|
||||
} catch (err) {
|
||||
totalEventsFailed++;
|
||||
const msg = err.message || String(err);
|
||||
errors[msg] = (errors[msg] || 0) + 1;
|
||||
}
|
||||
})());
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
const elapsed = Date.now() - batchStart;
|
||||
if (elapsed < 100) {
|
||||
await sleep(100 - elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
activeRelays.forEach(r => r.close());
|
||||
writeReport(reportDir);
|
||||
return;
|
||||
}
|
||||
|
||||
// Report
|
||||
latencies.sort((a, b) => a - b);
|
||||
const p50 = latencies[Math.floor(latencies.length * 0.5)] || 0;
|
||||
const p90 = latencies[Math.floor(latencies.length * 0.9)] || 0;
|
||||
const p95 = latencies[Math.floor(latencies.length * 0.95)] || 0;
|
||||
const p99 = latencies[Math.floor(latencies.length * 0.99)] || 0;
|
||||
const pool = new SimplePool();
|
||||
const sks = Array.from({ length: CLIENTS }, () => generateSecretKey());
|
||||
|
||||
console.log(`Starting load test against ${RELAY_URL} for ${DURATION_SEC}s...`);
|
||||
|
||||
const start = Date.now();
|
||||
const end = start + DURATION_SEC * 1000;
|
||||
|
||||
const interval = 1000 / RATE_EPS;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timer = setInterval(async () => {
|
||||
if (Date.now() >= end) {
|
||||
clearInterval(timer);
|
||||
pool.close([RELAY_URL]);
|
||||
stats.endTime = Date.now();
|
||||
writeReport(reportDir);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const clientIdx = Math.floor(Math.random() * CLIENTS);
|
||||
const sk = sks[clientIdx];
|
||||
const event = generateEvent(sk);
|
||||
|
||||
stats.totalAttempts++;
|
||||
const sendStart = Date.now();
|
||||
|
||||
try {
|
||||
await Promise.any(pool.publish([RELAY_URL], event));
|
||||
const latency = Date.now() - sendStart;
|
||||
stats.success++;
|
||||
stats.latencySum += latency;
|
||||
stats.latencyMin = Math.min(stats.latencyMin, latency);
|
||||
stats.latencyMax = Math.max(stats.latencyMax, latency);
|
||||
} catch (err) {
|
||||
stats.failed++;
|
||||
const msg = err.message || String(err);
|
||||
stats.errors[msg] = (stats.errors[msg] || 0) + 1;
|
||||
}
|
||||
}, interval);
|
||||
});
|
||||
}
|
||||
|
||||
function writeReport(dir) {
|
||||
const duration = (stats.endTime - stats.startTime) / 1000;
|
||||
const throughput = stats.success / duration;
|
||||
const avgLatency = stats.success > 0 ? stats.latencySum / stats.success : 0;
|
||||
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
config: {
|
||||
relayUrl: DRY_RUN ? 'N/A (Dry Run)' : RELAY_URL,
|
||||
relayUrl: RELAY_URL,
|
||||
clients: CLIENTS,
|
||||
durationSec: DURATION_SEC,
|
||||
rateEps: RATE_EPS,
|
||||
mix: MIX
|
||||
mix: MIX,
|
||||
dryRun: DRY_RUN
|
||||
},
|
||||
summary: {
|
||||
totalEventsAttempted,
|
||||
totalEventsSuccess,
|
||||
totalEventsFailed,
|
||||
throughputEps: totalEventsSuccess / DURATION_SEC,
|
||||
latencies: { p50, p90, p95, p99 }
|
||||
results: {
|
||||
totalAttempts: stats.totalAttempts,
|
||||
success: stats.success,
|
||||
failed: stats.failed,
|
||||
throughputEps: throughput,
|
||||
latency: {
|
||||
min: stats.latencyMin === Infinity ? 0 : stats.latencyMin,
|
||||
max: stats.latencyMax,
|
||||
avg: avgLatency
|
||||
},
|
||||
errors: stats.errors
|
||||
},
|
||||
errors
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
ensureDir(REPORT_DIR);
|
||||
const reportPath = path.join(REPORT_DIR, `load-report-${new Date().toISOString().split('T')[0]}.json`);
|
||||
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
|
||||
console.log(`Report generated at ${reportPath}`);
|
||||
const dateStr = new Date().toISOString().slice(0, 10);
|
||||
const jsonPath = path.join(dir, `load-report-${dateStr}.json`);
|
||||
fs.writeFileSync(jsonPath, JSON.stringify(report, null, 2));
|
||||
console.log(`JSON report written to ${jsonPath}`);
|
||||
|
||||
const mdPath = path.join(dir, `load-test-report-${dateStr}.md`);
|
||||
const mdContent = `
|
||||
# Load Test Report - ${dateStr}
|
||||
|
||||
## Configuration
|
||||
- **Relay**: ${RELAY_URL || '(dry run)'}
|
||||
- **Clients**: ${CLIENTS}
|
||||
- **Duration**: ${DURATION_SEC}s
|
||||
- **Rate**: ${RATE_EPS} eps
|
||||
- **Mix**: ${MIX}
|
||||
- **Dry Run**: ${DRY_RUN}
|
||||
|
||||
## Results
|
||||
- **Total Attempts**: ${stats.totalAttempts}
|
||||
- **Success**: ${stats.success}
|
||||
- **Failed**: ${stats.failed}
|
||||
- **Throughput**: ${throughput.toFixed(2)} events/sec
|
||||
- **Latency (Avg)**: ${avgLatency.toFixed(2)} ms
|
||||
- **Latency (Min/Max)**: ${stats.latencyMin === Infinity ? 0 : stats.latencyMin} / ${stats.latencyMax} ms
|
||||
|
||||
## Errors
|
||||
\`\`\`json
|
||||
${JSON.stringify(stats.errors, null, 2)}
|
||||
\`\`\`
|
||||
`;
|
||||
fs.writeFileSync(mdPath, mdContent.trim());
|
||||
console.log(`Markdown report written to ${mdPath}`);
|
||||
}
|
||||
|
||||
runLoadTest().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
main().catch(console.error);
|
||||
Reference in new issue
Block a user