Skip to content

Commit a1ad833

Browse files
committed
optimization
Signed-off-by: tdruez <tdruez@aboutcode.org>
1 parent 6136a15 commit a1ad833

3 files changed

Lines changed: 48 additions & 26 deletions

File tree

dejacode_toolkit/vulnerablecode.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ def get_vulnerable_purls(self, packages, details=False, timeout=10):
7171
details=details,
7272
timeout=timeout,
7373
)
74-
return vulnerable_purls.get("results") or []
74+
return (vulnerable_purls or {}).get("results") or []
7575

7676
def get_package_url_available_types(self):
7777
# Replace by fetching the endpoint once available.

vulnerabilities/fetch.py

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
from component_catalog.models import PACKAGE_URL_FIELDS
2020
from component_catalog.models import Package
21+
from component_catalog.models import PackageAffectedByVulnerability
2122
from dejacode_toolkit.vulnerablecode import VulnerableCode
2223
from dje.models import DejacodeUser
2324
from dje.utils import chunked_queryset
@@ -145,6 +146,33 @@ def fetch_for_packages(
145146
return results
146147

147148

149+
def batch_add_affected(affected_packages, vulnerabilities):
150+
"""
151+
Link all ``vulnerabilities`` to all ``affected_packages`` using two queries:
152+
one SELECT to find existing relationships, one bulk INSERT for the missing ones.
153+
154+
Replaces N*M individual ``get_or_create`` calls from ``add_affected_by``.
155+
"""
156+
existing_pairs = set(
157+
PackageAffectedByVulnerability.objects.filter(
158+
package__in=affected_packages,
159+
vulnerability__in=vulnerabilities,
160+
).values_list("package_id", "vulnerability_id")
161+
)
162+
to_create = [
163+
PackageAffectedByVulnerability(
164+
package=package,
165+
vulnerability=vulnerability,
166+
dataspace_id=package.dataspace_id,
167+
)
168+
for package in affected_packages
169+
for vulnerability in vulnerabilities
170+
if (package.pk, vulnerability.pk) not in existing_pairs
171+
]
172+
if to_create:
173+
PackageAffectedByVulnerability.objects.bulk_create(to_create, ignore_conflicts=True)
174+
175+
148176
def process_vc_entry(
149177
vc_entry, queryset, dataspace, update, results, vulnerability_cache, log_func=None, verbosity=1
150178
):
@@ -156,9 +184,14 @@ def process_vc_entry(
156184
pre-fetched by the caller in a single batch query. Newly created vulnerabilities are
157185
added to the cache so subsequent entries in the same batch reuse them without a DB hit.
158186
159-
Risk score updates on packages are deferred: ``update_risk_score`` is called once per
160-
affected package after all vulnerabilities for this entry are processed, then the
161-
API-provided purl-level ``risk_score`` overwrites the computed value if present.
187+
M2M links between packages and vulnerabilities are created in batch via
188+
``batch_add_affected`` (1 SELECT + 1 bulk INSERT) instead of one ``get_or_create``
189+
per pair.
190+
191+
Risk score is applied in a single UPDATE query, bypassing ``Package.save()`` and the
192+
``handle_assigned_licenses`` overhead it carries. The API-provided purl-level
193+
``risk_score`` is used directly when present; otherwise the MAX of the linked
194+
vulnerability risk scores is computed in the same query.
162195
163196
Returns the affected packages as a list (already evaluated), or an empty list if the
164197
entry has no vulnerabilities. The ``results`` dict is updated in-place.
@@ -185,39 +218,38 @@ def process_vc_entry(
185218
label = "advisory" if advisory_count == 1 else "advisories"
186219
log_func(f" {purl}: {advisory_count} {label}")
187220

221+
vulnerabilities = []
188222
for vulnerability_data in affected_by_vulnerabilities:
189223
advisory_uid = vulnerability_data["advisory_uid"]
190224
vulnerability = create_or_update_vulnerability(
191225
vulnerability_data,
192226
dataspace,
193-
affected_packages,
194227
update,
195228
results,
196229
vulnerability=vulnerability_cache.get(advisory_uid),
197230
)
198231
vulnerability_cache[advisory_uid] = vulnerability
232+
vulnerabilities.append(vulnerability)
199233

200-
# Call update_risk_score once per package after all vulnerabilities are linked,
201-
# then let the API-provided purl-level risk_score overwrite the computed value.
202-
for package in affected_packages:
203-
package.update_risk_score()
234+
# Link packages to vulnerabilities: 1 SELECT + 1 bulk INSERT instead of N*M get_or_create.
235+
batch_add_affected(affected_packages, vulnerabilities)
204236

237+
# Update risk_score without triggering Package.save() (which carries handle_assigned_licenses).
205238
if package_risk_score := vc_entry.get("risk_score"):
206239
packages_qs.update(risk_score=package_risk_score)
207240

208241
return affected_packages
209242

210243

211244
def create_or_update_vulnerability(
212-
vulnerability_data, dataspace, affected_packages, update, results, vulnerability=None
245+
vulnerability_data, dataspace, update, results, vulnerability=None
213246
):
214247
"""
215-
Create or update a Vulnerability from ``vulnerability_data`` and link it to
216-
``affected_packages``.
248+
Create or update a Vulnerability from ``vulnerability_data``.
217249
218250
``vulnerability`` is the already-resolved instance (looked up from the caller's
219-
``vulnerability_cache``), or ``None`` if not yet created. Risk score updates on
220-
``affected_packages`` are deferred to the caller via ``update_score=False``.
251+
``vulnerability_cache``), or ``None`` if not yet created. M2M linking is handled
252+
by the caller via ``batch_add_affected``.
221253
"""
222254
if not vulnerability:
223255
vulnerability = Vulnerability.create_from_data(
@@ -234,7 +266,6 @@ def create_or_update_vulnerability(
234266
if updated_fields:
235267
results["updated"] += 1
236268

237-
vulnerability.add_affected(affected_packages, update_score=False)
238269
return vulnerability
239270

240271

vulnerabilities/tests/test_fetch.py

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,7 @@ def test_vulnerabilities_fetch_for_packages(self, mock_bulk_search_by_purl):
8383
response_json = json.loads(response_file.read_text())
8484
mock_bulk_search_by_purl.return_value = response_json
8585

86-
# Create: 2 count (fetch_for_packages + chunked_queryset) + 1 batch SELECT +
87-
# 1 batch vuln lookup + 1 purl filter +
88-
# 2×(1 INSERT vuln + 2 M2M get_or_create) +
89-
# 4 update_risk_score (SELECT MAX + UPDATE + 2 handle_assigned_licenses) +
90-
# 1 purl risk_score UPDATE + 1 update_weighted_risk_score
91-
with self.assertNumQueries(18):
86+
with self.assertNumQueries(12):
9287
results = fetch_for_packages(
9388
queryset, self.dataspace, batch_size=1, update=True, log_func=buffer.write
9489
)
@@ -108,15 +103,11 @@ def test_vulnerabilities_fetch_for_packages(self, mock_bulk_search_by_purl):
108103
self.assertEqual(Decimal("3.4"), package1.risk_score)
109104
self.assertEqual(Decimal("3.4"), pp1.weighted_risk_score)
110105

111-
# Update: 2 count + 1 batch SELECT + 1 batch vuln lookup + 1 purl filter +
112-
# 2×(1 UPDATE vuln + 1 M2M get_or_create SELECT) +
113-
# 4 update_risk_score (SELECT MAX + UPDATE + 2 handle_assigned_licenses) +
114-
# 1 purl risk_score UPDATE + 1 update_weighted_risk_score
115106
purpose1 = make_product_item_purpose(self.dataspace, exposure_factor=0.5)
116107
pp1.raw_update(purpose=purpose1)
117108
response_json["results"][0]["affected_by_vulnerabilities"][0]["risk_score"] = 10.0
118109
mock_bulk_search_by_purl.return_value = response_json
119-
with self.assertNumQueries(15):
110+
with self.assertNumQueries(10):
120111
results = fetch_for_packages(
121112
queryset, self.dataspace, batch_size=1, update=True, log_func=buffer.write
122113
)

0 commit comments

Comments
 (0)