Skip to content

Commit ba394a1

Browse files
rjsparksclaude
andauthored
perf: optimize the person endpoint (#11635)
* perf: cap per-client concurrency on /person/ and /api/v1/ Keyed on the Cloudflare client address, with an empty key for every other path so only these two prefixes are limited. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * perf: query the two authorship tables separately in Person.rfcs() Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * perf: avoid table scans and per-alias queries in lookup_persons Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * perf: assemble the profile page's data in the view Gathers the RFC publication dates, reference counts and replaced-draft set for every listed person in one query each, rather than a query per table cell, and evaluates each per-person list once instead of on every template reference. The expired Internet-Drafts heading now counts the drafts it lists; it counted the replaced ones the list omits. Roles with the same name sort by group acronym instead of by whatever order the query returned. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * perf: cache each rendered profile section A repeat view of a profile, including the revalidation behind a conditional request, now costs neither the queries nor the render. Sections are keyed on person and date rather than position on the page, so the per-section element ids move from a loop counter to the person's id. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: guard the profile page's query count and section cache Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * revert: perf: cap per-client concurrency on /person/ and /api/v1/ This reverts commit fed51f0. * refactor: make the profile section cache lifetime a setting PERSON_PROFILE_CACHE_SECONDS, overridable from the environment in the k8s deployment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: stop dating the profile page's empty-section messages The dates claimed a precision the page does not have: sections are cached independently, so the data behind two of them can differ by a cache lifetime while both printed the same date. Without them nothing in a section depends on when it was rendered, so the cache key no longer needs the date either. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent b771d60 commit ba394a1

9 files changed

Lines changed: 388 additions & 173 deletions

File tree

‎ietf/person/models.py‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -278,9 +278,21 @@ def rfcs(self):
278278
# When RfcAuthors are populated, this may over-return if an author is dropped
279279
# from the author list between the final draft and the published RFC. Should
280280
# ignore DocumentAuthors when an RfcAuthor exists for a draft.
281-
rfcs = list(Document.objects.filter(type="rfc").filter(models.Q(documentauthor__person=self)|models.Q(rfcauthor__person=self)).distinct())
282-
rfcs.sort(key=lambda d: d.name )
283-
return rfcs
281+
#
282+
# The two authorship tables are queried separately and combined here. As a
283+
# single ORed queryset, neither person_id index is usable and the join has to
284+
# be materialized in full before being deduplicated.
285+
ids = set(
286+
Document.objects.filter(
287+
type="rfc", documentauthor__person=self
288+
).values_list("pk", flat=True)
289+
)
290+
ids.update(
291+
Document.objects.filter(
292+
type="rfc", rfcauthor__person=self
293+
).values_list("pk", flat=True)
294+
)
295+
return sorted(Document.objects.filter(pk__in=ids), key=lambda d: d.name)
284296

285297
def active_drafts(self):
286298
from ietf.doc.models import Document

‎ietf/person/tests.py‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import debug # pyflakes:ignore
2828

2929
from ietf.community.models import CommunityList
30+
from ietf.doc.factories import WgDraftFactory, WgRfcFactory
3031
from ietf.group.factories import RoleFactory
3132
from ietf.group.models import Group
3233
from ietf.message.models import Message
@@ -125,6 +126,61 @@ def test_person_profile(self):
125126
r = self.client.get(photo_url)
126127
self.assertEqual(r.status_code, 200)
127128

129+
def test_person_profile_query_count(self):
130+
"""The page's cost must not scale with how much a person has written"""
131+
132+
def profile_queries(rfcs, active, expired):
133+
person = PersonFactory()
134+
RoleFactory(person=person, name_id="chair")
135+
WgRfcFactory.create_batch(rfcs, authors=[person])
136+
WgDraftFactory.create_batch(active, authors=[person])
137+
WgDraftFactory.create_batch(
138+
expired, authors=[person], states=[("draft", "expired")]
139+
)
140+
url = urlreverse(
141+
"ietf.person.views.profile",
142+
kwargs={"email_or_name": person.plain_name()},
143+
)
144+
with CaptureQueriesContext(connection) as context:
145+
r = self.client.get(url)
146+
self.assertEqual(r.status_code, 200)
147+
return len(context.captured_queries)
148+
149+
few = profile_queries(1, 1, 1)
150+
many = profile_queries(6, 4, 5)
151+
self.assertEqual(
152+
many,
153+
few,
154+
f"{many} queries for 15 documents vs {few} for 3 - a query per row crept in",
155+
)
156+
157+
@override_settings(
158+
CACHES={
159+
"default": {"BACKEND": "django.core.cache.backends.dummy.DummyCache"},
160+
"slowpages": {
161+
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
162+
"LOCATION": "test-person-profile",
163+
},
164+
}
165+
)
166+
def test_person_profile_sections_cached(self):
167+
person = PersonFactory()
168+
WgRfcFactory(authors=[person])
169+
WgDraftFactory(authors=[person])
170+
url = urlreverse(
171+
"ietf.person.views.profile", kwargs={"email_or_name": person.plain_name()}
172+
)
173+
174+
first = self.client.get(url)
175+
self.assertEqual(first.status_code, 200)
176+
with CaptureQueriesContext(connection) as context:
177+
second = self.client.get(url)
178+
self.assertEqual(second.status_code, 200)
179+
# The cached section is HTML, not text to be escaped again.
180+
self.assertEqual(first.content, second.content)
181+
self.assertContains(second, person.name)
182+
self.assertLess(len(context.captured_queries), 5)
183+
128184
def test_person_profile_without_email(self):
129185
person = PersonFactory(name="foobar@example.com")
130186
# delete Email record

‎ietf/person/utils.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -337,11 +337,14 @@ def get_dots(person):
337337
return dots
338338

339339
def lookup_persons(email_or_name):
340-
aliases = Alias.objects.filter(name__iexact=email_or_name)
340+
aliases = Alias.objects.filter(name__iexact=email_or_name).select_related("person")
341341
persons = set(a.person for a in aliases)
342342

343343
if '@' in email_or_name:
344-
emails = Email.objects.filter(address__iexact=email_or_name)
344+
# Email.address is a citext column, so an exact match is already
345+
# case-insensitive and can use the index on it. Asking for iexact wraps the
346+
# column in UPPER() and costs a scan of the table.
347+
emails = Email.objects.filter(address=email_or_name).select_related("person")
345348
persons.update(e.person for e in emails)
346349

347350
persons = [p for p in persons if p and p.id]

‎ietf/person/views.py‎

Lines changed: 135 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@
77

88
from django.conf import settings
99
from django.contrib import messages
10-
from django.db.models import Q
10+
from django.core.cache import caches
11+
from django.db.models import Count, Q
1112
from django.http import HttpResponse, Http404
1213
from django.shortcuts import render, redirect
1314
from django.template.loader import render_to_string
14-
from django.utils import timezone
1515

1616
import debug # pyflakes:ignore
1717

18+
from ietf.doc.models import DocEvent, RelatedDocument
1819
from ietf.ietfauth.utils import role_required
1920
from ietf.person.models import Email, Person
2021
from ietf.person.fields import select2_id_name_json
@@ -26,6 +27,9 @@
2627
merge_persons,
2728
)
2829
from ietf.utils.mail import send_mail_text
30+
from ietf.utils.timezone import RPC_TZINFO
31+
32+
REFERENCE_RELATIONSHIPS = ("refnorm", "refinfo", "refunk", "refold")
2933

