Skip to content

Commit e8081d8

Browse files
authored
feat: implment bulk radius api (CM-1328) (#4390)
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
1 parent 979bba0 commit e8081d8

24 files changed

Lines changed: 1637 additions & 150 deletions

backend/src/api/public/v1/akrites-external/index.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ import { getAkritesExternalContactDetailBatch } from '../packages/getAkritesExte
1212
import { getAkritesExternalPackageDetail } from '../packages/getAkritesExternalPackageDetail'
1313
import { getAkritesExternalPackageDetailBatch } from '../packages/getAkritesExternalPackageDetailBatch'
1414
import { getBlastRadiusJob } from '../packages/getBlastRadiusJob'
15+
import { getBlastRadiusJobBatch } from '../packages/getBlastRadiusJobBatch'
1516
import { ingestAkritesExternalContactDetail } from '../packages/ingestAkritesExternalContactDetail'
1617
import { submitBlastRadiusJob } from '../packages/submitBlastRadiusJob'
18+
import { submitBlastRadiusJobBatch } from '../packages/submitBlastRadiusJobBatch'
1719

1820
const rateLimiter = createRateLimiter({ max: 60, windowMs: 60 * 1000 })
1921

@@ -29,7 +31,7 @@ function envTunableRateLimiter(envPrefix: string, defaultMax: number, defaultWin
2931
})
3032
}
3133

32-
// Blast-radius jobs default to 5 requests/hour.
34+
// Blast-radius jobs default to 50 requests/hour.
3335
const blastRadiusRateLimiter = envTunableRateLimiter(
3436
'AKRITES_BLAST_RADIUS_RATE_LIMIT',
3537
5,
@@ -110,7 +112,22 @@ export function akritesExternalRouter(): Router {
110112
const blastRadiusSubRouter = Router()
111113
blastRadiusSubRouter.use(requireScopes([SCOPES.READ_PACKAGES]))
112114
blastRadiusSubRouter.post('/jobs', blastRadiusRateLimiter, safeWrap(submitBlastRadiusJob))
115+
// Bulk submit multiplies Temporal workflow starts per request (up to
116+
// MAX_BLAST_RADIUS_JOBS_PER_BATCH), so it sits behind the same strict
117+
// blastRadiusRateLimiter as the single-job route, not the regular one.
118+
blastRadiusSubRouter.post(
119+
/^\/jobs:batch\/?$/,
120+
blastRadiusRateLimiter,
121+
safeWrap(submitBlastRadiusJobBatch),
122+
)
113123
blastRadiusSubRouter.get('/jobs/:analysisId', rateLimiter, safeWrap(getBlastRadiusJob))
124+
// Bulk poll is read-only, same cost profile as the other batch endpoints, so
125+
// it uses the regular rateLimiter.
126+
blastRadiusSubRouter.post(
127+
/^\/jobs:batch\/poll\/?$/,
128+
rateLimiter,
129+
safeWrap(getBlastRadiusJobBatch),
130+
)
114131
router.use('/blast-radius', blastRadiusSubRouter)
115132

116133
return router

backend/src/api/public/v1/akrites-external/openapi.yaml

Lines changed: 199 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,16 @@ tags:
5151
- name: Blast Radius
5252
description: >
5353
Advisory reachability analysis — submit (2a) and poll (2b) are both
54-
implemented. Submitting kicks off a Temporal workflow that runs the
55-
npm reachability pipeline (other ecosystems fail fast with
56-
ECOSYSTEM_NOT_SUPPORTED); poll returns job status and, once done,
57-
results. Rate-limited independently of the other akrites-external
58-
endpoints (default 5 requests/hour, configurable via
59-
AKRITES_BLAST_RADIUS_RATE_LIMIT_MAX / _WINDOW_MS). Same interim scope
60-
note as Advisories applies (read:packages, pending a dedicated
61-
read:advisories scope).
54+
implemented, each with a bulk counterpart. Submitting kicks off a
55+
Temporal workflow that runs the npm reachability pipeline (other
56+
ecosystems fail fast with ECOSYSTEM_NOT_SUPPORTED); poll returns job
57+
status and, once done, results. Bulk submit (jobs:batch) is capped at
58+
20 jobs per request (10 recommended as the default batch size) — each
59+
entry starts its own workflow — and stays behind the same strict rate
60+
limiter as the single-job route; bulk poll
61+
(jobs:batch/poll) is read-only and capped at 100 like the other batch
62+
endpoints. Same interim scope note as Advisories applies (read:packages,
63+
pending a dedicated read:advisories scope).
6264
6365
components:
6466
securitySchemes:
@@ -409,7 +411,10 @@ components:
409411
BlastRadiusJobEntry:
410412
type: object
411413
required: [analysisId, advisoryId, package, ecosystem, status]
412-
description: Response body of 2a — one job per request, never wrapped in an array.
414+
description: >
415+
Response body of 2a for a single submitted job. Returned directly (not
416+
wrapped in an array) by the single-job submit; the batch submit (2a bulk)
417+
returns an array of these under `results` — see BlastRadiusJobBatchResponse.
413418
properties:
414419
analysisId:
415420
type: string
@@ -427,7 +432,75 @@ components:
427432
status:
428433
type: string
429434
enum: [pending, running, done, failed]
430-
description: Always pending — the response is returned before the Temporal workflow runs.
435+
description: >
436+
Pending in the single-job submit response, always returned before the
437+
Temporal workflow runs. In the batch submit response, a job whose
438+
workflow failed to start comes back as failed instead — the rest of
439+
the batch is unaffected.
440+
441+
BlastRadiusJobBatchRequest:
442+
type: object
443+
required: [jobs]
444+
properties:
445+
jobs:
446+
type: array
447+
minItems: 1
448+
maxItems: 20
449+
description: >
450+
Capped much lower than the 100-item read batches — each entry
451+
starts its own Temporal workflow, so the batch multiplies
452+
workflow starts (and reachability-analysis cost) per request.
453+
10 is the recommended default batch size; 20 is the hard limit.
454+
items:
455+
$ref: '#/components/schemas/BlastRadiusJobRequest'
456+
457+
BlastRadiusJobBatchResponse:
458+
type: object
459+
required: [results]
460+
description: >
461+
Plain array in request order, one entry per requested job — unlike the
462+
read batches there is no found/not-found case, but a job can still come
463+
back with status: failed if row creation or the workflow start failed.
464+
properties:
465+
results:
466+
type: array
467+
items:
468+
$ref: '#/components/schemas/BlastRadiusJobEntry'
469+
470+
BlastRadiusJobPollBatchRequest:
471+
type: object
472+
required: [analysisIds]
473+
properties:
474+
analysisIds:
475+
type: array
476+
minItems: 1
477+
maxItems: 100
478+
items:
479+
type: string
480+
format: uuid
481+
page:
482+
type: integer
483+
minimum: 1
484+
default: 1
485+
pageSize:
486+
type: integer
487+
minimum: 1
488+
maximum: 100
489+
default: 20
490+
491+
BlastRadiusAnalysisBulkEntry:
492+
type: object
493+
required: [requestedAnalysisId, found, analysis]
494+
properties:
495+
requestedAnalysisId:
496+
type: string
497+
found:
498+
type: boolean
499+
analysis:
500+
type: object
501+
nullable: true
502+
allOf:
503+
- $ref: '#/components/schemas/BlastRadiusAnalysis'
431504

432505
BlastRadiusResultConfidence:
433506
type: string
@@ -1070,6 +1143,122 @@ paths:
10701143
schema:
10711144
$ref: '#/components/schemas/Error'
10721145

1146+
/akrites-external/blast-radius/jobs:batch:
1147+
post:
1148+
operationId: submitBlastRadiusJobBatch
1149+
summary: 2a bulk — Submit multiple blast-radius analysis jobs
1150+
description: >
1151+
One job per array entry, same semantics as the single-job submit —
1152+
omit package for an advisory-wide analysis, provide it to narrow to a
1153+
single package. Each entry starts its own Temporal workflow, so the
1154+
batch is capped at 20 jobs (10 recommended as the default batch
1155+
size), much lower than the 100-item read batches, and stays behind
1156+
the same strict rate limiter as the single-job route.
1157+
1158+
1159+
A per-job failure does not fail the whole batch — that entry comes
1160+
back with status: 'failed' and the rest still submit.
1161+
tags: [Blast Radius]
1162+
security:
1163+
- M2MBearer:
1164+
- read:packages
1165+
requestBody:
1166+
required: true
1167+
content:
1168+
application/json:
1169+
schema:
1170+
$ref: '#/components/schemas/BlastRadiusJobBatchRequest'
1171+
responses:
1172+
'202':
1173+
description: Jobs accepted, in request order.
1174+
content:
1175+
application/json:
1176+
schema:
1177+
$ref: '#/components/schemas/BlastRadiusJobBatchResponse'
1178+
'400':
1179+
description: Validation error (empty array, >20 items, or an invalid job entry).
1180+
content:
1181+
application/json:
1182+
schema:
1183+
$ref: '#/components/schemas/Error'
1184+
'401':
1185+
description: Missing or invalid bearer token.
1186+
content:
1187+
application/json:
1188+
schema:
1189+
$ref: '#/components/schemas/Error'
1190+
'403':
1191+
description: Token missing read:packages scope.
1192+
content:
1193+
application/json:
1194+
schema:
1195+
$ref: '#/components/schemas/Error'
1196+
'429':
1197+
description: Too many requests — rate-limited independently of the other endpoints.
1198+
content:
1199+
application/json:
1200+
schema:
1201+
$ref: '#/components/schemas/Error'
1202+
1203+
/akrites-external/blast-radius/jobs:batch/poll:
1204+
post:
1205+
operationId: getBlastRadiusJobBatch
1206+
summary: 2b bulk — Poll multiple blast-radius analysis jobs
1207+
description: >
1208+
Same found/not-found echo shape as the other batch endpoints: an
1209+
unknown analysisId comes back { found: false, analysis: null }
1210+
instead of 404ing the whole request. Read-only, so it is rate-limited
1211+
the same as the other non-blast-radius endpoints, not the strict
1212+
submit limiter.
1213+
tags: [Blast Radius]
1214+
security:
1215+
- M2MBearer:
1216+
- read:packages
1217+
requestBody:
1218+
required: true
1219+
content:
1220+
application/json:
1221+
schema:
1222+
$ref: '#/components/schemas/BlastRadiusJobPollBatchRequest'
1223+
responses:
1224+
'200':
1225+
description: One page of results, in request order.
1226+
content:
1227+
application/json:
1228+
schema:
1229+
type: object
1230+
required: [page, pageSize, total, results]
1231+
properties:
1232+
page:
1233+
type: integer
1234+
pageSize:
1235+
type: integer
1236+
total:
1237+
type: integer
1238+
description: Total number of requested analysisIds, across all pages.
1239+
results:
1240+
type: array
1241+
items:
1242+
$ref: '#/components/schemas/BlastRadiusAnalysisBulkEntry'
1243+
'400':
1244+
description: Validation error (empty array, >100 items, or a non-uuid analysisId).
1245+
content:
1246+
application/json:
1247+
schema:
1248+
$ref: '#/components/schemas/Error'
1249+
'401':
1250+
description: Missing or invalid bearer token.
1251+
content:
1252+
application/json:
1253+
schema:
1254+
$ref: '#/components/schemas/Error'
1255+
'403':
1256+
description: Token missing read:packages scope.
1257+
content:
1258+
application/json:
1259+
schema:
1260+
$ref: '#/components/schemas/Error'
1261+
10731262
/akrites-external/blast-radius/jobs/{analysisId}:
10741263
get:
10751264
operationId: getBlastRadiusJob
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import {
4+
MAX_BLAST_RADIUS_JOBS_PER_BATCH,
5+
MAX_BLAST_RADIUS_POLL_IDS_PER_BATCH,
6+
blastRadiusJobBatchRequestSchema,
7+
blastRadiusJobPollBatchRequestSchema,
8+
paginateAnalysisIds,
9+
} from './blastRadiusBatch'
10+
11+
describe('blastRadiusJobBatchRequestSchema', () => {
12+
it('accepts a batch of valid job requests', () => {
13+
const result = blastRadiusJobBatchRequestSchema.safeParse({
14+
jobs: [
15+
{ advisoryId: 'GHSA-jf85-cpcp-j695', ecosystem: 'npm' },
16+
{ advisoryId: 'CVE-2024-12345', ecosystem: 'npm', package: 'pkg:npm/lodash' },
17+
],
18+
})
19+
expect(result.success).toBe(true)
20+
})
21+
22+
it('rejects an empty jobs array', () => {
23+
const result = blastRadiusJobBatchRequestSchema.safeParse({ jobs: [] })
24+
expect(result.success).toBe(false)
25+
})
26+
27+
it('rejects more than MAX_BLAST_RADIUS_JOBS_PER_BATCH jobs', () => {
28+
const jobs = Array.from({ length: MAX_BLAST_RADIUS_JOBS_PER_BATCH + 1 }, () => ({
29+
advisoryId: 'GHSA-jf85-cpcp-j695',
30+
ecosystem: 'npm',
31+
}))
32+
const result = blastRadiusJobBatchRequestSchema.safeParse({ jobs })
33+
expect(result.success).toBe(false)
34+
})
35+
36+
it('rejects a batch containing one invalid job', () => {
37+
const result = blastRadiusJobBatchRequestSchema.safeParse({
38+
jobs: [
39+
{ advisoryId: 'GHSA-jf85-cpcp-j695', ecosystem: 'npm' },
40+
{ advisoryId: 'not-an-advisory-id', ecosystem: 'npm' },
41+
],
42+
})
43+
expect(result.success).toBe(false)
44+
})
45+
})
46+
47+
describe('blastRadiusJobPollBatchRequestSchema', () => {
48+
const validId = '3fa85f64-5717-4562-b3fc-2c963f66afa6'
49+
50+
it('accepts a batch of valid analysisIds and defaults page/pageSize', () => {
51+
const result = blastRadiusJobPollBatchRequestSchema.parse({ analysisIds: [validId] })
52+
expect(result.page).toBe(1)
53+
expect(result.pageSize).toBe(20)
54+
})
55+
56+
it('rejects an empty analysisIds array', () => {
57+
const result = blastRadiusJobPollBatchRequestSchema.safeParse({ analysisIds: [] })
58+
expect(result.success).toBe(false)
59+
})
60+
61+
it('rejects a non-uuid analysisId', () => {
62+
const result = blastRadiusJobPollBatchRequestSchema.safeParse({ analysisIds: ['not-a-uuid'] })
63+
expect(result.success).toBe(false)
64+
})
65+
66+
it('rejects more than MAX_BLAST_RADIUS_POLL_IDS_PER_BATCH analysisIds', () => {
67+
const analysisIds = Array.from(
68+
{ length: MAX_BLAST_RADIUS_POLL_IDS_PER_BATCH + 1 },
69+
() => validId,
70+
)
71+
const result = blastRadiusJobPollBatchRequestSchema.safeParse({ analysisIds })
72+
expect(result.success).toBe(false)
73+
})
74+
})
75+
76+
describe('paginateAnalysisIds', () => {
77+
it('slices the requested page out of the full analysisIds array', () => {
78+
const analysisIds = ['a', 'b', 'c', 'd', 'e']
79+
const result = paginateAnalysisIds({ analysisIds, page: 2, pageSize: 2 })
80+
expect(result).toEqual({
81+
page: 2,
82+
pageSize: 2,
83+
total: 5,
84+
pagedAnalysisIds: ['c', 'd'],
85+
})
86+
})
87+
88+
it('returns an empty page past the end of the array', () => {
89+
const result = paginateAnalysisIds({ analysisIds: ['a'], page: 2, pageSize: 20 })
90+
expect(result.pagedAnalysisIds).toEqual([])
91+
expect(result.total).toBe(1)
92+
})
93+
})

0 commit comments

Comments
 (0)