Problem/Motivation

Each time the permissions cache context is calculated, this is called, leading to five cache gets (to the database cache backend) on anonymous requests.

Proposed resolution

Add a class property to store hashes calculated during the request.

Remaining tasks

User interface changes

API changes

API addition - new service which provides the memory backend as a 'static cache' with cache tag support.

Beta phase evaluation

Reference: https://www.drupal.org/core/beta-changes
Issue category Task because nothing is broken.
Issue priority Major because of Performance.
Prioritized changes The main goal of this issue is performance.
Disruption Not disruptive for core/contributed and custom modules/themes, because it is a pure internal re-factoring.

Comments

catch’s picture

Status: Needs review » Needs work

The last submitted patch, permissions_hash.patch, failed testing.

catch’s picture

Priority: Normal » Major

So this 'fixes' the test. Another way would be to add a reset method to the permissions hash generator, both pretty ugly.

But I think what we really want here is a cache tag enabled memory backend (which we already have the code for), then the cache tag invalidation would just work.

Bumping to major since this is quite a lot of database hits for no good reason at the moment on every request.

catch’s picture

Status: Needs work » Needs review
StatusFileSize
new3.41 KB
new5.11 KB

Patch for the record.

catch’s picture

StatusFileSize
new5.82 KB

Here's a different approach.

I added a new @cache.static service, which uses the memory cache backend, and hence respects cache tag invalidations.

The simpletest has to clear the cache tags due to parent vs. child site, but this would be seamless on a real request.

Status: Needs review » Needs work

The last submitted patch, 5: 2501117.patch, failed testing.

wim leers’s picture

IMO the better solution would be to statically cache the results of each cache context. Then all cache contexts benefit in the same way.

(We could key the static cache on the hash of the request it is given, to not cause a lot of pain for tests that are running multiple requests.)

catch’s picture

@Wim so the problem with that is we need to be able to invalidate the static caches if they change. When I added a basic static cache above, our tests failed because adding a new permission to a role didn't invalidate. #4 shows a way to make the tests pass but that won't help real sites.

So I think #5 is the right approach (assuming the 3 fails are silly oversights by me).

cilefen’s picture

catch’s picture

Issue summary: View changes
Status: Needs work » Needs review
Issue tags: +Performance
StatusFileSize
new1.95 KB
new7.77 KB

Fixed the unit test and added coverage for the static cache to it.

@cilefen I think that will be useful for some things, but it doesn't have the ability by itself to handle cache tag invalidations, which this new memory cache backend does.

wim leers’s picture

@Wim so the problem with that is we need to be able to invalidate the static caches if they change. When I added a basic static cache above, our tests failed because adding a new permission to a role didn't invalidate. #4 shows a way to make the tests pass but that won't help real sites.

Well, but that's only really a problem in tests, right? Is it not okay for those few tests to call a \Drupal\Core\Cache\Context\CacheContextsManager::resetCache() method?

That'd improve performance for every single cache context, and would prevent a lot of repetitive work. I can imagine this saving hundreds of function calls easily?

catch’s picture

Well if you programmatically update a permission for a role, then calculate the permissions hash for a cache context afterwards, should the hash get updated?

wim leers’s picture

Status: Needs review » Needs work

I was gonna say why not just use an in-memory cache back-end… but then we don't know the necessary cache tags :P

In any case, this is a step forward, but I just feel like it'd be much better, architecturally, perf-wise and reasoning/DX-wise, to enforce that for a given request, the values for a cache context remain constant, which implies they don't need to be calculated every single time.

But, what you're saying makes sense too. It basically means that static caching should be done by every individual cache context, because we cannot do it at a higher level, because we won't know how to invalidate it.

