Skip to content

Defer content_ids in the content app pass-through path - #8044

Open
alex-faivre wants to merge 1 commit into
pulp:mainfrom
alex-faivre:defer-content-ids-content-handler
Open

Defer content_ids in the content app pass-through path#8044
alex-faivre wants to merge 1 commit into
pulp:mainfrom
alex-faivre:defer-content-ids-content-handler

Conversation

@alex-faivre

Copy link
Copy Markdown

What does this PR do and why is it necessary?

_match_distribution() select_related()s RepositoryVersion (directly and via publication) without deferring content_ids. content_ids is a large ArrayField (one UUID per unit of content in the version) -- for a distribution mirroring the Ubuntu archive this can be hundreds of thousands of entries.

For a distribution whose publication has no matching PublishedArtifact rows (e.g. a verbatim publication, or more generally any pass_through publication), every content-app request falls into the pass-through branch, which reads publication.repository_version.content. That property (RepositoryVersion.get_content()) calls _get_content_ids(), which unconditionally reads self.content_ids -- forcing psycopg2 to parse the entire array into a Python list on every uncached request, then filtering Content client-side with a pk__in of that whole list.

Measured against a real RepositoryVersion with 347068 content_ids (a mirrored Ubuntu archive): fetching that row costs ~0.85s of mostly CPU time (array deserialization), independent of the SQL query itself (~40ms in EXPLAIN ANALYZE). Under concurrency this cost serializes in the content app (measured: 5 concurrent cold GETs spread their TTFB across 13-20s).

Two independent fixes, matching the two places a RepositoryVersion gets loaded in this file:

  1. _match_distribution(): add .defer("repository_version__content_ids", "publication__repository_version__content_ids") next to the existing select_related() -- nothing in that method reads the array.
  2. the pass_through branch: replace publication.repository_version.content with a server-side unnest() subquery built off publication.repository_version_id. This one is not a bare defer(): a defer that gets read anyway (via .content -> get_content() -> _get_content_ids()) buys nothing, since Django reloads the deferred column in full on first touch. The subquery form never brings the array into Python at all -- PostgreSQL evaluates content_id IN (SELECT unnest(content_ids) FROM ...) server-side.

Net effect on the exact call this PR targets, measured on the same 347068-entry RepositoryVersion: ~0.85s -> ~0.01-0.02s (both changes combined; the first alone already drops _match_distribution from ~0.85s to ~0.005s for the general case).

Content served is unaffected -- I verified this by comparing, on a live deployment with three separate verbatim apt mirrors of different sizes (139702 / 347068 / 47180 content_ids entries), the ContentArtifact resolved by the pre-patch code path vs. the post-patch code path for 15 real relative_paths (5 per mirror, randomly sampled): identical ContentArtifact in all 15 cases.

I checked for related/overlapping work before opening this: #7867 ("Improve content_ids handling by defered loading") takes a broader, model-level approach (a custom RepositoryVersion manager that defers content_ids by default) but does not touch pulpcore/content/handler.py at all -- these two call sites are untouched by any currently open PR I could find. This PR is scoped narrowly to the content app's hot path and does not conflict with #7867 landing later (a defer() on an already-deferred field is a no-op).

What are the audit and quality assurance implications?

No behavior change beyond query shape -- same content served, verified as described above. No new dependencies, no migration.

Checklist

  • Commit is a single, cleanly-scoped change
  • A changelog entry has been added (CHANGES/+defer-content-ids-in-content-handler.bugfix)
  • Follows the Pulp policy on AI Usage -- see note below
  • (For new features) User documentation and test coverage has been added -- N/A, bugfix only

Note on the AI Usage checkbox: this patch was diagnosed and written with AI assistance (Claude) as part of investigating apt-mirror latency in my own deployment; I reviewed, independently measured, and verified every claim in this description myself (including the before/after query timings and the content-parity check) before opening this PR. I am leaving the checkbox unchecked pending my own re-read of the current AI usage policy text -- happy to update once I have.

_match_distribution() select_related()s RepositoryVersion (directly and
via publication) without deferring content_ids, a large ArrayField
(one UUID per unit of content). For a distribution with no matching
PublishedArtifact rows (e.g. a verbatim publication), every request
falls into the pass-through branch, which reads
publication.repository_version.content -- forcing psycopg2 to parse
the whole array into a Python list on every uncached request.

Two changes:
- defer repository_version__content_ids and
  publication__repository_version__content_ids on the
  _match_distribution() query, since nothing there needs the array.
- build the pass-through ContentArtifact filter from a server-side
  unnest() subquery on the FK id instead of the .content property,
  which reads self.content_ids unconditionally (so a bare defer()
  on its own does not help once .content is called: Django reloads
  the deferred column on first touch).

Measured against a real 347068-entry RepositoryVersion: this code
path drops from ~0.85s to ~0.01-0.02s. Content served is unaffected
-- verified identical ContentArtifact resolution before/after on a
sample of real relative_paths across three separate verbatim apt
mirrors.
@gerrod3

gerrod3 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

@alex-faivre Thanks for the PR, but we already have a PR open for this: #7867 we should put the changes there (and it happens to be waiting on me to review 😅) The benchmarks are good to know that the defer + unnest trick works.

A little note on the AI policy, the AI attribution should be in the commit message as well, not just the github PR message. Also, changes should not be overly commented. Most of your PR is comments vs the actual code changes. I'm not sure why bots love to write comments everywhere (maybe so they can sell you more tokens), but we prefer if the comments are reserved for complicated or unintuitive bits that need a little more context from outside of its immediate area. I'll make a note of that in our CLAUDE.md

Edit I've reviewed the other PR and there is the defer on the select_related that would be helpful, so I'm reopening this. Sorry for the premature judgement!

@gerrod3 gerrod3 closed this Sep 2, 2026
@gerrod3 gerrod3 reopened this Sep 2, 2026

@gerrod3 gerrod3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

And don't forget the AI attribution in the commit. (You can keep the results of the changes in the commit description after the first line)

"pulp_domain",
"publication__repository_version",
)
# `content_ids` is a large ArrayField (one UUID per unit of

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

One line comment would be fine here # defer content_ids to avoid parsing large array into memory

# pass-through
if publication.pass_through:
try:
# Do NOT read `publication.repository_version.content` here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This shouldn't be needed with the changes in the other PR, publication.repository_version.content will return the nested subquery

@@ -0,0 +1 @@
Stopped the content app from hydrating the full `content_ids` array on every request to a distribution served without a matching `PublishedArtifact` (e.g. verbatim publications): `_match_distribution()` now defers `content_ids` on the `RepositoryVersion`s it loads, and the pass-through fallback builds its `ContentArtifact` filter from a server-side `unnest()` subquery instead of the `RepositoryVersion.content` property. Measured on a 347068-entry `RepositoryVersion`: request time for this code path dropped from ~0.85s to ~0.01-0.02s.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Make this shorter Stopped content app hydrating a full `content_ids` array on every request

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants