[6.x] Optimize site loading with hundreds of sites - #14988
Conversation
|
Hey @lazerg, thanks for this! We've noticed that you opened several PRs in a row and while we appreciate this in general, could you please be more elaborate on what the actual changes are in the description? While some of the PRs you've opened are simple, something like this here is more complex. Reviewing PRs can take a lot of time to make sure it actually fixes the underlying issue without introducing new bugs or unforeseen (breaking) changes. Thank you! |
|
Hey @jasonvarga, please check this when you have time. |
|
Sorry, we can't provide an ETA for reviewing/merging this pull request. 😞 It's already on our to-do list and we'll review it when we can. If you need to use this fix in the meantime, consider applying this PR as a composer patch. |
…mance-hundreds-sites
|
Thanks for this! Made a few small follow-up changes on top of your work:
|
Fixes #14670.
This comes from the issue, where someone running around 800 sites profiled the control panel and found three spots that scale badly with site count. None of them show up with a handful of sites. The problem is that each one does work proportional to the total number of sites, on paths that run during normal page loads, so the cost climbs fast once you're in the hundreds or thousands.
Here's what each change does, why the old version was slow, and why the new one is safe to swap in.
1.
Nav::existsIn()no longer loads every site's tree to answer one questionexistsIn($site)wastrees()->has($site). The catch:trees()builds the full set by callingin()for every site and dropping the empty ones. So asking "does this nav exist in site X" quietly ran a repository lookup for every site in the install. It's nowin($site) !== null, which looks up only the site you asked about.in()already returns null when a site has no tree, so the boolean is identical. It just gets there without touching the other sites.2.
Sites::authorized()skips the per-site gate check for super usersauthorized()filters the sites withcan('view', $site), one gate call per site. For a super user the answer is always the whole list, becauseSitePolicy::before()returnstruefor them before the policy method even runs. But the gate still pays its per-call cost for every site to reach that answer. The early return hands back the full collection when the current user is a super user. Everyone else falls through to the same filter as before, so their permissions are evaluated exactly the way they were. This only cuts work in the case where the outcome is already decided.3.
Site::resolveAntlersValue()stops running plain strings through AntlersEvery site config value (name, url, locale, and so on) was passed through
Parse::config()and the Antlers runtime, including values likeen_USor/about/that have no Antlers in them. The guard returns the value untouched when it isn't a string, or when it's a string containing neither{nor@. Those are the only two characters that can start Antlers:{{ }}tags and@directives. A string without either can't be a template, so parsing it is wasted work. Anything that does contain Antlers still goes through the parser unchanged, so templated config values keep resolving as before.Tests
Added
SitesPerformanceTestandNavPerformanceTest. They check behavior, not timing, since wall clock is flaky in CI:existsIn()returns the right answer for a single site and across multiple sitesOne thing worth flagging
The issue also points out that the CP nav menu in
CoreNavchecks each nav with$nav->sites()->contains(Site::selected()->handle()), which runs throughtrees()and so hits the same load-everything problem change #1 fixes insideexistsIn(). Right nowexistsIn()is only called by the nav controller, so the CP menu itself doesn't benefit from change #1 until those two call sites switch to$nav->existsIn(...). I left it out to keep this PR narrow. It's a two-line change though, so happy to add it here if you'd rather keep it in one place, or send it as a small follow-up. Your call.