From b6a2191e3854ae1dc70cafbc3beeb4cb8419f83e Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Tue, 3 Mar 2026 10:20:58 +0800 Subject: [PATCH] fix(export): prevent card stats poll overlap with frontend/backend singleflight --- electron/services/chatService.ts | 322 ++++++++++++++++++------------- src/pages/ExportPage.tsx | 235 ++++++++++++---------- 2 files changed, 319 insertions(+), 238 deletions(-) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index bfd3b7d..21eaf14 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -276,6 +276,12 @@ class ChatService { private exportContentStatsRefreshPromise: Promise | null = null private exportContentStatsRefreshQueued = false private exportContentStatsRefreshForceQueued = false + private exportContentSessionCountsInFlight: { + promise: Promise<{ success: boolean; data?: ExportContentSessionCounts; error?: string }> + forceRefresh: boolean + traceId: string + startedAt: number + } | null = null private exportContentStatsDirtySessionIds = new Set() private exportContentScopeSessionIdsCache: { ids: string[]; updatedAt: number } | null = null private readonly exportContentScopeSessionIdsCacheTtlMs = 60 * 1000 @@ -2124,170 +2130,214 @@ class ChatService { traceId?: string }): Promise<{ success: boolean; data?: ExportContentSessionCounts; error?: string }> { const traceId = this.normalizeExportDiagTraceId(options?.traceId) + const forceRefresh = options?.forceRefresh === true + const triggerRefresh = options?.triggerRefresh !== false const stepStartedAt = this.startExportDiagStep({ traceId, stepId: 'backend-get-export-content-session-counts', stepName: '获取导出卡片统计', message: '开始计算导出卡片统计', data: { - triggerRefresh: options?.triggerRefresh !== false, - forceRefresh: options?.forceRefresh === true + triggerRefresh, + forceRefresh } }) let stepSuccess = false let stepError = '' let stepResult: ExportContentSessionCounts | undefined + let activePromise: Promise<{ success: boolean; data?: ExportContentSessionCounts; error?: string }> | null = null + let createdInFlight = false try { - const connectResult = await this.ensureConnected() - if (!connectResult.success) { - stepError = connectResult.error || '数据库未连接' - return { success: false, error: connectResult.error || '数据库未连接' } - } - this.refreshSessionMessageCountCacheScope() - - const forceRefresh = options?.forceRefresh === true - const triggerRefresh = options?.triggerRefresh !== false - const sessionIds = await this.listExportContentScopeSessionIds(forceRefresh, traceId) - const sessionIdSet = new Set(sessionIds) - - for (const sessionId of Array.from(this.exportContentStatsMemory.keys())) { - if (!sessionIdSet.has(sessionId)) { - this.exportContentStatsMemory.delete(sessionId) - this.exportContentStatsDirtySessionIds.delete(sessionId) - } - } - - const missingTextCountSessionIds: string[] = [] - let textSessions = 0 - let voiceSessions = 0 - let imageSessions = 0 - let videoSessions = 0 - let emojiSessions = 0 - const pendingMediaSessionSet = new Set() - - for (const sessionId of sessionIds) { - const entry = this.exportContentStatsMemory.get(sessionId) - if (entry) { - if (entry.hasAny) { - textSessions += 1 - } else if (forceRefresh || this.isExportContentEntryDirty(sessionId)) { - missingTextCountSessionIds.push(sessionId) - } - } else { - missingTextCountSessionIds.push(sessionId) - } - - const hasMediaSnapshot = Boolean(entry && entry.mediaReady) - if (hasMediaSnapshot) { - if (entry!.hasVoice) voiceSessions += 1 - if (entry!.hasImage) imageSessions += 1 - if (entry!.hasVideo) videoSessions += 1 - if (entry!.hasEmoji) emojiSessions += 1 - } else { - pendingMediaSessionSet.add(sessionId) - } - - if (this.isExportContentEntryDirty(sessionId) && hasMediaSnapshot) { - pendingMediaSessionSet.add(sessionId) - } - } - - if (missingTextCountSessionIds.length > 0) { - const textCountStepStartedAt = this.startExportDiagStep({ + if (this.exportContentSessionCountsInFlight) { + this.logExportDiag({ traceId, - stepId: 'backend-fill-text-counts', - stepName: '补全文本会话计数', - message: '开始补全文本会话计数', - data: { missingSessions: missingTextCountSessionIds.length } + source: 'backend', + level: 'info', + message: '复用进行中的导出卡片统计任务', + stepId: 'backend-get-export-content-session-counts', + stepName: '获取导出卡片统计', + status: 'running', + data: { + inFlightTraceId: this.exportContentSessionCountsInFlight.traceId, + inFlightForceRefresh: this.exportContentSessionCountsInFlight.forceRefresh, + requestedForceRefresh: forceRefresh, + inFlightElapsedMs: Date.now() - this.exportContentSessionCountsInFlight.startedAt + } }) - const textCountStallTimer = setTimeout(() => { - this.logExportDiag({ - traceId, - source: 'backend', - level: 'warn', - message: '补全文本会话计数耗时较长', - stepId: 'backend-fill-text-counts', - stepName: '补全文本会话计数', - status: 'running', - data: { - elapsedMs: Date.now() - textCountStepStartedAt, - missingSessions: missingTextCountSessionIds.length - } - }) - }, 3000) - const textCountResult = await this.getSessionMessageCounts(missingTextCountSessionIds, { - preferHintCache: false, - bypassSessionCache: true, - traceId - }) - clearTimeout(textCountStallTimer) - if (textCountResult.success && textCountResult.counts) { - const now = Date.now() - for (const sessionId of missingTextCountSessionIds) { - const count = textCountResult.counts[sessionId] - const hasAny = Number.isFinite(count) && Number(count) > 0 - const prevEntry = this.exportContentStatsMemory.get(sessionId) || this.createDefaultExportContentEntry() - const nextEntry: ExportContentSessionStatsEntry = { - ...prevEntry, - hasAny, - updatedAt: prevEntry.updatedAt || now - } - this.exportContentStatsMemory.set(sessionId, nextEntry) - if (hasAny) { - textSessions += 1 + activePromise = this.exportContentSessionCountsInFlight.promise + } else { + const createdPromise = (async () => { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + this.refreshSessionMessageCountCacheScope() + + const sessionIds = await this.listExportContentScopeSessionIds(forceRefresh, traceId) + const sessionIdSet = new Set(sessionIds) + + for (const sessionId of Array.from(this.exportContentStatsMemory.keys())) { + if (!sessionIdSet.has(sessionId)) { + this.exportContentStatsMemory.delete(sessionId) + this.exportContentStatsDirtySessionIds.delete(sessionId) } } - this.persistExportContentStatsScope(sessionIdSet) - this.endExportDiagStep({ - traceId, - stepId: 'backend-fill-text-counts', - stepName: '补全文本会话计数', - startedAt: textCountStepStartedAt, + + const missingTextCountSessionIds: string[] = [] + let textSessions = 0 + let voiceSessions = 0 + let imageSessions = 0 + let videoSessions = 0 + let emojiSessions = 0 + const pendingMediaSessionSet = new Set() + + for (const sessionId of sessionIds) { + const entry = this.exportContentStatsMemory.get(sessionId) + if (entry) { + if (entry.hasAny) { + textSessions += 1 + } else if (forceRefresh || this.isExportContentEntryDirty(sessionId)) { + missingTextCountSessionIds.push(sessionId) + } + } else { + missingTextCountSessionIds.push(sessionId) + } + + const hasMediaSnapshot = Boolean(entry && entry.mediaReady) + if (hasMediaSnapshot) { + if (entry.hasVoice) voiceSessions += 1 + if (entry.hasImage) imageSessions += 1 + if (entry.hasVideo) videoSessions += 1 + if (entry.hasEmoji) emojiSessions += 1 + } else { + pendingMediaSessionSet.add(sessionId) + } + + if (this.isExportContentEntryDirty(sessionId) && hasMediaSnapshot) { + pendingMediaSessionSet.add(sessionId) + } + } + + if (missingTextCountSessionIds.length > 0) { + const textCountStepStartedAt = this.startExportDiagStep({ + traceId, + stepId: 'backend-fill-text-counts', + stepName: '补全文本会话计数', + message: '开始补全文本会话计数', + data: { missingSessions: missingTextCountSessionIds.length } + }) + const textCountStallTimer = setTimeout(() => { + this.logExportDiag({ + traceId, + source: 'backend', + level: 'warn', + message: '补全文本会话计数耗时较长', + stepId: 'backend-fill-text-counts', + stepName: '补全文本会话计数', + status: 'running', + data: { + elapsedMs: Date.now() - textCountStepStartedAt, + missingSessions: missingTextCountSessionIds.length + } + }) + }, 3000) + const textCountResult = await this.getSessionMessageCounts(missingTextCountSessionIds, { + preferHintCache: false, + bypassSessionCache: true, + traceId + }) + clearTimeout(textCountStallTimer) + if (textCountResult.success && textCountResult.counts) { + const now = Date.now() + for (const sessionId of missingTextCountSessionIds) { + const count = textCountResult.counts[sessionId] + const hasAny = Number.isFinite(count) && Number(count) > 0 + const prevEntry = this.exportContentStatsMemory.get(sessionId) || this.createDefaultExportContentEntry() + const nextEntry: ExportContentSessionStatsEntry = { + ...prevEntry, + hasAny, + updatedAt: prevEntry.updatedAt || now + } + this.exportContentStatsMemory.set(sessionId, nextEntry) + if (hasAny) { + textSessions += 1 + } + } + this.persistExportContentStatsScope(sessionIdSet) + this.endExportDiagStep({ + traceId, + stepId: 'backend-fill-text-counts', + stepName: '补全文本会话计数', + startedAt: textCountStepStartedAt, + success: true, + message: '文本会话计数补全完成', + data: { updatedSessions: missingTextCountSessionIds.length } + }) + } else { + this.endExportDiagStep({ + traceId, + stepId: 'backend-fill-text-counts', + stepName: '补全文本会话计数', + startedAt: textCountStepStartedAt, + success: false, + message: '文本会话计数补全失败', + data: { error: textCountResult.error || '未知错误' } + }) + } + } + + if (forceRefresh && triggerRefresh) { + void this.startExportContentStatsRefresh(true, traceId) + } else if (triggerRefresh && (pendingMediaSessionSet.size > 0 || this.exportContentStatsDirtySessionIds.size > 0)) { + void this.startExportContentStatsRefresh(false, traceId) + } + + return { success: true, - message: '文本会话计数补全完成', - data: { updatedSessions: missingTextCountSessionIds.length } - }) - } else { - this.endExportDiagStep({ - traceId, - stepId: 'backend-fill-text-counts', - stepName: '补全文本会话计数', - startedAt: textCountStepStartedAt, - success: false, - message: '文本会话计数补全失败', - data: { error: textCountResult.error || '未知错误' } - }) + data: { + totalSessions: sessionIds.length, + textSessions, + voiceSessions, + imageSessions, + videoSessions, + emojiSessions, + pendingMediaSessions: pendingMediaSessionSet.size, + updatedAt: this.exportContentStatsScopeUpdatedAt, + refreshing: this.exportContentStatsRefreshPromise !== null + } + } + })() + activePromise = createdPromise + this.exportContentSessionCountsInFlight = { + promise: createdPromise, + forceRefresh, + traceId, + startedAt: Date.now() } + createdInFlight = true } - if (forceRefresh && triggerRefresh) { - void this.startExportContentStatsRefresh(true, traceId) - } else if (triggerRefresh && (pendingMediaSessionSet.size > 0 || this.exportContentStatsDirtySessionIds.size > 0)) { - void this.startExportContentStatsRefresh(false, traceId) + if (!activePromise) { + stepError = '统计任务未初始化' + return { success: false, error: stepError } } - - stepResult = { - totalSessions: sessionIds.length, - textSessions, - voiceSessions, - imageSessions, - videoSessions, - emojiSessions, - pendingMediaSessions: pendingMediaSessionSet.size, - updatedAt: this.exportContentStatsScopeUpdatedAt, - refreshing: this.exportContentStatsRefreshPromise !== null - } - stepSuccess = true - return { - success: true, - data: stepResult + const result = await activePromise + stepSuccess = result.success + if (result.success && result.data) { + stepResult = result.data + } else { + stepError = result.error || '未知错误' } + return result } catch (e) { console.error('ChatService: 获取导出内容会话统计失败:', e) stepError = String(e) return { success: false, error: String(e) } } finally { + if (createdInFlight && activePromise && this.exportContentSessionCountsInFlight?.promise === activePromise) { + this.exportContentSessionCountsInFlight = null + } this.endExportDiagStep({ traceId, stepId: 'backend-get-export-content-session-counts', diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 4449db0..ca7a531 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -971,6 +971,8 @@ function ExportPage() { const activeTaskCountRef = useRef(0) const hasBaseConfigReadyRef = useRef(false) const contentSessionCountsForceRetryRef = useRef(0) + const contentSessionCountsInFlightRef = useRef | null>(null) + const contentSessionCountsInFlightTraceRef = useRef('') const appendFrontendDiagLog = useCallback((entry: ExportCardDiagLogEntry) => { setFrontendDiagLogs(prev => { @@ -1537,130 +1539,159 @@ function ExportPage() { }, []) const loadContentSessionCounts = useCallback(async (options?: { silent?: boolean; forceRefresh?: boolean }) => { + if (contentSessionCountsInFlightRef.current) { + logFrontendDiag({ + level: 'info', + stepId: 'frontend-load-content-session-counts', + stepName: '前端请求导出卡片统计', + status: 'running', + message: '统计请求仍在进行中,跳过本次轮询', + data: { + silent: options?.silent === true, + forceRefresh: options?.forceRefresh === true, + inFlightTraceId: contentSessionCountsInFlightTraceRef.current || undefined + } + }) + return + } + const traceId = createExportDiagTraceId() const startedAt = Date.now() - logFrontendDiag({ - traceId, - stepId: 'frontend-load-content-session-counts', - stepName: '前端请求导出卡片统计', - status: 'running', - message: '开始请求导出卡片统计', - data: { - silent: options?.silent === true, - forceRefresh: options?.forceRefresh === true - } - }) - try { - const result = await withTimeout( - window.electronAPI.chat.getExportContentSessionCounts({ - triggerRefresh: true, - forceRefresh: options?.forceRefresh === true, - traceId - }), - 3200 - ) - if (!result) { - logFrontendDiag({ - traceId, - level: 'warn', - stepId: 'frontend-load-content-session-counts', - stepName: '前端请求导出卡片统计', - status: 'timeout', - durationMs: Date.now() - startedAt, - message: '导出卡片统计请求超时(3200ms)' - }) - return - } - if (result?.success && result.data) { - const next: ExportContentSessionCountsSummary = { - totalSessions: Number.isFinite(result.data.totalSessions) ? Math.max(0, Math.floor(result.data.totalSessions)) : 0, - textSessions: Number.isFinite(result.data.textSessions) ? Math.max(0, Math.floor(result.data.textSessions)) : 0, - voiceSessions: Number.isFinite(result.data.voiceSessions) ? Math.max(0, Math.floor(result.data.voiceSessions)) : 0, - imageSessions: Number.isFinite(result.data.imageSessions) ? Math.max(0, Math.floor(result.data.imageSessions)) : 0, - videoSessions: Number.isFinite(result.data.videoSessions) ? Math.max(0, Math.floor(result.data.videoSessions)) : 0, - emojiSessions: Number.isFinite(result.data.emojiSessions) ? Math.max(0, Math.floor(result.data.emojiSessions)) : 0, - pendingMediaSessions: Number.isFinite(result.data.pendingMediaSessions) ? Math.max(0, Math.floor(result.data.pendingMediaSessions)) : 0, - updatedAt: Number.isFinite(result.data.updatedAt) ? Math.max(0, Math.floor(result.data.updatedAt)) : 0, - refreshing: result.data.refreshing === true + const task = (async () => { + logFrontendDiag({ + traceId, + stepId: 'frontend-load-content-session-counts', + stepName: '前端请求导出卡片统计', + status: 'running', + message: '开始请求导出卡片统计', + data: { + silent: options?.silent === true, + forceRefresh: options?.forceRefresh === true } - setContentSessionCounts(next) - const looksLikeAllZero = next.totalSessions > 0 && - next.textSessions === 0 && - next.voiceSessions === 0 && - next.imageSessions === 0 && - next.videoSessions === 0 && - next.emojiSessions === 0 - - if (looksLikeAllZero && contentSessionCountsForceRetryRef.current < 3) { - contentSessionCountsForceRetryRef.current += 1 - const refreshTraceId = createExportDiagTraceId() + }) + try { + const result = await withTimeout( + window.electronAPI.chat.getExportContentSessionCounts({ + triggerRefresh: true, + forceRefresh: options?.forceRefresh === true, + traceId + }), + 3200 + ) + if (!result) { logFrontendDiag({ - traceId: refreshTraceId, - stepId: 'frontend-force-refresh-content-session-counts', - stepName: '前端触发强制刷新导出卡片统计', - status: 'running', - message: '检测到统计全0,触发强制刷新' + traceId, + level: 'warn', + stepId: 'frontend-load-content-session-counts', + stepName: '前端请求导出卡片统计', + status: 'timeout', + durationMs: Date.now() - startedAt, + message: '导出卡片统计请求超时(3200ms,后台可能仍在处理)' }) - void window.electronAPI.chat.refreshExportContentSessionCounts({ forceRefresh: true, traceId: refreshTraceId }).then((refreshResult) => { + return + } + if (result?.success && result.data) { + const next: ExportContentSessionCountsSummary = { + totalSessions: Number.isFinite(result.data.totalSessions) ? Math.max(0, Math.floor(result.data.totalSessions)) : 0, + textSessions: Number.isFinite(result.data.textSessions) ? Math.max(0, Math.floor(result.data.textSessions)) : 0, + voiceSessions: Number.isFinite(result.data.voiceSessions) ? Math.max(0, Math.floor(result.data.voiceSessions)) : 0, + imageSessions: Number.isFinite(result.data.imageSessions) ? Math.max(0, Math.floor(result.data.imageSessions)) : 0, + videoSessions: Number.isFinite(result.data.videoSessions) ? Math.max(0, Math.floor(result.data.videoSessions)) : 0, + emojiSessions: Number.isFinite(result.data.emojiSessions) ? Math.max(0, Math.floor(result.data.emojiSessions)) : 0, + pendingMediaSessions: Number.isFinite(result.data.pendingMediaSessions) ? Math.max(0, Math.floor(result.data.pendingMediaSessions)) : 0, + updatedAt: Number.isFinite(result.data.updatedAt) ? Math.max(0, Math.floor(result.data.updatedAt)) : 0, + refreshing: result.data.refreshing === true + } + setContentSessionCounts(next) + const looksLikeAllZero = next.totalSessions > 0 && + next.textSessions === 0 && + next.voiceSessions === 0 && + next.imageSessions === 0 && + next.videoSessions === 0 && + next.emojiSessions === 0 + + if (looksLikeAllZero && contentSessionCountsForceRetryRef.current < 3) { + contentSessionCountsForceRetryRef.current += 1 + const refreshTraceId = createExportDiagTraceId() logFrontendDiag({ traceId: refreshTraceId, stepId: 'frontend-force-refresh-content-session-counts', stepName: '前端触发强制刷新导出卡片统计', - status: refreshResult?.success ? 'done' : 'failed', - level: refreshResult?.success ? 'info' : 'warn', - message: refreshResult?.success ? '强制刷新请求已提交' : `强制刷新失败:${refreshResult?.error || '未知错误'}` + status: 'running', + message: '检测到统计全0,触发强制刷新' }) - }).catch((error) => { - logFrontendDiag({ - traceId: refreshTraceId, - stepId: 'frontend-force-refresh-content-session-counts', - stepName: '前端触发强制刷新导出卡片统计', - status: 'failed', - level: 'error', - message: '强制刷新请求异常', - data: { error: String(error) } + void window.electronAPI.chat.refreshExportContentSessionCounts({ forceRefresh: true, traceId: refreshTraceId }).then((refreshResult) => { + logFrontendDiag({ + traceId: refreshTraceId, + stepId: 'frontend-force-refresh-content-session-counts', + stepName: '前端触发强制刷新导出卡片统计', + status: refreshResult?.success ? 'done' : 'failed', + level: refreshResult?.success ? 'info' : 'warn', + message: refreshResult?.success ? '强制刷新请求已提交' : `强制刷新失败:${refreshResult?.error || '未知错误'}` + }) + }).catch((error) => { + logFrontendDiag({ + traceId: refreshTraceId, + stepId: 'frontend-force-refresh-content-session-counts', + stepName: '前端触发强制刷新导出卡片统计', + status: 'failed', + level: 'error', + message: '强制刷新请求异常', + data: { error: String(error) } + }) }) + } else { + contentSessionCountsForceRetryRef.current = 0 + setHasSeededContentSessionCounts(true) + } + logFrontendDiag({ + traceId, + stepId: 'frontend-load-content-session-counts', + stepName: '前端请求导出卡片统计', + status: 'done', + durationMs: Date.now() - startedAt, + message: '导出卡片统计请求完成', + data: { + totalSessions: next.totalSessions, + pendingMediaSessions: next.pendingMediaSessions, + refreshing: next.refreshing + } }) } else { - contentSessionCountsForceRetryRef.current = 0 - setHasSeededContentSessionCounts(true) + logFrontendDiag({ + traceId, + level: 'warn', + stepId: 'frontend-load-content-session-counts', + stepName: '前端请求导出卡片统计', + status: 'failed', + durationMs: Date.now() - startedAt, + message: `导出卡片统计请求失败:${result?.error || '未知错误'}` + }) } + } catch (error) { + console.error('加载导出内容会话统计失败:', error) logFrontendDiag({ traceId, - stepId: 'frontend-load-content-session-counts', - stepName: '前端请求导出卡片统计', - status: 'done', - durationMs: Date.now() - startedAt, - message: '导出卡片统计请求完成', - data: { - totalSessions: next.totalSessions, - pendingMediaSessions: next.pendingMediaSessions, - refreshing: next.refreshing - } - }) - } else { - logFrontendDiag({ - traceId, - level: 'warn', + level: 'error', stepId: 'frontend-load-content-session-counts', stepName: '前端请求导出卡片统计', status: 'failed', durationMs: Date.now() - startedAt, - message: `导出卡片统计请求失败:${result?.error || '未知错误'}` + message: '导出卡片统计请求异常', + data: { error: String(error) } }) } - } catch (error) { - console.error('加载导出内容会话统计失败:', error) - logFrontendDiag({ - traceId, - level: 'error', - stepId: 'frontend-load-content-session-counts', - stepName: '前端请求导出卡片统计', - status: 'failed', - durationMs: Date.now() - startedAt, - message: '导出卡片统计请求异常', - data: { error: String(error) } - }) + })() + + contentSessionCountsInFlightRef.current = task + contentSessionCountsInFlightTraceRef.current = traceId + try { + await task + } finally { + if (contentSessionCountsInFlightRef.current === task) { + contentSessionCountsInFlightRef.current = null + contentSessionCountsInFlightTraceRef.current = '' + } } }, [logFrontendDiag])