Skip to content

Commit d139e46

Browse files
committed
fix: revive soft-deleted ranges and fix lock ordering deadlock (CM-1258)
Signed-off-by: Uroš Marolt <uros@marolt.me>
1 parent d89f9fd commit d139e46

2 files changed

Lines changed: 42 additions & 19 deletions

File tree

services/apps/packages_worker/src/deps-dev/workflows/ingestAdvisories.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,14 @@ ON CONFLICT (advisory_id, ecosystem, package_name) DO NOTHING
8282
// source of truth over deps.dev for overlapping advisory_packages (ADR-0001
8383
// §advisory_affected_ranges delete/dedup strategy), and this merge runs on its own
8484
// BQ-driven schedule, independent of and potentially after an OSV sync — the
85-
// per-tuple ON CONFLICT DO NOTHING below can't see that, since a NULL-bounds raw
86-
// tuple has a different key than OSV's structured tuple and would insert as a new
87-
// live duplicate. The NOT EXISTS guard makes the ownership rule package-level
88-
// instead of tuple-level, so deps.dev never adds a live row once OSV owns the package.
85+
// per-tuple ON CONFLICT below can't see that, since a NULL-bounds raw tuple has a
86+
// different key than OSV's structured tuple and would insert as a new live
87+
// duplicate. The NOT EXISTS guard makes the ownership rule package-level instead
88+
// of tuple-level, so deps.dev never adds a live row once OSV owns the package.
89+
// ON CONFLICT revives (not skips) a soft-deleted row occupying the same tuple —
90+
// typical after supersedeDepsDevRanges soft-deletes deps.dev rows on OSV takeover
91+
// and OSV later drops the package again — otherwise DO NOTHING would leave
92+
// staging's live data with no corresponding live row.
8993
const ADVISORY_AFFECTED_RANGES_MERGE_SQL = `
9094
INSERT INTO advisory_affected_ranges (advisory_package_id, range_raw, unaffected_raw, introduced_version, created_at, updated_at)
9195
SELECT
@@ -106,7 +110,13 @@ WHERE NOT EXISTS (
106110
AND live.range_raw IS NULL
107111
AND live.unaffected_raw IS NULL
108112
)
109-
ON CONFLICT (advisory_package_id, COALESCE(introduced_version, ''), COALESCE(fixed_version, ''), COALESCE(last_affected, '')) DO NOTHING
113+
ON CONFLICT (advisory_package_id, COALESCE(introduced_version, ''), COALESCE(fixed_version, ''), COALESCE(last_affected, ''))
114+
DO UPDATE SET
115+
updated_at = NOW(),
116+
deleted_at = NULL,
117+
range_raw = EXCLUDED.range_raw,
118+
unaffected_raw = EXCLUDED.unaffected_raw
119+
WHERE advisory_affected_ranges.deleted_at IS NOT NULL
110120
`
111121

112122
// Runs as prepareSql (uncounted) ahead of ADVISORY_AFFECTED_RANGES_MERGE_SQL, in the

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

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
AdvisoryRangeInsertInput,
23
findPackageId,
34
reconcileOsvRanges,
45
supersedeDepsDevRanges,
@@ -36,6 +37,7 @@ async function upsertOne(qx: QueryExecutor, record: NormalizedRecord): Promise<v
3637

3738
const advisoryId = await upsertAdvisory(qx, advisory)
3839

40+
const entries: { advisoryPackageId: number; ranges: AdvisoryRangeInsertInput[] }[] = []
3941
for (const entry of packages) {
4042
const packageId = await findPackageId(qx, entry.pkg)
4143

@@ -46,26 +48,37 @@ async function upsertOne(qx: QueryExecutor, record: NormalizedRecord): Promise<v
4648
packageName: entry.pkg.packageName,
4749
})
4850

49-
// Row-locks this advisory_packages row (held until the enclosing transaction
50-
// commits) before touching advisory_affected_ranges, matching the lock
51-
// deps.dev's bulk merge takes on the same row (ADVISORY_PACKAGES_LOCK_SQL in
52-
// ingestAdvisories.ts) — whichever writer locks first forces the other to
53-
// wait and see its committed writes, closing the ownership race between the
54-
// two independently-scheduled write paths (ADR-0001 §Write semantics).
55-
await qx.result(`SELECT id FROM advisory_packages WHERE id = $(advisoryPackageId) FOR UPDATE`, {
56-
advisoryPackageId,
57-
})
58-
59-
await reconcileOsvRanges(
60-
qx,
51+
entries.push({
6152
advisoryPackageId,
62-
dedupeRanges(entry.ranges).map((range) => ({
53+
ranges: dedupeRanges(entry.ranges).map((range) => ({
6354
advisoryPackageId,
6455
introducedVersion: range.introducedVersion,
6556
fixedVersion: range.fixedVersion,
6657
lastAffected: range.lastAffected,
6758
})),
68-
)
59+
})
60+
}
61+
62+
// Lock advisory_packages rows in ascending id order — the same order deps.dev's
63+
// bulk merge locks them in (ADVISORY_PACKAGES_LOCK_SQL's ORDER BY ap.id in
64+
// ingestAdvisories.ts). OSV's payload order is otherwise arbitrary; locking out
65+
// of order here would let this per-record transaction and a concurrent deps.dev
66+
// chunk each hold one row and wait on the other's, deadlocking — and deps.dev's
67+
// maximumAttempts: 1 turns a deadlock abort into a hard merge failure, not a retry.
68+
entries.sort((a, b) => a.advisoryPackageId - b.advisoryPackageId)
69+
70+
for (const { advisoryPackageId, ranges } of entries) {
71+
// Row-locks this advisory_packages row (held until the enclosing transaction
72+
// commits) before touching advisory_affected_ranges, matching the lock
73+
// deps.dev's bulk merge takes on the same row — whichever writer locks first
74+
// forces the other to wait and see its committed writes, closing the
75+
// ownership race between the two independently-scheduled write paths
76+
// (ADR-0001 §Write semantics).
77+
await qx.result(`SELECT id FROM advisory_packages WHERE id = $(advisoryPackageId) FOR UPDATE`, {
78+
advisoryPackageId,
79+
})
80+
81+
await reconcileOsvRanges(qx, advisoryPackageId, ranges)
6982
await supersedeDepsDevRanges(qx, advisoryPackageId)
7083
}
7184
}

0 commit comments

Comments
 (0)