Rich text page links read the site root paths cache, and query Locale, once per link instead of once per page
Issue summary
PageLinkHandler.expand_db_attributes_many() batches the database access for the pages it links to, via get_many(), but then resolves each URL individually with page.localized.url. Both halves of that expression repeat work that the pass could share:
Page.get_url()ends inPage._get_site_root_paths(), which memoizes on thecache_objectit is given, and on the page instance otherwise. Rich text rewriting has no request in scope, andget_many()builds fresh page instances for every rich text value, so each linked page is a separatecache.get("wagtail_site_root_paths")— a network round trip on Redis or Memcached..localizedcallsLocale.get_active(), andLocaleManager.get_for_language()is an uncached.get(), so every link is also aSELECT ... FROM wagtailcore_locale WHERE language_code = %swhenWAGTAIL_I18N_ENABLEDis true.
On a fresh Wagtail 8.0 project (steps below), rendering a page whose body holds three links costs:
| links | cache reads of wagtail_site_root_paths |
wagtailcore_locale queries |
|---|---|---|
| none (baseline) | 0 | 1 |
| 3 links to 3 pages, in 3 rich text values | 3 | 4 |
| 3 links to 3 pages, in 1 rich text value | 3 | 4 |
| 3 links to the same page, in 1 rich text value | 1 | 4 |
The cache reads scale with the distinct pages linked from each rich text value, and the Locale queries with the links themselves. A single lookup per render would serve all of them.
This surfaced as a Sentry N+1 alert on a production page, whose repeating group was one cache read, the two queries from PageQuerySet.specific() in get_many(), and one Locale query, repeated for each rich text value in the body. The per-request cost is small, but it scales with how many links an editor writes into a page's body, and a project cannot reach it: {% pageurl %} fixes URL resolution in a project's own templates, but not inside rich text rewriting.
Steps to reproduce
- Start a new project with
wagtail start myproject, and setWAGTAIL_I18N_ENABLED = True. - Give
HomePagethreeRichTextFields,body_one,body_twoandbody_three, and render all three with therichtextfilter inhome_page.html. - Run the following, which creates a page to link to, puts one link in each rich text field, then counts the cache reads and the queries of a render:
import collections
from django.core.cache import cache
from django.db import connection
from django.test import Client
from django.test.utils import CaptureQueriesContext, setup_test_environment
from home.models import HomePage
setup_test_environment()
home = HomePage.objects.get(slug="home")
if not HomePage.objects.filter(slug="target").exists():
home.add_child(instance=HomePage(title="Target", slug="target"))
target = HomePage.objects.get(slug="target")
home.body_one = home.body_two = home.body_three = f'<p><a id="{target.id}" linktype="page">Target</a></p>'
home.save()
cache.clear()
reads = collections.Counter()
original = cache.get
cache.get = lambda key, *args, **kwargs: (reads.update([key]), original(key, *args, **kwargs))[1]
with CaptureQueriesContext(connection) as queries:
Client().get("/")
cache.get = original
print(reads["wagtail_site_root_paths"]) # 3, rather than 1
print(sum("wagtailcore_locale" in query["sql"] for query in queries.captured_queries)) # 4, rather than 1Additional information
Two ideas:
- Let
expand_db_attributes_many()resolve the site root paths once and share them with the pages it is about to resolve._get_site_root_paths()already takes acache_object, butget_url_parts()andget_url()only acceptrequest, which doubles as the memo carrier and as the argument toSite.find_for_request(). Threadingcache_objectthrough those two methods would make the sharing expressible without a request; priming_wagtail_cached_site_root_pathson the pages is the same thing done from outside. - Memoize
Locale.get_active(), which is a query on every call. That would help well beyond this code path, and locales change rarely enough to be cached against the active language.
The two get_many() queries per rich text value look inherent to rewriting each value separately, so this issue is about the per-link work.
Can be reproduced
Yes, on a fresh Wagtail project
Technical details
- Python version: 3.11.9
- Django version: 5.2.17
- Wagtail version: 8.0 (first observed on 7.4.2, with Django 5.2.16)
- Browser version: N/A, server-side
Working on this
If you would like to contribute to this issue, follow these steps:
- Confirm that the issue is reproducible, either on a fresh Wagtail project or the bakerydemo.
- Once confirmed, view our contributing guidelines, add a comment to the issue once you're ready to start.
Source: wagtail/wagtail