perf(team): throttle runtime watcher refreshes
This commit is contained in:
parent
c357793c2f
commit
005742e9ad
2 changed files with 249 additions and 4 deletions
180
src/renderer/components/team/useTeamAgentRuntimeWatcher.test.tsx
Normal file
180
src/renderer/components/team/useTeamAgentRuntimeWatcher.test.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
state: {
|
||||
leadActivityByTeam: {} as Record<string, 'active' | 'idle' | undefined>,
|
||||
teamDataByName: {} as Record<string, { isAlive?: boolean } | undefined>,
|
||||
provisioningActiveByTeam: {} as Record<string, boolean | undefined>,
|
||||
fetchTeamAgentRuntime: vi.fn(async (_teamName: string): Promise<void> => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/store', () => ({
|
||||
useStore: <T,>(selector: (state: typeof hoisted.state) => T): T => selector(hoisted.state),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/store/slices/teamSlice', () => ({
|
||||
isTeamProvisioningActive: (state: typeof hoisted.state, teamName: string): boolean =>
|
||||
state.provisioningActiveByTeam[teamName] === true,
|
||||
selectTeamDataForName: (
|
||||
state: typeof hoisted.state,
|
||||
teamName: string
|
||||
): { isAlive?: boolean } | undefined => state.teamDataByName[teamName],
|
||||
}));
|
||||
|
||||
import {
|
||||
__resetTeamAgentRuntimeWatcherForTests,
|
||||
useTeamAgentRuntimeWatcher,
|
||||
} from './useTeamAgentRuntimeWatcher';
|
||||
|
||||
interface HookProbeProps {
|
||||
teamName: string;
|
||||
enabled: boolean;
|
||||
isTeamProvisioning?: boolean;
|
||||
isTeamAlive?: boolean;
|
||||
}
|
||||
|
||||
const HookProbe = (props: HookProbeProps): null => {
|
||||
useTeamAgentRuntimeWatcher(props);
|
||||
return null;
|
||||
};
|
||||
|
||||
const mountedRoots: Root[] = [];
|
||||
|
||||
async function renderWatcher(props: HookProbeProps): Promise<Root> {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
mountedRoots.push(root);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(HookProbe, props));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
describe('useTeamAgentRuntimeWatcher', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(0);
|
||||
__resetTeamAgentRuntimeWatcherForTests();
|
||||
hoisted.state.leadActivityByTeam = {};
|
||||
hoisted.state.teamDataByName = {};
|
||||
hoisted.state.provisioningActiveByTeam = {};
|
||||
hoisted.state.fetchTeamAgentRuntime.mockReset();
|
||||
hoisted.state.fetchTeamAgentRuntime.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of mountedRoots.splice(0)) {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
document.body.innerHTML = '';
|
||||
__resetTeamAgentRuntimeWatcherForTests();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('backs off polling for an alive idle team', async () => {
|
||||
hoisted.state.teamDataByName['team-a'] = { isAlive: true };
|
||||
|
||||
await renderWatcher({ teamName: 'team-a', enabled: true });
|
||||
|
||||
expect(hoisted.state.fetchTeamAgentRuntime).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.state.fetchTeamAgentRuntime).toHaveBeenCalledWith('team-a');
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5_000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(hoisted.state.fetchTeamAgentRuntime).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10_000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(hoisted.state.fetchTeamAgentRuntime).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps active teams on the fast polling cadence', async () => {
|
||||
hoisted.state.leadActivityByTeam['team-a'] = 'active';
|
||||
|
||||
await renderWatcher({ teamName: 'team-a', enabled: true });
|
||||
|
||||
expect(hoisted.state.fetchTeamAgentRuntime).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5_000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(hoisted.state.fetchTeamAgentRuntime).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not overlap runtime refreshes for the same team', async () => {
|
||||
hoisted.state.leadActivityByTeam['team-a'] = 'active';
|
||||
let resolveFirstRefresh: () => void = () => undefined;
|
||||
const firstRefresh = new Promise<void>((resolve) => {
|
||||
resolveFirstRefresh = resolve;
|
||||
});
|
||||
hoisted.state.fetchTeamAgentRuntime
|
||||
.mockImplementationOnce(() => firstRefresh)
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await renderWatcher({ teamName: 'team-a', enabled: true });
|
||||
|
||||
expect(hoisted.state.fetchTeamAgentRuntime).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5_000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(hoisted.state.fetchTeamAgentRuntime).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
resolveFirstRefresh();
|
||||
await firstRefresh;
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5_000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(hoisted.state.fetchTeamAgentRuntime).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('coalesces multiple mounted watchers for one team', async () => {
|
||||
hoisted.state.leadActivityByTeam['team-a'] = 'active';
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
mountedRoots.push(root);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(
|
||||
React.Fragment,
|
||||
null,
|
||||
React.createElement(HookProbe, { teamName: 'team-a', enabled: true }),
|
||||
React.createElement(HookProbe, { teamName: 'team-a', enabled: true })
|
||||
)
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(hoisted.state.fetchTeamAgentRuntime).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -4,7 +4,66 @@ import { useStore } from '@renderer/store';
|
|||
import { isTeamProvisioningActive, selectTeamDataForName } from '@renderer/store/slices/teamSlice';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
const TEAM_AGENT_RUNTIME_REFRESH_MS = 5_000;
|
||||
const TEAM_AGENT_RUNTIME_ACTIVE_REFRESH_MS = 5_000;
|
||||
const TEAM_AGENT_RUNTIME_IDLE_REFRESH_MS = 15_000;
|
||||
const TEAM_AGENT_RUNTIME_REFRESH_GRACE_MS = 500;
|
||||
|
||||
type FetchTeamAgentRuntime = (teamName: string) => Promise<void>;
|
||||
|
||||
const runtimeRefreshInFlightByTeam = new Map<string, Promise<void>>();
|
||||
const runtimeRefreshLastStartedAtByTeam = new Map<string, number>();
|
||||
|
||||
function getTeamAgentRuntimeRefreshMs({
|
||||
isTeamProvisioning,
|
||||
leadActivity,
|
||||
}: {
|
||||
isTeamProvisioning: boolean | undefined;
|
||||
leadActivity: string | undefined;
|
||||
}): number {
|
||||
return isTeamProvisioning || leadActivity === 'active'
|
||||
? TEAM_AGENT_RUNTIME_ACTIVE_REFRESH_MS
|
||||
: TEAM_AGENT_RUNTIME_IDLE_REFRESH_MS;
|
||||
}
|
||||
|
||||
function requestTeamAgentRuntimeRefresh(
|
||||
teamName: string,
|
||||
fetchTeamAgentRuntime: FetchTeamAgentRuntime,
|
||||
minRefreshGapMs: number
|
||||
): Promise<void> {
|
||||
const inFlight = runtimeRefreshInFlightByTeam.get(teamName);
|
||||
if (inFlight) {
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const lastStartedAt = runtimeRefreshLastStartedAtByTeam.get(teamName);
|
||||
if (lastStartedAt !== undefined && now - lastStartedAt < minRefreshGapMs) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
runtimeRefreshLastStartedAtByTeam.set(teamName, now);
|
||||
|
||||
let refreshPromise: Promise<void>;
|
||||
try {
|
||||
refreshPromise = fetchTeamAgentRuntime(teamName);
|
||||
} catch (error) {
|
||||
runtimeRefreshLastStartedAtByTeam.delete(teamName);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
const trackedPromise = refreshPromise.finally(() => {
|
||||
if (runtimeRefreshInFlightByTeam.get(teamName) === trackedPromise) {
|
||||
runtimeRefreshInFlightByTeam.delete(teamName);
|
||||
}
|
||||
});
|
||||
runtimeRefreshInFlightByTeam.set(teamName, trackedPromise);
|
||||
return trackedPromise;
|
||||
}
|
||||
|
||||
export function __resetTeamAgentRuntimeWatcherForTests(): void {
|
||||
runtimeRefreshInFlightByTeam.clear();
|
||||
runtimeRefreshLastStartedAtByTeam.clear();
|
||||
}
|
||||
|
||||
interface TeamAgentRuntimeWatcherOptions {
|
||||
teamName: string;
|
||||
|
|
@ -41,10 +100,16 @@ export function useTeamAgentRuntimeWatcher({
|
|||
leadActivity === 'idle';
|
||||
if (!shouldWatch) return;
|
||||
|
||||
void fetchTeamAgentRuntime(teamName);
|
||||
const refreshMs = getTeamAgentRuntimeRefreshMs({
|
||||
isTeamProvisioning: effectiveIsTeamProvisioning,
|
||||
leadActivity,
|
||||
});
|
||||
const minRefreshGapMs = Math.max(0, refreshMs - TEAM_AGENT_RUNTIME_REFRESH_GRACE_MS);
|
||||
|
||||
void requestTeamAgentRuntimeRefresh(teamName, fetchTeamAgentRuntime, minRefreshGapMs);
|
||||
const timer = window.setInterval(() => {
|
||||
void fetchTeamAgentRuntime(teamName);
|
||||
}, TEAM_AGENT_RUNTIME_REFRESH_MS);
|
||||
void requestTeamAgentRuntimeRefresh(teamName, fetchTeamAgentRuntime, minRefreshGapMs);
|
||||
}, refreshMs);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue