fix(insight): invalidate snapshots on session deletion

Remove persisted annual summary snapshots whenever a session is deleted through shared or desktop cleanup paths. Drop cached in-memory snapshots when their backing files disappear so stale responses cannot expose deleted session data.
This commit is contained in:
digua committed 2026-07-12 15:29:09 +08:00
1 parent b15f38c4a5
commit e1288c3cc7
7 files changed
+59 -2

No files matched your search

+3
View File
@@ -176,6 +176,9 @@ export function deleteSession(sessionId: string): boolean {
sessionId, sessionId,
globalInsightService.getGlobalInsightFactsCacheDir(getPathProvider().getUserDataDir()) globalInsightService.getGlobalInsightFactsCacheDir(getPathProvider().getUserDataDir())
) )
globalInsightService.deleteAnnualSummarySnapshots(
globalInsightService.getGlobalInsightDir(getPathProvider().getUserDataDir())
)
deleteSessionCache( deleteSessionCache(
sessionId, sessionId,
peopleRelationshipsService.getPeopleRelationshipsFactsCacheDir(getPathProvider().getUserDataDir()) peopleRelationshipsService.getPeopleRelationshipsFactsCacheDir(getPathProvider().getUserDataDir())
@@ -28,7 +28,8 @@ import {
} from './migrations' } from './migrations'
import { tokenizeForFts } from './nlp/fts-tokenizer' import { tokenizeForFts } from './nlp/fts-tokenizer'
import { getContactsFactsCacheDir } from './services/contacts/paths' import { getContactsFactsCacheDir } from './services/contacts/paths'
import { getGlobalInsightFactsCacheDir } from './services/global-insight/paths' import { getGlobalInsightDir, getGlobalInsightFactsCacheDir } from './services/global-insight/paths'
import { deleteAnnualSummarySnapshots } from './services/global-insight/snapshot'
import { getPeopleRelationshipsFactsCacheDir } from './services/people/relationships/paths' import { getPeopleRelationshipsFactsCacheDir } from './services/people/relationships/paths'
function createMigrationDeps(overrides?: MigrationDeps): MigrationDeps { function createMigrationDeps(overrides?: MigrationDeps): MigrationDeps {
@@ -251,6 +252,7 @@ export class DatabaseManager {
deleteSessionCache(sessionId, path.join(cacheDir, 'query')) deleteSessionCache(sessionId, path.join(cacheDir, 'query'))
deleteSessionCache(sessionId, getContactsFactsCacheDir(this.pathProvider.getUserDataDir())) deleteSessionCache(sessionId, getContactsFactsCacheDir(this.pathProvider.getUserDataDir()))
deleteSessionCache(sessionId, getGlobalInsightFactsCacheDir(this.pathProvider.getUserDataDir())) deleteSessionCache(sessionId, getGlobalInsightFactsCacheDir(this.pathProvider.getUserDataDir()))
deleteAnnualSummarySnapshots(getGlobalInsightDir(this.pathProvider.getUserDataDir()))
deleteSessionCache(sessionId, getPeopleRelationshipsFactsCacheDir(this.pathProvider.getUserDataDir())) deleteSessionCache(sessionId, getPeopleRelationshipsFactsCacheDir(this.pathProvider.getUserDataDir()))
return existed return existed
} }
@@ -7,7 +7,7 @@ import type { PathProvider } from '@openchatlab/core'
import { DatabaseManager } from '../../database-manager' import { DatabaseManager } from '../../database-manager'
import { createDatabaseManagerAdapter } from './database-manager-adapter' import { createDatabaseManagerAdapter } from './database-manager-adapter'
import { getContactsFactsCacheDir } from '../contacts/paths' import { getContactsFactsCacheDir } from '../contacts/paths'
import { getGlobalInsightFactsCacheDir } from '../global-insight/paths' import { getGlobalInsightDir, getGlobalInsightFactsCacheDir } from '../global-insight/paths'
function makeTempDir(): string { function makeTempDir(): string {
const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()
@@ -52,14 +52,25 @@ test('DatabaseManager adapter deletes session database sidecar files and caches'
getGlobalInsightFactsCacheDir(pathProvider.getUserDataDir()), getGlobalInsightFactsCacheDir(pathProvider.getUserDataDir()),
`${sessionId}.cache.json` `${sessionId}.cache.json`
) )
const annualSummaryYearSnapshotPath = path.join(
getGlobalInsightDir(pathProvider.getUserDataDir()),
'annual-summary-year-2026.json'
)
const annualSummaryRecentSnapshotPath = path.join(
getGlobalInsightDir(pathProvider.getUserDataDir()),
'annual-summary-recent-365.json'
)
fs.mkdirSync(path.dirname(cachePath), { recursive: true }) fs.mkdirSync(path.dirname(cachePath), { recursive: true })
fs.mkdirSync(path.dirname(queryCachePath), { recursive: true }) fs.mkdirSync(path.dirname(queryCachePath), { recursive: true })
fs.mkdirSync(path.dirname(contactsFactsCachePath), { recursive: true }) fs.mkdirSync(path.dirname(contactsFactsCachePath), { recursive: true })
fs.mkdirSync(path.dirname(globalInsightFactsCachePath), { recursive: true }) fs.mkdirSync(path.dirname(globalInsightFactsCachePath), { recursive: true })
fs.mkdirSync(path.dirname(annualSummaryYearSnapshotPath), { recursive: true })
fs.writeFileSync(cachePath, '{}', 'utf-8') fs.writeFileSync(cachePath, '{}', 'utf-8')
fs.writeFileSync(queryCachePath, '{}', 'utf-8') fs.writeFileSync(queryCachePath, '{}', 'utf-8')
fs.writeFileSync(contactsFactsCachePath, '{}', 'utf-8') fs.writeFileSync(contactsFactsCachePath, '{}', 'utf-8')
fs.writeFileSync(globalInsightFactsCachePath, '{}', 'utf-8') fs.writeFileSync(globalInsightFactsCachePath, '{}', 'utf-8')
fs.writeFileSync(annualSummaryYearSnapshotPath, '{}', 'utf-8')
fs.writeFileSync(annualSummaryRecentSnapshotPath, '{}', 'utf-8')
assert.equal(adapter.deleteSessionFile(sessionId), true) assert.equal(adapter.deleteSessionFile(sessionId), true)
assert.equal(fs.existsSync(dbPath), false) assert.equal(fs.existsSync(dbPath), false)
@@ -69,4 +80,6 @@ test('DatabaseManager adapter deletes session database sidecar files and caches'
assert.equal(fs.existsSync(queryCachePath), false) assert.equal(fs.existsSync(queryCachePath), false)
assert.equal(fs.existsSync(contactsFactsCachePath), false) assert.equal(fs.existsSync(contactsFactsCachePath), false)
assert.equal(fs.existsSync(globalInsightFactsCachePath), false) assert.equal(fs.existsSync(globalInsightFactsCachePath), false)
assert.equal(fs.existsSync(annualSummaryYearSnapshotPath), false)
assert.equal(fs.existsSync(annualSummaryRecentSnapshotPath), false)
}) })
@@ -3,3 +3,4 @@ export type { GlobalInsightService, GlobalInsightServiceDeps, GlobalInsightServi
export { ANNUAL_SUMMARY_ALGORITHM_VERSION } from './types' export { ANNUAL_SUMMARY_ALGORITHM_VERSION } from './types'
export type { AnnualSummaryComputeRunner, AnnualSummaryRunnerOptions } from './worker-runner' export type { AnnualSummaryComputeRunner, AnnualSummaryRunnerOptions } from './worker-runner'
export { getGlobalInsightDir, getGlobalInsightFactsCacheDir } from './paths' export { getGlobalInsightDir, getGlobalInsightFactsCacheDir } from './paths'
export { deleteAnnualSummarySnapshots } from './snapshot'
@@ -6,6 +6,7 @@ import test from 'node:test'
import type { AnnualSummaryRange } from '@openchatlab/shared-types' import type { AnnualSummaryRange } from '@openchatlab/shared-types'
import type { SessionRuntimeAdapter } from '../adapters' import type { SessionRuntimeAdapter } from '../adapters'
import { createGlobalInsightService } from './service' import { createGlobalInsightService } from './service'
import { getAnnualSummarySnapshotPath, writeAnnualSummarySnapshot } from './snapshot'
import type { AnnualSummarySnapshot } from './types' import type { AnnualSummarySnapshot } from './types'
import type { AnnualSummaryComputeRunner } from './worker-runner' import type { AnnualSummaryComputeRunner } from './worker-runner'
@@ -101,6 +102,25 @@ test('serves stale data while recomputing after the DB signature changes', async
resolveRunner?.(makeSnapshot(range, stale.cache.signature ?? '', 9)) resolveRunner?.(makeSnapshot(range, stale.cache.signature ?? '', 9))
}) })
test('drops an in-memory stale snapshot after its persisted file is deleted', async (t) => {
const env = createEnv(async ({ signature, range }) => makeSnapshot(range, signature, 9))
t.after(async () => {
await env.service.close()
fs.rmSync(env.dir, { recursive: true, force: true })
})
const range = env.service.normalizeRange({ mode: 'year', year: 2026 })
const snapshotDir = path.join(env.dir, 'insight', 'annual-summary')
const staleSnapshot = makeSnapshot(range, 'old-signature')
writeAnnualSummarySnapshot(snapshotDir, staleSnapshot)
env.service.replaceSnapshotForTests(staleSnapshot)
fs.rmSync(getAnnualSummarySnapshotPath(snapshotDir, range))
const response = env.service.getAnnualSummary({ mode: 'year', year: 2026, acceptStale: true })
assert.equal(response.cache.status, 'missing')
assert.equal(response.metrics, null)
})
test('preserves failed state until explicit recompute', async (t) => { test('preserves failed state until explicit recompute', async (t) => {
let calls = 0 let calls = 0
const env = createEnv(async ({ signature, range }) => { const env = createEnv(async ({ signature, range }) => {
@@ -1,3 +1,4 @@
import fs from 'node:fs'
import type { PathProvider } from '@openchatlab/core' import type { PathProvider } from '@openchatlab/core'
import type { AnnualSummaryRange, AnnualSummaryResponse, AnnualSummaryTaskState } from '@openchatlab/shared-types' import type { AnnualSummaryRange, AnnualSummaryResponse, AnnualSummaryTaskState } from '@openchatlab/shared-types'
import type { RuntimeIdentity } from '../../data-dir-compat' import type { RuntimeIdentity } from '../../data-dir-compat'
@@ -7,6 +8,7 @@ import { getGlobalInsightDir } from './paths'
import { buildAnnualSummarySignature } from './signature' import { buildAnnualSummarySignature } from './signature'
import { import {
cleanupAnnualSummarySnapshotTempFiles, cleanupAnnualSummarySnapshotTempFiles,
getAnnualSummarySnapshotPath,
readAnnualSummarySnapshot, readAnnualSummarySnapshot,
writeAnnualSummarySnapshot, writeAnnualSummarySnapshot,
} from './snapshot' } from './snapshot'
@@ -96,6 +98,7 @@ class DefaultGlobalInsightService implements GlobalInsightService {
} }
replaceSnapshotForTests(snapshot: AnnualSummarySnapshot): void { replaceSnapshotForTests(snapshot: AnnualSummarySnapshot): void {
writeAnnualSummarySnapshot(this.snapshotDir, snapshot)
this.snapshots.set(toAnnualSummaryRangeKey(snapshot.range), snapshot) this.snapshots.set(toAnnualSummaryRangeKey(snapshot.range), snapshot)
} }
@@ -224,6 +227,9 @@ class DefaultGlobalInsightService implements GlobalInsightService {
private getSnapshot(range: AnnualSummaryRange): AnnualSummarySnapshot | null { private getSnapshot(range: AnnualSummaryRange): AnnualSummarySnapshot | null {
const key = toAnnualSummaryRangeKey(range) const key = toAnnualSummaryRangeKey(range)
if (this.snapshots.has(key) && !fs.existsSync(getAnnualSummarySnapshotPath(this.snapshotDir, range))) {
this.snapshots.delete(key)
}
if (!this.snapshots.has(key)) { if (!this.snapshots.has(key)) {
this.snapshots.set(key, readAnnualSummarySnapshot(this.snapshotDir, range, { now: this.deps.now })) this.snapshots.set(key, readAnnualSummarySnapshot(this.snapshotDir, range, { now: this.deps.now }))
} }
@@ -50,3 +50,15 @@ export function cleanupAnnualSummarySnapshotTempFiles(snapshotDir: string): void
} }
} }
} }
export function deleteAnnualSummarySnapshots(snapshotDir: string): void {
if (!fs.existsSync(snapshotDir)) return
for (const name of fs.readdirSync(snapshotDir)) {
if (!name.startsWith('annual-summary-') && !name.startsWith('annual-summary.')) continue
try {
fs.rmSync(path.join(snapshotDir, name), { force: true })
} catch (error) {
appLogger.warn('global-insight', 'failed to remove annual summary snapshot', error)
}
}
}