Problem/Motivation

The routing system caches each call to getRouteCollectionForRequest() including the query string. That cache includes the route lookup itself (1 query) and inbound processing, which typically includes one more query for the path alias lookup. This happens for the incoming request, but also happens for for any internal: URL being rendered through \Drupal\Core\Url::fromInternalUri for example, to check if it's valid and accessible.

Because the cache varies by path and query string, there tends to be a very large amount of cache entries. Additionally, there is no fine-grained cache invalidation mechanism. There is a single route_match cache tag and because this also caches aliases, any path alias update invalidates the entire route match cache. This cache system is also extensible, so that for example domain can extend it to cache not only by path but also domain, further increasing the number of caches. On sites with frequent content updates such as news sites, these invalidations happen very frequently. This generally results in fairly low cache hit rates.

The exact cache hit/miss rates will vary greatly between different sites, limited data is available on this. Using the new redis report, on one site with a relatively large amount of content but currently fairly infrequent content updates due to summer break still shows it as the cache tag with the highest "impact" rating (11 invalidations in 3 days * 10'000 usages (route match cache entries). so whenever the next invalidation happens, 10'000 cache entries will be flagged as invalidated and need to be recalculated.

This issue has originally been reported as a functional bug related to incorrectly caching empty results due to incorrect context. I haven not personally seen or reproduced that. Kind of the opposite problem of this is the fact that the MR to remove caching entirely currently has 2 failing functional tests, which are likely because the route match cache is in fact resulting in incorrect test asserts/assumptions and things may either not be working at all or the tests are wrong.

Additionally, this has been reported as a private security issue due to possibly infinitely growing cache (185062-drupal-security). This has been marked as "Security status: can be public", so I'm including it in this issue. The specific issue that was reported is that either an attacker can generate random query strings and result in a vast amount of cache entries. There were some unclear side effects around this that were never fully resolved but the same is true for unique paths leading to 404s and many other caches in drupal. However, it is true that even valid use cases such as personalized query from a newsletter/social media/ads can result in a very high number of different cache entries.

Steps to reproduce

To see the performance implications:
Using a script, do multiple requests, such as /node/1?foo=1 and then increment up. Create a new alias. notice how repeating that script will create new cache entries (updated checksum/created timestamp). The same works with real paths, 404's, the requests and the alias do not have anything in common such as the same prefix.

Steps to reproduce the functional bug have not been provided yet.

Proposed resolution

The most fundamental proposal is to remove the cache entirely. See MR 15544: https://git.drupalcode.org/project/drupal/-/merge_requests/15544. This does result in some "regressions" in performance tests, clearly visible in the json api test for example, where following requests need to run the alias and route lookup queries again (pondering: why does the alias prefix list not kick in here?). I do however believe that on many high frequency websites, this cache will not help much or is even harmful (more cache misses than hits, so many writes). On sites with low frequency of updates, the page cache/CDN likely handles many requests without having to do route cache lookups. A class of sites that may be affected by this would be something with many requests bypassing page cache such as many authenticated users and infrequent new content/alias changes.

Alternatives include many overlapping ideas:

1) Fix functional bug: Unclear what the problem is exactly and how to fix it, no known fix currently exists.
2) Stop caching by query string, only cache by path. Mentioned by @catch in the security issue, with reference to #3317868: Simplify private file routing as blocker. I'm not sure if that is a blocker, because that produces a query string from a request without one, so the cache is without query string? However, since inbound path processing is cached too, this likely affects things like language negotiation by query string and whatever contrib/custom code might be doing in inbound processing.
3) Stop caching empty results (404), assume they are not frequent or then cached by page cache. Still need to do the cache lookup since we don't know if there will be results or not. Also unclear why the functional bugs only happen with empty results (this fix was specifically proposed to fix the functional bug, not performance)
4) Avoid caching if there are query strings in the request. This could skip the cache lookup, not just the write as we know that upfront. Essentially the same as removing caching entirely, how many requests (%) that affects depends on the site. I don't really consider that security hardening because if the intent is to fill up the cache, there area way easier ways to do that. The very site that I reported the data above from was just hit today by tens of thousands of unique randomly chosen ?page query strings today that actually caused vast duplication of render/views result caches and server load. This seems negligible.
5) Improve cache invalidation through more specific cache tags. This is likely not trivial, a simple prefix isn't enough/predictable with things like language prefixes.

Remaining tasks

User interface changes

Introduced terminology

API changes

Data model changes

Release notes snippet

Original report

Drupal's Routing/RouteProvider caches all incoming request's routes and parameters for future use of course. This happens after all other processes are done and after all processInbounds (most importantly from the content POV).

The case: there's a link on the current page to a place that isn't accessible from that page's context - could be language, could be permissions, domain - just a link to node that's not supposed to be there and restricted by some logic - anything will do as long as it's entity reference.

When the page renders, Drupal looks for the routes and controllers responsible for this link (most importantly it should be entity reference) - and can't find them in the current context (let's take different language context and using the link field).

What happens - it can't find the routes and controllers for this reference (in the current language since the linked node is in different language) but still caches the empty route params for this "unaccessible" path. If you hit that path it should work as expected but it don't since it's already cached as 404, so it starts throwing 404.

The inconsistent part is that if you first hit the real thing - it's going to be cached correctly and later won't break. Unfortunately that's a pretty big "IF" and not very consistent and logical.

My solution was to add a patch in the core/lib/Drupal/Core/Routing/RouteProvider.php to prevent empty routes being cached.

Here is an example of broken (empty) cached route:

a:3:{s:4:"path";s:66:"/patients/prevention/plant-products/cancer-prevention-sulforaphane";s:5:"query";a:0:{}s:6:"routes";O:41:"Symfony\Component\Routing\RouteCollection":2:{s:49:" Symfony\Component\Routing\RouteCollection routes";a:0:{}s:52:" Symfony\Component\Routing\RouteCollection resources";a:0:{}}}

Readable:

Array
(
    [path] => /patients/prevention/plant-products/cancer-prevention-sulforaphane
    [query] => Array()
    [routes] => __PHP_Incomplete_Class Object
        (
            [__PHP_Incomplete_Class_Name] => Symfony\Component\Routing\RouteCollection
            [ Symfony\Component\Routing\RouteCollection routes] => Array()
            [ Symfony\Component\Routing\RouteCollection resources] => Array()
        )
)

The questions here are:
1. how big of an impact is it to not cache empty routes?
2. why does Drupal cache them at all?
3. is there a better way/place than what I found/did to fix this issue?

Issue fork drupal-3123401

Command icon 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

kadiiski created an issue. See original summary.

kadiiski’s picture

Here is the patch which fixed it for me in long term, with no visible consequences on the performance (the site only uses database caching for now and has more than 20k nodes and a lot of content in them). No difference was felt before and after the patch. In future I will provide before/after benchmarks.

idebr’s picture

Status: Active » Needs review
leoleoasd’s picture

Looking nice to me, but maybe moving the $cache_value into the if statement cause it's useless when we are not going to cache it?

      if ($routes->all() || $routes->getResources()) {
        $cache_value = [
          'path' => $path,
          'query' => $query_parameters,
          'routes' => $routes,
        ]; // Maybe move these lines into the if statement?
        $this->cache->set($cid, $cache_value, CacheBackendInterface::CACHE_PERMANENT, ['route_match']);
      }
kadiiski’s picture

StatusFileSize
new1.14 KB

@leoleoasd - you're right, looks better.

kadiiski’s picture

Any chance somebody has tested this one and has some comments/advices about this?

Version: 8.9.x-dev » 9.1.x-dev

Drupal 8.9.0-beta1 was released on March 20, 2020. 8.9.x is the final, long-term support (LTS) minor release of Drupal 8, which means new developments and disruptive changes should now be targeted against the 9.1.x-dev branch. For more information see the Drupal 8 and 9 minor version schedule and the Allowed changes during the Drupal 8 and 9 release cycles.

Version: 9.1.x-dev » 9.2.x-dev

Drupal 9.1.0-alpha1 will be released the week of October 19, 2020, which means new developments and disruptive changes should now be targeted for the 9.2.x-dev branch. For more information see the Drupal 9 minor version schedule and the Allowed changes during the Drupal 9 release cycle.

Version: 9.2.x-dev » 9.3.x-dev

Drupal 9.2.0-alpha1 will be released the week of May 3, 2021, which means new developments and disruptive changes should now be targeted for the 9.3.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

Version: 9.3.x-dev » 9.4.x-dev

Drupal 9.3.0-rc1 was released on November 26, 2021, which means new developments and disruptive changes should now be targeted for the 9.4.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

Version: 9.4.x-dev » 9.5.x-dev

Drupal 9.4.0-alpha1 was released on May 6, 2022, which means new developments and disruptive changes should now be targeted for the 9.5.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

smustgrave’s picture

Status: Needs review » Needs work
Issue tags: +Bug Smash Initiative, +Needs issue summary update, +Needs reroll, +Needs tests

Sending back to NW.

ravi.shankar’s picture

Issue tags: -Needs reroll

Removed needs reroll tag as #5 applies cleanly.

Version: 9.5.x-dev » 10.1.x-dev

Drupal 9.5.0-beta2 and Drupal 10.0.0-beta2 were released on September 29, 2022, which means new developments and disruptive changes should now be targeted for the 10.1.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

Version: 10.1.x-dev » 11.x-dev

Drupal core is moving towards using a “main” branch. As an interim step, a new 11.x branch has been opened, as Drupal.org infrastructure cannot currently fully support a branch named main. New developments and disruptive changes should now be targeted for the 11.x branch, which currently accepts only minor-version allowed changes. For more information, see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

drclaw’s picture

This feels like a bit of a sledgehammer fix since many of the cached "empty" routes could be belong to actual 404s (in which case you _want_ them to be cached for performance). The problem here isn't that the empty routes are being cached, but that they aren't being cached specifically enough.

The thing is that it's usually a module that's introducing the various context that makes a route accessible in one case and not another. In that case it's really up to the module to somehow inform the routing system about those contexts. Luckily core actually does have a way to do this with CacheableRouteProviderInterface::addExtraCacheKeyPart(). This was added in #3007669: Add publishing status to path aliases and allows you to add your own contexts to the route cache key.

An example of this is the Domain Access + Country Path module. Country path lets you toggle the active domain by path so:

- example.com/uk -> Domain #1
- example.com/us -> Domain #2

In this case when you see a link to example.com/us/whatever from example.com/uk on a cold cache, the empty route gets cached for all domains because the routing system doesn't know that there's a difference based if you're seeing the link on /us vs /uk. In this case Country path or even Domain access could inform the router about this difference. And in fact, there is actually a patch doing just that for Domain Access #3359253: Use core route provider with addExtraCacheKeyPart for route caching.

Really the hard part is usually figuring out specifically which module is introducing the contexts that cause this behaviour to happen. Ideally, the router would pull from the various render contexts that are floating around automatically, but I've only seen that eluded to in https://www.drupal.org/project/drupal/issues/2799543#comment-13344318

i-trokhanenko’s picture

StatusFileSize
new1.2 KB

Rerolled the #5 patch

gcalex5’s picture

StatusFileSize
new1.21 KB

Re-roll patch #17 for 11.2.x

Version: 11.x-dev » main

Drupal core is now using the main branch as the primary development branch. New developments and disruptive changes should now be targeted to the main branch.

Read more in the announcement.

standingtall’s picture

StatusFileSize
new2.93 KB

Re-rolled the patch for Drupal 11.3

catch’s picture

I think it's reasonable to skip caching of 404 lookups, in general the hit rate on those is going to be low. The internal page cache and reverse proxies/CDNs can cache 404 pages (by path) which means they won't hit the route lookup a second time anyway.

berdir made their first commit to this issue’s fork.

berdir’s picture

I'm a bit confused by the issue.

I see the performance aspect. With security scanners and so on, there might be a huge amount of requests to 404 pages and caching them is a bit pointless, as they're also frequently invalidated and following requests are cached by internal page cache/CDN, so hit rate is low.

What I don't quite understand and would like to see clear steps to reproduce is the functional bugs. #16 mentions domain/country_path, that made sense, but the issue referenced there was fixed. And entity/upcasting related access checks is done later. This only caches the inbound processing and the raw route query. I don't see how that's possible.

In regards to performance:

I did some profiling (on umami, so including language prefixes, with blackfire). On a 404 page with warm caches but disabled page cache, RouteProvider::getRouteCollectionForRequest() is called twice and makes up 4% of the request (of 27ms, so about 1ms). That's with two calls, one for the main request and one for the 404-rendering sub-request. That 1ms is split into 50% for processInbound() (1 call), 25% 2 database cache lookups (one miss, one hit for the 404 page), the remaining 25% is reported as the function itself, but that must include the call to getRoutesByPath() and the query.

Testing a regular cache hit, on redis, the method doesn't even register in blackfire.

But, simulating a cache miss on an existing route with warm caches including dynamic page cache, so a get + lookup + cache set, essentially the worst-case but frequent scenario on active sites due to frequent cache wipes through alias updates:

database: total request 38ms, getRouteCollectionForRequest() is almost 20% of that (7.45ms). *but*. 82% of that time is the cache write, inbound processing is 11%, cache get and the actual lookup is around 3% each.
redis: total request is 28ms, getRouteCollectionForRequest() is only 4% of that, 60% of that is inbound processing, cache get 10%, write doesn't register (yay redis 2.x!)

I tried profiling it without any caching, but blackfire had some issues, so couldn't compare there, but with xhprof/xhgui, the method is about 1%/1ms. It does trigger two dynamic queries, they're very fast now as they're in mariadb query cache but on real sites with many different variations, they're probably more expensive.

Still, I remain unconvinced that this cache is really beneficial, with the bad hit ratio on active sites.

berdir’s picture

> What I don't quite understand and would like to see clear steps to reproduce is the functional bugs. #16 mentions domain/country_path, that made sense, but the issue referenced there was fixed. And entity/upcasting related access checks is done later. This only caches the inbound processing and the raw route query. I don't see how that's possible.

So there is one test fail that seems to indicate that the problem isn't routing but alias lookups. That can apparently get cached with not enough context (workspace), that doesn't really seem 404 related, so there might be more issues lurking there?

workspaces does set a routing cache key in \Drupal\workspaces\EventSubscriber\WorkspaceRequestSubscriber::onKernelRequest() and I don't really understand the test yet to understand if this is a bug being exposed or just a faulty test.

tstoeckler’s picture

Just noting that this topic was also discussed over in #3580545: Make empty route lookup cacheable. I initially implemented proper cache context support for the 404 lookup there, but that is now not the direction the issue is going in. The code now just sits in an issue fork branch for posterity, and in case anyone wants to pick it up here.

That said and despite my disagreements with @Berdir in that issue, I'm not convinced the 404 caching makes too much sense either. On high performance sites ideally you would use some cache backend with a proper cache eviction policy, but with the default database backend it just seems like asking for cache pollution...

ironnuts’s picture

Re: #25 Re-ran the pipeline and it is all green. The failing tests seem to have been caused by a test runner failure. Seems they are unrelated.

Running the test-only code:

https://git.drupalcode.org/issue/drupal-3123401/-/jobs/9380425

LGTM.

ironnuts’s picture

Status: Needs work » Needs review
ironnuts’s picture

Issue tags: -Needs tests

berdir’s picture

I did an experimental merge request that removes caching completely to see where that lands us. See #23/#25 on thoughts and numbers.

One thing I realized when updating one performance tests is that at least in that test, we didn't actually any hits on the main request on that cache. But there were several removed cache gets and writes, which is actually secondary lookups, based on manual debugging, mostly due to Url::fromInternalPath() on link fields. Those attempt to resolve the URL to a route an match that.

that's one reason why the cache ID and logic around that is so complex.

berdir’s picture

I also updated the OpenTelemetryAuthenticatedPerformanceTest test, that's a bit a more honest scenario. As visible there, removing this cache results in two extra queries on a dynamic page cache hit, but only if we have a hit on the exact query string. Adding any kind of tracking URL for example would break that again and we're back to the same except we avoid the extra cache lookups and writes.

