Fence the affected package lookup with a LATERAL join
What does this MR do and why?
Problem
PackageMetadata::AffectedPackage.for_occurrences joins pm_affected_packages to a VALUES list of up to 50 SBOM components, and its callers iterate the result with each_batch. Before reading anything, each_batch probes the relation with ORDER BY id LIMIT 1.
Depending on the sampled statistics of package_name, Postgres can plan that probe as a scan of the whole primary key with the 50 names as a join filter. On a full advisory dataset, this can exceed the 15-second statement_timeout, and the SBOM scan fails.
Fix
The scope joins through a fenced LATERAL subquery instead of an INNER JOIN on the CTE:
LATERALmakes one index seek per component the only way to evaluate the join, so the probe cannot become a primary key scan (LATERAL planner fence)OFFSET 0stops Postgres from flattening the subquery back into a plain join, which would reopen the scanPackageMetadata::MalwareAffectedPackage.for_occurrencesgets the same changeeach_batchis kept, so a batch still loads at most 1,000 rows
Why the plan flips
package_name has a long tail: a few names have tens of thousands of advisory rows, most have one. ANALYZE samples the column and underestimates its distinct values (on a 4M row dataset: 9,532 estimated, 55,521 actual), so the planner expects more rows per index seek than there are and prices the index plan too high. Once that estimate exceeds the cost of walking the primary key in id order and stopping at the first match, the probe becomes a scan of the whole table. The fence removes that scan from the planner's options instead of depending on the sample.
Why each_batch stays
A slice is bounded to 50 components, but not in rows: one package name can carry many advisory rows. On the full advisory dataset:
Advisory rows in pm_affected_packages |
|
|---|---|
| Package names with more than 1,000 advisory rows | 375 |
| Package names with more than 5,000 advisory rows | 119 |
linux (purl type deb) |
148,334 |
| 50 largest Debian package names together | 1,168,534 |
A slice that contains package names like these would load all of those rows in one query. each_batch keeps it at 1,000 rows per batch.
Database
Production clone (Database Lab): no regression
each_batch runs two queries per batch:
- A probe for the next
id(ORDER BY id LIMIT 1) - The batch itself (
WHERE id >= X).
Both are built from for_occurrences, so both queries changed and both are listed below, before and after.
On GitLab.com the bug does not occur: the planner already picks the index plan for the probe on master, and the two shapes cost the same (863.85 and 865.80). The production clone therefore cannot show the fix. What it shows is that there is no regression: same plan, same pages, same time for both queries. The fix itself is demonstrated on a GDK by forcing the planner (table further below).
Plans from the postgresai CLI on the gitlab-production-main clone, EXPLAIN ANALYZE with 50 Maven (purl_type, name) pairs. Each query ran twice in the same session; the numbers are from the second, warm run, so the times are comparable.
| Query | Master | This MR |
|---|---|---|
each_batch probe (ORDER BY id LIMIT 1) |
Index Scan on (purl_type, package_name), 222 buffers, 1.6 ms |
same plan, 222 buffers, 1.6 ms |
Batch (WHERE id >= X) |
Index Scan on (purl_type, package_name), 241 buffers, 2.0 ms |
same plan, 219 buffers, 1.8 ms |
The second each_batch probe (WHERE id >= X ORDER BY id ASC LIMIT 1 OFFSET 1000) has the same shape as the first and is not listed separately.
Regression specs on both callers record the emitted queries and assert that every occurrences_cte query with ORDER BY ... LIMIT goes through the fenced LATERAL subquery.
Probe, master (first, cold run: 80 of its 222 buffers came from disk)
WITH occurrences_cte(purl_type, name) AS (VALUES (5, '...'), ... 50 pairs ...)
SELECT DISTINCT "pm_affected_packages"."id"
FROM "pm_affected_packages"
INNER JOIN occurrences_cte ON occurrences_cte.purl_type = "pm_affected_packages".purl_type
AND occurrences_cte.name = "pm_affected_packages".package_name
ORDER BY "pm_affected_packages"."id" ASC LIMIT 1 Limit (cost=863.85..863.85 rows=1 width=8) (actual time=86.702..86.704 rows=1 loops=1)
Buffers: shared hit=142 read=80 dirtied=37
-> Unique (cost=863.85..864.71 rows=173 width=8) (actual time=86.700..86.701 rows=1 loops=1)
Buffers: shared hit=142 read=80 dirtied=37
-> Sort (cost=863.85..864.28 rows=173 width=8) (actual time=86.698..86.699 rows=1 loops=1)
Sort Key: pm_affected_packages.id
Sort Method: quicksort Memory: 25kB
Buffers: shared hit=142 read=80 dirtied=37
-> Nested Loop (cost=0.43..857.42 rows=173 width=8) (actual time=5.146..86.661 rows=69 loops=1)
Buffers: shared hit=139 read=80 dirtied=37
-> Values Scan on "*VALUES*" (cost=0.00..0.62 rows=50 width=36) (actual time=0.001..0.054 rows=50 loops=1)
-> Index Scan using index_pm_affected_packages_on_purl_type_and_package_name on public.pm_affected_packages (cost=0.43..17.03 rows=11 width=25) (actual time=0.793..1.731 rows=1 loops=50)
Index Cond: ((pm_affected_packages.purl_type = "*VALUES*".column1) AND (pm_affected_packages.package_name = "*VALUES*".column2))
Buffers: shared hit=139 read=80 dirtied=37Probe, this MR
WITH occurrences_cte(purl_type, name) AS (VALUES (5, '...'), ... 50 pairs ...)
SELECT DISTINCT "pm_affected_packages"."id"
FROM occurrences_cte,
LATERAL (SELECT * FROM "pm_affected_packages"
WHERE "pm_affected_packages".purl_type = occurrences_cte.purl_type
AND "pm_affected_packages".package_name = occurrences_cte.name
OFFSET 0) "pm_affected_packages"
ORDER BY "pm_affected_packages"."id" ASC LIMIT 1 Limit (cost=865.80..865.80 rows=1 width=8) (actual time=0.398..0.399 rows=1 loops=1)
Buffers: shared hit=222
-> Sort (cost=865.80..865.82 rows=11 width=8) (actual time=0.397..0.398 rows=1 loops=1)
Sort Key: pm_affected_packages.id
Sort Method: top-N heapsort Memory: 25kB
Buffers: shared hit=222
-> HashAggregate (cost=865.63..865.74 rows=11 width=8) (actual time=0.371..0.378 rows=69 loops=1)
Group Key: pm_affected_packages.id
Batches: 1 Memory Usage: 32kB
Buffers: shared hit=219
-> Nested Loop (cost=0.43..864.26 rows=550 width=8) (actual time=0.028..0.348 rows=69 loops=1)
Buffers: shared hit=219
-> Values Scan on "*VALUES*" (cost=0.00..0.62 rows=50 width=36) (actual time=0.001..0.011 rows=50 loops=1)
-> Index Scan using index_pm_affected_packages_on_purl_type_and_package_name on public.pm_affected_packages (cost=0.43..17.05 rows=11 width=258) (actual time=0.005..0.006 rows=1 loops=50)
Index Cond: ((pm_affected_packages.purl_type = "*VALUES*".column1) AND (pm_affected_packages.package_name = "*VALUES*".column2))
Buffers: shared hit=219Batch, master
WITH occurrences_cte(purl_type, name) AS (VALUES (5, '...'), ... 50 pairs ...)
SELECT DISTINCT "pm_affected_packages".*
FROM "pm_affected_packages"
INNER JOIN occurrences_cte ON occurrences_cte.purl_type = "pm_affected_packages".purl_type
AND occurrences_cte.name = "pm_affected_packages".package_name
WHERE "pm_affected_packages"."id" >= 413700 Unique (cost=865.18..870.77 rows=172 width=127) (actual time=0.406..0.434 rows=34 loops=1)
Buffers: shared hit=241
-> Sort (cost=865.18..865.61 rows=172 width=127) (actual time=0.405..0.408 rows=34 loops=1)
Sort Key: pm_affected_packages.id, pm_affected_packages.pm_advisory_id, pm_affected_packages.created_at, pm_affected_packages.updated_at, pm_affected_packages.purl_type, pm_affected_packages.package_name, pm_affected_packages.distro_version, pm_affected_packages.solution, pm_affected_packages.affected_range, pm_affected_packages.fixed_versions, pm_affected_packages.overridden_advisory_fields, pm_affected_packages.versions
Sort Method: quicksort Memory: 31kB
Buffers: shared hit=241
-> Nested Loop (cost=0.43..858.79 rows=172 width=127) (actual time=0.026..0.351 rows=34 loops=1)
Buffers: shared hit=219
-> Values Scan on "*VALUES*" (cost=0.00..0.62 rows=50 width=36) (actual time=0.001..0.023 rows=50 loops=1)
-> Index Scan using index_pm_affected_packages_on_purl_type_and_package_name on public.pm_affected_packages (cost=0.43..17.05 rows=11 width=127) (actual time=0.005..0.006 rows=1 loops=50)
Index Cond: ((pm_affected_packages.purl_type = "*VALUES*".column1) AND (pm_affected_packages.package_name = "*VALUES*".column2))
Filter: (pm_affected_packages.id >= 413700)
Rows Removed by Filter: 1
Buffers: shared hit=219Batch, this MR
WITH occurrences_cte(purl_type, name) AS (VALUES (5, '...'), ... 50 pairs ...)
SELECT DISTINCT "pm_affected_packages".*
FROM occurrences_cte,
LATERAL (SELECT * FROM "pm_affected_packages"
WHERE "pm_affected_packages".purl_type = occurrences_cte.purl_type
AND "pm_affected_packages".package_name = occurrences_cte.name
OFFSET 0) "pm_affected_packages"
WHERE "pm_affected_packages"."id" >= 413700 HashAggregate (cost=882.13..882.24 rows=11 width=127) (actual time=0.393..0.400 rows=34 loops=1)
Group Key: pm_affected_packages.id, pm_affected_packages.pm_advisory_id, pm_affected_packages.created_at, pm_affected_packages.updated_at, pm_affected_packages.purl_type, pm_affected_packages.package_name, pm_affected_packages.distro_version, pm_affected_packages.solution, pm_affected_packages.affected_range, pm_affected_packages.fixed_versions, pm_affected_packages.overridden_advisory_fields, pm_affected_packages.versions
Batches: 1 Memory Usage: 40kB
Buffers: shared hit=219
-> Nested Loop (cost=0.43..865.63 rows=550 width=127) (actual time=0.026..0.356 rows=34 loops=1)
Buffers: shared hit=219
-> Values Scan on "*VALUES*" (cost=0.00..0.62 rows=50 width=36) (actual time=0.001..0.013 rows=50 loops=1)
-> Subquery Scan on pm_affected_packages (cost=0.43..17.19 rows=11 width=127) (actual time=0.005..0.007 rows=1 loops=50)
Filter: (pm_affected_packages.id >= 413700)
Rows Removed by Filter: 1
Buffers: shared hit=219
-> Index Scan using index_pm_affected_packages_on_purl_type_and_package_name on public.pm_affected_packages pm_affected_packages_1 (cost=0.43..17.05 rows=11 width=127) (actual time=0.005..0.006 rows=1 loops=50)
Index Cond: ((pm_affected_packages_1.purl_type = "*VALUES*".column1) AND (pm_affected_packages_1.package_name = "*VALUES*".column2))
Buffers: shared hit=219GDK with the full advisory dataset (4.0M rows): the fix
Setup: pm_affected_packages synced from the advisory export (4,003,326 rows, 55,521 distinct package_name values, n_distinct sampled as 9,532), Postgres 17.8, GDK defaults. On those defaults the planner picks the index plan at any list size, so the primary key plan is forced for the "before" runs with:
SET enable_sort = off; SET enable_hashagg = off; SET random_page_cost = 4;This takes the sort step of the index plan away from the planner and stands in for an instance whose statistics make the primary key plan look cheaper.
Probe, forced, 50 Maven pairs
| Master | This MR | |
|---|---|---|
| Plan | Parallel Index Scan using pm_affected_packages_pkey, VALUES as join filter |
Index Scan using index_pm_affected_packages_on_purl_type_and_package_name |
| Rows visited | 4,008,425 (the whole table) | 68 |
| Buffers | 275,012 | 218 |
| Execution time | 12,060 ms | 0.5 ms |
With the fence the planner has no other way to evaluate the join, so it keeps the index seeks even though the sort it needs for ORDER BY id LIMIT 1 is disabled (the 10000000000 in the cost is the disabled-node penalty).
Probe, master, forced
Limit (cost=1000.45..8320.63 rows=1 width=8) (actual time=12050.418..12059.781 rows=1 loops=1)
Buffers: shared hit=201153 read=73859
-> Unique (cost=1000.45..2563060.71 rows=350 width=8) (actual time=12050.417..12059.780 rows=1 loops=1)
Buffers: shared hit=201153 read=73859
-> Gather Merge (cost=1000.45..2563059.84 rows=350 width=8) (actual time=12050.417..12059.779 rows=1 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared hit=201153 read=73859
-> Unique (cost=0.43..2562019.42 rows=175 width=8) (actual time=44.385..8043.581 rows=22 loops=3)
Buffers: shared hit=201153 read=73859
-> Nested Loop (cost=0.43..2562018.98 rows=175 width=8) (actual time=44.385..8043.573 rows=22 loops=3)
Join Filter: ((pm_affected_packages.purl_type = "*VALUES*".column1) AND (pm_affected_packages.package_name = "*VALUES*".column2))
Rows Removed by Join Filter: 66807056
Buffers: shared hit=201153 read=73859
-> Parallel Index Scan using pm_affected_packages_pkey on pm_affected_packages (cost=0.43..264905.48 rows=1670628 width=25) (actual time=0.654..1428.428 rows=1336142 loops=3)
Buffers: shared hit=201153 read=73859
-> Values Scan on "*VALUES*" (cost=0.00..0.62 rows=50 width=36) (actual time=0.000..0.004 rows=50 loops=4008425)
Planning Time: 3.935 ms
Execution Time: 12059.862 msProbe, this MR, forced
Limit (cost=10000003378.75..10000003379.00 rows=1 width=8) (actual time=0.276..0.276 rows=1 loops=1)
Buffers: shared hit=218
-> Unique (cost=10000003378.75..10000003386.25 rows=30 width=8) (actual time=0.276..0.276 rows=1 loops=1)
Buffers: shared hit=218
-> Sort (cost=10000003378.75..10000003382.50 rows=1500 width=8) (actual time=0.275..0.275 rows=1 loops=1)
Sort Key: pm_affected_packages.id
Sort Method: quicksort Memory: 25kB
Buffers: shared hit=218
-> Nested Loop (cost=0.43..3299.62 rows=1500 width=8) (actual time=0.034..0.270 rows=68 loops=1)
Buffers: shared hit=218
-> Values Scan on "*VALUES*" (cost=0.00..0.62 rows=50 width=36) (actual time=0.000..0.005 rows=50 loops=1)
-> Index Scan using index_pm_affected_packages_on_purl_type_and_package_name on pm_affected_packages (cost=0.43..65.38 rows=30 width=258) (actual time=0.004..0.005 rows=1 loops=50)
Index Cond: ((purl_type = "*VALUES*".column1) AND (package_name = "*VALUES*".column2))
Buffers: shared hit=218
Planning Time: 0.092 ms
Execution Time: 0.501 msEnd to end: a 72 component Maven SBOM through SecurityReportBuilder
Run with bin/rails runner against the same database, once with the GDK defaults ("natural") and once with the settings above applied on the connection ("forced"). Queries were recorded with an sql.active_record subscriber.
| Master | This MR | |
|---|---|---|
| Natural plan, total | 185 ms | 144 ms |
| Forced primary key plan, total | 22,572 ms | 26 ms |
| Forced, slowest queries | 7,583 / 7,569 / 5,097 / 2,284 ms (the each_batch probes) |
3.5 ms |
| Findings | 2 | 2 |
Heavy slice through each_batch
A slice with linux, openssl and glibc (purl type deb), 152,705 matching rows, iterated with for_occurrences(...).with_advisory.each_batch. Single runs, GDK defaults.
| Master | This MR | |
|---|---|---|
| Batches | 153 | 153 |
| Largest batch | 1,000 rows | 1,000 rows |
| Total time | 13.8 s | 16.5 s |
Batching is unchanged by the fence. The extra time comes from the id >= X filter being applied outside the fenced subquery, so each batch re-reads the matched rows before filtering; it is per heavy name and bounded, unlike the full table scan.
References
Screenshots or screen recordings
Not applicable.
How to set up and validate locally
-
Load the advisory dataset into
pm_affected_packages: withPM_SYNC_IN_DEV=true, runbin/rails runner 'PackageMetadata::AdvisoriesSyncWorker.new.perform'repeatedly until the row count stops growing (each run is capped at a few minutes). -
In
gdk psql, take the index plan away from the planner and run the "Probe, master" query with 50(purl_type, name)pairs that exist in the table:SET enable_sort = off; SET enable_hashagg = off; SET random_page_cost = 4; EXPLAIN WITH occurrences_cte(purl_type, name) AS (VALUES ...) SELECT DISTINCT ...;The plan is a
Parallel Index Scan using pm_affected_packages_pkeyunder aNested Loopwith theVALUESlist as a join filter. -
Run the "Probe, this MR" query with the same settings. The plan is a
Nested Loopoverindex_pm_affected_packages_on_purl_type_and_package_name.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.