Skip to content

feat: set default workspace proxy based on latency #17812

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

Merged
merged 21 commits into from
May 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 28 additions & 7 deletions site/src/contexts/ProxyContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ import type * as ProxyLatency from "./useProxyLatency";
// here and not inside a unit test.
jest.mock("contexts/useProxyLatency", () => ({
useProxyLatency: () => {
return { proxyLatencies: hardCodedLatencies, refetch: jest.fn() };
return {
proxyLatencies: hardCodedLatencies,
refetch: jest.fn(),
loaded: true,
};
},
}));

Expand Down Expand Up @@ -115,7 +119,7 @@ describe("ProxyContextGetURLs", () => {
preferredPathAppURL,
preferredWildcardHostname,
) => {
const preferred = getPreferredProxy(regions, selected, latencies);
const preferred = getPreferredProxy(regions, selected, latencies, true);
expect(preferred.preferredPathAppURL).toBe(preferredPathAppURL);
expect(preferred.preferredWildcardHostname).toBe(
preferredWildcardHostname,
Expand All @@ -138,10 +142,22 @@ const TestingComponent = () => {

// TestingScreen just mounts some components that we can check in the unit test.
const TestingScreen = () => {
const { proxy, userProxy, isFetched, isLoading, clearProxy, setProxy } =
useProxy();
const {
proxy,
userProxy,
isFetched,
isLoading,
latenciesLoaded,
clearProxy,
setProxy,
} = useProxy();

return (
<>
<div
data-testid="latenciesLoaded"
title={latenciesLoaded.toString()}
></div>
<div data-testid="isFetched" title={isFetched.toString()}></div>
<div data-testid="isLoading" title={isLoading.toString()}></div>
<div data-testid="preferredProxy" title={proxy.proxy?.id}></div>
Expand Down Expand Up @@ -206,7 +222,6 @@ describe("ProxyContextSelection", () => {
};

it.each([
// Not latency behavior
[
"empty",
{
Expand All @@ -220,6 +235,7 @@ describe("ProxyContextSelection", () => {
"regions_no_selection",
{
expProxyID: MockPrimaryWorkspaceProxy.id,
expUserProxyID: MockPrimaryWorkspaceProxy.id,
regions: MockWorkspaceProxies,
storageProxy: undefined,
},
Expand Down Expand Up @@ -261,11 +277,12 @@ describe("ProxyContextSelection", () => {
expUserProxyID: MockHealthyWildWorkspaceProxy.id,
},
],
// Latency behavior is disabled, so the primary should be selected.
// First page load defers to the proxy by latency
[
"regions_default_low_latency",
{
expProxyID: MockPrimaryWorkspaceProxy.id,
expProxyID: MockHealthyWildWorkspaceProxy.id,
expUserProxyID: MockHealthyWildWorkspaceProxy.id,
regions: MockWorkspaceProxies,
storageProxy: undefined,
latencies: {
Expand Down Expand Up @@ -362,6 +379,10 @@ describe("ProxyContextSelection", () => {
TestingComponent();
await waitForLoaderToBeRemoved();

await screen.findByTestId("latenciesLoaded").then((x) => {
expect(x.title).toBe("true");
});

if (afterLoad) {
await afterLoad();
}
Expand Down
46 changes: 41 additions & 5 deletions site/src/contexts/ProxyContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ export interface ProxyContextValue {
// then the latency has not been fetched yet. Calculations happen async for each proxy in the list.
// Refer to the returned report for a given proxy for more information.
proxyLatencies: ProxyLatencies;
// latenciesLoaded is true when the latencies have been initially loaded.
// Once set to true, it will not be set to false again.
latenciesLoaded: boolean;
// refetchProxyLatencies will trigger refreshing of the proxy latencies. By default the latencies
// are loaded once.
refetchProxyLatencies: () => Date;
Expand Down Expand Up @@ -122,8 +125,11 @@ export const ProxyProvider: FC<PropsWithChildren> = ({ children }) => {

// Every time we get a new proxiesResponse, update the latency check
// to each workspace proxy.
const { proxyLatencies, refetch: refetchProxyLatencies } =
useProxyLatency(proxiesResp);
const {
proxyLatencies,
refetch: refetchProxyLatencies,
loaded: latenciesLoaded,
} = useProxyLatency(proxiesResp);

// updateProxy is a helper function that when called will
// update the proxy being used.
Expand All @@ -136,7 +142,8 @@ export const ProxyProvider: FC<PropsWithChildren> = ({ children }) => {
loadUserSelectedProxy(),
proxyLatencies,
// Do not auto select based on latencies, as inconsistent latencies can cause this
// to behave poorly.
// to change on each call. updateProxy should be stable when selecting a proxy to
// prevent flickering.
false,
),
);
Expand All @@ -149,6 +156,34 @@ export const ProxyProvider: FC<PropsWithChildren> = ({ children }) => {
updateProxy();
}, [proxiesResp, proxyLatencies]);

// This useEffect will auto select the best proxy if the user has not selected one.
// It must wait until all latencies are loaded to select based on latency. This does mean
// the first time a user loads the page, the proxy will "flicker" to the best proxy.
//
// Once the page is loaded, or the user selects a proxy, this will not run again.
// biome-ignore lint/correctness/useExhaustiveDependencies: Only update if the source data changes
useEffect(() => {
if (loadUserSelectedProxy() !== undefined) {
return; // User has selected a proxy, do not auto select.
}
if (!latenciesLoaded) {
// Wait until the latencies are loaded first.
return;
}

const best = getPreferredProxy(
proxiesResp ?? [],
loadUserSelectedProxy(),
proxyLatencies,
true,
);

if (best?.proxy) {
saveUserSelectedProxy(best.proxy);
updateProxy();
}
}, [latenciesLoaded, proxiesResp, proxyLatencies]);

return (
<ProxyContext.Provider
value={{
Expand All @@ -157,6 +192,7 @@ export const ProxyProvider: FC<PropsWithChildren> = ({ children }) => {
userProxy: userSavedProxy,
proxy: proxy,
proxies: proxiesResp,
latenciesLoaded: latenciesLoaded,
isLoading: proxiesLoading,
isFetched: proxiesFetched,
error: proxiesError,
Expand Down Expand Up @@ -214,12 +250,12 @@ export const getPreferredProxy = (

// If no proxy is selected, or the selected proxy is unhealthy default to the primary proxy.
if (!selectedProxy || !selectedProxy.healthy) {
// By default, use the primary proxy.
// Default to the primary proxy
selectedProxy = proxies.find((proxy) => proxy.name === "primary");

// If we have latencies, then attempt to use the best proxy by latency instead.
const best = selectByLatency(proxies, latencies);
if (autoSelectBasedOnLatency && best) {
if (autoSelectBasedOnLatency && best !== undefined) {
selectedProxy = best;
}
}
Expand Down
9 changes: 9 additions & 0 deletions site/src/contexts/useProxyLatency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ export const useProxyLatency = (
// Until the new values are loaded, the old values will still be used.
refetch: () => Date;
proxyLatencies: Record<string, ProxyLatencyReport>;
// loaded signals all latency requests have completed. Once set to true, this will not change.
// Latencies at this point should be loaded from local storage, and updated asynchronously as needed.
// If local storage has updated latencies, then this will be set to true with 0 actual network requests.
// The loaded latencies will all be from the cache.
loaded: boolean;
} => {
// maxStoredLatencies is the maximum number of latencies to store per proxy in local storage.
let maxStoredLatencies = 1;
Expand All @@ -73,6 +78,8 @@ export const useProxyLatency = (
new Date(new Date().getTime() - proxyIntervalSeconds * 1000).toISOString(),
);

const [loaded, setLoaded] = useState(false);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is how I track when the latencies are done loading, since they all come back async


// Refetch will always set the latestFetchRequest to the current time, making all the cached latencies
// stale and triggering a refetch of all proxies in the list.
const refetch = () => {
Expand Down Expand Up @@ -231,6 +238,7 @@ export const useProxyLatency = (

// Local storage cleanup
garbageCollectStoredLatencies(proxies, maxStoredLatencies);
setLoaded(true);
});

return () => {
Expand All @@ -241,6 +249,7 @@ export const useProxyLatency = (
return {
proxyLatencies,
refetch,
loaded,
};
};

Expand Down
1 change: 1 addition & 0 deletions site/src/modules/dashboard/Navbar/MobileMenu.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const meta: Meta<typeof MobileMenu> = {
component: MobileMenu,
args: {
proxyContextValue: {
latenciesLoaded: true,
proxy: {
preferredPathAppURL: "",
preferredWildcardHostname: "",
Expand Down
1 change: 1 addition & 0 deletions site/src/modules/dashboard/Navbar/NavbarView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { renderWithAuth } from "testHelpers/renderHelpers";
import { NavbarView } from "./NavbarView";

const proxyContextValue: ProxyContextValue = {
latenciesLoaded: true,
proxy: {
preferredPathAppURL: "",
preferredWildcardHostname: "",
Expand Down
1 change: 1 addition & 0 deletions site/src/modules/dashboard/Navbar/ProxyMenu.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { withDesktopViewport } from "testHelpers/storybook";
import { ProxyMenu } from "./ProxyMenu";

const defaultProxyContextValue = {
latenciesLoaded: true,
proxyLatencies: MockProxyLatencies,
proxy: getPreferredProxy(MockWorkspaceProxies, undefined),
proxies: MockWorkspaceProxies,
Expand Down
1 change: 1 addition & 0 deletions site/src/testHelpers/storybook.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ export const withProxyProvider =
return (
<ProxyContext.Provider
value={{
latenciesLoaded: true,
proxyLatencies: MockProxyLatencies,
proxy: getPreferredProxy([], undefined),
proxies: [],
Expand Down
Loading