Problem/Motivation
Issue split from #2899392: user_hook_toolbar() makes all pages uncacheable.
The shortcut implementation of hook_toolbar has a cache context per user in the global section that can not be placeholdered. This causes the Dynamic page cache to stop working, since the cardinality is too high.
Proposed resolution
Implement a lazy builder to add the shortcuts using placeholdering. Doing required some non-obvious (test) changes:
-
+++ b/core/modules/shortcut/shortcut.module @@ -324,6 +325,11 @@ function shortcut_preprocess_page_title(&$variables) { $shortcut_set = shortcut_current_displayed_set(); + // Add a list cache tag for shortcuts. + $cacheability_metadata = CacheableMetadata::createFromRenderArray($variables); + $cacheability_metadata->addCacheTags(\Drupal::entityTypeManager()->getDefinition('shortcut')->getListCacheTags()); + $cacheability_metadata->applyTo($variables); +Shortcut adds a link to the page title to add/remove a shortcut. That depends on the current list of shortcuts, adding or removing one could change this. So this now adds the shortcut_list cache tag. that means changing a shortcut will invalidate all those pages. But there are a few factors that IMHO make this a minor issue at best and doesn't need further optimizations:
* This only happens for users with administer shortcut permissions
* It only happens in themes that have this enabled, typically only admin themes
* Admin pages don't use dynamic page cache anyway, so it's only the page title block that's invalidated
* Changing shortcuts is a pretty rare operation on a live site. -
+++ b/core/modules/shortcut/tests/src/Functional/ShortcutLinksTest.php @@ -355,9 +355,9 @@ public function testAccessShortcutsPermission() { // Verify that users without the 'administer site configuration' permission - // can't see the cron shortcuts. + // can't see the cron shortcuts but can see shortcuts. $this->drupalLogin($this->drupalCreateUser(['access toolbar', 'access shortcuts'])); - $this->assertNoLink('Shortcuts', 'Shortcut link not found on page.'); + $this->assertLink('Shortcuts'); $this->assertNoLink('Cron', 'Cron shortcut link not found on page.');This is a small regression that can't be avoided.
Until now, if a user has a shortcut set but no access to any of the shortcuts inside, we didn't display an empty "Shortcuts" item. Now we do, because we don't know yet if there will be links or not.. they will be added only later on.
This is similar to #2135445: Toolbar displays Manage tab even if the user is not permitted to see it, which has been a known issue for a long time.
-
+++ b/core/modules/toolbar/tests/src/Functional/ToolbarCacheContextsTest.php @@ -98,11 +98,6 @@ public function testToolbarCacheContextsCaller() { $this->assertToolbarCacheContexts(['user.permissions'], 'Expected cache contexts found with tour module enabled.'); \Drupal::service('module_installer')->uninstall(['tour']); - - // Test with shortcut module enabled. - $this->installExtraModules(['shortcut']); - $this->adminUser2 = $this->drupalCreateUser(array_merge($this->perms, ['access shortcuts', 'administer shortcuts'])); - $this->assertToolbarCacheContexts(['user'], 'Expected cache contexts found with shortcut module enabled.');And finally, we remove this existing, pretty useless test coverage. Even though it is now a lazy builder, the final cache contexts still include user, as it includes the output of the lazy builder. So testing this doesn't help but it becomes complicated as the two users now have different contexts. Earlier patches did a lot of refactoring in this test instead.
The new test coverage covers this and much more.
See also #43 and #46 for more details.
Remaining tasks
User interface changes
None.
API changes
No API changes. There's a *behaviour* change as pages for users with access to the shortcuts now become cacheable in DPC. That means it is possible that custom/contrib code that incorrectly adds cache contexts suddenly becomes cached incorrectly. However, we already did such a change to the username that's displayed in the toolbar, so this already became an issue for sites without shortcuts in an earlier core version. Also, this usually only applies to things that aren't getting render-cached on their own, so logic outside of blocks/entities/views/...
Data model changes
None.
Release notes snippet
The shortcut module disabled the cache for Dynamic Page Cache for users with access to shortcuts. These pages will now be cached which should result in a performance improvement. In rare cases, cache coherency bugs which were previously hidden by the disabled cache may now be surfaced.
| Comment | File | Size | Author |
|---|---|---|---|
| #63 | 2914110-56.patch | 17.79 KB | berdir |
| #63 | 2914110-63-tests-only.patch | 7.23 KB | berdir |
| #56 | interdiff-2914110-53-56.txt | 684 bytes | acbramley |
| #56 | 2914110-56.patch | 17.79 KB | acbramley |
| #53 | 2914110-53-interdiff.txt | 7.49 KB | berdir |
Comments
Comment #3
wim leers#2899392: user_hook_toolbar() makes all pages uncacheable landed, now let's do this one!
Comment #4
berdirI think the only reason this depends on the user is because users have a setting for the shortcut set?
What about adding a cache context for the shortcut set (user.shortcut_set) and allow to cache shortcuts by that instead not caching it at all?
The thing is that shortcuts are actually quite slow slow to render, due to how we are accessing and rendering them (we create urls and go through the whole access/validation process them, which involves loading routes, executing access callbacks and so on). And unless other parts like breadcrumbs and local tasks, they do *not* vary by url and most pages only have a very small set of shortcut sets, so the cache hit ratio should be pretty good?
Comment #5
wim leersRight — quoting from
shortcut_toolbar():I think such a cache context is a good idea for the reasons you provide. The root cause though is #2339903: Shortcut module's access checking is insane. But fixing that is going to be VERY hard, simply because we absolutely cannot change existing behavior. The cache context you propose is the pragmatic way forward.
Comment #6
wim leersThis is effectively a big performance blocker. With this, D8 becomes significantly faster for many use cases.
Comment #7
berdirYeah, thinking about it a bit more, the problem is that with stickyness of cache contexts, the result would be that we'd have this cache context the on every page for all users once one admin user visited a given page. Maybe it could be a combination of forced lazy loader + separate cache, so we don't add the cache context to the page. But it's probably also not a big deal to have it, should be relatively fast to get that info.
Comment #8
fabianx commentedI thought so, too, but it turns out that we had been more clever in the past ;) ...
Because we have user.permissions as required cache context and a normal anonymous user does not have permission to 'access shortcut links', this would only affect admin users, which actually see the toolbar.
The $pre_bubbling_cid actually acts like a cache splitter after which we then do the cache_redirect logic - if needed.
And for those it is perfectly fine to have the cache context always in there.
Overall lazy building + caching (even if per user) is optimal.
The biggest problem is the empty condition as we need a cache context to determine whether to show the block in the first place or not.
Comment #9
wim leersWOW! @Fabianx is in the house! 🐦 Would be lovely to have this in, then Dynamic Page Cache and BigPipe can finally live up to their full potential, at least in Vanilla Drupal :) ✋
Comment #10
davidwhthomas commentedJust want to say nice to see these updates! Also to note, the POC patch in #8 fixes the issue from here - allowing dynamic page cache with the shortcut module enabled. Getting great performance with it. What else needs doing for this one?
Comment #12
andypostComment #13
kim.pepperThis should fix one of the tests.
Comment #15
davidwhthomas commentedThanks for the updates in this area.
I just want to note, I found with the lazyloaded shortcuts toolbar, sometimes the toolbar would overlap and obscure the page content header.
It seemed an issue with calculated the body offset or height in JS there.
I had to add some JS like this to ensure the shortcut toolbar height:
Perhaps there's some way to trigger the toolbar offset calc, attach the behaviour or similar in the patch here.
Otherwise is good! with thanks.
Comment #16
akalam commented#13 is working for us on d8.5.
Comment #17
wim leers#15:
This is exactly what I warned for in #2542050: Toolbar implementation creates super annoying re-rendering.. It's still good that that landed to improve the situation for most, but we'll now have to come up with a better solution. That's not in scope for this issue though! Fortunately!
Comment #18
arpad.rozsa commentedThe problem here was that, since the test user has the permission 'access shortcuts', the shortcuts link is visible. So I removed the part which asserts that the link doesn't exist.
Second fail:
The cache contexts for the shortcuts was changed to user.permissions, but we have another cache context for the tray, which I changed to 'user.permissions' also. We had a 'user.permissions' and a 'user' and as drupal optimizes cache contexts, it would use just the 'user' for the page since it stands higher in the hierarchy.
We could maybe even omit the cache context for the tray, since it adds the same, but I'm not sure about that.
Comment #19
arpad.rozsa commentedForgot to upload the interdiff.
Comment #21
berdirthis is not correct.
shortcut links *are* per-user and they must have this cache context.
I would have to investigate why it affects the test the way it does, but this change is not the correct fix.
kinda similar her, this seems to be a shortcut-specific access check, each shortcut needs a dedicated access check.
If this is broken then we might have a bug in the implementation here.
Comment #23
johnchqueSo debugging this it seems that one test failure was caused by the fact that even if there are no links to render in a set, the Shortcut set link is still displayed.
In this case, the Shortcuts link is still displayed even if there are no shortcuts inside. Is this an acceptable case?
Comment #25
wim leers@Berdir in Slack 12 hours ago:
It is indeed a common consequence of lazy building: we don't know ahead of time whether the user will have any shortcuts to see. I think for the Shortcut case this is super exceptional though, since the 99% case is going to have site-provided shortcuts per user role.
Plus, the same is already true for the "Manage" toolbar tab: a authenticated user in the Standard install profile who gets the
access toolbarpermission also sees a "Manage" toolbar tab without a tray. Same problem.Which matches what @yongt9412 wrote in #23:
I think the answer is "yes".
Notes:
Comment #26
wim leersPer #25, I think we can remove this condition.
Comment #27
wim leersFor
@Berdir pointed me to #2135445: Toolbar displays Manage tab even if the user is not permitted to see it, which I'd forgotten about.
Comment #29
johnchqueI might be mistaken but this should fix one fail. As we check for the permission
administer shortcuts permissionto add theusercontext, both users, where one has that permission and the other doesn't will have different contexts.And thank you @Wim Leers for the previous patch! :)
Comment #31
berdirHad a look at the last test fail and it confused me for a while but turns out, it is *beautiful*. What's was happening is that the page got cached in dynamic page cache, as we are testing on a node and not in the backend. So it worked at first, but adding a shortcut didn't invalidate the render cache and then it didn't show the link to remove it.
I added the shortcut list cache tag and then it worked.
Next steps:
* Document shortcut_toolbar_lazybuilder_links()
* Explicit test coverage for the dynamic page cache, I'd suggest we extend \Drupal\Tests\toolbar\Functional\ToolbarCacheContextsTest::testCacheIntegration(), enable the shortcut module and users with the necessary permission and make sure it still is a cache hit, at least for users without administer shortcuts. That said, the extra test method is a bit strange as dynamic_page_cache is enabled by default, this must be an old test that was created before that was done. Maybe we can speed it up a little bit by creating just one single test method for those two things?
* Also, the changes to that test confused me a bit. The problem is that what the test is doing is asserting cache contexts of the whole page, in a non-JS scenario. That means dynamic page cache runs, caches it, but then runs the lazy builders and the final result correctly contains their cacheability metadata, which means the returned page headers do still contain the user cache context. That simply isn't that useful. At minimum we need some better comments in the test and assertion method to explain what we are testing and then as mentioned before, we need to test for the X-Drupal-Dynamic-Cache responses too, to make sure the page is cacheable.
Comment #34
jibranThis seems wired adding the cacheable metadata in preprocess.
Comment #35
neclimdulI took a stab at the next steps from #31.
1) Documented lazy builder and I converted it to a trusted callback service because we shouldn't be adding code using deprecated interfaces :)
2) Rewrote the tests to explicitly check cache-ability. Stole the test cases from the ShortcutLinksTest and the verification logic from CommentCacheTagsTest and the base page cache class used for EntityCacheTagTestBase tests.
3) Not sure about this one but I think the new test covers things and is more clear?
re #34, yeah it kinda feels ugly but the preprocess adds a link that varies based on a cache tag so its correct right? I don't know but I think its fine.
Finally... this is going to fail. Same failure as #31. I dug in to this and its kinda frustrating and complicated and related to that page_title preprocess cache data. I'll follow up with another comment breaking it down and see if someone has a good solution but tldr it looks like a false positive and everything is working correctly.
Comment #36
neclimdulI started writing everything up but it became apparent this is a pretty notable shortcoming in
EntityCacheTagsTestBase. Basically, if you modify any page cache tags, you can't test entity cache tags. Really limiting for modules and unneeded complexity for the test case.Opened #3090949: EntityCacheTagsTestBase doesn't allow a module to interact with page tags and test entity cache tags at the same time with all the details... I guess this is probably blocked on coming up with a solution to that?
Comment #38
neclimdulOh man, i really didn't read that toolbar test right. Sort of half understood Berdir's review and saw what I expected I guess. I added the toolbar test back the way it was for now and I changed it so there's less code reuse though.
Comment #39
neclimdulComment #41
andypostAs toolbar displays only one set of shortcuts then there should not be "_list" cache tag there, so test expectation is ok
Comment #42
neclimdulso this is getting the wrong tag? It seems right, but maybe not. @berdir added it in #31 "I added the shortcut list cache tag and then it worked."
I still think EntityCacheTagsTestBase is kinda unworkable because any tag we add could break it. For example if we decide"config:shortcut.set.default" was the correct tag to add for the title link it would "fix" the test as its written now but only by accident because if we ran the same test referencing a different shortcut the tags wouldn't match again.
Comment #43
berdir> 1) Documented lazy builder and I converted it to a trusted callback service because we shouldn't be adding code using deprecated interfaces :)
This code was written long before certain security issues happened that resulted in callback hardening being added to core ;) Sure, now we have to do that, but it wasn't exactly possible back then (would have been possible to do it as a service..)
> As toolbar displays only one set of shortcuts then there should not be "_list" cache tag there, so test expectation is ok
This cache tag isn't about the toolbar, it's about the title block where shortcut adds the "Add to shortcuts link". And that link needs to check if a shortcut already exists or not. And the problem is that the shortcut set then again varies on the user, so that would also result in making it either a lazy builder itself or vary by user, which would be worse than the list cache tag. This is only added in admin themes unless you enable that feature for your frontend theme, so I can live with that.
Honestly, \Drupal\Tests\shortcut\Functional\ShortcutCacheTagsTest is just weird. Wim likes doing generic tests and applying them equally to each entity type and we didn't really have something better back then. But not all entity types are the same. Referencing shortcuts and rendering them is just not a thing. Why should we test that. We add specific cache coverage here for actually relevant use cases, so what I did now is just make it extend from PageCacheTagsTestBase and instead extend the test coverage to also cover additional new shortcuts and changes. And to make it up to Wim for removing test coverage he added, I added some Llamas :-)
Update: I suppose we could in theory also test multiple shortcut sets, but since it now *doesn't actually invalidate* the dynamic page cache, we also can't really verify that adding a shortcut to another set does not result in an invalidation.
Comment #44
berdirThat drupalGet() was just for debugging something.
> This is only added in admin themes unless you enable that feature for your frontend theme, so I can live with that.
Also, it's only added for users that have permission to change shortcuts.
Comment #45
amateescu commentedThis looks great to me! I just found a few nitpicks:
Missing class doxygen and an empty line after the class declaration.
inheritdoc :)
This method doesn't return anything.
Comment #46
berdirThanks for the review.
I did a bit more cleanup too.
* Testing cache contexts on the page when working with lazy builders is pretty meaningless, as contexts from lazy-built content is still included, at least without big_pipe. But ToolbarCacheContextsTest was doing just that. But that suddenly doesn't make much sense anymore and required a a lot of changes to that test. So instead I just extended the test coverage in ShortcutCacheTagsTest further and removed the shortcut tests from the toolbar test.
* I added some cache context asserts to ShortCacheTagsTest but the main thing is that I'm now additionally testing with two different users with the same set of permissions and making sure that the second user gets a cache hit on the first request. That was already kind of implied, because the whole point of this issue is that the page doesn't get cached at all when there is a non-placeholdered user cache context, but this makes it more explicit.
* @neclimdul added \Drupal\Tests\system\Functional\Cache\PageCacheTagsTestBase::verifyDynamicPageCache(), which is useful, but the support for $tags was never used by this new patch and wouldn't have worked. DPC is much more complicated in how it builds its cache key, which includes cache contexts, and there are cache redirects involved and what not. So I dropped support for that, which means we don't need the new helper method.
Edit: I included the interdiff but it's probably easier to just look at the actual patch as it's almost as big and confusing.
Comment #47
jibranYay!!! green patch. Nice work!
First of all hacking the page title in preprocess is not ideal and this problem is not generated here so leaving #34 alone. It seems fine to have this here given we are 'title_suffix' in this function.
This change makes total sense that overall shortcuts toolbar item showing depends on user permission and actual tray contents changes based on the actual user so +1 for the change. Let's update the IS to explain the UI change.
This is RTBC for me. Let's wait for @Wim Leers's review.
Comment #48
longwaveTypo in "cacheability"
Comment #49
acbramley commentedFixes #48
Comment #50
acbramley commentedComment #51
amateescu commentedLooks perfect now :)
Comment #52
berdirI think we can to a little more perfecter ;) No longer need the setUp() in the test and createEntity doesn't work as an @inheritdoc anymore, so I've inlined it into the one place that still used it.
I've also updated the issue summary and commented on the "interesting" changes there again.
Comment #53
berdirSo something about my comments in the issue summary still didn't feel quite right. So I checked again and yes, my arguments on why the new list cache tag in page_title is acceptable was based on assumptions, not facts. So as it turns out, shortcut.module *only* checks edit access, then it tries to find a matching shortcut, loads the whole set and only *then* figures out that it actually doesn't have to do anything. That's why we've seen the existing entity cache tags fail, as it has been added there already.
So, I've fixed that too, added explicit test coverage for when the cache tag should be present (and when not!), and even though I still think it doesn't make sense to test shortcut cashing as a regular entity, I've restored that test coverage so we don't remove anything from HEAD anymore.
This wasn't a big performance hit at least so far, as we still display these shortcuts in the toolbar, so we have to load them anyway. But now we only need to load them in the lazy builder, so combined with big_pipe, it only needs to happen after the initial response has been sent. And the loadByProperties() call itself is uncached, so it should save at least one query I think.
The interdiff might not be very useful as it reverts a good chunk of the test changes.
Comment #54
berdirComment #55
acbramley commentedI think this deserves a bit more of a description about why we're adding this list tag considering the discussion above ;)
Other than that, this looks like it should be good to go back to RTBC!
Comment #56
acbramley commentedHere's a patch for #55 with a bit of rewording.
Comment #57
kualeeReviewing this now @acbramley @DS
Comment #58
pameeela commentedHave tested this manually on a fresh install with Shortcuts enabled and confirmed:
Will wait for tests before marking RTBC.
Comment #59
larowlanReviewed the code at Drupal South 2019, looks good to me RTBC +1
Comment #60
kualeeI queued the patch for retest and it failed again on D8.7, not sure why
But I can apply the patch manually, no issue on my fresh install local env.
All looks good
RTBC+1
Comment #61
pameeela commentedI added 8.7 tests by mistake, this patch does not need to work on 8.7 so all good.
Comment #62
xjmCan we get a test-only patch for this to prove the coverage?
Comment #63
berdirSure thing. Also re-uploading the complete patch from #56, so it doesn't get set back to needs work. Note that it will fail early on the cache tag test coverage that was added for the related fix/improvement to the page title, not on the main thing that we're fixing here, but that would definitely fail as well.
Great that this is RTBC, I was hoping that Wim finds time to look at it, but he's drowned in migrate issues and other things at the moment.
The main thing where I would have liked his feedback has been improved since then, so I think it's not that important anymore and this has been reviewed and tested a lot.
Edit: For the record, I think backporting this to 8.8 is somewhat problematic, for the same reason that #2985253: content_moderation_entity_access() wrongly adds uncacheable dependencies wasn't, improving cacheabilty in core could expose missing cacheability in custom/contrib code and result in things being cached incorrectly. But I guess if anything then before 8.8.0 than in a patch release.
Comment #66
wim leers#43:
I agree with this conclusion. And I have to point out that even varying by user would be inadequate — see #2339903: Shortcut module's access checking is insane.
EDIT: oh hah, in #53 you also noticed this!
I agree with you :)
#46:
Perfect! 🥳
#63:
I agree — #53 made this patch much better :)
There are really only two changes in here:
theme_get_settings(…)if-condition is pulled up to the top-level if-statement — this causes the majority of the change hunks here, but most of them are just indentation changes$variables👍
👍 The "shortcuts" toolbar tab now appears for all users that have the appropriate permission.
This is what ensures Dynamic Page Cache can now actually cache the partial HTML response (with placeholders not yet rendered/lazy builders not yet executed). This is the performance problem this issue/patch fixes! 🥳
👍 Instead, the
user-varying rendered results are now guaranteed to be placeholdered and hence lazily built. Which is what allows Dynamic Page Cache to be effective even when the Shortcut module is installed.👍 But, thanks to the awesome work in this issue, we avoid the need for running the lazy builder on every request: it is cached per user.
Confirming RTBC 🥳
Comment #67
alexpottCommitted and pushed b941c7c4c3 to 9.0.x and 00ba9683c4 to 8.9.x. Thanks!
Going to ask release managers about committing this to 8.8.x. Tagged with 8.9.0 release notes as a notable bugfix if we choose to not release it in an 8.8.x bugfix release.
Comment #70
acbramley commentedThanks @alexpott!!
Comment #71
wim leers🥳🥳🥳🥳
Thanks to everyone who helped make this happen — finally we'll be able to see the full potential of Dynamic Page Cache & BigPipe, finally users with both
toolbarandshortcutenabled will also see the benefits!Comment #72
alexpottDiscussed with @catch - we decided to leave this as 8.9.x only as the patch adds a new service - which requires a container rebuild.
Comment #73
jibranNext up #2949457: Enhance Toolbar's subtree caching so that menu links with CSRF token do not need one subtree cache item per session which is waiting for review.
Comment #75
xjm"Noteworthy bug fix" does not describe important update information for a potentially disruptive change.
That said, we typically list the minor-only resolved criticals in the release notes, so I've added it to that list since the 8.9 release notes don't include much else aside from the fixed criticals. In general we should not do this though because for anything other than 8.9 there's too much copy and too many major issues to list even a subset of them.
Comment #76
berdir@xjm: This one might be worth mentioning. Fixing this bug has an "interesting" side effect. It can result in caching pages now with dynamic page cache that previously weren't cacheable for users that had had access to shortcuts. As a result, it is possible that suddenly, content might be cached that does not have correct cacheability metadata. Unikely to be a serious problem, as everything still varies by user role at least, but if sites have custom routes with per-user or query-argument content, then it could still result in problems for them.
Comment #77
phenaproximaI think we need to improve the release note here with a bit more information. :)
Comment #78
xjmAh yes, a release note based on #76 would be good. What it was before -- what changed -- who's affected -- what they might need to do as a result.
Comment #79
catchTried updating the release note.
Comment #80
catchComment #82
pratikshad commentedWe are still facing this issue even after trying all the patch from this thread, please suggest
Comment #83
andypost@PratikshaD most probably the cause is admin toolbar contrib module
Comment #84
pratikshad commentedthanks for the quick reply @andypost, we will check this by disabling that module
Comment #85
joseph.olstad#3131589: [meta] Investigate performance regression in Drupal 8.9.x and 9.x and 10.x