perf(main): avoid full team scans for global tasks
This commit is contained in:
parent
933bf9532d
commit
f4f42e2ca4
3 changed files with 145 additions and 18 deletions
|
|
@ -109,7 +109,7 @@ function normalizeProjectPathCandidate(value: unknown): string | undefined {
|
||||||
return trimmed.length > 0 ? trimmed : undefined;
|
return trimmed.length > 0 ? trimmed : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveProjectPathFromConfig(
|
export function resolveProjectPathFromConfig(
|
||||||
config: Pick<TeamConfig, 'projectPath' | 'projectPathHistory' | 'members'>
|
config: Pick<TeamConfig, 'projectPath' | 'projectPathHistory' | 'members'>
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
const direct = normalizeProjectPathCandidate(config.projectPath);
|
const direct = normalizeProjectPathCandidate(config.projectPath);
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ import {
|
||||||
choosePreferredLaunchSnapshot,
|
choosePreferredLaunchSnapshot,
|
||||||
readBootstrapLaunchSnapshot,
|
readBootstrapLaunchSnapshot,
|
||||||
} from './TeamBootstrapStateReader';
|
} from './TeamBootstrapStateReader';
|
||||||
import { TeamConfigReader } from './TeamConfigReader';
|
import { resolveProjectPathFromConfig, TeamConfigReader } from './TeamConfigReader';
|
||||||
import { TeamInboxReader } from './TeamInboxReader';
|
import { TeamInboxReader } from './TeamInboxReader';
|
||||||
import { TeamInboxWriter } from './TeamInboxWriter';
|
import { TeamInboxWriter } from './TeamInboxWriter';
|
||||||
import { TeamKanbanManager } from './TeamKanbanManager';
|
import { TeamKanbanManager } from './TeamKanbanManager';
|
||||||
|
|
@ -108,6 +108,7 @@ const TASK_MAP_YIELD_EVERY = 250;
|
||||||
const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification';
|
const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification';
|
||||||
const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000;
|
const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000;
|
||||||
const MEMBER_RUNTIME_ADVISORY_SNAPSHOT_BUDGET_MS = 250;
|
const MEMBER_RUNTIME_ADVISORY_SNAPSHOT_BUDGET_MS = 250;
|
||||||
|
const GLOBAL_TASK_TEAM_CONFIG_CONCURRENCY = 12;
|
||||||
const MIXED_TEAM_LIVE_MUTATION_BLOCK_MESSAGE =
|
const MIXED_TEAM_LIVE_MUTATION_BLOCK_MESSAGE =
|
||||||
'Live roster mutation on a running mixed team is not supported in V1. Stop the team, edit the roster, then relaunch.';
|
'Live roster mutation on a running mixed team is not supported in V1. Stop the team, edit the roster, then relaunch.';
|
||||||
|
|
||||||
|
|
@ -233,6 +234,36 @@ interface FileWatchReconcileDiagnostics {
|
||||||
lastPressureLogAt: number;
|
lastPressureLogAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GlobalTaskTeamInfo {
|
||||||
|
displayName: string;
|
||||||
|
projectPath?: string;
|
||||||
|
deletedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mapLimitLocal<T, R>(
|
||||||
|
items: readonly T[],
|
||||||
|
limit: number,
|
||||||
|
mapper: (item: T) => Promise<R>
|
||||||
|
): Promise<R[]> {
|
||||||
|
const results = new Array<R>(items.length);
|
||||||
|
let nextIndex = 0;
|
||||||
|
const workerCount = Math.min(Math.max(1, limit), items.length);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: workerCount }, async () => {
|
||||||
|
while (true) {
|
||||||
|
const index = nextIndex++;
|
||||||
|
if (index >= items.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
results[index] = await mapper(items[index]!);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
function applyDistinctRosterColors<T extends { name: string; color?: string; removedAt?: number }>(
|
function applyDistinctRosterColors<T extends { name: string; color?: string; removedAt?: number }>(
|
||||||
members: readonly T[]
|
members: readonly T[]
|
||||||
): T[] {
|
): T[] {
|
||||||
|
|
@ -423,6 +454,58 @@ export class TeamDataService {
|
||||||
return readConfigForUiSnapshot(this.configReader, teamName);
|
return readConfigForUiSnapshot(this.configReader, teamName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async readGlobalTaskTeamInfoFromListTeams(): Promise<Map<string, GlobalTaskTeamInfo>> {
|
||||||
|
const teams = await this.configReader.listTeams();
|
||||||
|
const teamInfoMap = new Map<string, GlobalTaskTeamInfo>();
|
||||||
|
for (const team of teams) {
|
||||||
|
teamInfoMap.set(team.teamName, {
|
||||||
|
displayName: team.displayName,
|
||||||
|
projectPath: team.projectPath,
|
||||||
|
deletedAt: team.deletedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return teamInfoMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readGlobalTaskTeamInfo(
|
||||||
|
rawTasks: readonly (TeamTask & { teamName: string })[]
|
||||||
|
): Promise<Map<string, GlobalTaskTeamInfo>> {
|
||||||
|
const canReadConfigDirectly =
|
||||||
|
typeof (this.configReader as { getConfigSnapshot?: unknown }).getConfigSnapshot ===
|
||||||
|
'function' ||
|
||||||
|
typeof (this.configReader as { getConfig?: unknown }).getConfig === 'function';
|
||||||
|
if (!canReadConfigDirectly) {
|
||||||
|
return this.readGlobalTaskTeamInfoFromListTeams();
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamNames = [...new Set(rawTasks.map((task) => task.teamName))];
|
||||||
|
const entries = await mapLimitLocal(
|
||||||
|
teamNames,
|
||||||
|
GLOBAL_TASK_TEAM_CONFIG_CONCURRENCY,
|
||||||
|
async (teamName) => {
|
||||||
|
const config = await readConfigForUiSnapshot(this.configReader, teamName).catch(() => null);
|
||||||
|
const displayName = config?.name?.trim();
|
||||||
|
if (!config || !displayName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
teamName,
|
||||||
|
{
|
||||||
|
displayName,
|
||||||
|
projectPath: resolveProjectPathFromConfig(config),
|
||||||
|
deletedAt: typeof config.deletedAt === 'string' ? config.deletedAt : undefined,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (entries.some((entry) => entry === null)) {
|
||||||
|
return this.readGlobalTaskTeamInfoFromListTeams();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Map(entries.filter((entry): entry is NonNullable<typeof entry> => entry !== null));
|
||||||
|
}
|
||||||
|
|
||||||
private invalidateGlobalTaskProjectionCache(): void {
|
private invalidateGlobalTaskProjectionCache(): void {
|
||||||
TeamTaskReader.invalidateAllTasksCache();
|
TeamTaskReader.invalidateAllTasksCache();
|
||||||
}
|
}
|
||||||
|
|
@ -1025,21 +1108,7 @@ export class TeamDataService {
|
||||||
|
|
||||||
async getAllTasks(): Promise<GlobalTask[]> {
|
async getAllTasks(): Promise<GlobalTask[]> {
|
||||||
const rawTasks = await this.taskReader.getAllTasks();
|
const rawTasks = await this.taskReader.getAllTasks();
|
||||||
const teams = await this.configReader.listTeams();
|
const teamInfoMap = await this.readGlobalTaskTeamInfo(rawTasks);
|
||||||
|
|
||||||
const teamInfoMap = new Map<
|
|
||||||
string,
|
|
||||||
{ displayName: string; projectPath?: string; deletedAt?: string }
|
|
||||||
>();
|
|
||||||
for (const team of teams) {
|
|
||||||
teamInfoMap.set(team.teamName, {
|
|
||||||
displayName: team.displayName,
|
|
||||||
projectPath: team.projectPath,
|
|
||||||
deletedAt: team.deletedAt,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const deletedTeams = new Set(teams.filter((t) => t.deletedAt).map((t) => t.teamName));
|
|
||||||
|
|
||||||
const MAX_GLOBAL_TASKS_EXPORTED = 500;
|
const MAX_GLOBAL_TASKS_EXPORTED = 500;
|
||||||
let tasksToExport = rawTasks.filter((task) => teamInfoMap.has(task.teamName));
|
let tasksToExport = rawTasks.filter((task) => teamInfoMap.has(task.teamName));
|
||||||
|
|
@ -1118,7 +1187,7 @@ export class TeamDataService {
|
||||||
kanbanColumn,
|
kanbanColumn,
|
||||||
teamName: task.teamName,
|
teamName: task.teamName,
|
||||||
teamDisplayName: info.displayName,
|
teamDisplayName: info.displayName,
|
||||||
teamDeleted: deletedTeams.has(task.teamName) || undefined,
|
teamDeleted: Boolean(info.deletedAt) || undefined,
|
||||||
});
|
});
|
||||||
processed++;
|
processed++;
|
||||||
if (processed % TASK_MAP_YIELD_EVERY === 0) {
|
if (processed % TASK_MAP_YIELD_EVERY === 0) {
|
||||||
|
|
|
||||||
|
|
@ -2235,6 +2235,64 @@ describe('TeamDataService', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses config snapshots instead of full team summaries for global task team info', async () => {
|
||||||
|
const listTeams = vi.fn(async () => [
|
||||||
|
{
|
||||||
|
teamName: 'my-team',
|
||||||
|
displayName: 'My team from list',
|
||||||
|
projectPath: '/repo-from-list',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const getConfigSnapshot = vi.fn(async (teamName: string) =>
|
||||||
|
teamName === 'my-team'
|
||||||
|
? {
|
||||||
|
name: 'My team from config',
|
||||||
|
members: [{ name: 'lead', role: 'Team Lead', cwd: '/repo-from-lead' }],
|
||||||
|
deletedAt: '2026-03-01T12:00:00.000Z',
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
const service = new TeamDataService(
|
||||||
|
{
|
||||||
|
listTeams,
|
||||||
|
getConfigSnapshot,
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
getAllTasks: vi.fn(async () => [
|
||||||
|
{
|
||||||
|
id: 'task-global-config',
|
||||||
|
teamName: 'my-team',
|
||||||
|
subject: 'Global config task',
|
||||||
|
status: 'pending',
|
||||||
|
owner: 'bob',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{
|
||||||
|
getState: vi.fn(async () => ({
|
||||||
|
teamName: 'my-team',
|
||||||
|
reviewers: [],
|
||||||
|
tasks: {},
|
||||||
|
})),
|
||||||
|
} as never
|
||||||
|
);
|
||||||
|
|
||||||
|
const tasks = await service.getAllTasks();
|
||||||
|
|
||||||
|
expect(listTeams).not.toHaveBeenCalled();
|
||||||
|
expect(getConfigSnapshot).toHaveBeenCalledWith('my-team');
|
||||||
|
expect(tasks[0]).toMatchObject({
|
||||||
|
id: 'task-global-config',
|
||||||
|
teamDisplayName: 'My team from config',
|
||||||
|
projectPath: '/repo-from-lead',
|
||||||
|
teamDeleted: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('caps global task projections before building lightweight comment payloads', async () => {
|
it('caps global task projections before building lightweight comment payloads', async () => {
|
||||||
const rawTasks = Array.from({ length: 501 }, (_, index) => ({
|
const rawTasks = Array.from({ length: 501 }, (_, index) => ({
|
||||||
id: `task-${index}`,
|
id: `task-${index}`,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue