Skip to content

Commit 6136a15

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

2 files changed

Lines changed: 82 additions & 22 deletions

File tree

vulnerabilities/fetch.py

Lines changed: 65 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,29 @@ def fetch_for_packages(
100100
f" API call: {humanize_time(api_elapsed)} ({len(vc_entries)} vulnerable purls)"
101101
)
102102

103+
# One SELECT for all advisory_uids in this batch instead of one per vulnerability.
104+
all_advisory_uids = [
105+
vulnerability_data["advisory_uid"]
106+
for vc_entry in vc_entries
107+
for vulnerability_data in (vc_entry.get("affected_by_vulnerabilities") or [])
108+
]
109+
vulnerability_cache = {
110+
vulnerability.advisory_uid: vulnerability
111+
for vulnerability in Vulnerability.objects.scope(dataspace).filter(
112+
advisory_uid__in=all_advisory_uids
113+
)
114+
}
115+
103116
for vc_entry in vc_entries:
104117
affected_packages = process_vc_entry(
105-
vc_entry, queryset, dataspace, update, batch_results, log_func, verbosity
118+
vc_entry,
119+
queryset,
120+
dataspace,
121+
update,
122+
batch_results,
123+
vulnerability_cache,
124+
log_func,
125+
verbosity,
106126
)
107127
batch_affected_packages.extend(affected_packages)
108128

@@ -125,50 +145,80 @@ def fetch_for_packages(
125145
return results
126146

127147

128-
def process_vc_entry(vc_entry, queryset, dataspace, update, results, log_func=None, verbosity=1):
148+
def process_vc_entry(
149+
vc_entry, queryset, dataspace, update, results, vulnerability_cache, log_func=None, verbosity=1
150+
):
129151
"""
130152
Process a single VulnerableCode purl entry: find the matching packages in ``queryset``,
131153
create or update each linked vulnerability, and apply the API-provided risk score.
132154
133-
Returns the queryset of affected packages, or an empty list if the entry has no
134-
vulnerabilities. The ``results`` dict is updated in-place.
155+
``vulnerability_cache`` is a dict mapping advisory_uid to Vulnerability instances,
156+
pre-fetched by the caller in a single batch query. Newly created vulnerabilities are
157+
added to the cache so subsequent entries in the same batch reuse them without a DB hit.
158+
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.
162+
163+
Returns the affected packages as a list (already evaluated), or an empty list if the
164+
entry has no vulnerabilities. The ``results`` dict is updated in-place.
135165
"""
136166
affected_by_vulnerabilities = vc_entry.get("affected_by_vulnerabilities")
137167
if not affected_by_vulnerabilities:
138168
return []
139169

140170
purl = PackageURL.from_string(vc_entry.get("purl"))
141-
affected_packages = queryset.filter(
171+
# Evaluate to a list immediately: packages_qs.update() below clears the QS cache,
172+
# which would cause a re-SELECT when the caller iterates the return value.
173+
packages_qs = queryset.filter(
142174
type=purl.type,
143175
namespace=purl.namespace or "",
144176
name=purl.name,
145177
version=purl.version,
146178
)
179+
affected_packages = list(packages_qs)
147180
if not affected_packages:
148181
raise CommandError("Could not find packages!")
149182

150183
if log_func and verbosity >= 2:
151-
vuln_count = len(affected_by_vulnerabilities)
152-
label = "advisory" if vuln_count == 1 else "advisories"
153-
log_func(f" {purl}: {vuln_count} {label}")
184+
advisory_count = len(affected_by_vulnerabilities)
185+
label = "advisory" if advisory_count == 1 else "advisories"
186+
log_func(f" {purl}: {advisory_count} {label}")
154187

155188
for vulnerability_data in affected_by_vulnerabilities:
156-
create_or_update_vulnerability(
157-
vulnerability_data, dataspace, affected_packages, update, results
189+
advisory_uid = vulnerability_data["advisory_uid"]
190+
vulnerability = create_or_update_vulnerability(
191+
vulnerability_data,
192+
dataspace,
193+
affected_packages,
194+
update,
195+
results,
196+
vulnerability=vulnerability_cache.get(advisory_uid),
158197
)
198+
vulnerability_cache[advisory_uid] = vulnerability
199+
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()
159204

160205
if package_risk_score := vc_entry.get("risk_score"):
161-
affected_packages.update(risk_score=package_risk_score)
206+
packages_qs.update(risk_score=package_risk_score)
162207

163208
return affected_packages
164209

165210

166211
def create_or_update_vulnerability(
167-
vulnerability_data, dataspace, affected_packages, update, results
212+
vulnerability_data, dataspace, affected_packages, update, results, vulnerability=None
168213
):
169-
advisory_uid = vulnerability_data["advisory_uid"]
170-
vulnerability = Vulnerability.objects.scope(dataspace).get_or_none(advisory_uid=advisory_uid)
214+
"""
215+
Create or update a Vulnerability from ``vulnerability_data`` and link it to
216+
``affected_packages``.
171217
218+
``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``.
221+
"""
172222
if not vulnerability:
173223
vulnerability = Vulnerability.create_from_data(
174224
dataspace=dataspace,
@@ -184,7 +234,7 @@ def create_or_update_vulnerability(
184234
if updated_fields:
185235
results["updated"] += 1
186236

187-
vulnerability.add_affected(affected_packages, update_score=True)
237+
vulnerability.add_affected(affected_packages, update_score=False)
188238
return vulnerability
189239

190240

vulnerabilities/tests/test_fetch.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,15 @@ 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-
results = fetch_for_packages(
87-
queryset, self.dataspace, batch_size=1, update=True, log_func=buffer.write
88-
)
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):
92+
results = fetch_for_packages(
93+
queryset, self.dataspace, batch_size=1, update=True, log_func=buffer.write
94+
)
8995
self.assertEqual(results, {"created": 2, "updated": 0})
9096

9197
self.assertEqual("Progress: 1/1", buffer.getvalue())
@@ -102,14 +108,18 @@ def test_vulnerabilities_fetch_for_packages(self, mock_bulk_search_by_purl):
102108
self.assertEqual(Decimal("3.4"), package1.risk_score)
103109
self.assertEqual(Decimal("3.4"), pp1.weighted_risk_score)
104110

105-
# Update
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
106115
purpose1 = make_product_item_purpose(self.dataspace, exposure_factor=0.5)
107116
pp1.raw_update(purpose=purpose1)
108117
response_json["results"][0]["affected_by_vulnerabilities"][0]["risk_score"] = 10.0
109118
mock_bulk_search_by_purl.return_value = response_json
110-
results = fetch_for_packages(
111-
queryset, self.dataspace, batch_size=1, update=True, log_func=buffer.write
112-
)
119+
with self.assertNumQueries(15):
120+
results = fetch_for_packages(
121+
queryset, self.dataspace, batch_size=1, update=True, log_func=buffer.write
122+
)
113123
self.assertEqual(results, {"created": 0, "updated": 2})
114124
vulnerability = package1.affected_by_vulnerabilities.filter(
115125
advisory_uid="pypa/idna/PYSEC-2024-60"

0 commit comments

Comments
 (0)