Skip to content

Commit d25dfec

Browse files
Maffoochclaude
andcommitted
fix(vuln-id): make backfill convergent + guard the legacy-table drop
Addresses review of the entity-only cutover (rollout machinery): - backfill.py: the reference build is now convergent — per finding_id window it DELETEs that window's references and re-inserts from the current legacy rows, inside one transaction per window. A pre-run + deploy-time catch-up where a finding's ids changed no longer (a) leaves stale references behind, nor (b) raises on the unique_finding_order constraint by re-inserting at occupied order slots. Still idempotent and resumable. Window bounds span both the legacy and reference tables so stale refs past the legacy max are still reconciled. - Remove the silent non-PostgreSQL vendor guard from the backfill migration (0287) and the migrate_vulnerability_ids command. DefectDojo is PostgreSQL-only, so the backfill runs unconditionally and fails loudly rather than skipping and letting the unconditional 0288 drop destroy un-migrated data. - 0288: add a one-query safety invariant before DeleteModel — abort the drop if dojo_vulnerability_id still has rows but the reference table is empty (the useful residue of the removed verify command). - Fix stale "frozen until dropped in a follow-up migration" docs in manager.py / queries.py — 0288 in this same change drops the legacy table. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5f198b6 commit d25dfec

6 files changed

Lines changed: 123 additions & 51 deletions

File tree

dojo/db_migrations/0287_backfill_vulnerability_id_entities.py

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,33 @@
11
# Data-only backfill of the Vulnerability entity + FindingVulnerabilityReference tables created
2-
# in 0286. atomic=False so each set-based statement (and each finding_id window in the reference
3-
# build) commits on its own — a crash mid-backfill resumes where it left off, and ON CONFLICT DO
4-
# NOTHING everywhere makes any re-run a no-op on completed work. Insert-only into the two NEW
5-
# tables: dojo_vulnerability_id and dojo_finding are never rewritten, locked, or deleted, so
6-
# concurrent readers/writers are not blocked.
2+
# in 0286. atomic=False so each entity insert and each finding_id window in the (convergent)
3+
# reference build commits on its own — a crash mid-backfill resumes where it left off, and the
4+
# reference build re-derives each window from the current legacy rows so any re-run converges on
5+
# already-current work. Only the two NEW tables are written (the reference table is also deleted
6+
# from, per window); dojo_vulnerability_id and dojo_finding are never rewritten, locked, or deleted.
77
#
88
# The actual work lives in dojo.vulnerability.backfill.run_backfill, shared with the
99
# `migrate_vulnerability_ids` management command (big installs pre-run the command on the previous
10-
# release; this migration is then an idempotent catch-up during the deploy).
10+
# release; this migration is then a convergent catch-up during the deploy).
11+
#
12+
# PostgreSQL only: DefectDojo is a PostgreSQL-only application (0286's GIN / full-text indexes
13+
# already require it), so this runs unconditionally and the underlying SQL fails loudly on any
14+
# other backend rather than skipping silently and leaving 0288 to drop the un-migrated source.
1115
import logging
1216

13-
from django.db import connection, migrations
17+
from django.db import migrations
1418

1519
logger = logging.getLogger(__name__)
1620

1721

1822
def backfill_vulnerability_id_entities(apps, schema_editor):
19-
if connection.vendor != "postgresql":
20-
logger.warning(
21-
"Vulnerability ID entity backfill is PostgreSQL-only and was skipped on the %s "
22-
"backend. Run `manage.py migrate_vulnerability_ids` for a backend-appropriate backfill.",
23-
connection.vendor,
24-
)
25-
return
2623
from dojo.vulnerability.backfill import run_backfill # noqa: PLC0415 -- lazy import
2724

2825
run_backfill(logger=logger)
2926

3027

3128
class Migration(migrations.Migration):
3229

33-
"""Idempotent, resumable, insert-only backfill of the vulnerability-id entity tables."""
30+
"""Idempotent, resumable, convergent backfill of the vulnerability-id entity tables."""
3431

3532
atomic = False
3633

dojo/db_migrations/0288_delete_vulnerability_id.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,34 @@
11
# Entity-only cutover: drop the legacy Vulnerability_Id store. Vulnerability ids now live entirely in
22
# the Vulnerability entity + FindingVulnerabilityReference through-table (created in 0286, backfilled
33
# from dojo_vulnerability_id in 0287 — which runs BEFORE this drop). ⚠️ DESTRUCTIVE: drops
4-
# dojo_vulnerability_id and its data. On upgrading installs, ensure 0287's backfill (or
5-
# `manage.py migrate_vulnerability_ids` pre-run) has completed before this migration removes the source.
4+
# dojo_vulnerability_id and its data.
5+
#
6+
# Safety invariant (the useful residue of the removed verify_vulnerability_ids command): refuse to
7+
# drop the legacy table if it still holds rows the backfill never transferred. This is the last
8+
# moment such a check can act — once the table is gone, the source of truth is gone with it. It
9+
# catches the one catastrophic ordering mistake (0288 reached with 0287 skipped/failed, e.g. on a
10+
# non-PostgreSQL backend where the raw-SQL backfill never ran) and turns silent data loss into a
11+
# loud, actionable migration failure.
612

7-
from django.db import migrations
13+
from django.db import connection, migrations
14+
15+
_ABORT_MESSAGE = (
16+
"Refusing to drop dojo_vulnerability_id: it still has rows but "
17+
"dojo_findingvulnerabilityreference is empty, so the 0287 backfill did not run (or did not "
18+
"complete). Dropping now would permanently lose vulnerability-id data. Run "
19+
"`manage.py migrate_vulnerability_ids` (PostgreSQL only) to backfill, then re-run migrate."
20+
)
21+
22+
23+
def assert_backfill_ran(apps, schema_editor):
24+
"""Abort the drop if the legacy table has data but the reference table is empty."""
25+
with connection.cursor() as cursor:
26+
cursor.execute("SELECT EXISTS (SELECT 1 FROM dojo_vulnerability_id)")
27+
legacy_has_rows = cursor.fetchone()[0]
28+
cursor.execute("SELECT EXISTS (SELECT 1 FROM dojo_findingvulnerabilityreference)")
29+
references_exist = cursor.fetchone()[0]
30+
if legacy_has_rows and not references_exist:
31+
raise RuntimeError(_ABORT_MESSAGE)
832

933

1034
class Migration(migrations.Migration):
@@ -14,6 +38,9 @@ class Migration(migrations.Migration):
1438
]
1539

1640
operations = [
41+
# Guard runs before the drop; on an atomic migration a raised RuntimeError rolls the whole
42+
# migration back, so the DeleteModel never executes when the invariant is violated.
43+
migrations.RunPython(assert_backfill_ran, migrations.RunPython.noop),
1744
migrations.DeleteModel(
1845
name="Vulnerability_Id",
1946
),

dojo/management/commands/migrate_vulnerability_ids.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import logging
22

33
from django.core.management.base import BaseCommand
4-
from django.db import connection
54

65
from dojo.vulnerability.backfill import DEFAULT_WINDOW_SIZE, run_backfill
76

@@ -14,10 +13,12 @@ class Command(BaseCommand):
1413
Backfill the Vulnerability entity + FindingVulnerabilityReference tables from the legacy
1514
dojo_vulnerability_id table and dojo_finding.cve.
1615
17-
Idempotent and resumable (insert-only + ON CONFLICT DO NOTHING), so it is safe to run
18-
repeatedly: large tenants can pre-run it on the previous release, then it is an instant
19-
catch-up during the deploy-time migration. Shares dojo.vulnerability.backfill.run_backfill
20-
with migration 0286.
16+
Idempotent and convergent: the reference build re-derives each finding_id window from the
17+
current legacy rows, so it is safe to run repeatedly. Large tenants can pre-run it on the
18+
previous release; migration 0287 is then a convergent catch-up during the deploy that also
19+
reconciles any ids that changed in between. Shares dojo.vulnerability.backfill.run_backfill
20+
with migration 0287. PostgreSQL only (DefectDojo is PostgreSQL-only); the underlying SQL fails
21+
loudly on any other backend.
2122
"""
2223

2324
help = "Idempotently backfill the vulnerability-id entity + reference tables (PostgreSQL only)."
@@ -31,9 +32,6 @@ def add_arguments(self, parser):
3132
)
3233

3334
def handle(self, *args, **options):
34-
if connection.vendor != "postgresql":
35-
self.stderr.write(f"Vulnerability-id backfill is PostgreSQL only; nothing to do on {connection.vendor}.")
36-
return
3735
counts = run_backfill(logger=logger, window_size=options["window_size"])
3836
for step, count in counts.items():
3937
self.stdout.write(f"{step}: {count}")

dojo/vulnerability/backfill.py

Lines changed: 71 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,32 @@
11
"""
2-
Idempotent, set-based backfill of the Vulnerability entity + FindingVulnerabilityReference
3-
tables from the legacy dojo_vulnerability_id table and dojo_finding.cve.
4-
5-
Shared by migration 0286 (RunPython forward) and the `migrate_vulnerability_ids` management
6-
command. PostgreSQL only (uses ``ON CONFLICT``); callers guard on vendor and print the escape
7-
hatch for other backends. Every statement is insert-only into the two NEW tables with
8-
``ON CONFLICT DO NOTHING`` — nothing in dojo_vulnerability_id / dojo_finding is ever read-locked,
9-
rewritten, or deleted, and any re-run (migration or command) is a no-op on completed work.
2+
Idempotent, convergent, set-based backfill of the Vulnerability entity +
3+
FindingVulnerabilityReference tables from the legacy dojo_vulnerability_id table and
4+
dojo_finding.cve.
5+
6+
Shared by migration 0287 (RunPython forward) and the `migrate_vulnerability_ids` management
7+
command. PostgreSQL only (uses ``ON CONFLICT`` and window functions) — DefectDojo is a
8+
PostgreSQL-only application (the models rely on GIN / full-text indexes), so the raw SQL below
9+
fails loudly on any other backend rather than silently skipping.
10+
11+
Entity inserts (steps 1-2) are insert-only with ``ON CONFLICT DO NOTHING``. The reference build
12+
(step 3) is **convergent**, not insert-only: each finding_id window ``DELETE``s the references for
13+
its findings and re-inserts them from the *current* legacy rows, inside one transaction per window.
14+
That is what makes a pre-run + deploy-time catch-up safe: if a finding's ids changed between the
15+
pre-run and the deploy, the stale references are removed and rebuilt rather than left to drift (and
16+
a plain re-insert would in any case violate the ``unique_finding_order`` constraint at the occupied
17+
order slots). Because each window commits atomically and the delete is a superset re-derive, a crash
18+
mid-backfill resumes cleanly and any re-run is a no-op on already-current work.
19+
20+
The legacy source tables (dojo_vulnerability_id / dojo_finding) are never rewritten, locked, or
21+
deleted — only the two NEW tables are written, and only the reference table is deleted from.
1022
1123
The reference `order` is assigned cve-first then legacy-PK-ascending, so ``order == 0`` is the
1224
finding's cve (primary id) by construction — matching every live write path.
1325
"""
1426

1527
import logging
1628

17-
from django.db import connection
29+
from django.db import connection, transaction
1830

1931
from dojo.finding.vulnerability_id import resolve_vulnerability_id_type
2032

@@ -37,11 +49,38 @@
3749
ON CONFLICT (vulnerability_id) DO NOTHING
3850
"""
3951

40-
# Windowed by legacy finding_id. ROW_NUMBER orders cve-match first, then legacy PK ascending;
41-
# s.ord - 1 makes order 0-based so the cve row lands at order 0. The join to dojo_vulnerability
42-
# resolves each legacy string to its entity PK. ON CONFLICT covers re-runs and the unique
43-
# (finding_id, vulnerability_id) constraint (dojo 0282 guarantees legacy pair uniqueness, so no
44-
# dedupe layer is needed here).
52+
# Reference window bounds span BOTH tables: a catch-up run must reach any finding that already has
53+
# references (to re-derive/clean it) even if its last legacy row was removed since the pre-run and
54+
# it now sits past MAX(dojo_vulnerability_id.finding_id). LEAST/GREATEST ignore NULLs on Postgres,
55+
# so an empty reference table (first run) collapses this to the legacy bounds.
56+
_SELECT_REFERENCE_WINDOW_BOUNDS = """
57+
SELECT
58+
LEAST(
59+
(SELECT MIN(finding_id) FROM dojo_vulnerability_id),
60+
(SELECT MIN(finding_id) FROM dojo_findingvulnerabilityreference)
61+
),
62+
GREATEST(
63+
(SELECT MAX(finding_id) FROM dojo_vulnerability_id),
64+
(SELECT MAX(finding_id) FROM dojo_findingvulnerabilityreference)
65+
)
66+
"""
67+
68+
# Convergent step 1: clear this window's references so the re-insert below rebuilds them from the
69+
# current legacy rows. Only the NEW reference table is touched. Paired with the insert inside one
70+
# transaction per window (see run_backfill), this removes any references whose finding's ids changed
71+
# since a pre-run — and it is what lets the insert stay conflict-free: after the delete there is no
72+
# occupied `order` slot for the ``unique_finding_order`` constraint to collide with.
73+
_DELETE_REFERENCES_WINDOW = """
74+
DELETE FROM dojo_findingvulnerabilityreference
75+
WHERE finding_id >= %(lo)s AND finding_id < %(hi)s
76+
"""
77+
78+
# Convergent step 2. Windowed by legacy finding_id. ROW_NUMBER orders cve-match first, then legacy
79+
# PK ascending; s.ord - 1 makes order 0-based so the cve row lands at order 0. The join to
80+
# dojo_vulnerability resolves each legacy string to its entity PK. The ON CONFLICT on
81+
# (finding_id, vulnerability_id) is a belt-and-suspenders guard for a concurrent live writer having
82+
# re-inserted the same pair between this window's DELETE and INSERT; the DELETE guarantees the
83+
# backfill's own inserts never collide (dojo 0282 also guarantees legacy pair uniqueness).
4584
_INSERT_REFERENCES_WINDOW = """
4685
INSERT INTO dojo_findingvulnerabilityreference (finding_id, vulnerability_id, "order", created)
4786
SELECT s.finding_id, e.id, s.ord - 1, now()
@@ -98,9 +137,10 @@ def _stamp_missing_types(cursor, logger):
98137

99138
def run_backfill(*, logger=None, window_size=DEFAULT_WINDOW_SIZE):
100139
"""
101-
Run the full idempotent backfill. PostgreSQL only. Returns a dict of per-step row counts.
140+
Run the full idempotent, convergent backfill. PostgreSQL only. Returns per-step row counts.
102141
103-
Callers (migration 0286 / migrate_vulnerability_ids) are responsible for the vendor guard.
142+
The reference build is convergent (per-window DELETE + re-insert in one transaction each), so it
143+
is safe to re-run as a deploy-time catch-up after a pre-run even when ids changed in between.
104144
"""
105145
logger = logger or logging.getLogger(__name__)
106146
counts = {}
@@ -115,17 +155,26 @@ def run_backfill(*, logger=None, window_size=DEFAULT_WINDOW_SIZE):
115155
counts["entities_from_cve"] = cursor.rowcount
116156
counts["types_stamped"] = _stamp_missing_types(cursor, logger)
117157

118-
logger.info("Backfill step 3/3: references (windowed by finding_id, window=%s)", window_size)
119-
cursor.execute("SELECT MIN(finding_id), MAX(finding_id) FROM dojo_vulnerability_id")
158+
logger.info("Backfill step 3/3: references (convergent, windowed by finding_id, window=%s)", window_size)
159+
cursor.execute(_SELECT_REFERENCE_WINDOW_BOUNDS)
120160
lo, hi_max = cursor.fetchone()
121-
references = 0
161+
deleted = 0
162+
inserted = 0
122163
if lo is not None:
123164
while lo <= hi_max:
124165
hi = lo + window_size
125-
cursor.execute(_INSERT_REFERENCES_WINDOW, {"lo": lo, "hi": hi})
126-
references += cursor.rowcount
166+
# One transaction per window: the DELETE + INSERT commit together, so a crash leaves
167+
# the window either fully rebuilt or untouched (resumable), and the DELETE's row lock
168+
# serializes the rebuild against a concurrent live delete-then-insert on the same
169+
# finding. atomic=False on the migration keeps each window a separate commit.
170+
with transaction.atomic():
171+
cursor.execute(_DELETE_REFERENCES_WINDOW, {"lo": lo, "hi": hi})
172+
deleted += cursor.rowcount
173+
cursor.execute(_INSERT_REFERENCES_WINDOW, {"lo": lo, "hi": hi})
174+
inserted += cursor.rowcount
127175
lo = hi
128-
counts["references_from_legacy"] = references
176+
counts["references_deleted"] = deleted
177+
counts["references_from_legacy"] = inserted
129178

130179
logger.info("Backfill complete: %s", counts)
131180
return counts

dojo/vulnerability/manager.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
Write seam for vulnerability ids: the ``Vulnerability`` entity + ``FindingVulnerabilityReference``
33
rows are the single source of truth. Every writer persists them through this one module.
44
5-
The legacy ``Vulnerability_Id`` store is no longer written (entity-only cutover); the legacy table
6-
still exists but is frozen until it is dropped in a follow-up migration.
5+
The legacy ``Vulnerability_Id`` store is gone: this entity-only cutover stops writing it and drops
6+
its table in the same change (migration 0288, after the 0287 backfill).
77
CWE buffering (store_cwes/pending_cwes/reconcile_cwes) is deliberately NOT owned here — it keeps
88
riding the importer flush boundary exactly as before.
99
"""

dojo/vulnerability/queries.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ def get_auth_filter(key):
1818
# ---------------------------------------------------------------------------
1919
# Read helpers over the entity/reference store — the single source of truth.
2020
# (The legacy Vulnerability_Id read path + V3_FEATURE_VULNERABILITY_IDS flag were
21-
# removed in the entity-only cutover; the legacy table is frozen until dropped.)
21+
# removed in the entity-only cutover; the legacy table is dropped by migration 0288
22+
# in the same change, after the 0287 backfill.)
2223
# ---------------------------------------------------------------------------
2324

2425

0 commit comments

Comments
 (0)