Repository navigation
fix(usage): stop usage.logins timing out with many profiles under load #9183
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
11e2833
a1cf486
cae22f8
3ddfd5b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,5 @@ | ||
| export { type FoundLogin, findCompletedLogin } from "./findCompletedLogin"; | ||
| export { | ||
| type FoundLogin, | ||
| findCompletedLogin, | ||
| nextBaseline, | ||
| } from "./findCompletedLogin"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| import { describe, expect, it } from "bun:test"; | ||
| import { TRPCClientError } from "@trpc/client"; | ||
| import { hostServiceQueryRetry } from "renderer/lib/host-service-client"; | ||
| import { loginsQueryRetry } from "./useHostUsageLogins"; | ||
|
|
||
| function hostError(code: string) { | ||
| return TRPCClientError.from({ | ||
| error: { message: `usage.logins: ${code}`, code: -32603, data: { code } }, | ||
| }); | ||
| } | ||
|
|
||
| describe("loginsQueryRetry", () => { | ||
| it("retries one timed-out poll before it shows as an error", () => { | ||
| expect(loginsQueryRetry(0, hostError("TIMEOUT"))).toBe(true); | ||
| expect(loginsQueryRetry(1, hostError("TIMEOUT"))).toBe(false); | ||
| }); | ||
|
|
||
| it("keeps the connection retries of every host-service query", () => { | ||
| const errors = [ | ||
| TRPCClientError.from(new TypeError("Failed to fetch")), | ||
| hostError("SERVICE_UNAVAILABLE"), | ||
| ]; | ||
| for (const error of errors) { | ||
| for (const failureCount of [0, 1, 2, 3]) { | ||
| expect(loginsQueryRetry(failureCount, error)).toBe( | ||
| hostServiceQueryRetry(failureCount, error), | ||
| ); | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| it("does not retry other host errors", () => { | ||
| for (const code of [ | ||
| "UNAUTHORIZED", | ||
| "BAD_REQUEST", | ||
| "INTERNAL_SERVER_ERROR", | ||
| ]) { | ||
| expect(loginsQueryRetry(0, hostError(code))).toBe(false); | ||
| } | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,28 @@ | ||
| import type { AppRouter } from "@superset/host-service"; | ||
| import { useQuery } from "@tanstack/react-query"; | ||
| import { TRPCClientError } from "@trpc/client"; | ||
| import type { inferRouterOutputs } from "@trpc/server"; | ||
| import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; | ||
| import { | ||
| getHostServiceClientByUrl, | ||
| hostServiceQueryRetry, | ||
| hostServiceQueryRetryDelay, | ||
| } from "renderer/lib/host-service-client"; | ||
|
|
||
| export type UsageLogins = inferRouterOutputs<AppRouter>["usage"]["logins"]; | ||
|
|
||
| const LOGIN_POLL_INTERVAL_MS = 3_000; | ||
| const LOGIN_TIMEOUT_RETRIES = 1; | ||
|
|
||
| /** | ||
| * A timed-out scan keeps running on the host and the retry joins it, so one | ||
| * slow poll is retried before it shows as an error. | ||
| */ | ||
| export function loginsQueryRetry(failureCount: number, error: unknown) { | ||
| if (error instanceof TRPCClientError && error.data?.code === "TIMEOUT") { | ||
| return failureCount < LOGIN_TIMEOUT_RETRIES; | ||
|
Comment on lines
+21
to
+22
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
| return hostServiceQueryRetry(failureCount, error); | ||
| } | ||
|
|
||
| /** | ||
| * Local-only login discovery on the host (no provider network calls), polled | ||
|
|
@@ -24,6 +41,8 @@ export function useHostUsageLogins(hostUrl: string | null, enabled: boolean) { | |
| }, | ||
| refetchInterval: enabled ? LOGIN_POLL_INTERVAL_MS : false, | ||
| refetchIntervalInBackground: true, | ||
| retry: loginsQueryRetry, | ||
| retryDelay: hostServiceQueryRetryDelay, | ||
| gcTime: 0, | ||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| import { describe, expect, it } from "bun:test"; | ||
| import { | ||
| FINGERPRINT_READ_CONCURRENCY, | ||
| readClaudeFingerprints, | ||
| scanLogins, | ||
| shareInFlight, | ||
| } from "./logins"; | ||
| import type { ClaudeProfile } from "./profiles"; | ||
| import { usageRouter } from "./usage"; | ||
|
|
||
| function profile( | ||
| configDir: string, | ||
| credentialKind: ClaudeProfile["credentialKind"] = "subscription", | ||
| ): ClaudeProfile { | ||
| return { | ||
| configDir, | ||
| sourceLabel: configDir, | ||
| email: null, | ||
| credentialKind, | ||
| loginFingerprint: | ||
| credentialKind === "api_key" ? `marker:${configDir}` : null, | ||
| credentialsPath: `${configDir}/.credentials.json`, | ||
| keychainServices: [], | ||
| }; | ||
| } | ||
|
|
||
| describe("usage.logins", () => { | ||
| it("has the 15 s budget of usage.quota, not the 5 s default", () => { | ||
| expect(usageRouter._def.procedures.logins._def.meta).toEqual({ | ||
| timeoutMs: 15_000, | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| describe("scanLogins", () => { | ||
| it("says when the Claude profile scan stopped at its time budget", async () => { | ||
| for (const partial of [true, false]) { | ||
| const logins = await scanLogins({ | ||
| scanClaudeProfiles: async () => ({ | ||
| profiles: [profile("/profiles/a")], | ||
| partial, | ||
| }), | ||
| discoverCodexHomes: async () => [], | ||
| readDefaultLoginEmail: async () => null, | ||
| readClaudeLoginFingerprint: async () => null, | ||
| }); | ||
| expect(logins.claude.map((login) => login.configDir)).toEqual([ | ||
| "/profiles/a", | ||
| ]); | ||
| expect(logins.claudePartial).toBe(partial); | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
| describe("shareInFlight", () => { | ||
| it("gives a call made while a scan runs that scan's result", async () => { | ||
| const scans: Array<(value: string) => void> = []; | ||
| const read = shareInFlight( | ||
| () => new Promise<string>((resolve) => scans.push(resolve)), | ||
| ); | ||
| const first = read(); | ||
| const second = read(); | ||
| expect(scans).toHaveLength(1); | ||
| scans[0]?.("scan 1"); | ||
| expect(await first).toBe("scan 1"); | ||
| expect(await second).toBe("scan 1"); | ||
| }); | ||
|
|
||
| it("starts a new scan once the last one has settled", async () => { | ||
| let scans = 0; | ||
| const read = shareInFlight(async () => ++scans); | ||
| expect(await read()).toBe(1); | ||
| expect(await read()).toBe(2); | ||
| }); | ||
|
|
||
| it("starts a new scan after a failed one", async () => { | ||
| let scans = 0; | ||
| const read = shareInFlight(async () => { | ||
| scans++; | ||
| if (scans === 1) throw new Error("scan failed"); | ||
| return scans; | ||
| }); | ||
| await expect(read()).rejects.toThrow("scan failed"); | ||
| expect(await read()).toBe(2); | ||
| }); | ||
| }); | ||
|
|
||
| describe("readClaudeFingerprints", () => { | ||
| it("reads a bounded number of profiles at once and keeps their order", async () => { | ||
| const profiles = Array.from({ length: 10 }, (_, index) => | ||
| profile(`/profiles/${index}`, index === 3 ? "api_key" : "subscription"), | ||
| ); | ||
| const waiting: Array<() => void> = []; | ||
| const read: string[] = []; | ||
| let inFlight = 0; | ||
| let peak = 0; | ||
| let settled = false; | ||
| const result = readClaudeFingerprints(profiles, async (configDir) => { | ||
| read.push(configDir); | ||
| inFlight++; | ||
| peak = Math.max(peak, inFlight); | ||
| await new Promise<void>((resolve) => waiting.push(resolve)); | ||
| inFlight--; | ||
| return `token:${configDir}`; | ||
| }).finally(() => { | ||
| settled = true; | ||
| }); | ||
| while (!settled) { | ||
| await new Promise((resolve) => setTimeout(resolve, 0)); | ||
| for (const resolve of waiting.splice(0).reverse()) resolve(); | ||
| } | ||
|
|
||
| expect(peak).toBe(FINGERPRINT_READ_CONCURRENCY); | ||
| expect(read).not.toContain("/profiles/3"); | ||
| expect((await result).map((login) => login.fingerprint)).toEqual( | ||
| profiles.map(({ configDir, credentialKind }) => | ||
| credentialKind === "api_key" | ||
| ? `marker:${configDir}` | ||
| : `token:${configDir}`, | ||
| ), | ||
| ); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do not make an unrecognized sign-in the baseline.
If the first baseline is partial and a user completes the manual Claude sign-in before the first complete poll,
findCompletedLoginrejects the newly observed profile becausecommandSucceededis false.nextBaselinethen stores that poll as the baseline. Later polls have the same fingerprint, so the dialog never detects the sign-in. Establish a complete baseline before allowing this manual flow, or check the selected profile independently when the initial scan is partial.