Skip to content

[Improvement] Cache element permission checks per request (isAllowed N+1) #212

Description

@mcop1

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).

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    Affected capability

    None yet

    Platform Version

    None yet

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions