Skip to content

perf(core): aggregate roles via LATERAL in getUsersByOrganizationId#8826

Merged
simeng-li merged 1 commit into
masterfrom
simeng-log-13475-perf-d-rewrite-getusersbyorganizationid-with-lateral-role
May 19, 2026
Merged

perf(core): aggregate roles via LATERAL in getUsersByOrganizationId#8826
simeng-li merged 1 commit into
masterfrom
simeng-log-13475-perf-d-rewrite-getusersbyorganizationid-with-lateral-role

Conversation

@simeng-li
Copy link
Copy Markdown
Contributor

Summary

Refs LOG-13475. Fourth task in the Phase 0.5 performance milestone. Branched from current master — both prerequisite indexes (Perf-A merged via #8818, Perf-B merged via #8819) are already there.

What changed

  • packages/core/src/queries/organization/user-relations.tsgetUsersByOrganizationId entities query rewritten:
    • Role aggregation moved from LEFT JOIN organization_role_user_relations JOIN organization_roles GROUP BY users.id to a LEFT JOIN LATERAL (...) ON TRUE subquery that produces exactly one organizationRoles JSON array per user row.
    • The lateral subquery does an INNER JOIN between organization_role_user_relations and organization_roles, so the filter (where roles.id is not null) guard from aggregateRoles() is no longer needed; coalesce(json_agg(...), '[]'::json) handles the empty-role case.
    • ORDER BY fields.userId (the relation table's user_id, NOT NULL by table definition) is added explicitly. The previous query relied on GROUP BY users.id to stabilize pagination row order; without it, page boundaries would be non-deterministic. Using fields.userId instead of users.fields.id keeps the sort key non-null even if the LEFT JOIN to users were ever to produce an orphan row.
  • The count branch is untouched — it doesn't join role tables and its count(*) remains consistent with the entities branch.
  • .changeset/perf-list-org-users-lateral.md@logto/core patch.

Expected result

  • GET /organizations/:id/users?page=1&page_size=20 on a 10k-member org with an average of 3 roles per user runs the role-aggregation lateral ~20 times (one per user in the page) instead of materializing a ~30k-row intermediate join. Each lateral execution hits organization_role_user_relations__tenant_id_org_id_user_id (added by LOG-13473 / #8819) directly.
  • Response shape is unchanged: same user fields, same organizationRoles array contents (ids + names, ordered by role name), same [] for users with no roles, same [totalCount, entities] return shape, same pagination semantics.
  • Behavior is unchanged for clients.

Reviewer notes

  • The lateral form is required because the role-set fan-out per user is what the optimizer can't push past GROUP BY in master; a CTE wouldn't help because the planner still needs to know whether to materialize the full join before LIMIT.
  • aggregateRoles() in queries/organization/utils.ts is still in use by getOrganizationsByUserId (which has the symmetric problem on the org-side but isn't in this PR's scope). The aggregation is inlined here instead of reusing the helper because the lateral form uses an INNER JOIN inside the subquery, so the helper's filter (where roles.id is not null) guard isn't needed.
  • A behavior-parity self-review confirmed: same row set for any (org, search) combination, same organizationRoles contents, same orphan-row behavior, same identifier casing on the returned column.

Testing

Tested locally

Checklist

  • .changeset
  • unit tests
  • integration tests
  • necessary TSDoc comments

Copilot AI review requested due to automatic review settings May 18, 2026 06:18
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 18, 2026

COMPARE TO master

Total Size Diff 📈 +4.97 KB

Diff by File
Name Diff
.changeset/perf-list-org-users-lateral.md 📈 +1.05 KB
packages/core/src/queries/organization/user-relations.ts 📈 +766 Bytes
packages/integration-tests/src/tests/api/organization/organization-user.test.ts 📈 +3.17 KB

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Rewrites the entities branch of getUsersByOrganizationId so role aggregation runs inside a LEFT JOIN LATERAL subquery (one execution per user row) instead of joining roles up-front and collapsing with GROUP BY users.id. This lets LIMIT/OFFSET prune users before role lookups, leveraging the recently added organization_role_user_relations__tenant_id_org_id_user_id index. An explicit ORDER BY fields.userId replaces the implicit ordering that the previous GROUP BY provided.

Changes:

  • Replaces the role join + aggregateRoles() + GROUP BY users.id with a per-user LATERAL subquery emitting the organizationRoles JSON array directly.
  • Adds explicit ORDER BY ${fields.userId} to keep pagination ordering deterministic.
  • Adds a @logto/core patch changeset documenting the performance fix.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
packages/core/src/queries/organization/user-relations.ts Rewrites entities query in getUsersByOrganizationId to use a LATERAL role-aggregation subquery and explicit ordering.
.changeset/perf-list-org-users-lateral.md Patch changeset describing the perf fix.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@simeng-li simeng-li force-pushed the simeng-log-13475-perf-d-rewrite-getusersbyorganizationid-with-lateral-role branch from aecad0f to 296ce5f Compare May 18, 2026 06:25
@github-actions github-actions Bot added size/s and removed size/s labels May 18, 2026
The entities query joined organization_role_user_relations and
organization_roles, then collapsed back with GROUP BY users.id before
applying LIMIT. Postgres had to materialize the full
members x roles_per_member intermediate join on every paginated request
regardless of page size — a 20-row page over a 10k-member org with 3
roles each produced ~30k intermediate rows.

The rewrite moves the per-user role aggregation into a LATERAL
subquery joined to the outer user set. LIMIT now prunes user rows
before the role-table lookups fire, and each lateral lookup hits
organization_role_user_relations__tenant_id_org_id_user_id (the index
landed in LOG-13473) directly.

ORDER BY fields.userId is added explicitly. The previous query relied
on GROUP BY users.id to stabilize row ordering for pagination; without
it, page boundaries would be non-deterministic. fields.userId is the
relation table's own column and is NOT NULL, so the ordering remains
stable even for the edge case where the LEFT JOIN to users produces a
NULL users.id.

Refs LOG-13475
@simeng-li simeng-li force-pushed the simeng-log-13475-perf-d-rewrite-getusersbyorganizationid-with-lateral-role branch from 296ce5f to 1707952 Compare May 18, 2026 06:29
Copilot AI review requested due to automatic review settings May 18, 2026 06:29
@github-actions github-actions Bot added size/m and removed size/s labels May 18, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

@simeng-li simeng-li merged commit 3edda52 into master May 19, 2026
44 of 45 checks passed
@simeng-li simeng-li deleted the simeng-log-13475-perf-d-rewrite-getusersbyorganizationid-with-lateral-role branch May 19, 2026 06:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Development

Successfully merging this pull request may close these issues.

4 participants