Skip to content

Commit 5fd73ac

Browse files
perf(importers): batch Vulnerability_Id inserts (#14966)
* perf(importers): batch vulnerability_id inserts Replace per-row Vulnerability_Id saves with bulk_create in two layers: - fix sanitize_vulnerability_ids to return filtered list (was a no-op bug — reassigned local variable, caller never saw the result) - save_vulnerability_ids now uses bulk_create per finding instead of one INSERT per ID; fixes all callers including the reimporter path - DefaultImporter.store_vulnerability_ids accumulates Vulnerability_Id objects across all findings in a batch; flush_vulnerability_ids() does a single bulk_create at each batch boundary (alongside location_handler.persist()) For a scan with 1000 findings × 5 CVEs each: 5000 INSERT queries reduced to O(batches) bulk_create calls. * perf(reimporter): batch vulnerability_id reconciliation Extend the cross-finding accumulation pattern to DefaultReImporter: - reconcile_vulnerability_ids now accumulates changed findings into pending_vuln_id_deletes / pending_vulnerability_ids instead of issuing per-finding DELETE + INSERT immediately - flush_vulnerability_ids (BaseImporter) runs one bulk DELETE WHERE finding_id IN (...) followed by one bulk_create for all new IDs - flush called at both dedupe batch boundaries (alongside location_handler.persist()) and after the mitigation loop Early-exit path (unchanged IDs) never touches either buffer, so the common case pays zero extra cost. Add two unit tests: cross-finding batch (3 findings, 2 changed + 1 unchanged, verify buffer contents before flush and DB state after) and unchanged-IDs early-exit (verify buffers stay empty). * test(performance): re-baseline importer query counts Remove pending-rebaseline skips from TestDojoImporterPerformanceSmall and TestDojoImporterPerformanceSmallLocations. Update all expected query counts to reflect the batch Vulnerability_Id insert optimisation (counts decrease by 1-20 queries per step depending on the scan size and code path). * fix(test): update TestSaveVulnerabilityIds mock for bulk_create The test mocked Vulnerability_Id.save (individual saves) but save_vulnerability_ids now uses bulk_create. Django's bulk_create validates FK references before issuing SQL, raising ValueError when the finding has no pk. Mock bulk_create instead and assert on the deduplicated object list passed to it. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0fce166 commit 5fd73ac

7 files changed

Lines changed: 184 additions & 64 deletions

File tree

dojo/finding/helper.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -987,27 +987,26 @@ def add_locations(finding, form, *, replace=False):
987987
return set(locations_to_associate)
988988

989989

990-
def sanitize_vulnerability_ids(vulnerability_ids) -> None:
990+
def sanitize_vulnerability_ids(vulnerability_ids):
991991
"""Remove undisired vulnerability id values"""
992-
vulnerability_ids = [x for x in vulnerability_ids if x.strip()]
992+
return [x for x in vulnerability_ids if x.strip()]
993993

994994

995995
def save_vulnerability_ids(finding, vulnerability_ids, *, delete_existing: bool = True):
996-
# Remove duplicates
996+
# Remove duplicates and empty/whitespace IDs
997997
vulnerability_ids = list(dict.fromkeys(vulnerability_ids))
998+
vulnerability_ids = sanitize_vulnerability_ids(vulnerability_ids)
998999

9991000
# Remove old vulnerability ids if requested
10001001
# Callers can set delete_existing=False when they know there are no existing IDs
10011002
# to avoid an unnecessary delete query (e.g., for new findings)
10021003
if delete_existing:
10031004
Vulnerability_Id.objects.filter(finding=finding).delete()
10041005

1005-
# Remove undisired vulnerability ids
1006-
sanitize_vulnerability_ids(vulnerability_ids)
1007-
# Save new vulnerability ids
1008-
# Using bulk create throws Django 50 warnings about unsaved models...
1009-
for vulnerability_id in vulnerability_ids:
1010-
Vulnerability_Id(finding=finding, vulnerability_id=vulnerability_id).save()
1006+
Vulnerability_Id.objects.bulk_create([
1007+
Vulnerability_Id(finding=finding, vulnerability_id=vid)
1008+
for vid in vulnerability_ids
1009+
])
10111010

10121011
# Set CVE
10131012
if vulnerability_ids:

dojo/importers/base_importer.py

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
Test_Import,
3232
Test_Import_Finding_Action,
3333
Test_Type,
34+
Vulnerability_Id,
3435
)
3536
from dojo.notifications.helper import create_notification
3637
from dojo.tags.utils import bulk_add_tags_to_instances
@@ -77,6 +78,8 @@ def __init__(
7778
and will raise a `NotImplemented` exception
7879
"""
7980
ImporterOptions.__init__(self, *args, **kwargs)
81+
self.pending_vulnerability_ids: list[Vulnerability_Id] = []
82+
self.pending_vuln_id_deletes: list[int] = []
8083

8184
def check_child_implementation_exception(self):
8285
"""
@@ -778,21 +781,31 @@ def store_vulnerability_ids(
778781
finding: Finding,
779782
) -> Finding:
780783
"""
781-
Store vulnerability IDs for a finding.
782-
Reads from finding.unsaved_vulnerability_ids and saves them overwriting existing ones.
783-
784-
Args:
785-
finding: The finding to store vulnerability IDs for
786-
787-
Returns:
788-
The finding object
789-
784+
Accumulate Vulnerability_Id objects for bulk insert at the batch boundary.
785+
Call flush_vulnerability_ids() to persist.
790786
"""
791787
self.sanitize_vulnerability_ids(finding)
792-
vulnerability_ids_to_process = finding.unsaved_vulnerability_ids or []
793-
finding_helper.save_vulnerability_ids(finding, vulnerability_ids_to_process, delete_existing=False)
788+
vulnerability_ids_to_process = list(dict.fromkeys(finding.unsaved_vulnerability_ids or []))
789+
vulnerability_ids_to_process = [x for x in vulnerability_ids_to_process if x.strip()]
790+
self.pending_vulnerability_ids.extend([
791+
Vulnerability_Id(finding=finding, vulnerability_id=vid)
792+
for vid in vulnerability_ids_to_process
793+
])
794+
if vulnerability_ids_to_process:
795+
finding.cve = vulnerability_ids_to_process[0]
796+
else:
797+
finding.cve = None
794798
return finding
795799

800+
def flush_vulnerability_ids(self) -> None:
801+
"""Delete stale and bulk-insert accumulated Vulnerability_Id objects, then clear buffers."""
802+
if self.pending_vuln_id_deletes:
803+
Vulnerability_Id.objects.filter(finding_id__in=self.pending_vuln_id_deletes).delete()
804+
self.pending_vuln_id_deletes.clear()
805+
if self.pending_vulnerability_ids:
806+
Vulnerability_Id.objects.bulk_create(self.pending_vulnerability_ids, batch_size=1000)
807+
self.pending_vulnerability_ids.clear()
808+
796809
def process_files(
797810
self,
798811
finding: Finding,

dojo/importers/default_importer.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ def _process_findings_internal(
275275
# If batch is full or we're at the end, persist locations/endpoints and dispatch
276276
if len(batch_finding_ids) >= batch_max_size or is_final_finding:
277277
self.location_handler.persist()
278+
self.flush_vulnerability_ids()
278279
# Apply parser-supplied tags for this batch before post-processing starts,
279280
# so rules/deduplication tasks see the tags already on the findings.
280281
bulk_apply_parser_tags(findings_with_parser_tags)
@@ -415,6 +416,7 @@ def close_old_findings(
415416
)
416417
# Persist any accumulated location/endpoint status changes
417418
self.location_handler.persist()
419+
self.flush_vulnerability_ids()
418420
# push finding groups to jira since we only only want to push whole groups
419421
# We dont check if the finding jira sync is applicable quite yet until we can get in the loop
420422
# but this is a way to at least make it that far

dojo/importers/default_reimporter.py

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
Notes,
2424
Test,
2525
Test_Import,
26+
Vulnerability_Id,
2627
)
2728
from dojo.tags import inheritance as tag_inheritance
2829
from dojo.tags.inheritance import apply_inherited_tags_for_findings
@@ -438,6 +439,7 @@ def _process_findings_internal(
438439
# They don't need to be aligned since they optimize different operations.
439440
if len(batch_finding_ids) >= dedupe_batch_max_size or is_final:
440441
self.location_handler.persist()
442+
self.flush_vulnerability_ids()
441443
# Apply parser-supplied tags for this batch before post-processing starts,
442444
# so rules/deduplication tasks see the tags already on the findings.
443445
bulk_apply_parser_tags(findings_with_parser_tags)
@@ -561,6 +563,7 @@ def close_old_findings(
561563
mitigated_findings.append(finding)
562564
# Persist any accumulated location/endpoint status changes
563565
self.location_handler.persist()
566+
self.flush_vulnerability_ids()
564567
# push finding groups to jira since we only only want to push whole groups
565568
# We dont check if the finding jira sync is applicable quite yet until we can get in the loop
566569
# but this is a way to at least make it that far
@@ -955,33 +958,34 @@ def reconcile_vulnerability_ids(
955958
) -> Finding:
956959
"""
957960
Reconcile vulnerability IDs for an existing finding.
958-
Checks if IDs have changed before updating to avoid unnecessary database operations.
959-
Uses prefetched data if available, otherwise fetches efficiently.
960-
961-
Args:
962-
finding: The existing finding to reconcile vulnerability IDs for.
963-
Must have unsaved_vulnerability_ids set.
964-
965-
Returns:
966-
The finding object
967-
961+
Accumulates changes into pending_vuln_id_deletes / pending_vulnerability_ids
962+
for batch flush at the batch boundary via flush_vulnerability_ids().
968963
"""
969-
vulnerability_ids_to_process = finding.unsaved_vulnerability_ids or []
964+
vulnerability_ids_to_process = list(dict.fromkeys(finding.unsaved_vulnerability_ids or []))
965+
vulnerability_ids_to_process = [x for x in vulnerability_ids_to_process if x.strip()]
970966

971967
# Use prefetched data directly without triggering queries
972968
existing_vuln_ids = {v.vulnerability_id for v in finding.vulnerability_id_set.all()}
973969
new_vuln_ids = set(vulnerability_ids_to_process)
974970

975-
# Early exit if unchanged
971+
# Early exit if unchanged — no DB work needed
976972
if existing_vuln_ids == new_vuln_ids:
977973
logger.debug(
978974
f"Skipping vulnerability_ids update for finding {finding.id} - "
979975
f"vulnerability_ids unchanged: {sorted(existing_vuln_ids)}",
980976
)
981977
return finding
982978

983-
# Update if changed
984-
finding_helper.save_vulnerability_ids(finding, vulnerability_ids_to_process, delete_existing=True)
979+
# Accumulate delete + insert for batch flush
980+
self.pending_vuln_id_deletes.append(finding.id)
981+
self.pending_vulnerability_ids.extend([
982+
Vulnerability_Id(finding=finding, vulnerability_id=vid)
983+
for vid in vulnerability_ids_to_process
984+
])
985+
if vulnerability_ids_to_process:
986+
finding.cve = vulnerability_ids_to_process[0]
987+
else:
988+
finding.cve = None
985989
return finding
986990

987991
def finding_post_processing(

unittests/test_finding_helper.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -220,8 +220,8 @@ class TestSaveVulnerabilityIds(DojoTestCase):
220220

221221
@patch("dojo.finding.helper.Vulnerability_Id.objects.filter")
222222
@patch("django.db.models.query.QuerySet.delete")
223-
@patch("dojo.finding.helper.Vulnerability_Id.save")
224-
def test_save_vulnerability_ids(self, save_mock, delete_mock, filter_mock):
223+
@patch("dojo.finding.helper.Vulnerability_Id.objects.bulk_create")
224+
def test_save_vulnerability_ids(self, bulk_create_mock, delete_mock, filter_mock):
225225
finding = Finding()
226226
new_vulnerability_ids = ["REF-1", "REF-2", "REF-2"]
227227
filter_mock.return_value = Vulnerability_Id.objects.none()
@@ -230,7 +230,10 @@ def test_save_vulnerability_ids(self, save_mock, delete_mock, filter_mock):
230230

231231
filter_mock.assert_called_with(finding=finding)
232232
delete_mock.assert_called_once()
233-
self.assertEqual(save_mock.call_count, 2)
233+
bulk_create_mock.assert_called_once()
234+
# Duplicates are removed: REF-1 and REF-2 only
235+
created_objects = bulk_create_mock.call_args[0][0]
236+
self.assertEqual(2, len(created_objects))
234237
self.assertEqual("REF-1", finding.cve)
235238

236239
@patch("dojo.models.Finding_Template.save")

unittests/test_importers_importer.py

Lines changed: 106 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -803,14 +803,15 @@ def create_default_data(self):
803803
}
804804

805805
def test_handle_vulnerability_ids_references_and_cve(self):
806-
# Why doesn't this test use the test db and query for one?
807806
vulnerability_ids = ["CVE", "REF-1", "REF-2"]
808807
finding = Finding()
809808
finding.unsaved_vulnerability_ids = vulnerability_ids
810809
finding.test = self.test
811810
finding.reporter = self.testuser
812811
finding.save()
813-
DefaultImporter(**self.importer_data).store_vulnerability_ids(finding)
812+
importer = DefaultImporter(**self.importer_data)
813+
importer.store_vulnerability_ids(finding)
814+
importer.flush_vulnerability_ids()
814815

815816
self.assertEqual("CVE", finding.vulnerability_ids[0])
816817
self.assertEqual("CVE", finding.cve)
@@ -827,7 +828,9 @@ def test_handle_no_vulnerability_ids_references_and_cve(self):
827828
finding.save()
828829
finding.unsaved_vulnerability_ids = vulnerability_ids
829830

830-
DefaultImporter(**self.importer_data).store_vulnerability_ids(finding)
831+
importer = DefaultImporter(**self.importer_data)
832+
importer.store_vulnerability_ids(finding)
833+
importer.flush_vulnerability_ids()
831834

832835
self.assertEqual("CVE", finding.vulnerability_ids[0])
833836
self.assertEqual("CVE", finding.cve)
@@ -841,7 +844,9 @@ def test_handle_vulnerability_ids_references_and_no_cve(self):
841844
finding.reporter = self.testuser
842845
finding.save()
843846
finding.unsaved_vulnerability_ids = vulnerability_ids
844-
DefaultImporter(**self.importer_data).store_vulnerability_ids(finding)
847+
importer = DefaultImporter(**self.importer_data)
848+
importer.store_vulnerability_ids(finding)
849+
importer.flush_vulnerability_ids()
845850

846851
self.assertEqual("REF-1", finding.vulnerability_ids[0])
847852
self.assertEqual("REF-1", finding.cve)
@@ -854,7 +859,9 @@ def test_no_handle_vulnerability_ids_references_and_no_cve(self):
854859
finding.test = self.test
855860
finding.reporter = self.testuser
856861
finding.save()
857-
DefaultImporter(**self.importer_data).store_vulnerability_ids(finding)
862+
importer = DefaultImporter(**self.importer_data)
863+
importer.store_vulnerability_ids(finding)
864+
importer.flush_vulnerability_ids()
858865
self.assertEqual(finding.cve, None)
859866
self.assertEqual(finding.unsaved_vulnerability_ids, None)
860867
self.assertEqual(finding.vulnerability_ids, [])
@@ -880,7 +887,9 @@ def test_clear_vulnerability_ids_on_empty_list(self):
880887

881888
# Process with empty list - should clear all IDs
882889
finding.unsaved_vulnerability_ids = []
883-
DefaultReImporter(test=self.test, environment=self.importer_data["environment"], scan_type=self.importer_data["scan_type"]).reconcile_vulnerability_ids(finding)
890+
reimporter = DefaultReImporter(test=self.test, environment=self.importer_data["environment"], scan_type=self.importer_data["scan_type"])
891+
reimporter.reconcile_vulnerability_ids(finding)
892+
reimporter.flush_vulnerability_ids()
884893
# Save the finding to persist the cve=None change
885894
finding.save()
886895

@@ -917,7 +926,9 @@ def test_change_vulnerability_ids_on_reimport(self):
917926
# Process with different IDs - should replace old IDs
918927
new_vulnerability_ids = ["CVE-2021-9999", "GHSA-xxxx-yyyy"]
919928
finding.unsaved_vulnerability_ids = new_vulnerability_ids
920-
DefaultReImporter(test=self.test, environment=self.importer_data["environment"], scan_type=self.importer_data["scan_type"]).reconcile_vulnerability_ids(finding)
929+
reimporter = DefaultReImporter(test=self.test, environment=self.importer_data["environment"], scan_type=self.importer_data["scan_type"])
930+
reimporter.reconcile_vulnerability_ids(finding)
931+
reimporter.flush_vulnerability_ids()
921932
# Save the finding to persist the cve change
922933
finding.save()
923934

@@ -932,6 +943,94 @@ def test_change_vulnerability_ids_on_reimport(self):
932943
# Verify only new Vulnerability_Id objects exist
933944
vuln_ids = list(Vulnerability_Id.objects.filter(finding=finding).values_list("vulnerability_id", flat=True))
934945
self.assertEqual(set(new_vulnerability_ids), set(vuln_ids))
946+
finding.delete()
947+
948+
def test_reconcile_vulnerability_ids_cross_finding_batch(self):
949+
"""Multiple findings accumulated before flush — one delete+insert pair per changed finding."""
950+
reimporter = DefaultReImporter(test=self.test, environment=self.importer_data["environment"], scan_type=self.importer_data["scan_type"])
951+
952+
# finding_a: IDs change (CVE-A → CVE-B)
953+
finding_a = Finding(test=self.test, reporter=self.testuser)
954+
finding_a.save()
955+
Vulnerability_Id.objects.create(finding=finding_a, vulnerability_id="CVE-A-OLD")
956+
finding_a.cve = "CVE-A-OLD"
957+
finding_a.save()
958+
959+
# finding_b: IDs change (CVE-B1, CVE-B2 → CVE-B-NEW)
960+
finding_b = Finding(test=self.test, reporter=self.testuser)
961+
finding_b.save()
962+
Vulnerability_Id.objects.create(finding=finding_b, vulnerability_id="CVE-B1")
963+
Vulnerability_Id.objects.create(finding=finding_b, vulnerability_id="CVE-B2")
964+
finding_b.cve = "CVE-B1"
965+
finding_b.save()
966+
967+
# finding_c: IDs unchanged — should not appear in delete/insert buffers
968+
finding_c = Finding(test=self.test, reporter=self.testuser)
969+
finding_c.save()
970+
Vulnerability_Id.objects.create(finding=finding_c, vulnerability_id="CVE-C-SAME")
971+
finding_c.cve = "CVE-C-SAME"
972+
finding_c.save()
973+
974+
finding_a.unsaved_vulnerability_ids = ["CVE-A-NEW"]
975+
finding_b.unsaved_vulnerability_ids = ["CVE-B-NEW"]
976+
finding_c.unsaved_vulnerability_ids = ["CVE-C-SAME"]
977+
978+
# Accumulate all three before any flush
979+
reimporter.reconcile_vulnerability_ids(finding_a)
980+
reimporter.reconcile_vulnerability_ids(finding_b)
981+
reimporter.reconcile_vulnerability_ids(finding_c)
982+
983+
# pending_vuln_id_deletes only contains changed findings, not finding_c
984+
self.assertIn(finding_a.id, reimporter.pending_vuln_id_deletes)
985+
self.assertIn(finding_b.id, reimporter.pending_vuln_id_deletes)
986+
self.assertNotIn(finding_c.id, reimporter.pending_vuln_id_deletes)
987+
self.assertEqual(2, len(reimporter.pending_vulnerability_ids))
988+
989+
# Old IDs still in DB (not yet deleted)
990+
self.assertEqual(1, Vulnerability_Id.objects.filter(finding=finding_a).count())
991+
self.assertEqual(2, Vulnerability_Id.objects.filter(finding=finding_b).count())
992+
993+
reimporter.flush_vulnerability_ids()
994+
995+
# Buffers cleared
996+
self.assertEqual([], reimporter.pending_vuln_id_deletes)
997+
self.assertEqual([], reimporter.pending_vulnerability_ids)
998+
999+
# finding_a: old deleted, new inserted
1000+
vuln_ids_a = list(Vulnerability_Id.objects.filter(finding=finding_a).values_list("vulnerability_id", flat=True))
1001+
self.assertEqual(["CVE-A-NEW"], vuln_ids_a)
1002+
self.assertEqual("CVE-A-NEW", finding_a.cve)
1003+
1004+
# finding_b: both old deleted, new inserted
1005+
vuln_ids_b = list(Vulnerability_Id.objects.filter(finding=finding_b).values_list("vulnerability_id", flat=True))
1006+
self.assertEqual(["CVE-B-NEW"], vuln_ids_b)
1007+
self.assertEqual("CVE-B-NEW", finding_b.cve)
1008+
1009+
# finding_c: unchanged — IDs untouched
1010+
vuln_ids_c = list(Vulnerability_Id.objects.filter(finding=finding_c).values_list("vulnerability_id", flat=True))
1011+
self.assertEqual(["CVE-C-SAME"], vuln_ids_c)
1012+
1013+
finding_a.delete()
1014+
finding_b.delete()
1015+
finding_c.delete()
1016+
1017+
def test_reconcile_vulnerability_ids_unchanged_no_db_write(self):
1018+
"""Early-exit path: unchanged IDs never touch pending buffers."""
1019+
reimporter = DefaultReImporter(test=self.test, environment=self.importer_data["environment"], scan_type=self.importer_data["scan_type"])
1020+
1021+
finding = Finding(test=self.test, reporter=self.testuser)
1022+
finding.save()
1023+
Vulnerability_Id.objects.create(finding=finding, vulnerability_id="CVE-2020-1234")
1024+
finding.cve = "CVE-2020-1234"
1025+
finding.save()
1026+
1027+
finding.unsaved_vulnerability_ids = ["CVE-2020-1234"]
1028+
reimporter.reconcile_vulnerability_ids(finding)
1029+
1030+
self.assertEqual([], reimporter.pending_vuln_id_deletes)
1031+
self.assertEqual([], reimporter.pending_vulnerability_ids)
1032+
1033+
finding.delete()
9351034

9361035

9371036
class ReimportDuplicateReactivationTest(DojoTestCase):

0 commit comments

Comments
 (0)