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 { isTeamProvisioningActive, selectTeamDataForName } from '@renderer/store/slices/teamSlice';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
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 {
|
interface TeamAgentRuntimeWatcherOptions {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
|
@ -41,10 +100,16 @@ export function useTeamAgentRuntimeWatcher({
|
||||||
leadActivity === 'idle';
|
leadActivity === 'idle';
|
||||||
if (!shouldWatch) return;
|
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(() => {
|
const timer = window.setInterval(() => {
|
||||||
void fetchTeamAgentRuntime(teamName);
|
void requestTeamAgentRuntimeRefresh(teamName, fetchTeamAgentRuntime, minRefreshGapMs);
|
||||||
}, TEAM_AGENT_RUNTIME_REFRESH_MS);
|
}, refreshMs);
|
||||||
return () => {
|
return () => {
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue