Skip to content

perf: improve /doc/search performance - #11507

Merged
jennifer-richards merged 11 commits into
ietf-tools:mainfrom
rjsparks:search-performance
Aug 13, 2026
Merged

perf: improve /doc/search performance#11507
jennifer-richards merged 11 commits into
ietf-tools:mainfrom
rjsparks:search-performance

Conversation

@rjsparks

@rjsparks rjsparks commented Aug 13, 2026

Copy link
Copy Markdown
Member

perf: improve /doc/search performance

The incident

/doc/search/?author=Barry+Leiba&by=author&name=rfc&rfcs=on

118 seconds, 745M shared buffer hits, ~1GB of temp spill — to return 32 documents. The
same search with the draft checkboxes ticked did not complete at all: Postgres failed it
with could not resize shared memory segment ... No space left on device in a parallel
worker, reproducibly.

Profiling turned up two independent failure modes. One pathological query, and a per-row
query storm that affects every search, not just this one.

1. Multi-valued relations ORed into a single filter()

The author filter ORed five lookups spanning documentauthor → person → {alias,email} and
rfcauthor → person → {alias,email} into one .filter() call. Django cannot split those
across separate joins, so the two author paths cross-multiply. The name filter added a
third multi-valued relation, targets_related, to the same OR group.

Measured fan-out for the URL above:

stage rows
doc_document where type=rfc 9,819
× documentauthor 23,552
× rfcauthor 81,460
× person/email/alias (draft side) 467,328
× doc_document_states 474,948
× person/email/alias (rfcauthor side) 2,175,516
× targets_related (the name filter) ~126,000,000
surviving the five ILIKEs 930

The planner estimated 7 rows for the node that produced 41.9M, so it committed to nested
loops. A six-million-fold misestimate is not fixable by tuning — the fan-out has to go.

.distinct() then ran over the full column list, which prepare_document_table widens
further with select_related(), forcing a sort over 72 columns including abstract,
group.description and person.biography.

2. A per-row query storm

Rendering the table cost 1,900–3,800 queries for 200 rows: related_ipr walked the
obs/replaces graph with a query per node per row; review_assignments_to_list_for_docs
was called once per document from inside a loop despite being built to answer for many;
friendly_state asked for replacements and became-RFC per row; Person.email() and
person_link's alias check ran per row because select_related("ad") gives every row its
own Person instance; and fill_in_telechat_date ran a query and threw the result away
because the two lines that stored it were commented out.

Results

search queries before after main query before after
the reported URL 298 63 118 s 0.14 s
same author, drafts included 65 did not complete 0.16 s
by=group&group=dnsop&… 1,902 88
by=group&group=tls&… 1,594 164
name=security&… 1,708 107
activedrafts=on&olddrafts=on&rfcs=on 3,765 305

Other views that render the same table, all with byte-identical output:

view before after
iesg.agenda_documents 616 274
iesg.agenda 382 196
doc.docs_for_iesg 4,353 837
doc.recent_drafts 527 126
doc.drafts_in_last_call 304 94

Correctness bugs fixed along the way

These were found while profiling and are not performance issues:

One user's personal bookmark state was served to other users. prepare_document_table
attaches per-user flags (community-list membership, review wishes), and the whole prepared
list was cached under a key with no user in it. Whoever requested a search first had their
state baked in and served to everyone for the next 300 seconds. Demonstrated on main:
with user A tracking draft-ietf-sfc-nsh-ecn-support, user B's page showed it bookmarked;
with B first, A's own tracked draft showed as untracked. Fixed by caching document ids and
re-preparing per request.

Two different searches shared a cache entry. The key came from QueryDict.items(),
which returns only the last value of a multi-valued key, so
?doctypes=charter&doctypes=statchg and ?doctypes=statchg collided and could serve each
other's results.

The heaviest searches were never cached at all. The cached payload was the prepared
Document objects — 1.28MB for 200 rows, over memcached's 1MB item limit, which
LenientMemcacheCache exists to swallow silently. The searches most worth caching were
recomputed every time.

A cached result carried the sort order it was first computed with, since sort is not
part of the key, so clicking a column header could return the previous ordering.

fill_in_telechat_date was a no-op, so the IESG agenda views it exists to speed up
paid a latest_event() query per document anyway.