So, let's do this.


  1. +++ b/core/core.services.yml
    @@ -140,6 +140,16 @@ services:
    +  cache.backend.memory:
    

    If we're adding this back to core, then we must remove it again from sites/default/development.services.yml.

  2. +++ b/core/core.services.yml
    @@ -140,6 +140,16 @@ services:
    +  # A special cache bin that by default does not persist beyond the length of the request.
    

    80 cols.

  3. +++ b/core/lib/Drupal/Core/Session/PermissionsHashGenerator.php
    @@ -40,9 +47,10 @@ class PermissionsHashGenerator implements PermissionsHashGeneratorInterface {
    +  public function __construct(PrivateKey $private_key, CacheBackendInterface $cache, CacheBackendInterface $static) {
    

    New parameter, but docblock not updated.

  4. +++ b/core/tests/Drupal/Tests/Core/Session/PermissionsHashTest.php
    @@ -150,6 +150,27 @@ public function testGenerateCache() {
    +   * Tests the generate method with static cache returned.
    

    Just @covers ::generate will do.

  5. +++ b/core/tests/Drupal/Tests/Core/Session/PermissionsHashTest.php
    @@ -150,6 +150,27 @@ public function testGenerateCache() {
    +    $this->permissionsHasn = new PermissionsHashGenerator($this->privateKey, new \Drupal\Core\Cache\MemoryBackend('test'), $this->cache);
    

    s/Hasn/Hash/

    :)

catch’s picture

Status: Needs work » Needs review
StatusFileSize
new6.66 KB
new9.92 KB

Fixes #13.

We can still look at caching at a higher level but I wonder what that looks like for pre-generation etc.

catch’s picture

StatusFileSize
new9.92 KB

Couple of stray whitespace changes, just fixing those.

The last submitted patch, 14: 2501117.patch, failed testing.

Status: Needs review » Needs work

The last submitted patch, 15: 2501117.patch, failed testing.

wim leers’s picture

berdir filed exactly the same issue, for the exact same reasons: #2509478: PermissionsHashGenerator should have a static cache. Marked it as a duplicate.

Copying his IS verbatim:

Problem/Motivation

While profiling #2498919: Node::isPublished() and Node::getOwnerId() are expensive, I noticed that PermissionsHashGenerator is called multiple times (5, in my case) and every time calls out to the cache backend. That's almost 1ms in my case.

Caching itself might be questionable. For my authenticated user with only the default permissions from the authenticated role, fetching from cache was actually slower than generating the hash. But I guess that is different if you have multiple roles and many permissions.

Proposed resolution

Add a static cache, by uid or list of roles.

catch’s picture

Status: Needs work » Needs review
StatusFileSize
new895 bytes
new9.92 KB

Unit test was fine, but found a bug in the actual code.

wim leers’s picture

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

Love it! RTBC + 1

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 19: 2501117.patch, failed testing.

wim leers’s picture

Status: Needs work » Reviewed & tested by the community
StatusFileSize
new10.33 KB

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 23: 2501117-23.patch, failed testing.

olli’s picture

Status: Needs work » Needs review
StatusFileSize
new9.9 KB
new1.32 KB
  1. +++ b/core/core.services.yml
    @@ -140,6 +140,16 @@ services:
    +  cache.backend.memory:
    +    class: Drupal\Core\Cache\MemoryBackendFactory
    +    arguments: ['@cache_tags.invalidator.checksum']
    
    +++ b/sites/development.services.yml
    @@ -3,7 +3,5 @@
    -  cache.backend.memory:
    -    class: Drupal\Core\Cache\MemoryBackendFactory
    

    Removed arguments:

  2. +++ b/core/core.services.yml
    @@ -140,6 +140,16 @@ services:
    +  cache.static:
    +    class: Drupal\Core\Cache\CacheBackendInterface
    +    tags:
    +      - { name: cache.bin, default_backend: cache.backend.memory }
    +    factory: cache_factory:get
    +    arguments: [memory]
    

    I think other bins are named as cache.[bin name] so replaced [memory] with [static].

  3. +++ b/core/tests/Drupal/Tests/Core/Session/PermissionsHashGeneratorTest.php
    @@ -182,12 +200,45 @@ public function testGenerateCache() {
    +    $this->permissionsHash->generate($this->account1);
    

    To make the test pass I changed account1 to account2 because account1 (super user) doesn't use the static cache.

dawehner’s picture

Maybe a naive question, I see that Drupal\Core\Cache\Context\CacheContextsManager::convertTokensToKeys is kinda the root of the evilness here.

So as you will see there, we ask MANY things multiple times, of which the PermissionHashGenerator is just one of them by accident.
Would it not be much more effective to static cache on that heigher level?

Function Name Calls Calls% Incl. Wall Time
(microsec)
IWall% Incl.
MemUse
(bytes)
IMemUse% Incl.
PeakMemUse
(bytes)
IPeakMemUse%
Current Function
Drupal\Core\Cache\Context\CacheContextsManager::convertTokensToKeys 12 6.1% 5,465 6.0% 437,752 2.6% 422,736 2.5%
Exclusive Metrics for Current Function 325 5.9% -23,240 -5.3% 5,672 1.3%
Parent function
Drupal\Core\Render\RenderCache::createCacheID 12 100.0% 5,465 100.0% 437,752 100.0% 422,736 100.0%
Child functions
Drupal\Core\Cache\Context\AccountPermissionsCacheContext::getContext 5 2.8% 1,687 30.9% 7,664 1.8% 21,856 5.2%
Drupal\Core\Cache\Context\CacheContextsManager::optimizeTokens 12 6.7% 937 17.1% 119,088 27.2% 83,960 19.9%
Drupal\Core\Cache\Context\ThemeCacheContext::getContext 12 6.7% 797 14.6% 189,000 43.2% 175,648 41.6%
Drupal\Core\Cache\Context\MenuActiveTrailsCacheContext::getContext 8 4.5% 649 11.9% 16,672 3.8% 37,888 9.0%
Drupal\Core\Cache\Context\LanguagesCacheContext::getContext 12 6.7% 573 10.5% 26,976 6.2% 31,840 7.5%
Drupal\Core\Cache\Context\CacheContextsManager::getService 41 22.9% 152 2.8% 22,000 5.0% 20,088 4.8%
Drupal\node\Cache\NodeAccessGrantsCacheContext::getContext 1 0.6% 87 1.6% 10,368 2.4% 9,936 2.4%
Drupal\Core\Cache\Context\CacheContextsManager::parseTokens 24 13.4% 78 1.4% 31,320 7.2% 9,096 2.2%
Drupal\Core\Cache\Context\CacheContextsManager::validateTokens 12 6.7% 63 1.2% 8,288 1.9% 8,160 1.9%
Drupal\Core\Cache\Context\PagersCacheContext::getContext 1 0.6% 30 0.5% 7,520 1.7% 6,640 1.6%
spl_autoload_call 1 0.6% 25 0.5% 9,776 2.2% 8,264 2.0%
Drupal\Core\Cache\CacheableMetadata::merge 12 6.7% 21 0.4% 2,440 0.6% 688 0.2%
Drupal\Core\Cache\Context\TimeZoneCacheContext::getContext 1 0.6% 17 0.3% 2,048 0.5% 1,376 0.3%
array_diff 12 6.7% 12 0.2% 2,696 0.6% 752 0.2%
Drupal\Core\Cache\Context\UserRolesCacheContext::getContext 1 0.6% 11 0.2% 3,744 0.9% 0 0.0%
sort 12 6.7% 1 0.0% 672 0.2% 456 0.1%
Drupal\Core\Cache\Context\ContextCacheKeys::__construct 12 6.7% 0 0.0% 720 0.2% 416 0.1%
berdir’s picture

Yes, but as often, the trick is invalidation. The permission hash generator knows that it's based on the current user. But the context manager doesn't know that the the generated user.permission varies by the current user. We could cache it by current user, but I'm not sure that will always work. We have the cacheablity metadata now, but I'm not sure that will help.

And I think this will still be called directly as well.

dawehner’s picture

Status: Needs review » Reviewed & tested by the community
+++ b/core/tests/Drupal/Tests/Core/Session/PermissionsHashGeneratorTest.php
@@ -138,12 +145,15 @@ protected function setUp() {
+    $this->staticCache = $this->getMockBuilder('Drupal\Core\Cache\CacheBackendInterface')
+      ->disableOriginalConstructor()
+      ->getMock();

Quick tip: This can be just $this->getMock('Drupal\Core\Cache\CacheBackendInterface');

Did some quick profiling, this now safes 1% in total for the default frontpage as anonymous user without page cache:

### FINAL REPORT

=== 8.0.x..8.0.x compared (55a37f7567581..55a37f90df5d2):

ct  : 30,717|30,717|0|0.0%
wt  : 71,937|72,163|226|0.3%
mu  : 16,723,496|16,723,456|-40|-0.0%
pmu : 16,755,552|16,755,552|0|0.0%

=== 8.0.x..2501117 compared (55a37f7567581..55a37fa26abf8):

ct  : 30,717|30,610|-107|-0.3%
wt  : 71,937|71,277|-660|-0.9%
mu  : 16,723,496|16,761,328|37,832|0.2%
pmu : 16,755,552|16,793,400|37,848|0.2%

---
ct = function calls, wt = wall time, cpu = cpu time used, mu = memory usage, pmu = peak memory usage

### XHPROF-LIB REPORT

+-------------------+------------+------------+------------+------------+------------+
| namespace         |        min |        max |       mean |     median |       95th |
+-------------------+------------+------------+------------+------------+------------+
| Calls             |            |            |            |            |            |
|                   |            |            |            |            |            |
| 2501117           |     30,610 |     32,910 |     30,633 |     30,610 |     30,610 |
| 8_0_x             |     30,717 |     33,017 |     30,740 |     30,717 |     30,717 |
|                   |            |            |            |            |            |
| Wall time         |            |            |            |            |            |
|                   |            |            |            |            |            |
| 2501117           |     71,277 |    100,554 |     75,279 |     73,877 |     80,367 |
| 8_0_x             |     72,163 |    101,668 |     75,761 |     74,842 |     81,553 |
|                   |            |            |            |            |            |
| Memory usage      |            |            |            |            |            |
|                   |            |            |            |            |            |
| 2501117           | 16,761,312 | 17,310,096 | 16,804,254 | 16,761,328 | 17,155,360 |
| 8_0_x             | 16,723,440 | 17,271,544 | 16,772,721 | 16,723,456 | 17,075,725 |
|                   |            |            |            |            |            |
| Peak memory usage |            |            |            |            |            |
|                   |            |            |            |            |            |
| 2501117           | 16,793,336 | 17,480,560 | 16,837,677 | 16,793,400 | 17,186,680 |
| 8_0_x             | 16,755,552 | 17,442,376 | 16,806,196 | 16,755,552 | 17,106,853 |
|                   |            |            |            |            |            |
+-------------------+------------+------------+------------+------------+------------+
olli’s picture

Thanks for the tip and profiling @dawehner!

+++ b/core/modules/user/src/Tests/UserPermissionsTest.php
@@ -56,6 +57,7 @@ function testUserPermissionChanges() {
+    Cache::invalidateTags(["config:user.role.$this->rid"]);

@@ -70,6 +72,7 @@ function testUserPermissionChanges() {
+    Cache::invalidateTags(["config:user.role.$this->rid"]);

@@ -83,6 +86,7 @@ function testUserPermissionChanges() {
+    Cache::invalidateTags(["config:user.role.$this->rid"]);

It would be nice to get rid of these. How about adding MemoryBackend::reset() method that is called by WebTestBase::refreshVariables()?

wim leers’s picture

Status: Reviewed & tested by the community » Needs review

#29:

But I think what we really want here is a cache tag enabled memory backend (which we already have the code for), then the cache tag invalidation would just work.

So calling MemoryBackend::reset() shouldn't be necessary. OTOH, indeed, invalidating the cache tags manually also shouldn't be necessary. Why is that?

olli’s picture

Related issues: +#2311945: Add ResettableCacheBackendInterface
StatusFileSize
new8.29 KB
new2.53 KB

#30: It's necessary to see any changes made in the other thread.

Here's a patch with MemoryBackend::reset().

fabianx’s picture

Status: Needs review » Reviewed & tested by the community

Back to RTBC, that looks great!

wim leers’s picture

Status: Reviewed & tested by the community » Needs review

I don't understand:

  1. how/where MemoryBackend::reset() is being called?
  2. why that is necessary at all; why aren't the cache tag invalidations caused by changing permissions (which cause Role entities to be saved, and hence the cache tags for those roles to be invalidated) sufficient?
fabianx’s picture

#33: Cache tags are cached statically, hence they are only loaded / invalidated once per request.

berdir’s picture

Status: Needs review » Reviewed & tested by the community

Yes, this is correct and better.

As always, we have two runtime environments in simpletest, the test and actual requests. It's impossible to for the code in the requests to reset the static caches in the test environment, that's why we have refreshVariables() and reset config, cache tags on the checksum service and so on already.

fabianx’s picture

RTBC + 1 - x-post

wim leers’s picture

Ahhhhhh! I was very confused because CacheBackendInterface::reset() does not exist. So I was wondering how this could possibly ever be called automatically. But, of course, using is_callable() directly like https://api.drupal.org/api/drupal/core%21modules%21simpletest%21src%21We... does… well, that works.

Let's create a ResettableCacheBackendInterface in a follow-up?

wim leers’s picture

Oh, and RTBC+1 then :)

Sorry for the distraction & confusion!

wim leers’s picture

Related issues:

Thanks to @Berdir for pointing me to #2311945: Add ResettableCacheBackendInterface, which is the issue to do #37.

wim leers’s picture

Related issues:

Oops, olli already linked it in #30. I had no idea based on the issue title that that was what it did. Now retitled that other issue.

olli’s picture

+++ b/core/core.services.yml
@@ -1259,7 +1268,7 @@ services:
-    arguments: ['@private_key', '@cache.default']
+    arguments: ['@private_key', '@cache.default', '@cache.static']

Sorry, one more question... How about injecting a BackendChain (here or in a follow-up)? Isn't this an use case for it?

dawehner’s picture

While reading the memory backend code I got confused that we serialize/unserialize the data there. I cannot imagine that there is a proper usecase for it.

berdir’s picture

References. If we don't, then we might end up with changing cached data by reference (objects), that's not something that you'd expect as a user of the API.

fabianx’s picture

Issue summary: View changes

I added a beta evaluation.

alexpott’s picture

Conceptually I think I'd prefer this to be using something like #2260187: [PP-1] Deprecate drupal_static() and drupal_static_reset() because to me it looks like we're using the memory cache as a substitute for a static property that we want to be able to clear at test time.

wim leers’s picture

#46: I don't understand what you mean; this issue uses the memory cache backend specifically to allow cache tag invalidations to still work. #2260187: [PP-1] Deprecate drupal_static() and drupal_static_reset() does not fix that AFAICT.

But I suspect I'm overlooking/misreading something :)

catch’s picture

@alexpott so in core the memory cache is only useful for tests.

However, some kind of queue processing which both messes with permissions and say does pre-generation of the render cache for something would need the same behaviour. We might never have that queue processor, but having the cache tags 'just work' with the property was nice I thought.

alexpott’s picture

Status: Reviewed & tested by the community » Fixed

Committed 9aaddd2 and pushed to 8.0.x. Thanks!

Thanks for adding the beta evaluation to the issue summary.

  • alexpott committed 9aaddd2 on 8.0.x
    Issue #2501117 by catch, olli, Wim Leers, dawehner: Add static caching...

Status: Fixed » Closed (fixed)

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

xtineroque’s picture

ndobromirov’s picture

This is somewhat related.