Problem/Motivation

In https://www.drupal.org/node/2222293 it was stated that the replacement for DRUPAL_CACHE_NONE was to set max_age = 0.

This does not work in a cache bubbling enabled caching world.

Proposed resolution

  1. Introduce #cache['max-age']
  2. Bubble max-age just like we bubble cache tags
  3. Ensure render arrays with max-age === 0 aren't render cached

Remaining tasks

Reviews.

User interface changes

None.

API changes

API addition for Render API: #cache['max-age']

Support from Acquia helps fund testing for Drupal Acquia logo

Comments

Fabianx’s picture

Issue summary: View changes
effulgentsia’s picture

In https://www.drupal.org/node/2222293 it was stated that the replacement for DRUPAL_CACHE_NONE was to set max_age = 0.

This does not work in a cache bubbling enabled caching world.

Why not? Because render arrays don't have a #cache['max_age'] support? Should they? In which case, we could bubble that up, right (where merge = min())? In which case, would setting that to 0 be preferable to an 'uncacheable' context?

Fabianx’s picture

#2: Oh even better :).

Yes, I have been of the opinion that max-age should be bubbling and it sounds like a great way to make that happen automatically.

I would suggest to also implement downstream-ttl at the same time, which is an Akamai concept, but very interesting - especially because it comes from their ESI spec:

Lets start with an example, lets say we have a file short-time.jpg, which is exchanged every 10 min.

Now this file sets a header of: Cache-Control: public, max-age=600

Now this means however that the browser is able to cache this for 600 seconds as well, so in the worst case, the file was cached by the browser at a time when it was just about to expire from Akamai.

So then the browser has it for 600+599 seconds == 1199 seconds, which means it gets the new file 599 seconds too late.

Of course you can half now the time to max-age = 300 and it will mostly be correct, but in the best case be just 300 seconds.

To solve this problem Akamai does a trick (which can be done in Varnish, too):

- It sets the max-age to the expiration time, so in our example above the browser would get a max-age of:

max-age=1

And then after it expires, the new version with e.g. max-age=596

Thus the caches are synchronized correctly.

To make it possible to cache, long.jpg, which can be cached for a year or two that doesn't matter, you can send a downstream-ttl header, like:

Edge-Control: downstream-ttl=6000000000

which means that the max-age for the browser is exactly put to this number and we end up with the old case, where the max time is doubled the amount of time.

Hence while we can implement max-age first in a very simple way, I think what we ultimately want is not only the set max-age, but also the current 'age' (or adjusting max-age), so that we can calculate the correct real TTL.

But +1 to bubbling max-age and having it in #cache, render_cache in D7 already supports max-age and downstream-ttl as a cache properties, so definitely part of my todo list for D8 as well :).

Wim Leers’s picture

Title: Add cache_context.uncacheable to disable caching or force placeholder creation » Add #cache[max-age] to disable caching or force placeholder creation
Status: Active » Needs review
Issue tags: +D8 cacheability, +Needs tests
FileSize
11.46 KB

Ok, so let's do #cache['max-age'], per #2 & #3.

This is a first, rough iteration. I won't continue until I get some initial feedback whether we indeed want to continue down this road.

Status: Needs review » Needs work

The last submitted patch, 4: cache_max_age-2443073-4.patch, failed testing.

Wim Leers’s picture

Status: Needs work » Needs review

Failures don't matter, this needs a review.

larowlan’s picture

Code makes sense to me. Can we get an issue summary update? Are we tackling downstream-ttl here too?

  1. +++ b/core/lib/Drupal/Core/Render/BubbleableMetadata.php
    @@ -52,14 +59,17 @@ class BubbleableMetadata {
    +  public function __construct(array $contexts = [], array $tags = [], $max_age = Cache::PERMANENT, array $attached = [], array $post_render_cache = []) {
    

    The constructor is starting to get hairy here, and given all arguments have default values, a builder-pattern might be of merit for DX here

    BubbleableMetadata::create()->setMaxAge($max_age)->setContexts($contexts);
    
  2. +++ b/core/lib/Drupal/Core/Render/BubbleableMetadata.php
    @@ -52,14 +59,17 @@ class BubbleableMetadata {
    +    $this->maxAge = $max_age;
    

    This is the first argument in the constructor that isn't type-hinted. I think we should be doing some validation of the incoming value here and throwing an Exception if something unexpected is encountered. We're doing min() comparisons with the value at a later date, so before we get that far we should be ensuring the incoming value is sane

  3. +++ b/core/lib/Drupal/Core/Render/MainContent/HtmlRenderer.php
    @@ -138,14 +138,21 @@ public function renderResponse(array $main_content, Request $request, RouteMatch
    +var_dump($cache_max_age);
    

    :)

  4. +++ b/core/modules/filter/src/FilterProcessResult.php
    @@ -49,9 +52,15 @@
    + *   $result->setCacheContexts(['language']);
    

    unrelated?

  5. +++ b/core/modules/filter/src/FilterProcessResult.php
    @@ -218,6 +235,30 @@ public function setCacheContexts(array $cache_contexts) {
    +    $this->cacheMaxAge = $max_age;
    

    Same story here re validation?

Berdir’s picture

There's an existing issue about being able to prevent caching by setting a flag, sounds like we can close that as a duplicate of this... doing so now: #2344243: Support a kill flag in $render_array[#cache']

Wim Leers’s picture

Assigned: Unassigned » Wim Leers

#7: thanks for the general +1 and the actual review! Now rerolling.

#8: cool — thanks!

Now rerolling to be more than just a quick POC.

Wim Leers’s picture

FileSize
11.88 KB

First: a straight reroll with the conflict caused by #2445761: Add a X-Drupal-Cache-Contexts header to aid in debugging and testing fixed.

Status: Needs review » Needs work

The last submitted patch, 10: cache_max_age-2443073-10.patch, failed testing.

Wim Leers’s picture

Status: Needs work » Needs review
FileSize
20.23 KB
12.62 KB

#7

  1. Agreed in principle, not entirely in practice:
    1. There are only 2 places in all of core that pass arguments to this: tests and FilterProcessResult.
    2. Almost everything uses the ::createFromRenderArray() helper.
    3. That increases the number of function calls massively.

    But writing the above actually convinced me of your point! :D

    The thing is: you want BubbleableMetadata to support chained calls to set various things, but… FilterProcessResult already has this! So if we: A) move all the get/set/add methods from FilterProcessResult to BubbleableMetadata and B) make FilterProcessResult extend BubbleableMetadata… then we have what you wanted. And we even end up with less code overall! And without an API change! (With the exception of removing FilterProcessResult::getBubbleableMetadata(), but that's something that affects the .1%, the rest of the API affects the 99.9%.)

  2. Agreed, fixed. But note that cache contexts & tags must be arrays of strings, so string[], not array. But you're right that that at least gives a certain level of assurance.
  3. :P
  4. Yeah, strictly speaking that is unrelated, but since I have to update that docblock, I might as well do what we (I) forgot to do in #2329101: CacheableInterface only has a getCacheKeys() method, no getCacheContexts(), leads to awkward implementations.
  5. Fixed.

In the next reroll, I'm going to change the constructor of BubbleableMetadata, so that we're forced to use the "builder pattern" everywhere.

Wim Leers’s picture

FileSize
23.52 KB
12.62 KB

Tests still needed, but besides that, this should be mostly done.

The last submitted patch, 12: cache_max_age-2443073-11.patch, failed testing.

Wim Leers’s picture

FileSize
6.02 KB

This is the right interdiff for #13.

Status: Needs review » Needs work

The last submitted patch, 13: cache_max_age-2443073-13.patch, failed testing.

Wim Leers’s picture

Status: Needs work » Needs review
FileSize
24.47 KB
1.59 KB
Wim Leers’s picture

Issue tags: -Needs tests
FileSize
25.65 KB
1.23 KB
Wim Leers’s picture

Issue summary: View changes

Updated IS to match #2 + #3.

Wim Leers’s picture

Assigned: Wim Leers » Unassigned
Fabianx’s picture

I opened #2449749: Add #cache['downstream-ttl'] to force expiration after a certain time and fix #cache['max-age'] logic by adding #cache['age'] as a follow-up to discuss the scope of cache hierarchy's and ttls, so this one can go in as the easy thing.

Fabianx’s picture

Title: Add #cache[max-age] to disable caching or force placeholder creation » Add #cache[max-age] to disable caching and bubble the max-age
Status: Needs review » Reviewed & tested by the community
Issue tags: +Needs change record

I reviewed this again and this is RTBC!

Nice work! Could use a beta evaluation and needs a change record ...

larowlan’s picture

+1 RTBC

joshtaylor’s picture

This was broken with a recent core update, reroll attached.

joshtaylor’s picture

Status: Reviewed & tested by the community » Needs review
Fabianx’s picture

Status: Needs review » Reviewed & tested by the community

Back to RTBC, thanks for the re-roll.

webchick’s picture

Assigned: Unassigned » catch

Boink!

joshtaylor’s picture

joshtaylor’s picture

Status: Reviewed & tested by the community » Needs review

Status: Needs review » Needs work

The last submitted patch, 28: cache_max_age-2443073-24.patch, failed testing.

joshtaylor’s picture

Wrong patch, sorry.

Fabianx’s picture

Status: Needs work » Reviewed & tested by the community

Back to RTBC - if tests still pass.

alexpott’s picture

+++ b/core/lib/Drupal/Core/Cache/Cache.php
@@ -71,6 +71,36 @@ public static function mergeTags() {
+    if (in_array(Cache::PERMANENT, $max_ages)) {
+      $max_ages = array_filter($max_ages, function ($max_age) {
+        return $max_age !== Cache::PERMANENT;
+      });

I wonder if it is just quicker to do the array_filter all the time rather then searching for Cache::PERMANENT?

Wim Leers’s picture

You're right when dealing with very large sets of max-ages (100):

But not when dealing with smaller ones (10) — and we almost always are handling only 2 values.

(But the differences are very small.)

Wim Leers’s picture

That was actually a bad example. This comparison is better. 10000 times running a set of typically encountered max-age values, in the different (typical) combinations, to exercise all code paths of this function. That's a much fairer comparison.

And that apparently leads to the same conclusion. So I think no changes are necessary.

Wim Leers’s picture

catch’s picture

Status: Reviewed & tested by the community » Needs review
+++ b/core/lib/Drupal/Core/Cache/Cache.php
@@ -71,6 +71,36 @@ public static function mergeTags() {
+    if (in_array(Cache::PERMANENT, $max_ages)) {

Hmm this is micro-optimization but I wonder if a straight foreach() would be quicker.

$min = FALSE;
foreach ($max_ages as $age) {
  if ($age !== CACHE_PERMANENT && ($min === FALSE || $age < $min)) {
    $min = $age;
  }
}

return $min !== FALSE ? $min : CACHE_PERMANENT;

Also it's not clear from a quick scan how this affects:
1. Internal page cache
2. Max age header for external caches.

I think it does not affect the internal page cache, but it does affect external caches.

Wim Leers’s picture

Status: Needs review » Reviewed & tested by the community

#37

  • RE: micro-optimization: doesn't seem like there's a noticeable difference: http://3v4l.org/sfiMN/perf#tabs vs. http://3v4l.org/VVNBe/perf#tabs
  • RE: page-level cache header. Good question!

    We do bubble up the max-age to the page-level header:

    +++ b/core/lib/Drupal/Core/Render/MainContent/HtmlRenderer.php
    @@ -135,21 +135,29 @@ public function renderResponse(array $main_content, Request $request, RouteMatch
    +    // If an explicit non-infinite max-age is specified by a part of the page,
    +    // respect that by applying it to the response's headers.
    +    if ($cache_max_age !== Cache::PERMANENT) {
    +      $response->setMaxAge($cache_max_age);
    +    }
    

    … but interestingly, it doesn't have any effect right now! Because we still conflate "page cache settings" with "internal page cache" (i.e. simple built-in reverse proxy for anon users). Consequently, in FinishResponseSubscriber::onRespond(), we do this:

        $is_cacheable = ($this->requestPolicy->check($request) === RequestPolicyInterface::ALLOW) && ($this->responsePolicy->check($response, $request) !== ResponsePolicyInterface::DENY);
    
        // Add headers necessary to specify whether the response should be cached by
        // proxies and/or the browser.
        if ($is_cacheable && $this->config->get('cache.page.max_age') > 0) {
          if (!$this->isCacheControlCustomized($response)) {
            // Only add the default Cache-Control header if the controller did not
            // specify one on the response.
            $this->setResponseCacheable($response, $request);
          }
        }
        else {
          // If either the policy forbids caching or the sites configuration does
          // not allow to add a max-age directive, then enforce a Cache-Control
          // header declaring the response as not cacheable.
          $this->setResponseNotCacheable($response, $request);
        }
    

    So even with the internal page cache turned on, the "page cache maximum age" setting (cache.page.max_age) at /admin/config/development/performance trumps whatever max-age was bubbled.

    But… I think that belongs in a separate issue.

    This issue is about marking it possible to mark parts of the page uncacheable, and propagating that uncacheability. The patch does that just fine. Making sure it also bubbles to the response level as expected (it is bubbled to response, but overwritten by the above) can be a separate issue, because it also relates to the internal page cache. Doing that separately would be just like we did e.g. #2445761: Add a X-Drupal-Cache-Contexts header to aid in debugging and testing separately.

catch’s picture

Status: Reviewed & tested by the community » Fixed

This was what I meant in terms of the comparison, but also not anything in it: http://3v4l.org/EYE5S/perf#tabs

The internal cache overriding is consistent with what we do now so yes separate issue if we decide to change the behaviour - I'm not sure we should anyway.

Committed/pushed to 8.0.x, thanks!

  • catch committed 14f0f45 on 8.0.x
    Issue #2443073 by Wim Leers, joshtaylor: Add #cache[max-age] to disable...
Wim Leers’s picture

Assigned: catch » Unassigned

Awesome, now #2099137: Entity/field access and node grants not taken into account with core cache contexts is unblocked! :) And we now have a much more sensible Render API, which now finally is starting to map to HTTP closely :)

Published the CR.

Wim Leers’s picture

Status: Fixed » Closed (fixed)

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