Query performance: N+1 in search, one-join aggregates, closed cards loaded, uncached site settings, missing composite indexes #231
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Postulo/postulo#231
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
None of these hurts in the first months; all of them grow with a year or two of data. Each is cheap to fix. Found in the 2026-09-15 code audit (the API pagination problem has its own issue, #230).
core/search.py:143-147,application.events.filter(...).first()). Every group is loaded and sorted in Python to show five hits (:408,416), and listings load full descriptions (:109-115). Searching "engineer" over 300 applications and 400 listings costs 300 queries and 400 descriptions.→ Per group,
count()plus[:limit]in SQL, ordered withCase(When(title__icontains=q)); the excerpt event through aPrefetchorSubquery.CompanyQuerySet.with_table_data(jobs/models.py:119-124) combinesCount("postings"),Count("postings__applications"),Count("contacts")andMax("postings__applications__events__occurred_at")in one GROUP BY. With search joins and.distinct()on top, the paginator'scount()runs it all again. One company with 4 postings, 12 events per application and 5 contacts makes 240 intermediate rows.→ Correlated
Subquerycounts, as the identifier columns already do, andExistsfor search.applications/views.py:160-165loads every application, closed ones included, with prefetch and four subqueries, and drops the columns it doesn't show in Python (:202-216).quiet_applications()then recomputes the same subqueries (:204).→
status__in=BOARD_STATUSESin SQL; work out quiet from the annotations already present.site.current()runsSiteSettings.objects.filter(pk=1).first()every time (core/site.py:103). It is reached from the middleware (core/middleware.py:52,66), theuicontext processor (context_processors.py:62-64) andis_empty(), and htmx fragments pay too becauseuiis not lazy.→ Memoise per request, or cache and clear on
SiteSettings.save; make context values callables.policy.decidereads the plugins JSON record from disk and queriesPluginPolicyon every call.CompanyDetailViewcalls it six times (jobs/views.py:211-234), andCompany.descendantsruns one query per node.→ Memoise
decideper request; cacheread_record()keyed on the file's mtime.importlib.metadatapath checks (core/server_views.py:801-824,available_sources(refresh=True)). This page is slow on a Raspberry Pi and costs about 40 s of CI.→ No
refresh=Trueon GET; provenance only for plugins on the data volume, cached per record mtime.applications/models.py:143-149,127,181,381).→
ApplicationEvent("application", "-occurred_at"),Reminder("application", "done_at", "due_at"),Interview("application", "outcome", "starts_at").ApplicationBulkView._tagandCompanyBulkView._industryrunexists()andadd()per row, about 300 queries for 100 rows.→ One query for the ids that already have the tag, then
through.objects.bulk_create(..., ignore_conflicts=True).jobs/listing_views.py:59-76).→ One
aggregate()with conditionalCount(filter=Q(...)), usingExistsfor "has applications".reports.buildloads every application ever sent and filters the period in Python (reports.py:404-412);_first_reachingloads all status events three times per report.analytics.buildruns on every dashboard view with no cache.→ Filter by the period in SQL, compute
Min(occurred_at)per application in SQL, and cache Insights keyed on the newest event id andupdated_at.change_statusreadspreviousfrom the caller's object withoutselect_for_update(services.py:89), andApplicationUpdateViewsaves every field, status included. Two concurrent changes can write a transition that never happened.→ Re-read with
select_for_update()insidechange_status, and exclude status from the update view'supdate_fields.Add a
django_assert_max_num_queriestest for the table, board, search and API list endpoints so none of these comes back.Two tooling notes for this work: one to find them, one to keep them fixed
Every problem listed above was found by reading code. That worked, and it does not
scale to the next one — nothing in the
devdependency group would have surfaced any ofthem, and nothing will notice when one comes back.
Finding them:
django-debug-toolbarin thedevgroup. Version 8.0.0 classifiesDjango 6.1, so it is in range for
django>=6.1,<6.2. Its SQL panel gives the per-requestquery count and the duplicate-query grouping that turns "the companies table feels slow"
into the
GROUP BYfan-out described in item 2. Development-only, no production surface.nplusoneis the narrower alternative if a full toolbar is unwanted — it only warns onN+1 access patterns.
Keeping them fixed:
assertNumQueries, which needs no package at all. This is themore important half. Each fix here should land with a test pinning the query count for
that view:
CompanyQuerySet.with_table_datathrough the paginator, which runscount()over thesame joins — item 2;
site.current()is one query and not five — item 4.Without those, every item above is a fix that regresses silently the next time somebody
adds an annotation. With them, the regression fails CI on the commit that causes it, which
is the only point at which it is cheap.
The assertions are worth adding even if the toolbar is not: the toolbar is a
convenience for exploration, the assertions are the guard.