Skip to content

Commit 0ed4591

Browse files
committed
feat: optimize bulk calls
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
1 parent 33d880c commit 0ed4591

17 files changed

Lines changed: 624 additions & 146 deletions

File tree

backend/src/api/public/v1/index.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,18 @@ export function v1Router(): Router {
4343
router.use('/ossprey', oauth2Middleware(AUTH0_CONFIG), osspreyRouter())
4444

4545
router.use('/akrites', oauth2Middleware(AUTH0_CONFIG), akritesRouter())
46-
router.use('/akrites-external', oauth2Middleware(AUTH0_CONFIG), akritesExternalRouter())
46+
// LOCAL-BYPASS-TODO-REVERT: real Auth0 check commented out for a local blast-radius
47+
// e2e load test (no easy way to mint a token locally). Restore the line below and
48+
// delete the stub before this ever reaches a shared/prod branch.
49+
// router.use('/akrites-external', oauth2Middleware(AUTH0_CONFIG), akritesExternalRouter())
50+
router.use(
51+
'/akrites-external',
52+
(req, _res, next) => {
53+
req.actor = { id: 'local-load-test', type: 'service', scopes: Object.values(SCOPES) }
54+
next()
55+
},
56+
akritesExternalRouter(),
57+
)
4758

4859
router.use(() => {
4960
throw new NotFoundError()

backend/src/api/public/v1/packages/submitBlastRadiusJobBatch.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,16 @@ async function submitOneJob(
8787
})
8888
} catch (err) {
8989
// Unlike the single-job submit, this does not rethrow — one job's workflow
90-
// failing to start must not take the rest of the batch down with it.
90+
// failing to start must not take the rest of the batch down with it. Same
91+
// reasoning applies to failAnalysis itself: if marking the row failed also
92+
// fails (e.g. transient DB error), that must not reject this job's promise
93+
// and take Promise.all (and the whole batch response) down with it.
9194
const errorMessage = err instanceof Error ? err.message : String(err)
92-
await blastRadiusDal.failAnalysis(qx, analysisInput, errorMessage)
95+
try {
96+
await blastRadiusDal.failAnalysis(qx, analysisInput, errorMessage)
97+
} catch {
98+
// best-effort — the job's entry below still reports status: 'failed'
99+
}
93100

94101
return {
95102
analysisId,
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
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+
})
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[
2+
{ "advisoryId": "GHSA-jf85-cpcp-j695", "package": "lodash", "ecosystem": "npm" },
3+
{ "advisoryId": "GHSA-cxjh-pqwp-8mfp", "package": "follow-redirects", "ecosystem": "npm" },
4+
{ "advisoryId": "GHSA-pch5-whg9-qr2r", "package": "netmask", "ecosystem": "npm" },
5+
{ "advisoryId": "GHSA-c429-5p7v-vgjp", "package": "@hapi/hoek", "ecosystem": "npm" }
6+
]

services/apps/packages_worker/src/bin/blast-radius-worker.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const config: Config = {
1616

1717
const options: Options = {
1818
postgres: { enabled: false }, // packages-db is managed via getPackagesDb()
19-
maxConcurrentActivityTaskExecutions: 8,
19+
maxConcurrentActivityTaskExecutions: 16,
2020
}
2121

2222
const svc = new ServiceWorker(config, options)

services/apps/packages_worker/src/blast-radius/activities.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { heartbeat } from '@temporalio/activity'
1+
import { Context, heartbeat } from '@temporalio/activity'
22

33
import * as blastRadiusDal from '@crowd/data-access-layer/src/packages/blastRadius'
44
import { getServiceChildLogger } from '@crowd/logging'
@@ -75,7 +75,10 @@ export async function blastRadiusIntel(input: BlastRadiusActivityInput): Promise
7575
export async function blastRadiusDependents(input: BlastRadiusActivityInput): Promise<void> {
7676
log.info({ analysisId: input.analysisId }, 'blast-radius: dependents stage starting')
7777
const qx = await getPackagesDb()
78-
await runDependentsStage(qx, input.analysisId, heartbeat)
78+
// Pass Temporal's own cancellation signal through to the scan so a timed-out or
79+
// cancelled attempt actually stops its in-flight fetches instead of running on as
80+
// a zombie in the background while a retry starts a duplicate scan of the same analysis.
81+
await runDependentsStage(qx, input.analysisId, heartbeat, Context.current().cancellationSignal)
7982
log.info({ analysisId: input.analysisId }, 'blast-radius: dependents stage done')
8083
}
8184

services/apps/packages_worker/src/blast-radius/agent/runner.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
// Agent runner wrapping Claude Agent SDK with read-only tool restrictions,
55
// API key fallback, structured output, and timeout support.
66

7+
import { getServiceChildLogger } from '@crowd/logging'
8+
9+
const log = getServiceChildLogger('blast-radius-agent-runner')
10+
711
export interface AgentRunResult {
812
structuredOutput: Record<string, unknown> | null
913
isError: boolean
@@ -51,6 +55,13 @@ export async function runAnalysisAgent(input: RunAnalysisAgentInput): Promise<Ag
5155
}
5256
: undefined
5357

58+
log.info(
59+
{
60+
authMode: apiKey ? (baseUrl ? 'api-key via base-url override (e.g. LiteLLM)' : 'api-key') : 'fallback on local claude code token',
61+
},
62+
'blast-radius agent: auth mode for this run',
63+
)
64+
5465
// Setup timeout via AbortController
5566
const controller = new AbortController()
5667
const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs)

0 commit comments

Comments
 (0)