Problem/Motivation
Inspired by @berdir in #3340999: The path-alias preloading is not used if the "Enforce clean and canonical URLs" option is enabled.
We currently have a path preload cache, added by me and others in #456824: drupal_lookup_path() speedup - cache system paths per page.. It records all the system paths found on a page when the cache is cold, writes them to a per-URL cache object, then when that page is requested again, if a path alias is requested, uses that cache item to preload all the path aliases.
It saves a lot of queries but is less effective in 11.x than it was in 7.x because we now have render caching and dynamic_page_cache, meaning the amount of times we're generating the same path aliases on the same page will be lower (if still possibly more than once every 24 hours). Also it takes up quite a lot of cache space.
In #3340999: The path-alias preloading is not used if the "Enforce clean and canonical URLs" option is enabled @berdir suggested looking at potentially removing it - this would be fine on sites with good render cache hit rates, but sites with lots of content changes (comment posting on the same URL, node list cache tag constantly invalidated) might still do a fair bit of extra queries.
Steps to reproduce
Proposed resolution
When we look up an alias, just before we get to the point where we need to look it up from the database, we can check if we're inside a Fiber and if so suspend. Before doing so, we add the system path to a class property.
Alias lookups happen via Url::toString(), which is called when URLs are printed in twig templates, this means that we need twig rendering to happen inside a Fiber.
This relies on #3437499: Use placeholdering for more blocks which puts more blocks into placeholders and related issues to use placeholdering more widely. This is necessary to have more rendering happen within a single loop we control (e.g. placeholder rendering), although the technique also works when rendering views rows within fibers (see below).
This enables a code flow like the following:
Fiber 1 - hits a path alias lookup, adds to list, suspends.
Fiber 2 - hits a path alias lookup, adds to list, suspends.
Fiber 3 - hits a path alias lookup, adds to list, suspends.
Fiber 1 - resumes, loads all three path aliases, adds them to the static cache.
Fiber 2 - gets the alias from the static cache
Fiber 3 - gets the alias from the static cache.
This doesn't rely on anything async, it's more that we can force parallel execution of path lookups instead of serial, by backing out of one, starting another, backing out etc. then when we get back to the first one, load everything we need into the static cache and save the individual lookups.
This sounds complicated, and conceptually it maybe is, but the actual code is very simple and isolated to AliasManager, it should be an improvement for all requests (cold and hot caches) because we remove a high cardinality/low hit rate cache item and also reduce the number of database queries on cache misses - potentially by dozens on some pages once we start to optimize for this a bit more.
This allows us to completely remove the path alias preload cache (net reduction of ~500 lines of code) while improving cold cache performance.
Remaining tasks
Was previously blocked on #2511330: Deprecate RendererInterface::render()'s sole $is_root_call parameter and #3518179: Renderer::executeInRenderContext() needs to handle nested calls and suspended fibers.
Independent of this issue, but #3518662: Use a lazy builder/placeholder for the top bar and #3518668: Use Fibers for rendering views rows both significantly add to the performance improvements from this issue by allowing more path aliases to be pre/bulk loaded.
User interface changes
Introduced terminology
API changes
The method ::setCacheKey() on AliasManager becomes a no-op and is deprecated for removal in 12.x. This was only used by modules that manipulate path aliases like workspaces (see the changes in there). Otherwise all the old and new logic is internal.
Data model changes
Release notes snippet
| Comment | File | Size | Author |
|---|---|---|---|
| #10 | Screenshot from 2025-01-03 22-15-52.png | 450.01 KB | catch |
| #9 | Screenshot from 2025-01-03 21-07-51.png | 444.27 KB | catch |
| #9 | Screenshot from 2025-01-03 21-07-44.png | 313.74 KB | catch |
| Screenshot from 2024-12-28 17-30-23.png | 242.77 KB | catch | |
| Screenshot from 2024-12-28 17-30-13.png | 192.44 KB | catch |
Issue fork drupal-3496369
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
- 3496369-preload-no-cache
changes, plain diff MR !13252
- 3496369-with-renderer-support
changes, plain diff MR !10792
- with-3518668
changes, plain diff MR !12470
- 3496369-preload-without-cache
changes, plain diff MR !10724
- 3496369-plus-3518179
changes, plain diff MR !12430
- preload-and-root
changes, plain diff MR !11784
- 3496369-with-more-placeholdering
compare
Comments
Comment #2
catchComment #3
catchInstead of doing this in the url generator we can probably do it in AliasManager::getAliasByPath() instead.
Add the system path to a class property ready for preloading, Fiber::suspend(), when we get resumed, look for any paths to multiple/preload that haven't already been, multiple load, continue.
The main question is how many times can we ::suspend() before we resume, but we only need to pick up two path aliases each time to reduce the number of queries by 50% so the odds might be decent. The existing solution has built in staleness where subsequent page requests will individually lookup paths that weren't found when the preload cache was set.
Comment #4
catchPut up a proof of concept here but it doesn't do anything useful on standard yet - almost nothing is rendered in a placeholder and that's the only inside-fiber rendering we do.
So this will need #3437499: Use placeholdering for more blocks - at least a rebase of the MR there and some combined testing, in order to see whether it can be viable.
I did add some views blocks (recent comments, recent content) to the footer along with adding some alias for things like comment/reply, and saw this working (i.e. it would multiple four aliases) - so in theory it has potential just not in practice yet.
Comment #6
catchComment #7
catchAnother attempt at a better issue title.
Comment #9
catchOK this actually works.
Manual testing with https://git.drupalcode.org/issue/drupal-3496369/-/tree/3496369-with-rend...
HEAD:

MR:

With a completely cold cache on an Umami recipe page, there are 38 calls to
Drupal\path_alias\AliasRepository::lookupBySystemPath+ the cache get and set for the preload cache.With the MR, there are 31 calls to
Drupal\path_alias\AliasRepository::preloadPathAliasand no cache get/set.With a warm render cache, there will be zero path alias gets at all because the resultant URLs are in the render cache anyway.
So the worst case scenario is improved, and the best case scenario is the same.
When the path alias cache is warm but the render cache is not (multiple roles viewing the same path for example), then there will probably be more database queries - but what's in the cache will depend on which role visits the page first, so it's not like preloading gets the queries down to one, there's always some stragglers.
Comment #10
catchThen I added #3437499: Use placeholdering for more blocks locally and got down to 14 calls to preloadPathAlias() - this is nearly a 2/3rds reduction in path lookup queries on a cold cache. Because the cache is per-URL it will be a similar reduction across all the first visits to a URL on a site after a cache clear or each time the cache expires if the render cache for that page isn't warm."
Comment #11
catchComment #12
catchComment #13
catchThis issue + #3437499: Use placeholdering for more blocks seems to be enough without #3496835: [PP-2] Render children in fibers, which is good because changing the renderer like that is a bit alarming.
However when testing I ran into the exception that #2511330: Deprecate RendererInterface::render()'s sole $is_root_call parameter is trying to remove, so we will need that issue too to make this all work together.
The actual MR here still has a couple of test failures in path alias tests, but it'll be reviewable by itself without the other two issues - just won't be a performance improvement (or a very marginal one) until those also land.
Not exactly sure what test coverage looks like for this - we can probably set up some Fibers in a kernel test that call getAliasByPath() and check that the aliases are still returned correctly.
#3476421: Add assertions to OpenTelemetryNodePagePerformanceTest::testNodePageCoolCache() would hopefully cover performance testing once it's in, alongside the other two issues.
Comment #15
catchNot entirely proven yet, but I think this might be ::executeInRenderContext() which relies on a (static) stack to track cacheability metadata from code that returns strings, some discussion in #3507959: Refactor the render context stack to avoid a static property.
I think we might need to do something like use a fiber in executeInRenderContext() so that we can 'trap' any fibers underneath - e.g. if they suspend, resume them again, so that any metadata doesn't leak into the stack.
Explicitly postponing this on #3437499: Use placeholdering for more blocks.
Comment #16
catchComment #18
berdirWorked a bit on this to clean up the unit tests so we can see the other tests now that they are blocking. I adjusted some and remove several that were testing the interaction with the cached preload routes.
Two performance tests then failed, one with the is root exception, the other changed how the query looked but otherwise pretty minimal impact. It did drop one query though, not exactly sure what happened to that.
Missed that we we need [#3476421: Add assertions to OpenTelemetryNodePagePerformanceTest::testNodePageCoolCache()] for the scenario where this matters.
Note that #3408713: Add database and cache assertions to OpenTelemetryFrontPagePerformanceTest and OpenTelemetryNodePagePerformanceTest is kind of a duplicate of that one, should clean up those issues a bit to avoid duplicates.
Comment #19
catchThe is root exception is going to be a hard blocker here (and potentially for async queries too since I think it will hit the same thing).
Comment #20
catchPretty sure this (and potentially any other fiber suspension within placeholder rendering) is blocked on #2511330: Deprecate RendererInterface::render()'s sole $is_root_call parameter.
Comment #21
catchIf you combine the changes here with #2511330: Deprecate RendererInterface::render()'s sole $is_root_call parameter you get a new error:
But that gave me an idea on how to workaround it, pushing a new branch to see if it helps with the test failures.
Comment #23
catchMR up on #3518179: Renderer::executeInRenderContext() needs to handle nested calls and suspended fibers now. That makes this PP-2, but pretty sure those two issues are the only hard blockers and we mostly need to adjust test expectations here once they're in. Per #18 #3408713: Add database and cache assertions to OpenTelemetryFrontPagePerformanceTest and OpenTelemetryNodePagePerformanceTest is necessary to see the improvement, although the existence of the test means we can run a combined MR to see the difference even if it's not actually committed yet.
Comment #24
catchWith #3408713: Add database and cache assertions to OpenTelemetryFrontPagePerformanceTest and OpenTelemetryNodePagePerformanceTest there is hardly any visible improvement or regression.
However manually testing with umami + the navigation module enabled, and then truncating cache_render, I do see a couple of path aliases loaded together (but several loaded individually).
If we were to render individual entities in views listings in placeholders (which we theoretically could), I think we'd see a significant saving though.
Comment #25
catchRemoving 'try to' from the issue title because this definitely works.
Right now, with the three issues, it works, but only a couple of path aliases get multiple loaded.
To make it more effective, the following two issues would help a lot:
#3518662: Use a lazy builder/placeholder for the top bar
#3518668: Use Fibers for rendering views rows
The reason those should make a big difference is because the way this works is by picking up the first link it finds when rendering a placeholder, suspending the Fiber, and then doing that for each available placeholder. When it reaches the first placeholder again (e.g. the Fiber is resumed), then it will preload any path aliases picked up from the first iteration through the placeholders and load them at once. #3437499: Use placeholdering for more blocks did similar for several blocks already, but a lot of blocks don't necessarily have unique path aliases to look up.
So if you have five placeholders (at the same level of nesting), and each placeholder has one alias to load, you get five aliases loaded in one loookup.
But if you have a single placeholder with 50 URLs, then it will only multiple load the first one or two, then the rest individually, because it only has one placeholder with that many links in it.
For e.g. Umami views showing lists of recipes or taxonomy terms, if each of those is a placeholder, then we're looking at 10, 20, 50 path alias lookups at once instead of individually, and crucially, this will work on both render cache hits and misses. Also allows us to remove a high-cardinality, low-hit rate cache item at the same time. The entity/navigation placeholder issues make sense in their own right and could land either before or after this one, but it's another case where an improvement in one issue amplifies improvements in another.
Comment #26
catchCross-linking the original issue that added the system lookup cache for multiple loading of path aliases back in 2009 #456824: drupal_lookup_path() speedup - cache system paths per page.. The code has moved around a lot but the logic hasn't really changed, pretty nice to be in a position to remove it with something better, umm, 16 years later.
Comment #27
catchComment #28
catchIn combination with the two blocking issues, and #3518668: Use Fibers for rendering views rows, and also #3408713: Add database and cache assertions to OpenTelemetryFrontPagePerformanceTest and OpenTelemetryNodePagePerformanceTest, it's possible to see a real reduction in queries on cold caches now - about 15 less on the Umami front page and node pages. Combined MR is https://git.drupalcode.org/project/drupal/-/merge_requests/11784
Comment #29
catchComment #30
catchComment #31
catchComment #35
catchCombined MR with #3518179: Renderer::executeInRenderContext() needs to handle nested calls and suspended fibers is green now and removes about 500 lines of code.
To see a larger reduction in the number of database queries on cold caches, we need #3518662: Use a lazy builder/placeholder for the top bar and #3518668: Use Fibers for rendering views rows - but those can happen independently of this issue.
Comment #37
catchWill need a rebase here now the blocker is in.
The current MR only shows a very small reduction in queries in performance tests, this is because the specific URL aliases being rendered for anonymous users in those tests aren't in a good position to be preloaded.
However I opened an extra MR combined with #3518668: Use Fibers for rendering views rows (https://git.drupalcode.org/project/drupal/-/merge_requests/12470) which starts to show the potential on cold caches:
The difference there is that all the URL aliases from all the views rows can be preloaded, so the Umami block listing taxonomy terms is able to get all the aliases for all the taxonomy term URLs at once instead of individually.
Comment #41
catchThis should be ready for review now.
The draft MR at https://git.drupalcode.org/project/drupal/-/merge_requests/12470 is there to show the preloading working a bit better with one additional core issue applied, but shouldn't actually be reviewed.
Comment #43
catchUpdated the issue summary.
Comment #44
catchComment #45
needs-review-queue-bot commentedThe Needs Review Queue Bot tested this issue. It no longer applies to Drupal core. Therefore, this issue status is now "Needs work".
This does not mean that the patch necessarily needs to be re-rolled or the MR rebased. Read the Issue Summary, the issue tags and the latest discussion here to determine what needs to be done.
Consult the Drupal Contributor Guide to find step-by-step guides for working with issues.
Comment #46
oily commentedFix merge conflict.
Comment #47
joachim commented> This doesn't rely on anything async, it's more that we can force parallel execution of path lookups instead of serial, by backing out of one, starting another, backing out etc. then when we get back to the first one, load everything we need into the static cache and save the individual lookups.
This is ingenious, but what if there were another place in code where we wanted to do the same sort of trick? Is it the case that once we're doing this technique here, we can't do it anywhere else?
Comment #48
catchThe answer here is... yes and no.
I've got #1237636: Lazy load multiple entities at a time using fibers open which works almost exactly the same as this, except for the fact that it breaks something horribly in placeholder rendering so does not actually work yet.
If we take the two together, you can see how it might or might not work, it will all depend on exactly what is going on with particular pages.
If you have multiple blocks that load one entity, then render a link to that entity, the process will look something like this:
So in the first scenario, three path aliases are loaded in one round trip, and three entities are loaded in one round trip, whereas prior to this change they'd each have been loaded one by one.
Overall 1/3rd the round trips.
But it could also look like this, in this case block 2 renders one path alias but doesn't load an entity either before or after.
In this case, two entities are loaded in one round trip, three path aliases are loaded in two round trips - 3/5ths the round trips.
So because the paths and entities are out of sequence, it's not able to reduce the round trips as much - but the important thing is that it's still reducing them a bit, and the reduction isn't worse than if we only did path aliases or only did entities.
Also, if a single block/placeholder has multiple links in it, this approach only works when other blocks have the same number of links.
So if three blocks have one link each, three links will be loaded in one round trip. But if one block has three links, it will require three round trips - because the first link has to be loaded before the second.
There are situations where we have structures that are usually within a block, which predictably deal with multiple of the same thing, so we can make it work for those cases too:
#3518668: Use Fibers for rendering views rows would render views rows in fibers (even loop once we have that) and in general views rows are rendering exactly the same type of thing for each row (card view mode with an image and link, or a table row with links etc.)
Local tasks and similar places too where it's always a list of links. If we event-loop those, then we'll get a higher hit rate most of the time.
But also if two blocks have two links each, then those can be loaded in two round trips without any special handling withing the blocks (it works as long as the loop finds the aliases at the same point while it's progressively going through the blocks in the loop).
Overall, it does have limitations - we're never going to be able to load every single path alias on a page in a single round trip using this method, it will load 'batches' of path aliases that are rendered in similar code paths. But also the technique has almost zero overhead (apart from conceptually being quite complex, but the code itself is simple and lightweight), and the worst case is that it doesn't save much in particular scenarios.
Comment #49
catchThis has been rebased since the bot marked it needs work.
Comment #50
needs-review-queue-bot commentedThe Needs Review Queue Bot tested this issue. It no longer applies to Drupal core. Therefore, this issue status is now "Needs work".
This does not mean that the patch necessarily needs to be re-rolled or the MR rebased. Read the Issue Summary, the issue tags and the latest discussion here to determine what needs to be done.
Consult the Drupal Contributor Guide to find step-by-step guides for working with issues.
Comment #51
darvanenAs requested giving this one a review.
I'm new to Threads but the loop makes sense to me from the examples given. If I have understood it correctly then the answer to my question on the MR should be 'none'. If that's the case then I'd say the logic here is solid (and we can remove a couple of unset commands).
My only other concern is I don't think we're testing the non-thread use-case? I know they were introduced in 8.1 and 8.0 is out of support, but what if someone is running a NTS binary or something?
Comment #52
berdir> So if three blocks have one link each, three links will be loaded in one round trip. But if one block has three links, it will require three round trips - because the first link has to be loaded before the second.
For routes, we have \Drupal\Core\Routing\PreloadableRouteProviderInterface::preLoadRoutes(), which is used by the global preloader that I'm trying to change, but we also call it in \Drupal\Core\Routing\RouteProvider::getRoutesByNames(), which used for example by \Drupal\Core\Menu\MenuLinkTree::load to preload all routes for a given menu.
Maybe as a follow-up, we can think of something similar for specific cases where we know that we have multiple links/url objects, we can explicitly request those to be preloaded? We need to make some assumptions and path aliases did move into a technically optional module now but maybe we can think of something, could go through the UrlGenerator and an event?
So just like my route preloader issue, we still have the concept of preloading, but we move from global preloading to per-block/element preloading, which fits better with partial/render caching.
Comment #53
catch#3518668: Use Fibers for rendering views rows is one such case where we can assume it's likely that multiple links/urls are involved. However we don't easily have a way to explicitly request preloading there or not that I can think of.
Another one which doesn't have an issue yet is local tasks/actions.
Those would definitely pick a lot more paths up and be less dependent on what's going on in the rest of the page.
Comment #57
catchThere was a merge commit in the branch history which made things impossible to rebase. I started a new branch, reverted to the last known good commit, then rebased from there - have hidden the other branches for now.
Comment #58
catchBack to needs review. https://git.drupalcode.org/project/drupal/-/merge_requests/13252 is the one to review now.
Comment #59
needs-review-queue-bot commentedThe Needs Review Queue Bot tested this issue. It no longer applies to Drupal core. Therefore, this issue status is now "Needs work".
This does not mean that the patch necessarily needs to be re-rolled or the MR rebased. Read the Issue Summary, the issue tags and the latest discussion here to determine what needs to be done.
Consult the Drupal Contributor Guide to find step-by-step guides for working with issues.
Comment #63
catchBot is understandably confused by the multiple branches. Closed all MRs except the active one.
Comment #64
catchAdded an administrator performance test to Umami because I realised just being logged in as an admin is enough to show this working - it shaves 7 database queries off hitting a node page when all caches are empty. Pretty sure this is because contextual links are rendered via big pipe, and therefore happen inside fibers.
Comment #65
catchTest only job should hopefully show the diff https://git.drupalcode.org/issue/drupal-3496369/-/jobs/6679091
Comment #66
oily commentedTest-only output is:
I do not fully understand it but it looks good to me.
Comment #67
catchThe important bit from those test failures is this:
e.g. this allows us to multiple load 7 path aliases on cold cache (Umami node page) requests, that previously had to be requested individually.
It also allows us to remove the per-page path alias cache - it doesn't work when it's cold, but also it doesn't do anything when render caches are warm because path aliases don't need to be resolved at all when the dynamic page cache / render cache can retrieve cached HTML.
This way we're getting an improvement on cold cache requests, no regression on warm caches, and by removing a per-page cache item we're also reducing overall cache storage requirements. There are some pages in the middle (mixture of warm and cold render caches) where it may result in more queries, but the current system doesn't perfectly deal with those anyway (e.g. if a non-admin warmed the path alias cache, an admin's path aliases have to be retrieved individually anyway) so even in those cases it could end up being neutral or an improvement.
Comment #68
berdirFully agreed with #67, to elaborate on "but also it doesn't do anything when render caches are warm because path aliases don't need to be resolved at all". It's not just that it doesn't do anything, but also that if there is one to look up, it loaded a possibly large amount of aliases that aren't actually needed because those parts are still render-cached.
And, due to #3340999: The path-alias preloading is not used if the "Enforce clean and canonical URLs" option is enabled, the whole feature didn't do work at all on a vast amount of sites for a probably very long time.
Just some minor remarks on the MR, like the deprecated version, then this is RTBC for me.
Comment #69
oily commentedRE: #67 and #68 Thank you for the interesting insight into caching!
Comment #70
catchThe two threads on the MR should be resolved now.
Comment #71
berdirThanks, I think this is ready now. This will conflict with #1237636: Lazy load multiple entities at a time using fibers and we'll need to rebase one of the issues once the first lands as they both change the same performance assertions.
Comment #72
alexpott#1237636: Lazy load multiple entities at a time using fibers has landed so this needs work to resolve the conflicts.
Comment #73
catchIn rebasing, it looks like once this issue is added (e.g. more fiber suspensions) there's an additional undiscovered bug with #1237636: Lazy load multiple entities at a time using fibers although fortunately one that was (I think) introduced by last minute refactoring which made it easy to find and would explain why it didn't surface before when the two issues were combined.
I've committed a fix here and will see if it gets tests back to green, but we might need to do a follow-up or revert and recommit in that issue or something.
Comment #74
catchOpened #3549380: Fiber entity multiple loading tweaks.
Cherry-picked the bugfix over here to try to get back to green in this issue.
Comment #75
catch#3549380: Fiber entity multiple loading tweaks needs to land before this one now, so explicitly marking postponed.
Comment #76
alexpottUnblocked...
Comment #77
catchRebased - only resolved merge conflicts / performance test changes since this was last RTBC, so I think I'm good to re-RTBC.
Comment #78
catchDiscussed the removal of ::writeCache() with @alexpott and he suggested leaving it as a no-op with a deprecation just in case someone somewhere is calling it.
Comment #79
alexpottCommitted 4ba82cd and pushed to 11.x. Thanks!
Comment #82
catch