Reading the commits

Nine commits, each green on its own, tests alongside the change they guard. The first
three carry the behaviour risk and fix the incident; 4–9 are mechanical batching with
output verified byte-identical.

  1. stop the author and name filters cross-multiplying — the 118s → 0.14s fix
  2. give each document search its own cache entry — the doctypes collision
  3. cache result ids rather than prepared documents — payload, personalization, sort order
  4. make fill_in_telechat_date actually fill in the telechat date
  5. look up review assignments once for the whole table — 342 queries → 3
  6. batch the document relations the table renders
  7. batch the person lookups behind the AD and shepherd columns
  8. stop the table refetching rows it already has — select_related/prefetch corrections
  9. guard the table against per-row queries — the invariance test

Verification

  • 26 query shapes compared old vs new at the queryset level against a production-scale
    database: identical result sets, no duplicate rows anywhere. This is what licenses
    dropping .distinct().
  • 13 representative searches rendered through the view before and after and compared
    byte for byte; differences reduce to elapsed-day counters and deliberate tie-ordering.
  • Every view rendering search_result_row.html or status_columns.html exercised
    against the live database; output identical, query counts down or unchanged.
  • Full test suite: 1786 tests. Plus 695 across doc, group, community, person,
    review and iesg on the branch tip.

Notable Behaviour changes

Ordering is now deterministic. retrieve_search_results ordered by -time, which is
not a total order — documents sharing a timestamp to the second are common. Both the set
of rows kept by the 200-row truncation and the order equal sort keys fell back to varied
between executions. It now orders by ('-time', 'pk'). This reorders only what was
previously arbitrary, but it is a visible change for tie-heavy sorts (ipr, status, ad).

related_that / related_that_doc consult a per-instance cache when one has been
seeded (commit 6). The cache is read-only and opt-in: nothing else in the tree sets those
attributes and an unseeded document falls through to the original query, so other callers
are untouched. This is the one model-level change.

Person.has_alias_for_name() is new (commit 7) and person_link — used by 62
templates — now calls it. It is the same expression that was inline in the tag, memoised
per instance, with save() dropping the memo when the aliases change.


Prepared with the assistance of Claude

rjsparks and others added 10 commits August 11, 2026 16:07
Searching by author ORed five lookups into a single filter(), spanning
documentauthor -> person -> {alias,email} and rfcauthor -> person ->
{alias,email}. Because they share one filter() call, Django cannot split
them across separate joins, so the two author paths cross-multiply. The
name filter added a third multi-valued relation, targets_related, to the
same OR group.

For /doc/search/?author=Barry+Leiba&by=author&name=rfc&rfcs=on that meant
18 joins and roughly 126M intermediate rows to return 32 documents: 118
seconds, and 745M shared buffer hits. The planner estimated 7 rows for
the node that produced 41.9M, so it committed to nested loops; the
misestimate is not fixable by tuning, only by removing the fan-out. The
same search with drafts included did not complete at all, failing with
"could not resize shared memory segment ... No space left on device".

Resolve the author text to people first and match documents by primary
key, and reach related documents through a pk subquery instead of the
targets_related join. Measured 118s -> 0.14s on the same data.

Skip the draft-state clause when no draft state is allowed. On the
types-driven path that excludes nothing -- "draft" is only in `types`
when at least one draft state is allowed -- while still emitting two
joins that multiplied rows in the rest of the query. The all_types path
does not consult `types`, so there it keeps excluding drafts explicitly.
When the clause does apply, express it as a subquery too: a document has
several state rows, so joining duplicated every non-draft that passed the
other half of the OR.

That was the only thing requiring the trailing distinct(), so drop it.
It was applied to the full column list, which prepare_document_table
widens further with select_related(), forcing a sort over 72 columns
including abstract, group description and person biography -- about 1GB
of temp spill on the search above.

Verified against a full-size database: 26 query shapes produce identical
result sets with no duplicate rows, and the all_types path matches too,
including the case where no checkbox is set and drafts must be excluded.
The search cache key was built from request.GET via QueryDict.items(),
which returns only the last value of a multi-valued key. doctypes is a
ModelMultipleChoiceField, so

    ?doctypes=charter&doctypes=statchg&name=foo
    ?doctypes=statchg&name=foo

hashed to the same key while returning different result sets, and each
could be served the other's results.

Derive the key from the validated cleaned_data instead. That fixes the
collision and also stops equivalent spellings of the same search from
splitting the cache -- "on" and "1" for a checkbox now agree, and the
order the values of a multi-valued field arrive in no longer matters.

'sort' stays out of the key, as before: prepare_document_table applies
the requested sort on every request.
The search view cached the fully prepared Document objects: their
prefetch caches, latest_event_cache DocEvents, milestones, sessions and
select_related Person and Group rows. A 200-row all-types search pickles
to 1.28MB, over memcached's default 1MB item limit -- which is why
LenientMemcacheCache exists to swallow "object too large for cache". The
searches most worth caching were therefore never cached, and recomputed
on every request.

Cache the matching document ids and re-prepare on a hit. The payload
becomes a few hundred integers.

This also fixes a second problem. 'sort' is not part of the cache key,
but prepare_document_table sorts in place, so a cached entry carried
whatever order it was first computed with and clicking a column header
could return the previous ordering. Re-preparing on each request applies
the requested sort every time.

Re-preparing does mean the rows arrive in whatever order the pk lookup
returns, and prepare_document_table's sort is stable, so tie-heavy sort
keys -- ipr, status and ad -- would render a page differently depending
on whether it hit the cache. Order the lookup the same way
retrieve_search_results does. That ordering is now ('-time', 'pk'):
`time` alone is not a total order, documents sharing a timestamp to the
second are common, and without the tie-break both the set of rows kept by
the max_results truncation and the order equal sort keys fall back to
vary between executions. This replaces arbitrary ordering with stable
ordering; it does not reorder anything that was previously well defined.

Use "is None" rather than a falsy test so an empty result set is a cache
hit instead of being recomputed every time.
The two lines that stored the result were commented out, so the function
ran a query over TelechatDocEvent, walked the results and threw them
away. Every row of the document table then called doc.telechat_date()
itself, one latest_event() query apiece -- 103 of them on a 187-row group
search -- and the five IESG views that call this before filtering on
doc.telechat_date() paid the same per-document cost.

Restore the assignment, and set the remaining documents to None so a
document with no telechat event does not fall through to the method
either. Order by ('-time', '-id') to match Document.latest_event(), so
the precomputed value equals what the method returns; a test covers that
equality, because a mismatch here silently drops documents off a telechat
agenda.

wrap_value becomes a module-level callable class rather than returning a
lambda. ietf.doc.views_search.recent_drafts pickles prepared documents
into the slowpages cache, which is file-based in production, so a lambda
would have made /doc/recent/ raise "AttributeError: Can't get local
object". Dev and test configure that cache as a DummyCache, which never
serializes, so nothing in the suite would have caught it -- hence the
accompanying picklability test.

It also defines __hash__ alongside __eq__, which Python would otherwise
set to None -- a method it stands in for is hashable.
fill_in_document_table_attributes called review_assignments_to_list_for_docs
with a single-element list from inside its per-document loop, even though
the helper takes a list and is built to answer for many documents at
once. Each call ran a breadth-first walk of the replaces graph -- one
query per level -- plus its own assignment query. On a 187-row group
search that was 342 queries where three suffice.

Hoist the call out of the loop and index the result by document name.

The assignment queryset only prefetched "result" while the table renders
the request's document, team and type and links to the review itself, so
each assignment shown cost several more queries. select_related them.
That also benefits the other two callers, ietf.doc.views_doc and
ietf.iesg.agenda.
Each row of the document table reads several relations: friendly_state
asks for the replacements and whether the draft became an RFC, the RFC
branch asks what subseries the document is part of, and the date column
asks what it replaces. Document.related_that and related_that_doc run a
query apiece, so those were roughly 370 queries on a 187-row search.

The IPR column was worse. Document.related_ipr collects disclosures
against the document and everything it transitively obsoletes or
replaces, and it walks that graph with all_relations_that_doc, which
issues a query per node it visits, per document -- another 244.

Fetch all of it up front: two queries for the relations the table reads,
and a breadth-first walk of the obs/replaces graph for the whole result
set at once, one query per level of depth plus one for the disclosures.

