perf(extension): cache parsed session files by mtime and size
GET /api/sessions (and every session lookup) re-read and JSON.parsed every .jsonl session file synchronously on each request, blocking the agent's event loop. Parsed metadata is now cached per path (bounded to 500 entries) and invalidated when mtime or size changes.
This commit is contained in:
1 parent
3dd3fa5d05
commit
e8927ebf78
3 files changed
+122
-9
No files matched your search
@@ -21,6 +21,7 @@ import {
|
|||||||
type StoredSttSettings,
|
type StoredSttSettings,
|
||||||
type StoredTtsSettings,
|
type StoredTtsSettings,
|
||||||
} from './piagentui-settings-store.js'
|
} from './piagentui-settings-store.js'
|
||||||
|
import { createStatCache } from './piagentui-stat-cache.js'
|
||||||
import { applySttSettingsUpdate, applyTtsSettingsUpdate, isTtsResponseFormat } from './piagentui-speech-settings.js'
|
import { applySttSettingsUpdate, applyTtsSettingsUpdate, isTtsResponseFormat } from './piagentui-speech-settings.js'
|
||||||
import {
|
import {
|
||||||
buildExtensionCatalog,
|
buildExtensionCatalog,
|
||||||
@@ -518,8 +519,18 @@ export default function (pi: ExtensionAPI) {
|
|||||||
.slice(0, 120)
|
.slice(0, 120)
|
||||||
}
|
}
|
||||||
|
|
||||||
function readSessionMetadata(filePath: string): any | null {
|
type ParsedSessionFile = {
|
||||||
try {
|
id: string
|
||||||
|
title: string
|
||||||
|
cwd: string | undefined
|
||||||
|
headerTimestamp: string | undefined
|
||||||
|
messageCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/sessions parses every session file; re-parse only files whose mtime/size changed.
|
||||||
|
const sessionFileCache = createStatCache<ParsedSessionFile | null>()
|
||||||
|
|
||||||
|
function parseSessionFile(filePath: string): ParsedSessionFile | null {
|
||||||
const content = fs.readFileSync(filePath, 'utf8')
|
const content = fs.readFileSync(filePath, 'utf8')
|
||||||
const lines = content.trim().split('\n').filter(Boolean)
|
const lines = content.trim().split('\n').filter(Boolean)
|
||||||
if (lines.length === 0) return null
|
if (lines.length === 0) return null
|
||||||
@@ -547,19 +558,37 @@ export default function (pi: ExtensionAPI) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = fs.statSync(filePath)
|
|
||||||
const createdTime = header.timestamp ? new Date(header.timestamp).getTime() : stats.birthtimeMs
|
|
||||||
const title = sessionInfoTitle || firstUserMessage || getFallbackSessionTitle(header.id)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: header.id,
|
id: header.id,
|
||||||
title,
|
title: sessionInfoTitle || firstUserMessage || getFallbackSessionTitle(header.id),
|
||||||
directory: header.cwd || latestCtx?.cwd || process.cwd(),
|
cwd: header.cwd,
|
||||||
|
headerTimestamp: header.timestamp,
|
||||||
|
messageCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSessionMetadata(filePath: string): any | null {
|
||||||
|
try {
|
||||||
|
const stats = fs.statSync(filePath)
|
||||||
|
let cached = sessionFileCache.get(filePath, stats)
|
||||||
|
if (!cached) {
|
||||||
|
cached = { value: parseSessionFile(filePath) }
|
||||||
|
sessionFileCache.set(filePath, stats, cached.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = cached.value
|
||||||
|
if (!parsed) return null
|
||||||
|
|
||||||
|
const createdTime = parsed.headerTimestamp ? new Date(parsed.headerTimestamp).getTime() : stats.birthtimeMs
|
||||||
|
return {
|
||||||
|
id: parsed.id,
|
||||||
|
title: parsed.title,
|
||||||
|
directory: parsed.cwd || latestCtx?.cwd || process.cwd(),
|
||||||
time: {
|
time: {
|
||||||
created: Number.isNaN(createdTime) ? stats.birthtimeMs : createdTime,
|
created: Number.isNaN(createdTime) ? stats.birthtimeMs : createdTime,
|
||||||
updated: stats.mtimeMs,
|
updated: stats.mtimeMs,
|
||||||
},
|
},
|
||||||
summary: { deletions: 0, files: 0, additions: messageCount },
|
summary: { deletions: 0, files: 0, additions: parsed.messageCount },
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { createStatCache } from './piagentui-stat-cache.js'
|
||||||
|
|
||||||
|
const stamp = (mtimeMs: number, size = 10) => ({ mtimeMs, size })
|
||||||
|
|
||||||
|
describe('createStatCache', () => {
|
||||||
|
it('misses for unknown paths and hits for the same stamp', () => {
|
||||||
|
const cache = createStatCache<string>()
|
||||||
|
expect(cache.get('a', stamp(1))).toBeUndefined()
|
||||||
|
cache.set('a', stamp(1), 'x')
|
||||||
|
expect(cache.get('a', stamp(1))).toEqual({ value: 'x' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats a changed mtime or size as a miss and drops the stale entry', () => {
|
||||||
|
const cache = createStatCache<string>()
|
||||||
|
cache.set('a', stamp(1, 10), 'x')
|
||||||
|
expect(cache.get('a', stamp(2, 10))).toBeUndefined()
|
||||||
|
expect(cache.size).toBe(0)
|
||||||
|
|
||||||
|
cache.set('a', stamp(1, 10), 'x')
|
||||||
|
expect(cache.get('a', stamp(1, 11))).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can cache null values', () => {
|
||||||
|
const cache = createStatCache<string | null>()
|
||||||
|
cache.set('a', stamp(1), null)
|
||||||
|
expect(cache.get('a', stamp(1))).toEqual({ value: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('evicts the oldest entries beyond the limit', () => {
|
||||||
|
const cache = createStatCache<number>(2)
|
||||||
|
cache.set('a', stamp(1), 1)
|
||||||
|
cache.set('b', stamp(1), 2)
|
||||||
|
cache.set('c', stamp(1), 3)
|
||||||
|
expect(cache.size).toBe(2)
|
||||||
|
expect(cache.get('a', stamp(1))).toBeUndefined()
|
||||||
|
expect(cache.get('b', stamp(1))).toEqual({ value: 2 })
|
||||||
|
expect(cache.get('c', stamp(1))).toEqual({ value: 3 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refreshing a key moves it to the newest position', () => {
|
||||||
|
const cache = createStatCache<number>(2)
|
||||||
|
cache.set('a', stamp(1), 1)
|
||||||
|
cache.set('b', stamp(1), 2)
|
||||||
|
cache.set('a', stamp(2), 11)
|
||||||
|
cache.set('c', stamp(1), 3)
|
||||||
|
expect(cache.get('b', stamp(1))).toBeUndefined()
|
||||||
|
expect(cache.get('a', stamp(2))).toEqual({ value: 11 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export type FileStamp = { mtimeMs: number; size: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small bounded cache of values derived from file contents, keyed by path and
|
||||||
|
* invalidated when the file's mtime or size changes. Oldest entries are evicted first.
|
||||||
|
*/
|
||||||
|
export function createStatCache<T>(maxEntries = 500) {
|
||||||
|
const entries = new Map<string, FileStamp & { value: T }>()
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** Returns `{ value }` on a hit (the value itself may be null), `undefined` on a miss or stale entry. */
|
||||||
|
get(filePath: string, stamp: FileStamp): { value: T } | undefined {
|
||||||
|
const entry = entries.get(filePath)
|
||||||
|
if (!entry) return undefined
|
||||||
|
if (entry.mtimeMs !== stamp.mtimeMs || entry.size !== stamp.size) {
|
||||||
|
entries.delete(filePath)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return { value: entry.value }
|
||||||
|
},
|
||||||
|
set(filePath: string, stamp: FileStamp, value: T): void {
|
||||||
|
entries.delete(filePath)
|
||||||
|
entries.set(filePath, { mtimeMs: stamp.mtimeMs, size: stamp.size, value })
|
||||||
|
while (entries.size > maxEntries) {
|
||||||
|
const oldest = entries.keys().next().value
|
||||||
|
if (oldest === undefined) break
|
||||||
|
entries.delete(oldest)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
get size(): number {
|
||||||
|
return entries.size
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user