Proposed commit message:

Issue #2543332 by Wim Leers, Fabianx, effulgentsia, Crell, dawehner, borisson_: Auto-placeholdering for #lazy_builder without bubbling

Problem/Motivation

See #2499157: [meta] Auto-placeholdering.

Proposed resolution

Elements that have:

  1. #lazy_builder
  2. poor cacheability (low max-age, high-cardinality cache contexts or high-invalidation frequency cache tags (concrete examples: see below)

Remaining tasks

Review.

User interface changes

None.

API changes

None.

API addition: auto_placeholder_conditions: the conditions can be set for any of the cacheability metadata properties:

  • max-age: if at or below a specified max-age, it qualifies for auto-placeholdering, comes with a sane default: 0 — but some sites may want to set this to e.g. 60 (to placeholder anything that can only be cached for a minute)
  • contexts: any contexts that are specified make it qualify for auto-placeholdering (recommended: only high cardinality cache contexts, comes with a sane default: ['session', 'user'] — but some sites may want to exclude e.g. 'user' because they have a very limited number of users)
  • tags: any cache tags that are specified make it qualify for auto-placeholdering (recommended: only cache tags that have a very high invalidation frequency, comes with a sane default: [] — but some sites may want to exclude e.g. a cache tag like current-temperature for sites showing the current temperature)

Data model changes

None.

Support from Acquia helps fund testing for Drupal Acquia logo

Comments

Wim Leers’s picture

Title: Auto-placeholdering for <code>#lazy_builder</code> without bubbling » Auto-placeholdering for #lazy_builder without bubbling
Wim Leers’s picture

Issue summary: View changes
Status: Active » Needs review

Notes to aid reviewers:

  • very simple changes to Renderer!
  • adds renderer.config.high_cardinality_cache_contexts to default.services.yml, the default value is: ['session', 'user'], i.e. when those are encountered, placeholdering will happen, because letting those cache contexts bubble and affect the supertree/response results in poor cacheability
  • adds 4 new permutations/test cases to \Drupal\Tests\Core\Render\RendererPlaceholdersTest::providerPlaceholders(), bringing it to 8 tested permutations (that themselves go through multiple scenarios):
    1. #lazy_builder + #cache[max-age] = 0 + NO #cache[keys]
    2. #lazy_builder + #cache[max-age] = 0 + #cache[keys]
    3. #lazy_builder + #cache[contexts] = [user] + NO #cache[keys]
    4. #lazy_builder + #cache[contexts] = [user] + #cache[keys]
Wim Leers’s picture

LOL, forgot to attach the patch.

Wim Leers’s picture

If people are +1 to that, we could also already get rid of one manual #create_placeholder call in Drupal 8: that for the comment form. It can automatically be placeholdered easily now: we only need to provide the cache contexts it varies by.
I'm happy to remove this from the patch if it feels too distracting. But in any case it provides a clear (and easy to test) example that shows the benefits.

This makes CommentDefaultFormatter significantly simpler.

Status: Needs review » Needs work

The last submitted patch, 4: auto-placeholdering-step1-2543332-4.patch, failed testing.

Fabianx’s picture

Overall looks really truly fantastic, I agree to include one conversion here.

Just had one quick remark:

+++ b/sites/default/default.services.yml
@@ -84,6 +84,14 @@ parameters:
+    # @default ['session', 'user']
+    high_cardinality_cache_contexts: ['session', 'user']

Either this default needs to live in core.services.yml as well OR

in the __construct() we do something like:

$this->config = $config + [
'required_cache_contexts' => [ '...' ],
'high_card_...' => ['...'],
]

Wim Leers’s picture

Status: Needs work » Needs review
FileSize
23.25 KB
3.98 KB

#6: Yep: that bit being missing from core.services.yml also caused a kernel test failure. (I very much disagree with all this duplication, but that's a subject for another issue — opened #2543514: Confusing duplication of container parameters in core.services.yml and default.services.yml for that.)

effulgentsia’s picture

Patch looks great. Just some minor nits and questions:

  1. +++ b/core/lib/Drupal/Core/Render/Renderer.php
    @@ -636,6 +645,29 @@ protected function replacePlaceholders(array &$elements) {
    +   * @see sites/default/default.services.yml
    

    I don't think we need that. We don't do it for other container parameters.

  2. +++ b/core/modules/comment/src/Plugin/Field/FieldFormatter/CommentDefaultFormatter.php
    @@ -184,30 +184,23 @@ public function viewElements(FieldItemListInterface $items) {
    +          // All anonymous users get the same form, but authenticated users get
    +          // personalized forms.
    

    Why? I realize this is already the case in HEAD, but I think a comment explaining why comment forms are personalized would be helpful. Is it just the CSRF token or something else? If it's the CSRF token, why doesn't FAPI set the 'user' cache context on that token element? No need to refactor that here, but a comment, and if applicable, a @todo linking to an issue, would help.

  3. +++ b/core/modules/comment/src/Tests/CommentTranslationUITest.php
    @@ -38,10 +38,9 @@ class CommentTranslationUITest extends ContentTranslationUITestBase {
    -    'user.permissions',
    -    'user.roles'
    +    'user'
    +++ b/core/modules/node/src/Tests/NodeTranslationUITest.php
    @@ -27,13 +27,12 @@ class NodeTranslationUITest extends ContentTranslationUITestBase {
    -    'user.permissions',
    -    'user.roles'
    +    'user'
    

    Is this because of the CommentDefaultFormatter change or due to something else? If due to the CommentDefaultFormatter change, then a) why does that affect the translation UI, and b) how was HEAD working prior to this patch, given that the comment form was personalized there as well?

Wim Leers’s picture

  1. Okay; @dawehner asked for this in another issue, so I figured I'd do it here too. I'll remove it.
  2. Because for authenticated users, we don't show a "enter your username" field, but instead we show the current user's name, linking to his profile. Hence it is personalized per user, so the user cache context is necessary.
  3. Yes, it is because of the changes to CommentDefaultFormatter: rather than hardcoding its #create_placeholder = TRUE, it now just sets the cache contexts that it varies by (user, if the current user is authenticated) and lets auto-placeholdering take care of that. Which means that user cache context still applies to the resulting final page that has the placeholder for the comment form replaced. And since user implies user.permission and user.roles, these changes to the expectations are necessary.
Crell’s picture

  1. +++ b/core/lib/Drupal/Core/Render/Renderer.php
    @@ -337,6 +340,12 @@ protected function doRender(&$elements, $is_root_call = FALSE) {
    +    // Determine whether to do auto-placeholdering.
    +    if (isset($elements['#lazy_builder']) && (!isset($elements['#create_placeholder']) || $elements['#create_placeholder'] !== FALSE)) {
    +      if ($this->hasPoorCacheability($elements)) {
    +        $elements['#create_placeholder'] = TRUE;
    +      }
    +    }
    

    Why is this 2 separate conditionals? Seems like it's just one long set of them...

  2. +++ b/core/modules/comment/src/Plugin/Field/FieldFormatter/CommentDefaultFormatter.php
    @@ -184,30 +184,23 @@ public function viewElements(FieldItemListInterface $items) {
    +            '#lazy_builder' => ['comment.lazy_builders:renderForm', [
    

    Since I can't keep up with all the moving parts of render API, am I reading this correctly that the lazy builder can/should be a service name and method? (The correct answer is yes.)

  3. +++ b/core/modules/comment/src/Plugin/Field/FieldFormatter/CommentDefaultFormatter.php
    @@ -184,30 +184,23 @@ public function viewElements(FieldItemListInterface $items) {
    +          // All anonymous users get the same form, but authenticated users get
    +          // personalized forms.
    +          if ($this->currentUser->isAuthenticated()) {
    +            $output['comment_form']['#cache']['contexts'][] = 'user';
               }
    

    I'm confused. Why do we need to add the user context only if the user is authenticated? All anonymous users should get the same form, presumably, and all have the same user ID, so why would adding the user context in all cases be a problem?

  4. +++ b/sites/default/default.services.yml
    @@ -84,6 +84,14 @@ parameters:
    +    # The Renderer will automatically placeholder cacheable render arrays that
    +    # have a #lazy_builder callback, to ensure fragments that vary by these
    +    # cache contexts do not affect the containing fragments.
    +    #
    +    # @default ['session', 'user']
    +    high_cardinality_cache_contexts: ['session', 'user']
    

    This is a file that people will be customizing early in their Drupal experience. To someone who hasn't already written large portions of the render API, this paragraph is completely useless if not counter-productive with how much it assumes. Even I only barely follow it, and I am reading this patch. :-)

    Alternative text:

    Drupal allows portions of the page to be automatically deferred when rendering to improve cache performance. That is especially helpful for cache contexts that vary widely, such as the active user. On some sites those may be different, however, such as sites with only a handful of users. If you know what the high-cardinality cache contexts are for your site, specify those here. If you're not sure, the defaults are fairly safe in general.

    And then maybe a @see to something that explains about #lazy_builder (which I've not actually heard about until just now, to show how obscure that reference is).

Wim Leers’s picture

#8 Fixed point 1, and fixed point 2 by improving the code comments.

#10:

  1. LOL, excellent question. To which the answer is: because I'm stupid :)
  2. This question is out of scope here and is answered by the CR: https://www.drupal.org/node/2498803. Simply put: all of the Renderer callbacks support "regular" callbacks (PHP's native ones), plus a method on a service. Hence #lazy_builder supports that too.
  3. Already answered in #9 and in the updated comment in this reroll. But! Yes, technically we could just always add the user cache context. But I think this is a bit conceptually clearer, and is more in line with the original logic that this patch now simplifies.
    If you feel strongly about this though, I'm happy to change it to just 'user' :)
  4. This is a file that people will be customizing early in their Drupal experience.

    I'm glad you said that, because that statement is apparently wrong — I was under the same false impression. See #2543514: Confusing duplication of container parameters in core.services.yml and default.services.yml. At least I was not alone :)

    RE: not being able to follow it — thanks for the suggested text, much better indeed. Used it verbatim :) Also added that link you asked for, but not with an @see, but in the same language that is used in the twig.config.debug documentation.

Crell’s picture

Re #11.3: On the list of things I have a strong feeling about it's fairly low on the list :-), but it does strike me as something that is going to confuse a lot of people who stare at it and go "what's the subtle reason we have to only add it conditionally, what am I missing?" which is exactly what I did. If the answer is "meh, no particular reason", then let's skip the confusing part and just always add it.

If there is some subtle reason for it, the that needs to be better documented.

Wim Leers’s picture

Well, we only conditionally vary per user (i.e. if the user is authenticated). So, we use the same conditionality to associate the 'user' cache context. That's the entire reason. I think #11 documented that very clearly, didn't it?

effulgentsia’s picture

But I think this is a bit conceptually clearer, and is more in line with the original logic that this patch now simplifies.

I share the same confusion as Crell. In HEAD, there's an if/else for anonymous/authenticated, because one uses #lazy_builder and the other doesn't. But the patch unifies to always use #lazy_builder. So, given that, I'm confused how:

+++ b/core/modules/comment/src/Plugin/Field/FieldFormatter/CommentDefaultFormatter.php
@@ -184,30 +184,25 @@ public function viewElements(FieldItemListInterface $items) {
+            '#cache' => [
+              'contexts' => [
+                'user.roles:authenticated',
+              ],
+            ],
+          ];
+          if ($this->currentUser->isAuthenticated()) {
+            $output['comment_form']['#cache']['contexts'][] = 'user';
           }

has any functional difference to:

+            $output['comment_form']['#cache']['contexts'][] = 'user';

So all I see is 8 lines of code performing what could be done with 1.

Well, we only conditionally vary per user (i.e. if the user is authenticated).

Huh? All anonymous users have uid=0, so what does it mean to "only conditionally vary per user if the user is authenticated"?

effulgentsia’s picture

On the other hand:

+++ b/core/modules/comment/src/Plugin/Field/FieldFormatter/CommentDefaultFormatter.php
@@ -184,30 +184,25 @@ public function viewElements(FieldItemListInterface $items) {
+          // All anonymous users get the same form, but authenticated users get
+          // personalized forms (instead of showing a "Your name" field where
+          // the user can enter their name, the field displays the name of the
+          // current user instead, which is of course different per user.)
+          if ($this->currentUser->isAuthenticated()) {
+            $output['comment_form']['#cache']['contexts'][] = 'user';
           }

Does it make sense to disconnect this context assignment from the "Your name" field? Shouldn't we do this within CommentForm::form() instead, at the point that we actually perform the personalization? Is doing it here just a temporary artifact of this issue's title ending with "without bubbling"? If so, then maybe leaving it as a separate conditional makes sense, if we add a @todo to the "with bubbling" issue?

Wim Leers’s picture

#14: There indeed is no functional difference, only a semantical difference.

#15: Correct, this is just a temporary artifact until #2543334: Auto-placeholdering for #lazy_builder with bubbling of contexts and tags! I should've added such a @todo from the very beginning, that'd have made this clearer. My bad. Done.

effulgentsia’s picture

dawehner’s picture

+++ b/core/modules/comment/src/Plugin/Field/FieldFormatter/CommentDefaultFormatter.php
@@ -184,30 +184,25 @@ public function viewElements(FieldItemListInterface $items) {
+          // All anonymous users get the same form, but authenticated users get
+          // personalized forms (instead of showing a "Your name" field where
+          // the user can enter their name, the field displays the name of the
+          // current user instead, which is of course different per user.)

I'm curious, could this username display be a placeholder for itself so most of the form could be cached? Is there an issue for that?

Wim Leers’s picture

#17 looks great to me. I wanted to minimize change, but this is clearly better :)

#18: I've considered that too, and even tried it a long time ago (>1 year ago). It doesn't work because Form API tracks which fields exist in a given built form, for validation/security purposes. We cannot just add a form field at a later time.

borisson_’s picture

  1. +++ b/core/lib/Drupal/Core/Render/Renderer.php
    @@ -170,6 +170,9 @@ protected function renderPlaceholder($placeholder, array $elements) {
    +    // Prevent it from being auto-placeholdered again.
    

    Prevent the renderable array? Or the placeholder?

  2. +++ b/core/lib/Drupal/Core/Render/Renderer.php
    @@ -636,6 +643,27 @@ protected function replacePlaceholders(array &$elements) {
    +    return
    +      // Auto-placeholder if max-age === 0.
    +      (isset($element['#cache']['max-age']) && $element['#cache']['max-age'] === 0)
    +      ||
    +      // Auto-placeholder if a high-cardinality cache context is set.
    +      (isset($element['#cache']['contexts']) && array_intersect($element['#cache']['contexts'], $this->rendererConfig['high_cardinality_cache_contexts']));
    

    I think the readability would be better if you split this if up into 2 different if statements.

    if (...) { return true; }
    if (...) { return true; }
    return false;
    
dawehner’s picture

Responded as a new issue #2544560: Try to make the comment form cacheable. I think its solveable.

Wim Leers’s picture

  1. Clarified. (Though I don't think there was room for misinterpretation, given the variable that was being modified.)
  2. Done.
Crell’s picture

Status: Needs review » Reviewed & tested by the community

I'm comfortable with where this is ending up. Nice work, all!

Wim Leers’s picture

Status: Reviewed & tested by the community » Needs work

catch reviewed this (see IRC log below), and wants to see two changes that Fabianx & I agree with:

  1. the renderer.config.high_cardinality_cache_contexts container parameter should be renamed to renderer.config.placeholder_cache_contexts (rationale: see below)
  2. the protected hasPoorCacheability() helper method should be renamed to shouldAutomaticallyPlaceholder() (rationale: see below)
12:55:51 catch: WimLeers: ping?
12:56:10 WimLeers: catch: pong
12:56:34 catch: WimLeers: so https://www.drupal.org/node/2543332 I'm not sure about high cardinality/poor cacheability.
12:56:37 Druplicon: https://www.drupal.org/node/2543332 => Auto-placeholdering for #lazy_builder without bubbling #2543332: Auto-placeholdering for #lazy_builder without bubbling => 23 comments, 3 IRC mentions
12:56:47 WimLeers: catch: which one?
12:57:02 WimLeers: catch: or you mean the concept of auto-placeholdering for such cache contexts at all?
12:57:10 WimLeers: Fabianx-screen: ^^
12:57:11 catch: WimLeers: per-user can be low cardinality if you have one account on a site, per-user is always lower cardinality than url cache context, since any Drupal site has a url per user.
12:57:45 catch: WimLeers: why not just call them 'placeholder_cache_context'?
12:58:10 WimLeers: catch: but things that are per-URL almost always only appear on a certain URL. Per-user things almost always appear on many URLs.
12:58:12 catch: WimLeers: it makes sense to placeholder them, but we need to be clear why.
12:58:26 catch: WimLeers: right, so the issue is not the cardinality.
12:58:37 WimLeers: I see your point
12:58:47 WimLeers: Per URL is also high cardinality, that's true
12:58:59 catch: Same with query args, that's infinite.
12:59:44 WimLeers: catch: So, renaming it to 'placeholder_cache_contexts' makes sense to me from that POV
13:01:02 WimLeers: catch: Good arguments!
13:02:10 catch: WimLeers: I was trying to think of another term, but I can't.
13:02:25 catch: So I think naming it for what we do with it might be the only option.
13:02:52 catch: WimLeers: so then poor cacheability - can we make that hasPlaceholderableCacheContext or something?
13:03:39 WimLeers: catch: I was also trying to think of another term
13:03:44 WimLeers: catch: also failed :/
13:04:07 WimLeers: catch: RE: hasPlaceholderableCacheContext() does not capture the fact that we also placeholder for max-age=0
13:04:28 WimLeers: catch: I can rename it to shouldPlaceholder() or shouldAutomaticallyPlaceholder() or something like that
13:04:39 catch: WimLeers: that could work.
13:04:51 Fabianx-screen: WimLeers: shouldAutomaticallyPlaceholder works for me.
13:06:04 Fabianx-screen: FWIW
13:07:47 WimLeers: catch: Fabianx-screen: ok, so rename those two things. Will do.
13:07:53 WimLeers: catch++
13:07:56 catch: *afk quickly.*
Wim Leers’s picture

Issue summary: View changes
Status: Needs work » Needs review
FileSize
26.75 KB
9.92 KB

Discussed it more with catch & Fabianx. Refined #24 more.

13:31:19 WimLeers: catch: Fabianx-screen: what about "auto_placeholder_cache_contexts" instead of "placeholder_cache_contexts"?
13:32:10 WimLeers: catch: Fabianx-screen:  Or "placeholder_triggering_cache_contexts"
13:34:10 WimLeers: catch: Fabianx-screen: or even:
  renderer.config:
    auto_placholder_triggers:
      max-age: 0
      cache_contexts: ['session', 'user']
13:34:29 WimLeers: catch: that'd allow you to say that for your site, you also want to placeholder anything that has a max-age below 2 minutes, for example
13:35:21 catch: WimLeers: hmm not sure that is more useful than placeholder_cache_contexts?
naveenvalecha|af [~naveenval@49.249.252.168] entered the room. (13:36:01)
13:36:11 WimLeers: catch: It'd be more consistent, because then the shouldAutomaticallyPlaceholder() method entirely depends on configuration parameters
13:36:37 WimLeers: catch: "placeholder_cache_contexts" can be misread as "the cache contexts that are assigned to every placeholder", at least at first sight
13:36:42 WimLeers: catch: hence my suggestions
13:37:09 WimLeers: Fabianx-screen: ^^
13:38:26 WimLeers: catch: that's why I  kinda like the "auto_placeholder_triggers" idea
13:38:43 WimLeers: catch: it makes it very clear that these are the conditions that trigger auto-placeholdering
13:39:06 WimLeers: catch: and it leaves room to have any cacheability metadata property (tags/contexts/max-age) to have their own triggers
13:39:07 catch: WimLeers: that makes sense I think.
13:39:19 catch: I'm trying to think of a word other than trigger, because trigger module
13:39:29 WimLeers: catch: so in the future, we could choose to auto-placeholder something that has a cache tag we know are going to be invalidated frequently!
13:39:38 WimLeers: catch: which is a quite good capability to have
13:40:04 WimLeers: catch: auto_placeholder_reasons, auto_placeholder_requirements
13:40:11 WimLeers: auto_placeholder_conditions
13:40:13 WimLeers: conditions!
13:40:33 WimLeers: catch: ^^ → conditions makes a ton of sense to me
13:40:42 catch: WimLeers: yes that's good.
13:41:20 WimLeers: catch: Fabianx-screen: so:
  renderer.config:
    auto_placeholder_conditions:
      max-age: 0
      contexts: ['session', 'user']
      tags: []
13:43:43 catch: WimLeers: like it.
13:44:15 WimLeers: catch: me too :) Very glad you made those remarks!
13:44:19 WimLeers: catch: this is much better
13:44:35 WimLeers: catch: and then instead of shouldAutomaticallyPlaceholder(), meetsAutoPlaceholderingConditions() ?
13:50:53 WimLeers: nvm, the original is fine
14:04:35 Fabianx-screen: WimLeers: +1 looks great.

Updated patch attached, including expanded test coverage, IS updated too.

Fabianx’s picture

+++ b/sites/default/default.services.yml
@@ -96,9 +96,19 @@ parameters:
+      # Max-age at or below which caching is not considered worthwhile.
+      #
+      # @default 0

Should add:

Disable by setting to max-age: -1

and for the other two:

Disable by specifying [].

Overall looks great! :)

Wim Leers’s picture

Fabianx’s picture

Status: Needs review » Reviewed & tested by the community

Back to RTBC. Patch looks great!

Wim Leers’s picture

Status: Reviewed & tested by the community » Needs work

Oops, @effulgentsia just pointed out I lost his #17 interdiff. Fixing that.

Wim Leers’s picture

Status: Needs work » Reviewed & tested by the community
FileSize
29.57 KB

The #17 interdiff applies to this reroll. Simply brought that back.

Crell’s picture

Still +1 to RTBC. Nice tweakage.

Fabianx’s picture

Issue summary: View changes

Added a proposed commit message, patch indeed is still RTBC.


Proposed commit message:

Issue #2543332 by Wim Leers, Fabianx, effulgentsia, Crell, dawehner, borisson_: Auto-placeholdering for #lazy_builder without bubbling
catch’s picture

Status: Reviewed & tested by the community » Fixed

Committed/pushed to 8.0.x, thanks!

  • catch committed b2303ac on 8.0.x
    Issue #2543332 by Wim Leers, effulgentsia, Fabianx, Crell, dawehner,...
Wim Leers’s picture

davidhernandez’s picture

This appears to have caused some weirdness with messages. See #2547127: [regression] Empty messages container appearing when a Notice occurs

I tracked it to this commit.

szeidler’s picture

After performing drush cr with the current HEAD i get an Undefined index once.

Notice: Undefined index: auto_placeholder_conditions in Drupal\Core\Render\Renderer->shouldAutomaticallyPlaceholder() (line 658 of core/lib/Drupal/Core/Render/Renderer.php).

I didn't saw any side-effects along with this notice.

Wim Leers’s picture

CR created to help people upgrade existing sites due to the updated container parameter: https://www.drupal.org/node/2547351. For details on why this is necessary, see #2547127-14: [regression] Empty messages container appearing when a Notice occurs.

#37: instead of doing that, you need to do what the CR says. #37 introduces a bug.

Wim Leers’s picture

#36:

szeidler’s picture

Status: Fixed » Closed (fixed)

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