Skip to content

Commit 2510e55

Browse files
committed
fix: address review on the folded-migration round
Six findings from Copilot + Cursor on commit df89604, all valid per the sub-agent eval pass. Three structural fixes, three doc/code hygiene. Code: - workflows.ts: split proxyActivities into separate configs for sync and derive. Sync keeps the 5-min heartbeatTimeout (matches the per-1000-record heartbeat in the sync activity). Derive has no heartbeatTimeout — it's a single tight loop with no per-page heartbeat, and sharing the sync config would silently cancel the activity at Tier 2 scale (~600-700k packages). 1-hour startToCloseTimeout gives generous headroom over the ~5-min observed derive duration. Cursor caught this (CRITICAL). - parseOsvRecord.ts: when an OSV record provides only an enumerated versions[] list (no ranges[]), convert each version into a degenerate range {introduced: v, fixed: null, last_affected: v} so isInRange matches exact-version vulnerability. Previously we kept the advisory_package row but wrote zero ranges, silently breaking the version-in-range check downstream. Copilot caught it (HIGH). Two new tests cover the conversion and the ranges-wins-over-versions case when both are present. - upsertAdvisory.ts: scope the per-pass DELETE to rows where range_raw IS NULL AND unaffected_raw IS NULL. The deps.dev BQ ingest worker (future) will write rows with only the raw text columns populated; OSV's unconditional DELETE would wipe them on every pass. Copilot caught it (HIGH). Cleanup: - types.ts: drop the unused OsvConfig interface. Leftover from the pre-Temporal standalone-bin shape; the activity reads env on demand via requirePositiveInt in activities.ts. Misleading + dead code. - V1779710880: rewrite the comment above advisory_affected_ranges. The previous comment said the structured boundary columns "are populated by a future range-parsing workstream" — that's no longer true; OSV ingest writes them now. The new comment captures the OSV-vs-deps.dev split and the DELETE-filter contract that keeps the two sources from clobbering each other. - PR body: rewritten to describe the single-consolidated-migration shape instead of the three-additive-migrations shape the earlier draft assumed. All 72 unit tests pass. Schema reapplied locally from a fresh volume to confirm correctness. Signed-off-by: Joan Reyero <joan@reyero.io>
1 parent df89604 commit 2510e55

6 files changed

Lines changed: 91 additions & 17 deletions

File tree

backend/src/osspckgs/migrations/V1779710880__initial_schema.sql

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -614,10 +614,14 @@ CREATE INDEX ON advisory_packages (package_id)
614614
WHERE
615615
package_id IS NOT NULL;
616616

