CVs: every page, preview and download fetches each entry with its own query, and each skill group three more #559

Closed
opened 2026-10-01 10:47:38 +00:00 by tiagoagueda · 1 comment
Owner

Found on 2026-09-29 in a read-only review of postulo at 95348908d, by counting the queries of the CV pages, exports and API at two CV sizes.

Checked again on 2026-10-01 against main at 0a5cad587: still present. The code is now at documents/rendering.py:133-134 (build_sections), documents/models.py:257-259 (CV.included_items) and documents/models.py:315-316 (CVItem.__str__), resume/translating.py:269-272 (named_by_classification), resume/translating.py:368 (fallen_back) and resume/translating.py:202-208 (skill_names); the views, the API router, resume/models.py and the templates have not moved.

A CV item points at its entry through a GenericForeignKey, and every reader walks the items and reads item.item without prefetching it, so each entry costs a query. Skill groups then cost three more each when they are shown in a language. The cost grows with every entry on the CV, on the page somebody edits most and on every export.

  • Where:
    • documents/rendering.py:159-160 (build_sections: cv.included_items() then cv_item.item per row), which feeds the preview, the text view, the .txt and .docx downloads and every PDF render
    • documents/models.py:217-219 (CV.included_items: select_related("content_type") only)
    • documents/views.py:159 (CVDetailView: self.object.items, read as {{ item.item }} at templates/documents/cv_detail.html:130,133)
    • resume/translating.py:353-356 (named_by_classification) and resume/translating.py:476 (fallen_back), which walk the same items again for the CV page
    • resume/translating.py:286-292 (skill_names on the translated entry): entry.skills.all(), overrides_by_entry(skills, …) and entry.owner, one query each per group
    • api/routers/documents.py:40-46 (_cv_out: str(item) per item, which reads item.item through CVItem.__str__, documents/models.py:275-276)
    • templates/resume/overview.html:93-94,146 with resume/models.py:135-137 (SkillGroup.skill_names): the career overview reads each group's skills twice, with no prefetch
  • What happens: a CV of 63 entries (nine of each of seven kinds) against one of 7:
    • CV page: 29 → 101 queries
    • preview, .txt, .docx: 22 → 102; text view: 24 → 104
    • GET /api/v1/cvs/{id}: 26 → 82
    • career overview with 9 skill groups against 1: 24 → 48
      Each PDF render runs the same build_sections.
  • Why it matters: the CV page and its exports cost a query per entry for the life of the CV. Nothing tests the query count of these pages, so the cost can only grow as entry kinds are added.
  • Fix:
    • Prefetch the entries where the items are read: cv.included_items().prefetch_related("item") (one query per content type), and in CVDetailView and _cv_out likewise.
    • For skill groups, GenericPrefetch("item", [SkillGroup.objects.prefetch_related("skills"), …]) (already used in jobs/history.py:261).
    • In skill_names, pass the owner down (or use entry.owner_id) and look up overrides and classified names once for all the CV's skills rather than per group.
    • On the career overview, prefetch_related("skills") for the skill-group section and read skill_names from the prefetched list.

Done when

  • A test in tests/test_query_budget.py renders the CV page, the preview, the .docx download and GET /api/v1/cvs/{id} for a CV with 7 entries and with 63, and asserts the same number of queries for both; likewise the career overview with 1 and 9 skill groups.
Found on 2026-09-29 in a read-only review of postulo at 95348908d, by counting the queries of the CV pages, exports and API at two CV sizes. Checked again on 2026-10-01 against `main` at `0a5cad587`: still present. The code is now at `documents/rendering.py:133-134` (`build_sections`), `documents/models.py:257-259` (`CV.included_items`) and `documents/models.py:315-316` (`CVItem.__str__`), `resume/translating.py:269-272` (`named_by_classification`), `resume/translating.py:368` (`fallen_back`) and `resume/translating.py:202-208` (`skill_names`); the views, the API router, `resume/models.py` and the templates have not moved. A CV item points at its entry through a `GenericForeignKey`, and every reader walks the items and reads `item.item` without prefetching it, so each entry costs a query. Skill groups then cost three more each when they are shown in a language. The cost grows with every entry on the CV, on the page somebody edits most and on every export. - **Where:** - `documents/rendering.py:159-160` (`build_sections`: `cv.included_items()` then `cv_item.item` per row), which feeds the preview, the text view, the `.txt` and `.docx` downloads and every PDF render - `documents/models.py:217-219` (`CV.included_items`: `select_related("content_type")` only) - `documents/views.py:159` (`CVDetailView`: `self.object.items`, read as `{{ item.item }}` at `templates/documents/cv_detail.html:130,133`) - `resume/translating.py:353-356` (`named_by_classification`) and `resume/translating.py:476` (`fallen_back`), which walk the same items again for the CV page - `resume/translating.py:286-292` (`skill_names` on the translated entry): `entry.skills.all()`, `overrides_by_entry(skills, …)` and `entry.owner`, one query each per group - `api/routers/documents.py:40-46` (`_cv_out`: `str(item)` per item, which reads `item.item` through `CVItem.__str__`, `documents/models.py:275-276`) - `templates/resume/overview.html:93-94,146` with `resume/models.py:135-137` (`SkillGroup.skill_names`): the career overview reads each group's skills twice, with no prefetch - **What happens:** a CV of 63 entries (nine of each of seven kinds) against one of 7: - CV page: 29 → 101 queries - preview, `.txt`, `.docx`: 22 → 102; text view: 24 → 104 - `GET /api/v1/cvs/{id}`: 26 → 82 - career overview with 9 skill groups against 1: 24 → 48 Each PDF render runs the same `build_sections`. - **Why it matters:** the CV page and its exports cost a query per entry for the life of the CV. Nothing tests the query count of these pages, so the cost can only grow as entry kinds are added. - **Fix:** - Prefetch the entries where the items are read: `cv.included_items().prefetch_related("item")` (one query per content type), and in `CVDetailView` and `_cv_out` likewise. - For skill groups, `GenericPrefetch("item", [SkillGroup.objects.prefetch_related("skills"), …])` (already used in `jobs/history.py:261`). - In `skill_names`, pass the owner down (or use `entry.owner_id`) and look up overrides and classified names once for all the CV's skills rather than per group. - On the career overview, `prefetch_related("skills")` for the skill-group section and read `skill_names` from the prefetched list. ## Done when - A test in `tests/test_query_budget.py` renders the CV page, the preview, the `.docx` download and `GET /api/v1/cvs/{id}` for a CV with 7 entries and with 63, and asserts the same number of queries for both; likewise the career overview with 1 and 9 skill groups.
tiagoagueda added this to the 0.6.0 milestone 2026-10-01 10:47:38 +00:00
Author
Owner

Landed on main with a green Forgejo CI run. Its entry in the CHANGELOG's Unreleased section ends in (#559).

Landed on main with a green Forgejo CI run. Its entry in the CHANGELOG's Unreleased section ends in (#559).
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#559
No description provided.