Skip to content

perf(user): let authorization accessors honour eager-loaded relations (12 → 0 queries/row) - #251

Open
dounisaur wants to merge 1 commit into
fleetbase:mainfrom
dounisaur:perf/user-authorization-accessors-honour-eager-loading
Open

perf(user): let authorization accessors honour eager-loaded relations (12 → 0 queries/row)#251
dounisaur wants to merge 1 commit into
fleetbase:mainfrom
dounisaur:perf/user-authorization-accessors-honour-eager-loading

Conversation

@dounisaur

Copy link
Copy Markdown

The problem

User::$role, $roles, $policies and $permissions each execute a fresh query on every read, because they call the relation as a query builder rather than reading the loaded relation:

return $this->companyUser->roles()->first();     // ->roles() then ->first() = a query
return $this->companyUser->policies()->get();
return $this->companyUser->permissions()->get();

The consequence is that eager-loading does nothing. with('companyUser.roles') loads the data and the accessor queries anyway.

Http\Resources\User::toArray() compounds it by evaluating $this->role four times per row — twice for role, twice for role_name — each one paying the full accessor cost.

Measurements

Taken with DB::listen over a real page of users (5 rows), on a deployed 1.6.55 install. The four methods are byte-identical on main at b7691c0, so these apply to HEAD.

queries per row
today 12
resource reads role once 8
+ eager-load companyUser 7
+ deep eager-load (companyUser.roles/policies/permissions) 7 — no change
accessors honour the loaded relation + eager-load 0

The fourth row is the interesting one: deep eager-loading currently buys nothing, which is what identifies the accessors rather than the query as the cause.

For scale, on the install this was found on the IAM Users list took ~12 s for 18 rows and grew linearly with row count.

The change

  1. Models/User.php — the four authorization accessors prefer the eager-loaded relation and fall back to the query when it is absent. Behaviour is unchanged for callers that do not eager-load.
  2. Http/Controllers/Internal/v1/UserController.phponQueryRecord() eager-loads companyUser.roles, companyUser.policies, companyUser.permissions. Placed before the early return so it applies on both branches.
  3. Http/Resources/User.php — reads the role accessor once into a local.

What it deliberately does not do

  • No memoisation and no setRelation on the accessors. Caching into a relation slot would make role / policies / permissions appear in the model's array output, which would be a silent serialization change. This approach has no such side effect.
  • No response shape change. Every key and value is identical.
  • instanceof Model guard. companyUser is not always an Eloquent model — this repo's own UserModelAuthorizationPivotFake is duck-typed — so the loaded-relation path is guarded and those callers keep the query path. Without the guard, Tests\Unit\Models\UserModelTest fails.

Test

Adds it reads eager-loaded authorization relations without re-querying them. Its pivot throws from roles() / policies() / permissions(), so the suite fails loudly if an accessor ever queries past a loaded relation again.

Verified the test has teeth: reverting the accessor change makes it fail.

Tests: 1429 passed (10024 assertions)   # 1428 before, + the new test

Deprecations and warnings are unchanged from the baseline run.

Noted, not addressed here

  • Traits/ProxiesAuthorizationMethods — its __call proxy forwards any role/policy/permission-named method to the pivot and queries per call, so getRoleName() and friends still pay per-row. The new test found this; it is a separate path and out of scope for this change.
  • Http/Resources/Role and Http/Resources/Policy always serialize their full permissions array. On the same install this made the IAM Roles and Policies lists ~357 KB and ~332 KB per page. A list-context permissions_count would fix it, but that is a breaking response-shape change, so I have left it out rather than bundle it here. Happy to raise it separately if you would take it.
  • Per-row Setting::lookup('user.<uuid>.locale') and the companyUser() fallback when users.company_uuid is null are both further per-row costs, also left out to keep this reviewable.

`User::$role`, `$roles`, `$policies` and `$permissions` each execute a fresh
query on every read, because they call the relation as a query builder
(`$this->companyUser->roles()->first()`) rather than reading the loaded
relation. Eager-loading `companyUser.roles` therefore does nothing: the data is
loaded and the accessor queries anyway.

`Http\Resources\User::toArray()` compounds it by evaluating `$this->role` four
times per row (twice for `role`, twice for `role_name`).

Measured on a real page with `DB::listen`, 5 users:

  today                                              12 queries/row
  resource reads `role` once                          8 queries/row
  + eager-load `companyUser`                          7 queries/row
  + deep eager-load (roles/policies/permissions)      7 queries/row  (no change)
  accessors honour the loaded relation + eager-load   0 queries/row

Each accessor now prefers the loaded relation and falls back to the query when
it is absent, so behaviour is unchanged for callers that do not eager-load. The
`instanceof Model` guard keeps duck-typed pivots working -- the suite's own
UserModelAuthorizationPivotFake is one.

No response shape changes, no memoisation, and nothing new appears in the
model's array output.

Adds a test whose pivot throws from roles()/policies()/permissions(), so the
suite fails loudly if an accessor ever queries past a loaded relation again.
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