Problem/Motivation
PageCache determines which responses to cache by inspecting the Expires header. (This should be updated to use the Cache-Control header instead, but that's out of scope here.)
Responses that have Expires: Sun, 19 Nov 1978 05:00:00 GMT are meant to not be cached, because it's an expiration date in the past. Yet PageCache caches them permanently:
$expire = ($date > $request_time) ? $date : Cache::PERMANENT;
This has been the case for a very long time, even predating #2527126: Only send X-Drupal-Cache-Tags and -Contexts headers when developer explicitly enables them. Quite possibly the rationale was it's fine to cache uncacheable responses, because cache tags will ensure the responses are invalidated in a timely manner
.
Now, to make matters worse, even when you configure a non-zero "max age" at /admin/config/development/performance, \Drupal\Core\EventSubscriber\FinishResponseSubscriber::setResponseCacheable() will still cause that same Expires header to be set? Why? Because when \Drupal\Core\EventSubscriber\FinishResponseSubscriber::onRespond() calls \Drupal\Core\EventSubscriber\FinishResponseSubscriber::setResponseCacheable(), the latter contains this code:
// HTTP/1.0 proxies do not support the Vary header, so prevent any caching
// by sending an Expires date in the past. HTTP/1.1 clients ignore the
// Expires header if a Cache-Control: max-age directive is specified (see
// RFC 2616, section 14.9.3).
if (!$response->headers->has('Expires')) {
$this->setExpiresNoCache($response);
}
In other words:
- Drupal 8 intentionally disables HTTP/1.0 proxies
- Drupal 8 ships with a HTTP/1.0 reverse proxy that is breaking the spec: Page Cache
Steps to reproduce
(below test case to be verified by OP)
- Install D11, enable the modulepage_cache (for anonymous users). The status of dynamic_page_cache (for authenticated users) is irrelevant;
- Use a local installation, without any external caching mechanismes
- Edit a node, make sure it has , and/or max-age: 0/PERMANENT/in the near futureExpires: Sun, 19 Nov 1978 05:00:00 GMT
- As Anonymous user, view the page. It is expected to be created new
- As Anonymous user, view the page again. It is expected that the cached version is served, depending on max-age/Expires setting
However, when Expires: Sun, 19 Nov 1978 05:00:00 GMT, the page is served from cache instead of crested new.
Proposed resolution
Make \Drupal\page_cache\StackMiddleware\PageCache a HTTP/1.1 proxy: make it inspect Cache-Control rather than Expires
Remaining tasks
TBD
User interface changes
None.
Introduced terminology
None.
API changes
None.
Data model changes
None.
Release notes snippet
| Comment | File | Size | Author |
|---|---|---|---|
| #13 | 2835068-13.patch | 1.06 KB | oknate |
| #12 | 2835068-11.patch | 903 bytes | oknate |
| #9 | 2835068-Pagecache-expires-9.patch | 774 bytes | nigelcunningham |
Issue fork drupal-2835068
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #6
leo pitt commentedThis seems like quite a major issue - but no activity on it in the past two years ...?
Is it still a current issue?
Comment #7
Anonymous (not verified) commentedI think it is still current, yes. I continue to find that whenever I want a page to be entirely uncacheable for some reason, I have to both set the max-age to 0 and call the page cache kill switch, even though just setting the max-age should be enough; and when I want a page cache to expire after a particular amount of time, I've been needing to sort of do so with cron and custom cache tags since simply setting max-age doesn't do the trick. My understanding is that this ticket is to resolve this issue and get cache max-age to apply normally to the page cache, the same as cache tags and cache contexts already do.
Comment #9
nigelcunningham commentedI'm testing the attached patch in combination with the cache control override module and a couple of patches to it (https://www.drupal.org/files/issues/2019-09-26/cache_control_override_ex... and https://www.drupal.org/files/issues/2020-01-09/cache_control_override-31...), to good effect.
Comment #11
mxr576Comment #12
oknateHere's a patch that turns off caching in the page cache if it's set to not cacheable in FinishResponseSubscriber::onRespond().
It's similar to patch #9, but is more explicit.
The only place that date is used in Drupal core is FinishResponseSubscriber.
UPDATE: the next patch I added adds a more clear note about this.
This will make it more obvious to developers that their caching isn't working properly, for example if they've disabled it!
I tried Wim's suggestion to check the max age, but it was set to a negative number in this case. I like the idea of checking the same date to which the FinishResponseSubscriber sets the expires.
I ran into this with the Sitewide Alert module. https://www.drupal.org/project/sitewide_alert/issues/3189144
If you set expires header on a CacheableJsonResponse returned in a controller, like this:
$response->setExpires($expireDate->getPhpDateTime());but you have set cache.page.max_age to 0 (
<no caching>) at /admin/config/development/performance, the CacheableJsonResponse gets mangled in \Drupal\Core\EventSubscriber\FinishResponseSubscriber::onRespond() and set to not cacheable, but Page Cache caches it anyway with 'expire' set to -1. This breaks the scheduling feature. The solution in my case easy, to set the cache.page.max_age to something other than 0.I was also able to fix it with the kill switch. But I'm glad I figured out that had forgot to set my max-page in the performance settings.
Comment #13
oknateJust adding a little more info in the comment.
Wim, can you add a little more info on this suggestion:
Symfony has a
\Symfony\Component\HttpFoundation\Response::isCacheable()method that looks to me to be perfect here. I don't see it used in Drupal core at all.Perhaps instead of checking the expires we could check if it's cacheable using that method.
Comment #14
oknateWim, I don't think the second part of the description is still true:
When I was testing tonight, with a CacheableJsonResponse from a controller, this wasn't the case. I think as long as it the response is an instance of CacheableResponseInterface, and cache.page.max_age is greater than zero, the expires is not altered.
Comment #16
anatolij zajika commentedHello everybody. Here is the module that solves this problem and has settings - https://www.drupal.org/project/custom_cache
Comment #20
socialnicheguru commentedComment #21
smustgrave commented#13 had many failures and was not ready for review.
Which show this will need it's own test case and work for BC.
Comment #27
vidorado commentedThis was a tough one, but I believe I've managed to sort things out.
Some unexpected behaviors were occurring, such as the
FinishResponseSubscribersetting anExpiresheader, which made the response non-cacheable—inside thesetResponseCacheable()method, which should theoretically do the opposite.Additionally, some functional tests in
PageCacheTestwere incorrectly expecting the response to be non-cacheable when it actually should have been. As a result, they were effectively masking the issue with cacheability headers.A more in-deep review is necessary, as this is a complex scenario with multiple edge cases involved, and a thorough understanding of the HTTP cacheability headers specification is required to do so.
Also, I haven't been able to mark the MR as "Ready" I'm not sure if someone with higher privileges could do it, so I don't have to create a new one.
Comment #28
steinmb commentedTagging with Performance. I think @catch have the habit of checking that tag, might have some valuable feedback on the MR. I have none.
Comment #29
johnvI tested the patch, hoping it would solve my "Anonymous never get fresh pages" problem for a contrib field module.
Reading the summary closely, it will not, since my use case does not set the Expire, only max-age (there is no guidance how to apply setExpire() by contrib either.)
Anyway, the Summary lacks a test script. I added one. Please correct it if necessary.
Comment #30
vidorado commentedAfter digging into the code, I realized I made a mistake. To prevent multiple tests from failing, I mistakenly kept the very piece of code that was wrong in the first place:
I also confirmed that this issue is only related to the
Expiresheader and not to PageCache’s behavior of ignoringmax-age. For that second issue, please refer to #2352009: Bubbling of elements' max-age to the page's headers and the page cache @johnv.Comment #31
vidorado commentedI've finally fixed the caching behavior and all tests.
Things I'm concerned about:
W/) prefix from theETagheader, as it was sometimes being added, causing multipleEntityResourceTestBasetests to fail. I was able to reproduce the issue, but I'm not entirely sure if it's due to an Nginx configuration or something else.system.performance.cache.page.max_ageis being properly honored, I had to set amax_agevalue in several tests expecting responses to be cacheable. I'm not sure if this is the correct approach or if we should configure it globally inBrowserTestBase.Varyheaders in the response is correct, but I haven't determined why they are being set separately. An additionalAccept-Encodingvalue is added in a secondVaryheader instead of being merged into the existingVaryheader containingCookieandOriginvalues. To account for this, I modifiedCorsIntegrationTestto check for "Origin" across all possibleVaryheaders.Comment #32
vidorado commentedBy the way, now that this fix ensures the
system.performance.cache.page.max_ageconfig value is properly honored byPageCache, I'm not sure how it will overlap with #2352009: Bubbling of elements' max-age to the page's headers and the page cache, but it's certainly related.Comment #33
znerol commentedSorry for the late answer, commenting on the issue summary and the two points raised by @Wim Leers:
This has been the case since Drupal 7 (and also Pressflow - Drupal 6 optimized for high performance content oriented websites). It has been necessary for any web application which uses cookies to disable HTTP/1.0 caches. There is no way in HTTP/1.0 to maintain different page variations under the same URL. Everything which influences the result (like desired language, session and login state) has to be encoded in the URL. Yes, session identifiers were passed via request parameters back then.
When HTTP/1.1 introduced cookies and cache control, it was specified that the Cache-Control: max-age directive takes precedence over the HTTP/1.0
Expiresheader."Modern" web applications which serve multiple variants of a page under the same URL (depending on the application state maintained in the cookie header) can be made compatible with both HTTP protocol versions by serving a suitable
Cache-ControlandExpiresheader. The way to do this is to serve an appropriateCache-Controlheader to permit storage in HTTP/1.1 caches (if appropriate) and serve anExpiresheader which is set into the past in order to disable caching inHTTP/1.0caches. Remember, HTTP/1.0 caches assume that the only thing which can influence the response is the URL.This pattern is not specific to Drupal. Everybody does this.
The page cache module is not a HTTP/1.0 reverse proxy. It is neither a HTTP/1.1 reverse proxy. The caching logic has its roots in Pressflow and is tailored to content oriented websites.
In unmodified HTTP/1.1+ reverse proxies and browser caches, the only way to get rid of cached pages is to limit their validity to a certain time frame (i.e.,
max-ageors-max-age). Thesystem.performance.cache.page.max_ageconfig value is the way to specify that value for those downstream caches which are otherwise out-of-reach for Drupal.With the built-in cache the situation is different. It is possible to remove cached pages whenever a change is made on the website. Stock Drupal 7 did nuke the whole page cache whenever any content was changed. Some contrib modules rectified that heavy handed approach and made it possible to define rules which pages were purged depending on which content was changed.
D8+ introduced cache tags and that removed all the guess work. No site builder has to think about which pages to purge from the cache nowadays.
For this reason the page cache module keeps rendered pages in the cache for an unspecified amount of time. This has been the way it worked since Pressflow.
The following remark in the issue summary is indeed pointing to a problem point in the page caching module:
The page cache module really shouldn't inspect the
Expiresheader. That logic needs to be deprecated indeed.Updating issue status to Postponed (maintainer needs more info). I will wait a couple of weeks and then set it to Closed (works as designed) unless there is significant new (and valid) information added to the issue summary.
Comment #34
vidorado commentedThanks @znerol for a detailed response. It makes me feel better after have worked in vain, due to misunderstanding the Page Cache role in the whole picture.
I think that the only change this issue fixes, then, is setting an
Expiresheader which honorssystem_performance.cache.page.max_age, which would be possibly beneficial for HTTP 1.0 proxies down the line of a response.Before:
After:
Which I believe is not in the scope of the original issue and could fit better as an additional task to #2352009: Bubbling of elements' max-age to the page's headers and the page cache.
But, as you said, a problem remains unsolved:
So, what's next? I'm not sure at all...
max_ageconfig parameter honoring to #2352009: Bubbling of elements' max-age to the page's headers and the page cacheExpiresheader and go on to Fixed? I'm not sure if it's beneficial for HTTP 1.0 proxies or if they really exist yet nowadays.PageCacheinspectingExpiresheader instead ofCache-Control, so this one don't get "noisy"?Am I right? :)
Comment #35
znerol commentedNo, that wouldn't be desirable. If Drupal did this, then that would lead to potential information leak via HTTP/1.0 caches. This is because HTTP/1.0 caches would store/serve every page, regardless of whether there were cookies on the request or not. HTTP/1.0 caches are unable to differentiate between authenticated requests and anonymous ones simply because they do not know anything about cookies.
Depends. If you mean that no page should be cached in the page cache longer than
system_performance.cache.page.max_age, then no. If you mean that page cache should honor themax-agedirective collected from render metadata, then yes, that is the correct issue.No need for that, this is covered by #2352009: Bubbling of elements' max-age to the page's headers and the page cache.
Comment #36
vidorado commentedOh, my bad—I mixed things up again...
Now it’s clear to me: HTTP 1.0 proxies must always be skipped using a past
Expiresheader. So, there’s nothing to fix. Thanks for rephrasing the explanation—it helped me finally grasp the full picture.Since the third point is covered by #2352009: Bubbling of elements' max-age to the page's headers and the page cache, there's nothing else to address. I agree on closing this as "Works as designed" and leaving it as is. 🙂
Comment #37
smustgrave commentedThanks all!