feat: enhance team data fetching with performance logging and timeout handling

- Added performance logging to the handleGetData function, tracking the duration of team data retrieval and logging warnings for slow responses.
- Implemented a timeout mechanism in the selectTeam function to prevent long-running fetch operations, improving user experience.
- Enhanced the getTeamData method with detailed timing metrics for each data loading step, allowing for better performance analysis and debugging.
This commit is contained in:
iliya 2026-02-28 22:29:43 +02:00
parent 0f302521e4
commit 9ce6d37528
3 changed files with 104 additions and 16 deletions

View file

@ -321,6 +321,8 @@ async function handleGetData(
return { success: false, error: validated.error ?? 'Invalid teamName' }; return { success: false, error: validated.error ?? 'Invalid teamName' };
} }
const tn = validated.value!; const tn = validated.value!;
const startedAt = Date.now();
logger.info(`[teams:getData] start team=${tn}`);
let data: TeamData; let data: TeamData;
try { try {
data = await getTeamDataService().getTeamData(tn); data = await getTeamDataService().getTeamData(tn);
@ -335,6 +337,14 @@ async function handleGetData(
logger.error(`[teams:getData] ${message}`); logger.error(`[teams:getData] ${message}`);
return { success: false, error: message }; return { success: false, error: message };
} }
const getDataMs = Date.now() - startedAt;
if (getDataMs >= 1000) {
logger.warn(
`[teams:getData] slow team=${tn} ms=${getDataMs} tasks=${data.tasks.length} members=${data.members.length} messages=${data.messages.length}`
);
} else {
logger.info(`[teams:getData] done team=${tn} ms=${getDataMs}`);
}
const provisioning = getTeamProvisioningService(); const provisioning = getTeamProvisioningService();
const isAlive = provisioning.isTeamAlive(tn); const isAlive = provisioning.isTeamAlive(tn);

View file

@ -169,10 +169,21 @@ export class TeamDataService {
} }
async getTeamData(teamName: string): Promise<TeamData> { async getTeamData(teamName: string): Promise<TeamData> {
const startedAt = Date.now();
const marks: Record<string, number> = {};
const mark = (label: string): void => {
marks[label] = Date.now();
};
const msSince = (label: string): number => {
const t = marks[label];
return typeof t === 'number' ? t - startedAt : -1;
};
const config = await this.configReader.getConfig(teamName); const config = await this.configReader.getConfig(teamName);
if (!config) { if (!config) {
throw new Error(`Team not found: ${teamName}`); throw new Error(`Team not found: ${teamName}`);
} }
mark('config');
const warnings: string[] = []; const warnings: string[] = [];
@ -184,6 +195,7 @@ export class TeamDataService {
warnings.push('Tasks failed to load'); warnings.push('Tasks failed to load');
tasksLoaded = false; tasksLoaded = false;
} }
mark('tasks');
let inboxNames: string[] = []; let inboxNames: string[] = [];
try { try {
@ -191,6 +203,7 @@ export class TeamDataService {
} catch { } catch {
warnings.push('Inboxes failed to load'); warnings.push('Inboxes failed to load');
} }
mark('inboxNames');
let messages: InboxMessage[] = []; let messages: InboxMessage[] = [];
try { try {
@ -198,6 +211,7 @@ export class TeamDataService {
} catch { } catch {
warnings.push('Messages failed to load'); warnings.push('Messages failed to load');
} }
mark('messages');
try { try {
const leadTexts = await this.extractLeadSessionTexts(config); const leadTexts = await this.extractLeadSessionTexts(config);
@ -207,6 +221,7 @@ export class TeamDataService {
} catch { } catch {
warnings.push('Lead session texts failed to load'); warnings.push('Lead session texts failed to load');
} }
mark('leadTexts');
try { try {
const sentMessages = await this.sentMessagesStore.readMessages(teamName); const sentMessages = await this.sentMessagesStore.readMessages(teamName);
@ -216,6 +231,7 @@ export class TeamDataService {
} catch { } catch {
warnings.push('Sent messages failed to load'); warnings.push('Sent messages failed to load');
} }
mark('sentMessages');
messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
@ -225,6 +241,7 @@ export class TeamDataService {
} catch { } catch {
warnings.push('Member metadata failed to load'); warnings.push('Member metadata failed to load');
} }
mark('metaMembers');
let kanbanState: KanbanState = { let kanbanState: KanbanState = {
teamName, teamName,
@ -238,6 +255,7 @@ export class TeamDataService {
warnings.push('Kanban state failed to load'); warnings.push('Kanban state failed to load');
canRunKanbanGc = false; canRunKanbanGc = false;
} }
mark('kanbanState');
if (canRunKanbanGc && tasksLoaded) { if (canRunKanbanGc && tasksLoaded) {
try { try {
@ -247,6 +265,7 @@ export class TeamDataService {
warnings.push('Kanban state cleanup failed'); warnings.push('Kanban state cleanup failed');
} }
} }
mark('kanbanGc');
const tasksWithKanban: TeamTaskWithKanban[] = tasks.map((task) => { const tasksWithKanban: TeamTaskWithKanban[] = tasks.map((task) => {
const col = kanbanState.tasks[task.id]?.column; const col = kanbanState.tasks[task.id]?.column;
@ -261,9 +280,11 @@ export class TeamDataService {
tasksWithKanban, tasksWithKanban,
messages messages
); );
mark('resolveMembers');
// Enrich members with git branch when it differs from lead's branch // Enrich members with git branch when it differs from lead's branch
await this.enrichMemberBranches(members, config); await this.enrichMemberBranches(members, config);
mark('enrichBranches');
// Auto-sync: create comments from task-related inbox messages // Auto-sync: create comments from task-related inbox messages
if (tasksLoaded && messages.length > 0) { if (tasksLoaded && messages.length > 0) {
@ -277,6 +298,7 @@ export class TeamDataService {
warnings.push('Comment sync from messages failed'); warnings.push('Comment sync from messages failed');
} }
} }
mark('syncComments');
const tasksToReturn: TeamTaskWithKanban[] = tasks.map((task) => { const tasksToReturn: TeamTaskWithKanban[] = tasks.map((task) => {
const col = kanbanState.tasks[task.id]?.column; const col = kanbanState.tasks[task.id]?.column;
@ -290,6 +312,22 @@ export class TeamDataService {
} catch { } catch {
warnings.push('Processes failed to load'); warnings.push('Processes failed to load');
} }
mark('processes');
const totalMs = Date.now() - startedAt;
if (totalMs >= 1500) {
logger.warn(
`[getTeamData] slow team=${teamName} total=${totalMs}ms config=${msSince('config')} tasks=${msSince('tasks')} inboxNames=${msSince(
'inboxNames'
)} messages=${msSince('messages')} leadTexts=${msSince('leadTexts')} sent=${msSince(
'sentMessages'
)} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince(
'kanbanGc'
)} resolveMembers=${msSince('resolveMembers')} enrichBranches=${msSince(
'enrichBranches'
)} syncComments=${msSince('syncComments')} processes=${msSince('processes')}`
);
}
// Auto-track teams with alive processes for periodic health checks // Auto-track teams with alive processes for periodic health checks
const hasAlive = processes.some((p) => !p.stoppedAt); const hasAlive = processes.some((p) => !p.stoppedAt);
@ -484,20 +522,27 @@ export class TeamDataService {
return; return;
} }
await Promise.all( const candidates = members.filter((m) => m.cwd && m.cwd !== leadCwd);
members.map(async (member) => { if (candidates.length === 0) return;
if (!member.cwd || member.cwd === leadCwd) return;
try { const concurrency = process.platform === 'win32' ? 4 : 8;
const branch = await gitIdentityResolver.getBranch(member.cwd); for (let i = 0; i < candidates.length; i += concurrency) {
if (branch && branch !== leadBranch) { const batch = candidates.slice(i, i + concurrency);
// eslint-disable-next-line no-param-reassign -- intentional in-place enrichment await Promise.all(
member.gitBranch = branch; batch.map(async (member) => {
if (!member.cwd) return;
try {
const branch = await gitIdentityResolver.getBranch(member.cwd);
if (branch && branch !== leadBranch) {
// eslint-disable-next-line no-param-reassign -- intentional in-place enrichment
member.gitBranch = branch;
}
} catch {
// Member cwd may not be a git repo — skip silently
} }
} catch { })
// Member cwd may not be a git repo — skip silently );
} }
})
);
} }
async addMember(teamName: string, request: AddMemberRequest): Promise<void> { async addMember(teamName: string, request: AddMemberRequest): Promise<void> {
@ -909,6 +954,11 @@ export class TeamDataService {
const TASK_ID_PATTERN = /#(\d+)/g; const TASK_ID_PATTERN = /#(\d+)/g;
let synced = false; let synced = false;
const tasksById = new Map<string, TeamTask>();
for (const t of tasks) {
tasksById.set(t.id, t);
}
// Dedup broadcasts: same sender + same text → process only once // Dedup broadcasts: same sender + same text → process only once
const processedTexts = new Set<string>(); const processedTexts = new Set<string>();
@ -927,7 +977,7 @@ export class TeamDataService {
} }
for (const taskId of taskIds) { for (const taskId of taskIds) {
const task = tasks.find((t) => t.id === taskId); const task = tasksById.get(taskId);
if (!task) continue; if (!task) continue;
const commentId = `msg-${msg.messageId}`; const commentId = `msg-${msg.messageId}`;

View file

@ -7,6 +7,20 @@ import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
const logger = createLogger('teamSlice'); const logger = createLogger('teamSlice');
const TEAM_GET_DATA_TIMEOUT_MS = 30_000;
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<T>((_resolve, reject) => {
timer = setTimeout(() => {
reject(new Error(`Timeout after ${ms}ms: ${label}`));
}, ms);
});
return Promise.race([promise, timeout]).finally(() => {
if (timer) clearTimeout(timer);
});
}
import type { AppState } from '../types'; import type { AppState } from '../types';
import type { import type {
AddMemberRequest, AddMemberRequest,
@ -387,8 +401,18 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
return; return;
} }
const startedAt = Date.now();
const traceId = `${teamName}:${startedAt}`;
logger.info(
`[selectTeam] start trace=${traceId} skipProjectAutoSelect=${opts?.skipProjectAutoSelect === true}`
);
try { try {
const data = await unwrapIpc('team:getData', () => api.teams.getData(teamName)); const data = await withTimeout(
unwrapIpc('team:getData', () => api.teams.getData(teamName)),
TEAM_GET_DATA_TIMEOUT_MS,
`team:getData(${teamName}) trace=${traceId}`
);
// Stale check: user may have switched to another team during the async call // Stale check: user may have switched to another team during the async call
if (get().selectedTeamName !== teamName) { if (get().selectedTeamName !== teamName) {
return; return;
@ -400,6 +424,10 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
selectedTeamError: null, selectedTeamError: null,
}); });
logger.info(
`[selectTeam] done trace=${traceId} ms=${Date.now() - startedAt} tasks=${data.tasks.length} members=${data.members.length} messages=${data.messages.length}`
);
// Sync tab label with the team's display name from config // Sync tab label with the team's display name from config
const displayName = data.config.name || teamName; const displayName = data.config.name || teamName;
const allTabs = get().getAllPaneTabs(); const allTabs = get().getAllPaneTabs();
@ -466,7 +494,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
: error instanceof Error : error instanceof Error
? error.message ? error.message
: 'Failed to fetch team data'; : 'Failed to fetch team data';
logger.error(`[team:getData] ${message}`); logger.error(`[selectTeam] fail team=${teamName} ms=${Date.now() - startedAt} ${message}`);
set({ set({
selectedTeamLoading: false, selectedTeamLoading: false,
selectedTeamData: null, selectedTeamData: null,