617-
-- Version ranges affected by an advisory per package.
617+
-- Version ranges affected by an advisory per package. Populated by the OSV
618+
-- ingest worker (packages_worker/src/osv) using introduced_version /
619+
-- fixed_version / last_affected. range_raw / unaffected_raw are reserved
620+
-- for the deps.dev BQ ingest worker (future): that worker writes the raw
621+
-- range strings without parsing into structured boundaries. The OSV upsert
622+
-- path only deletes rows where range_raw / unaffected_raw are both NULL,
623+
-- so deps.dev rows are not clobbered when OSV re-syncs.
618624
-- COALESCE prevents silent duplicates when introduced_version is NULL.
619-
-- BQ-sourced rows populate range_raw / unaffected_raw only; introduced/fixed/last_affected
620-
-- are populated by a future range-parsing workstream.
621625
CREATE TABLE advisory_affected_ranges (
622626
id bigserial PRIMARY KEY,
623627
advisory_package_id bigint NOT NULL REFERENCES advisory_packages (id),

services/apps/packages_worker/src/osv/__tests__/parseOsvRecord.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,46 @@ describe('parseOsvRecord — range flattening', () => {
202202
expect(out.packages).toEqual([])
203203
})
204204

205+
it('converts versions[] to discrete ranges when ranges[] is empty', () => {
206+
// Some OSV records list exact vulnerable versions without a structured
207+
// range. Without the conversion the advisory_package would land in the DB
208+
// with zero ranges and deriveCriticalFlag would never flag the package.
209+
const r = baseRecord({
210+
affected: [
211+
{
212+
package: { ecosystem: 'npm', name: 'pkg' },
213+
versions: ['1.0.0', '1.0.1', '1.0.2'],
214+
},
215+
],
216+
})
217+
const out = parseOsvRecord(r, ALLOW)
218+
expect(out.packages).toHaveLength(1)
219+
expect(out.packages[0].ranges).toEqual([
220+
{ introducedVersion: '1.0.0', fixedVersion: null, lastAffected: '1.0.0' },
221+
{ introducedVersion: '1.0.1', fixedVersion: null, lastAffected: '1.0.1' },
222+
{ introducedVersion: '1.0.2', fixedVersion: null, lastAffected: '1.0.2' },
223+
])
224+
})
225+
226+
it('ignores versions[] when ranges[] is non-empty (avoids redundant rows)', () => {
227+
// If OSV provides both, ranges[] is the source of truth — versions[] is
228+
// typically a subset enumeration. Avoid writing the same vulnerability
229+
// window twice (once as a structured range, once as a per-version range).
230+
const r = baseRecord({
231+
affected: [
232+
{
233+
package: { ecosystem: 'npm', name: 'pkg' },
234+
ranges: [{ type: 'SEMVER', events: [{ introduced: '1.0.0' }, { fixed: '2.0.0' }] }],
235+
versions: ['1.5.0'],
236+
},
237+
],
238+
})
239+
const out = parseOsvRecord(r, ALLOW)
240+
expect(out.packages[0].ranges).toEqual([
241+
{ introducedVersion: '1.0.0', fixedVersion: '2.0.0', lastAffected: null },
242+
])
243+
})
244+
205245
it('merges multiple affected[] entries for the same package', () => {
206246
// Some OSV records list the same (ecosystem, name) multiple times to express
207247
// disjoint range sets. We collapse them under one advisory_package row.

services/apps/packages_worker/src/osv/parseOsvRecord.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,17 @@ export function parseOsvRecord(
115115
if (!allowedEcosystems.has(ecosystem)) continue
116116

117117
const ranges = flattenRanges(affected)
118-
if (ranges.length === 0 && (affected.versions ?? []).length === 0) {
118+
// If OSV only provides an enumerated `versions[]` list (no `ranges[]`),
119+
// convert each exact version into a degenerate range (introduced=v,
120+
// last_affected=v) so isInRange matches when `latest_version === v`.
121+
// Without this we'd record the advisory_package but no range rows, and
122+
// deriveCriticalFlag would silently fail to flag the package.
123+
if (ranges.length === 0 && (affected.versions ?? []).length > 0) {
124+
for (const v of affected.versions ?? []) {
125+
ranges.push({ introducedVersion: v, fixedVersion: null, lastAffected: v })
126+
}
127+
}
128+
if (ranges.length === 0) {
119129
// No usable affected range at all; skip this package entry but keep going.
120130
continue
121131
}

services/apps/packages_worker/src/osv/types.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,3 @@
1-
// Worker config, read from env in src/config.ts.
2-
export interface OsvConfig {
3-
bulkBaseUrl: string
4-
ecosystems: string[]
5-
syncIntervalHours: number
6-
idleSleepSec: number
7-
tmpDir: string
8-
batchSize: number
9-
deriveBatchSize: number
10-
}
11-
121
// ---- Raw OSV JSON (only the fields we read) ----
132
// Reference: https://ossf.github.io/osv-schema/
143

services/apps/packages_worker/src/osv/upsertAdvisory.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,18 @@ async function upsertOne(qx: QueryExecutor, record: NormalizedRecord): Promise<v
8585
)
8686
const advisoryPackageId = advisoryPackageRow.id as number
8787

88+
// Only delete OSV-derived rows: rows with at least one of
89+
// introduced/fixed/last_affected populated AND no deps.dev-source raw text
90+
// columns. The deps.dev BQ worker (future) is expected to populate
91+
// range_raw / unaffected_raw on rows of its own; we must not wipe those
92+
// on every OSV pass.
8893
await qx.result(
89-
`DELETE FROM advisory_affected_ranges WHERE advisory_package_id = $(advisoryPackageId)`,
94+
`
95+
DELETE FROM advisory_affected_ranges
96+
WHERE advisory_package_id = $(advisoryPackageId)
97+
AND range_raw IS NULL
98+
AND unaffected_raw IS NULL
99+
`,
90100
{ advisoryPackageId },
91101
)
92102

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,14 @@ import type * as activities from './activities'
99
// §`has_critical_vulnerability` semantics the derive step runs after every
1010
// ingest pass so packages added between schedule firings are at most one
1111
// cycle stale and self-heal on the next run.
12-
const { osvSyncEcosystem, osvDeriveCriticalFlag } = proxyActivities<typeof activities>({
12+
//
13+
// Sync and derive use separate proxyActivities configs because their
14+
// heartbeat shape differs: sync emits one heartbeat per ~1000 records (see
15+
// activities.ts) so a 5-minute heartbeatTimeout is the right liveness signal;
16+
// derive is a single tight loop over packages with no per-page heartbeat,
17+
// so it relies on startToCloseTimeout only — sharing the sync heartbeat
18+
// config would silently cancel the derive activity at Tier 2 scale.
19+
const { osvSyncEcosystem } = proxyActivities<typeof activities>({
1320
// npm sync alone is ~1 hour today (N+1 upsert path, see deferred review
1421
// comment on upsertAdvisory.ts). Maven is ~5 minutes. We give each
1522
// per-ecosystem activity a generous 2-hour ceiling.
@@ -27,6 +34,20 @@ const { osvSyncEcosystem, osvDeriveCriticalFlag } = proxyActivities<typeof activ
2734
},
2835
})
2936

37+
const { osvDeriveCriticalFlag } = proxyActivities<typeof activities>({
38+
// Paged scan over packages (~600-700k at Tier 2 scale). The whole derive
39+
// pass runs in ~5 minutes on the current dataset; we give it 1 hour of
40+
// headroom for the table to grow. No heartbeatTimeout — the activity does
41+
// not heartbeat, so adding one would cancel the activity silently before
42+
// startToCloseTimeout fires.
43+
startToCloseTimeout: '1 hour',
44+
retry: {
45+
initialInterval: '30 seconds',
46+
backoffCoefficient: 2,
47+
maximumAttempts: 3,
48+
},
49+
})
50+
3051
export interface OsvSyncWorkflowInput {
3152
// OSV ecosystem labels in OSV's canonical case ('npm', 'Maven', 'PyPI', …).
3253
// Same list is used by the per-ecosystem download loop, where OSV's bucket

0 commit comments

Comments
 (0)