Summary
Add request-scoped memoization of element permission resolution. Every isAllowed()/areAllowed() call currently runs fresh SQL against users_workspaces_*, with no caching — so repeated checks on the same element within a request re-query the database every time.
Problem
AbstractElement::isAllowed() (models/Element/AbstractElement.php:495-520) delegates to the type Dao (models/DataObject/AbstractObject/Dao.php:508) which queries users_workspaces_* on every call. There is no per-request cache anywhere: calling isAllowed('view', $user) twice on the same element issues the query twice.
Any code path that checks permissions in a loop is therefore a SQL N+1: grid exports (Studio export re-enters core isAllowed per element), custom controllers/API endpoints, scheduled tasks, permission dialogs, legacy admin. (Studio's own tree/grid is index-filtered and unaffected.)
Measured (demo, non-admin user, 6 permission types × 286 objects = 1,716 checks/pass)
| Pass |
Queries |
Time |
| Stock, first pass |
1,842 |
2,430 ms |
| Stock, identical repeat |
1,787 |
2,420 ms |
| With memoization, repeat |
0 |
5 ms |
The identical second stock pass costing the same as the first is the proof that nothing is cached today.
Proposed solution
A request-scoped cache keyed on (userId, elementType, elementId, permissionType), implementing Symfony's ResetInterface so it clears automatically per request and per messenger message. Cache only the Dao lookup — the workflow-deny check and the ELEMENT_PERMISSION_IS_ALLOWED event stay outside, since listeners may decide dynamically.
final class ElementPermissionCache implements \Symfony\Contracts\Service\ResetInterface
{
/** @var array<string, bool> */
private array $cache = [];
public function get(User $user, ElementInterface $el, string $type): ?bool
{
return $this->cache[$this->key($user, $el, $type)] ?? null;
}
public function set(User $user, ElementInterface $el, string $type, bool $allowed): void
{
$this->cache[$this->key($user, $el, $type)] = $allowed;
}
public function reset(): void { $this->cache = []; }
private function key(User $u, ElementInterface $e, string $t): string
{
return $u->getId() . '|' . Service::getElementType($e) . '|' . $e->getId() . '|' . $t;
}
}
Integration in isAllowed() / areAllowed():
if (null === $isAllowed = $cache->get($user, $this, $type)) {
$isAllowed = $this->getDao()->isAllowed($type, $user);
$cache->set($user, $this, $type, $isAllowed);
}
Invalidation: reset() on user/role save, workspace (users_workspaces_*) writes, and element move/path change. All three are rare interactive events — a full reset is simpler and safer than granular eviction, and ResetInterface already handles the request/worker boundary.
Behavior / risk
Behavior-preserving by construction: the cache key is the exact set of inputs the Dao reads, and workspace data cannot change mid-request except through the reset hooks above. No public API change; the cache is an internal service.
Effort
S — one service, ~3 integration points, reset hooks, tests.
Related
Part of the performance audit; companion tickets: cache write-limit fix (separate issue) and the permission-query rewrite (separate issue — handled as a bugfix).
Summary
Add request-scoped memoization of element permission resolution. Every
isAllowed()/areAllowed()call currently runs fresh SQL againstusers_workspaces_*, with no caching — so repeated checks on the same element within a request re-query the database every time.Problem
AbstractElement::isAllowed()(models/Element/AbstractElement.php:495-520) delegates to the type Dao (models/DataObject/AbstractObject/Dao.php:508) which queriesusers_workspaces_*on every call. There is no per-request cache anywhere: callingisAllowed('view', $user)twice on the same element issues the query twice.Any code path that checks permissions in a loop is therefore a SQL N+1: grid exports (Studio export re-enters core
isAllowedper element), custom controllers/API endpoints, scheduled tasks, permission dialogs, legacy admin. (Studio's own tree/grid is index-filtered and unaffected.)Measured (demo, non-admin user, 6 permission types × 286 objects = 1,716 checks/pass)
The identical second stock pass costing the same as the first is the proof that nothing is cached today.
Proposed solution
A request-scoped cache keyed on
(userId, elementType, elementId, permissionType), implementing Symfony'sResetInterfaceso it clears automatically per request and per messenger message. Cache only the Dao lookup — the workflow-deny check and theELEMENT_PERMISSION_IS_ALLOWEDevent stay outside, since listeners may decide dynamically.Integration in
isAllowed()/areAllowed():Invalidation:
reset()on user/role save, workspace (users_workspaces_*) writes, and element move/path change. All three are rare interactive events — a full reset is simpler and safer than granular eviction, andResetInterfacealready handles the request/worker boundary.Behavior / risk
Behavior-preserving by construction: the cache key is the exact set of inputs the Dao reads, and workspace data cannot change mid-request except through the reset hooks above. No public API change; the cache is an internal service.
Effort
S — one service, ~3 integration points, reset hooks, tests.
Related
Part of the performance audit; companion tickets: cache write-limit fix (separate issue) and the permission-query rewrite (separate issue — handled as a bugfix).