Skip to content

Commit 4cf0734

Browse files
authored
fix: dedup advisory_affected_ranges via diff-based upsert (CM-1258) (#4366)
Signed-off-by: Uroš Marolt <uros@marolt.me>
1 parent 3151db9 commit 4cf0734

9 files changed

Lines changed: 483 additions & 40 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
ALTER TABLE advisory_affected_ranges ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
2+
3+
CREATE INDEX IF NOT EXISTS advisory_affected_ranges_live_idx
4+
ON advisory_affected_ranges (advisory_package_id)
5+
WHERE deleted_at IS NULL;

docs/adr/0001-oss-packages-design-decisions.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ Five sub-workers run concurrently (npm, Maven, OSV, GitHub, Docker Hub), all wri
223223
| `versions` | Append-only via `INSERT … ON CONFLICT DO NOTHING`. Yanked/deprecated status is a separate targeted `UPDATE (is_yanked = true) WHERE …`. |
224224
| `repos` | Registry workers (npm, Maven) do **not** write `repos` enrichment metadata. They INSERT a minimal `repos(url, host)` row — `url` (canonical) and `host` (coarse classification) are both derived from the declared repository URL — solely to create the FK target their `package_repos` link needs. `owner`/`name`/`stars`/`description` and all other metadata stay NULL and remain enricher-owned; existing rows are never updated by registry workers. The GitHub enricher — triggered when `repos.last_synced_at IS NULL` — upserts `repos` with metadata. Docker Hub worker adds `docker_*` columns on top. |
225225
| `package_repos` | Composite PK `(package_id, repo_url)`. Each `source` value ('declared', 'deps_dev', 'heuristic', 'manual') is a separate row — sources do not overwrite each other. |
226-
| `advisories` | Upsert on `osv_id`. OSV is the single source of truth; no other worker writes to this table. |
226+
| `advisories` | Upsert on `osv_id`. OSV is the source of truth for ongoing updates; deps.dev also writes this table at backfill / new-package time (see §Source of truth: deps.dev backfill vs registries / OSV) — OSV's writes are expected to overwrite deps.dev's over time, not the other way around. |
227227
| `maintainers` / `package_maintainers` | `maintainers`: upsert on `(ecosystem, username)`, never deleted — the identity history is preserved. `package_maintainers`: reflects the **current** link set — the npm worker replaces a package's links each ingest (delete + reinsert), so prior link rows are not retained. |
228228
| `downloads_daily` | Append-only time-series. Each `(package_id, date)` row is written once. npm and Maven workers own disjoint rows by ecosystem. Historical timelines are preserved — workers do not overwrite past dates. |
229229
| `downloads_last_30d` | Upsert on `(purl, end_date)`. Written by the weekly ranking worker only. The cached `packages.downloads_last_30d` column must be updated in the same pass. |
@@ -364,6 +364,27 @@ Local verification against the live OSV dataset (2026-05-28) showed the multi-ra
364364

365365
---
366366

367+
### `advisory_affected_ranges` delete/dedup strategy
368+
369+
Two duplication problems surfaced in Tinybird for `advisory_affected_ranges` (CM-1258):
370+
371+
**Problem A — zombie rows from hard-delete + reinsert.** The OSV worker's per-sync flow (`upsertAdvisory.ts``deleteOsvOnlyRanges` in `services/libs/data-access-layer/src/packages/osv.ts`) hard-deletes all OSV-owned ranges for an advisory_package and reinserts the deduped set via plain `INSERT` (no `ON CONFLICT`), so an unchanged range gets a brand-new PK every sync. `advisory_affected_ranges` is Sequin-replicated with `REPLICA IDENTITY FULL` into a Tinybird `ReplacingMergeTree(updatedAt)` datasource that has no sign/`is_deleted` column and no delete-shaped ingest path — the DELETE never propagates downstream, only the new-PK reinsert does. Net effect: every sync produces one zombie row per range, even when nothing changed.
372+
373+
**Problem B — deps.dev raw rows and OSV structured rows never collide.** deps.dev writes `range_raw`/`unaffected_raw`; OSV writes `introduced_version`/`fixed_version`/`last_affected`. Per §`advisory_affected_ranges` uniqueness scope above, the unique index is on the structured columns only, so a deps.dev row (structured columns NULL) never conflicts with the equivalent OSV row — both survive as separate rows for the same real-world range, even though §Source of truth already says OSV should overwrite deps.dev's data over time.
374+
375+
**Decided fix — diff-based upsert with soft-delete, not sweep-and-reinsert:**
376+
377+
- Add a `deleted_at` column to `advisory_affected_ranges` (soft-delete, same pattern as `V1783036800__security_contacts_soft_delete.sql`).
378+
- Per advisory_package, per sync: read existing live OSV-owned tuples, diff against the new deduped tuple set. Tuple in both → skip (no write, no event). Tuple only in new → `INSERT ... ON CONFLICT (advisory_package_id, COALESCE(introduced_version,''), COALESCE(fixed_version,''), COALESCE(last_affected,'')) DO UPDATE SET updated_at = NOW(), deleted_at = NULL`. Tuple only in old → soft-delete (`SET deleted_at = NOW()`), not hard-delete. An unchanged advisory_package now produces zero writes and zero Sequin/Tinybird events per sync.
379+
- Soft-delete (not hard-delete) is required because the unique key is the value tuple itself: when OSV corrects a range (e.g. narrows `fixed_version`), the corrected tuple is a different key, so the old tuple's row has no successor row to collapse into — without a `deleted_at` flag it would sit forever as a false-positive vulnerable-range match.
380+
- After OSV writes its structured ranges for an advisory_package, soft-delete any live deps.dev-owned row (`range_raw IS NOT NULL OR unaffected_raw IS NOT NULL`) for that same advisory_package — this is the §Source of truth overwrite rule, made to actually take effect now that raw and structured rows are reconciled instead of coexisting.
381+
- Tinybird: add `deletedAt` to `advisoryAffectedRanges.datasource` and filter `deletedAt IS NULL` in `ossPackages_enriched.pipe`'s join, alongside the existing `FINAL` modifier (`FINAL` only collapses rows sharing a sorting key — it does not merge two distinct PKs claiming the same real-world range, which is why this needs an explicit filter rather than relying on `FINAL` alone).
382+
- **Rollout note**: the `deletedAt IS NULL` filter only stops *new* duplicates. Zombie rows already replicated into Tinybird under the old hard-delete + reinsert path have no surviving Postgres row to emit a `deleted_at` update from, so they keep reading as `deletedAt IS NULL` forever. Deploying this fix must be paired with a one-time truncate + full Sequin resnapshot of the `advisoryAffectedRanges` datasource from the live Postgres table, or the existing false-positive vulnerable ranges persist.
383+
384+
**Decided**: 2026-07-20
385+
386+
---
387+
367388
### Per-source ingestion strategies
368389

369390
#### npm
@@ -576,6 +597,7 @@ The writer of a `downloads_last_30d` row must also update the cached `packages.d
576597
- 2026-05-29 — clarified `packages_universe` import semantics (one-time backfill + weekly snapshot-diff incrementals; the ranking job updates score/flag columns in place). Added §Source of truth: deps.dev backfill vs registries / OSV with lifecycle ownership rules and the agreed `package_source_log` provenance table (`(package_id, source)` PK; `columns` array tracks `table.column` paths each source writes).
577598
- 2026-05-29 — added §Criticality scoring methodology (graph signals — transitive dependent count and PageRank centrality; per-ecosystem percentile-rank formula in `[0, 1]`; floor + ceiling tier budget policy; `package_criticality_spotlight` table).
578599
- 2026-06-08 — retired `packages_universe` table. Signals (`downloads_last_30d`, `centrality_score`, `rank_in_ecosystem`) migrated onto `packages`. Both `rank_packages_universe()` overloads dropped; replaced by `rank_packages()` operating directly on `packages`, scoped to ecosystems present in `critical_top_n_by_ecosystem` JSONB. §Downloads timeline by tier simplified (Tier 2/3 split removed). §Write semantics `packages_universe` row removed.
600+
- 2026-07-20 (CM-1258) — corrected stale §Write semantics `advisories` row (deps.dev also writes this table; OSV overwrites over time per §Source of truth, it isn't the sole writer). Added §`advisory_affected_ranges` delete/dedup strategy: diff-based upsert + soft-delete (`deleted_at`) replaces hard-delete + reinsert to stop zombie rows in Tinybird, and OSV now supersedes deps.dev's raw rows on overlap so the two sources stop coexisting as duplicates.
579601
---
580602

581603
## Note on promotion to production

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

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,28 @@ LEFT JOIN packages p ON p.purl = s.purl
7777
ON CONFLICT (advisory_id, ecosystem, package_name) DO NOTHING
7878
`
7979

80-
// Separate statement — must execute after ADVISORY_PACKAGES_MERGE_SQL so advisory_packages rows exist
80+
// Separate statement — must execute after ADVISORY_PACKAGES_MERGE_SQL so advisory_packages rows exist.
81+
// Skips any advisory_package that already has a live OSV-owned range: OSV is the
82+
// source of truth over deps.dev for overlapping advisory_packages (ADR-0001
83+
// §advisory_affected_ranges delete/dedup strategy), and this merge runs on its own
84+
// BQ-driven schedule, independent of and potentially after an OSV sync — the
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.
93+
// DISTINCT ON (ap.id) is required because DO UPDATE (unlike DO NOTHING) errors
94+
// with "ON CONFLICT DO UPDATE command cannot affect row a second time" if two
95+
// staging rows for the same package (e.g. multiple disjoint deps.dev ranges
96+
// under one advisory) hit the same conflict target within one statement — every
97+
// deps.dev row shares one key per advisory_package_id since introduced_version
98+
// etc. are always NULL here.
8199
const ADVISORY_AFFECTED_RANGES_MERGE_SQL = `
82100
INSERT INTO advisory_affected_ranges (advisory_package_id, range_raw, unaffected_raw, introduced_version, created_at, updated_at)
83-
SELECT
101+
SELECT DISTINCT ON (ap.id)
84102
ap.id,
85103
s.range_raw,
86104
s.unaffected_raw,
@@ -91,7 +109,39 @@ JOIN advisories adv ON adv.osv_id = s.osv_id
91109
JOIN advisory_packages ap ON ap.advisory_id = adv.id
92110
AND ap.ecosystem = s.ecosystem
93111
AND ap.package_name = s.package_name
94-
ON CONFLICT (advisory_package_id, COALESCE(introduced_version, ''), COALESCE(fixed_version, ''), COALESCE(last_affected, '')) DO NOTHING
112+
WHERE NOT EXISTS (
113+
SELECT 1 FROM advisory_affected_ranges live
114+
WHERE live.advisory_package_id = ap.id
115+
AND live.deleted_at IS NULL
116+
AND live.range_raw IS NULL
117+
AND live.unaffected_raw IS NULL
118+
)
119+
ORDER BY ap.id
120+
ON CONFLICT (advisory_package_id, COALESCE(introduced_version, ''), COALESCE(fixed_version, ''), COALESCE(last_affected, ''))
121+
DO UPDATE SET
122+
updated_at = NOW(),
123+
deleted_at = NULL,
124+
range_raw = EXCLUDED.range_raw,
125+
unaffected_raw = EXCLUDED.unaffected_raw
126+
WHERE advisory_affected_ranges.deleted_at IS NOT NULL
127+
`
128+
129+
// Runs as prepareSql (uncounted) ahead of ADVISORY_AFFECTED_RANGES_MERGE_SQL, in the
130+
// same transaction. Row-locks every advisory_packages this chunk will touch, in id
131+
// order, before the NOT EXISTS ownership check runs. OSV's upsertOne (services/apps/
132+
// packages_worker/src/osv/upsertAdvisory.ts) takes the matching per-row lock before it
133+
// writes advisory_affected_ranges for that advisory_package, so whichever transaction
134+
// (deps.dev chunk or OSV record) locks the row first now forces the other to wait and
135+
// see its committed writes — closing the race the two independently-scheduled write
136+
// paths would otherwise have on the ownership check (ADR-0001 §Write semantics).
137+
const ADVISORY_PACKAGES_LOCK_SQL = `
138+
SELECT ap.id
139+
FROM advisory_packages ap
140+
JOIN staging.osspckgs_advisory_packages_raw s
141+
ON s.ecosystem = ap.ecosystem AND s.package_name = ap.package_name
142+
JOIN advisories adv ON adv.osv_id = s.osv_id AND adv.id = ap.advisory_id
143+
ORDER BY ap.id
144+
FOR UPDATE OF ap
95145
`
96146

97147
const ADVISORIES_PG_COLUMNS = [
@@ -259,6 +309,7 @@ export async function ingestAdvisories(opts: {
259309

260310
const { rowsAffected, tableRowCounts } = await mergeStagingToTable({
261311
jobId: pkgsExport.jobId,
312+
prepareSql: ADVISORY_PACKAGES_LOCK_SQL,
262313
mergeSql: [ADVISORY_PACKAGES_MERGE_SQL, ADVISORY_AFFECTED_RANGES_MERGE_SQL],
263314
tableNames: ['advisory_packages', 'advisory_affected_ranges'],
264315
isFinal,

0 commit comments

Comments
 (0)