You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
perf(nvd): replace per-call I/O with in-memory indexes for overrides and fix-date lookups
Two related hot-loop bottlenecks in the NVD provider, both caused by
per-item I/O inside a tight loop over ~250k CVEs.
Bottleneck 1: NVDOverrides.cve() — per-CVE file reads
cve() maintained a filepath index (CVE ID → path) but opened, read, and
JSON-parsed the file on every call. A TODO comment already flagged the
problem.
Fix: _build_data_by_cve() globs and parses all CVE-*.json files once
into a dict on first access. All subsequent cve() calls are O(1) dict
lookups with zero I/O. The duplicated lazy-init guard is extracted into
_ensure_loaded().
Bottleneck 2: GrypeDBStore.get() — per-CPE SQLite queries
get() executed an individual SELECT for every (vuln_id, cpe_or_package)
pair. Each CVE can have 5–20 CPE matches:
250,000 CVEs × 5–20 CPE matches ≈ 1.25M–5M SQLite queries per sync
At 0.1ms per query that is ~4 minutes of pure SQLite overhead.
Fix: _build_index() bulk-loads the entire fixdates table once into two
in-memory dicts keyed by (vuln_id, cpe) and (vuln_id, package,
ecosystem). get() becomes two dict lookups. The SQLAlchemy connection
infrastructure is retained — still required by get_changed_vuln_ids_since().
No provider filter is applied in _build_index(): each Store downloads
from a provider-scoped OCI image
(ghcr.io/anchore/grype-db-observed-fix-date/{provider}), so the
database only ever contains rows for this provider.
Signed-off-by: James Gardner <james.gardner@chainguard.dev>
**Root cause:**`cve()` maintained a filepath index (CVE ID → path on disk) but opened, read, and JSON-parsed the file on every single call. With ~250k CVEs in a full sync, that is ~250k `open()` + `json.loads()` calls — one per CVE lookup.
14
+
15
+
A `# TODO: implement in-memory index` comment already marked the problem in the original code.
16
+
17
+
**Fix:** Replace the filepath index with a fully parsed in-memory dict built once on first access. All subsequent `cve()` calls become O(1) dict lookups with zero I/O.
**Root cause:**`get()` executed an individual `SELECT` against the SQLite fix-date database for every `(vuln_id, cpe_or_package)` pair during NVD processing. Each CVE can have 5–20 CPE matches, and a full NVD sync processes ~250k CVEs, yielding:
At even 0.1 ms per query, 2.5M queries = ~4 minutes of pure SQLite overhead.
37
+
38
+
**Scale of the problem:** The fix-date database typically contains tens of thousands of rows (one per CVE/package combination where a fix date was observed). The entire table fits comfortably in memory.
39
+
40
+
---
41
+
42
+
## Fix: Bulk-Load Both into Memory at Startup
43
+
44
+
The fix for both bottlenecks is the same pattern: **load once, look up in O(1)**.
45
+
46
+
### NVDOverrides fix
47
+
48
+
`_build_data_by_cve()` globs all `CVE-*.json` files, reads and parses each once, and stores the result in `__data_by_cve__: dict[str, Any]`. The dict is populated lazily on first call and reused for all subsequent `cve()` calls.
49
+
50
+
### GrypeDBStore fix
51
+
52
+
`_build_index()` executes a single `SELECT * FROM fixdates` after the ORAS download completes, then splits the results into two in-memory dicts:
53
+
54
+
-`_cpe_index`: keyed by `(vuln_id.lower(), full_cpe.lower())`
55
+
-`_pkg_index`: keyed by `(vuln_id.lower(), package_name.lower(), ecosystem.lower())`
56
+
57
+
`get()` is replaced with dict lookups against these indexes. The index is built lazily on first `get()` call, ensuring it works correctly whether or not the download was a no-op (digest cache hit).
58
+
59
+
The SQLAlchemy connection infrastructure (`_get_connection`, `cleanup_thread_connections`) is retained — it is still required by `get_changed_vuln_ids_since()`, which queries the `runs` table separately.
0 commit comments