related_that and related_that_doc consult a per-instance cache when one
has been seeded. The cache is read-only and opt-in: nothing else in the
tree sets those attributes, and an unseeded document falls through to the
original query, so every other caller is unaffected.

related_ipr is attached wrapped rather than as a bare list so the
attribute stays callable like the method it shadows; templates auto-call
either way, but a bare list would make doc.related_ipr() a TypeError for
any Python caller handed a prepared document.
select_related("ad") gives every row of the document table its own Person
instance, so the memoisation on Person.email() never helps across rows.
person_link also asked, per row, whether the person's name is recorded as
one of their aliases. Between them that was 178 queries on a 187-row
search, plus more for each action holder listed.

Add Person.has_alias_for_name(), which is the expression person_link had
inline, memoised per instance and dropped in save() where the aliases
change. Seed it, and Person.email()'s own cache, for the ADs, shepherds
and action holders of a whole result set at once: the emails come from
prefetches, so only the aliases need a query, and one covers all of them.

Action holders are only read when the document has them enabled, which is
the condition the template applies too, so callers that skipped the
prefetch pay nothing extra.

person_link is used by 62 templates. Every call site passes a Person, and
plain_name() is evaluated before the changed line, so anything else would
already have failed there.

The address sort key is casefolded because Email.address is a CICharField,
so the database orders it case-insensitively and a byte-wise Python sort
could otherwise pick a different address than Person.email() would.
Six small fixes to fill_in_document_table_attributes and its querysets,
each independently visible in a query trace of a search page.

The latest-event cache selected every published_rfc, ballot-position,
started_iesg_process and new_revision event for the whole result set,
ordered ascending, and let later rows overwrite earlier ones in Python. A
draft routinely has dozens of new_revision events. Use DISTINCT ON to
fetch one row per (document, type) instead.

Documents with no ballot event at all had no `ballot` attribute, so
ballot_icon fell back to doc.active_ballot() -- a query apiece. Default it
to None, which is exactly what that fallback returns: same event types,
same ('-time', '-id') ordering, same created_ballot test.

The milestone sort key read m.state.slug when m.state_id already is the
slug, costing a query per milestone. The obsoleted-by and updated-by
lookup select_related only the target, then read rel.source.

select_related "type", which search_heading renders for every non-draft
row, and "shepherd__person", which the shepherd column reads. Prefetch
the action holders and their emails, which the status column reads.

Drop the "iprdocrel_set" prefetch: nothing uses it. The table renders
doc.related_ipr, which builds its own queryset, and Document.ipr() calls
.filter() on the related manager, which discards a prefetch and requeries
anyway -- ?sort=ipr measured identical at 187 per-row queries with and
without it.
Rendering the table fills every attribute in for the whole result set at
once, so the number of queries should not depend on the number of rows.
Assert that directly: render a group search, triple the result set, and
require the query count not to move. A lookup that slips back into the
per-row path adds at least one query per new document, so it fails loudly
rather than quietly costing a few hundred queries a page.

Written as an invariant rather than a fixed number so it does not need
updating whenever the fixed setup cost changes.

@jennifer-richards jennifer-richards 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.

Looks good. Not thrilled with splitting the caching of doc relationships / person aliases and emails between methods on the models that read a cache populated by special purpose utility code, but we can sort out how to rearchitect that later if it becomes a problem.

@codecov

codecov Bot commented Aug 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.96732% with 23 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.65%. Comparing base (6fcc7d8) to head (3544b5f).
⚠️ Report is 20 commits behind head on main.

Files with missing lines Patch % Lines
ietf/doc/utils_search.py 80.18% 22 Missing ⚠️
ietf/doc/views_search.py 96.55% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #11507      +/-   ##
==========================================
+ Coverage   88.59%   88.65%   +0.06%     
==========================================
  Files         333      333              
  Lines       44728    44966     +238     
==========================================
+ Hits        39626    39866     +240     
+ Misses       5102     5100       -2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jennifer-richards
jennifer-richards merged commit 0c1ffae into ietf-tools:main Aug 13, 2026
9 checks passed
@github-actions github-actions Bot locked as resolved and limited conversation to collaborators Aug 17, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants