|
| 1 | +// Local load-test harness for the blast-radius Temporal pipeline. Not meant to |
| 2 | +// ship — this is a dev-only tool, kept under bin/scripts like other one-offs. |
| 3 | +// |
| 4 | +// Starts N real analyzeBlastRadius workflows directly via the Temporal client |
| 5 | +// (bypassing the public HTTP/Zod/Auth0 layer, same as prod's own submit path |
| 6 | +// underneath), optionally capping the worker container's memory and stopping |
| 7 | +// each workflow right after a chosen stage via `stopAfterStage` — so this can |
| 8 | +// safely profile stage 2 (dependents, no LLM) without ever reaching the paid |
| 9 | +// stage 3 (reachability, Sonnet) unless explicitly asked to. |
| 10 | +// |
| 11 | +// Usage: |
| 12 | +// cd backend && npx tsx src/bin/scripts/blastRadiusLoadTest.ts \ |
| 13 | +// --jobs=8 --scanConcurrency=8 --memCap=2g --stopAfter=dependents \ |
| 14 | +// --advisories=src/bin/scripts/blastRadiusLoadTestAdvisories.json |
| 15 | +// |
| 16 | +// Flags (all optional): |
| 17 | +// --jobs=N number of concurrent analyses to start (default 8) |
| 18 | +// --scanConcurrency=N sets BLAST_RADIUS_SCAN_CONCURRENCY on the worker container |
| 19 | +// for the duration of this run, then clears it (default: unset) |
| 20 | +// --memCap=2g docker memory cap applied to the worker container for the |
| 21 | +// duration of this run, then reset to unlimited (default: none) |
| 22 | +// --stopAfter=STAGE 'intel' | 'dependents' | 'reachability' — workflow stops |
| 23 | +// right after this stage succeeds (default: 'dependents') |
| 24 | +// --container=NAME worker container name (default crowd_blast-radius-worker-dev_1) |
| 25 | +// --advisories=FILE path to a JSON array of {advisoryId, package, ecosystem} — |
| 26 | +// jobs round-robin across these instead of all hitting the |
| 27 | +// same package (default: a single lodash advisory, repeated) |
| 28 | + |
| 29 | +import { execSync } from 'child_process' |
| 30 | +import * as fs from 'fs' |
| 31 | + |
| 32 | +import { generateUUIDv4 } from '@crowd/common' |
| 33 | +import * as blastRadiusDal from '@crowd/data-access-layer/src/packages/blastRadius' |
| 34 | +import { TemporalWorkflowId } from '@crowd/types' |
| 35 | + |
| 36 | +import { getPackagesQx } from '@/db/packagesDb' |
| 37 | +import { getPackagesTemporalClient } from '@/db/packagesTemporal' |
| 38 | + |
| 39 | +function flag(name: string, fallback?: string): string | undefined { |
| 40 | + const arg = process.argv.find((a) => a.startsWith(`--${name}=`)) |
| 41 | + return arg ? arg.slice(name.length + 3) : fallback |
| 42 | +} |
| 43 | + |
| 44 | +const JOBS = Number(flag('jobs', '8')) |
| 45 | +const SCAN_CONCURRENCY = flag('scanConcurrency') |
| 46 | +const MEM_CAP = flag('memCap') |
| 47 | +const STOP_AFTER = flag('stopAfter', 'dependents') as 'intel' | 'dependents' | 'reachability' |
| 48 | +const CONTAINER = flag('container', 'crowd_blast-radius-worker-dev_1') |
| 49 | +const ADVISORIES_FILE = flag('advisories') |
| 50 | + |
| 51 | +const POLL_INTERVAL_MS = 5_000 |
| 52 | +const TIMEOUT_MS = 60 * 60 * 1000 |
| 53 | + |
| 54 | +interface AdvisoryTarget { |
| 55 | + advisoryId: string |
| 56 | + package: string |
| 57 | + ecosystem: string |
| 58 | +} |
| 59 | + |
| 60 | +const DEFAULT_ADVISORIES: AdvisoryTarget[] = [ |
| 61 | + { advisoryId: 'GHSA-jf85-cpcp-j695', package: 'lodash', ecosystem: 'npm' }, // real OSV.dev entry, validated |
| 62 | +] |
| 63 | + |
| 64 | +function loadAdvisories(): AdvisoryTarget[] { |
| 65 | + if (!ADVISORIES_FILE) return DEFAULT_ADVISORIES |
| 66 | + const parsed = JSON.parse(fs.readFileSync(ADVISORIES_FILE, 'utf-8')) |
| 67 | + if (!Array.isArray(parsed) || parsed.length === 0) { |
| 68 | + throw new Error(`--advisories file must contain a non-empty JSON array: ${ADVISORIES_FILE}`) |
| 69 | + } |
| 70 | + return parsed |
| 71 | +} |
| 72 | + |
| 73 | +function sh(cmd: string): string { |
| 74 | + return execSync(cmd, { encoding: 'utf-8' }).trim() |
| 75 | +} |
| 76 | + |
| 77 | +function applyRunConfig() { |
| 78 | + if (MEM_CAP) { |
| 79 | + console.log(`[loadtest] capping ${CONTAINER} memory at ${MEM_CAP}`) |
| 80 | + sh(`docker update --memory=${MEM_CAP} --memory-swap=${MEM_CAP} ${CONTAINER}`) |
| 81 | + } |
| 82 | + if (SCAN_CONCURRENCY) { |
| 83 | + // Env vars can't be changed on an already-running container (unlike the memory |
| 84 | + // cgroup cap above, which docker update can patch live) — they're baked in at |
| 85 | + // container start. Verify the worker already has the value this run wants |
| 86 | + // instead of silently testing against whatever it happened to start with. |
| 87 | + const actual = sh( |
| 88 | + `docker exec ${CONTAINER} sh -c 'echo $BLAST_RADIUS_SCAN_CONCURRENCY'`, |
| 89 | + ) |
| 90 | + if (actual !== SCAN_CONCURRENCY) { |
| 91 | + throw new Error( |
| 92 | + `--scanConcurrency=${SCAN_CONCURRENCY} requested but ${CONTAINER} was started with ` + |
| 93 | + `BLAST_RADIUS_SCAN_CONCURRENCY=${actual || '(unset)'}. Restart it first: ` + |
| 94 | + `BLAST_RADIUS_SCAN_CONCURRENCY=${SCAN_CONCURRENCY} ./scripts/cli service blast-radius-worker restart`, |
| 95 | + ) |
| 96 | + } |
| 97 | + console.log(`[loadtest] confirmed BLAST_RADIUS_SCAN_CONCURRENCY=${SCAN_CONCURRENCY} on worker`) |
| 98 | + } |
| 99 | +} |
| 100 | + |
| 101 | +function resetRunConfig() { |
| 102 | + console.log('[loadtest] resetting container memory cap to unlimited') |
| 103 | + try { |
| 104 | + sh(`docker update --memory=0 --memory-swap=0 ${CONTAINER}`) |
| 105 | + } catch { |
| 106 | + // some docker versions reject 0; fall back to a generous cap instead of leaving 2g stuck |
| 107 | + try { |
| 108 | + sh(`docker update --memory=8g --memory-swap=8g ${CONTAINER}`) |
| 109 | + } catch { |
| 110 | + console.warn('[loadtest] could not reset memory cap automatically — check manually') |
| 111 | + } |
| 112 | + } |
| 113 | +} |
| 114 | + |
| 115 | +function sampleContainerStats(): { memUsage: string; cpuPerc: string } | null { |
| 116 | + try { |
| 117 | + const raw = sh( |
| 118 | + `docker stats ${CONTAINER} --no-stream --format "{{.MemUsage}}|{{.CPUPerc}}"`, |
| 119 | + ) |
| 120 | + const [memUsage, cpuPerc] = raw.split('|') |
| 121 | + return { memUsage, cpuPerc } |
| 122 | + } catch { |
| 123 | + return null |
| 124 | + } |
| 125 | +} |
| 126 | + |
| 127 | +function percentile(values: number[], p: number): number | null { |
| 128 | + if (values.length === 0) return null |
| 129 | + const sorted = [...values].sort((a, b) => a - b) |
| 130 | + const idx = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length)) |
| 131 | + return sorted[idx] |
| 132 | +} |
| 133 | + |
| 134 | +function stats(values: number[]) { |
| 135 | + if (values.length === 0) return null |
| 136 | + return { |
| 137 | + count: values.length, |
| 138 | + min: Math.min(...values), |
| 139 | + avg: Math.round(values.reduce((a, b) => a + b, 0) / values.length), |
| 140 | + p95: percentile(values, 95), |
| 141 | + max: Math.max(...values), |
| 142 | + } |
| 143 | +} |
| 144 | + |
| 145 | +async function main() { |
| 146 | + const advisories = loadAdvisories() |
| 147 | + console.log( |
| 148 | + `[loadtest] jobs=${JOBS} scanConcurrency=${SCAN_CONCURRENCY ?? '(default 32)'} ` + |
| 149 | + `memCap=${MEM_CAP ?? '(none)'} stopAfter=${STOP_AFTER} container=${CONTAINER} ` + |
| 150 | + `advisories=${advisories.length}${ADVISORIES_FILE ? ` (${ADVISORIES_FILE})` : ' (default)'}`, |
| 151 | + ) |
| 152 | + |
| 153 | + applyRunConfig() |
| 154 | + |
| 155 | + const qx = await getPackagesQx() |
| 156 | + const packagesTemporal = await getPackagesTemporalClient() |
| 157 | + |
| 158 | + const analysisIds: string[] = [] |
| 159 | + const submittedAt = Date.now() |
| 160 | + |
| 161 | + try { |
| 162 | + await Promise.all( |
| 163 | + Array.from({ length: JOBS }, async (_, i) => { |
| 164 | + const target = advisories[i % advisories.length] |
| 165 | + const analysisId = generateUUIDv4() |
| 166 | + const analysisInput = { |
| 167 | + id: analysisId, |
| 168 | + advisoryOsvId: target.advisoryId, |
| 169 | + packageName: target.package, |
| 170 | + ecosystem: target.ecosystem, |
| 171 | + force: false, |
| 172 | + } |
| 173 | + await blastRadiusDal.createAnalysis(qx, analysisInput) |
| 174 | + await packagesTemporal.workflow.start('analyzeBlastRadius', { |
| 175 | + taskQueue: 'blast-radius-worker', |
| 176 | + workflowId: `${TemporalWorkflowId.BLAST_RADIUS_ANALYSIS}/${analysisId}`, |
| 177 | + retry: { maximumAttempts: 1 }, |
| 178 | + args: [ |
| 179 | + { |
| 180 | + analysisId, |
| 181 | + advisoryId: target.advisoryId, |
| 182 | + package: target.package, |
| 183 | + ecosystem: target.ecosystem, |
| 184 | + force: false, |
| 185 | + stopAfterStage: STOP_AFTER, |
| 186 | + }, |
| 187 | + ], |
| 188 | + }) |
| 189 | + analysisIds.push(analysisId) |
| 190 | + }), |
| 191 | + ) |
| 192 | + |
| 193 | + console.log(`[loadtest] submitted ${analysisIds.length} analyses in ${Date.now() - submittedAt}ms`) |
| 194 | + console.log(`[loadtest] analysisIds: ${analysisIds.join(', ')}`) |
| 195 | + |
| 196 | + const deadline = Date.now() + TIMEOUT_MS |
| 197 | + let allDone = false |
| 198 | + const memSamples: string[] = [] |
| 199 | + |
| 200 | + while (Date.now() < deadline) { |
| 201 | + // With stopAfterStage, the analysis row itself stays 'running' forever (the |
| 202 | + // workflow returns cleanly instead of finishing all stages) — so completion |
| 203 | + // is judged from stage_runs reaching the requested stop stage, not from |
| 204 | + // blast_radius_analyses.status. |
| 205 | + const rows = await qx.select( |
| 206 | + `select analysis_id, status from blast_radius_stage_runs |
| 207 | + where analysis_id in ($(ids:csv)) and stage = $(stage)`, |
| 208 | + { ids: analysisIds, stage: STOP_AFTER }, |
| 209 | + ) |
| 210 | + const finished = rows.filter( |
| 211 | + (r: { status: string }) => r.status === 'succeeded' || r.status === 'failed', |
| 212 | + ) |
| 213 | + |
| 214 | + const sample = sampleContainerStats() |
| 215 | + if (sample) { |
| 216 | + memSamples.push(sample.memUsage) |
| 217 | + console.log( |
| 218 | + `[loadtest] ${finished.length}/${analysisIds.length} finished stage=${STOP_AFTER} ` + |
| 219 | + `mem=${sample.memUsage} cpu=${sample.cpuPerc} (${new Date().toISOString()})`, |
| 220 | + ) |
| 221 | + } else { |
| 222 | + console.log( |
| 223 | + `[loadtest] ${finished.length}/${analysisIds.length} finished stage=${STOP_AFTER} ` + |
| 224 | + `(${new Date().toISOString()})`, |
| 225 | + ) |
| 226 | + } |
| 227 | + |
| 228 | + if (finished.length === analysisIds.length) { |
| 229 | + allDone = true |
| 230 | + break |
| 231 | + } |
| 232 | + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)) |
| 233 | + } |
| 234 | + |
| 235 | + if (!allDone) { |
| 236 | + console.warn('[loadtest] TIMED OUT waiting for all analyses to reach the stop stage') |
| 237 | + } |
| 238 | + |
| 239 | + const stageRuns = await qx.select( |
| 240 | + `select analysis_id, stage, status, duration_ms, cost_usd, error |
| 241 | + from blast_radius_stage_runs where analysis_id in ($(ids:csv))`, |
| 242 | + { ids: analysisIds }, |
| 243 | + ) |
| 244 | + |
| 245 | + console.log('\n=== Per-analysis stage outcomes ===') |
| 246 | + for (const id of analysisIds) { |
| 247 | + const runs = stageRuns.filter((r: { analysis_id: string }) => r.analysis_id === id) |
| 248 | + const summary = runs |
| 249 | + .map((r: { stage: string; status: string; error: string | null }) => `${r.stage}=${r.status}${r.error ? ` (${r.error})` : ''}`) |
| 250 | + .join(', ') |
| 251 | + console.log(`${id}: ${summary || 'no stage runs recorded'}`) |
| 252 | + } |
| 253 | + |
| 254 | + console.log('\n=== Stage duration stats (ms) ===') |
| 255 | + for (const stage of ['intel', 'dependents', 'reachability', 'report']) { |
| 256 | + const durations = stageRuns |
| 257 | + .filter((r: { stage: string; status: string }) => r.stage === stage && r.status === 'succeeded') |
| 258 | + .map((r: { duration_ms: number | string }) => Number(r.duration_ms)) |
| 259 | + console.log(`${stage}:`, stats(durations)) |
| 260 | + } |
| 261 | + |
| 262 | + // blast_radius_analyses.total_cost_usd is only ever set by the report stage |
| 263 | + // (finalizeAnalysis) — with stopAfterStage set, report never runs, so the only |
| 264 | + // place real per-stage cost shows up is here, on stage_runs, scoped to this run's |
| 265 | + // own analysisIds (not a global window, since other runs/deployments write here too). |
| 266 | + console.log('\n=== Cost (USD, from blast_radius_stage_runs) ===') |
| 267 | + let totalCost = 0 |
| 268 | + for (const stage of ['intel', 'dependents', 'reachability', 'report']) { |
| 269 | + const costs = stageRuns |
| 270 | + .filter((r: { stage: string }) => r.stage === stage) |
| 271 | + .map((r: { cost_usd: number | string | null }) => Number(r.cost_usd ?? 0)) |
| 272 | + const stageCost = costs.reduce((sum: number, c: number) => sum + c, 0) |
| 273 | + totalCost += stageCost |
| 274 | + console.log(`${stage}: $${stageCost.toFixed(4)} (${costs.length} runs)`) |
| 275 | + } |
| 276 | + console.log(`total: $${totalCost.toFixed(4)}`) |
| 277 | + |
| 278 | + if (memSamples.length > 0) { |
| 279 | + console.log(`\n=== Memory samples (docker stats, ${memSamples.length} points) ===`) |
| 280 | + console.log(memSamples.join(' -> ')) |
| 281 | + } |
| 282 | + |
| 283 | + console.log(`\n[loadtest] total wall-clock: ${Date.now() - submittedAt}ms`) |
| 284 | + process.exit(allDone ? 0 : 1) |
| 285 | + } finally { |
| 286 | + resetRunConfig() |
| 287 | + } |
| 288 | +} |
| 289 | + |
| 290 | +main().catch((err) => { |
| 291 | + console.error('[loadtest] fatal error', err) |
| 292 | + resetRunConfig() |
| 293 | + process.exit(1) |
| 294 | +}) |
0 commit comments