@@ -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
166211def 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
0 commit comments