mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
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:
1 parent
ce5e6c2d4a
commit
7316a09823
7 files changed
+212
-14
No files matched your search
+1
@@ -6,6 +6,7 @@ export {
|
||||
deleteSessionCache,
|
||||
computeAndSetOverviewCache,
|
||||
computeAndSetMembersCache,
|
||||
getValidatedOverviewCache,
|
||||
CACHE_KEY_OVERVIEW,
|
||||
CACHE_KEY_MEMBERS,
|
||||
} from './session-cache'
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -284,6 +284,7 @@ export {
|
||||
deleteSessionCache,
|
||||
computeAndSetOverviewCache,
|
||||
computeAndSetMembersCache,
|
||||
getValidatedOverviewCache,
|
||||
CACHE_KEY_OVERVIEW,
|
||||
CACHE_KEY_MEMBERS,
|
||||
} from './cache'
|
||||
|
||||
Reference in new issue
Block a user