Problem/Motivation

The membership loader is used in all kinds of places. This means it could get called a lot. For instance, every time a permission is check for a group in Drupal\group\Entity\Group::hasPermission(), the membership load is used to check if the user is member of the group and which roles are added to the membership. Each permission check results in a query. This could add up pretty quick. Especially when using the subgroups patch in #2736233: Port Subgroup (ggroup) to the D8 version.

Proposed resolution

To improve performance I suggest 2 changes:

  • Statically cache the methods
  • Make load() method use the results of loadByUser(). When only checking 1 permission of 1 group, this is slower, but when you need to do a lot of permission check for a lot of groups, reusing improves the performance a lot.I think this trade-off is worth it.

Remaining tasks

Review and discuss..

User interface changes

-

API changes

-

Data model changes

-

CommentFileSizeAuthor
#34 2895988-33-2.x.patch5.3 KBseanB
#34 interdiff-29-33.txt1.39 KBseanB
#32 2895988-29-2.x.patch4.9 KBseanB
#29 2895988-29.patch6.16 KBcatch
#29 2895988-29-interdiff.patch5.91 KBcatch
#28 2895988-28.patch5.91 KBcatch
#28 2895988-28-interdiff.txt1013 bytescatch
#22 interdiff-2895988-20-22.txt1.23 KBopdavies
#22 2895988-22.patch6.1 KBopdavies
#20 interdiff-2895988-18-20.txt1003 bytesopdavies
#20 2895988-20.patch5.87 KBopdavies
#18 2895988-18.patch5.8 KBopdavies
#11 2895988-11.patch6.07 KBerlendoos
#9 2895988-9.patch6.25 KBerlendoos
#7 interdiff-4-7.txt1020 bytesbojan_dev
#7 2895988-7.patch6.28 KBbojan_dev
#6 interdiff-2895988-4-6.txt1020 bytesbojan_dev
#6 2895988-6.patch6.28 KBbojan_dev
#4 interdiff-3-4.txt1.01 KBseanB
#4 2895988-4.patch6.15 KBseanB
#3 interdiff-2-3.txt5.21 KBseanB
#3 2895988-3.patch6.07 KBseanB
#2 2895988-2.patch3.83 KBseanB

Issue fork group-2895988

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:

Support from Acquia helps fund testing for Drupal Acquia logo

Comments

seanB created an issue. See original summary.

seanB’s picture

Status: Active » Needs review
FileSize
3.83 KB

Here is a patch. Please review!

seanB’s picture

We obviously need to reset the static cache during runtime when membership are created/deleted. This patch addresses the issue. Also spotted that memberships for groups were not correctly cached by role.

seanB’s picture

Sorry about that, fixed the mistakes that killed the tests.

sriharsha.uppuluri’s picture

I see there is a lot of speed after applying the patch. From 10s to 1s. And also after apply of this @https://www.drupal.org/node/2888588

bojan_dev’s picture

When not providing an account in the loadByUser method you will get the following error: Call to a member function id() on null. I have moved the current user fallback code on top in the method.

bojan_dev’s picture

Patch #6 went a bit wrong, the #7 patch is the correct one.

borisson_’s picture

In theory this sounds like a great idea. I think providing some kind of actual performance numbers would be super awesome, but I don't think you could go wrong with committing this.

I'm not sure how strict the group maintainers are regarding tests, but it looks like this doesn't provide any - but it does increase the API surface. I think this should be covered with unit or kernel tests.

erlendoos’s picture

Comment #7 breaks getMembers.

Original:

  protected function wrapGroupContentEntities($entities) {
    $group_memberships = [];
    foreach ($entities as $group_content) {
      $group_memberships[] = new GroupMembership($group_content);
    }
    return $group_memberships;
  }

New:

  protected function wrapGroupContentEntities($entities) {
    $group_memberships = [];
    foreach ($entities as $group_content) {
      $group_memberships[$group_content->gid->target_id] = new GroupMembership($group_content);
    }
    return $group_memberships;
  }

Only one membership per group is allowed.

Status: Needs review » Needs work

The last submitted patch, 9: 2895988-9.patch, failed testing. View results

erlendoos’s picture

erlendoos’s picture

Status: Needs work » Needs review

Update status

Status: Needs review » Needs work

The last submitted patch, 11: 2895988-11.patch, failed testing. View results

Berdir’s picture

You might also be interested in #3029849: Statically cache gnode_node_grants()

Berdir’s picture

This badly broke in our case, the group page was no longer accessible. Didn't investigate yet, but somehow "return GroupAccessResult::allowedIfHasGroupPermission($entity, $account, 'view group');" returned FALSE for the user, despite having access to do that.

Berdir’s picture

Uploading a proper patch that doesn't contain "modules/contrib/group" in the path might reveal some test fails.

kristiaanvandeneynde’s picture

Keeping an eye on this. Maybe we should offload some of the logic to the GroupContentStorage? That would be the ideal place to do any caching related to loading group content entities. The membership loader is also a valid class to do some static caching in, but the bulk should probably happen in the storage.

