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' };
}
const tn = validated.value!;
const startedAt = Date.now();
logger.info(`[teams:getData] start team=${tn}`);
let data: TeamData;
try {
data = await getTeamDataService().getTeamData(tn);
@ -335,6 +337,14 @@ async function handleGetData(
logger.error(`[teams:getData] ${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 isAlive = provisioning.isTeamAlive(tn);

View file

@ -169,10 +169,21 @@ export class TeamDataService {
}
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);
if (!config) {
throw new Error(`Team not found: ${teamName}`);
}
mark('config');
const warnings: string[] = [];
@ -184,6 +195,7 @@ export class TeamDataService {
warnings.push('Tasks failed to load');
tasksLoaded = false;
}
mark('tasks');
let inboxNames: string[] = [];
try {
@ -191,6 +203,7 @@ export class TeamDataService {
} catch {
warnings.push('Inboxes failed to load');
}
mark('inboxNames');
let messages: InboxMessage[] = [];
try {
@ -198,6 +211,7 @@ export class TeamDataService {
} catch {
warnings.push('Messages failed to load');
}
mark('messages');
try {
const leadTexts = await this.extractLeadSessionTexts(config);
@ -207,6 +221,7 @@ export class TeamDataService {
} catch {
warnings.push('Lead session texts failed to load');
}
mark('leadTexts');
try {
const sentMessages = await this.sentMessagesStore.readMessages(teamName);
@ -216,6 +231,7 @@ export class TeamDataService {
} catch {
warnings.push('Sent messages failed to load');
}
mark('sentMessages');
messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
@ -225,6 +241,7 @@ export class TeamDataService {
} catch {
warnings.push('Member metadata failed to load');
}
mark('metaMembers');
let kanbanState: KanbanState = {
teamName,
@ -238,6 +255,7 @@ export class TeamDataService {
warnings.push('Kanban state failed to load');
canRunKanbanGc = false;
}
mark('kanbanState');
if (canRunKanbanGc && tasksLoaded) {
try {
@ -247,6 +265,7 @@ export class TeamDataService {
warnings.push('Kanban state cleanup failed');
}
}
mark('kanbanGc');
const tasksWithKanban: TeamTaskWithKanban[] = tasks.map((task) => {
const col = kanbanState.tasks[task.id]?.column;
@ -261,9 +280,11 @@ export class TeamDataService {
tasksWithKanban,
messages
);
mark('resolveMembers');
// Enrich members with git branch when it differs from lead's branch
await this.enrichMemberBranches(members, config);
mark('enrichBranches');
// Auto-sync: create comments from task-related inbox messages
if (tasksLoaded && messages.length > 0) {
@ -277,6 +298,7 @@ export class TeamDataService {
warnings.push('Comment sync from messages failed');
}
}
mark('syncComments');
const tasksToReturn: TeamTaskWithKanban[] = tasks.map((task) => {
const col = kanbanState.tasks[task.id]?.column;
@ -290,6 +312,22 @@ export class TeamDataService {
} catch {
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
const hasAlive = processes.some((p) => !p.stoppedAt);
@ -484,20 +522,27 @@ export class TeamDataService {
return;
}
await Promise.all(
members.map(async (member) => {
if (!member.cwd || member.cwd === leadCwd) 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;
const candidates = members.filter((m) => m.cwd && m.cwd !== leadCwd);
if (candidates.length === 0) return;
const concurrency = process.platform === 'win32' ? 4 : 8;
for (let i = 0; i < candidates.length; i += concurrency) {
const batch = candidates.slice(i, i + concurrency);
await Promise.all(
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> {
@ -909,6 +954,11 @@ export class TeamDataService {
const TASK_ID_PATTERN = /#(\d+)/g;
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
const processedTexts = new Set<string>();
@ -927,7 +977,7 @@ export class TeamDataService {
}
for (const taskId of taskIds) {
const task = tasks.find((t) => t.id === taskId);
const task = tasksById.get(taskId);
if (!task) continue;
const commentId = `msg-${msg.messageId}`;

View file

@ -7,6 +7,20 @@ import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
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 {
AddMemberRequest,
@ -387,8 +401,18 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
return;
}
const startedAt = Date.now();
const traceId = `${teamName}:${startedAt}`;
logger.info(
`[selectTeam] start trace=${traceId} skipProjectAutoSelect=${opts?.skipProjectAutoSelect === true}`
);
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
if (get().selectedTeamName !== teamName) {
return;
@ -400,6 +424,10 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
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
const displayName = data.config.name || teamName;
const allTabs = get().getAllPaneTabs();
@ -466,7 +494,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
: error instanceof Error
? error.message
: 'Failed to fetch team data';
logger.error(`[team:getData] ${message}`);
logger.error(`[selectTeam] fail team=${teamName} ms=${Date.now() - startedAt} ${message}`);
set({
selectedTeamLoading: false,
selectedTeamData: null,