Skip to content

Commit 2c031f9

Browse files
committed
fix: diff-based upsert + soft-delete for advisory ranges (CM-1258)
Signed-off-by: Uroš Marolt <uros@marolt.me>
1 parent d9bd7e7 commit 2c031f9

2 files changed

Lines changed: 87 additions & 30 deletions

File tree

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

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {
2-
deleteOsvOnlyRanges,
32
findPackageId,
4-
insertAdvisoryRange,
3+
reconcileOsvRanges,
4+
supersedeDepsDevRanges,
55
upsertAdvisory,
66
upsertAdvisoryPackage,
77
} from '@crowd/data-access-layer/src/packages/osv'
@@ -46,16 +46,17 @@ async function upsertOne(qx: QueryExecutor, record: NormalizedRecord): Promise<v
4646
packageName: entry.pkg.packageName,
4747
})
4848

49-
await deleteOsvOnlyRanges(qx, advisoryPackageId)
50-
51-
for (const range of dedupeRanges(entry.ranges)) {
52-
await insertAdvisoryRange(qx, {
49+
await reconcileOsvRanges(
50+
qx,
51+
advisoryPackageId,
52+
dedupeRanges(entry.ranges).map((range) => ({
5353
advisoryPackageId,
5454
introducedVersion: range.introducedVersion,
5555
fixedVersion: range.fixedVersion,
5656
lastAffected: range.lastAffected,
57-
})
58-
}
57+
})),
58+
)
59+
await supersedeDepsDevRanges(qx, advisoryPackageId)
5960
}
6061
}
6162

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

Lines changed: 78 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -139,42 +139,98 @@ export async function upsertAdvisoryPackage(
139139
return row.id as number
140140
}
141141

