Repository navigation
perf(web/ee): getLinkedAccounts reloads the entire config file for each linked account #1601
Description
Summary
getLinkedAccounts in packages/web/src/ee/features/sso/actions.ts:55-72 calls await getIdentityProviderConfig(account.providerId) inside a for (const account of accounts) loop. Each call internally invokes getIdentityProviderConfigs() → loadConfig(env.CONFIG_PATH) → readFile(configPath) (or fetch if the config URL is remote).
The result: a user with N linked accounts triggers N full reads of the config file on every call to getLinkedAccounts. The function is invoked from the SSO settings page on every page load, so the cost scales with both the number of linked accounts and the size of the config file.
Reproduction
- Configure Sourcebot with
CONFIG_PATHpointing at a JSON file. - Link 5 identity provider accounts for the same user (any combination of GitHub, GitLab, Google, etc.).
- Reload the settings/SSO page.
- Watch the config file get read 5 times in a row (strace / fs_usage / log a single line at the top of
loadConfig).
The second loop in the same function (line 75) already calls getIdentityProviderConfigs() once before iterating the returned object — proving the call is cacheable for the lifetime of one function invocation.
Root cause
// packages/web/src/ee/features/sso/actions.ts:56-72
for (const account of accounts) {
const providerConfig = await getIdentityProviderConfig(account.providerId);
// ...
}getIdentityProviderConfig (in packages/shared/src/env.server.ts:149-153) is implemented as:
export const getIdentityProviderConfig = async (id: string): Promise<IdentityProviderConfig | undefined> => {
const idps = await getIdentityProviderConfigs(); // calls loadConfig every time
const idp = idps[id] as IdentityProviderConfig | undefined;
return idp;
}There is no memoization around loadConfig itself, so each call re-reads and re-parses the config.
Proposed fix
In getLinkedAccounts, hoist the getIdentityProviderConfigs() call to once before the first loop, then do a synchronous object lookup per account:
const identityProviders = await getIdentityProviderConfigs();
for (const account of accounts) {
const providerConfig = identityProviders[account.providerId];
// ...
}This matches the pattern already used by the second loop in the same function (line 75) and reduces N+1 to 1.
The two remaining call sites (backend/src/ee/accountPermissionSyncer.ts:292, backend/src/ee/tokenRefresh.ts:213, web/src/lib/encryptedPrismaAdapter.ts:31) are single-call, not in loops, so they don't trigger this pattern and are out of scope here.
Impact
- Performance: A user with 5 linked accounts and a 50 KB config file goes from 5 × 50 KB = 250 KB of disk reads + 5 × JSON.parse + 5 × ajv schema validation per settings-page load to 1 × 50 KB + 1 × parse + 1 × validation.
- Reliability: Each call to
loadConfigincludes retry-on-ENOENTlogic (up to 5 attempts × 2 s delay). The N+1 amplifies any startup race where the config file is being mounted, multiplying the worst-case latency. - Scope: Web/EE only. The other call sites are single-call and unaffected.
Acceptance criteria
getLinkedAccountstriggers exactly 1 call toloadConfig(and therefore 1 file read) per invocation, regardless of the number of linked accounts.- Existing behavior is preserved (same
LinkedAccount[]shape, same ordering, samedisplayName/isAccountLinkingProvider/required/supportsPermissionSyncderivations). - A new vitest case asserts that
getIdentityProviderConfigs(or the underlyingloadConfig) is called exactly once for an account list of size N≥2.
Testing requirements
- Unit test in
packages/web/src/ee/features/sso/actions.test.tsthat mocks@sourcebot/sharedand countsgetIdentityProviderConfigsinvocations. yarn workspace @sourcebot/web test --runmust remain green.npx tsc --noEmit -p packages/web/tsconfig.jsonmust remain clean.
Out of scope
- Adding memoization to
getIdentityProviderConfigs/loadConfigthemselves. That's a larger architectural change (cache invalidation on config reload, multi-process correctness) and is better as its own change. The minimal fix is to remove the N+1 at the call site. backend/src/ee/accountPermissionSyncer.ts,backend/src/ee/tokenRefresh.ts,web/src/lib/encryptedPrismaAdapter.ts. None of these are in a loop, so the same pattern does not apply.
Activity