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
1123The reference `order` is assigned cve-first then legacy-PK-ascending, so ``order == 0`` is the
1224finding's cve (primary id) by construction — matching every live write path.
1325"""
1426
1527import logging
1628
17- from django .db import connection
29+ from django .db import connection , transaction
1830
1931from dojo .finding .vulnerability_id import resolve_vulnerability_id_type
2032
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
99138def 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
0 commit comments