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:
parent
0f302521e4
commit
9ce6d37528
3 changed files with 104 additions and 16 deletions
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}`;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue