Skip to content

Commit a9fa630

Browse files
authored
fix: npm ingest packages deadlock (#4234)
Signed-off-by: anilb <epipav@gmail.com>
1 parent 428e5a5 commit a9fa630

8 files changed

Lines changed: 96 additions & 23 deletions

File tree

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,18 +124,23 @@ async function ingestOne(qx: QueryExecutor, purl: string, dispatcher?: Dispatche
124124
}
125125

126126
// 429 / 5xx / network → bubble up so Temporal retries the activity with exponential backoff.
127-
if (!isClientError(packumentResult.statusCode, packumentResult.kind)) {
127+
// MALFORMED is permanent (a 200 body that isn't a packument — retrying won't change it),
128+
// so it takes the quick-retry-then-skip path below instead of poisoning the lane forever.
129+
if (
130+
!isClientError(packumentResult.statusCode, packumentResult.kind) &&
131+
packumentResult.kind !== 'MALFORMED'
132+
) {
128133
throw new Error(`Failed to fetch packument for ${name}: ${packumentResult.message}`)
129134
}
130135

131-
// 4xx → quick retry a few times (1s, 2s); give up and skip after the last attempt.
136+
// 4xx/MALFORMED → quick retry a few times (1s, 2s); give up and skip after the last attempt.
132137
if (attempt < INGEST_4XX_ATTEMPTS) {
133138
await sleep(attempt * INGEST_4XX_BACKOFF_MS)
134139
continue
135140
}
136141
log.warn(
137142
{ purl, statusCode: packumentResult.statusCode, kind: packumentResult.kind },
138-
'npm packument 4xx after fast retries — marking scanned and skipping',
143+
'npm packument 4xx/malformed after fast retries — marking scanned and skipping',
139144
)
140145
await markNpmPackageScanned(qx, purl, {
141146
status: 'error',

services/apps/packages_worker/src/npm/fetchPackument.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,34 @@ export async function fetchPackument(
4848
return { kind: 'MALFORMED', message: 'invalid JSON' }
4949
}
5050

51-
if (!isPackument(json)) return { kind: 'MALFORMED', message: 'unexpected shape' }
51+
if (!isPackument(json)) {
52+
const stub = asUnpublishedStub(json)
53+
if (stub) return stub
54+
return { kind: 'MALFORMED', message: 'unexpected shape' }
55+
}
5256
delete (json as unknown as Record<string, unknown>).readme
5357
return json
5458
}
5559

5660
function isPackument(v: unknown): v is Packument {
5761
return typeof v === 'object' && v !== null && 'name' in v && 'versions' in v && 'dist-tags' in v
5862
}
63+
64+
// A fully unpublished package returns HTTP 200 with a stub document — just name + time,
65+
// where time.unpublished records the unpublish event; there are no versions/dist-tags keys,
66+
// so isPackument rejects it. Normalize the stub into an empty packument with `unpublished`
67+
// set, so ingest stores status='unpublished' instead of erroring on shape.
68+
function asUnpublishedStub(v: unknown): Packument | null {
69+
if (typeof v !== 'object' || v === null) return null
70+
const o = v as Record<string, unknown>
71+
if (typeof o.name !== 'string' || typeof o.time !== 'object' || o.time === null) return null
72+
const t = o.time as Record<string, unknown>
73+
const unpublished = t.unpublished
74+
if (typeof unpublished !== 'object' || unpublished === null) return null
75+
if (typeof (unpublished as Record<string, unknown>).time !== 'string') return null
76+
const time: Record<string, string> = {}
77+
for (const [key, value] of Object.entries(t)) {
78+
if (typeof value === 'string') time[key] = value
79+
}
80+
return { name: o.name, 'dist-tags': {}, versions: {}, time, unpublished }
81+
}

services/apps/packages_worker/src/npm/normalize.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,33 @@ export function normalizeLicenses(packument: Packument): string[] {
5656
)
5757
}
5858

59+
// A version's `license` field comes in several shapes: a plain SPDX string ("MIT"),
60+
// an object ({ type, url }), or the legacy array form ([{ type, file }, ...]). Passing those
61+
// raw into a text column would persist objects/arrays, so collapse every shape to a single
62+
// string (OR-joined for the array form) or null. Non-string or blank `type` values are dropped.
63+
export function versionLicense(raw: unknown): string | null {
64+
if (raw == null) return null
65+
if (typeof raw === 'string') return blankToNull(raw)
66+
if (Array.isArray(raw)) {
67+
const types = raw
68+
.map((l) => (typeof l === 'string' ? blankToNull(l) : licenseType(l)))
69+
.filter((t): t is string => t !== null)
70+
return types.length ? types.join(' OR ') : null
71+
}
72+
return licenseType(raw)
73+
}
74+
75+
function licenseType(v: unknown): string | null {
76+
if (typeof v !== 'object' || v === null) return null
77+
const type = (v as { type?: unknown }).type
78+
return typeof type === 'string' ? blankToNull(type) : null
79+
}
80+
81+
function blankToNull(s: string): string | null {
82+
const trimmed = s.trim()
83+
return trimmed || null
84+
}
85+
5986
function clean(s: string): string {
6087
return s.replace(/[()]/g, '').trim()
6188
}

services/apps/packages_worker/src/npm/upsertPackage.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
normalizeLicenses,
1616
parseNpmName,
1717
stripNullBytesDeep,
18+
versionLicense,
1819
} from './normalize'
1920
import type { FundingEntry, Packument } from './types'
2021

@@ -98,7 +99,7 @@ export async function upsertPackage(
9899
publishedAt: time[number] ?? null,
99100
isLatest: number === latestVersion,
100101
isPrerelease: isPrerelease(number),
101-
license: v.license ?? licenses[0] ?? null,
102+
license: versionLicense(v.license) ?? licenses[0] ?? null,
102103
})),
103104
)
104105
verChanged.forEach((f) => changed.add(f))

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const heartbeatingActs = proxyActivities<typeof activities>({
2727

2828
// Per lane, per round. Total purls fetched per round = lanes × INGEST_PER_LANE.
2929
const INGEST_PER_LANE = 50
30-
const INGEST_ROUNDS_PER_RUN = 25
30+
const INGEST_ROUNDS_PER_RUN = 5
3131

3232
interface IngestState {
3333
unscannedCursor: string

services/libs/data-access-layer/src/packages/maintainers.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ export async function upsertNpmMaintainers(
1414
): Promise<string[]> {
1515
const changed = new Set<string>()
1616

17-
for (const m of maintainers) {
17+
const ordered = [...maintainers].sort((a, b) =>
18+
a.username < b.username ? -1 : a.username > b.username ? 1 : 0,
19+
)
20+
21+
for (const m of ordered) {
1822
const row: { changed_fields: string[] } = await qx.selectOne(
1923
`WITH old AS (
2024
SELECT display_name, email FROM maintainers WHERE ecosystem = 'npm' AND username = $(username)
@@ -50,7 +54,7 @@ export async function upsertNpmMaintainers(
5054
})
5155

5256
const afterMap = new Map<string, string | null>()
53-
for (const m of maintainers) {
57+
for (const m of ordered) {
5458
const row: { maintainer_id: string } | null = await qx.selectOneOrNone(
5559
`INSERT INTO package_maintainers (package_id, maintainer_id, role, created_at, updated_at)
5660
SELECT $(packageId)::bigint, id, $(role), NOW(), NOW() FROM maintainers WHERE ecosystem = 'npm' AND username = $(username)

services/libs/data-access-layer/src/packages/packages.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,11 @@ export async function upsertNpmPackage(
6666
dist_tags_latest = EXCLUDED.dist_tags_latest,
6767
dist_tags_next = EXCLUDED.dist_tags_next,
6868
dist_tags_beta = EXCLUDED.dist_tags_beta,
69-
versions_count = EXCLUDED.versions_count,
69+
-- An unpublished stub reports 0 versions; don't clobber a previously-known count
70+
-- (keep it consistent with the version rows that are retained on unpublish).
71+
versions_count = CASE WHEN EXCLUDED.versions_count = 0
72+
THEN packages.versions_count
73+
ELSE EXCLUDED.versions_count END,
7074
latest_version = EXCLUDED.latest_version,
7175
first_release_at = EXCLUDED.first_release_at,
7276
latest_release_at = EXCLUDED.latest_release_at,

services/libs/data-access-layer/src/packages/repos.ts

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,31 @@ export async function getOrCreateRepoByUrl(
55
url: string,
66
host: string,
77
): Promise<{ id: string; changedFields: string[] }> {
8-
const row: { id: string; created: boolean } = await qx.selectOne(
9-
`
10-
WITH ins AS (
11-
INSERT INTO repos (url, host) VALUES ($(url), $(host))
12-
ON CONFLICT (url) DO NOTHING
13-
RETURNING id
14-
)
15-
SELECT id::text AS id, true AS created FROM ins
16-
UNION ALL
17-
SELECT id::text AS id, false AS created
18-
FROM repos
19-
WHERE url = $(url) AND NOT EXISTS (SELECT 1 FROM ins)
20-
`,
8+
// Repos are shared across packages (every package in a monorepo points at one repo)
9+
// so this is by far the common case
10+
const existing: { id: string } | null = await qx.selectOneOrNone(
11+
`SELECT id::text AS id FROM repos WHERE url = $(url)`,
12+
{ url },
13+
)
14+
if (existing) return { id: existing.id, changedFields: [] }
15+
16+
// Not seen yet — try to create it. ON CONFLICT DO NOTHING so a concurrent ingest lane creating
17+
// the same shared repo doesn't raise a unique violation.
18+
const inserted: { id: string } | null = await qx.selectOneOrNone(
19+
`INSERT INTO repos (url, host) VALUES ($(url), $(host))
20+
ON CONFLICT (url) DO NOTHING
21+
RETURNING id::text AS id`,
2122
{ url, host },
2223
)
23-
return { id: row.id, changedFields: row.created ? ['repos.url', 'repos.host'] : [] }
24+
if (inserted) return { id: inserted.id, changedFields: ['repos.url', 'repos.host'] }
25+
26+
// Lost the race: another lane committed the same url between our SELECT and INSERT, so
27+
// ON CONFLICT DO NOTHING returned no row. Re-read in a fresh statement — under READ COMMITTED
28+
const row: { id: string } = await qx.selectOne(
29+
`SELECT id::text AS id FROM repos WHERE url = $(url)`,
30+
{ url },
31+
)
32+
return { id: row.id, changedFields: [] }
2433
}
2534

2635
export async function upsertPackageRepo(

0 commit comments

Comments
 (0)