mirror of
https://github.com/andwati/zitadel.git
synced 2026-11-04 05:12:16 +00:00
<!-- CURSOR_AGENT_PR_BODY_BEGIN --> ## Summary `ListUsers` with `LoginNameQuery` + equals/equals-ignore-case was very slow on large orgs (e.g. ~330k users) because the generated SQL filtered the `projections.login_names3` **view** on the computed `login_name_lower` expression and correlated that subquery on `users14.id`. Postgres nested-looped every user and never used `login_names3_users_search (instance_id, user_name_lower)`. This change rewrites the **query planner** for that hot path: when an equals/equals-ignore-case login-name filter is present (and not under `OR`/`NOT`), the user list query **INNER JOINs** an indexed matches subquery instead of filtering via the view expression. The matches SQL mirrors `user_by_login_name.sql` (`user_name_lower` / domain paths + `preferred` / `is_primary`). Non-equals methods and OR combinations keep the previous view-based filter so semantics stay unchanged. Also adds a k6 use case that mirrors login v2 discovery (`loginNameQuery` EQUALS_IGNORE_CASE + `organizationIdQuery`, `limit: 2`): ```bash cd benchmark make users_by_login_name USER_AMOUNT=100000 VUS=10 DURATION=60s ``` ## Approach 1. `NewLoginNameSearchQuery` for equals / equals-ignore-case returns a marker `loginNameEqualsFilter` (other methods unchanged). 2. `prepareUsersQuery` extracts that marker when safe, then: - builds the usual `sq.SelectBuilder` **without** the login-name view predicate - adds `JoinClause` to `user_login_name_matches(.sql)` / `_case_sensitive.sql` as `login_name_matches` - keeps metadata JOIN/`DISTINCT` only when metadata filters are present (same as before) 3. Embedded SQL files under `internal/query/` for the matches subquery. Local smoke against ~330k synthetic users: baseline ~1392ms → rewritten path ~0.4ms for a single equals-ignore-case lookup. ## Test plan - [x] `go test ./internal/query/ -run 'TestLoginName|TestUsers|TestUserByLoginName'` (after generate-stubs) - [x] Existing `user_test` expected SQL updated (no always-on metadata join; login-name equals uses JOIN) - [ ] Run k6 before/after on a large `USER_AMOUNT` (e.g. 50k–100k+) and compare `list_users_duration` p50/p95/p99 - [x] Manual login v2 username discovery against a large org - [x] Confirm OR / NOT / CONTAINS login-name queries still return expected results <!-- CURSOR_AGENT_PR_BODY_END --> <div><a href="https://cursor.com/agents/bc-252883bd-48d9-492e-b619-5ccfa93cf9c3"><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-252883bd-48d9-492e-b619-5ccfa93cf9c3"><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]>
71 lines
2.4 KiB
TypeScript
71 lines
2.4 KiB
TypeScript
import { loginByUsernamePassword } from '../login_ui';
|
|
import { createOrg, removeOrg } from '../org';
|
|
import { mapPool } from '../pool';
|
|
import { createHuman, User, createMachine, setUserMetadata, listUsers } from '../user';
|
|
import { Config } from '../config';
|
|
import { check } from 'k6';
|
|
import encoding from 'k6/encoding';
|
|
|
|
const userAmount = parseInt(__ENV.USER_AMOUNT) || 2500;
|
|
const setupConcurrency = parseInt(__ENV.SETUP_CONCURRENCY) || 50;
|
|
|
|
export async function setup() {
|
|
const tokens = loginByUsernamePassword(Config.admin as User);
|
|
console.info('setup: admin signed in');
|
|
|
|
const org = await createOrg(tokens.accessToken!);
|
|
console.info(`setup: org (${org.organizationId}) created`);
|
|
|
|
const progressEvery = Math.max(1, Math.floor(userAmount / 100));
|
|
const users = await mapPool(
|
|
Array.from({ length: userAmount }, (_, i) => i),
|
|
setupConcurrency,
|
|
async (i) => {
|
|
let user: User;
|
|
let type: 'human' | 'machine';
|
|
if (i % 2 === 0) {
|
|
user = await createHuman(`zitizen-${i}`, org, tokens.accessToken!);
|
|
type = 'human';
|
|
} else {
|
|
user = await createMachine(`zitachine-${i}`, org, tokens.accessToken!);
|
|
type = 'machine';
|
|
}
|
|
await setUserMetadata(
|
|
[
|
|
{ key: 'type', value: encoding.b64encode(type, 'rawurl') },
|
|
{ key: 'org', value: encoding.b64encode(org.organizationId, 'rawurl') },
|
|
{ key: 'id', value: encoding.b64encode(user.userId, 'rawurl') },
|
|
],
|
|
user.userId,
|
|
tokens.accessToken!,
|
|
);
|
|
if (i % progressEvery === 0 || i === userAmount - 1) {
|
|
console.log(`setup: ${i + 1} of ${userAmount} users setup`);
|
|
}
|
|
return user;
|
|
},
|
|
);
|
|
console.info(`setup: ${users.length} users created (concurrency=${setupConcurrency})`);
|
|
|
|
return { tokens, org, users };
|
|
}
|
|
|
|
export default async function (data: any) {
|
|
const result = await listUsers(
|
|
{
|
|
queries: [
|
|
{ metadataValueFilter: { value: encoding.b64encode('human', 'rawurl'), method: 'BYTE_FILTER_METHOD_EQUALS' } },
|
|
],
|
|
},
|
|
data.tokens.accessToken!,
|
|
);
|
|
check(result, {
|
|
'total result length': (res) => res.details.totalResult == userAmount / 2,
|
|
}) || console.log(`unexpected amount of users. expected ${userAmount / 2} but got ${result.details.totalResult}`);
|
|
}
|
|
|
|
export function teardown(data: any) {
|
|
removeOrg(data.org, data.tokens.accessToken);
|
|
console.info('teardown: org removed');
|
|
}
|