Repository navigation
perf(findings): resolve tag filter names to tag ids so the planner sees how common a tag is - #16634
Merged
Merged
Conversation
tag_exists() matched the tag through a join on the tag table's name, so the planner could not tell which tag a query was about and assumed the average number of rows per tag. For a tag carried by most findings that estimate was off by three orders of magnitude (573 expected, 400,001 actual), and the finding list's COUNT and id queries took a nested-loop semi-join that probed every tagged row: 20.6 s for one COUNT over 400k tagged findings, 2.5 s once the subquery names the tag id. The matching tags are now read first (one indexed lookup on the tag table) and the EXISTS subquery tests the through table against those ids. A lookup that matches more than TAG_ID_LITERAL_LIMIT tags (a short icontains value) keeps the join. Results, counts and paging are unchanged: the existing equivalence tests against the old JOIN form pass as they are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
Author
Adversarial review: full tier, head 32e35b6Verdict: changes requested (R1 to R3). The rewrite is semantically identical to the join on every input I tried, and it fixes the misestimate on skewed tags. What remains is query count on Findings
What I verified
|
…ng for an unknown one tags__and (and its test__, test__engagement__ and test__engagement__product__ forms) looked each listed tag up on its own, one query per tag. The names are now resolved in one name__in read, and one EXISTS per tag id is built from it. A listed name that is no tag cannot be present, so the filter returns an empty queryset without building the subqueries. Tests: the over-limit fallback is checked on the rows it returns, not only its SQL, and an unknown tag is checked through the FilterSet for tags__and, test__engagement__tags__and and not_tags. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
Author
Review findings: resolution
|
blakeaowens
approved these changes
Oct 30, 2026
devGregA
approved these changes
Oct 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Tag-filtered
GET /api/v2/findings/requests stay slow on large instances after #16509. Performance reports show about 30 queries per request but 5 to 10 s of SQL, for exampletags=<tag>&test__engagement__product=<id>&active=true&limit=100&offset=400.#16509 replaced the DISTINCT join with an
EXISTSon the tags through table, but that subquery still joins the tag table and matches on itsname:The planner cannot tell which tag id that name resolves to, so it assumes the average number of rows per tag (rows ÷ distinct tags). A tag carried by most findings, such as a scanner or source tag, is the worst case. On a 400k-finding dataset where one tag is on every finding, a COUNT over the 160k findings matching
active+severity+ that tag:dojo_finding. 20,636 ms.U0.tagulous_finding_tags_id IN (1)): the planner sees the id's real frequency from the column statistics and picks a parallel hash join. 2,487 ms.The finding list runs that filter at least twice per page (the COUNT, then the page or id query), so the request pays it twice.
This change resolves the matching tags to their ids first, with one indexed lookup on the tag table, and the subquery tests the through table against those ids:
It covers every filter built on
tag_exists:tag,tags,tags__and,not_tag,not_tagsand thetest__,test__engagement__andtest__engagement__product__variants. A lookup that matches more thanTAG_ID_LITERAL_LIMIT(1000) tags, such as a one-lettertag=(icontains) value, keeps the join instead of writing thousands of literals into the query. An unknown tag resolves to no ids: the filter matches nothing and itsnot_form matches everything, as before.tags__and(and itstest__,test__engagement__andtest__engagement__product__forms) resolves all of its listed names in onename__inread and builds oneEXISTSper tag id. When a listed name is no tag, the AND cannot match, so it returns an empty queryset without building any subquery.Cost: one extra query per tag filter (the tag lookup, an index scan on the tag name), whatever the number of AND-ed tags.
Measurements
Whole requests through the API, 400k findings over 1,000 products, the target product holding 21k active findings with the tag (the tag is on all 400k findings), 5-run medians on a loaded development machine. Each row compares the old and new
tag_existson the same data and code. Response bodies (rows, order, counts) are byte-identical before and after for every row.tags=<tag>&test__engagement__product=<id>&active=true&limit=100&offset=400offset=1200tags__and=<two less common tags>&severity=Critical&limit=100The last row filters on tags that are each on a small share of findings, which the old join already planned well. It stays within run-to-run noise. In this measurement each AND-ed tag still had its own lookup (two more queries); they are now one. One product-Owner request at
offset=1200measured 972 ms before and 1,644 ms after in this run. Its COUNT, repeated six times in each form withEXPLAIN ANALYZE, had a median of about 500 ms before and about 310 ms after, so that request-level number is noise from the loaded machine.Test results
unittests/test_finding_tag_filters.py: the subquery names the tag id and never joins the tag table, forexact,inandicontains; an unknown tag matches nothing and its negation everything, directly and through the FilterSet (tags__and,test__engagement__tags__and,not_tags, each also compared to the old JOIN form); a lookup over the literal limit keeps the join and returns the same rows;tags__andwith three tags reads the tag table once. The id, literal-limit and single-read tests fail without this change.manage.py test unittests.test_finding_tag_filters, 25 tests, OK.ruff check(0.16.10,ruff.toml): clean.Documentation
No user-visible change: filter names, parameters and results are unchanged.
Checklist
dev.devbranch.