Problem/Motivation
See #2352009-171: Bubbling of elements' max-age to the page's headers and the page cache for some context.
Currently if the routing system cannot find a route for a given path, the resulting response is not cacheable.
This is because we bubble the cacheability metadata from the not found exception to the response (see DefaultExceptionHtmlSubscriber::makeSubrequest()) and the exception - since it is coming from Symfony's routing system is not a cacheable one (see RouterListener::onKernelRequest()).
Steps to reproduce
-
Proposed resolution
- Add a
CacheableResourceNotFoundExceptionas a cacheable version of Symfony'sResourceNotFoundException - Make
Router::matchRequest()throw aCacheableResourceNotFoundExceptioninstead of aResourceNotFoundException - Add a custom
RouterListenerto replace Symfony's which can convert aCacheableResourceNotFoundExceptioninto aCacheableNotFoundHttpException. Notably our version does not includeRouterListener::createWelcomeResponse()and the associated code (see below for the concrete functional change of this)
Note that this does not actually make the resulting 404 pages more or less cacheable than they currently are, because:
- Dynamic Page Cache does not cache this because it caches per-route so can only activate after the route lookup
- Page Cache currently currently ignores the max-age 0, see #2352009: Bubbling of elements' max-age to the page's headers and the page cache
Introduced terminology
-
API changes
- The Symfony welcome page is no longer triggered if a controller does
throw new NotFoundHttpException(previous: new NoConfigurationException()). Instead the regular Drupal 404 page is rendered.
Data model changes
Release notes snippet
| Comment | File | Size | Author |
|---|
Issue fork drupal-3580545
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 #3
tstoecklerAlright, here's a first stab at this.
I went ahead and copied Symfony's
RouterListenerinstead of extending as it is marked final. That leads to a bunch of duplicated code, but as far as I can tell, that's the preferred way to do it these days 🤷.Also note that the deprecation notice points to a fake change record as of now, I'd like to see some confirmation on the general approach and also on the concrete deprecation before writing a proper change notice that can be linked to.
Comment #4
prudloff commentedComment #5
catchI checked out the MR branch, and I still didn't get cacheable http headers from a 404 page. Tested with Umami.
The 404 page gets into the page cache (same as HEAD) and it doesn't get into the dynamic page cache (same as HEAD). The 404 subrequest does get into the dynamic page cache (also same as HEAD).
It's possible I'm missing a step in testing, but we could use test coverage of hitting a 404 logged out and getting cacheable http headers I think.
Comment #6
tstoecklerRight, sorry, I wanted to get this "out the door" on Friday evening, so was a bit terse on explanation/context. The way I tested that this "works" is by actually verifying with XDebug that the cacheable exception correctly arrives in
DefaultExceptionHtmlSubscriber::makeSubrequest()so that the request no longer gets the max-age 0.DynamicPageCacheSubscriberalready runs before that point. Will need to take some time to pull apart exactly what the functional change is with this other than unblocking #2352009: Bubbling of elements' max-age to the page's headers and the page cache and/or check if there's anything missing here still. Will hopefully get to that on Monday. And, yes, will definitely need some test coverage.Comment #7
berdirSorry about the dynamic page cache confusion. dynamic_page_cache runs after routing, so obviously it can't cache the result of not finding a route.
This includes a lot of changes to make the route provider able return CacheableMetadata, many methods are fully removed/deprecated. BC is going to be complex around this. Yes, I think we need to cover deprecated method and the constructor for that too, there are a bunch of overrides in contrib, such as domain.module. Possibly this would allow domain module to no longer have the override, not sure.
Is all that really required to resolve this or can we do an intermediate step here?
Comment #8
catchYes sorry didn't mean to imply it should be cached in the dynamic page cache. Just could not see anywhere it was getting cached that it currently wasn't
Comment #9
berdirThat's where #2352009-171: Bubbling of elements' max-age to the page's headers and the page cache (comment #171 and following) comes in. With that change, page_cache does respect the fact that these responses are uncacheable and stops doing that. Plus external caching is inconsistent too, but I need to have a closer look at that again, it's been a while. #2472335: Support an independent max-age for 4xx responses is also related to that part.
Comment #10
berdirA bit unsure what how far this should go.
As mentioned in the bubble issue, on main, the headers are a really weird mix. Testing with
curl -I https://core.ddev.site/not-found.first request:
But on the second request, you get a
x-drupal-cache: HIT, even though cache control is no-cache, private. And even dynamic page cache, despite reporting uncacheable here, is a hit if you disable the page_cache bin. I suppose that's the headers of the sub-request? Looking at the cache entries, for page_cache it's the /not-found URL, for dynamic page cache, it's the system.404 route.With this MR, the first request is:
So this is not yet reflected on the cache-control header, as a start, which I kind of expected. despite the change to dynamic-page-cache and max-age going permanent and miss-but-cacheable. I'm not sure if that's in scope of this issue or not and didn't investigate why this happens yet.
Another interesting thing is the difference in cache contexts. When I hit an arbitrary 404 path such as /foo or /not-found, I now get "url.query_args" in the cache contexts, for node/X, where X is a non-existing ID, I only get the same as on head: "url.query_args:_wrapper_format", so it varies by all query args. languages:language_url is also newly added (language interface was already there), as is url.route_path. I'm not so sure what is actually affected by that. dynamic page cache is not, since we don't even try to cache this, only the inner request. and the route cache in both cases contains the query string, just in a different way.
I also did some tests to make sure that caches are properly invalidated:
/not-found => create a page with that alias => route lookup invalidated through the route_match cache tag due to the alias, page cache invalidated through 4xx-response cache tag.
/node/N => create node with that ID => route cache invalidation not needed, as this finds the route, it just converts the failed upcasting later on to a 404. page cache again invalidated through 4xx-response.
So that seems good.
Comment #11
tstoecklerStatus update
OK, next attempt at pushing this forward. Expanded the issue summary now to make the (non-)interactions with Dynamic Page Cache / and Page Cache and also the scope of this in general more clear. Also re-titling for this purpose. Also made the proposed resolution more explicit and included more detail. Some notable things from there:
url.route_pathcache context which is distinct fromurl.pathin that it does not contain the base path. There is a lengthy comment inRouteProvider::$cacheContextson why that is needed.throw new NotFoundHttpException(previous: new NoConfigurationException())in a controller to trigger the Symfony welcome page. That doesn't seem to be terribly sensible for Drupal and is presumably simply an oversight, so I left it out of the Drupal implementation. This is just to reduce the amount of (duplicated) code, it could just as well be copied over if there's something that I'm missing.Comment replies
Also added some test coverage of the new behavior so that this is now almost ready from my point of view, but before posing the last remaining question from my end, going through the latest comments now in detail:
#7
That's broadly correct, although a bit simplistic, as you also found out in #10. Anyway, added that detail to the issue summary now.
I guess you are talking about DomainSourceRouteProvider here. I would have thought that since it's not public API it would be fine to remove e.g.
RouteProvider::getRouteCollectionCacheId(), but yes, that would breakdomain_source, apparently. I don't think it's particularly complex to change this to deprecate more internal methods instead of removing them. I just wanted to reduce the amount of legacy baggage, but if more BC is wanted, that's fine too. Do you want this forRouteProvider::getRouteCollectionCacheId()explicitly then, or for all the removed methods?From searching contrib it seems the following modules override
RouteProvider::getCollectionForRequest()so broadly that they wouldn't be affected (just from skimming the code, didn't dig too deeply): purl, micro_site, social_auth_buttons, urct and only group_domain also (only) relies onRouteProvider::getRouteCollectionCacheId().So not sure, but maybe just bringing that one back would be sufficient? (See also below for some thoughts on that method specifically.)
I have laid out the problem and the reasoning for the solution pretty in-depth now in the issue summary, feel free to tell me of a less invasive way to fix this problem, but I don't think there is one. There are a couple of minor things that I chose to do that are not strictly necessary but leave the resulting code in less of a bad shape as it otherwise would be in. All of these can be reverted, though, if you prefer:
RouteProviderto use constructor promotion. This was because I needed to newly injectCacheContextsManagerso the constructor needed to be changed anywayRouteProvider::getRouteCollectionCacheId(). This is because we need theContextCacheKeysobject now, to bubble its cache tags. So leaving the call like it is and just changingRouteProvider::getRouteCollectionCacheId()internally would necessarily result in repeated calls toRouteProvider::getContextCacheKeys()or alternatively we could keep a per-request cache of theCacheContextKeysobjectRouter::getInitialRouteCollection()because it is a one-line wrapper around RouteProvider::getRouteCollectionForRequest() and removing it makes it more clear that we're later asking the same object - and with the same parameters - for the cacheable metadata that we asked for the route collection. This makes it closer to what we conceptually are doing, namely fetching a "cacheable route collection result", than with that wrapperWorkspaceRequestSubscriber::onKernelRequest()because that line needed to be changed anywayIf there's anything else that feels gratuitous to you feel free to point that out explicitly.
#8/#9
Yes, indeed, this doesn't actually lead to anything being cached that previously wasn't. The new title and improved issue summary should hopefully clarify that.
#10
The
no-cacheheader is just set byFinishResponseSubscribersince (at least by default) thesystem.performance:cache.page.max_ageis 0. As far as I can tell this is completely independent of the actual cacheable metadata.(this is on
mainjust to be clear for readers)Well, yes, there is a cache hit in Dynamic Page Cache for the subrequest, but then
DynamicPageCacheSubscriber::onResponse()is run for the main request and there it currently detects the 0 max-age and thus, adds theUNCACHEABLE (poor cacheability)header. It's arguable whether that is correct or not, but it's certainly confusing.(this is with the MR just to be clear for readers)
So the max-age being -1 now is correct, I would say, as that is specifically what is being tackled here and I think is a sensible change. The dynamic page cache header with the MR (as of writing this) is leaked from the subrequest now. This is because in
DynamicPageCacheSubscriber::onResponse()we no longer set theUNCACHEABLE (poor cacheability)(which in itself makes sense) and then just exit because!isset($this->requestPolicyResults[$request]). It seems wrong to returnMISSor (yes, indeed)HITwhile we are never going to get a hit for the main request, but not sure how to handle this, this was basically the remaining question I had.As part of investigating this I created a
RouteCachingNotFoundTestto verify the different headers onmainand with this MR. The test passes with this MR and the diff is a reverse-diff of what is needed to make it pass onmain, i.e. if we already had a test like that inmainthe diff would be what would be needed in this MR. Hope that helps for the review. Not yet sure if it makes sense to add the test as part of the MR (which is why I am providing it separately for now), since as discussed above there is no additional caching happening. Depending on how we decide to handle the Dynamic Page Cache headers, though, it might make sense to test that part, not sure.Yes, and I think that's correct. The route provider already varies its cache by all query parameters, so this just reflects the reality.
Again, this just "exposes" what was already the case.
Right, concretely nothing actually is. In the issue summary I elaborate on a theoretical scenario where the cache context itself bubbles a cache tag, which would now be picked up by the page cache and is currently not. But yeah, in practice, this doesn't make any difference.
Remaining questions
So as a kind of tl:dr;, I think the two concrete things that are left here, as of now are:
RouteProvidermethods?Comment #12
tstoecklerSo just one addendum that came to mind while righting the above:
One simple way to address the Dynamic Page Cache header leakage would be in the place in
DynamicPageCacheSubscriber::onResponse()where we exit for 403s/404s:to just add always add a
UNCACHEABLE (403/404)(or similar) header. Since that is currently the only exist point (except!$event->getRequest()->isMethodCacheable()) that doesn't set a header explicitly, that kind of feels like a simple enough change.Comment #13
catch#12 is a good idea. We could do that here or a spin-off. Although I also like having this information in case the underlying 404 controller has cacheability issues tbh.
Comment #14
tstoecklerOK, went ahead with that. Went with "UNCACHEABLE (error)" now, as some of the tests revealed that this is also returned on 50x responses. Definitely open to suggestions, if someone thinks of something better. Since 40x responses are categorized as "client errors" I think it's technically correct, but it feels somewhat strange to return the string "error" for a 403/404.
Also reverted the deletions of the internal methods now and added proper deprecations (including tests) and also wrote a draft change record now.
As far as I can tell none of the contrib modules that override
RouteProviderwould break with this (although, again, did not test that).The CI runners seem to be acting up at the moment, in general the tests should be green.
Comment #15
tstoecklerComment #16
tstoecklerComment #17
berdir> The no-cache header is just set by FinishResponseSubscriber since (at least by default) the system.performance:cache.page.max_age is 0. As far as I can tell this is completely independent of the actual cacheable metadata.
You are of course correct, not sure what I was thinking. As part of #2352009: Bubbling of elements' max-age to the page's headers and the page cache, we should probably revisit if it should be more closely connected. I also wrote some thoughts on that in #2916705: Page cache isn't invalidated. One sensible change IMHO would be to use a max($configured_max_age, $max_age_from_metadata). But that's obviously not in scope of this issue, so lets set that aside for now.
I added a bunch of review comments. I'm also available to discuss this on slack or so if there's something I'm missing.
Comment #18
tstoecklerFixed a bunch of things and replied in the Gitlab thread. Leaving at needs work for now, because the open thread probably needs to be resolved in one way or the other for this to move on. I'm not on Slack, but am reachable privately via e-mail for example, or on Mastodon.
Comment #19
catchThought about this some more, it's very possible that 404/403 pages have cacheability issues, for example #3516173: Block status code visibility condition should use a status code cache context. So it's good to know if the subrequest is hit/miss/uncacheable. Also definitely don't think we should put
errorin this because it's not really.If we know we're making a subrequest, could we potentially do 'Main request: UNCACHEABLE; sub-request: HIT' or something along those lines?
Comment #20
berdirI was also thinking we could do comma separated, similar to how some proxies and CDN's also do that for information like that.
Comment #21
tstoecklerOK, in order to reduce the scope here I moved the changes to the Dynamic Page Cache headers into (the pre-existing) #3451483: Improve Dynamic Page Cache headers for HTML and JSON 4xx responses.
Also, went ahead and implemented the "reduced" proposal now. Replied more in depth in the Gitlab thread, but, yeah, basically hoping that that will allow this issue to get resolved even though I don't particularly care for the solution.
Comment #22
tstoecklerOh and for posterity I pushed the previous approach to a separate branch in the issue fork and just pushed the reverts on top of the existing branch so that the discussion can stay in the same place.
Comment #23
berdirThanks for addressing the feedback. I added one small nitpick on the comment, otherwise this looks good to me.
I wasn't sure if the rest.module tests were failing without the changes or if that's added test coverage (do rest requests behave different than before?)
What about adding some extra test coverage on \Drupal\Tests\page_cache\Functional\PageCacheTest::testPageCacheAnonymous403404 about the header changes that this causes? I think it's just the max-age now that the dynamic page cache changes are moved to #2352009: Bubbling of elements' max-age to the page's headers and the page cache
Comment #24
tstoecklerYes, they are failing because if you don't add the expected cache headers, the base test class asserts that the headers are not present. And that is what changes on those specific lines: Before there were no debug cache headers, now you can see:
in the browser test output HTML thingy.
Yes, it's just the max age header that goes from "0 (Uncacheable)" to "-1 (Permanent)" for 404s. Added assertions for all debug headers, though, to be more explicit.
Comment #25
berdirThanks, looks good to me, now I think we just need to update/slim down https://www.drupal.org/node/3581985. We only need to mention the new exception although I think it's also worth adding that this means that 404 responses are now marked as cacheable and have updated debug headers.
Comment #26
tstoecklerAhh good point, did that now 👍️
Comment #27
berdirThanks, added a note and reference to the bubble page cache issue. I think this is ready now.
I understand that you consider this a not-good solution, but just to add one more issue into the mix: #3123401: Routes are being cached with no parameters (ex: 404 routes). With that, we will likely stop caching 404/empty route lookups *within* the routing system, which will be the start of a possibly growing disconnect between what we report as the to the outer layer vs what we actually cache inside. So I think it's OK or even preferred to not unify those two systems.
Comment #28
berdirthis needs a manual rebase I think, UI doesn't tell so, but at least the rebase through the UI failed due to #3451483: Improve Dynamic Page Cache headers for HTML and JSON 4xx responses getting in, and it is 80 commits behind, thought it makes sense to retest against latest HEAD now that other issue is in.
Comment #29
tstoecklerYup, thanks for the heads up. Rebased now.
Comment #30
alexpottInteresting issue - I've read the code and all the points of @catch and @berdir and think we're in a good place to go forward. I had a slight concern that maybe something in contrib is depending on the Symfony code we're replacing but review of the Symfony class allays those fears.
Committed and pushed 01c0bc54701 to main and 8ba75262a50 to 11.x. Thanks!
Comment #35
quietone commentedcorrect branch on the change record.