mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-22 15:32:15 +00:00
perf(query): use UNION of index seeks for ListUsers login equality (#12701)
<!-- CURSOR_AGENT_PR_BODY_BEGIN --> # Which Problems Are Solved Login V2 `ListUsers` lookups of the form username OR email (and email OR phone) hash-join the full `users14` / `users14_humans` tables, then filter. That cost grows with instance size (~40ms at 10k users, ~650ms at 200k). Top-level login-name equals already used an indexed join, but the same login-name filter inside an `OrQuery` fell back to the slow view-based exists query. # How the Problems Are Solved **TL;DR:** equality lookups on username, email, phone, or login name become `INNER JOIN (UNION of indexed ID seeks) AS matches`. Mixed / contains / name searches keep the old WHERE shape. ### Reviewer map | Read first | Why | | --- | --- | | `internal/query/user_login_equality.go` | Typed filters + extractor + UNION join | | `internal/query/user.go` (`prepareUsersQuery`, constructors) | Wiring; username/email/phone equals return `lowerEqualsFilter` | | `cmd/setup/76.go` + `cmd/setup/76/*.sql` | Concurrent indexes for existing deployments; drop invalid leftovers | | `internal/query/projection/user.go` | Same indexes in `Init()` for new databases | | `internal/query/user_login_name_exists_test.go` | SQL-shape coverage | | `internal/api/grpc/user/v2/integration_test/query_test.go` | Nested username AND/OR user results | ### Query rewrite - Extract one equals leaf, a flat `OrQuery` of those leaves, or the same nested under `AndQuery`. Remaining AND conjuncts stay as WHERE. Equality nested under `OrQuery` / `NotQuery` is left as WHERE. - Login-name equals is a UNION arm that reuses `login_names3_users` SQL, so it works inside OR as well as at the top level. - EQUALS seeks use `LOWER(col)` for the index, then recheck `col = value` so case-sensitive searches do not match extra rows. EQUALS_IGNORE_CASE is unchanged. - Empty OR arms are not skipped (that would change OR meaning); mixed empty-OR falls back to WHERE. Login UI already omits empty arms. ### Indexes - Setup 76: `CREATE INDEX CONCURRENTLY IF NOT EXISTS` on `users14_username_lower_idx` `(instance_id, LOWER(username))` and `users14_humans_phone_lower_idx` `(instance_id, LOWER(phone))`. Not wrapped in a transaction. - Before create, drop any leftover `pg_index.indisvalid = false` indexes of those names (failed/cancelled `CREATE INDEX CONCURRENTLY`). `IF NOT EXISTS` would otherwise skip rebuild. - `Init()` creates the same indexes without `CONCURRENTLY` (empty tables). Existing `users14_username` and email lower indexes are unchanged. # Additional Changes - Unit tests assert UNION SQL for username OR email, email OR phone, login name OR email, username OR username, nested `AndQuery(loginName, org)`, and nested `AndQuery(Or(...), org)`; that contains / display-name / mixed OR / nested `OrQuery(AndQuery(...), ...)` do not UNION; that email EQUALS rechecks the raw column; and that empty-OR is not extracted. - Setup 76 sqlmock coverage: missing `users14` skips work; valid/absent indexes only create; invalid leftover username index is dropped then both creates run. - `TestServer_ListUsers_UsernameAndOr` checks live results: org AND (alice OR bob) returns both users, sibling username AND is empty, nested `AndQuery(Or(...), org)` returns both. # Additional Context - Base branch: `main` - Extends the login-name equals rewrite from #12460 to username/email/phone OR trees used by Login V2 search. <!-- CURSOR_AGENT_PR_BODY_END --> <div><a href="https://cursor.com/agents/bc-1f043b1c-6168-45ca-9e9e-c18451994207?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a> <a href="https://cursor.com/background-agent?bcId=bc-1f043b1c-6168-45ca-9e9e-c18451994207&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a> </div> --------- Co-authored-by: Cursor Agent <[email protected]> Co-authored-by: Silvan <[email protected]>
This commit is contained in:
co-authored by
Cursor Agent
Silvan
parent
95cbe6a796
commit
684de2e691
@@ -1252,6 +1252,64 @@ func TestServer_SystemUsers_ListUsers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_ListUsers_UsernameAndOr(t *testing.T) {
|
||||
iamOwnerCtx := InstancePermissionV2.WithAuthorizationToken(OrgCTX, integration.UserTypeIAMOwner)
|
||||
orgResp := InstancePermissionV2.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email())
|
||||
alice := createUser(iamOwnerCtx, InstancePermissionV2, orgResp.OrganizationId, false)
|
||||
bob := createUser(iamOwnerCtx, InstancePermissionV2, orgResp.OrganizationId, false)
|
||||
orgQuery := OrganizationIdQuery(orgResp.OrganizationId)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
queries []*user.SearchQuery
|
||||
wantUsernames []string
|
||||
}{
|
||||
{
|
||||
name: "org and username or username",
|
||||
queries: []*user.SearchQuery{
|
||||
orgQuery,
|
||||
OrQuery([]*user.SearchQuery{UsernameQuery(alice.Username), UsernameQuery(bob.Username)}),
|
||||
},
|
||||
wantUsernames: []string{alice.Username, bob.Username},
|
||||
},
|
||||
{
|
||||
name: "org and sibling usernames",
|
||||
queries: []*user.SearchQuery{
|
||||
orgQuery,
|
||||
UsernameQuery(alice.Username),
|
||||
UsernameQuery(bob.Username),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nested and of or and org",
|
||||
queries: []*user.SearchQuery{
|
||||
AndQuery([]*user.SearchQuery{
|
||||
OrQuery([]*user.SearchQuery{UsernameQuery(alice.Username), UsernameQuery(bob.Username)}),
|
||||
orgQuery,
|
||||
}),
|
||||
},
|
||||
wantUsernames: []string{alice.Username, bob.Username},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := &user.ListUsersRequest{Queries: tt.queries}
|
||||
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(iamOwnerCtx, 20*time.Second)
|
||||
require.EventuallyWithT(t, func(ttt *assert.CollectT) {
|
||||
got, err := InstancePermissionV2.Client.UserV2.ListUsers(iamOwnerCtx, req)
|
||||
require.NoError(ttt, err)
|
||||
|
||||
gotUsernames := make([]string, 0, len(got.GetResult()))
|
||||
for _, u := range got.GetResult() {
|
||||
gotUsernames = append(gotUsernames, u.GetUsername())
|
||||
}
|
||||
assert.ElementsMatch(ttt, tt.wantUsernames, gotUsernames)
|
||||
}, retryDuration, tick, "timeout waiting for expected user result")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func InUserIDsQuery(ids []string) *user.SearchQuery {
|
||||
return &user.SearchQuery{
|
||||
Query: &user.SearchQuery_InUserIdsQuery{
|
||||
@@ -1312,6 +1370,16 @@ func OrQuery(queries []*user.SearchQuery) *user.SearchQuery {
|
||||
}
|
||||
}
|
||||
|
||||
func AndQuery(queries []*user.SearchQuery) *user.SearchQuery {
|
||||
return &user.SearchQuery{
|
||||
Query: &user.SearchQuery_AndQuery{
|
||||
AndQuery: &user.AndQuery{
|
||||
Queries: queries,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func MetadataKeyContainsQuery(metadataKey string) *user.SearchQuery {
|
||||
return &user.SearchQuery{
|
||||
Query: &user.SearchQuery_MetadataKeyFilter{
|
||||
|
||||
Reference in New Issue
Block a user