Skip to content

Commit cf9314d

Browse files
authored
perf: prioritize critical packages in go enrichment scans [CM-1292] (#4308)
Signed-off-by: Mouad BANI <mouad-mb@outlook.com>
1 parent 827c956 commit cf9314d

3 files changed

Lines changed: 104 additions & 54 deletions

File tree

services/apps/packages_worker/src/go/activities.ts

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,27 +16,61 @@ const log = getServiceChildLogger('go')
1616
const PROXY_SOURCE = 'go-proxy'
1717
const PKGGODEV_SOURCE = 'pkg-go-dev'
1818

19-
// TODO: filter to critical packages once computed
19+
export interface GoScanCursor {
20+
criticalAfter: string
21+
after: string
22+
}
23+
24+
type GoRow = { purl: string; name: string }
25+
26+
// Two independent purl-keyset cursors — one for critical packages, one for everything else —
27+
// each ordered/paginated purely by purl so WHERE and ORDER BY always match (no gaps, no
28+
// duplicates). A single query sorted by is_critical DESC with one shared purl cursor was tried
29+
// and rejected: the cursor advances to the last row's purl, and when a batch is critical-heavy
30+
// that purl can be far ahead of unprocessed non-critical rows, permanently excluding them for
31+
// the rest of the run.
2032
async function getGoBatch(
2133
qx: QueryExecutor,
34+
isCritical: boolean,
2235
afterPurl: string,
2336
batchSize: number,
24-
): Promise<Array<{ purl: string; name: string }>> {
37+
): Promise<GoRow[]> {
38+
if (batchSize <= 0) return []
2539
return qx.select(
2640
`SELECT purl, name FROM packages
27-
WHERE ecosystem = 'go' AND purl > $(after)
28-
ORDER BY last_synced_at ASC NULLS FIRST, purl ASC
41+
WHERE ecosystem = 'go' AND is_critical = $(isCritical) AND purl > $(after)
42+
ORDER BY purl ASC
2943
LIMIT $(limit)`,
30-
{ after: afterPurl, limit: batchSize },
44+
{ isCritical, after: afterPurl, limit: batchSize },
3145
)
3246
}
3347

48+
// Drains not-yet-processed critical packages first, then tops up the rest of the batch with
49+
// non-critical ones — so a rate-limit run only ever starves the non-critical tail.
50+
async function getGoPriorityBatch(
51+
qx: QueryExecutor,
52+
cursor: GoScanCursor,
53+
batchSize: number,
54+
): Promise<{ rows: GoRow[]; nextCursor: GoScanCursor }> {
55+
const critical = await getGoBatch(qx, true, cursor.criticalAfter, batchSize)
56+
const nonCritical = await getGoBatch(qx, false, cursor.after, batchSize - critical.length)
57+
58+
return {
59+
rows: [...critical, ...nonCritical],
60+
nextCursor: {
61+
criticalAfter:
62+
critical.length > 0 ? critical[critical.length - 1].purl : cursor.criticalAfter,
63+
after: nonCritical.length > 0 ? nonCritical[nonCritical.length - 1].purl : cursor.after,
64+
},
65+
}
66+
}
67+
3468
export async function enrichGoVersionsBatch(
35-
afterPurl: string,
69+
cursor: GoScanCursor,
3670
batchSize: number,
37-
): Promise<string | null> {
71+
): Promise<GoScanCursor | null> {
3872
const qx = await getPackagesDb()
39-
const rows = await getGoBatch(qx, afterPurl, batchSize)
73+
const { rows, nextCursor } = await getGoPriorityBatch(qx, cursor, batchSize)
4074
if (rows.length === 0) return null
4175

4276
const { fetchTimeoutMs, proxyConcurrency } = getGoConfig()
@@ -89,15 +123,15 @@ export async function enrichGoVersionsBatch(
89123
}
90124

91125
log.info({ count: rows.length, concurrency: proxyConcurrency }, 'Enriched go versions batch')
92-
return rows[rows.length - 1].purl
126+
return nextCursor
93127
}
94128

95129
export async function enrichGoStatusBatch(
96-
afterPurl: string,
130+
cursor: GoScanCursor,
97131
batchSize: number,
98-
): Promise<string | null> {
132+
): Promise<GoScanCursor | null> {
99133
const qx = await getPackagesDb()
100-
const rows = await getGoBatch(qx, afterPurl, batchSize)
134+
const { rows, nextCursor } = await getGoPriorityBatch(qx, cursor, batchSize)
101135
if (rows.length === 0) return null
102136

103137
const { fetchTimeoutMs } = getGoConfig()
@@ -137,5 +171,5 @@ export async function enrichGoStatusBatch(
137171
}
138172

139173
log.info({ count: rows.length }, 'Enriched go status batch')
140-
return rows[rows.length - 1].purl
174+
return nextCursor
141175
}

services/apps/packages_worker/src/go/proxyClient.ts

Lines changed: 52 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import { FetchError, GoProxyLatest } from './types'
22

33
const BASE = process.env.GO_PROXY_BASE_URL ?? 'https://proxy.golang.org'
44
const ZERO_TIME = '0001-01-01T00:00:00Z'
5+
const MAX_429_RETRIES = 5
6+
7+
function sleep(ms: number): Promise<void> {
8+
return new Promise((r) => setTimeout(r, ms))
9+
}
510

611
// GOPROXY spec: uppercase letters in a module path are escaped as '!' + lowercase.
712
export function escapeModulePath(module: string): string {
@@ -13,40 +18,55 @@ export async function fetchLatest(
1318
timeoutMs: number,
1419
): Promise<GoProxyLatest | FetchError> {
1520
const url = `${BASE}/${escapeModulePath(module)}/@latest`
16-
const controller = new AbortController()
17-
const timer = setTimeout(() => controller.abort(), timeoutMs)
18-
19-
let res: Response
20-
try {
21-
res = await fetch(url, { signal: controller.signal })
22-
} catch (e) {
23-
return { kind: 'TRANSIENT', message: `network error: ${(e as Error).message}` }
24-
} finally {
25-
clearTimeout(timer)
26-
}
2721

28-
if (res.status === 429) {
29-
return { kind: 'RATE_LIMIT', statusCode: 429, message: 'rate limited' }
30-
}
31-
// Any other 4xx is permanent (unknown/invalid module path) — skip, don't retry.
32-
if (res.status >= 400 && res.status < 500) {
33-
return { kind: 'NOT_FOUND', statusCode: res.status, message: `${res.status}` }
34-
}
35-
if (res.status !== 200) {
36-
return { kind: 'TRANSIENT', statusCode: res.status, message: `unexpected status ${res.status}` }
37-
}
22+
for (let attempt = 0; attempt <= MAX_429_RETRIES; attempt++) {
23+
const controller = new AbortController()
24+
const timer = setTimeout(() => controller.abort(), timeoutMs)
3825

39-
let body: { Version?: string; Time?: string; Origin?: { URL?: string } }
40-
try {
41-
body = (await res.json()) as { Version?: string; Time?: string; Origin?: { URL?: string } }
42-
} catch {
43-
return { kind: 'MALFORMED', message: 'invalid json' }
44-
}
45-
if (!body.Version) return { kind: 'MALFORMED', message: 'missing Version' }
26+
let res: Response
27+
try {
28+
res = await fetch(url, { signal: controller.signal })
29+
} catch (e) {
30+
return { kind: 'TRANSIENT', message: `network error: ${(e as Error).message}` }
31+
} finally {
32+
clearTimeout(timer)
33+
}
34+
35+
if (res.status === 429) {
36+
if (attempt === MAX_429_RETRIES) {
37+
return { kind: 'RATE_LIMIT', statusCode: 429, message: '429 after retries' }
38+
}
39+
const retryAfterSec = parseInt(res.headers.get('retry-after') ?? '', 10)
40+
const waitMs = Number.isNaN(retryAfterSec) ? 1000 * 2 ** attempt : retryAfterSec * 1000
41+
await sleep(waitMs)
42+
continue
43+
}
44+
// Any other 4xx is permanent (unknown/invalid module path) — skip, don't retry.
45+
if (res.status >= 400 && res.status < 500) {
46+
return { kind: 'NOT_FOUND', statusCode: res.status, message: `${res.status}` }
47+
}
48+
if (res.status !== 200) {
49+
return {
50+
kind: 'TRANSIENT',
51+
statusCode: res.status,
52+
message: `unexpected status ${res.status}`,
53+
}
54+
}
4655

47-
return {
48-
version: body.Version,
49-
releaseAt: body.Time && body.Time !== ZERO_TIME ? body.Time : null,
50-
repoUrl: body.Origin?.URL || null,
56+
let body: { Version?: string; Time?: string; Origin?: { URL?: string } }
57+
try {
58+
body = (await res.json()) as { Version?: string; Time?: string; Origin?: { URL?: string } }
59+
} catch {
60+
return { kind: 'MALFORMED', message: 'invalid json' }
61+
}
62+
if (!body.Version) return { kind: 'MALFORMED', message: 'missing Version' }
63+
64+
return {
65+
version: body.Version,
66+
releaseAt: body.Time && body.Time !== ZERO_TIME ? body.Time : null,
67+
repoUrl: body.Origin?.URL || null,
68+
}
5169
}
70+
71+
return { kind: 'RATE_LIMIT', statusCode: 429, message: '429 after retries' }
5272
}

services/apps/packages_worker/src/go/workflows.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,26 +15,22 @@ const acts = proxyActivities<typeof activities>({
1515
const BATCH = 100
1616
const ROUNDS_PER_RUN = 200
1717

18-
interface ScanState {
19-
cursor: string
20-
}
18+
const START_CURSOR = { criticalAfter: '', after: '' }
2119

22-
export async function enrichGoVersions(state: ScanState = { cursor: '' }): Promise<void> {
23-
let { cursor } = state
20+
export async function enrichGoVersions(cursor = START_CURSOR): Promise<void> {
2421
for (let r = 0; r < ROUNDS_PER_RUN; r++) {
2522
const next = await acts.enrichGoVersionsBatch(cursor, BATCH)
2623
if (next === null) return
2724
cursor = next
2825
}
29-
await continueAsNew<typeof enrichGoVersions>({ cursor })
26+
await continueAsNew<typeof enrichGoVersions>(cursor)
3027
}
3128

32-
export async function enrichGoStatus(state: ScanState = { cursor: '' }): Promise<void> {
33-
let { cursor } = state
29+
export async function enrichGoStatus(cursor = START_CURSOR): Promise<void> {
3430
for (let r = 0; r < ROUNDS_PER_RUN; r++) {
3531
const next = await acts.enrichGoStatusBatch(cursor, BATCH)
3632
if (next === null) return
3733
cursor = next
3834
}
39-
await continueAsNew<typeof enrichGoStatus>({ cursor })
35+
await continueAsNew<typeof enrichGoStatus>(cursor)
4036
}

0 commit comments

Comments
 (0)