3034

3135
def ajax_select2_search(request, model_name):
@@ -77,9 +81,136 @@ def ajax_select2_search(request, model_name):
7781
return HttpResponse(select2_id_name_json(objs), content_type='application/json')
7882

7983

84+
def rfc_rows(persons):
85+
"""Build the RFC table rows for each person
86+
87+
Returns a dict keyed on person pk. The columns are gathered for every person at
88+
once - read one at a time off the Document, each row costs a query per column.
89+
"""
90+
rfcs = {p.pk: p.rfcs() for p in persons}
91+
rfc_ids = {d.pk for docs in rfcs.values() for d in docs}
92+
93+
# The references of the draft an RFC was published from count as the RFC's own.
94+
draft_of = dict(
95+
RelatedDocument.objects.filter(
96+
target_id__in=rfc_ids, relationship="became_rfc"
97+
).values_list("target_id", "source_id")
98+
)
99+
referenced_by = dict(
100+
RelatedDocument.objects.filter(
101+
target_id__in=rfc_ids | set(draft_of.values()),
102+
relationship__in=REFERENCE_RELATIONSHIPS,
103+
source__type__slug="rfc",
104+
)
105+
.values("target_id")
106+
.annotate(count=Count("id"))
107+
.values_list("target_id", "count")
108+
)
109+
110+
# Matches Document.latest_event ordering, so the first row seen for a document
111+
# is the one its pub_date would have reported.
112+
published = {}
113+
for doc_id, time in (
114+
DocEvent.objects.filter(doc_id__in=rfc_ids, type="published_rfc")
115+
.order_by("-time", "-id")
116+
.values_list("doc_id", "time")
117+
):
118+
published.setdefault(doc_id, time)
119+
120+
return {
121+
pk: [
122+
{
123+
"doc": doc,
124+
"pub_date": (
125+
published[doc.pk].astimezone(RPC_TZINFO).date()
126+
if doc.pk in published
127+
else None
128+
),
129+
"referenced_by": referenced_by.get(doc.pk, 0)
130+
+ referenced_by.get(draft_of.get(doc.pk), 0),
131+
}
132+
for doc in docs
133+
]
134+
for pk, docs in rfcs.items()
135+
}
136+
137+
138+
def profile_data(persons):
139+
"""Build everything person/profile.html renders for each of persons"""
140+
rfcs = rfc_rows(persons)
141+
expired = {p.pk: list(p.expired_drafts().prefetch_related("states")) for p in persons}
142+
replaced = set(
143+
RelatedDocument.objects.filter(
144+
target_id__in={d.pk for docs in expired.values() for d in docs},
145+
relationship="replaces",
146+
).values_list("target_id", flat=True)
147+
)
148+
149+
profiles = []
150+
for person in persons:
151+
# Role.Meta orders by name_id alone, which leaves ties to the query plan.
152+
roles = sorted(
153+
person.role_set.select_related("name", "group", "email"),
154+
key=lambda r: (r.name_id, r.group.acronym),
155+
)
156+
profiles.append(
157+
{
158+
"person": person,
159+
"has_roles": bool(roles),
160+
"roles": [
161+
r
162+
for r in roles
163+
if r.group.state_id in ["active", "bof"]
164+
and r.group.acronym != "secretariat"
165+
],
166+
"ext_resources": list(
167+
person.personextresource_set.select_related("name")
168+
),
169+
"rfcs": rfcs[person.pk],
170+
"active_drafts": list(
171+
person.active_drafts().prefetch_related("states")
172+
),
173+
"expired_drafts": [
174+
d for d in expired[person.pk] if d.pk not in replaced
175+
],
176+
"has_drafts": person.has_drafts(),
177+
}
178+
)
179+
return profiles
180+
181+
182+
def profile_sections(persons):
183+
"""Render each person's part of the profile page
184+
185+
The rendered sections are cached, so a repeat view of a profile - including the
186+
revalidation a conditional request makes - costs neither the queries nor the
187+
render. Nothing in a section is tied to the moment it was rendered, so how stale
188+
one can be is entirely PERSON_PROFILE_CACHE_SECONDS.
189+
"""
190+
slowpages = caches["slowpages"]
191+
keys = {person.pk: f"person:profile:{person.pk}" for person in persons}
192+
sections = slowpages.get_many(list(keys.values()))
193+
194+
uncached = [person for person in persons if keys[person.pk] not in sections]
195+
for profile in profile_data(uncached):
196+
person = profile["person"]
197+
section = {
198+
"id": person.pk,
199+
"name": str(person),
200+
"has_drafts": profile["has_drafts"],
201+
"html": render_to_string("person/profile_body.html", {"profile": profile}),
202+
}
203+
slowpages.set(keys[person.pk], section, settings.PERSON_PROFILE_CACHE_SECONDS)
204+
sections[keys[person.pk]] = section
205+
206+
return [sections[keys[person.pk]] for person in persons]
207+
208+
80209
def profile(request, email_or_name):
81210
persons = lookup_persons(email_or_name)
82-
return render(request, 'person/profile.html', {'persons': persons, 'today': timezone.now()})
211+
return render(
212+
request, "person/profile.html", {"sections": profile_sections(persons)}
213+
)
83214

84215

85216
def profile_by_uuid(request, uuid):
@@ -95,7 +226,7 @@ def profile_by_uuid(request, uuid):
95226
return render(
96227
request,
97228
"person/profile.html",
98-
{"persons": [person_uuid.person], "today": timezone.now()},
229+
{"sections": profile_sections([person_uuid.person])},
99230
)
100231

101232

‎ietf/settings.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -886,6 +886,10 @@ def skip_unreadable_post(record):
886886
PDFIZER_CACHE_TIME = HTMLIZER_CACHE_TIME
887887
PDFIZER_URL_PREFIX = IDTRACKER_BASE_URL+"/doc/pdf"
888888

889+
# How long a rendered person profile section is served from the slowpages cache.
890+
# This is how stale a profile's roles and documents can be.
891+
PERSON_PROFILE_CACHE_SECONDS = 60*15 # 15 minutes
892+
889893
# Email settings
890894
IPR_EMAIL_FROM = 'ietf-ipr@ietf.org'
891895
AUDIO_IMPORT_EMAIL = ['ietf@meetecho.com']

0 commit comments

Comments
 (0)