fix(export): prevent card stats poll overlap with frontend/backend singleflight

This commit is contained in:
tisonhuang
2026-03-03 10:20:58 +08:00
parent 84b54e43aa
commit b6a2191e38
2 changed files with 319 additions and 238 deletions

View File

@@ -276,6 +276,12 @@ class ChatService {
private exportContentStatsRefreshPromise: Promise<void> | null = null private exportContentStatsRefreshPromise: Promise<void> | null = null
private exportContentStatsRefreshQueued = false private exportContentStatsRefreshQueued = false
private exportContentStatsRefreshForceQueued = 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<string>() private exportContentStatsDirtySessionIds = new Set<string>()
private exportContentScopeSessionIdsCache: { ids: string[]; updatedAt: number } | null = null private exportContentScopeSessionIdsCache: { ids: string[]; updatedAt: number } | null = null
private readonly exportContentScopeSessionIdsCacheTtlMs = 60 * 1000 private readonly exportContentScopeSessionIdsCacheTtlMs = 60 * 1000
@@ -2124,170 +2130,214 @@ class ChatService {
traceId?: string traceId?: string
}): Promise<{ success: boolean; data?: ExportContentSessionCounts; error?: string }> { }): Promise<{ success: boolean; data?: ExportContentSessionCounts; error?: string }> {
const traceId = this.normalizeExportDiagTraceId(options?.traceId) const traceId = this.normalizeExportDiagTraceId(options?.traceId)
const forceRefresh = options?.forceRefresh === true
const triggerRefresh = options?.triggerRefresh !== false
const stepStartedAt = this.startExportDiagStep({ const stepStartedAt = this.startExportDiagStep({
traceId, traceId,
stepId: 'backend-get-export-content-session-counts', stepId: 'backend-get-export-content-session-counts',
stepName: '获取导出卡片统计', stepName: '获取导出卡片统计',
message: '开始计算导出卡片统计', message: '开始计算导出卡片统计',
data: { data: {
triggerRefresh: options?.triggerRefresh !== false, triggerRefresh,
forceRefresh: options?.forceRefresh === true forceRefresh
} }
}) })
let stepSuccess = false let stepSuccess = false
let stepError = '' let stepError = ''
let stepResult: ExportContentSessionCounts | undefined let stepResult: ExportContentSessionCounts | undefined
let activePromise: Promise<{ success: boolean; data?: ExportContentSessionCounts; error?: string }> | null = null
let createdInFlight = false
try { try {
const connectResult = await this.ensureConnected() if (this.exportContentSessionCountsInFlight) {
if (!connectResult.success) { this.logExportDiag({
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<string>()
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, traceId,
stepId: 'backend-fill-text-counts', source: 'backend',
stepName: '补全文本会话计数', level: 'info',
message: '开始补全文本会话计数', message: '复用进行中的导出卡片统计任务',
data: { missingSessions: missingTextCountSessionIds.length } 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(() => { activePromise = this.exportContentSessionCountsInFlight.promise
this.logExportDiag({ } else {
traceId, const createdPromise = (async () => {
source: 'backend', const connectResult = await this.ensureConnected()
level: 'warn', if (!connectResult.success) {
message: '补全文本会话计数耗时较长', return { success: false, error: connectResult.error || '数据库未连接' }
stepId: 'backend-fill-text-counts', }
stepName: '补全文本会话计数', this.refreshSessionMessageCountCacheScope()
status: 'running',
data: { const sessionIds = await this.listExportContentScopeSessionIds(forceRefresh, traceId)
elapsedMs: Date.now() - textCountStepStartedAt, const sessionIdSet = new Set(sessionIds)
missingSessions: missingTextCountSessionIds.length
} for (const sessionId of Array.from(this.exportContentStatsMemory.keys())) {
}) if (!sessionIdSet.has(sessionId)) {
}, 3000) this.exportContentStatsMemory.delete(sessionId)
const textCountResult = await this.getSessionMessageCounts(missingTextCountSessionIds, { this.exportContentStatsDirtySessionIds.delete(sessionId)
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({ const missingTextCountSessionIds: string[] = []
traceId, let textSessions = 0
stepId: 'backend-fill-text-counts', let voiceSessions = 0
stepName: '补全文本会话计数', let imageSessions = 0
startedAt: textCountStepStartedAt, let videoSessions = 0
let emojiSessions = 0
const pendingMediaSessionSet = new Set<string>()
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, success: true,
message: '文本会话计数补全完成', data: {
data: { updatedSessions: missingTextCountSessionIds.length } totalSessions: sessionIds.length,
}) textSessions,
} else { voiceSessions,
this.endExportDiagStep({ imageSessions,
traceId, videoSessions,
stepId: 'backend-fill-text-counts', emojiSessions,
stepName: '补全文本会话计数', pendingMediaSessions: pendingMediaSessionSet.size,
startedAt: textCountStepStartedAt, updatedAt: this.exportContentStatsScopeUpdatedAt,
success: false, refreshing: this.exportContentStatsRefreshPromise !== null
message: '文本会话计数补全失败', }
data: { error: textCountResult.error || '未知错误' } }
}) })()
activePromise = createdPromise
this.exportContentSessionCountsInFlight = {
promise: createdPromise,
forceRefresh,
traceId,
startedAt: Date.now()
} }
createdInFlight = true
} }
if (forceRefresh && triggerRefresh) { if (!activePromise) {
void this.startExportContentStatsRefresh(true, traceId) stepError = '统计任务未初始化'
} else if (triggerRefresh && (pendingMediaSessionSet.size > 0 || this.exportContentStatsDirtySessionIds.size > 0)) { return { success: false, error: stepError }
void this.startExportContentStatsRefresh(false, traceId)
} }
const result = await activePromise
stepResult = { stepSuccess = result.success
totalSessions: sessionIds.length, if (result.success && result.data) {
textSessions, stepResult = result.data
voiceSessions, } else {
imageSessions, stepError = result.error || '未知错误'
videoSessions,
emojiSessions,
pendingMediaSessions: pendingMediaSessionSet.size,
updatedAt: this.exportContentStatsScopeUpdatedAt,
refreshing: this.exportContentStatsRefreshPromise !== null
}
stepSuccess = true
return {
success: true,
data: stepResult
} }
return result
} catch (e) { } catch (e) {
console.error('ChatService: 获取导出内容会话统计失败:', e) console.error('ChatService: 获取导出内容会话统计失败:', e)
stepError = String(e) stepError = String(e)
return { success: false, error: String(e) } return { success: false, error: String(e) }
} finally { } finally {
if (createdInFlight && activePromise && this.exportContentSessionCountsInFlight?.promise === activePromise) {
this.exportContentSessionCountsInFlight = null
}
this.endExportDiagStep({ this.endExportDiagStep({
traceId, traceId,
stepId: 'backend-get-export-content-session-counts', stepId: 'backend-get-export-content-session-counts',

View File

@@ -971,6 +971,8 @@ function ExportPage() {
const activeTaskCountRef = useRef(0) const activeTaskCountRef = useRef(0)
const hasBaseConfigReadyRef = useRef(false) const hasBaseConfigReadyRef = useRef(false)
const contentSessionCountsForceRetryRef = useRef(0) const contentSessionCountsForceRetryRef = useRef(0)
const contentSessionCountsInFlightRef = useRef<Promise<void> | null>(null)
const contentSessionCountsInFlightTraceRef = useRef('')
const appendFrontendDiagLog = useCallback((entry: ExportCardDiagLogEntry) => { const appendFrontendDiagLog = useCallback((entry: ExportCardDiagLogEntry) => {
setFrontendDiagLogs(prev => { setFrontendDiagLogs(prev => {
@@ -1537,130 +1539,159 @@ function ExportPage() {
}, []) }, [])
const loadContentSessionCounts = useCallback(async (options?: { silent?: boolean; forceRefresh?: boolean }) => { 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 traceId = createExportDiagTraceId()
const startedAt = Date.now() const startedAt = Date.now()
logFrontendDiag({ const task = (async () => {
traceId, logFrontendDiag({
stepId: 'frontend-load-content-session-counts', traceId,
stepName: '前端请求导出卡片统计', stepId: 'frontend-load-content-session-counts',
status: 'running', stepName: '前端请求导出卡片统计',
message: '开始请求导出卡片统计', status: 'running',
data: { message: '开始请求导出卡片统计',
silent: options?.silent === true, data: {
forceRefresh: options?.forceRefresh === true 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
} }
setContentSessionCounts(next) })
const looksLikeAllZero = next.totalSessions > 0 && try {
next.textSessions === 0 && const result = await withTimeout(
next.voiceSessions === 0 && window.electronAPI.chat.getExportContentSessionCounts({
next.imageSessions === 0 && triggerRefresh: true,
next.videoSessions === 0 && forceRefresh: options?.forceRefresh === true,
next.emojiSessions === 0 traceId
}),
if (looksLikeAllZero && contentSessionCountsForceRetryRef.current < 3) { 3200
contentSessionCountsForceRetryRef.current += 1 )
const refreshTraceId = createExportDiagTraceId() if (!result) {
logFrontendDiag({ logFrontendDiag({
traceId: refreshTraceId, traceId,
stepId: 'frontend-force-refresh-content-session-counts', level: 'warn',
stepName: '前端触发强制刷新导出卡片统计', stepId: 'frontend-load-content-session-counts',
status: 'running', stepName: '前端请求导出卡片统计',
message: '检测到统计全0触发强制刷新' 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({ logFrontendDiag({
traceId: refreshTraceId, traceId: refreshTraceId,
stepId: 'frontend-force-refresh-content-session-counts', stepId: 'frontend-force-refresh-content-session-counts',
stepName: '前端触发强制刷新导出卡片统计', stepName: '前端触发强制刷新导出卡片统计',
status: refreshResult?.success ? 'done' : 'failed', status: 'running',
level: refreshResult?.success ? 'info' : 'warn', message: '检测到统计全0触发强制刷新'
message: refreshResult?.success ? '强制刷新请求已提交' : `强制刷新失败:${refreshResult?.error || '未知错误'}`
}) })
}).catch((error) => { void window.electronAPI.chat.refreshExportContentSessionCounts({ forceRefresh: true, traceId: refreshTraceId }).then((refreshResult) => {
logFrontendDiag({ logFrontendDiag({
traceId: refreshTraceId, traceId: refreshTraceId,
stepId: 'frontend-force-refresh-content-session-counts', stepId: 'frontend-force-refresh-content-session-counts',
stepName: '前端触发强制刷新导出卡片统计', stepName: '前端触发强制刷新导出卡片统计',
status: 'failed', status: refreshResult?.success ? 'done' : 'failed',
level: 'error', level: refreshResult?.success ? 'info' : 'warn',
message: '强制刷新请求异常', message: refreshResult?.success ? '强制刷新请求已提交' : `强制刷新失败:${refreshResult?.error || '未知错误'}`
data: { error: String(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 { } else {
contentSessionCountsForceRetryRef.current = 0 logFrontendDiag({
setHasSeededContentSessionCounts(true) 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({ logFrontendDiag({
traceId, traceId,
stepId: 'frontend-load-content-session-counts', level: 'error',
stepName: '前端请求导出卡片统计',
status: 'done',
durationMs: Date.now() - startedAt,
message: '导出卡片统计请求完成',
data: {
totalSessions: next.totalSessions,
pendingMediaSessions: next.pendingMediaSessions,
refreshing: next.refreshing
}
})
} else {
logFrontendDiag({
traceId,
level: 'warn',
stepId: 'frontend-load-content-session-counts', stepId: 'frontend-load-content-session-counts',
stepName: '前端请求导出卡片统计', stepName: '前端请求导出卡片统计',
status: 'failed', status: 'failed',
durationMs: Date.now() - startedAt, durationMs: Date.now() - startedAt,
message: `导出卡片统计请求失败:${result?.error || '未知错误'}` message: '导出卡片统计请求异常',
data: { error: String(error) }
}) })
} }
} catch (error) { })()
console.error('加载导出内容会话统计失败:', error)
logFrontendDiag({ contentSessionCountsInFlightRef.current = task
traceId, contentSessionCountsInFlightTraceRef.current = traceId
level: 'error', try {
stepId: 'frontend-load-content-session-counts', await task
stepName: '前端请求导出卡片统计', } finally {
status: 'failed', if (contentSessionCountsInFlightRef.current === task) {
durationMs: Date.now() - startedAt, contentSessionCountsInFlightRef.current = null
message: '导出卡片统计请求异常', contentSessionCountsInFlightTraceRef.current = ''
data: { error: String(error) } }
})
} }
}, [logFrontendDiag]) }, [logFrontendDiag])