perf(core): add TwoRelationsQueries.replaceWithDelta(); switch org-user PUT to use it#8820
Merged
simeng-li merged 2 commits intoMay 19, 2026
Conversation
COMPARE TO
|
| Name | Diff |
|---|---|
| .changeset/console-audit-logs-time-picker.md | 📉 -434 Bytes |
| .changeset/perf-list-org-users-lateral.md | 📉 -1.05 KB |
| .changeset/perf-relation-queries-replace-delta.md | 📉 -1.36 KB |
| packages/console/src/components/AuditLogTable/index.tsx | 📉 -181 Bytes |
| packages/core/src/libraries/protected-app.test.ts | 📉 -1.54 KB |
| packages/core/src/libraries/protected-app.ts | 📉 -116 Bytes |
| packages/core/src/queries/application-secrets.ts | 📉 -950 Bytes |
| packages/core/src/queries/organization/user-relations.ts | 📉 -766 Bytes |
| packages/core/src/routes/applications/application-secret.test.ts | 📉 -5.95 KB |
| packages/core/src/routes/applications/application-secret.ts | 📉 -1.75 KB |
| packages/core/src/routes/applications/application.ts | 📉 -44 Bytes |
| packages/core/src/routes/organization/user/index.ts | 📉 -96 Bytes |
| packages/core/src/utils/RelationQueries.test.ts | 📉 -3.86 KB |
| packages/core/src/utils/RelationQueries.ts | 📉 -2.89 KB |
| packages/integration-tests/src/tests/api/organization/organization-user.test.ts | 📉 -1.99 KB |
| packages/phrases/src/locales/ar/errors/application.ts | 📉 -80 Bytes |
| packages/phrases/src/locales/de/errors/application.ts | 📉 -97 Bytes |
| packages/phrases/src/locales/en/errors/application.ts | 📉 -69 Bytes |
| packages/phrases/src/locales/es/errors/application.ts | 📉 -88 Bytes |
| packages/phrases/src/locales/fr/errors/application.ts | 📉 -94 Bytes |
| packages/phrases/src/locales/it/errors/application.ts | 📉 -98 Bytes |
| packages/phrases/src/locales/ja/errors/application.ts | 📉 -113 Bytes |
| packages/phrases/src/locales/ko/errors/application.ts | 📉 -96 Bytes |
| packages/phrases/src/locales/pl-pl/errors/application.ts | 📉 -89 Bytes |
| packages/phrases/src/locales/pt-br/errors/application.ts | 📉 -83 Bytes |
| packages/phrases/src/locales/pt-pt/errors/application.ts | 📉 -84 Bytes |
| packages/phrases/src/locales/ru/errors/application.ts | 📉 -125 Bytes |
| packages/phrases/src/locales/th/errors/application.ts | 📉 -94 Bytes |
| packages/phrases/src/locales/tr-tr/errors/application.ts | 📉 -84 Bytes |
| packages/phrases/src/locales/zh-cn/errors/application.ts | 📉 -71 Bytes |
| packages/phrases/src/locales/zh-hk/errors/application.ts | 📉 -71 Bytes |
| packages/phrases/src/locales/zh-tw/errors/application.ts | 📉 -71 Bytes |
| packages/schemas/src/consts/application.ts | 📉 -62 Bytes |
| packages/schemas/src/consts/index.ts | 📉 -34 Bytes |
Contributor
There was a problem hiding this comment.
Pull request overview
This PR introduces an opt-in, delta-based replacement method for 2-table relation sets to reduce write amplification and avoid unintended FK-cascade side effects, and migrates PUT /organizations/:id/users to use it.
Changes:
- Added
TwoRelationsQueries.replaceWithDelta()that computes added/removed relation IDs via a single Postgres CTE (INSERT/DELETE) and returns{ added, removed }. - Switched
PUT /organizations/:id/usersto usereplaceWithDelta()to avoid rewriting large membership sets and to preserve role relations for unchanged members. - Added unit tests for the new delta SQL shape and integration tests covering delta semantics + role-preservation regression.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/core/src/utils/RelationQueries.ts | Adds replaceWithDelta() transactional delta CTE implementation returning added/removed sets. |
| packages/core/src/routes/organization/user/index.ts | Migrates org membership PUT endpoint to use replaceWithDelta(). |
| packages/core/src/utils/RelationQueries.test.ts | Adds unit tests asserting row-lock ordering and the delta CTE structure/return shape. |
| packages/integration-tests/src/tests/api/organization/organization-user.test.ts | Adds integration coverage for PUT delta semantics and role-preservation regression. |
| .changeset/perf-relation-queries-replace-delta.md | Documents the new method and the migrated call site as a @logto/core minor change. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
44a9e9e to
33f35a6
Compare
8a90c2d to
a43c4ed
Compare
wangsijie
approved these changes
May 17, 2026
Base automatically changed from
simeng-log-13472-perf-a-add-index-on-organization_user_relations-tenant_id
to
master
May 18, 2026 02:35
…er PUT to use it
TwoRelationsQueries.replace() runs DELETE-all + bulk INSERT in a
transaction, rewriting O(N) rows on every call regardless of the actual
delta size. At 10k+ memberships that became a meaningful share of PUT
request latency, and at the FK-cascade fan-out level it silently
dropped dependent rows on every call (e.g. wiping every member's role
assignments on every PUT /organizations/:id/users).
This change adds an opt-in sibling method, replaceWithDelta(), that
computes the added/removed sets in a single CTE statement and writes
only the rows that change. A no-op call writes zero rows; a one-row
delta writes one row. It returns { added, removed } so consumers (e.g.
LOG-13462's webhook payload work) can use the delta without a
re-query. replace() is unchanged and continues to back the other nine
TwoRelationsQueries subclasses, none of which need the new behavior.
Only one caller migrates in this PR: PUT /organizations/:id/users.
That route's membership set is the one that grows to 10k+ in
production, and it sits upstream of the cascade to
organization_role_user_relations — so the migration also fixes the
silent role-dropping bug. An integration test guards the
role-preservation behavior; unit tests cover the new SQL shape and
the preserved select ... for update lock.
Refs LOG-13474
a43c4ed to
54638b5
Compare
gao-sun
approved these changes
May 19, 2026
gao-sun
reviewed
May 19, 2026
change version bump to patch
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Refs LOG-13474. Third task in the Phase 0.5 performance milestone. Stacked on #8818 (Perf-A — the
organization_user_relations (tenant_id, user_id)index) which the new method's delta CTE depends on at scale.What changed
packages/core/src/utils/RelationQueries.ts— adds a new opt-in methodreplaceWithDelta(schema1Id, schema2Ids)onTwoRelationsQueries. Computes added/removed id sets in a single CTE statement (Postgres supportsINSERTandDELETEinWITH) and returns{ added, removed }. Preserves theselect ... for updaterow lock on the Schema1 entity thatreplace()already takes. The existingreplace()method is left untouched.packages/core/src/routes/organization/user/index.ts— switchesPUT /organizations/:id/usersto callreplaceWithDelta()instead ofreplace(). This is the only call site that migrates in this PR.packages/core/src/utils/RelationQueries.test.ts— new file. 3 unit tests using a slonik mock pool that captures every query: verifies the row lock fires first, the delta CTE structure (withgraph +returning+array_agg), and the empty-result return shape.packages/integration-tests/src/tests/api/organization/organization-user.test.ts— 5 new integration cases underPUT /organizations/:id/users delta semantics: no-op PUT, partial-overlap, empty body, no-overlap-from-empty, and a role-preservation regression test that fails on master and passes after this change..changeset/perf-relation-queries-replace-delta.md—@logto/corepatch.Expected result
PUT /organizations/:id/userswith a no-op body produces zero row writes againstorganization_user_relations(master: ~2N).DELETE WHERE org_id = Xinreplace()cascaded throughorganization_role_user_relationsand silently dropped every member's roles on every PUT — even no-op PUTs. New behavior is strictly more correct.204 No Content).replaceWithDelta's return value is discarded by this caller; it is wired up for downstream consumption by Phase 1 (LOG-13462).TwoRelationsQueriessubclasses (SSO connector relations, the fourapplication_user_consent_*tables,organization_role_scope_relations,organization_role_resource_scope_relations,organization_invitation_role_relations,organization_jit_roles) continue to usereplace()and see no behavior change.Reviewer notes
replaceWithDeltawas kept as a separate method rather than rewritingreplace()in place because of the cascade-preservation behavior difference. Of the tenTwoRelationsQueriessubclasses, only two sit upstream of FK cascades (organization_user_relationsandorganization_application_relations), and only one of those (organization_user_relations) is the membership-set hot path. Opt-in migration bounds the blast radius to call sites that explicitly want the new semantics, and lets reviewers reason about each migration on its own.NOT INform for the deleted CTE was rewritten toNOT EXISTSafter a self-review surfaced its NULL-fragility.next_set.idfromunnest(varchar[])can't be NULL in normal operation, but the rewrite is null-safe and a touch more index-friendly.createMockPoolonly exposes aqueryoverride, nottransaction. The mock records every query (including the ones insidepool.transaction(cb)), which is sufficient for asserting structural invariants without locking in line-by-line SQL.Testing
Unit tests, integration tests
Checklist
.changeset