Problem/Motivation

Improve performance of menu tree rendering.
+
Correctly cache menu tree rendering.

  1. See profiling in #144 for indications what the impact of an improved menu tree rendering (one that leverages render caching) is. (Also see #97 for the current problematic performance.)
  2. In current D8 profiling, menu rendering consistently shows up as one of the biggest problems.
  3. Finally, #2158003: Remove Block Cache API in favor of blocks returning #cache with cache tags enabled render caching of menu blocks, but wrongly assumed that all menu links can be cached per role. Current HEAD is hence broken in that regard (steps to reproduce: create two non-admin users of the same role, give them the "view own unpublished content" permission, let create user A an unpublished node, put that in a menu that's in a menu block, look at that menu block with user B and you'll see the link to the unpublished node).

Proposed resolution

A number of proposed resolutions were discussed early on in the process, but then the issue became silent (probably due to the complexity).

Over the past few months year, lots of performance improvements and groundwork went in, that make this patch much more feasible. Notable changes include:

  1. #2287071: Add cacheability metadata to access checks — without this, we'd not be able to know which variations of a menu tree to render.
  2. #2479767: Fix MenuLinkInterface::isCacheable(): remove it in favor of implementing CacheableDependencyInterface — without this, we'd not be able to know which variations of a menu link to render.
  3. #2335661: Outbound path & route processors must specify cacheability metadata — without this, we'd not be able to know which variations of a URL to render.
  4. #2429257: Bubble cache contexts — without this, this solution would have to be much more complex; much more in line with what the pre-#144 patches did.

The changes to menu tree rendering

This is the stuff that this issue is all about.

  1. DefaultMenuLinkTreeManipulators::checkAccess() now sets an AccessResult object (which implements both AccessResultInterface and CacheableDependencyInterface). This ensures that the final menu tree contains all the necessary cacheability metadata during rendering.
  2. MenuLinkTree::build() now tracks the cacheability metadata of both access results and the links it renders, and then applies that cacheability metadata to the the render array it returns. The cacheability of rendered URLs is taken care of in menu.html.twig, because MenuLinkTree::build() does not actually render URLs, the Twig template does. MenuLinkTree::build() just figures out *which* links to render.
    (This means that any code that was transforming a menu link tree, and included an access-checking menu link tree manipulator, and manually rendered (i.e. not using MenuLinkTree::build()) the end result, must now perform access checking in that manual rendering.)
  3. SystemMenuBlock::getCacheContexts() now no longer hardcodes certain cache contexts, which was done to enable caching of menu blocks, despite that being very much flawed (it just assumed that caching per role was sufficient, which it clearly is not).

Combined with the notable changes listed above, particularly the cache context bubbling, that yields a correct end result: the solution is actually very simple now that we have bubbling of cache contexts! It's just a matter of gathering the cacheability metadata of the things a menu tree render array depends upon, and setting it on the render array.

Note that any menu links that contain a CSRF token get max-age = 0, which means that the entire menu will not be render cached in those cases. That's already tested by MenuLinkContentCacheabilityBubblingTest and was already fixed in #2335661: Outbound path & route processors must specify cacheability metadata. This means that those menus containing such superdynamic links will not be cached, which is a fine trade-off.

Full unit test coverage in \Drupal\Tests\system\Unit\Menu\MenuLinkTreeTest. See #170 for why this test coverage should be enough.

Other changes

  1. Changed MenuParentFormSelectorInterface::getParentSelectOptions(), so that that too is able to gather the necessary cacheability metadata. It's not strictly necessary for this issue, but it is within scope since it touches upon the same areas — we do want the forms using the menu parent form selector to be cacheable as well. Hence these changes. Disruption is approximately zero, since this is very rarely used.
  2. Updated all code that either rendered menus in different shapes or showed a menu parent form selector to have the proper cacheability metadata, plus updated the test coverage.
  3. Added Element::isEmpty() — pure API addition, zero disruption.
  4. Lots of test coverage updates/additions.

Follow-ups

Follow-ups that are blocked on this and will further improve cacheability/performance:

  1. #2254865: toolbar_pre_render() runs on every page and is responsible for ~15ms/17000 function calls
  2. #2483181: Make book navigation block cacheable
  3. #2483183: Make breadcrumb block cacheable

Remaining tasks

None.

User interface changes

None.

API changes

  1. API break: MenuLinkTreeElement::access must now be NULL or an AccessResultInterface object, it used to be either NULL or a boolean.
  2. API addition: MenuParentFormSelectorInterface::getParentSelectOptions() has gained a new optional parameter.
  3. API addition: Element::isEmpty()

Beta phase evaluation

Reference: https://www.drupal.org/core/beta-changes
Issue category Task because lack of menu render caching is not a bug, but the poor performance of menu rendering is very important to solve.
Issue priority Critical because this has a big performance & security impact.
Prioritized changes The main goal of this issue is performance
Disruption Minimally disruptive for core/contributed and custom modules, because it breaks BC for code that is directly transforming (manipulating and/or rendering menu trees. Read the CR for details.

First attempt by Wim Leers

Problem/Motivation

Improve performance of menu tree rendering.

  1. See profiling in #97 for indications what the impact of an improved menu tree rendering (one that leverages render caching) can be, which simultaneously clearly shows the current problematic performance.
  2. In current D8 profiling, menu rendering consistently shows up as one of the biggest problems.
  3. Finally, #2158003: Remove Block Cache API in favor of blocks returning #cache with cache tags enabled render caching of menu blocks, but wrongly assumed that all menu links can be cached per role. Current HEAD is hence broken in that regard (steps to reproduce: create two non-admin users of the same role, give them the "view own unpublished content" permission, let create user A an unpublished node, put that in a menu that's in a menu block, look at that menu block with user B and you'll see the link to the unpublished node).

Proposed resolution

A number of proposed resolutions were discussed early on in the process, but then the issue became silent (probably due to the complexity).

Over the past few months, lots of performance improvements and groundwork went in, that make this patch much more feasible.

The current proposed approach is in #96, initial patch in #97. This approach was proposed by Wim Leers, with a crucial part being thought up together with catch. It's now actively being worked on towards a more reviewable whole, after initial approval from msonnabaum.

The high-level explanation of what this issue does can be explained by reading the docblocks for the refactored MenuTreeInterface and the newly added CacheableAccessCheckMenuTreeManipulator (see below).
The former had to be refactored to allow the "menu tree" abstraction to be used everywhere in Drupal core, including for selecting a parent menu item and the menu administration UI.
The latter contains all the logic to analyze the access cacheability of menu trees and to efficiently (optimally) apply uncacheable access checks.

I've also added a new section to the Menu API documentation in menu.inc, for the very high-level explanation:

 * @section Rendering menus
 * Once you have created menus (that contain menu links), you want to render
 * them. Drupal provides a block (Drupal\system\Plugin\Block\SystemMenuBlock) to
 * do so.
 *
 * However, perhaps you have more advanced needs and you're not satisfied with
 * what the menu blocks offer you. If that's the case, you'll want to:
 * - Instantiate \Drupal\menu_link\MenuTreeParameters, and set its values to
 *   match your needs.
 * - Potentially write a custom menu tree manipulator, see
 *   \Drupal\menu_link\DefaultMenuTreeManipulators for examples.
 * - Call \Drupal\menu_link\MenuTree::build() with your menu tree parameters,
 *   this will return a menu tree.
 * - Pass the menu tree to \Drupal\menu_link\MenuTree::render(), this will
 *   return a renderable array.

Then, still at a high level, but with considerable more detail

/**
 * Defines an interface for building and rendering trees of menu links.
 *
 * The main goal of this service is to, given a menu name, build (::build()) the
 * corresponding tree of menu links.
 * Building the menu consists of two phases. First: loading the menu links in
 * the given menu. Second: transforming this list of menu links into a tree (by
 * looking at their tree metadata) and applying tree manipulators. Tree
 * manipulators can perform simple tasks like translating the title of all menu
 * links in a tree, or more complex tasks like access checking (which will
 * remove inaccessible menu links), or even very complex tasks like extracting a
 * subtree from the tree according depending on the active trail.
 * Which links are loaded and which tree manipulators are applied can be
 * specified in the MenuTreeParameters you pass to ::build().
 *
 * If desired, that tree of menu links can then be rendered (::render()).
 *
 *
 * @section rendered_menu_tree_cacheability Rendered menu tree cacheability
 *
 * Unfortunately, because Drupal only wants to show menu links to a user that
 * are accessible for that user, we have a performance problem. A menu consists
 * of menu links arranged in a tree. Each menu link points to a route (or an
 * external URL). Each route can have any number of access checks defined. And,
 * finally, those access checks can be of arbitrary complexity, and therefore of
 * arbitrary cacheability. Some may be globally cacheable, others per role, yet
 * others per user, per page, per language, and whatnot. Some may not even be
 * cacheable at all. Caching per user, for example, is usually not an option:
 * the cache hit ratio would be too low on a typical Drupal site.
 *
 * Access checking is performed by menu tree manipulators. The simplest possible
 * manipulator will just apply access checks for the given user, and end up with
 * the correct result. Unfortunately, the resulting tree will only be cacheable
 * per user. To allow for more cacheable access checking, ::render() allows each
 * link to have a 'needs_dynamic_access_check' property. Those menu links will
 * be rendered into render cache placeholders. See ::render() for details.
 *
 * @see \Drupal\menu_link\DefaultMenuTreeManipulators
 * @see \Drupal\menu_link\CacheableAccessCheckMenuTreeManipulator
 */
interface MenuTreeInterface {}
/**
 * Provides a very advanced tree manipulator for optimal menu render caching.
 *
 * By default, this menu tree manipulator is configured to cache menu links
 * access (::setAccessCachingContexts()) per user role.
 *
 * We can classify access checks as non-dynamic (hence cacheable, per role by
 * default) or dynamic (needing to run on every page load, hence non-cacheable,
 * because not cacheable per role — again, by default). This service exploits
 * that property.
 *
 * We can then analyze the cacheability of the access checks in a given menu
 * tree (::analyzeAccessCacheability()), and if there aren't any menu links with
 * dynamic access checks, then the rendered menu is fully cacheable.
 * In the other case, we calculate and cache the dynamic access check results
 * per user (::getAccountDynamicAccess()). Those cached results are tagged with
 * the @link cache cache tags @endlink associated with those dynamic access
 * checks, to ensure that when the (cached) result might change, we run the
 * dynamic access checks again.
 * Now that we have deep insight in the cacheability of a given menu, the entire
 * menu tree can be render cached.
 *
 * Using this access checking menu tree manipulator instead of the simpler yet
 * uncacheable \Drupal\menu_link\DefaultMenuTreeManipulators::checkAccess(),
 * the entire menu tree can be rendered in a way that performs well
 * (\Drupal\menu_link\MenuTreeInterface::render()). Those menu links that have
 * >=1 dynamic access checks are rendered as render cache placeholders.
 * Then, on a page load with a warm cache, the rendered menu can be retrieved
 * from the render cache, the dynamic access check results for the current user
 * are retrieved from the cache once, and a #post_render_cache callback gets
 * applied (\Drupal\menu_link\MenuTreeInterface::renderItemPlaceholder()) to
 * each placeholder. That callback then has all the necessary information to
 * decide whether to render, and to do the rendering itself, so it can occur
 * very efficiently.
 */
class CacheableAccessCheckMenuTreeManipulator {}

Remaining tasks

Original report by effulgentsia

#1137920: Fix toolbar on small screen sizes and redesign toolbar for desktop is a critical feature: otherwise, D8 ships with a terrible mobile experience. Therefore, making this issue a critical task.

One of the aspects of the current design in that issue is to from any page, be able to drill down to any admin link with purely client-side logic. In this way, it shares a resemblance to Admin menu.

Since rendering (whether all the way to HTML, or to a JSON tree containing access filtered and argument resolved URLs) an entire menu tree is expensive, this requires caching. Admin menu 3.x implements its own caching with fully rendered HTML cached in {cache_menu} with cid of 'admin_menu:' . $user->uid . ':' . session_id() . ':' . $language->language;. In other words, a per-user cache scoped to admin_menu.

In comment 110 of that issue, catch requested that we not port that approach to a toolbar-specific cache, but rather solve the problem generally in the menu system.

I see this issue as having three parts (perhaps not all of them need to be solved as part of this issue):

  • Make the output of menu_build_tree() not page-specific. Because the output is already user-specific (access filtered), and a cache that is both per-user and per-page is close to useless. However, it can only be non-page-specific when called with empty 'expanded', 'active_trail', and 'only_in_active_trail' build parameters. I think it's fine if we only cache the output when those conditions are met, and assume that consumers of this cache are therefore use cases that can implement their own tree collapsing client-side. Any thoughts on this or whether there's any other subtle problems with reusing this information across different pages?
  • Instead of caching menu_build_tree(), we'd get more performance gains by caching a client-consumable rendered form of it, either the result of menu_tree_output() (fully rendered HTML with ULs and LIs), or a JSON tree (possibly with anchor tags rendered but not the UL/LI wrappers). If we only render a JSON tree with anchor tags, then we just need to make sure the 'active' class isn't added, which isn't hard. But then the JS code that adds ULs and LIs potentially needs to be made consistent with whatever theme overrides of theme_menu_tree() and theme_menu_link() are in use by the active theme. OTOH, if we render the full HTML, then we need to make menu_tree_output() not page-specific, both for its active trail handling, and by documenting that preprocess and theme overrides of 'menu_tree' and 'menu_link' shouldn't have page-specific logic.
  • If this is to be truly reusable outside of toolbar, it would be helpful to have some other use case to work with in designing it. Catch suggested core's menu block. However, this would imply converting that to a JS-based solution (for rendering what's expanded vs. what's collapsed based on the current page), and we'd have to figure what the no-js behavior should be. This could actually be a nice performance gain for Drupal as long as we can come up with a no-js fallback that's acceptable.

Any thoughts on the above, or something I missed?

CommentFileSizeAuthor
#210 interdiff.txt783 bytesWim Leers
#210 menu_render_caching-1805054-210.patch63.48 KBWim Leers
#207 interdiff.txt6.17 KBWim Leers
#207 menu_render_caching-1805054-207.patch63.51 KBWim Leers
#206 interdiff.txt6.55 KBWim Leers
#206 menu_render_caching-1805054-206.patch60.47 KBWim Leers
#200 interdiff.txt8.24 KBWim Leers
#200 menu_render_caching-1805054-200.patch61.1 KBWim Leers
#189 interdiff.txt7.19 KBWim Leers
#189 menu_render_caching-1805054-189.patch62.02 KBWim Leers
#188 interdiff.txt3.09 KBWim Leers
#188 menu_render_caching-1805054-187.patch60.85 KBWim Leers
#183 interdiff.txt2.61 KBWim Leers
#183 menu_render_caching-1805054-183.patch60.5 KBWim Leers
#180 interdiff.txt3.9 KBWim Leers
#180 menu_render_caching-1805054-180.patch60.37 KBWim Leers
#176 interdiff.txt786 bytesWim Leers
#176 menu_render_caching-1805054-176.patch60.27 KBWim Leers
#175 interdiff.txt14.99 KBWim Leers
#175 menu_render_caching-1805054-175.patch60.13 KBWim Leers
#169 interdiff.txt894 bytesWim Leers
#169 menu_render_caching-1805054-169.patch46.73 KBWim Leers
#167 interdiff.txt3.67 KBWim Leers
#167 menu_render_caching-1805054-167.patch46.71 KBWim Leers
#165 interdiff.txt6.03 KBWim Leers
#163 menu_render_caching-1805054-163.patch44.89 KBWim Leers
#162 menu_render_caching-1805054-162.patch41.17 KBWim Leers
#158 interdiff.txt1.32 KBWim Leers
#158 menu_render_caching_only-1805054-157-do-not-test.patch41.17 KBWim Leers
#158 menu_render_caching_only-1805054-157-including_dependencies.patch152.52 KBWim Leers
#156 interdiff.txt786 bytesWim Leers
#156 menu_render_caching_only-1805054-156-do-not-test.patch39.92 KBWim Leers
#156 menu_render_caching_only-1805054-156-including_dependencies.patch151.26 KBWim Leers
#155 interdiff.txt16.56 KBWim Leers
#155 menu_render_caching_only-1805054-155-do-not-test.patch40.67 KBWim Leers
#155 menu_render_caching_only-1805054-155-including_dependencies.patch152.01 KBWim Leers
#152 interdiff.txt7.29 KBWim Leers
#152 menu_render_caching_only-1805054-152-do-not-test.patch26.77 KBWim Leers
#152 menu_render_caching_only-1805054-152-including_dependencies.patch148.42 KBWim Leers
#150 interdiff.txt660 bytesWim Leers
#150 menu_render_caching_only-1805054-150-do-not-test.patch23.61 KBWim Leers
#150 menu_render_caching_only-1805054-150-including_dependencies.patch145.26 KBWim Leers
#147 interdiff.txt2.52 KBWim Leers
#147 menu_render_caching_only-1805054-147-do-not-test.patch23.66 KBWim Leers
#147 menu_render_caching_only-1805054-147-including_dependencies.patch145.31 KBWim Leers
#146 interdiff.txt4.12 KBWim Leers
#146 menu_render_caching_only-1805054-146-do-not-test.patch23.04 KBWim Leers
#146 menu_render_caching_only-1805054-146-including_dependencies.patch144.69 KBWim Leers
#144 HEAD-fixed-histogram_interleaved.png8.32 KBWim Leers
#144 HEAD-fixed-histogram_facet.png6.03 KBWim Leers
#144 HEAD-histogram_interleaved.png7.24 KBWim Leers
#144 HEAD-histogram_facet.png6.06 KBWim Leers
#144 ab runs.zip12.92 KBWim Leers
#144 menu_render_caching_only-1805054-144-do-not-test.patch21.62 KBWim Leers
#144 menu_render_caching_only-1805054-144-including_dependencies.patch141.13 KBWim Leers
#118 interdiff.txt18 KBWim Leers
#118 menu_render_caching-1805054-118.patch349.98 KBWim Leers
#117 interdiff.txt693 byteseffulgentsia
#117 menu_render_caching-1805054-117.patch362.76 KBeffulgentsia
#115 interdiff.txt12.91 KBWim Leers
#115 menu_render_caching-1805054-115.patch367.63 KBWim Leers
#113 menu_render_caching-1805054-112.patch357.11 KBWim Leers
#112 interdiff.txt1.06 KBWim Leers
#112 menu_render_caching-1805054-112.patch357.11 KBWim Leers
#109 interdiff.txt323.01 KBWim Leers
#109 menu_render_caching-1805054-109.patch357.06 KBWim Leers
#104 interdiff.txt45.51 KBWim Leers
#104 menu_render_caching-1805054-104.patch134.38 KBWim Leers
#102 menu_render_caching-1805054-102.patch107.71 KBWim Leers
#97 menu_render_caching-1805054-97-do-not-test.patch121.39 KBWim Leers
#97 xhprof-complex-front-before_correct.png26.6 KBWim Leers
#97 xhprof-complex-front-before_wrong.png25.66 KBWim Leers
#97 xhprof-complex-contact-before_correct.png27.62 KBWim Leers
#97 xhprof-complex-contact-before_wrong.png27.58 KBWim Leers
#75 1805054-74.patch10.23 KBGábor Hojtsy
#74 interdiff.txt1.38 KBGábor Hojtsy
#74 1805054-74.patch0 bytesGábor Hojtsy
#63 1805054-63.patch9.99 KBGábor Hojtsy
#56 1805054-56.patch11.19 KBjessebeach
#54 1805054-54-do-not-test.patch11.19 KBjessebeach
#54 interdiff_47-to-54.txt2.65 KBjessebeach
#52 1805054-52.patch9.68 KBGábor Hojtsy
#52 interdiff.txt1.23 KBGábor Hojtsy
#50 1805054-50.patch9.68 KBGábor Hojtsy
#50 interdiff.txt1.5 KBGábor Hojtsy
#48 interdiff.txt1.79 KBGábor Hojtsy
#48 1805054-47.patch9.7 KBGábor Hojtsy
#46 1805054-46.patch8.86 KBGábor Hojtsy
#36 1805054-36.patch8.85 KBjibran
#36 conflict.txt1.09 KBjibran
#35 1805054-35.patch8.98 KBjibran
#35 interdiff.txt913 bytesjibran
#26 1805054-26.patch8.8 KBjibran
#15 menu-cache.patch8.79 KBeffulgentsia
#11 toolbar-bench-do-not-test.patch1.11 KBeffulgentsia
#10 menu-cache.patch4.95 KBeffulgentsia
#6 menu-cache.patch4.92 KBeffulgentsia
#5 menu-cache.patch3.78 KBeffulgentsia
#4 Snapshot 10:9:12 1:37 PM.png18.89 KBjessebeach
#4 Snapshot 10:9:12 2:01 PM.png57.42 KBjessebeach
Support from Acquia helps fund testing for Drupal Acquia logo

Comments

moshe weitzman’s picture

1. I think we have to accept #1 and make this a non page-specific cache. I agree that the caller can tweak from there. I'd be fine with tweaking happenning in PHP or JS.

3. The menu block is only relevant if we don't show the mobile toolbar to desktop sized browsers. Why would one want a the menu block along with a toolbar? The only other use case I can think of is quick-navigation widget like what some of the toolbar designs have shown. It would be an autocomplete which whisks you to the selected admin page upon submit.

catch’s picture

@moshe, with the menu block I meant it'd be good if we could cache that more efficiently, currently that's some of the least efficient caching we have (has to be calculated, if not stored uniquely, per page, access checks (which can load entire views, entities etc.) run during runtime, full rendering from the big array that we store in the cache). It also happens to be in core already.

Not sure why we'd need to cache by both user ID and session ID, that potentially means different cache entries for anonymous users if they have a session and I can't think of a use case for that - maybe admin_menu has some per-session logic it specifically has to account for or something?

Dries’s picture

Issue tags: +Spark

Tagging it with Spark, as it is relavant for Spark.

jessebeach’s picture

re: Personally, I'd like to see all of the menu state code (active, expanded, active-trail) move from the server to the client. It gives us the boost of eliminating per-page cacheing.

I think we have some room to explore different combinations of server-side versus client-side DOM list building. Our options are:

  • Send the full menu tree, without page-specific state markup, as HTML from the server. This is results in highly-cacheable output.
  • Send some subset of the full menu tree, again without page-specific state markup, and get sub-page trees asynchronously when the user drills down the menu. This reduces the initial page payload but might have deleterious effects on perceived page speed for users who frequently use deep menu structures to navigate their site.
  • Send menu data as JSON asynchronously and render menus some time soon after post DOM load. This would add a hard dependency on JavaScript for admin functionality.

I just ran some base-line speed tests with a small and obscenely-large set of links in unordered lists. I used Charles to simulate throttled bandwidth and measured performance with Chrome's Network tab. Here are the results for combined wait/receive time:

110 links in a nested (2 levels) ul element.

unthrottled/cable: 3ms
256 kbps/DSL: 303ms
3g: 696ms

1110 links in a nested (3 levels) ul element.

unthrottled/cable: 4ms
256 kbps/DSL: 2670ms
3g: 1330ms

5555 links in a nested (3 levels) ul element.

unthrottled/cable: 4ms
256 kbps/DSL: 14048ms
3g: 4560ms

11,110 links in a nested (4 levels) ul element.

unthrottled/cable: 6ms
256 kbps/DSL: 27900ms
3g: 84300ms

Bar chart showing increasing request, receive times for menus of increasing size on different bandwidth connections

A standard installation of D7 with the Administration module installed (so that we get dropdowns) has 154 links in the toolbar menu.

The Drupal Gardens site, which has the Administration menu installed and a lot of modules enabled has 683 menu links loaded on each page. That is still well shy of the 1110 links in the chart above where page load begins to be affected on 3g by a large menu. Still, it's enough time to warrant exploring hybrid options to load top levels of a menu and deeper sub-levels with some asynchronous method.

To sum up, we shouldn't have to treat the toolbar menu with special gloves in terms of cacheing. We put in sensible, per user cacheing (perhaps even per-role? I'm waiving my hands here, this is not my area of expertise), returned a rendered HTML list and leave the state decoration (current page, active path) for the client to deal with (that I can do). The Administration menu module already has a lot of excellent front end code we can leverage for that part of the built-out.

We could also get rid of this option for menu links, which has always driven me mad, especially when I need to edit every link in a complex menu to get it to display as a full tree.

Screenshot of admin/structure/menu/item/20/edit with the show menu as expanded checkbox highlighted

effulgentsia’s picture

Status: Active » Needs work
FileSize
3.78 KB

Just wanted to post a work in progress patch. I'm working with a devel generated menu block with 300 links upto a depth of 4, caching the result of menu_build_tree() per-user, but not per-page. I'm caching subtrees, since retrieving a fully expanded tree from cache is both memory intensive and slow.

I'm not seeing any performance benefit from it yet, but that's because most of the savings can come from caching the result of l(), and I'm not doing that yet, but will work on that next.

effulgentsia’s picture

Status: Needs work » Needs review
FileSize
4.92 KB

Ok, this once caches the l().

Status: Needs review » Needs work
Issue tags: -Spark

The last submitted patch, menu-cache.patch, failed testing.

effulgentsia’s picture

Status: Needs work » Needs review

#6: menu-cache.patch queued for re-testing.

Status: Needs review » Needs work
Issue tags: +Spark

The last submitted patch, menu-cache.patch, failed testing.

effulgentsia’s picture

Status: Needs work » Needs review
FileSize
4.95 KB
effulgentsia’s picture

Here's a benchmarking script that can be applied on top of #1137920-114: Fix toolbar on small screen sizes and redesign toolbar for desktop. It requires devel module enabled.

effulgentsia’s picture

#10 includes a hunk that outputs some times above menu blocks of devel generated menus. On a 300 link menu where all links are node links and I have no node access module in use, when viewing a node page that returns 25 links to the browser (only menu items that are not children of collapsed parents), I get:

HEAD: 
- time to get the access filtered and localized tree: 3ms
- time to build the render array and render it: 8ms

With #10:
- time to get the access filtered and localized tree: 3ms (no change, despite caching menu_tree_check_access())
- time to build the render array and render it: 4ms (2x improvement due to no runtime l() calls)

#11 can be applied to the toolbar patch in that other issue. That benchmarks the time to get, build, and render a fully expanded Management menu. For that, I get:

HEAD: 
- time to get the access filtered and localized tree: 20ms
- time to build the render array and render it: 18ms

With #10:
- time to get the access filtered and localized tree: 5ms (4x improvement due to caching menu_tree_check_access())
- time to build the render array and render it: 9ms (2x improvement due to no runtime l() calls)

These numbers are relative to an approximately 100ms total page response, so correspond roughly to percentages.

Status: Needs review » Needs work

The last submitted patch, menu-cache.patch, failed testing.

sun’s picture

I'm not comfortable with caching access/per-user. It will result in too many cache items on large sites (essentially: users x menus).

But the idea of caching the "rendered" link makes sense to me.

However, that requires a lot of additional context info for the cache item that contains the rendered links:

- HTTP vs. HTTPS
- conf_path() / HTTP_HOST / domain (in case an absolute link is contained)
- language
- last timestamp of a changed string tanslation
- theme key (also: theme vs. admin_theme)
[potentially more; these are the most obvious only]

- ?destination
- active trail
- session (link tokens)

Furthermore, the link rendering would have to happen in a special context that is exempt of user permissions; i.e., all links need to be accessible to be rendered and cached.

Inaccessible links would be removed when the cached menu link tree + cached menu links are rendered for the current user. This could potentially allow us to merge the rendered links into the cached menu link tree even.

[snip]

Now that I wrote this, I just added ?destination and active trail to above list of cache contexts. These are the two major problems that admin_menu solves after the fact on the client-side:

admin_menu makes sure that the cached output does not contain an active trail, and dynamically injects the active trail classes into the rendered menu links for the current page.

admin_menu also replaces every instance of ?destination=... in all links that have a destination for the currently rendered page.

Lastly, admin_menu caches per user, because links may contain drupal_get_token() session token query parameters.

Doing that makes sense for admin_menu, because there is usually only a very small subset of administrative users on a site, even on large-scale sites. E.g., even if there are 1,000,000 users on a community site, there are usually only ~10-500 users with access to the site administration, which results in the equivalent amount of caches.

Hrm. Overall, I do not think it is feasible to change this for all menus in core. The performance of rendering large menu trees is certainly a problem, but from my perspective, the current build/access/render process is well thought-out and takes all context possibilities into account that might have an impact on the final output.

admin_menu is only able to choose a different approach, because it is a single use-case, because it knows exactly how and where the rendered menu tree is used, and because it renders the specific menu tree of administration links. Without that advantage, the amount of contexts is factually unlimited.

effulgentsia’s picture

Status: Needs work » Needs review
FileSize
8.79 KB

Thanks for the review, sun. Ok, how about this instead?

I removed the calling of l() from _menu_link_translate() for all the reasons given in #14. I think this is worth looking into more, but perhaps in a different issue. Also, we shouldn't really be caching l(), just url(), but all the same issues apply to that as well.

I also changed menu_build_tree() to only cache localized menus that aren't dealing with active trail, per the issue summary. This means this caching is no longer being automatically applied to regular menu blocks. Applying it to menu blocks would need to be done in a separate issue, together with the javascript for applying the active(-trail) classes client-side.

Note that since even the existing toolbar builds a menu tree (with depth=1) with no active trail handling, this patch does implement per-user caching of that. That might not actually be a good thing, since localizing a 1 deep toolbar takes very little time, but meanwhile, we're adding a cache record per user (who has access to the toolbar). But, since catch asked to explore this issue generically, that's the current patch behavior.

I also changed theme_menu_link() to not call l(), but render the anchor tag directly. First of all, at DrupalCon Munich, when discussing template conversions to twig, the themers present all asked for putting anchor tags in menu(-link).twig instead of calling l() on TX grounds alone. Secondly, decoupling from l() lets us override 'active' class handling, and pre-generating/caching url().

Finally, this patch adds a demo hunk to toolbar.module to (in the presence of a 'toolbar-tmp-depth' query string) append an expanded Management menu of the desired depth along with the number of ms it took to build the tree and render it. It is here that I also added render caching for it. Given #14, I don't think we want to add render caching into menu_tree_output() itself (and therefore, this patch doesn't), but doing so in the use-case specific code is easy as this patch demonstrates.

By employing both menu_build_tree() caching and render caching, this I think solves all server-side performance issues of #1137920: Fix toolbar on small screen sizes and redesign toolbar for desktop. We still might want to implement client-side localStorage caching in that issue though for mobile bandwidth optimization. However, sun is calling into question the premise of per-user caching on sites with many users, so additional feedback from people about that would be helpful.

sun’s picture

Honestly, I think this is a lot of complexity that menu.inc should not contain.

It wasn't really easy to follow/understand what the new code is doing, and under which (special) conditions (and when not).

Is there any way we could allow modules/consumers to achieve the same, without adding even more complexity to menu.inc?

Although I'm still not sure whether it is appropriate to "hack" a core subsystem in this way, mainly to improve the performance of a number of core/contrib modules that I can probably count on one hand?

That said, I also wonder what the actual (performance) situation with the new Symfony router looks like - the new router itself shouldn't have an impact, but our current menu link tree building is deeply intertwined with the (old) router...

sun’s picture

Category: task » feature

Reclassifying as a feature, since the only issue being blocked by this is a feature request as well, and I assume we surely don't want to block other, ready patches due to this issue.

#15 still caches per-user (and lacks session_id(), btw). An acceptable amount of caches for such a generic implementation would be per-role[s], but that would inherently break menu access checks/restrictions for link-specific endpoints being backed by e.g. node_access().

catch’s picture

I'm confused by sun's per-user caching comment.

Current menu caching:

- menu trees are calculated on every page, then the hash of the tree is calculated.
- two cache entries are stored, a cid for the page, pointing to the hash. Then a cid with the hash, pointing to the tree.
This is designed to prevent massive filling-up of the menu cache bin (which was a critical Drupal 6 bug report).

If we move to per-user caching, then that doesn't necessarily mean more cache entries at all.

On most sites I work with, there's a vast quantity of traffic to tens or hundreds of thousands of unique paths every day from search engine crawlers. These currently require a cache entry for each individual page per menu. Per-user caching would mean a single entry per-menu for all of those bots, since they'd all be anonymous requests.

There'll be some sites with many, many authenticated users, but you'd need tens to hundreds of thousands of unique authenticated user requests each day to get anywhere close to the number of cache misses that there currently are with core.

Once we have a generator (the Symfony version of url() for internal paths), I'd expect menu tree generation to get more expensive, additionally if menu items become entities that'll probably come with some performance overhead as well. See also the discussion on #1793520: Add access control mechanism for new router system which is sort of the counterpart to the toolbar issue since they'll compound each other quite significantly.

catch’s picture

Category: feature » task
Priority: Critical » Major

Given #916388: Convert menu links into entities I think this is a more appropriate status. It may need to be bumped to critical again if either of those patches goes in before this one and we find a measurable performance regression.

effulgentsia’s picture

Per #18, I think this issue continues to be appropriate, even as a major task, for reasons other than Toolbar. For Toolbar though, I think #1814932: Caching strategy for D8 toolbar is better.

sun’s picture

I doubt that we have any sophisticated tests for menu caching and cached rendering currently. So if we want to change this, then I'd suggest to start with writing tests for all the special cases outlined in #14.

corbacho’s picture

I would like to support Jesse's and eff idea of a JSON rendered output

The output can be embedded directly in the HTML (printed in the template), to avoid the delay. There is no need to make it via AJAX, but it could be used the same code to create an on-demand expanding tree. #1814932: Caching strategy for D8 toolbar

This solution will help to make a fast search/autocomplete, since the data is already in native format. No need to parse the html.

// Use it
// $tree = toolbar_get_menu_tree();
// json_encode (toolbar_get_filtered_tree($tree)); // output this in the toolbar template

function toolbar_get_filtered_tree($tree){
  // [[  Cache stuff here  ]]
  // Inject not-cached variables: Current path, language, etc and additional context info that @sun posted
  return toolbar_recursive_filter_tree($tree);
}

function toolbar_recursive_filter_tree($tree, $level = 0, &$result = array()){
  $i = 0;
  foreach ($tree as $key => $menu) {
    if (empty($menu['link'])) continue;
    $result[$i]['title'] = $menu['link']['title'];
    $result[$i]['href'] = $menu['link']['href'];
    $result[$i]['weight'] = $menu['link']['weight'];
    $result[$i]['depth'] = $level;
   // more attributes ?? (white-listing assignment )
    if (!empty($tree[$key]['below'])) {
      $result[$i]['below'] = toolbar_recursive_filter_tree($tree[$key]['below'], $level + 1);
    }
    $i++;
  }
  return $result;
}

I did it for a prototype, and it works (except that tasks are mixed with regular links). For a simple example of how to use the rendered json data in JavaScript to render a navigation menu: (see var jsontree)
http://drupalmotion.com/sites/default/files/demos/toolbar/demo1.js

The big caveat (the elephant in the room): This won't work in Javascript disabled browsers. So it's not a generic solution for every single menu. But it will adapt very well to the toolbar case (autocomplete, dynamic collapsing tree, etc )

YesCT’s picture

This could probably use a issue summary update (doc: http://drupal.org/node/1427826)

since this is at needs review, instead of needs work... I'm guessing a reroll (http://drupal.org/patch/reroll) might be useful.

YesCT’s picture

#15: menu-cache.patch queued for re-testing.

Status: Needs review » Needs work
Issue tags: +Needs issue summary update, +Spark

The last submitted patch, menu-cache.patch, failed testing.

jibran’s picture

Status: Needs work » Needs review
FileSize
8.8 KB

re-roll

Status: Needs review » Needs work
Issue tags: -Needs issue summary update, -Spark

The last submitted patch, 1805054-26.patch, failed testing.

YesCT’s picture

Status: Needs work » Needs review

#26: 1805054-26.patch queued for re-testing.

Status: Needs review » Needs work
Issue tags: +Needs issue summary update, +Spark

The last submitted patch, 1805054-26.patch, failed testing.

YesCT’s picture

I'm not sure what "failed to run tests" indicates.

the testbot faq: http://drupal.org/project/testbot might help

@jibran, where there any conflicts when you did the re-roll?

YesCT’s picture

Maybe check the testbot number...
this might also provide a clue: #1878216: Bot #664 disabled (repeatedly failed to run tests)

jibran’s picture

nope no conflicts simple merges
here is the merge if it helps.

git rebase 8.x
First, rewinding head to replay your work on top of it...
Applying: Patch from http://drupal.org/node/1805054#comment-6596618
Using index info to reconstruct a base tree...
M       core/includes/menu.inc
M       core/modules/toolbar/toolbar.module
Falling back to patching base and 3-way merge...
Auto-merging core/modules/toolbar/toolbar.module
Auto-merging core/includes/menu.inc
jibran’s picture

Status: Needs work » Needs review
Issue tags: -Needs issue summary update, -Spark

#26: 1805054-26.patch queued for re-testing.

Status: Needs review » Needs work
Issue tags: +Needs issue summary update, +Spark

The last submitted patch, 1805054-26.patch, failed testing.

jibran’s picture

Status: Needs work » Needs review
FileSize
913 bytes
8.98 KB

Replaced a call to drupal_array_set_nested_value() with NestedArray::setValue() as per
#1705920: Convert all calls to procedural drupal_array_*() functions to Drupal\Component\Utility\NestedArray

jibran’s picture

FileSize
1.09 KB
8.85 KB

Another reroll had conflict in menu.inc

@@@ -1398,8 -1483,12 +1471,17 @@@ function _menu_build_tree($menu_name, a
  
      // Build an ordered array of links using the query result object.
      $links = array();
++<<<<<<< HEAD
 +    if ($result = $query->execute()) {
 +      $links = menu_link_load_multiple($result);
++=======
+     $min_depth = 0;
+     foreach ($query->execute() as $item) {
+       $links[] = $item;
+       if (!$min_depth || ($item['depth'] < $min_depth)) {
+         $min_depth = $item['depth'];
+       }
++>>>>>>> patch from 35
      }
      $active_trail = (isset($parameters['active_trail']) ? $parameters['active_trail'] : array());
      $data['tree'] = menu_tree_data($links, $active_trail, $min_depth);

Resolved like this

     if ($result = $query->execute()) {
+      $min_depth = 0;
       $links = menu_link_load_multiple($result);
+      $min = min(array_map(function($item){ return $item['depth'];}, $links));
+      if (!$min_depth || ($min < $min_depth)) {
+        $min_depth = $min;
+      }
     }

Next reroll will be in next 2 months.

xjm’s picture

Issue tags: +D8 cacheability
nod_’s picture

benjifisher’s picture

#36: 1805054-36.patch queued for re-testing.

bsnodgrass’s picture

I will work on the issue summary

bsnodgrass’s picture

#36: 1805054-36.patch queued for re-testing.

Status: Needs review » Needs work

The last submitted patch, 1805054-36.patch, failed testing.

bsnodgrass’s picture

Status: Needs work » Needs review

#36: 1805054-36.patch queued for re-testing.

bsnodgrass’s picture

Issue summary: View changes

updated issue summary

bsnodgrass’s picture

Issue summary: View changes

Updated issue summary. Test bot was misbehaving at the time of the retest.

bsnodgrass’s picture

Issue summary: View changes

Updated issue summary.

bsnodgrass’s picture

Issue summary: View changes

Updated issue summary.

bsnodgrass’s picture

Issue summary: View changes

Updated issue summary.

bsnodgrass’s picture

Issue summary: View changes

Updated issue summary.

Status: Needs review » Needs work
Issue tags: +Needs issue summary update, +Spark, +D8 cacheability

The last submitted patch, 1805054-36.patch, failed testing.

Wim Leers’s picture

Gábor Hojtsy’s picture

Status: Needs work » Needs review
FileSize
8.86 KB

Rerolled and fixed the constant notice issue.

Status: Needs review » Needs work

The last submitted patch, 1805054-46.patch, failed testing.

Gábor Hojtsy’s picture

Status: Needs work » Needs review
FileSize
9.7 KB
1.79 KB

Eliminate use of global $user and document new arguments (and add type hints). Reviews welcome!

Status: Needs review » Needs work

The last submitted patch, 1805054-47.patch, failed testing.

Gábor Hojtsy’s picture

Status: Needs work » Needs review
FileSize
1.5 KB
9.68 KB

Of course languages also changed to have 'id' properties not 'langcode' in the meantime :)

Status: Needs review » Needs work

The last submitted patch, 1805054-50.patch, failed testing.

Gábor Hojtsy’s picture

Status: Needs work » Needs review
FileSize
1.23 KB
9.68 KB

I don't know where did I get 'account' from as the attribute name. It should be '_account'. Also uid is not a public property, so we need to use id(). Should be better.

Status: Needs review » Needs work

The last submitted patch, 1805054-52.patch, failed testing.

jessebeach’s picture

I ran into a problem with the output of the cache_menu, as well. I had to change the bin name from cache_menu to menu.

Calling language() has apparently been deprecated as well.

Gábor Hojtsy’s picture

Status: Needs work » Needs review

Let's go testbot!

jessebeach’s picture

FileSize
11.19 KB

Same patch as #54, this time without the do-not-test, so that testbot takes notice.

Status: Needs review » Needs work

The last submitted patch, 1805054-56.patch, failed testing.

jessebeach’s picture

effulgentsia, Gábor Hojtsy and I had a chat about how to further improve the cacheability of the menus. We came up with two changes that reduce the contextual variability of a rendered menu, shifting context and behavior decisions from the server to the client.

1. Deliver the full menu tree to a specified depth; qualify expanded branches with a data attribute; hide unexpanded, non-active-trail branches with client-side code.

Whether or not a menu is expanded is a decision that should be made on the client side, where context is available. The suggestion to expand a menu can be made on the client side, as is done today, but instead of shifting the content that gets returned based on the context of the request path, we simply deliver the same menu structure, with an attribute like data-drupal-menu-expand on the li elements that should be opened, and let client-side code do the proper presentation and behavior embellishments.

2. Render the active trail qualification on the client-side using the currentPath setting.

Active trail in a menu is easily derived on the client side. We shouldn't have to make menus uncacheable just to inject this highly contextual bit of information.

Implementation

These changes will require some tweaking to the PHP code for menus, but not much. I will build the menu client side code to do the expansion and active trail marking. Much of this code exists in the Toolbar module, so I plan to pull that code into more generic files and make the behaviors available to any module.

Gábor Hojtsy’s picture

Is data-drupal-menu-expand a kind of data that the toolbar would need or does it keep track of what was opened on its own anyway? Eg. if I navigate to http://myd8site/#overlay=admin/config/content/formats now via the menu, so long as I reload the page, the menu will not be open. The configuration item will be marked as active (based on it being generated from the menu system with active trail I guess) but none of the rest of the items are open. Is this the expected behaviour? Then I don't see a need for additional context specific data in the menu tree. (Not having that would let us cache the menu client side for multiple pages as well, which would be a huge win). If we don't need that info, then this issue is getting pretty close I think?

jessebeach’s picture

The data-drupal-menu-expand would indicate the state of the expand checkbox on the menu link config. Presently, if the checkbox is unchecked, we don't deliver the HTML for the sub tree. Under this the proposed behavior, we deliver the full tree for all links (to the depth requested) and those links that have the expand checkbox checked will have the data-drupal-menu-expand attribute applied to them.

Gábor Hojtsy’s picture

Oh, great. The menu code does expansion based on the expand flag and the active trail, so I mashed up the two too much. Thanks for the clarification! That sounds good. Looks like an easy addition to this patch.

jessebeach’s picture

nod_ has been working on active link decoration in the client (removing the 'active' class from the l() function) here: #1979468: ".active" from linkGenerator(), l() and theme_links() forces an upper limit of per-page caching for all content containing links.

Gábor Hojtsy’s picture

Status: Needs work » Needs review
FileSize
9.99 KB

After an epic debug session I figured out the patch fails because on 403 pages (in general!), the request does not contain an _account at the point when the menu system wants to get it (for toolbar_toolbar()). I reproduced this with other 403 pages like the filter format delete page. Only the shortcut set test catches this because the toolbar tests have no 403 testing and only the shortcut tests have toolbar enabled as well.

This is pretty late in the request in toolbar_toolbar() (my original assumption was that it is related to routing items being resolved, but that was a dead end), so why that would be is not entirely clear to me. However, if we don't have a user, we cannot really cache it for the user anyway (and we should not cache it for anonymous), so I added one more condition and no caching when there was no user identified. This works on 403 pages for me now.

Did not yet add the data attribute, but that would be next.

catch’s picture

jessebeach’s picture

Wim Leers’s picture

Gábor Hojtsy’s picture

So we can work around #2062151: Create a current user service to ensure that current account is always available pretty simply here for now. Any concerns with this patch specifically? :)

jessebeach’s picture

However, if we don't have a user, we cannot really cache it for the user anyway (and we should not cache it for anonymous), so I added one more condition and no caching when there was no user identified.

Since we're optimizing the generic menu building code, it might be possible to render a menu for a anonymous user, no? So what about cacheing menu output for the anonymous user? A list of navigation links exposed to non-authenticated users seems a likely case where we'd want to cache the rendering for anon users.

Gábor Hojtsy’s picture

Nonono, we can and should (and already do) cache for anonymous. If the user is not there, we cannot tell if it was an anonymous user or not, so my comment just concerned that situation. If the user is there, the uid may still be 0 (drupal_is_anonymous() used to be useful for this, now there may be a method on the user). So long as we don't have a user, we cannot cache it by user :)

jessebeach’s picture

I polled a few themers/site builders in my office to get some diversity of opinion on this topic. Here's what I came away with:

It's very frustrating to create a sub-menu item, go out to your site to preview the menu and find that the menu item isn't displayed on the page. This happens when the user fails to click the expand checkbox on the menu item's parent.

Solution: The expand checkbox should be selected by default.

We only want to hide sub-menus when JavaScript is functioning. In the case that a JavaScript error occurs on the page or JavaScript is disabled, we want sub-menus to be accessible, so a blanket display none of non-expanded sub-menus is not desired. For instance, the following code would be bad

/* This is a negative example */
.menu .menu {
  display: none;
}
.menu .expanded {
  display:block;
}

Because it assumes that sub-menus are hidden until exposed. We rather want sub-menus to be accessible/visible by default and condensed/hidden as the context demands (progressive enhancement). We'll get this by adding a data attribute to sub-menu parent items that do not have their expand check box setting checked.

Solution: add a data attribute data-drupal-menu-collapse to sub-menu item parents that are not configured to be expanded.

jessebeach’s picture

This seems to be the place in menu_tree_output where we want to intervene to get the data attribute data-drupal-menu-collapse

// Set a class for the <li>-tag. Since $data['below'] may contain local
// tasks, only set 'expanded' class if the link also has children within
// the current menu.
if ($data['link']['has_children'] && $data['below']) {
  $class[] = 'expanded';
}
elseif ($data['link']['has_children']) {
  $class[] = 'collapsed';
}
else {
  $class[] = 'leaf';
}

The problem I'm having is that $data['below'] is not populated if the menu item has sub-items in general; it's only populated if the current request for data has resulted in sub-items being rendered. So maybe we need to intervene before getting to this function. If we always render the entire tree, then $data['below'] should be true when the menu item is a parent of sub-items.

Gábor Hojtsy’s picture

Solution: The expand checkbox should be selected by default.

I think this is out of scope for this issue. We deal with performance improvements here. Arguing about form defaults may push this off too far :/

Solution: add a data attribute data-drupal-menu-collapse to sub-menu item parents that are not configured to be expanded.

Sounds like you suggest either having an expand or a collapse data attribute then (and none if the item is a leaf?). Can you clarify that?

jessebeach’s picture

I'm spinning on the PHP part of this patch, so I'm going to jam in whatever I need to get the approximate output of the attribute I want on menu links with sub-items. I'll get the JS bits into the patch and hopefully someone else will correct the PHP part :)

Gábor Hojtsy’s picture

FileSize
0 bytes
1.38 KB

I see you may have hit some issues (also based on your recent tweets). Here is a simple version I worked out. This will put on a collapse or an expand attribute based on the setting for the menu item.

Note that the caching on the menu is pretty aggressive, so editing the menu item will not make it appear properly changed in the rendered tree. You need to clear cache for now.

Is this what you have been looking for? I double-checked that this makes collapse appear on items that did not have the checkbox checked and expand only on those that did.

Also fixed the performance proof code to do something useful :D On my machine the cached version is over 2x as fast. 11.6ms vs. 4.3ms.

Gábor Hojtsy’s picture

FileSize
10.23 KB

Maybe > 0 bytes would be better :D

Status: Needs review » Needs work

The last submitted patch, 1805054-74.patch, failed testing.

jessebeach’s picture

Gábor Hojtsy’s picture

Status: Needs work » Needs review

#75: 1805054-74.patch queued for re-testing.

pwolanin’s picture

Status: Needs review » Postponed

This seems really premature given how much change is left to do for menu links to make them work properly with routes.

Gábor Hojtsy’s picture

@pwolanin: Wait, what this patch does it allows to skip active trail marking, so the rendered menu can be cached. How is that related to where the menu data comes from (routes or whatnot)?

pwolanin’s picture

Status: Postponed » Needs review

@Gabor - ok, maybe it doesn't' need to be postponed, but a bunch of it will need to be re-written since e.g. url() will not be the way most links are rendered.

Gábor Hojtsy’s picture

Is there an expected timeframe on those conversions? We'd love to have this in sooner than later. :)

johnv’s picture

Dear core/ D8 menu maintainers, apologies for this cross-post.
Please take a look at the #1978176: Build menu_tree without loading so many objects, which contains a D7/D8 performance patch for the menu build (see the 'related issues' in the issue summary for the symptoms). (One error is a problem with cached menus..)
My main question is:
- is the proposed patch/solution also valid for D8? Since it does not touch the new menu router system?
- Is the current D7-menu building system still supported in D8, will it be fased out, or will it exist next to the new system?
Depending on the answer, I will continue to work on that issue.

johnv’s picture

Issue summary: View changes

Updated issue summary.

catch’s picture

Priority: Major » Critical

Bumping to critical again. We can stick it back to major if profiling shows there's no problem, but that seems extremely unlikely.

martin107’s picture

Status: Needs review » Needs work
Issue tags: +Needs reroll

Patch no longer applies.

moshe weitzman’s picture

Capturing a brainstorm on this with @effulgentsia and @msonnabaum. We noted that discussion on this issue predates #post_render_cache. So,

  1. Render cache a localized menu that varies per page but not per user. Let's call this A.
  2. In a #post_render_cache, apply menu access checks for the current user, If we are still too slow ...
    1. Inside of A, cache the ids of the menu items in the menu.
    2. In a separate cache, cache the results of entity access check for each menu item.
    3. In #post_render_cache, use those cache results to quickly filter out inaccessible menu items.

We also discussed that we will hopefully soon have #597236: Add entity caching to core. This affects the discussion here but it isn't quite clear how.

effulgentsia’s picture

The key to #86.2.2 would be the assumption that menu link access checks could vary per user but not per page, while #86.1 can vary per page but not per user.

moshe weitzman’s picture

Wim just educated me about menu block caching since #2158003: Remove Block Cache API in favor of blocks returning #cache with cache tags. We now cache menu blocks per language and per role. Sites that do more crazy access checks can disable caching for this block and implement other strategy. Should we call this issue Won't Fix?

moshe weitzman’s picture

Status: Needs work » Closed (won't fix)

OK, lets go with Won't Fix as per #88. Feel free to reopen if you disagree.

effulgentsia’s picture

Priority: Critical » Major
Status: Closed (won't fix) » Active

I still think #86 is a good idea to implement in core, not punt to contrib. Downgrading to major though if there's no per-user access checks in core that justify this being critical.

effulgentsia’s picture

Priority: Major » Critical

Actually, wondering if "view own unpublished content" permission makes this critical. I.e., does menu block in HEAD cache a menu link to an unpublished node if the menu is first viewed by the node's author, and then display that link to other users of the same role?

pwolanin’s picture

@effulgentsia - yes, I think the bug you outline could happen. In Drupal 7 unpublished nodes were never shown in menu blocks. In Drupal 8 they are.

However, this seems like this could happen for any entity where you can have per-user access be different based on status or some other flag.

catch’s picture

Yes 'view own unpublished content' makes this critical.

@pwolanin we have #2099137: Entity/field access and node grants not taken into account with core cache contexts for contrib modules to affect render cache granularity, and #post_render_cache, so contrib has at least two methods to ensure cache correctness. However where the cache granularity is broken in core we should fix that in core.

Wim Leers’s picture

Assigned: Unassigned » Wim Leers
Issue tags: +Performance, +sprint

I worked on #2254865: toolbar_pre_render() runs on every page and is responsible for ~15ms/17000 function calls yesterday, which showed huge perf win potential. 15% speed win on a full node page. 33 DB queries less. Turns out it was because I had stupidly failed to notice I was caching a menu tree… incorrectly, of course. Which brings me to this issue. I'm taking this on because it's sorely needed.

By making menu blocks cacheable, I also introduced that "view own unpublished content" bug.

I will be able to leverage some of #2224861: Cache SystemMenuBlock and BookNavigationBlock per active trail (currently cached per URL, this breaks on very long URLs). I'll first read through the entire issue, but will probably apply a strategy like the on in #86.

Wim Leers’s picture

I've now been working on this quasi non-stop since #94 — a week ago. I'll post 2 comments: one with the general approach, and one with the patch that I have so far — but it will not apply to HEAD since I haven't found the time yet to rebase. You should be able to review the overall approach though.

Wim Leers’s picture

The 90% of the problem space that already has solutions

Having read this entire issue, I think sun's comment in #14 is the single most important comment:

[…] the idea of caching the "rendered" link makes sense to me.

However, that requires a lot of additional context info for the cache item that contains the rendered links:

- HTTP vs. HTTPS
- conf_path() / HTTP_HOST / domain (in case an absolute link is contained)
- language
- last timestamp of a changed string tanslation
- theme key (also: theme vs. admin_theme)
[potentially more; these are the most obvious only]

- ?destination
- active trail
- session (link tokens)

Context, context, context. And in fact: dependencies, dependencies, dependencies. That's what this issue is all about and what makes it so complex: our menu system is so incredibly flexible, because it allows so many contexts/dependencies to affect the rendered result. At the same time, that's precisely why it performs so poorly!

Let's number all of these contexts, plus missing ones (emphasized):

  1. URI scheme. (HTTP/HTTPS, and HTTP/2 aka SPDY in the future)
  2. Site in a multisite. (conf_path()/HTTP host/domain)
  3. Language.
  4. Last string (UI) translation.
  5. Theme key.
  6. The <code>.active class. (Page-specific.)
  7. Active menu trail.
  8. ?destination.
  9. Session. (Session tokens in menu links.)
  10. Last change to contained menu link or containing menu.

Now, I think a few patches from the past few months greatly help with addressing this (same order as the above numbered list):

  1. The "scheme" (1) and "site in a multisite" (2) contexts haven't been addressed yet, but #2158003: Remove Block Cache API in favor of blocks returning #cache with cache tags introduced (see corresponding change notice) the concept of "cache contexts" to address precisely this problem. Cache contexts allow you to expose a global (i.e. request) context to Drupal's cache system. We already have LanguageCacheContext, ThemeCacheContext, UrlCacheContext, UserCacheContext and UserRolesCacheContext. By adding a DomainCacheContext, we can easily cache per protocol/site in a multisite combination. Note that this also allows for site-specific contexts, such as CountryCacheContext, CurrencyCacheContext or WeatherCacheContext.
  2. The "language" context has already been addressed, see first bullet: LanguageCacheContext.
  3. The "theme" context has already been addressed, see first bullet: ThemeCacheContext.
  4. "Last string translation" is not a cache context (in that it doesn't make sense to cache "per version of the string translation"), but whenever any string translation changes, the locale cache tag is deleted, which will cause all cache items tagged with it to be invalidated.
  5. The .active class problem was addressed in #1979468: ".active" from linkGenerator(), l() and theme_links() forces an upper limit of per-page caching for all content containing links, by introducing data- attributes on links that should get that class when applicable plus a #post_render_cache callback that is applied to the entire page.
  6. The active menu trail problem was addressed in #2224861: Cache SystemMenuBlock and BookNavigationBlock per active trail (currently cached per URL, this breaks on very long URLs) for menu blocks, by introducing MenuTreeInterface::getActiveTrailIds(), which allows us to generate an active trail cache key, and hence cache per-active trail instead of per-page (which is much better because many pages have the same active trail). An even better solution might include
  7. The ?destination problem was addressed in #2136507: Use client-side cache tags & caching to eliminate 1 HTTP requests/page for rendering Contextual Links for contextual links, but we can apply it here also (uses drupalSettings.path.currentPath plus a tiny amount of JS).
  8. The session link tokens can be addressed in an analogous manner.
  9. "Last menu link/menu change" is not a cache context for the same reasons "last string translation" isn't. Here, too, we have corresponding cache tags that are invalidated.

Combined, this allows us to (again matching the above numbered list):

  1. Cache per domain (scheme + domain + port). It's fine to do this *always*, not just when there's >=1 links in the menu tree that has absolute => TRUE, because the number of domains is always going to be limited.
  2. Cache per language — simple & essential.
  3. Cache per theme — simple & essential.
  4. Invalidate whenever translations change.
  5. Ignore the (page-specific) .active class problem; we can consider that a now-irrelevant cache context.
  6. Cache across pages, thanks to menu trail-specific caching.
  7. Ignore the (page-specific) ?destination problem; we can turn that into an irrelevant cache context.
  8. Ignore the (session-specific) session link tokens problem; we can turn that into an irrelevant cache context.
  9. Invalidate whenever contained menu links or the menu in question change.

It should also be possible to alter a menu tree's rendering to depend on more cache contexts, because that way, you can retain the same cacheability, but allow for e.g. menu links that have access dependent on one of those cache contexts ("this link can only be accessed by people in Belgium").

The 10% of the problem space (that will be 90% of the work) that is not yet solved

This is, once again, the "arbitrary access checks" problem. i.e. hook_entity_access()/hook_node_access().

The solution that catch and I came up with together:

  1. We render cache the entire menu tree by role/language/theme/active trail/… (see the above).
  2. Access-wise, we render cache those items whose router information only has a _permission requirement (or more generally, whose access checks are cacheable per role).
  3. If any other route requirement is present, we don't render that menu link, but render a render cache placeholder instead, with an associated #post_render_cache callback. This will allow us to render cache the entire menu tree per role/language/theme/active trail/…, but still render those links that need dynamic access checks.
  4. To ensure that that #post_render_cache can run as efficiently as possible, we calculate and cache the results of the dynamic access checks in a menu per user, and associate the cache tags of the route parameters that turn out to be entities after route parameter enhancing (all entities have cache tags as of #2217749: Entity base class should provide standardized cache tags and built-in invalidation!), so that whenever any of those entities change, the dynamic access checks results that are cached per user & menu are recalculated (e.g. a menu link to an unpublished node, and then suddenly that node gets published — cache tags ensure the correct invalidation happens).
Wim Leers’s picture

Helper at #2268971: Helper for #1805054.

Child issues created while working on this so far:

  1. #2271025: Don't let menu_navigation_links() set the "active" class, the drupal.active-links library does that for us (plus, it's setting it incorrectly)
  2. #2271659: Split up _menu_link_translate() to unblock render caching of menus
  3. #2271763: Split active trail handling from MenuTree(Interface) into MenuActiveTrail(Interface)
  4. #2272081: BlockAccessController::checkAccess() should run the block plugin's access check last

A few of these have landed, which allow this patch to become simpler.

Also filed #2273277: Figure out a solution for the problematic interaction between the render system and the theme system when using #pre_render, which has been extremely problematic for some time now.


Patch is against 71287c867fc06483b2f0b73b2241efa4842102f6, from now 10 days ago. Hence marked as "do not test".


Attached patch still needs quite a bit of cleanup, but I hope that by reading MenuTreeInterface and MenuTree, the approach is clear (and if not, see the above, that should help also).

This patch converts all rendered menus:

  • menu blocks
  • "navigation links" (primary & secondary menu)
  • toolbar tray
  • menu tree select widgets in the UI

Into the exact same pipeline. It makes all of them cacheable.

It also fixes the critical regression of dynamic access checking no longer happening in menu blocks. As of #2158003: Remove Block Cache API in favor of blocks returning #cache with cache tags, menu blocks are cached per role. Which means that any menu link that needs dynamic access checks (e.g. "view own unpublished content") simply is cached for the first user with a given role.

This also removes all caching in the MenuTree::build*() functions — we now rely solely on render caching, plus of course the cached versions of "access cacheability analysis" for each menu tree. Overall, this allows us to use caches much more effectively.

Good news is that e.g. the admin menu has ZERO non-role cacheable access checks by default (and probably nearly always). Which also means that we'll be able to change the toolbar tray caching to be per-role :) (or even better: detect whether it should be cached per user or per role).

Finally, note that menu blocks in their "Cache settings" now tell you which menu links are costly. This needs UX love, and is questionable (because scary), but at least this is now possible.

Benchmarks

Note:

  • "Before" is with caching of menu blocks disabled, because only then we get correct results.
  • "BeforeWrong" is with caching of menu blocks enabled, but keep in mind that this yields incorrect results.
  • "After" compares to "Before" — that's the only apples-to-apples comparison possible here.

(Measured using the excellent https://drupal.org/project/webprofiler), using PHP 5.5 with OpCache enabled, XDebug disabled.)

Default menu

The default menu, with 1 link: "Home" (which is role-dependent).

/
  BeforeWrong Before After Δ
DB queries 189 193 165 -28
Response time (ms) 240 255 230 -25
Memory (MB) 14.2 14.5 13.8 -0.7
Cache gets 78 83 70 -13
Config gets 50 51 49 -2
/contact
  BeforeWrong Before After Δ
DB queries 139 142 111 -31
Response time (ms) 190 200 180 -20
Memory (MB) 11.0 11.5 10.5 -1
Cache gets 46 51 38 -13
Config gets 41 42 41 -1

Typical menu

I configured the "main menu" with 3 links: 3 role-dependent ones ("Home", "Contact", "Admin").

/
  BeforeWrong Before After Δ
DB queries 195 198 165 -33
Response time (ms) 250 260 230 -30
Memory (MB) 14.2 14.5 13.8 -0.7
Cache gets 80 84 70 -14
Config gets 50 51 49 -2
/contact
  BeforeWrong Before After Δ
DB queries 139 142 111 -31
Response time (ms) 210 220 180 -40
Memory (MB) 11.2 11.5 10.5 -1
Cache gets 48 52 38 -14
Config gets 41 42 41 -1

Typical complex menu

I configured the "main menu" with 6 links: 3 role-dependent ones ("Home", "Contact", "Admin") and 3 non-role-dependent ones (to nodes 1, 2, 3).

/
  BeforeWrong Before After Δ
DB queries 205 207 166 -41
Response time (ms) 250 260 230 -30
Memory (MB) 14.5 14.5 13.8 -0.7
Cache gets 84 87 71 -16
Config gets 50 51 49 -2
/contact
  BeforeWrong Before After Δ
DB queries 170 172 113 -59
Response time (ms) 220 230 180 -50
Memory (MB) 12.2 12.2 10.5 -1.7
Cache gets 58 61 39 -22
Config gets 48 49 41 -8

Profiling

I profiled the typical complex menu scenario with XHProf, with the following results:

/ — Before vs. After
/contact — Before vs. After
/ — BeforeWrong vs. After
/contact — BeforeWrong vs. After

i.e. 12–20K less function calls per page load (out of 80–90K calls per page load).

pwolanin’s picture

I'm concerned about the approach in the issue summary. A menu tree can be arbitrarily large. Loading the whole thing and doing later collapsing and filtering is fine for the toolbar, but not for the general use case.

Is the summary up to date?

I'd prefer to discuss the approach here in more detail.

effulgentsia’s picture

Is the summary up to date?

No. It doesn't reflect the patch at all. I was about to add the "Needs issue summary update" tag, except looks like it already has it.

Wim Leers’s picture

#98: pwolanin: please read #96. I think #96 with some minor adjustments will make for a fine "proposed resolution" section in the issue summary.

msonnabaum’s picture

#96 seems reasonable to me, and the patch looks like a solid improvement already. Looking forward to the cleaned up version.

Wim Leers’s picture

Issue tags: -Needs reroll
FileSize
107.71 KB

Straight reroll to apply to HEAD. It still appears to be working :)

(#2222719: Use parameter matching via reflection for access checks instead of pulling variables from request attributes landed a few days ago, which made rerolling this more painful than if it were just #2247991: [May 27] Move all module code from …/lib/Drupal/… to …/src/… for PSR-4, which landed yesterday. But it was actually still surprisingly easy, considering this was rolled against a now ~2 weeks old Drupal 8!)

Status: Needs review » Needs work

The last submitted patch, 102: menu_render_caching-1805054-102.patch, failed testing.

Wim Leers’s picture

Issue summary: View changes
Status: Needs work » Needs review
Issue tags: -Needs issue summary update
FileSize
134.38 KB
45.51 KB

Clean-up phase 1. Refactored the determining of 'access check is cacheable per role' to be sane.

Updated the issue summary at a high level. As I clean up different parts of the patch, I'll continue expanding the issue summary.

Status: Needs review » Needs work

The last submitted patch, 104: menu_render_caching-1805054-104.patch, failed testing.

martin107’s picture

Issue tags: +Needs reroll

Patch no longer applies ..

camprandall’s picture

I will re-roll this patch.

camprandall’s picture

I can't find a commit where this patch succeeds so, being a new contributor, I'll let someone else handle this. ;o)

Wim Leers’s picture

Status: Needs work » Needs review
Issue tags: -Needs reroll
FileSize
357.06 KB
323.01 KB

#108: thanks for the attempt; this is definitely a very non-trivial reroll :)


High-level changes in this reroll:

  • Last two patches didn't include DomainCacheContext; now included. That was the main cause of the ~6500 test failures.
  • Full unit test coverage! This represents the majority of the patch. 2500 lines of additional unit test coverage!
    • MenuTree(Interface) is now much more focused: it's only purposes are to build a menu tree and render it.
    • For a large part, this is thanks to splitting the convoluted-and-impossible-to-grok logic in MenuTree::buildAllData(), MenuTree::buildPageData() and MenuTree::buildTreeData() into smaller pieces that are reusable, rather than the same logic being repeated in multiple places with subtle differences. The logic responsible for determining the query to retrieve a menu's menu links according to certain parameters now lives in a new MenuTreeParameters object
    • The access checking, translating, active trail-expanding etc. logic no longer lives partially in menu.inc and partially in MenuTree (partially in those); it now all lives in MenuDefaultTreeManipulators
    • The active trail handling is unchanged, but now lives in MenuActiveTrail(Interface)
    • A MenuTreeItem value object has been added, to make the resulting menu tree (of MenuTreeInterface::build()) more well-defined/formalized and hence more reliable/less brittle. A menu tree now consists of menu tree items, just like before, but instead of it being a particularly structured array, it's now a tree of MenuTreeItems. Menu tree manipulators receive and manipulate these.
  • The only reason links with the expanded flag enabled are not expanded if the current response is a 404, is performance: that used to cause a cache miss, but thanks to per-active trail caching, that's no longer a cost. So we don't need to special case it anymore, especially not because this "optimization" actually hurts performance now :)
  • MenuLinkStorage::loadModuleAdminTasks() is obsolete now, so it's been removed.
  • Note that despite all this, the exact same logic is still used in 98% of the scenarios, it's now just been split up in logically independent parts with unit test coverage.

Next steps

  1. This patch includes #2247779: Allow #pre_render, #post_render and #post_render_cache callbacks to be service methods.
  2. The access check cacheability metadata functionality that was cleaned up in #104 can go in in a separate issue.

Getting both of those in separately will already make this patch much more digestable. Once those have landed, I'll split off more parts if desired.

Apologies

… for posting such a huge patch. I indicated earlier that I wanted to update the patch (and the issue summary) in phases, but it turned out to be impossible, because every single part I tried to change caused ripple effects everywhere else: the current ingrokkable, convoluted spaghetti code base made step-by-step cleanups infeasible. That's also why I wrote unit tests for All The Things: so that this patch becomes easier to iterate, and so that once it lands, future bug fixes, regression tests, feature additions and refactorings become significantly easier.

Also apologies because I've been under the radar for 2 weeks, but if I'd been posting patches that are impossible to follow without unit test coverage, that would also only have been an annoying distraction. I felt my time would be best spent presenting a solid patch, rather than a series of rough patches.

There's still room for improvement, of course, but now it's been proven to work. If you think there's an edge case that's uncovered, please just add a new unit test case that fails :)

Before
wim.leers at wimleers-acquia in ~/Work/drupal-duo/core on 8.x*
$ php vendor/bin/phpunit --group menu_link
PHPUnit 3.7.21 by Sebastian Bergmann.

Configuration read from /Users/wim.leers/Work/drupal-duo/core/phpunit.xml.dist

...........

Time: 1 second, Memory: 66.25Mb

OK (11 tests, 35 assertions)
After
wim.leers at wimleers-acquia in ~/Work/drupal-tres/core on 1805054_menu_render_cache_FINAL*
$ php vendor/bin/phpunit --group menu_link
PHPUnit 3.7.21 by Sebastian Bergmann.

Configuration read from /Users/wim.leers/Work/drupal-tres/core/phpunit.xml.dist

.............................................................   61 / 5219 (  1%)
...............

Time: 0 seconds, Memory: 71.25Mb

OK (76 tests, 556 assertions)
Wim Leers’s picture

Status: Needs review » Needs work

The last submitted patch, 109: menu_render_caching-1805054-109.patch, failed testing.

Wim Leers’s picture

Status: Needs work » Needs review
FileSize
357.11 KB
1.06 KB

I'm on PHP 5.5. That's probably why I didn't notice that pesky invalid syntax problem :)

Wim Leers’s picture

testbot's not picking up #112. Reuploading.

Status: Needs review » Needs work

The last submitted patch, 113: menu_render_caching-1805054-112.patch, failed testing.

Wim Leers’s picture

Status: Needs work » Needs review
FileSize
367.63 KB
12.91 KB

This should fix all fails except for those in Drupal\rest\Tests\CsrfTest. How the changes in this patch can break X-CSRF-Token header authentication, is beyond me. If someone else knows how to efficiently debug that, please do so.

Status: Needs review » Needs work

The last submitted patch, 115: menu_render_caching-1805054-115.patch, failed testing.

effulgentsia’s picture

Status: Needs work » Needs review
FileSize
362.76 KB
693 bytes

Simple fix :)

Wim Leers’s picture

FileSize
349.98 KB
18 KB

HAHA :D Thanks, Alex :)

#2247779: Allow #pre_render, #post_render and #post_render_cache callbacks to be service methods landed, which was also included in this patch. Removing that from this patch.

The next step (working on that right now) is to move the addition of cacheability metadata (see #104) into a separate issue.

Wim Leers’s picture

Access checker cacheability metadata has been split off into its own patch now: #2287071: Add cacheability metadata to access checks.

This patch is still ready for reviews though — just feel free to ignore the changes to access checks!

Anonymous’s picture

i like the look of this patch. only thing that stood out was this:

  public function analyzeAccessCacheability($menu_name, MenuTreeParameters $parameters = NULL) {
    // Build the cache ID.
    $keys = array('menu', $menu_name, 'access_cacheability_analysis');
    $keys = array_merge($keys, $this->accessCachingContexts);
    $keys = $this->cacheContexts->convertTokensToKeys($keys);
    $cid = implode(':', $keys);

- the comment is redundant
- more importantly, it looks like the order of the keys could vary, so we prolly want to sort before implode...

msonnabaum’s picture

1. The handling of primary/seconday menus is obviously not very DRY, but assuming https://drupal.org/node/1869476 goes in as the comment suggests, that's a non-issue.

2. The naming here is awkward, and I had to dig into the code to figure out what was going on:

if (!$cacheable_access_check->willBeEmptyForAccount($main_links_source, $account, $main_tree_parameters)) {

I think the source of the awkwardness is that the method is on $cacheable_access_check, so it doesn't read quite right. willBeEmptyForAccount would be a fine method name if it was on something like MenuTree, but here, it would probably work better as something like willMenuBeEmptyForAccount.

3. It took me a while to get the whole menu tree manipulator concept, but it still looks considerably simpler than what was there before. A few things threw me, but I think they are fixable.

$parameters->appendManipulator('menu_link.cacheable_access_check_tree_manipulator:checkNonDynamicAccess')
    ->appendManipulator('menu_link.default_tree_manipulators:translate')
    ->appendManipulator('menu_link.default_tree_manipulators:generateIndexAndSort')
    ->appendManipulator('menu_link.default_tree_manipulators:setHrefPropertyIfExternal')
    ->appendManipulator('menu_link.default_tree_manipulators:setTreeItemClass');

Using the servicename:method notation with two different services makes me think that this is an extensible system, where contrib could define new services to be added to menus in core. I don't think this is the case, which makes sense, but I think we can suggest it more in code.

I'd prefer to see all the manipulator methods on DefaultMenuTreeManipulators, regardless of who is doing the work. We'd just need a proxy for checkNonDynamicAccess:

class DefaultMenuTreeManipulators {

  public function checkNonDynamicAccess(array $tree) {
   return $this->cacheableAccessCheckMenuTreeManipulator->checkNonDynamicAccess($tree);
 }

Then the previous section could be simplified to:

$parameters->appendManipulator('checkNonDynamicAccess')
  ->appendManipulator('translate')
  ->appendManipulator('generateIndexAndSort')
  ->appendManipulator('setHrefPropertyIfExternal')
  ->appendManipulator('setTreeItemClass');

4. This class name is awkward, as I'm not sure whether "Check" is a verb or a noun until I read the whole thing, but it is accurate.

namespace Drupal\menu_link;

class CacheableAccessCheckMenuTreeManipulator {

Maybe we could do this instead?

namespace Drupal\menu_link\MenuTreeManipulator;

class CacheableAccessCheck {

That said, the docs on this class are *amazing*.

Overall, this patch is looking great. Huge step forward.

effulgentsia’s picture

I discussed this patch with Wim and feel like some of the caching related logic and APIs (e.g., #2287071: Add cacheability metadata to access checks) still need a fair amount of review and polish before being committable. However, this patch also includes a ton of general menu rendering refactoring and test coverage. I think it would be good to get that part in separately for two reasons:
1) I think a fair amount of that will conflict with #2256521: [META] New plan, Phase 2: Implement menu links as plugins, including static admin links and views, and custom links with menu_link_content entity, all managed via menu_ui module, and Wim would prefer to get the test coverage in first, and then git merge into that issue with this coverage available for helping resolve conflicts, rather than the reverse order. But I don't want that issue held up on getting all the caching related stuff finished.
2) The caching logic here isn't trivial, so getting the other refactors in first and out of this patch will facilitate the review process on the caching logic.

Therefore, I recommended to Wim that he open a new issue titled something like "Refactor MenuTree to be simpler and have full unit test coverage" for getting that initial piece in.

Wim Leers’s picture

#122: done: #2289033: Refactor MenuTree to be simpler and have full unit test coverage.

That, together with #2287071: Add cacheability metadata to access checks, will allow this patch to become much more reviewable. It should end up in the sub 100K range, probably 50–70K.


#121: thank you so much for the review! :) Points 1, 2 and 4 apply only to the render caching that's being introduced here. Your 3rd point applies partially to #2289033 now, but the second half applies to this issue. I addressed the first half of point 3 at #2289033-3: Refactor MenuTree to be simpler and have full unit test coverage.

pwolanin’s picture

The conflict with #2256521: [META] New plan, Phase 2: Implement menu links as plugins, including static admin links and views, and custom links with menu_link_content entity, all managed via menu_ui module is possibly very fundamental in terms of this approach of "manipulators". I think Wim and I need to talk to figure out a common plan.

Wim Leers’s picture

Title: Cache localized, access filtered, URL resolved, (and rendered?) menu trees » [PP-2] Cache localized, access filtered, URL resolved, (and rendered?) menu trees
Status: Needs review » Postponed
Related issues: +#2256521: [META] New plan, Phase 2: Implement menu links as plugins, including static admin links and views, and custom links with menu_link_content entity, all managed via menu_ui module

Siamese twin comments were posted to #2289033-8: Refactor MenuTree to be simpler and have full unit test coverage and #2256521-75: [META] New plan, Phase 2: Implement menu links as plugins, including static admin links and views, and custom links with menu_link_content entity, all managed via menu_ui module, respectively. In short: pwolanin and I have had a long discussion today on how to move forward; the conclusion is that #2289033 (my issue) will be closed and will be combined with #2256521 (pwolanin's issue).

So, that's my next big focus: helping bring that to completion. Once that's done, and perhaps in between, I'll continue working on #2287071, which has been hugely expanded in scope on effulgentsia's request.

I'll update the issue summary of this issue as soon as either of those land.

Wim Leers’s picture

Title: [PP-2] Cache localized, access filtered, URL resolved, (and rendered?) menu trees » [PP-1] Cache localized, access filtered, URL resolved, (and rendered?) menu trees

All 5 parts of #2256521: [META] New plan, Phase 2: Implement menu links as plugins, including static admin links and views, and custom links with menu_link_content entity, all managed via menu_ui module have been committed by now. Which means that #2289033: Refactor MenuTree to be simpler and have full unit test coverage has been committed as part of that, though in slightly changed form.


The only other blocker for this issue is #2287071: Add cacheability metadata to access checks.

I've been working on that one for the past week, and I just posted the first patch there that's actually reviewable: #2287071-15: Add cacheability metadata to access checks.
effulgentsia insisted in that issue that we widen the scope to fully resolve the lack of access check result cacheability metadata, so including the entity access hooks (that's why it took so long to get a reviewable patch going). While this is the cause of the significant increase of work and patch size, at the same time that will allow us to cache almost every menu link, because we will then finally know whether e.g. a node is accessible and that this result is valid per role, or per language, or per role and per language, or per group, or … In other words: we will finally no longer need to treat entity access checks as a "black box", of which the results are per definition uncacheable. That should mean that this patch will become even more effective! :)

catch’s picture

I'm not clear why #2287071: Add cacheability metadata to access checks blocks this as opposed to allowing a further optimization?

Wim Leers’s picture

#127: I just replied to your comment in #2287071: Add cacheability metadata to access checks, perhaps it's clear after reading that?

Wim Leers’s picture

Title: [PP-1] Cache localized, access filtered, URL resolved, (and rendered?) menu trees » [PP-2] Cache localized, access filtered, URL resolved, (and rendered?) menu trees
Issue summary: View changes

While working on this, it became clear that #2273277: Figure out a solution for the problematic interaction between the render system and the theme system when using #pre_render needs to be fixed in order for this issue to be fixed. I now have a reviewable (and hopefully RTBCable) patch there. #2287071: Add cacheability metadata to access checks has also been pushed forward significantly in the past few weeks. I'm blocked on reviews on both of those.

This patch is unnecessarily complicated by the fact that primary and secondary menu links still are special snowflakes with their own rendering pipeline dating back to Drupal <=5 — they're rendered inside the theme preprocessing layer rather than as blocks. #474004: Add options to system menu block so primary and secondary menus can be blocks rather than variables and #1869476: Convert global menus (primary links, secondary links) into blocks will fix that.

So, the effective list of blockers is now:

(I'm leading the first two and helping with the last two.)


I'm currently working on a reroll of this that's based on top of the latest patches in #2287071 and #2273277.

Wim Leers’s picture

Title: [PP-2] Cache localized, access filtered, URL resolved, (and rendered?) menu trees » [PP-1] Cache localized, access filtered, URL resolved, (and rendered?) menu trees

In the mean time, #2273277: Figure out a solution for the problematic interaction between the render system and the theme system when using #pre_render has landed, making this issue only blocked on a single remaining issue: #2287071: Add cacheability metadata to access checks. That one is also very close. I've been working on rerolling this patch already :)

There's also news for the two soft blockers: #474004: Add options to system menu block so primary and secondary menus can be blocks rather than variables landed today, and I rolled an initial (rough, incomplete) patch for #1869476: Convert global menus (primary links, secondary links) into blocks. The former already having landed doesn't help this issue at all, but hopefully the second will land, which will greatly simplify the work for this issue.

Wim Leers’s picture

Title: [PP-1] Cache localized, access filtered, URL resolved, (and rendered?) menu trees » [PP-2] Cache localized, access filtered, URL resolved, (and rendered?) menu trees

The last patch that "completely" addressed this issue did not yet address the case of "dynamic menu links": menu links where the link title is dynamic, or it has a ?destination=<current_path> query string, a session token, or a CSRF token.

Since #2256521: [META] New plan, Phase 2: Implement menu links as plugins, including static admin links and views, and custom links with menu_link_content entity, all managed via menu_ui module, we now have MenuLinkInterface::isCacheable() that gives us that information. Such dynamic links can not be render cached. The only way we can render them is either through JavaScript or through #post_render_cache, otherwise they'd "infect" a render cached menu. They're rare enough that it isn't worthwhile to spend the time building a JavaScript-based solution which is potentially more efficient. Therefore, we must render them via #post_render_cache.

So, we must be able to rely on the return value of isCacheable(), or otherwise we'll end up render caching e.g. link with CSRF tokens. Turns out that we currently cannot rely on isCacheable(), indeed causing render cached CSRF tokens. I've opened #2335661: Outbound path & route processors must specify cacheability metadata to fix that, and have posted an initial patch.

Wim Leers’s picture

Title: [PP-2] Cache localized, access filtered, URL resolved, (and rendered?) menu trees » [PP-1] Cache localized, access filtered, URL resolved, (and rendered?) menu trees
Fabianx’s picture

I opened #2351015: URL generation does not bubble cache contexts as an alternate approach to unpostpone this issue.

Fabianx’s picture

ianthomas_uk’s picture

Issue summary: View changes

Added new blockers to issue summary

xjm’s picture

Issue tags: +Triaged D8 critical
webchick’s picture

Issue summary: View changes

I'm not sure but I believe that this issue is now postponed on #2351015: URL generation does not bubble cache contexts so noting this in the issue summary.

catch’s picture

It is, however we discussed this a bit last week

1. 99.9% of menu links don't actually use CSRF protection.

2. There's no hard code dependency between the two issues.

So given the menu link CSRF protection issue is critical, I think it'd be OK to un-postpone this and handle them in parallel.

catch’s picture

Status: Postponed » Needs work

Actually un-postponing this time.

webchick’s picture

Title: [PP-1] Cache localized, access filtered, URL resolved, (and rendered?) menu trees » Cache localized, access filtered, URL resolved, (and rendered?) menu trees
Wim Leers’s picture

Status: Needs work » Postponed

From the #drupal-performance IRC channel, March 10, 2015:

18:20:35 WimLeers|afk: Fabianx-screen: catch: So having thought about it, I think the combination of:
1. default cache contexts
2. the introduction of #pre_render_cache (in addition to #pre_render)
3. ability to mark cache contexts as "high frequency"
4. the combination of 2+3 to have automatically do placeholdering
5. association the proper cache contexts with URLs themselves (notably a 'host' cache context)
6. small changes to menu link tree rendering (to use #pre_render_cache)

… will allow us to close https://drupal.org/node/1805054 by only doing point 6, which would be a tiny, tiny patch

So, basically postponing this on #2429287: [meta] Finalize the cache contexts API & DX/usage, enable a leap forward in performance. See the Google doc in that IS for details.

effulgentsia’s picture

Title: Cache localized, access filtered, URL resolved, (and rendered?) menu trees » [PP-1] Cache localized, access filtered, URL resolved, (and rendered?) menu trees

That meta issue has child issues, so this title should probably reflect a larger number, but until we figure out exactly which children are blockers for this, just putting "1" into the title for now.

webchick’s picture

Title: [PP-1] Cache localized, access filtered, URL resolved, (and rendered?) menu trees » [PP-2] Cache localized, access filtered, URL resolved, (and rendered?) menu trees
Issue summary: View changes

According to the issue summary of #2335661: Outbound path & route processors must specify cacheability metadata that is also blocking this one.

Wim Leers’s picture

Title: [PP-2] Cache localized, access filtered, URL resolved, (and rendered?) menu trees » [PP-1] Cache localized, access filtered, URL resolved, (and rendered?) menu trees
Issue summary: View changes
Status: Postponed » Needs review
Issue tags: +Needs issue summary update
FileSize
141.13 KB
21.62 KB
12.92 KB
6.06 KB
7.24 KB
6.03 KB
8.32 KB

Just #2335661: Outbound path & route processors must specify cacheability metadata already presents a big step forward: it makes it so that the generated links themselves have the right cacheability metadata. That's the biggest blocker to this issue. And that patch is green and close to RTBC.

From the work I had lying around from long ago, I also split off #2479363: Cache MenuActiveTrail::getActiveIds() for *all* menus per route match: 1 cache get instead of N DB queries, saves 1 ms/response into a child issue already. That removes a few unnecessary queries.

After that, there are basically two problems left:

  1. also applying access results cacheability metadata
  2. excluding the parts that are not cacheable or "too dynamic to cache" (high-cardinality cache contexts such as "per user") and render them using placeholders. (This includes links with CSRF tokens.)

But… in reality:

  • almost no links are uncacheable (how often does one add a link with a CSRF token to a menu?)
  • almost no access checks are uncacheable (pretty much every access check is cacheable)

In other words: the 99.99% case — and that's not an exaggeration — doesn't have uncacheable links or access checks in menus. The use case of menu links being hyperdynamic (the "Hello, Wim" use case) works just fine too: they'd be cached per user, which would mean the menu is cached per user. Sites that want to do that sort of thing therefore will just have menus that are cached per-user. That's fine: only those sites will have that problem.

That allows us to move forward with a solution for the 99.99% (i.e. this is then no longer postponed on #2429287: [meta] Finalize the cache contexts API & DX/usage, enable a leap forward in performance as described in #141). The remaining 0.01% can happen in 8.x.0 without an API break, or in contrib: it is possible to placeholder the uncacheable parts and still cache the cacheable portions of a rendered menu. But it will make the code significantly more complex. The analysis I made in #141 is incorrect; it oversimplifies things: the tricky thing with menus is that child menu items affect the parent menu item: the expanded and collapsed classes.


Note again that HEAD IS BROKEN: it caches menu blocks, but it actually doesn't know how: therefore it may show links to a user that should not even be able to see them. So, below, "HEAD-fixed" is HEAD with the menu block no longer cached (by adding getCacheMaxAge() { return 0; }.


This is profiled as UID 1 on the frontpage, using the Standard install profile. Zero extra menu links, so only the "Add content" menu link. UID 1 because that is *cheaper* in terms of access checking (lots of things return early for UID 1) and because that user can access the toolbar.

In other words: the numbers below present the minimum expected improvement, for more complex set-ups and for non-admin users, the improvement will be much bigger. (See #97 for an indication of the performance improvements in other scenarios; they should be roughly comparable still.

Profiling (XHProf)

HEAD
Function calls: 64427
Mem: 22.4 MB
Peak mem: 22.7 MB
HEAD-fixed
Function calls: 68091
Mem: 22.6 MB
Peak mem: 22.8 MB
Patch
Function calls: 61444
Mem: 22.0 MB
Peak mem: 22.3 MB

Benchmarking (ab -n 1000 -c 1)

HEAD
Requests per second:    9.30 [#/sec] (mean)
Time per request:       107.557 [ms] (mean)
Time per request:       107.557 [ms] (mean, across all concurrent requests)
Transfer rate:          184.41 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.0      0       0
Processing:   102  107   2.2    107     124
Waiting:       92   96   2.1     96     113
Total:        102  108   2.2    107     124a
HEAD-fixed
Requests per second:    8.90 [#/sec] (mean)
Time per request:       112.305 [ms] (mean)
Time per request:       112.305 [ms] (mean, across all concurrent requests)
Transfer rate:          176.62 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.0      0       0
Processing:   106  112   2.7    112     135
Waiting:       95  101   2.7    100     124
Total:        106  112   2.7    112     136
After
Requests per second:    9.57 [#/sec] (mean)
Time per request:       104.454 [ms] (mean)
Time per request:       104.454 [ms] (mean, across all concurrent requests)
Transfer rate:          189.79 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.0      0       0
Processing:    98  104   2.5    104     132
Waiting:       88   93   2.4     93     120
Total:         99  104   2.5    104     132

HEAD vs. patch

(i.e. fast-but-broken HEAD)

HEAD-fixed vs. patch

(i.e. slower-but-correct HEAD)


The included patches are:

As you can see, the actual patch is only ~20 KB!

Status: Needs review » Needs work
Wim Leers’s picture

So, the failures in:

Therefore, the included patches are now:

Wim Leers’s picture

As I said in #146, this patch still needed to be updated to support #2479767-12: Fix MenuLinkInterface::isCacheable(): remove it in favor of implementing CacheableDependencyInterface, which keeps the ability for links to have dynamic text/URL, but makes the support for that sane. This reroll adds support for such dynamic links to MenuLinkTree::build(), and therefore to menu tree render caching.

Status: Needs review » Needs work
Wim Leers’s picture

Status: Needs review » Needs work
Wim Leers’s picture

Status: Needs review » Needs work
Wim Leers’s picture

The failures in #152 were most welcome: they pointed out:

  1. two bugs in my patch
  2. tests asserting the bubbling of certain cacheability metadata, but now more cacheability metadata is bubbled (i.e. not just of placed menu blocks, but also of the links and cacheability metadata bubbled from *within* that block)
  3. the menu parent selector was broken, because it was showing even inaccessible links. I had two options to fix this: A) bring back the old behavior that removed inaccessible links during the tree manipulation process, just for those cases, B) make the menu parent form selector actually set #cache, to indicate the cacheability of its options. I went with option B. I ensured that users of \Drupal\Core\Menu\MenuParentFormSelectorInterface do not get broken.

Rerolled on top of #2335661-117: Outbound path & route processors must specify cacheability metadata.

Wim Leers’s picture

No need to modify toolbar.module here, we have a dedicated issue for that: #2254865: toolbar_pre_render() runs on every page and is responsible for ~15ms/17000 function calls, so reverting those changes. (Note: those changes didn't have any effect anyway, also not in the benchmarks in #144, because the toolbar module is doing some very complex trickery.)

Wim Leers’s picture

Wim Leers’s picture

Two @todos remain, but neither should be solved in this issue, IMO. Opened #2483181: Make book navigation block cacheable and #2483183: Make breadcrumb block cacheable, including explanations for splitting it off.

So updating those @todos to those new issues, to keep the scope of this issue manageable.

dawehner’s picture

  1. +++ b/core/lib/Drupal/Core/Menu/DefaultMenuLinkTreeManipulators.php
    @@ -63,33 +63,56 @@ public function __construct(AccessManagerInterface $access_manager, AccountInter
    -      if ($tree[$key]->access) {
    +      if ($tree[$key]->access->isAllowed()) {
    
    +++ b/core/modules/menu_link_content/src/Tests/MenuLinkContentCacheabilityBubblingTest.php
    @@ -110,6 +111,16 @@ public function testOutboundPathAndRouteProcessing() {
    +    $access_anything_tree_manipulator = function (array $tree) use (&$access_anything_tree_manipulator) {
    +      foreach ($tree as $key => $element) {
    +        $tree[$key]->access = AccessResult::allowed();
    +        if ($tree[$key]->subtree) {
    +          $tree[$key]->subtree = $access_anything_tree_manipulator($tree[$key]->subtree);
    +        }
    +      }
    +      return $tree;
    +    };
    

    So we do that in order to avoid whether $tree[$key]->access exists? From an API POV I could imagine creating a tree to render things without carrying about access in the first place.

  2. +++ b/core/lib/Drupal/Core/Menu/DefaultMenuLinkTreeManipulators.php
    @@ -63,33 +63,56 @@ public function __construct(AccessManagerInterface $access_manager, AccountInter
    +        // not perform access checking on the subtree, thanks to bubbling/cache
    +        // redirects. This therefore allows us to still do significantly less
    

    That is nice!

  3. +++ b/core/lib/Drupal/Core/Menu/DefaultMenuLinkTreeManipulators.php
    @@ -155,7 +180,7 @@ protected function collectNodeLinks(array &$tree, array &$node_links) {
    -        $element->access = FALSE;
    +        $element->access = AccessResult::neutral();
    

    Well, it used to be FALSE, doesn't it mean we want to apply ::forbidden() ?

  4. +++ b/core/lib/Drupal/Core/Menu/DefaultMenuLinkTreeManipulators.php
    @@ -169,24 +194,23 @@ protected function collectNodeLinks(array &$tree, array &$node_links) {
    +      return AccessResult::allowed()->cachePerPermissions();
    ...
    +      return AccessResult::allowed()->cachePerPermissions();
    ...
    +      return $this->accessManager->checkNamedRoute($definition['route_name'], $definition['route_parameters'], $this->account, TRUE)->cachePerPermissions();
    

    So all return statements apply cachePerPermissions. What about not doing an early return and apply it at the end? That seems to be possible when we rewrite the code without any additional executed code.

  5. +++ b/core/lib/Drupal/Core/Menu/MenuLinkTree.php
    @@ -192,25 +214,36 @@ public function build(array $tree, $level = 0) {
    +        $menu_name = $link->getMenuName();
    ...
    +        $build['#cache']['tags'][] = 'config:system.menu.' . $menu_name;
    

    Didn't we tried to decouple menu rendering from its config entity many times already? Do we have to reintroduce it again?

  6. +++ b/core/lib/Drupal/Core/Menu/MenuParentFormSelector.php
    @@ -53,7 +54,7 @@ public function __construct(MenuLinkTreeInterface $menu_link_tree, EntityManager
    -  public function getParentSelectOptions($id = '', array $menus = NULL) {
    +  public function getParentSelectOptions($id = '', array $menus = NULL, CacheableMetadata &$cacheability = NULL) {
    
    +++ b/core/lib/Drupal/Core/Menu/MenuParentFormSelectorInterface.php
    @@ -21,12 +23,15 @@
    +   * @param \Drupal\Core\Cache\CacheableMetadata|NULL &$cacheability
    +   *   Optional cacheability metadata object, which will be populated based on
    +   *   the accessibility of the links and the cacheability of the links.
    ...
    -  public function getParentSelectOptions($id = '', array $menus = NULL);
    +  public function getParentSelectOptions($id = '', array $menus = NULL, CacheableMetadata &$cacheability = NULL);
    

    Do we really have to adapt the interface? We seem to not change any of the implementations using that method beside the internal one of the form element.

  7. +++ b/core/modules/block/src/BlockViewBuilder.php
    @@ -103,7 +105,7 @@ public function buildBlock($build) {
    -    if (!empty($content)) {
    +    if (!empty($content) && !(count($content) === 1 && array_keys($content) === ['#cache'])) {
    

    Should we have some form of helper method for that checking? Seems quite random that this is part of blocks.

  8. +++ b/core/modules/block/src/BlockViewBuilder.php
    @@ -131,6 +135,15 @@ public function buildBlock($build) {
    +        CacheableMetadata::createFromRenderArray($build)
    +          ->merge(CacheableMetadata::createFromRenderArray($content))
    +          ->applyTo($build);
    

    It still odd that you need all those three lines. Maybe we could open a follow up to also be able to pass in two render arrays?

  9. +++ b/core/modules/menu_ui/src/Tests/MenuTest.php
    @@ -555,6 +555,9 @@ function testUnpublishedNodeMenuItem() {
    +    // The cache contexts associated with the (in)accessible menu links are
    +    // bubbled.
    
    +++ b/core/modules/page_cache/src/Tests/PageCacheTagsIntegrationTest.php
    @@ -78,7 +78,9 @@ function testPageCacheTags() {
    +      // The cache contexts associated with the (in)accessible menu links are
    +      // bubbled.
    

    Do we mind describing where those are coming from? I could imagine that this eases debugging in the future.

  10. +++ b/core/modules/system/src/SystemManager.php
    @@ -200,6 +200,14 @@ public function getAdminBlock(MenuLinkInterface $instance) {
    +        // @todo Bubble cacheability metadata of both accessible and
    +        //   inaccessible links. Currently made impossible by the way admin
    +        //   blocks are rendered.
    
    +++ b/core/modules/system/system.module
    @@ -1158,6 +1158,12 @@ function system_get_module_admin_tasks($module, array $info) {
    +      // @todo Bubble cacheability metadata of both accessible and inaccessible
    +      //   links. Currently made impossible by the way admin tasks are rendered.
    

    Should we open up a follow up for that?

Wim Leers’s picture

Title: [PP-1] Cache localized, access filtered, URL resolved, (and rendered?) menu trees » Cache localized, access filtered, URL resolved, (and rendered?) menu trees

#2335661: Outbound path & route processors must specify cacheability metadata landed!

I'm gonna work on addressing #160 + test coverage on my flight to LA.

Wim Leers’s picture

Title: Cache localized, access filtered, URL resolved, (and rendered?) menu trees » Cache localized, access filtered, URL resolved, and rendered menu trees
Wim Leers’s picture

First, a straight reroll. Hopefully still green.

(Roaming + phone, plane has not yet left.)

Wim Leers’s picture

FileSize
44.89 KB

#159:

  1. The first bit you quoted happens immediately after access checking has happened, *inside* \Drupal\Core\Menu\DefaultMenuLinkTreeManipulators::checkAccess. I don't think you're questioning that.
    The second bit you quoted exists because \Drupal\menu_link_content\Tests\MenuLinkContentCacheabilityBubblingTest is an integration test that wants to verify cacheability metadata bubbles correctly. Which means we must render the menu tree. Which is done by \Drupal\Core\Menu\MenuLinkTree::build(), which indeed always checks $tree[$key]->access.
    I agree that from an API POV, it should be possible to create a tree without caring about access… and in that case you would do something like this. The alternative is to let \Drupal\Core\Menu\MenuLinkTree::build() happily ignore any MenuTreeElement whose access property is NULL. But then we cannot distinguish access checked from non-access checked elements in the tree.
    I think this is a reasonable compromise (i.e. requiring access to be set to be allowed on every element in the tree when you don't want to do access checking). I hope you do too.
  2. :)
  3. Yes, we could. But I think that's not what we want here. The code you cited has this comment:
            // Deny access by default. checkNodeAccess() will re-add it.
            $element->access = AccessResult::neutral();
    

    Whenever we talk about defaults in terms of access results, we want other code — in this case a menu link tree manipulator that runs *after* this one — to be able to override it. "Forbidden" means "we have conclusively determined that this should never ever be accessible, for explicit reasons". Here, there are no explicit reasons.
    Therefore, I think "neutral" is more appropriate here. If the argument I just gave doesn't convince you though, then I'm happy to change it. I don't feel strongly about it.

  4. Good call, done.
  5. This is not newly introduced by this patch; it's in HEAD. This only changes the indentation of that line.
  6. Not "really" in the sense that we could choose to have forms using this not set the correct cacheability metadata. Now also updated the caller in menu_ui.module. Updating the only other caller (in MenuController) is currently pointless, since it'd imply making the JSON response cacheable, which is out of scope for this issue. I've added a @todo to that one.
  7. I was wondering that too. This is related to #953034: [meta] Themes improperly check renderable arrays when determining visibility. But I honestly can't think of a good place. The best I can think of: Element::hasOnlyCacheabilityMetadata()… which is quiet a mouthful. Other suggestions?
  8. Actually, we have \Drupal\Core\Render\Renderer::mergeBubbleableMetadata() for that already. But indeed, out of scope here.
  9. Done.
  10. Yes; can't do that right now though, writing this on a plane :) That's for a subsequent reroll.
jibran’s picture

Interdiff please. :)

Wim Leers’s picture

FileSize
6.03 KB

Oops.

dawehner’s picture

I don't think you're questioning that.

No, I actually question exactly that ... the API does not force you to add that manipulator, so the building should also not depend on it directly.

This is not newly introduced by this patch; it's in HEAD. This only changes the indentation of that line.

Ah right, shit stays shit, I hope this at least doesn't cause failures if a corresponding menu config does not exist.

Element::hasOnlyCacheabilityMetadata()

Can't we call it Element::emptyRenderArray() or something like that.

Wim Leers’s picture

Status: Needs review » Needs work

The last submitted patch, 167: menu_render_caching-1805054-167.patch, failed testing.

Wim Leers’s picture

Status: Needs work » Needs review
FileSize
46.73 KB
894 bytes
Wim Leers’s picture

This issue still has the "needs tests" issue tag. Let's analyze what's necessary to be able to remove that.

Aspect Test coverage?
general bubbling of cacheability metadata Lots of tests already, RendererBubblingTest and many integration tests.
a link's access check result's cacheability needed
a menu link's cacheability (MenuLinkInterface implementations may return dynamic results, i.e. for dynamic menu link titles or even pointing to different menu links) needed
the rendered link itself (outbound route/path processors) Covered by MenuLinkContentCacheabilityBubblingTest (which was added in #2335661: Outbound path & route processors must specify cacheability metadata)

So, now working on tests coverage for the two aspects that are still lacking test coverage.

dawehner’s picture

Just some more small points.

+++ b/core/tests/Drupal/Tests/Core/Render/ElementTest.php
@@ -179,4 +179,25 @@ public function providerTestSetAttributes() {
+  public function providerTestIsEmpty() {
+    return [
+      [[], TRUE],
+      [['#cache' => []], TRUE],
+      [['#cache' => ['tags' => ['foo']]], TRUE],
+      [['#cache' => ['contexts' => ['bar']]], TRUE],
+
+      [['#cache' => [], '#any_other_property' => TRUE], FALSE],
+      [['#any_other_property' => TRUE], FALSE],
+    ];
+  }

Just for testing for a more realistic usecase it would be nice to add an element with just #markup at some point.

+++ b/core/lib/Drupal/Core/Menu/DefaultMenuLinkTreeManipulators.php
@@ -63,33 +63,56 @@ public function __construct(AccessManagerInterface $access_manager, AccountInter
+      if ($tree[$key]->access->isAllowed()) {

Don't we need an instanceof check here as well?

dawehner’s picture

Status: Needs review » Needs work

.

dawehner’s picture

@Wim Leers
So are you done with that test coverage or can I help you with the two remaining points?

Wim Leers’s picture

Currently writing that test coverage. I'll fix the nits in #171 too, unless you want to do that already (feel free to). Thanks for the review!

Wim Leers’s picture

Status: Needs work » Needs review
Issue tags: -Needs tests
FileSize
60.13 KB
14.99 KB

Here's the test coverage. Of course, I found two nice bugs in the process :)

  1. we were gathering access cacheability metadata even when MenuLinkTreeElement::access === NULL, despite that explicitly being the way that dawehner wants to allow people to use MenuLinkTree::build() to render menu trees without using access checking
  2. most hilariously/epicly: cacheability metadata of any levels beyond the root level was being discarded

Next: addressing #171.

Wim Leers’s picture

FileSize
60.27 KB
786 bytes

#171:

  • Done.
  • No, we don't need that check, because we set it on the preceding line if it's still NULL. (If ::checkAccess() doesn't reliably set an access result, then we are in big trouble.)

Next: IS update.

Fabianx’s picture

  1. +++ b/core/lib/Drupal/Core/Menu/MenuLinkTree.php
    @@ -154,10 +154,12 @@ public function transform(array $tree, array $manipulators) {
    +  public function build(array $tree, $level = 0, CacheableMetadata &$tree_access_cacheability = NULL, CacheableMetadata &$tree_link_cacheability = NULL) {
    

    Why do you need a reference?

    The lowest level should never change and I don't see the caller calling this?

    Also does this not need an interface change?

  2. +++ b/core/lib/Drupal/Core/Menu/MenuLinkTree.php
    @@ -171,7 +173,12 @@ public function build(array $tree, $level = 0) {
    +      if ($data->access instanceof AccessResultInterface) {
    +        $tree_access_cacheability = $tree_access_cacheability->merge(CacheableMetadata::createFromObject($data->access));
    +      }
    

    So $data->access can never be set to FALSE or such?

The last submitted patch, 175: menu_render_caching-1805054-175.patch, failed testing.

Status: Needs review » Needs work

The last submitted patch, 176: menu_render_caching-1805054-176.patch, failed testing.

Wim Leers’s picture

Status: Needs work » Needs review
FileSize
60.37 KB
3.9 KB

#177

  1. I don't, fixed. And no, no interface change is necessary; this is an optional parameter used by this specific implementation. For analogous reasons (internal use only), the $level parameter only exists on this implementation, not on the interface.
  2. Nope. NULL or an AccessResultInterface instance.

Also fixed the hundreds of exceptions.

Wim Leers’s picture

Issue summary: View changes
Issue tags: -Needs issue summary update

Et voila, a vastly better issue summary.

Status: Needs review » Needs work

The last submitted patch, 180: menu_render_caching-1805054-180.patch, failed testing.

Wim Leers’s picture

Status: Needs work » Needs review
FileSize
60.5 KB
2.61 KB

Fixed the test failures. Had to undo #177.1/#178.1, because merging two cacheability metadata objects results in a new cacheability metadata object.

catch’s picture

  1. +++ b/core/lib/Drupal/Core/Menu/DefaultMenuLinkTreeManipulators.php
    @@ -63,33 +63,56 @@ public function __construct(AccessManagerInterface $access_manager, AccountInter
    +   * accessible to some users) will bubble just
    

    Incomplete sentence.

  2. +++ b/core/lib/Drupal/Core/Menu/DefaultMenuLinkTreeManipulators.php
    @@ -63,33 +63,56 @@ public function __construct(AccessManagerInterface $access_manager, AccountInter
    +        // at all, therefore forcing an empty menu on all users.
    

    Would this really be an empty menu for all users, or would just the menu never contain this top level link?

    Also, in the case of root menu links we have to keep the link for the cache context bubbling, but all other levels we don't have to keep - because of cache context bubbling. Without going in and debugging, I can't see just from reading the patch through why only the 'root level inaccessible' links are the only ones that have to be kept and the others discarded, just off the top of my head I'd expect it to be the 'top level inaccessible link' at whichever level of the menu hierarchy - since it's the cache context for that which determines whether there's ever an opportunity to view the links below it.

  3. +++ b/core/lib/Drupal/Core/Menu/MenuLinkTree.php
    @@ -152,17 +154,44 @@ public function transform(array $tree, array $manipulators) {
    +      // tree, yet still automatically the rendered menu by the same cache
    

    automatically vary?

  4. +++ b/core/modules/system/src/SystemManager.php
    @@ -200,6 +200,14 @@ public function getAdminBlock(MenuLinkInterface $instance) {
    +        // @todo Bubble cacheability metadata of both accessible and
    

    What's the impact of this?

  5. +++ b/core/modules/system/tests/src/Unit/Menu/MenuLinkTreeTest.php
    @@ -0,0 +1,261 @@
    +   *    tags and/or contexts), to indicate the extent of dynamicness.
    

    Nit: dynamism (although that's not a perfect word here, but it is a word :P)

  6. +++ b/core/modules/system/tests/src/Unit/Menu/MenuLinkTreeTest.php
    @@ -0,0 +1,261 @@
    +   *    b. anything else: non-permanently cacheable, and/or cache tags, and/or
    

    Is the only choice for non-permanent max-age=0 or can you set max-age=86400 or something?

mbrett5062’s picture

At the top of the issue summary it says this is blocked by #2351015 "and" #2335661.

However, in the remaining tasks it says we must land either #2351015 "or" #2335661.

Which is it?

Wim Leers’s picture

Issue summary: View changes

#185: Oops, I intended to remove that in #181, but clearly I failed. Removed now.

#184: Addressing that next.

mbrett5062’s picture

Thanks @Wim

Wim Leers’s picture

FileSize
60.85 KB
3.09 KB
  1. Fixed.
  2. Yes, it would really be an empty menu for all users, if the first user to trigger a page load with this menu does not have access to any of the menu links in the root level. Since also removing inaccessible subtrees at the root level would result in us not knowing about any cache contexts, Drupal would be forced to conclude that this menu is inaccessible for all users.
    (Funny enough, precisely this is what point 1 is about — but as you pointed out, the sentence was incomplete.)

    […] I'd expect it to be the 'top level inaccessible link' at whichever level of the menu hierarchy - since it's the cache context for that which determines whether there's ever an opportunity to view the links below it.

    Oh my, can't believe I missed that. You're right of course. Fixing that in the next reroll, and will include test coverage.

  3. Yep, fixed.
  4. No impact: remains at status quo. That stuff currently doesn't emit the right cacheability metadata, and it still doesn't. To do so, it requires cleaning up how that stuff is rendered. Hence deferred to a @todo, because that's definitely out of scope for this issue.
  5. Hah, thanks, fixed :)
  6. Oh, yes, any max-age value, of course. I merely chose to only vary cache contexts here, and not max-age and tags as well. The CacheableMetadata test coverage ensures that all of the bits of cacheability metadata (max-age, contexts, tag) are handled correctly. No need to repeat that here.
Wim Leers’s picture

FileSize
62.02 KB
7.19 KB

Here we go.

Status: Needs review » Needs work

The last submitted patch, 189: menu_render_caching-1805054-189.patch, failed testing.

Wim Leers’s picture

Status: Needs work » Needs review

Testbot, you're dumb. Re-testing.

Status: Needs review » Needs work

The last submitted patch, 189: menu_render_caching-1805054-189.patch, failed testing.

Wim Leers’s picture

Status: Needs work » Needs review

Testbot, you suck. Re-testing again.

dawehner’s picture

Status: Needs review » Reviewed & tested by the community

We reviewed it enough over the time. I think we can get this in, finally

webchick’s picture

Assigned: Wim Leers » catch

This one could definitely use catch-like eyeballs, methinks.

Fabianx’s picture

Overall RTBC + 1, just did a very thorough review again.

My main concern being $access change from bool to AccessResultInterface and not having a CR nor a API change tag for that.

Also very unclear of how $access worked before and how that code is now gonna deal with having an object in there.

  1. +++ b/core/lib/Drupal/Core/Menu/MenuLinkTree.php
    @@ -152,17 +154,44 @@ public function transform(array $tree, array $manipulators) {
    -  public function build(array $tree, $level = 0) {
    +  public function build(array $tree, $level = 0, CacheableMetadata &$tree_access_cacheability = NULL, CacheableMetadata &$tree_link_cacheability = NULL) {
    

    Not affecting RTBC status:

    If the interface says public function build() I expect this to be used plus an internal protected function:

    protected function recursiveBuild(array $tree, $level, CacheableMetadata &$tree_access_cacheability = NULL, ...) {
    }

    It is an implementation detail and using default arguments, while it works needs some special cases for level === 0, which would be totally unnecessary with the helper function - hence making the code simpler and always returning $items from recursiveBuild and a render array from build().

    - Would love to see as follow-up.

  2. +++ b/core/lib/Drupal/Core/Menu/MenuLinkTree.php
    @@ -152,17 +154,44 @@ public function transform(array $tree, array $manipulators) {
    +      // However, if $data->access is not an AccessResultInterface object, this
    +      // still will render the menu link, because this method does not want to
    +      // require access checking to be able to render a menu tree.
    

    Just a question:

    If something sets $data->access to FALSE (say I copied code from node before this change) I think then we would hide the link by default for this circumstances, but because no cacheable metadata bubbles up, then it could suddenly be available for me, right?

    Should we not assert strongly (well exception) that its either NULL or TRUE, but not FALSE?

    And if it is FALSE at least add a max-age=0 or throw an exception?

  3. +++ b/core/lib/Drupal/Core/Menu/MenuLinkTree.php
    @@ -152,17 +154,44 @@ public function transform(array $tree, array $manipulators) {
    +      if ($data->access instanceof AccessResultInterface && !$data->access->isAllowed()) {
    +        continue;
    +      }
    

    I assume something deep down checked for if ($data->access === FALSE) before or such? Or how did access checking prior to this work?

  4. +++ b/core/lib/Drupal/Core/Menu/MenuLinkTree.php
    @@ -176,14 +205,15 @@ public function build(array $tree, $level = 0) {
    -      $element['below'] = $data->subtree ? $this->build($data->subtree, $level + 1) : array();
    +      $element['below'] = $data->subtree ? $this->build($data->subtree, $level + 1, $tree_access_cacheability, $tree_link_cacheability) : array();
    

    Nice, here the work to check accessible-from-the-top pays off.

  5. +++ b/core/lib/Drupal/Core/Menu/MenuLinkTree.php
    @@ -192,25 +222,36 @@ public function build(array $tree, $level = 0) {
    +    // Non-root levels: return $items.
    +    if ($level > 0) {
    +      return $items;
    

    If we move it (in a follow-up) to its own helper function, can probably even remove the $level parameter.

  6. +++ b/core/lib/Drupal/Core/Menu/MenuLinkTree.php
    @@ -192,25 +222,36 @@ public function build(array $tree, $level = 0) {
    +        // Set cache tag.
    +        $build['#cache']['tags'][] = 'config:system.menu.' . $menu_name;
    

    Note to committers:

    Commit conflict with the config:system.menu to menu:[menu-name] cache tag conversion.

    Probably this patch would fail if the other was committed first, but alerting in any case ...

  7. +++ b/core/lib/Drupal/Core/Menu/MenuLinkTreeElement.php
    @@ -70,10 +70,10 @@ class MenuLinkTreeElement {
    -   * @var bool|NULL
    +   * @var \Drupal\Core\Access\AccessResultInterface|NULL
    

    Necessary API change, still think we need to at least add an Exception in case someone uses the old interface or does it D7 style by habit ...

    => Information disclosure prevention

    => can be follow-up though

  8. +++ b/core/lib/Drupal/Core/Menu/MenuParentFormSelector.php
    @@ -98,6 +100,7 @@ public function parentSelectElement($menu_parent, $id = '', array $menus = NULL)
           }
    +      $options_cacheability->applyTo($element);
           return $element;
    

    Tricky question:

    Is anything in here allowing to change $element via a hook or something?

    If not we are fine ...

  9. +++ b/core/lib/Drupal/Core/Menu/MenuParentFormSelector.php
    @@ -137,13 +140,29 @@ protected function getParentDepthLimit($id) {
    -  protected function parentSelectOptionsTreeWalk(array $tree, $menu_name, $indent, array &$options, $exclude, $depth_limit) {
    +  protected function parentSelectOptionsTreeWalk(array $tree, $menu_name, $indent, array &$options, $exclude, $depth_limit, CacheableMetadata &$cacheability = NULL) {
    

    Immutable objects have interesting patterns ...

  10. +++ b/core/lib/Drupal/Core/Menu/MenuParentFormSelector.php
    @@ -137,13 +140,29 @@ protected function getParentDepthLimit($id) {
    +        $cacheability = $cacheability
    +          ->merge(CacheableMetadata::createFromObject($element->access))
    +          ->merge(CacheableMetadata::createFromObject($element->link));
    ...
    +      if (!$element->access->isAllowed()) {
    

    Could ->access be NULL or FALSE here and not an object?

    How do we prevent that?

  11. +++ b/core/lib/Drupal/Core/Render/Element.php
    @@ -177,4 +177,20 @@ public static function setAttributes(array &$element, array $map) {
    +  public static function isEmpty(array $elements) {
    +    return empty($elements) || count($elements) === 1 && array_keys($elements) === ['#cache'];
    +  }
    

    What about #post_render_cache and #attached?

    Are we not loosing data that way atm.?

    It is an edge-case, but still asking ...

    --

    O ic, in that case we would treat it as non-empty, which would render the block, which is fine ...

  12. +++ b/core/modules/menu_link_content/src/Tests/MenuLinkContentCacheabilityBubblingTest.php
    @@ -118,6 +129,7 @@ public function testOutboundPathAndRouteProcessing() {
    +      $tree = $menu_tree->transform($tree, [['callable' => $access_anything_tree_manipulator]]);
    
    @@ -140,6 +152,7 @@ public function testOutboundPathAndRouteProcessing() {
    +    $tree = $menu_tree->transform($tree, [['callable' => $access_anything_tree_manipulator]]);
    

    Oh, why do we do that?
    Have to check the issue for that ...

  13. +++ b/core/modules/menu_ui/menu_ui.module
    @@ -402,7 +403,8 @@ function menu_ui_form_node_type_form_alter(&$form, FormStateInterface $form_stat
    +  $options_cacheability = new CacheableMetadata();
    +  $options = $menu_parent_selector->getParentSelectOptions('', NULL, $options_cacheability);
    

    Only slightly related, but what happens if an editor/admin with more rights selected a link and an editor with less rights then comes and tries to save that form? Invalid choice?

  14. +++ b/core/modules/menu_ui/src/Controller/MenuController.php
    @@ -60,6 +60,8 @@ public function getParentOptions(Request $request) {
    +    // @todo Update this to use the optional $cacheability parameter, so that
    +    //   a cacheable JSON response can be sent.
         $options = $this->menuParentSelector->getParentSelectOptions('', $available_menus);
    

    So we currently theoretically disclose information as that JsonResponse is (?) cached by page cache, but necessary contexts are not available?

  15. +++ b/core/modules/menu_ui/src/MenuForm.php
    @@ -337,7 +338,15 @@ protected function buildOverviewForm(array &$form, FormStateInterface $form_stat
    +      $tree_access_cacheability = $tree_access_cacheability->merge(CacheableMetadata::createFromObject($element->access));
    +
    +      // Only render accessible links.
    +      if (!$element->access->isAllowed()) {
    +        continue;
    +      }
    

    Isn't that a rather large UI/UX change (do we have product manager sign-off on that?) that I can no longer access all menus / menu items within the admin?

    Have to find where this was discussed in here.

    --

    Also: Isn't that change out of scope here?

  16. +++ b/core/modules/menu_ui/src/Tests/MenuCacheTagsTest.php
    @@ -54,6 +54,9 @@ public function testMenuBlock() {
    +      // The cache contexts associated with the (in)accessible menu links are
    +      // bubbled.
    +      'config:user.role.anonymous',
    

    Yeah!

  17. +++ b/core/modules/system/src/Controller/SystemController.php
    @@ -128,8 +129,16 @@ public function overview($link_id) {
    +      // Only render accessible links.
    +      if (!$element->access->isAllowed()) {
    +        continue;
    +      }
    

    Again, is this in scope? Why do we change this here?

  18. +++ b/core/modules/system/src/Plugin/Block/SystemMenuBlock.php
    @@ -187,10 +187,14 @@ public function getCacheTags() {
    -      'user.roles',
           'route.menu_active_trails:' . $menu_name,
         ];
    

    Yeah!

  19. +++ b/core/modules/system/src/SystemManager.php
    @@ -200,6 +200,14 @@ public function getAdminBlock(MenuLinkInterface $instance) {
    +      // Only render accessible links.
    +      if (!$element->access->isAllowed()) {
    +        // @todo Bubble cacheability metadata of both accessible and
    +        //   inaccessible links. Currently made impossible by the way admin
    +        //   blocks are rendered.
    +        continue;
    +      }
    
    +++ b/core/modules/system/system.module
    @@ -1168,6 +1168,12 @@ function system_get_module_admin_tasks($module, array $info) {
    +    if (!$element->access->isAllowed()) {
    +      // @todo Bubble cacheability metadata of both accessible and inaccessible
    +      //   links. Currently made impossible by the way admin tasks are rendered.
    +      continue;
    +    }
    

    Are those the changes for it before being ->access as a boolean and now being an AccessResultInterface?

    Should not the code that before checked $access do that?

webchick’s picture

Assigned: catch » Wim Leers
Status: Reviewed & tested by the community » Needs work

Bonk.

Wim Leers’s picture

Assigned: Wim Leers » Fabianx
Status: Needs work » Needs review
FileSize
61.1 KB
8.24 KB
  1. Fair point, done.
  2. I'm always +1 for strict type checking, so added that.
  3. Yes, see \Drupal\Core\Menu\DefaultMenuLinkTreeManipulators::checkAccess's if (!isset($element->access)).
  4. Indeed.
  5. Oh, I see what you mean. I adjusted the changes I made for point 1 to make this possible. Cleaner indeed!
  6. Yep, this will conflict with #2488918: Use 'menu:<menu name>' as cache tag for menus instead of 'config:system.menu:<menu name>' to avoid coupling, but that's easy enough to resolve. Preferably this one would go in first, of course, since it is critical.
  7. Already did that now as part of point 2.
  8. No, there isn't. So we're fine.
  9. Heh, ok.
  10. No, this can never be NULL or FALSE, it is always an object, because ::getParentSelectOptions() always uses the ::checkNodeAccess() + ::checkAccess() menu link tree manipulators. That means this code will always receive MenuLinkTreeElement objects with the access property set to AccessResultInterface objects.
  11. Indeed. In either of those cases you mention, something is rendered. Only when the render array is actually empty, or has #cache to specify the reasons for its emptiness, it is considered empty.
  12. dawehner asked this too in #159.1. The answer used to be that it became required to have AccessResultInterface objects as MenuLinkTreeElement::access values. But that's no longer true! Good catch :)
    So reverting all changes in this file; tests should still pass. That's another piece of proof that dawehner's request/requirement to have MenuLinkTree::build() also work on non-access checked trees, still works.
  13. That's completely out of scope for this issue. This issue only makes sure the associated cacheability metadata is present on the form. This issue does not at all change which options are presented to the end user!
  14. No, one needs the "administer menu" permissions. But you're right that the necessary cache contexts/tags are not associated with the JSON response. Pre-existing problem though, that's out of scope here. Then again, this will never end up in Page Cache, because it's not accessible by anonymous users.
  15. No, there is zero behavior change here. The removal of inaccessible links must now simply happen in the code rendering the tree, because it used to be that \Drupal\Core\Menu\DefaultMenuLinkTreeManipulators::checkAccess() itself already removed inaccessible links! That, however, was very, very, very wrong/problematic: it prevented the rendering code to know the cacheability metadata of inaccessible links. Which meant that we only knew the cacheability metadata of accessible links. Which of course is insufficient/incomplete knowledge. And could result in some branches of a menu never becoming accessible, because we didn't know about the cache context that would make that link accessible to other users!
  16. :)
  17. Same as 2 points higher.
  18. :)
  19. Same as 2 points/4 points higher.

So, good review, but fortunately lots of things that have been answered before. It's good that you now have full clarity on them.

So, only 2 changes in this patch:

  1. restructure MenuLinkTree::build() a bit, so that it doesn't add optional parameters relative to what the interface says, and to have easier to understand code
  2. revert changes to MenuLinkContentCacheabilityBubblingTest
larowlan’s picture

Looking good, just a couple of questions/comments

  1. +++ b/core/lib/Drupal/Core/Menu/MenuLinkTree.php
    @@ -152,17 +154,99 @@ public function transform(array $tree, array $manipulators) {
    +    $tree_cacheability = $tree_access_cacheability->merge(CacheableMetadata::createFromObject($tree_link_cacheability));
    

    $tree_link_cacheability is already a CacheableMetadata object - any reason why we use createFromObject again? If so could we expand the comment.

  2. +++ b/core/lib/Drupal/Core/Menu/MenuLinkTree.php
    @@ -152,17 +154,99 @@ public function transform(array $tree, array $manipulators) {
    +      // still will render the menu link, because this method does not want to
    

    %/still will/will still/?

  3. +++ b/core/lib/Drupal/Core/Render/Element.php
    @@ -177,4 +177,20 @@ public static function setAttributes(array &$element, array $map) {
    +    return empty($elements) || count($elements) === 1 && array_keys($elements) === ['#cache'];
    

    Should we wrap the second condition in () to make it clear the order here?

  4. +++ b/core/modules/block/src/BlockViewBuilder.php
    @@ -131,6 +135,15 @@ public function buildBlock($build) {
    +      // *why* they are empty.
    

    Any reason for the asterisks here, we don't normally do that?

Fabianx’s picture

Assigned: Fabianx » Wim Leers
Status: Needs review » Needs work
Issue tags: +API change, +Needs change record, +Needs beta evaluation, +Needs issue summary update

Thanks for the great response!

- Tagging API change
- This needs a change record for the API break (I agree the API change is more than necessary)
- It would be great to have a quick beta evaluation, too

- This needs also a change record and an issue summary update for the way more subtle, but important part that you know need to check access yourself (!) after using the default menu tree manipulators.

And this is where I am unfortunately still a little unhappy.

But given we don't have anyway to store cacheability data on something like the root of the tree, this is likely still the best option to keep the element.

However:

Don't we have a NULL link / Menu Element we could set to prevent ever exposing the path / menu text accidentally? (to prevent new information disclosure)

It feels to me that the caller needs to check access is acceptable, but only if we ensure we don't expose the underlying data.

So lets say we would have:

Before:

unset($tree[$key]);

After:

$tree[$key] = (object) [
  'access' => $tree[$key]['access'],
  'subtree' => [],
  'link' => new InvalidLinkAccessLink(t('The caller has not checked access properly. Please fix the code ...')); // _trigger_error when trying to render that link ...
];

Don't know too much about the data structures used there, but that would be my gut feeling how to prevent this API change from causing any information disclosure down the road.

If we feel InvalidLinkAccessLink() is too complicated, could also be an empty link or whatever makes the most sense ...

Fabianx’s picture

And another nit-review with mostly optional changes:

  1. +++ b/core/lib/Drupal/Core/Menu/DefaultMenuLinkTreeManipulators.php
    @@ -63,33 +63,58 @@ public function __construct(AccessManagerInterface $access_manager, AccountInter
    +   * @param int $level
    +   *   The level of the tree we're doing access checking for.
    ...
    -  public function checkAccess(array $tree) {
    +  public function checkAccess(array $tree, $level = 0) {
    ...
    -          $tree[$key]->subtree = $this->checkAccess($tree[$key]->subtree);
    +          $tree[$key]->subtree = $this->checkAccess($tree[$key]->subtree, $level + 1);
    

    nit - $level is no longer used

  2. +++ b/core/lib/Drupal/Core/Menu/DefaultMenuLinkTreeManipulators.php
    @@ -63,33 +63,58 @@ public function __construct(AccessManagerInterface $access_manager, AccountInter
    +        // Always keep top-level inaccessible links: their cacheability metadata
    +        // that indicates why they're not accessible by the current user must be
    +        // bubbled. Otherwise, those subtrees will not be varied by any cache
    +        // contexts at all, therefore forcing them to remain empty for all users
    +        // unless some other part of the menu link tree accidentally varies by
    +        // the same cache contexts.
    +        // For deeper levels, we *can* remove the subtrees and therefore also
    +        // not perform access checking on the subtree, thanks to bubbling/cache
    +        // redirects. This therefore allows us to still do significantly less
    +        // work in case of inaccessible subtrees, which is the entire reason why
    +        // this deletes subtrees in the first place.
    

    That comment does no longer make as much sense as the behavior is the same for every level.

  3. +++ b/core/lib/Drupal/Core/Menu/MenuLinkTree.php
    @@ -152,17 +154,99 @@ public function transform(array $tree, array $manipulators) {
    +   * @param int $level
    +   *   Internal use only. The level that is currently being built.
    ...
    +  protected function buildItems(array $tree, $level, CacheableMetadata &$tree_access_cacheability, CacheableMetadata &$tree_link_cacheability) {
    
    @@ -176,14 +260,15 @@ public function build(array $tree, $level = 0) {
    -      $element['below'] = $data->subtree ? $this->build($data->subtree, $level + 1) : array();
    +      $element['below'] = $data->subtree ? $this->buildItems($data->subtree, $level + 1, $tree_access_cacheability, $tree_link_cacheability) : array();
    

    nit - $level is unused now.

  4. +++ b/core/modules/system/src/Controller/SystemController.php
    @@ -145,15 +154,19 @@ public function overview($link_id) {
    -      );
    +      ];
    +      $tree_access_cacheability->applyTo($build);
    +      return $build;
    ...
    +      ];
    +      $tree_access_cacheability->applyTo($build);
    +      return $build;
    

    nit nit nit - we want to do the same in the if/else case, could theoretically simplify the code here.

jibran’s picture

Issue tags: -Needs beta evaluation

It would be great to have a quick beta evaluation, too

Why would we want that? This is critical issue related to performance. It is already triaged by commiters.

Fabianx’s picture

Issue tags: +Needs beta evaluation

Because of the disruption section.

Wim Leers’s picture

Assigned: Wim Leers » Fabianx
Issue summary: View changes
Status: Needs work » Needs review
Issue tags: -Needs change record, -Needs issue summary update, -Needs beta evaluation
FileSize
60.47 KB
6.55 KB

#201:

  1. Hah, copy/paste stupidity. Indeed a small improvement. Fixed.
  2. Sure, changed. Non-native-speakerness shining through.
  3. Sure, done.
  4. No reason other than emphasis. Removed.

RE: unhappiness. There are many factors significantly diminishing the risk:

  1. 99% of people will just use the menu blocks. And hence be safe.
  2. 90% of the 1% will use MenuLinkTree::build(). And hence be safe.
  3. 10% of the 1% will use custom menu tree rendering. There clearly is an MenuLinkTreeElement::access property. It already exists in HEAD. It was just being strangely abused/underused by the the default menu link tree access checking logic, solely for legacy reasons. It's there, and it's clearly documented. Any half-assed QA testing would cause the developer to discover that inaccessible links are being rendered.

I think that's more than enough risk limiting. The only way we can every truly prevent any problems ever here, is by somehow disallowing menus to be rendered manually. Which we cannot prevent.

On top of that, your proposal doesn't make sense, because it is acceptable to not run access checking: if MenuLinkTreeElement === NULL, then no access checking happened, and we just render the entire tree. @dawehner very, very strongly felt that it was important we don't require access checking to be able to render a menu tree. See #159.1 + #166.1.


#203:

  1. Great catch, removed :)
  2. That comment does make sense. It used to say "root level". Now it says "top-level inaccessible links", which is the exact wording used by @catch. It means "a subtree whose root link is inaccessible". By keeping the link itself, but setting its own subtree to the empty array, we effectively do what that comment says.
  3. Great catch again, fixed :)
  4. Yes, but that requires more code changes, I optimizer for fewer code changes. Kept it the same.

Rebased; there were a few conflicts caused by #2491155: Update drupal.org and kernel.org URLs in core modules (Follow-up to 2489912).

Wim Leers’s picture

FileSize
63.51 KB
6.17 KB

Discussed further with Fabianx & catch, they feel quite strongly about the thing Fabianx proposed in #202:

  'link' => new InvalidLinkAccessLink(t('The caller has not checked access properly. Please fix the code ...')); // _trigger_error when trying to render that link ...

So, implemented that. Found a way to minimize the DX damage, i.e. by still allowing people debugging code to see which link is actually the inaccessible one.

dawehner’s picture

Mh, doesn't that adds quite some memory for users without access? I'm especially thinking in terms of #2250315: Regression: Bring back node access optimization to menu trees

alexpott’s picture

Adding dawehner, catch, larowlan and fabianx to the credit section.

Wim Leers’s picture

FileSize
63.48 KB
783 bytes

#208: Yes, it'd increase memory usage a bit, but not a whole lot, because only the top-level inaccessible links get this treatment. Deeper links are still removed altogether.


Fabianx pointed out a silly oversight in #207. Fixing.

Wim Leers’s picture

I think we should also give commit credit to sun for comment #14, which was hugely important.

alexpott’s picture

Adding sun to the credit section.

Fabianx’s picture

Status: Needs review » Reviewed & tested by the community

This looks very great to me now!

Thanks for taking care of my concerns:

- Issue summary, change record, beta eval look very great
- All feedback has been addressed

Back to RTBC!

Fabianx’s picture

Assigned: Fabianx » catch
dawehner’s picture

#208: Yes, it'd increase memory usage a bit, but not a whole lot, because only the top-level inaccessible links get this treatment. Deeper links are still removed altogether.

Ah good to know, thank you!

RTBC+1

jibran’s picture

Huh!!! I posted the same point before but couldn't find it now so adding back again.

+++ b/core/lib/Drupal/Core/Menu/MenuLinkTree.php
@@ -152,17 +154,97 @@ public function transform(array $tree, array $manipulators) {
+    $build = [];
...
+    $tree_cacheability->applyTo($build);
...
+    if ($items) {
...
+    return $build;

$build is empty and we are adding the cache meta data anyway. Wouldn't it make more sense to add it in `if` when we actually have a render array? Or before returning from the function if we want to add it anyway?

Wim Leers’s picture

$build is empty and we are adding the cache meta data anyway. Wouldn't it make more sense to add it in `if` when we actually have a render array? Or before returning from the function if we want to add it anyway?

That's very much intentional: when the rendered menu — in a block or elsewhere — is empty, then we don't want to just render cache that empty menu and call it a day: we want to know the cache contexts that explain why it is empty. Simple example: all links in a menu have permission-based access control. As the anonymous user, I will be unable to see any links, and the reason for that is a lack of permissions. So, the render cached empty menu has the user.permissions cache context. So that if another user with a different set of permission accesses a page with this menu, (s)he does see the menu links.

jibran’s picture

That's very much intentional: when the rendered menu — in a block or elsewhere — is empty, then we don't want to just render cache that empty menu and call it a day: we want to know the cache contexts that explain why it is empty.

This is WTF IMHO because it can be easily missed. Do we always have to mention a cache matedata for a render element whether it's showing on the page for current user or not? Or is it a special case for menu.

Simple example: all links in a menu have permission-based access control. As the anonymous user, I will be unable to see any links, and the reason for that is a lack of permissions. So, the render cached empty menu has the user.permissions cache context. So that if another user with a different set of permission accesses a page with this menu, (s)he does see the menu links.

But, I do agree with this.

Thanks for the explanation.

Wim Leers’s picture

This is WTF IMHO because it can be easily missed. Do we always have to mention a cache matedata for a render element whether it's showing on the page for current user or not? Or is it a special case for menu.

If the emptiness is conditional and it is render cached then it's required. Otherwise, we'd end up sending the render cached version (the empty string) to all users (anon, auth, admin, all).

dawehner’s picture

This is WTF IMHO because it can be easily missed. Do we always have to mention a cache matedata for a render element whether it's showing on the page for current user or not? Or is it a special case for menu.

That is a thing used everywhere now, or at least should better be done, as otherwise you have easily potential security holes by not having the required cache contexts.

  • catch committed 9ae3c9f on 8.0.x
    Issue #1805054 by Wim Leers, Gábor Hojtsy, effulgentsia, jibran,...
catch’s picture

Status: Reviewed & tested by the community » Fixed

That is a thing used everywhere now, or at least should better be done, as otherwise you have easily potential security holes by not having the required cache contexts.

This should be OK for security.

User 1 has access - stuff is rendered - cache contexts are bubbled.

User 2 does not have access - stuff is not rendered - cache contexts are not bubbled.

It would be possible for 1 to see content meant for user 2, but not for user 2 to see content for user 1. So there's a bug, but no information disclosure.

Patch looks great now. Issue is almost as old as Drupal 8 itself.

Committed/pushed to 8.0.x, thanks!

Wim Leers’s picture

Wim Leers’s picture

If you want to help out with making menus and cacheability better in D8, then hopefully I will see you fine folks in

I've either provided patches, or initial patches in all of them. Please check them out, and help if you're interested :) Thanks!

Wim Leers’s picture

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.