Skip to content

Optimize q filter to use pk__in subqueries instead of set operations#7848

Draft
acheng-01 wants to merge 2 commits into
pulp:mainfrom
acheng-01:ac/subquery-q-express-filter
Draft

Optimize q filter to use pk__in subqueries instead of set operations#7848
acheng-01 wants to merge 2 commits into
pulp:mainfrom
acheng-01:ac/subquery-q-express-filter

Conversation

@acheng-01

@acheng-01 acheng-01 commented Jul 7, 2026

Copy link
Copy Markdown

The q complex filter composed its NOT/AND/OR operands with QuerySet .difference()/.intersection()/.union(), which compile to SQL EXCEPT/INTERSECT/UNION. For NOT in particular, the left operand is the full unfiltered queryset, so evaluating an expression like repository_version=X AND NOT repository_version=Y seq-scans the entire content table and sorts/hashes every leg, spilling to disk on large repositories.

Rewrite the three operand evaluators to compose with pk__in subqueries:

  • _NotAction: qs.exclude(pk__in=expr.evaluate(qs).values("pk"))
  • _AndAction: chained qs.filter(pk__in=...) semi-joins
  • _OrAction: OR of Q(pk__in=...) subqueries

This collapses the plan to indexed anti-/semi-joins with no full-table scan, no SetOp, and no disk-spilling sorts. Results are unchanged (each operand still filters the same base queryset on a unique pk, so the implicit de-duplication of the set operations is preserved); the existing q filter functional tests pass without modification.

Benchmarked on a 200k-unit repository computing a 1000-unit diff between two repository versions (steady-state, work_mem=4MB):

Version sizes Before (set-ops) After (pk__in)
199k units ~1400 ms ~680 ms
50k units ~450 ms ~150 ms

Planning time also drops (e.g. ~1.4 ms -> ~0.5 ms) as the large inlined operand lists are replaced by parameterized subqueries.

Closes #7831

Assisted by: Claude Opus 4.8

📜 Checklist

  • Commits are cleanly separated with meaningful messages (simple features and bug fixes should be squashed to one commit)
  • A changelog entry or entries has been added for any significant changes
  • Follows the Pulp policy on AI Usage
  • (For new features) - User documentation and test coverage has been added

See: Pull Request Walkthrough

The `q` complex filter composed its NOT/AND/OR operands with QuerySet
.difference()/.intersection()/.union(), which compile to SQL
EXCEPT/INTERSECT/UNION. For NOT in particular, the left operand is the
full unfiltered queryset, so evaluating an expression like
`repository_version=X AND NOT repository_version=Y` seq-scans the entire
content table and sorts/hashes every leg, spilling to disk on large
repositories.

Rewrite the three operand evaluators to compose with `pk__in` subqueries:

  - _NotAction:  qs.exclude(pk__in=expr.evaluate(qs).values("pk"))
  - _AndAction:  chained qs.filter(pk__in=...) semi-joins
  - _OrAction:   OR of Q(pk__in=...) subqueries

This collapses the plan to indexed anti-/semi-joins with no full-table
scan, no SetOp, and no disk-spilling sorts. Results are unchanged (each
operand still filters the same base queryset on a unique pk, so the
implicit de-duplication of the set operations is preserved); the
existing q filter functional tests pass without modification.

Benchmarked on a 200k-unit repository computing a 1000-unit diff
between two repository versions (steady-state, work_mem=4MB):

  Version sizes    Before (set-ops)    After (pk__in)
  ---------------  ------------------  ----------------
  199k units       ~1400 ms            ~680 ms
   50k units       ~450 ms             ~150 ms

Planning time also drops (e.g. ~1.4 ms -> ~0.5 ms) as the large inlined
operand lists are replaced by parameterized subqueries.

Closes pulp#7831

Assisted by: Claude Opus 4.8
@acheng-01 acheng-01 force-pushed the ac/subquery-q-express-filter branch from 5d1d31a to f6efa56 Compare July 7, 2026 19:48
@acheng-01 acheng-01 mentioned this pull request Jul 7, 2026
4 tasks
Comment thread pulpcore/filters.py
class _OrAction:
def __init__(self, tokens):
self.exprs = tokens[0]
self.complexity = sum((expr.complexity for expr in self.exprs)) + 1

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI, the complexity is meant to prevent easy dos attacks by generating arbitrarily complex queries. I guess you can already get quite far with the current setting.

@mdellweg mdellweg left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would be willing to go this way.
It sounds like it can be a performance boost for all uses of q.

Do I understand correctly that your concern is manly how the performance is impacted by deeply nesting constructs?

Comment thread CHANGES/7831.bugfix Outdated
@@ -0,0 +1,3 @@
Optimized the ``q`` complex filter to compose ``NOT``/``AND``/``OR`` operands using ``pk__in``
subqueries instead of the ``EXCEPT``/``INTERSECT``/``UNION`` set operations, avoiding full-table
scans and disk-spilling sorts on large content sets. No newline at end of file

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is supposed to be markdown, not ReST.
Single backticks work better, as far as I recall.

@acheng-01

Copy link
Copy Markdown
Author

I would be willing to go this way. It sounds like it can be a performance boost for all uses of q.

Do I understand correctly that your concern is manly how the performance is impacted by deeply nesting constructs?

Kind of. The benchmark copilot ran yesterday were done from more theoretical flattened SQL queries that q= can not actually achieve and thus produced misleading improvement figures. Because each sub-filter now returns qs.filter(...) and the operators compose by wrapping, every operand expands into several stacked core_content self-joins. For example, repository_version=X turns into:

content WHERE pk IN (SELECT pk FROM content WHERE pk IN (
    SELECT pk FROM content WHERE pk IN (SELECT unnest(content_ids) FROM version)))

Switching from set-ops to pk__in subqueries removes the EXCEPT / INTERSECT disk-spill sorts, which is an improvement for all of q usage, but it trades in one more wrapping layer, so the net gain through the actual filter is more modest than a flattened query (going the base_version filter route) would suggest (~15% rather than ~100%). I just want to be upfront that it optimizes the shape rather than eliminating the underlying per-operand nesting.

Builds on the prior pk__in subquery rewrite. That change removed the
set-operations, but still wrapped every operand of a NOT/AND/OR in its
own pk__in subquery, so a q expression compiled to one subquery per
operand and the SQL grew a nesting level for every AND/OR/NOT -- up to 8
stacked subqueries at the complexity cap. This is the "deep nesting"
cost the reviewer asked about.

Introduce a hybrid Q-composition. Each operand can now report an
`as_q()`:

  - A leaf is Q-reducible only when it is a standard django-filter
    Filter (no custom .filter() override, no method=, no distinct=). It
    returns a plain `Q(field__lookup=value)` (negated for exclude
    filters). Opaque operands -- label filters, repository_version,
    href/prn/id resolvers, method filters, and ChoiceFilter (which
    overrides .filter() for null handling) -- return None.
  - NOT/AND/OR combine their children's Q objects with ~, &, | when all
    children are reducible.

`evaluate()` then folds every reducible operand into a single flat Q and
applies only the genuinely opaque operands as pk__in subqueries. An
all-reducible expression -- however deeply nested -- collapses to one
flat SELECT; mixed expressions keep one subquery per opaque operand.
Opaque operands are always evaluated against the small base queryset, so
they never re-embed an accumulated filter chain.

Results are unchanged and the existing q filter functional tests
(test_q_filter_valid / test_q_filter_invalid, 21 cases) pass without
modification.

Benchmarks (100k rows, steady-state, count(), best of 4), old set-ops
vs. this change; SELECTs = number of SQL SELECT statements emitted:

  Repositories (all-reducible name/number fields)
  Expression                          set-ops         this change
  ----------------------------------  --------------  --------------
  name AND name                        42 ms /  3      15 ms / 1
  (A AND B) OR (C AND D)               79 ms /  7      20 ms / 1
  (A AND (B OR C)) AND NOT D  (cx 8)  144 ms /  8      19 ms / 1
  name AND retain>=x AND retain<y      44 ms /  4      17 ms / 1
  (num range) OR num=0                124 ms /  5      15 ms / 1

  Tasks
  name AND NOT name                    68 ms /  4      17 ms / 1
  name AND (NOT state OR state)       119 ms /  6      90 ms / 5

  ContentGuards (name)
  A AND B                              56 ms /  3      19 ms / 1
  (A AND B) OR C                      155 ms /  5      20 ms / 1

Opaque-only expressions are unchanged: content repository_version
`X AND NOT Y` over 200k units stays ~2.45 s (199k pair) with no
regression, since both operands remain pk__in subqueries.

The speedup grows with nesting depth because the old plan added a
subquery per level while this one stays flat -- ~1.6x on a shallow OR up
to ~8x at the complexity-8 limit. The remaining opaque cases (e.g. task
`state`, a ChoiceFilter) could be reduced too in a follow-up.

Refs pulp#7831

Assisted by: Claude Opus 4.8
@acheng-01

acheng-01 commented Jul 8, 2026

Copy link
Copy Markdown
Author

Following up on my own comment — I pushed a commit that I think actually addresses the performance issue.

Before: each operand of an AND / OR / NOT got its own pk__in subquery, so nesting just stacked up more layers of SQL.
Now: each operand can report an as_q() — if it's a plain field filter (name, dates, numbers, __in, etc.) it returns a Q object, and the operators just combine those with & / | / ~. A whole expression of standard fields therefore folds into a single flat SQL WHERE, no matter how deep it's nested.
• Operands that genuinely can't be reduced — labels, repository_version, href/prn/id lookups — return no Q and fall back to a pk__in subquery like before.
• Since nearly every filter on every endpoint is a plain field, this helps the common case across the board.
repository_version on its own is unchanged (~2.4s on 200k units) since it stays a subquery, it just doesn't get the flattening benefit.

The existing q filter tests all pass without changes.

@acheng-01

acheng-01 commented Jul 8, 2026

Copy link
Copy Markdown
Author

Some more benchmarks run on actual differences in code:

100k rows (200k for repository_version), best of 4.
The number next to the dot is the number of SELECTs made by the query.

ContentGuard (name)

Expression set-ops subquery hybrid
A AND B 68 ms · 2 41 ms · 3 14 ms · 1
A OR B (tiny result) 11 ms · 2 23 ms · 3 13 ms · 1
(A AND B) OR C 128 ms · 3 101 ms · 5 17 ms · 1
NOT A 124 ms · 2 31 ms · 2 12 ms · 1

Repository (name / number)

Expression set-ops subquery hybrid
name AND name 164 ms · 2 43 ms · 3 15 ms · 1
(A AND B) OR (C AND D) 170 ms · 4 98 ms · 7 20 ms · 1
(A AND (B OR C)) AND NOT D (complexity 8) 559 ms · 5 166 ms · 8 22 ms · 1
name AND retain>=x AND retain<y 251 ms · 3 52 ms · 4 15 ms · 1
(num range) OR num=0 263 ms · 3 117 ms · 5 13 ms · 1

Task

Expression set-ops subquery hybrid
name AND NOT name 303 ms · 3 69 ms · 4 17 ms · 1
name AND (NOT state OR state) 478 ms · 4 105 ms · 6 97 ms · 5

Content — repository_version (opaque, 200k units)

Expression set-ops subquery hybrid
199k AND NOT 199k 2662 ms · 7 2307 ms · 8 2234 ms · 8
50k AND NOT 50k 1900 ms · 5 1458 ms · 6 1558 ms · 6

As the results show, the changes made in the new commit will in fact improve q= performance overall for all queries. However, for operands that can't be reduced/flattened, performance doesn't have as dramatic of an improvement. So in our case with repository_version = X AND NOT repository_version = Y, we would get marginal benefits from this vs the other base_version filter change. We could still look into that route and/or we should do something about the double wrapper on ContentRepositoryVersionFilter that might not need to be there. Happy to hear any thoughts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Need a performant solution to find content diffs between non-adjacent repo versions

2 participants