Skip to content

[pull] main from ietf-tools:main - #277

Merged
pull[bot] merged 3 commits into
AnantRatnam:mainfrom
ietf-tools:main
Aug 13, 2026
Merged

[pull] main from ietf-tools:main#277
pull[bot] merged 3 commits into
AnantRatnam:mainfrom
ietf-tools:main

Conversation

@pull

@pull pull Bot commented Aug 13, 2026

Copy link
Copy Markdown

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

jennifer-richards and others added 3 commits August 5, 2026 15:22
* perf: stop the document search author and name filters cross-multiplying

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.

* fix: give each document search its own cache entry

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.

* perf: cache document search result ids rather than prepared documents

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.

* fix: make fill_in_telechat_date actually fill in the telechat date

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.

* perf: look up review assignments once for the whole document table

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.

* perf: batch the document relations the search table renders

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.

* perf: batch the person lookups behind the AD and shepherd columns

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.

* perf: stop the document table refetching rows it already has

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.

* test: guard the document table against per-row queries

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.

* chore: use QuerySetAny for isinstance check

Avoids mypy lint

---------

Co-authored-by: Jennifer Richards <jennifer@staff.ietf.org>
@pull pull Bot locked and limited conversation to collaborators Aug 13, 2026
@pull pull Bot added the ⤵️ pull label Aug 13, 2026
@pull
pull Bot merged commit 01f0bff into AnantRatnam:main Aug 13, 2026
1 of 2 checks passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants