fix(ai): fix stale overview and analysis caches after incremental import

Overview cache had no self-invalidation: once written it would return
stale lastMessageTs forever if the postImportHook failed silently.
Analysis query cache (17 chart/stats queries) was never cleared on
import, so charts showed stale data until the user triggered a manual
invalidate.

Add MAX(message.id) fingerprint to OverviewCache and introduce
getValidatedOverviewCache(), which recomputes automatically when new
messages are inserted. Update resolveOverview() in the Electron worker
to use this instead of bare getCache().

In both postImportHooks (incrementalImport, streamImport) also call
deleteSessionCache against the query cache directory so chart data is
fresh after every import.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
diguaandClaude Opus 4.8 committed 2026-06-12 11:36:46 +08:00
1 parent ce5e6c2d4a
commit 7316a09823
7 files changed
+212 -14

No files matched your search

+1
View File
@@ -6,6 +6,7 @@ export {
deleteSessionCache,
computeAndSetOverviewCache,
computeAndSetMembersCache,
getValidatedOverviewCache,
CACHE_KEY_OVERVIEW,
CACHE_KEY_MEMBERS,
} from './session-cache'
+147
View File
@@ -0,0 +1,147 @@
/**
* Regression tests for overview cache self-invalidation.
*
* Key scenario: after incremental import inserts new messages the cache
* fingerprint (MAX(message.id)) must mismatch, triggering a recompute so
* the AI system prompt always sees the latest lastMessageTs.
*/
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import Database from 'better-sqlite3'
import { CHAT_DB_SCHEMA } from '@openchatlab/core'
import { BetterSqliteAdapter } from '../better-sqlite3-adapter'
import {
computeAndSetOverviewCache,
getCache,
setCache,
getValidatedOverviewCache,
CACHE_KEY_OVERVIEW,
type OverviewCache,
} from './session-cache'
const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node')
function makeTempDir(): string {
const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()
return fs.mkdtempSync(path.join(baseDir, 'chatlab-session-cache-'))
}
/**
* Create an in-memory (or temp-file) chat DB with schema and one member so
* sender_id FK joins work.
*/
function makeTestDb(filePath?: string): Database.Database {
const db = new Database(filePath ?? ':memory:', { nativeBinding })
db.exec(CHAT_DB_SCHEMA)
// Insert a system member and a regular member
db.prepare(
`INSERT INTO member (platform_id, account_name, group_nickname, avatar, roles)
VALUES (?, ?, ?, NULL, '[]')`
).run('sys', '系统消息', null)
db.prepare(
`INSERT INTO member (platform_id, account_name, group_nickname, avatar, roles)
VALUES (?, ?, ?, NULL, '[]')`
).run('u1', 'Alice', 'Alice')
return db
}
function insertMessage(db: Database.Database, senderId: number, ts: number): void {
db.prepare('INSERT INTO message (sender_id, ts, type, content) VALUES (?, ?, 0, ?)').run(senderId, ts, `msg at ${ts}`)
}
// ──────────────────────────────────────────────────────────────────────────────
// Test 1: core regression — cache becomes stale after incremental import; must recompute
// ──────────────────────────────────────────────────────────────────────────────
test('getValidatedOverviewCache recomputes when new messages are inserted after caching', () => {
const tmpDir = makeTempDir()
const dbPath = path.join(tmpDir, 'test.db')
const db = makeTestDb(dbPath)
const adapter = new BetterSqliteAdapter(db)
// Pre-import: two messages, latest ts=1000
insertMessage(db, 2, 500) // sender_id 2 = Alice (id=2 after sys=1)
insertMessage(db, 2, 1000)
// Build initial cache (simulates what postImportHook does after first import)
computeAndSetOverviewCache(adapter, 'test-session', tmpDir)
// Verify cache was written with correct values
const cached = getCache<OverviewCache>('test-session', CACHE_KEY_OVERVIEW, tmpDir)
assert.ok(cached, 'cache should exist after computeAndSetOverviewCache')
assert.equal(cached.lastMessageTs, 1000, 'cached lastMessageTs should be 1000')
// Simulate incremental import: insert a newer message (ts=2000)
// This is what happens when the user imports new chat data
insertMessage(db, 2, 2000)
// NOW call getValidatedOverviewCache — it must detect the fingerprint mismatch
// and recompute, returning the updated lastMessageTs=2000
const result = getValidatedOverviewCache(adapter, 'test-session', tmpDir)
assert.equal(
result.lastMessageTs,
2000,
'getValidatedOverviewCache must return updated lastMessageTs=2000 after incremental import'
)
})
// ──────────────────────────────────────────────────────────────────────────────
// Test 2: cache hit — no new messages means fingerprint matches, no recompute
// ──────────────────────────────────────────────────────────────────────────────
test('getValidatedOverviewCache returns cached value without recompute when data is unchanged', () => {
const tmpDir = makeTempDir()
const dbPath = path.join(tmpDir, 'test.db')
const db = makeTestDb(dbPath)
const adapter = new BetterSqliteAdapter(db)
insertMessage(db, 2, 1000)
computeAndSetOverviewCache(adapter, 'test-session', tmpDir)
// Inject a stale lastMessageTs into the cache to prove we are NOT recomputing
const staleOverride: OverviewCache & { maxMessageId: number } = {
totalMessages: 1,
totalMembers: 1,
firstMessageTs: 1000,
lastMessageTs: 999, // intentionally wrong — we want to detect if this is returned as-is
maxMessageId: (db.prepare('SELECT MAX(id) AS m FROM message').get() as { m: number }).m,
}
setCache('test-session', CACHE_KEY_OVERVIEW, staleOverride, tmpDir)
// Since no new messages were inserted, fingerprint still matches
// The function must return the cached value (999) without recomputing
const result = getValidatedOverviewCache(adapter, 'test-session', tmpDir)
assert.equal(result.lastMessageTs, 999, 'should return cached value unchanged when fingerprint matches (no new data)')
})
// ──────────────────────────────────────────────────────────────────────────────
// Test 3: legacy cache without maxMessageId triggers recompute
// ──────────────────────────────────────────────────────────────────────────────
test('getValidatedOverviewCache recomputes for old cache files that lack maxMessageId', () => {
const tmpDir = makeTempDir()
const dbPath = path.join(tmpDir, 'test.db')
const db = makeTestDb(dbPath)
const adapter = new BetterSqliteAdapter(db)
insertMessage(db, 2, 1500)
// Manually write a legacy-format cache entry WITHOUT maxMessageId
const legacyCache: OverviewCache = {
totalMessages: 0,
totalMembers: 0,
firstMessageTs: null,
lastMessageTs: null,
// maxMessageId intentionally absent (legacy format)
}
setCache('test-session', CACHE_KEY_OVERVIEW, legacyCache, tmpDir)
// Must recompute because maxMessageId is missing from cached value
const result = getValidatedOverviewCache(adapter, 'test-session', tmpDir)
assert.equal(
result.lastMessageTs,
1500,
'should recompute and return real lastMessageTs when legacy cache lacks maxMessageId'
)
})
+37
View File
@@ -95,6 +95,23 @@ export interface OverviewCache {
totalMembers: number
firstMessageTs: number | null
lastMessageTs: number | null
/**
* MAX(message.id) at the time the cache was written.
* Used as a cheap O(1) freshness fingerprint: if current MAX(id) differs
* the cache is stale and must be recomputed. Absent in legacy cache files
* written before this field was added; absence is treated as stale.
*/
maxMessageId?: number
}
/**
* Return the current MAX(message.id) for freshness checking.
* This is O(1) via the AUTOINCREMENT rowid B-tree.
* Returns 0 for an empty table.
*/
function getMaxMessageId(db: DatabaseAdapter): number {
const row = db.prepare('SELECT MAX(id) AS m FROM message').get() as { m: number | null }
return row.m ?? 0
}
export function computeAndSetOverviewCache(db: DatabaseAdapter, sessionId: string, cacheDir: string): OverviewCache {
@@ -124,6 +141,7 @@ export function computeAndSetOverviewCache(db: DatabaseAdapter, sessionId: strin
totalMembers,
firstMessageTs: msgStats.first_ts,
lastMessageTs: msgStats.last_ts,
maxMessageId: getMaxMessageId(db),
}
setCache(sessionId, CACHE_KEY_OVERVIEW, data, cacheDir)
@@ -132,6 +150,25 @@ export function computeAndSetOverviewCache(db: DatabaseAdapter, sessionId: strin
return data
}
/**
* Cache-first overview read with fingerprint validation.
*
* Checks cached `maxMessageId` against the live `MAX(message.id)`. If they
* match the cache is fresh and returned as-is (one extra O(1) query).
* On any mismatch — new inserts, legacy cache lacking the field, or a cold
* miss — the cache is recomputed and written before returning.
*
* Use this instead of bare `getCache` whenever you need a guaranteed-fresh
* overview (e.g. AI system prompt data-snapshot construction).
*/
export function getValidatedOverviewCache(db: DatabaseAdapter, sessionId: string, cacheDir: string): OverviewCache {
const cached = getCache<OverviewCache>(sessionId, CACHE_KEY_OVERVIEW, cacheDir)
if (cached && cached.maxMessageId !== undefined && cached.maxMessageId === getMaxMessageId(db)) {
return cached
}
return computeAndSetOverviewCache(db, sessionId, cacheDir)
}
// ==================== Members cache (per-member stats) ====================
export const CACHE_KEY_MEMBERS = 'members'
+1
View File
@@ -284,6 +284,7 @@ export {
deleteSessionCache,
computeAndSetOverviewCache,
computeAndSetMembersCache,
getValidatedOverviewCache,
CACHE_KEY_OVERVIEW,
CACHE_KEY_MEMBERS,
} from './cache'