Skip to content

Faster user permission lookups - #20149

Merged
brandonkelly merged 3 commits into
5.12from
perf/user-permission-lookups
Oct 28, 2026
Merged

brandonkelly merged 3 commits into
5.12from
perf/user-permission-lookups

Conversation

@brandonkelly

Copy link
Copy Markdown
Member

Summary

 UserPermissions::doesUserHavePermission($userId, $permission)
-  in_array(strtolower($permission), $permissionsByUserId[$userId])   // O(n) per check
+  isset($permissionMapsByUserId[$userId][strtolower($permission)])   // O(1) per check

Users' cached permission lists now have a map alongside them for lookups. The map is rebuilt whenever the list is cached (getPermissionsByUserId(), saveUserPermissions()) and cleared by reset().

Evidence

Per-lookup cost (doesUserHavePermission(), mean of 20,000 lookups, half hits):

User’s permissions Before After
200 0.89 µs 0.14 µs
2,000 7.09 µs 0.13 µs
8,600 25.3 µs 0.14 µs

Semi-complex site (PostgreSQL, production mode, GraphQL caching off; median of 11–15 fresh processes):

  • 3 sites, 112 fields (86 simple, 20 relation, 6 Matrix), 72 entry types
  • 30 sections (18 channels, 8 structures, 4 singles), 6 volumes, 5 category groups, 2 tag groups, 4 global sets
  • 6 user groups; the “Editors” group, whose permissions the benchmark user has, holds 197 permissions across two-thirds of the sections, plus all volumes, categories and globals
  • 304 entries
Scenario Before After
Entry index authorization for an editor (sources + canView/Save/Delete/Duplicate/CreateDrafts × 100 entries) 119.3 ms 115.9 ms
GraphQL entries(limit: 20) 405.2 ms 409.7 ms
Building an authenticated GraphQL schema for the editor (#20147) 65.4 ms 67.9 ms

At this scale, permission lookups aren't the bottleneck; the differences are within noise. The gains appear with thousands of permissions. In a mocked test with 1,000 sections and 200 volumes (8,600 permissions), the authenticated schema build went from 118 ms to 20 ms.

Regression test: UserPermissionsTest::testDoesUserHavePermission now also checks that lookups are case-insensitive and that reset() drops cached permissions. It fails if reset() doesn't clear the new map.

Related issues

doesUserHavePermission() scanned the user's whole permission list for every
check. Keep a map alongside the cached list so lookups are constant-time.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-ae777030-d792-49ae-8853-87e83195c88f
@brandonkelly
brandonkelly force-pushed the perf/user-permission-lookups branch from a0c4a60 to 90a86b5 Compare October 28, 2026 15:12
brandonkelly and others added 2 commits October 28, 2026 15:18
Keeps doesUserHavePermission() working for subclasses that override
getPermissionsByUserId().

Amp-Thread-ID: https://ampcode.com/threads/T-ae777030-d792-49ae-8853-87e83195c88f
Co-authored-by: Amp <amp@ampcode.com>
@brandonkelly
brandonkelly merged commit 79e7539 into 5.12 Oct 28, 2026
11 checks passed
@brandonkelly
brandonkelly deleted the perf/user-permission-lookups branch October 28, 2026 15:38
brandonkelly added a commit that referenced this pull request Oct 29, 2026
Ports the 5.12 changes to 6.x:

- User permission lookups use a hash map built from
  getPermissionsByUserId(), cleared whenever user or group permissions
  are saved (#20149)
- Applying volume config saved before the Propagation Method setting
  existed keeps existing volumes' propagation method, rather than
  switching them to "all" and queuing a resave, and new volumes follow
  the migration's site-count rule

6.x's Str::convertToUtf8() already falls back to Windows-1252, and the
yii2-phpstan dev dependency only applies to 5.x's PHPStan setup, so
StringHelper and composer.lock keep 6.x's versions.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-5834f157-5515-4cdf-8d3d-71e4c0bfc685
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant