|
| 1 | +import { Runloop } from "@runloop/api-client"; |
| 2 | + |
| 3 | +const REQUEST_COUNT = parseInt(process.env.REQUEST_COUNT ?? "100000", 10); |
| 4 | +const RUNLOOP_BASE_URL = process.env.RUNLOOP_BASE_URL; |
| 5 | +const USE_HTTP2 = process.env.USE_HTTP2 === "1"; |
| 6 | +const PROGRESS_INTERVAL_MS = 2000; |
| 7 | + |
| 8 | +function buildClient(): Runloop { |
| 9 | + const opts: ConstructorParameters<typeof Runloop>[0] = { |
| 10 | + maxRetries: 0, |
| 11 | + timeout: 120_000, |
| 12 | + }; |
| 13 | + if (RUNLOOP_BASE_URL) { |
| 14 | + opts.baseURL = RUNLOOP_BASE_URL; |
| 15 | + } |
| 16 | + if (USE_HTTP2) { |
| 17 | + (opts as any).http2 = true; |
| 18 | + } |
| 19 | + return new Runloop(opts); |
| 20 | +} |
| 21 | + |
| 22 | +interface RequestResult { |
| 23 | + index: number; |
| 24 | + latencyMs: number; |
| 25 | + status: number | null; |
| 26 | + error: string | null; |
| 27 | +} |
| 28 | + |
| 29 | +async function sendRequest( |
| 30 | + client: Runloop, |
| 31 | + index: number, |
| 32 | + runId: string, |
| 33 | +): Promise<RequestResult> { |
| 34 | + const start = performance.now(); |
| 35 | + try { |
| 36 | + await client.devboxes.create({ |
| 37 | + blueprint_id: "bp_nonexistent_loadtest_00000", |
| 38 | + name: `loadtest-${runId}-${index}`, |
| 39 | + environment_variables: { |
| 40 | + TEST_VAR_1: "value_one", |
| 41 | + TEST_VAR_2: "value_two", |
| 42 | + }, |
| 43 | + metadata: { |
| 44 | + test_run: runId, |
| 45 | + index: String(index), |
| 46 | + }, |
| 47 | + launch_parameters: { |
| 48 | + resource_size_request: "SMALL", |
| 49 | + keep_alive_time_seconds: 300, |
| 50 | + }, |
| 51 | + }); |
| 52 | + return { |
| 53 | + index, |
| 54 | + latencyMs: performance.now() - start, |
| 55 | + status: 200, |
| 56 | + error: null, |
| 57 | + }; |
| 58 | + } catch (err: any) { |
| 59 | + return { |
| 60 | + index, |
| 61 | + latencyMs: performance.now() - start, |
| 62 | + status: err?.status ?? null, |
| 63 | + error: err?.message ?? String(err), |
| 64 | + }; |
| 65 | + } |
| 66 | +} |
| 67 | + |
| 68 | +function percentile(sorted: number[], p: number): number { |
| 69 | + const idx = Math.ceil((p / 100) * sorted.length) - 1; |
| 70 | + return sorted[Math.max(0, idx)]; |
| 71 | +} |
| 72 | + |
| 73 | +function printMetrics(results: RequestResult[], wallClockMs: number): void { |
| 74 | + const latencies = results.map((r) => r.latencyMs).sort((a, b) => a - b); |
| 75 | + |
| 76 | + const statusCounts = new Map<string, number>(); |
| 77 | + for (const r of results) { |
| 78 | + const key = r.status != null ? String(r.status) : "network_error"; |
| 79 | + statusCounts.set(key, (statusCounts.get(key) ?? 0) + 1); |
| 80 | + } |
| 81 | + |
| 82 | + console.log("\n=== Load Test Results ==="); |
| 83 | + console.log(`Requests: ${results.length}`); |
| 84 | + console.log(`Wall clock: ${(wallClockMs / 1000).toFixed(2)}s`); |
| 85 | + console.log( |
| 86 | + `Throughput: ${(results.length / (wallClockMs / 1000)).toFixed(1)} req/s`, |
| 87 | + ); |
| 88 | + console.log(""); |
| 89 | + console.log("Latency (ms):"); |
| 90 | + console.log(` min: ${latencies[0].toFixed(1)}`); |
| 91 | + console.log(` p50: ${percentile(latencies, 50).toFixed(1)}`); |
| 92 | + console.log(` p90: ${percentile(latencies, 90).toFixed(1)}`); |
| 93 | + console.log(` p95: ${percentile(latencies, 95).toFixed(1)}`); |
| 94 | + console.log(` p99: ${percentile(latencies, 99).toFixed(1)}`); |
| 95 | + console.log(` max: ${latencies[latencies.length - 1].toFixed(1)}`); |
| 96 | + console.log(""); |
| 97 | + console.log("Status codes:"); |
| 98 | + for (const [status, count] of [...statusCounts.entries()].sort()) { |
| 99 | + console.log(` ${status}: ${count}`); |
| 100 | + } |
| 101 | +} |
| 102 | + |
| 103 | +async function main(): Promise<void> { |
| 104 | + const fdLimit = await checkFileDescriptorLimit(); |
| 105 | + if (!USE_HTTP2 && fdLimit < 10000) { |
| 106 | + console.warn( |
| 107 | + `\nWARNING: File descriptor limit is ${fdLimit}. For 100k HTTP/1.1 requests, run:\n` + |
| 108 | + ` ulimit -n 65536\n` + |
| 109 | + `Or use HTTP/2 multiplexing: USE_HTTP2=1\n`, |
| 110 | + ); |
| 111 | + } |
| 112 | + |
| 113 | + const client = buildClient(); |
| 114 | + const runId = `run-${Date.now()}`; |
| 115 | + |
| 116 | + console.log(`Starting load test: ${REQUEST_COUNT} concurrent requests`); |
| 117 | + console.log(`Run ID: ${runId}`); |
| 118 | + console.log(`HTTP mode: ${USE_HTTP2 ? "HTTP/2 (undici)" : "HTTP/1.1 (node-fetch)"}`); |
| 119 | + console.log(`Base URL: ${RUNLOOP_BASE_URL ?? "(SDK default)"}`); |
| 120 | + console.log(`File descriptor limit: ${fdLimit}`); |
| 121 | + console.log(""); |
| 122 | + |
| 123 | + let completed = 0; |
| 124 | + const progressTimer = setInterval(() => { |
| 125 | + console.log( |
| 126 | + ` progress: ${completed}/${REQUEST_COUNT} (${((completed / REQUEST_COUNT) * 100).toFixed(1)}%)`, |
| 127 | + ); |
| 128 | + }, PROGRESS_INTERVAL_MS); |
| 129 | + |
| 130 | + const wallStart = performance.now(); |
| 131 | + |
| 132 | + const promises = Array.from({ length: REQUEST_COUNT }, (_, i) => |
| 133 | + sendRequest(client, i, runId).then((result) => { |
| 134 | + completed++; |
| 135 | + return result; |
| 136 | + }), |
| 137 | + ); |
| 138 | + |
| 139 | + const results = await Promise.all(promises); |
| 140 | + |
| 141 | + const wallClockMs = performance.now() - wallStart; |
| 142 | + clearInterval(progressTimer); |
| 143 | + |
| 144 | + printMetrics(results, wallClockMs); |
| 145 | +} |
| 146 | + |
| 147 | +async function checkFileDescriptorLimit(): Promise<number> { |
| 148 | + try { |
| 149 | + const { execSync } = await import("child_process"); |
| 150 | + return parseInt(execSync("ulimit -n", { encoding: "utf-8" }).trim(), 10); |
| 151 | + } catch { |
| 152 | + return -1; |
| 153 | + } |
| 154 | +} |
| 155 | + |
| 156 | +main().catch((err) => { |
| 157 | + console.error("Fatal error:", err); |
| 158 | + process.exit(1); |
| 159 | +}); |
0 commit comments