142-
// Delete every advisory_affected_ranges row for this advisory_package that
143-
// lacks deps.dev raw columns — i.e. every OSV-owned row. The deps.dev BQ
144-
// worker (future) is expected to populate range_raw / unaffected_raw on rows
145-
// of its own and never set the structured introduced/fixed/last_affected
146-
// columns; this predicate scopes the wipe to the OSV pipeline's rows so a
147-
// deps.dev row is never clobbered on resync. OSV rows where all three
148-
// structured columns are NULL (e.g. some MAL- "always vulnerable" ranges)
149-
// are still deleted here and re-inserted from the new payload below — that's
150-
// fine, the row is OSV-owned and idempotent.
151-
export async function deleteOsvOnlyRanges(
142+
// Diff-based upsert + soft-delete for OSV-owned advisory_affected_ranges rows.
143+
// Replaces the old hard-delete + reinsert sweep (which minted a new PK for
144+
// every unchanged range every sync, producing zombie rows once Sequin
145+
// replicates DELETEs into a Tinybird ReplacingMergeTree that has no
146+
// sign/is_deleted column — see ADR-0001 §`advisory_affected_ranges`
147+
// delete/dedup strategy, CM-1258).
148+
//
149+
// An advisory_package whose range set hasn't changed since the last sync now
150+
// produces zero writes: matching tuples are left untouched (not even an
151+
// `updated_at` bump), so no Sequin/Tinybird event fires for them either.
152+
//
153+
// Tuple in both old and new -> untouched. Tuple only in new -> upserted
154+
// (INSERT ON CONFLICT, clearing deleted_at in case it was previously
155+
// soft-deleted and OSV brought it back). Tuple only in old -> soft-deleted.
156+
// Soft-delete (not hard-delete) is required because the unique key is the
157+
// value tuple itself: when OSV corrects a range, the corrected tuple has no
158+
// successor row to collapse into, so without deleted_at the stale tuple
159+
// would sit forever as a false-positive vulnerable-range match.
160+
export async function reconcileOsvRanges(
152161
qx: QueryExecutor,
153162
advisoryPackageId: number,
163+
ranges: AdvisoryRangeInsertInput[],
154164
): Promise<void> {
165+
const values = ranges.map((r) => ({
166+
introduced_version: r.introducedVersion,
167+
fixed_version: r.fixedVersion,
168+
last_affected: r.lastAffected,
169+
}))
170+
155171
await qx.result(
156172
`
157-
DELETE FROM advisory_affected_ranges
158-
WHERE advisory_package_id = $(advisoryPackageId)
159-
AND range_raw IS NULL
160-
AND unaffected_raw IS NULL
173+
INSERT INTO advisory_affected_ranges
174+
(advisory_package_id, introduced_version, fixed_version, last_affected, created_at, updated_at)
175+
SELECT $(advisoryPackageId), v.introduced_version, v.fixed_version, v.last_affected, NOW(), NOW()
176+
FROM jsonb_to_recordset($(values)::jsonb) AS v(
177+
introduced_version text,
178+
fixed_version text,
179+
last_affected text
180+
)
181+
ON CONFLICT (advisory_package_id, COALESCE(introduced_version, ''), COALESCE(fixed_version, ''), COALESCE(last_affected, ''))
182+
DO UPDATE SET
183+
updated_at = NOW(),
184+
deleted_at = NULL
185+
WHERE advisory_affected_ranges.deleted_at IS NOT NULL
161186
`,
162-
{ advisoryPackageId },
187+
{ advisoryPackageId, values: JSON.stringify(values) },
188+
)
189+
190+
// Soft-delete OSV-owned live rows whose tuple isn't in the new set anymore.
191+
await qx.result(
192+
`
193+
UPDATE advisory_affected_ranges ar
194+
SET deleted_at = NOW()
195+
WHERE ar.advisory_package_id = $(advisoryPackageId)
196+
AND ar.deleted_at IS NULL
197+
AND ar.range_raw IS NULL
198+
AND ar.unaffected_raw IS NULL
199+
AND NOT EXISTS (
200+
SELECT 1
201+
FROM jsonb_to_recordset($(values)::jsonb) AS v(
202+
introduced_version text,
203+
fixed_version text,
204+
last_affected text
205+
)
206+
WHERE COALESCE(v.introduced_version, '') = COALESCE(ar.introduced_version, '')
207+
AND COALESCE(v.fixed_version, '') = COALESCE(ar.fixed_version, '')
208+
AND COALESCE(v.last_affected, '') = COALESCE(ar.last_affected, '')
209+
)
210+
`,
211+
{ advisoryPackageId, values: JSON.stringify(values) },
163212
)
164213
}
165214

166-
export async function insertAdvisoryRange(
215+
// OSV is the source of truth for ranges over time (see ADR-0001 §Write
216+
// semantics `advisories` row): once OSV has written its structured ranges
217+
// for an advisory_package, any live deps.dev raw row for that same
218+
// advisory_package is superseded and should stop being treated as current.
219+
// Soft-deleted (not hard-deleted) for the same reason as reconcileOsvRanges —
220+
// no successor row exists to collapse a deps.dev tuple into.
221+
export async function supersedeDepsDevRanges(
167222
qx: QueryExecutor,
168-
range: AdvisoryRangeInsertInput,
223+
advisoryPackageId: number,
169224
): Promise<void> {
170225
await qx.result(
171226
`
172-
INSERT INTO advisory_affected_ranges
173-
(advisory_package_id, introduced_version, fixed_version, last_affected, created_at, updated_at)
174-
VALUES
175-
($(advisoryPackageId), $(introducedVersion), $(fixedVersion), $(lastAffected), NOW(), NOW())
227+
UPDATE advisory_affected_ranges
228+
SET deleted_at = NOW()
229+
WHERE advisory_package_id = $(advisoryPackageId)
230+
AND deleted_at IS NULL
231+
AND (range_raw IS NOT NULL OR unaffected_raw IS NOT NULL)
176232
`,
177-
range,
233+
{ advisoryPackageId },
178234
)
179235
}
180236

0 commit comments

Comments
 (0)