refactor(electron): unify worker analytics cache onto file-version invalidation

The desktop worker kept a second analytics result cache in the same
cacheDir/query directory but invalidated it with a fragile scheme:
app-version .cache_version file, a dev-startup wipe, and explicit
invalidateAnalysisCache() calls after import. Replace it with the shared
getOrComputeAnalysisCache, fingerprinting entries by product version plus
the session DB file state, so the worker (still used by AI tools and the
desktop public REST API) and the shared HTTP routes share one cache and
one invalidation model. Fold the product version into the HTTP-route
fingerprint too, preserving the cross-release invalidation that
.cache_version used to provide, and drop the now-dead invalidation code.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
diguaandCursor committed 2026-06-14 23:54:12 +08:00
1 parent e87b359d23
commit 8192a43a41
5 files changed
+18 -92

No files matched your search

+1 -1
View File
@@ -224,7 +224,7 @@ export function registerChatHandlers(ctx: IpcContext): void {
} catch (e) {
console.error('[IpcMain] Failed to incrementally generate session index:', e)
}
worker.invalidateAnalysisCache(sessionId).catch(() => {})
// 分析缓存按 DB 文件版本自动失效,无需手动清理
// 通知渲染进程刷新会话列表(与 API 路由的 notifySessionListChanged 保持一致)
win.webContents.send('api:importCompleted')
}
+11 -19
View File
@@ -11,8 +11,8 @@
import * as path from 'path'
import { parentPort, workerData } from 'worker_threads'
import { initDbDir, closeDatabase, closeAllDatabases, getCacheDir } from './core'
import { getCache, setCache, deleteSessionCache } from '@openchatlab/node-runtime'
import { initDbDir, closeDatabase, closeAllDatabases, getCacheDir, getDbPath } from './core'
import { getDbFileVersion, getOrComputeAnalysisCache } from '@openchatlab/node-runtime'
import {
getAvailableYears,
getMemberActivity,
@@ -95,6 +95,9 @@ if (workerData.nlpDir) {
const ANALYSIS_CACHE_PREFIX = 'analysis:'
// 缓存版本指纹的代码维度:随发布版本变化,使查询逻辑变更后的旧缓存失效。
const APP_VERSION: string = workerData?.appVersion ?? ''
function getQueryCacheDir(): string {
const cacheDir = getCacheDir()
return cacheDir ? path.join(cacheDir, 'query') : ''
@@ -243,15 +246,6 @@ const syncHandlers: Record<string, (payload: any) => any> = {
// 深度搜索(LIKE 子串匹配)
deepSearchMessages: (p) => deepSearchMessages(p.sessionId, p.keywords, p.filter, p.limit, p.offset, p.senderId),
// 缓存管理
invalidateAnalysisCache: (p) => {
const queryCacheDir = getQueryCacheDir()
if (queryCacheDir && p.sessionId) {
deleteSessionCache(p.sessionId, queryCacheDir)
}
return true
},
}
// 异步消息处理器(流式操作)
@@ -287,17 +281,15 @@ parentPort?.on('message', async (message: WorkerMessage) => {
throw new Error(`Unknown message type: ${type}`)
}
// 可缓存查询:先查缓存,miss 后执行并写回
// 可缓存查询:按「产品版本 + DB 文件状态」派生版本指纹,命中即返回,未命中则计算并写回。
// 与共享 HTTP 路由共用同一份 cacheDir/query 文件与文件版本失效机制,数据变更后自动失效。
const queryCacheDir = getQueryCacheDir()
if (queryCacheDir && CACHEABLE_QUERIES.has(type) && payload.sessionId) {
const cacheKey = buildAnalysisCacheKey(type, payload)
const cached = getCache(payload.sessionId, cacheKey, queryCacheDir)
if (cached !== null) {
parentPort?.postMessage({ id, success: true, result: cached })
return
}
const result = await syncHandler(payload)
setCache(payload.sessionId, cacheKey, result, queryCacheDir)
const version = `${APP_VERSION}|${getDbFileVersion(getDbPath(payload.sessionId))}`
const result = getOrComputeAnalysisCache(payload.sessionId, cacheKey, queryCacheDir, version, () =>
syncHandler(payload)
)
parentPort?.postMessage({ id, success: true, result })
return
}
+1 -69
View File
@@ -62,63 +62,6 @@ function getWorkerPath(): string {
}
}
/**
* 清空 cache/query/ 下所有 .cache.json 文件
*/
function clearAnalysisCacheFiles(queryCacheDir: string): void {
if (!fs.existsSync(queryCacheDir)) return
const files = fs.readdirSync(queryCacheDir)
for (const file of files) {
if (file.endsWith('.cache.json')) {
fs.unlinkSync(path.join(queryCacheDir, file))
}
}
}
/**
* 启动时检查是否需要清除分析缓存:
* - 开发模式:每次启动都清空(代码随时可能变更,避免旧缓存误导)
* - 生产模式:版本号变更时清空(兜底查询逻辑变更导致的缓存过时)
*/
function checkAndResetAnalysisCache(): void {
const queryCacheDir = path.join(getCacheDir(), 'query')
const isDev = !app.isPackaged
if (isDev) {
console.log('[WorkerManager] Dev mode: clearing analysis cache on startup')
try {
clearAnalysisCacheFiles(queryCacheDir)
} catch (err) {
console.error('[WorkerManager] Failed to clear analysis cache:', err)
}
return
}
const versionFile = path.join(queryCacheDir, '.cache_version')
const currentVersion = getDesktopAppVersion(app.getVersion())
let lastVersion: string | null = null
try {
if (fs.existsSync(versionFile)) {
lastVersion = fs.readFileSync(versionFile, 'utf-8').trim()
}
} catch {
// ignore read errors
}
if (lastVersion === currentVersion) return
console.log(`[WorkerManager] Version changed (${lastVersion ?? 'none'} → ${currentVersion}), clearing analysis cache`)
try {
clearAnalysisCacheFiles(queryCacheDir)
ensureDir(queryCacheDir)
fs.writeFileSync(versionFile, currentVersion, 'utf-8')
} catch (err) {
console.error('[WorkerManager] Failed to reset analysis cache:', err)
}
}
/**
* 初始化 Worker
*/
@@ -128,8 +71,6 @@ export function initWorker(): void {
return
}
checkAndResetAnalysisCache()
const workerPath = getWorkerPath()
console.log('[WorkerManager] Initializing worker at:', workerPath)
@@ -140,6 +81,7 @@ export function initWorker(): void {
cacheDir: getCacheDir(),
tempDir: getTempDir(),
nlpDir: getNlpDir(),
appVersion: getDesktopAppVersion(app.getVersion()),
},
})
@@ -323,16 +265,6 @@ export async function pluginCompute<TOutput = any>(fnString: string, input: any)
return sendToWorker('pluginCompute', { fnString, input }, 120000)
}
// ==================== 缓存管理 ====================
/**
* 清除指定 session 的所有分析结果缓存
* 在数据变更(增量导入、成员删除/别名更新)后调用
*/
export async function invalidateAnalysisCache(sessionId: string): Promise<boolean> {
return sendToWorker('invalidateAnalysisCache', { sessionId })
}
// ==================== 导出的异步 API ====================
export async function getAvailableYears(sessionId: string): Promise<number[]> {
@@ -56,6 +56,7 @@ describe('withAnalyticsCache', () => {
return {
pathProvider: { getCacheDir: () => path.join(root, 'cache') },
sessionAdapter: { getDbPath: () => dbPath },
getVersion: () => '0.0.0-test',
} as unknown as HttpRouteContext
}
+4 -3
View File
@@ -4,8 +4,9 @@
* Wraps expensive analytics / NLP computations with the platform-agnostic
* analytics cache (cacheDir/query/{sessionId}.cache.json). Used by both CLI Web
* (`chatlab start`) and the Electron internal server so the two share one cache
* implementation. Validity is keyed to the session DB file fingerprint, so any
* import / incremental import / member edit transparently invalidates entries.
* implementation. Validity is keyed to the product version plus the session DB
* file fingerprint, so any release that changes query logic, and any import /
* incremental import / member edit, transparently invalidates entries.
*/
import * as path from 'path'
@@ -46,7 +47,7 @@ export function withAnalyticsCache<T>(
compute: () => T
): T {
const queryCacheDir = path.join(ctx.pathProvider.getCacheDir(), 'query')
const version = getDbFileVersion(ctx.sessionAdapter.getDbPath(sessionId))
const version = `${ctx.getVersion()}|${getDbFileVersion(ctx.sessionAdapter.getDbPath(sessionId))}`
const key = buildAnalyticsCacheKey(namespace, params)
return getOrComputeAnalysisCache(sessionId, key, queryCacheDir, version, compute)
}