smustgrave’s picture

Status: Needs review » Needs work

Can this one get a summary update please

berdir’s picture

Issue summary: View changes
Status: Needs work » Needs review
Issue tags: -Needs issue summary update

I rebased, updated some more performance tests but not all.

I tried my best writing a new issue summary. It's long, it's a complicated issue with many possible alternatives.

The functional tests, also mentioned in the issue summary are quite interesting, PathWorkspacesTest and PathLanguageTest fail in ways that I can currently only assume are because they only worked due to cache hits that should not have been cache hits, maybe the tests are set up incorrectly or this is hiding bugs.

berdir’s picture

Issue summary: View changes
needs-review-queue-bot’s picture

Status: Needs review » Needs work
StatusFileSize
new98 bytes

The Needs Review Queue Bot tested this issue. The merge request has merge conflicts and cannot be merged. Therefore, this issue status is now "Needs work".

This does not mean that the patch necessarily needs to be re-rolled or the MR rebased. Read the Issue Summary, the issue tags and the latest discussion here to determine what needs to be done.

Consult the Drupal Contributor Guide to find step-by-step guides for working with issues.

berdir’s picture

Title: Routes are being cached with no parameters (ex: 404 routes) » Stop caching route lookups without query parameters or without results
Category: Bug report » Task
Status: Needs work » Needs review
Issue tags: +Performance, +Needs issue summary update

Changing to a performance task.

Discussed a little bit with @catch, he mentioned a preference for only stopping to cache lookups with query strings and those with no results.

Adjusted for that, also made it easy for a subclass to disable completely and deprecated the helper method for that.

berdir changed the visibility of the branch 3123401-remote-route-cache to hidden.

berdir’s picture

Updated the explicit caching tests and performance tests.

The standard login tests now result in not using the cache, this is because standard is now using the login form on the access denied page and that has special logic around the destination and includes it. this wouldn't happen for the first request in umami, but the redirect will include the cookie check.

We could try doing some more fancy, like ignore certain query parameters like destination that likely won't affect routing, but that will make it more complicated. or limit to only those. But that would make it more complicated. I also kind of expected routes with _format, such as ajax requests to pop up somewhere, but I haven't seen those yet, I guess we don't have explicit tests for those.

berdir’s picture

Title: Stop caching route lookups without query parameters or without results » Stop caching route lookups with query parameters or without results
needs-review-queue-bot’s picture

Status: Needs review » Needs work
StatusFileSize
new98 bytes

The Needs Review Queue Bot tested this issue. The merge request has merge conflicts and cannot be merged. Therefore, this issue status is now "Needs work".

This does not mean that the patch necessarily needs to be re-rolled or the MR rebased. Read the Issue Summary, the issue tags and the latest discussion here to determine what needs to be done.

Consult the Drupal Contributor Guide to find step-by-step guides for working with issues.

scott_euser made their first commit to this issue’s fork.

scott_euser’s picture

Assigned: Unassigned » scott_euser

Just trying to resolve conflicts; haven't played with the performance tests so might take a bit; will revert the merge commit if I get stuck

berdir’s picture

Assigned: scott_euser » berdir

As discussed on slack, I rebased and updated the asserts, force pushed over your merge. Keeping at needs work as I want to apply the changes I discussed with @catch in slack.

berdir’s picture

Status: Needs work » Needs review

Rebased again, that extra performance test is gone again.

Undeprecated the query method and created #3621551: Further route caching improvements

needs-review-queue-bot’s picture

Status: Needs review » Needs work
StatusFileSize
new1.29 KB

The Needs Review Queue Bot tested this issue. It fails the Drupal core commit checks. Therefore, this issue status is now "Needs work".

This does not mean that the patch necessarily needs to be re-rolled or the MR rebased. Read the Issue Summary, the issue tags and the latest discussion here to determine what needs to be done.

Consult the Drupal Contributor Guide to find step-by-step guides for working with issues.

berdir’s picture

Status: Needs work » Needs review