#3029878: Statically cache loadByEntity() takes a similar approach (making the storage responsible for caching)

opdavies’s picture

Here's a new version of #11, without the modules/contrib/group paths included.

Status: Needs review » Needs work

The last submitted patch, 18: 2895988-18.patch, failed testing. View results

opdavies’s picture

Status: Needs work » Needs review
FileSize
5.87 KB
1003 bytes

Work in progress, but this reduces the number of test failures.

Status: Needs review » Needs work

The last submitted patch, 20: 2895988-20.patch, failed testing. View results

opdavies’s picture

Status: Needs work » Needs review
FileSize
6.1 KB
1.23 KB
moshe weitzman’s picture

Its green. I'm not familiar enough with group yet to properly review the patch unfortunately.

kristiaanvandeneynde’s picture

I'm close to introducing a custom static cache backend and would like to use that instead of class properties because it would support cache tags for clearing the cache entries. See the feature/query_access branch for the 'cache.group.static_no_serialize' service.

moshe weitzman’s picture

So this should be marked OutDated or Wont Fix?

catch’s picture

We have MemoryCache in core now, https://www.drupal.org/node/2973262

This is still an issue, so should stay open I think even if the fix ends up being different.

catch’s picture

Component: Group (group) » Code
Status: Needs review » Needs work
  1. +++ b/src/GroupMembershipLoader.php
    @@ -78,24 +92,44 @@ class GroupMembershipLoader implements GroupMembershipLoaderInterface {
    +
    +    if (!isset($this->userMemberships[$account->id()][$cache_id][$group->id()])) {
    +      $group_contents = $this->groupContentStorage()->loadByGroup($group, 'group_membership', $filters);
    +
    +      if ($group_memberships = $this->wrapGroupContentEntities($group_contents)) {
    +        $this->userMemberships[$account->id()][$cache_id][$group->id()] = reset($group_memberships);
    +      }
    +    }
    

    This sets $this->userMemberships[$account->id()[$cache_id][$group->id()] with a single group.

  2. +++ b/src/GroupMembershipLoader.php
    @@ -106,6 +140,15 @@ class GroupMembershipLoader implements GroupMembershipLoaderInterface {
         }
     
    +    $cache_id = md5($account->id());
    +    if ($roles) {
    +      $cache_id = is_array($roles) ? ':' . md5(implode('-', $roles)) : ':' . md5($roles);
    +    }
    +
    +    if (isset($this->userMemberships[$account->id()][$cache_id])) {
    +      return $this->userMemberships[$account->id()][$cache_id];
    +    }
    +
    

    This looks in the same cache item and takes what's in there - but it can already have been primed for a single group earlier in the request.

    So if the first call to the membership loader is for a single group, you end up with a corrupt static cache of group memberships, and this in turn corrupts the group permissions cache.

    If we wanted to share the cache between load() and loadByUser() then it either needs separate cache entries with load() falling back to loadByUser() or always populate the cache ID for every group.

Additionally:

    if (!isset($this->userMemberships[$account->id()][$cache_id][$group->id()])) {
      $group_contents = $this->groupContentStorage()->loadByGroup($group, 'group_membership', $filters);

      if ($group_memberships = $this->wrapGroupContentEntities($group_contents)) {
        $this->userMemberships[$account->id()][$cache_id][$group->id()] = reset($group_memberships);
      }
    }

This only sets the static cache if a group membership is returned, meaning if ::load() is called repeatedly for the same group membership that doesn't exist, it will try to load it each time. It would be better to explictly cache when no memberships are returned too.

catch’s picture

Category: Feature request » Task
Status: Needs work » Needs review
FileSize
1013 bytes
5.91 KB

This should help - just check to see if we already called loadByUser() and if not, call it and use the results of that.

catch’s picture

Fixing that bug in the patch uncovered another one.

kristiaanvandeneynde’s picture

Thinking of scrapping the membership loader altogether, see #3382559: Deprecate the membership loader in favor of shared bundle classes

We can try to incorporate this into the new static methods from that issue, though. But I'm thinking of using a memory cache so that we can use cache tags rather than manual clears. It's just safer that way as we have dedicated cache tags for when someone gets a new membership for instance.

kristiaanvandeneynde’s picture

Version: 8.x-1.x-dev » 3.2.x-dev
Status: Needs review » Needs work
Issue tags: +Group 3.3.0

I'd like this in the 3.3.0 release. We can try and bake this into the new GroupMembership shared bundle class.

seanB’s picture

FileSize
4.9 KB

Rerolled the patch for Group 2.x.

kristiaanvandeneynde’s picture

Version: 3.2.x-dev » 3.3.x-dev

Gonna see if I can fix this in 3.3.x

seanB’s picture

Found a small issue with my latest patch, this should fix it!

seanB’s picture

Status: Needs work » Needs review

Added a MR as well for 3.3.x.

kristiaanvandeneynde’s picture

Status: Needs review » Needs work
  1. +++ b/src/GroupMembershipLoader.php
    @@ -47,25 +61,63 @@ class GroupMembershipLoader implements GroupMembershipLoaderInterface {
    +    // Prime the cache with all group memberships via ::loadByUser().
    

    People could have a large amount of memberships, every membership incurring an entity load. I think it would be better to load the single membership here and rely on the fact that people use single loads sparingly.

  2. +++ b/src/GroupMembershipLoader.php
    @@ -47,25 +61,63 @@ class GroupMembershipLoader implements GroupMembershipLoaderInterface {
    +    return $this->userMemberships[$account->id()][$cache_id][$group->id()] ?? FALSE;
    

    Don't think this will work as we don't set the third index of this array anywhere. $group->id() is nowhere used in loadByUser()

Adjusting the above in the MR I'm working on.

kristiaanvandeneynde’s picture

Assigned: Unassigned » kristiaanvandeneynde

Cross-posted :/ Think you just addressed my 2nd point. Will take it from here.

kristiaanvandeneynde’s picture

Assigned: kristiaanvandeneynde » Unassigned
Status: Needs work » Needs review

This is what I had in mind. Reviews welcome.

Simplified the cache ID logic, we don't need md5 here IMO. Also swapped the dash out for a dot because role IDs can contain dashes and we could potentially get a cache ID collision if we use dashes.

I'm not a fan of all the static stuff, but that's what you get with these bundle classes. Also not a fan of calling a cache clear three times, I think we need to switch to cache tags on a memory cache here. That way, we don't need any manual clears at all and it works even if people bypass Group::addMember() ad Group::removeMember().

kristiaanvandeneynde’s picture

We used to have a filter argument on the storage loader methods. I removed those because it made caching the outcome really hard. Now I'm thinking if we should move all of this logic back to the storage, find a clever way to cache it there and then simply rely on the storage for doing the heavy lifting.

kristiaanvandeneynde’s picture

Status: Needs review » Needs work

Hmm, gonna revert the move to the storage handler for a bit and think about this more.

kristiaanvandeneynde’s picture

Hmm, issues I see with this are when your conditions become invalid.

E.g.: We cache all members of a group with certain roles. During the request we change one of these members' roles. Now we're screwed because we will keep returning said member from the cache as if they still had the roles we're querying for.

If we were to use a proper cache, this might not be a problem because we could invalidate the entry by virtue of cache tags. The only downside is that we only promoted MemoryCache to a fully supported one in D10.3 (#3402850: Fix MemoryCache discovery and DX), so any test relying on RefreshVariablesTrait won't work with whatever code relies on the MemoryCache.

Currently thinking of using MemoryCache nonetheless and adding a @todo to properly declare it once D10.3 is out.

kristiaanvandeneynde’s picture

We could even use a DB cache here and merely cache the GroupRelationship IDs. EntityStorageBase already makes sure we do not load an entity more than we need to, so we could then query our cache and call $storage->loadMultiple() on it. If an entity is saved anywhere, we will get a fresh copy without having to worry about invalidating our own caches.

This will also keep the memory footprint down because we're only caching IDs rather than full entities. The only thing we have to make sure is that we really get our cache tags right so that we don't serve stale results from the DB/static cache.

kristiaanvandeneynde’s picture

Status: Needs work » Needs review

Pushed a version which uses a proper cache as described above. This cache stores the IDs and then we still call $storage->loadMultiple() on that, keeping in mind that the storage caches the actual entity objects.

If this works, it could be a really elegant solution as we wouldn't have to manually clear anything.

seanB’s picture

Tomorrow is my day off, but I'll run the latest version through our tests to see if we can remove the custom cache resets. Thanks for working on this. The content overview was kind of slow taking over 10 sec for some users. With the patch it went down to like 4/5 sec, so pretty significant improvement.

kristiaanvandeneynde’s picture

Tests are still failing, will try to fix that tomorrow

kristiaanvandeneynde’s picture

Okay so this goes green now (will fix the unrelated phpstan issues in a separate issue).

The beauty of this solution is that it caches the query results across requests, meaning that if your membership information doesn't change, we immediately know what your membership IDs are from a single cache get rather than having to go to the potentially humungous group_relationship_field_data table.

One thing to keep an eye on is how many cache entries this will generate, we are currently using the default backend as the DB backend, but we might want to assign a custom one like we did for the memory cache.

kristiaanvandeneynde’s picture

@catch hinted that for the single loads I could use a primed cache to reduce the amount of entity queries we're firing when warming the cache. Something that was already in the earlier patches, actually.

Will see if I can adjust the MR with that change in mind.

kristiaanvandeneynde’s picture

Okay, all tests go green with the recent suggestion added to the MR. I'm gonna add one more comment to document why we choose to prime the cache with loadByUser rather than loadByGroup and call it a day. I'm feeling really optimistic about the impact this MR might have on performance.

kristiaanvandeneynde’s picture

Made a few changes:

  • Used a custom DB backend because on large websites this may take up a lot of data, so people can specifically turn off the persistent caching but not the memory caching.
  • Removed the individual cacheability, list cache tags should suffice here
kristiaanvandeneynde’s picture

Right, for me this patch is "ready". So if any of the reporters in this issue could verify and post results, that would be great. :)