mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 01:58:26 +00:00
perf(gateway): cache session list transcript fields
This commit is contained in:
@@ -72,6 +72,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Sessions: archive previous transcript files on `/new` and `/reset` session resets (including gateway `sessions.reset`) so stale transcripts do not accumulate on disk. (#14869) Thanks @mcaxtr.
|
- Sessions: archive previous transcript files on `/new` and `/reset` session resets (including gateway `sessions.reset`) so stale transcripts do not accumulate on disk. (#14869) Thanks @mcaxtr.
|
||||||
- Status/Sessions: stop clamping derived `totalTokens` to context-window size, keep prompt-token snapshots wired through session accounting, and surface context usage as unknown when fresh snapshot data is missing to avoid false 100% reports. (#15114) Thanks @echoVic.
|
- Status/Sessions: stop clamping derived `totalTokens` to context-window size, keep prompt-token snapshots wired through session accounting, and surface context usage as unknown when fresh snapshot data is missing to avoid false 100% reports. (#15114) Thanks @echoVic.
|
||||||
- Gateway/Routing: speed up hot paths for session listing (derived titles + previews), WS broadcast, and binding resolution.
|
- Gateway/Routing: speed up hot paths for session listing (derived titles + previews), WS broadcast, and binding resolution.
|
||||||
|
- Gateway/Sessions: cache derived title + last-message transcript reads to speed up repeated sessions list refreshes.
|
||||||
- CLI/Completion: route plugin-load logs to stderr and write generated completion scripts directly to stdout to avoid `source <(openclaw completion ...)` corruption. (#15481) Thanks @arosstale.
|
- CLI/Completion: route plugin-load logs to stderr and write generated completion scripts directly to stdout to avoid `source <(openclaw completion ...)` corruption. (#15481) Thanks @arosstale.
|
||||||
- CLI: lazily load outbound provider dependencies and remove forced success-path exits so commands terminate naturally without killing intentional long-running foreground actions. (#12906) Thanks @DrCrinkle.
|
- CLI: lazily load outbound provider dependencies and remove forced success-path exits so commands terminate naturally without killing intentional long-running foreground actions. (#12906) Thanks @DrCrinkle.
|
||||||
- CLI: speed up startup by lazily registering core commands (keeps rich `--help` while reducing cold-start overhead).
|
- CLI: speed up startup by lazily registering core commands (keeps rich `--help` while reducing cold-start overhead).
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
readFirstUserMessageFromTranscript,
|
readFirstUserMessageFromTranscript,
|
||||||
readLastMessagePreviewFromTranscript,
|
readLastMessagePreviewFromTranscript,
|
||||||
readSessionMessages,
|
readSessionMessages,
|
||||||
|
readSessionTitleFieldsFromTranscript,
|
||||||
readSessionPreviewItemsFromTranscript,
|
readSessionPreviewItemsFromTranscript,
|
||||||
resolveSessionTranscriptCandidates,
|
resolveSessionTranscriptCandidates,
|
||||||
} from "./session-utils.fs.js";
|
} from "./session-utils.fs.js";
|
||||||
@@ -367,6 +368,68 @@ describe("readLastMessagePreviewFromTranscript", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("readSessionTitleFieldsFromTranscript cache", () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
let storePath: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-fs-test-"));
|
||||||
|
storePath = path.join(tmpDir, "sessions.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns cached values without re-reading when unchanged", () => {
|
||||||
|
const sessionId = "test-cache-1";
|
||||||
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
|
const lines = [
|
||||||
|
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
||||||
|
JSON.stringify({ message: { role: "user", content: "Hello world" } }),
|
||||||
|
JSON.stringify({ message: { role: "assistant", content: "Hi there" } }),
|
||||||
|
];
|
||||||
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
|
const readSpy = vi.spyOn(fs, "readSync");
|
||||||
|
|
||||||
|
const first = readSessionTitleFieldsFromTranscript(sessionId, storePath);
|
||||||
|
const readsAfterFirst = readSpy.mock.calls.length;
|
||||||
|
expect(readsAfterFirst).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const second = readSessionTitleFieldsFromTranscript(sessionId, storePath);
|
||||||
|
expect(second).toEqual(first);
|
||||||
|
expect(readSpy.mock.calls.length).toBe(readsAfterFirst);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalidates cache when transcript changes", () => {
|
||||||
|
const sessionId = "test-cache-2";
|
||||||
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
|
const lines = [
|
||||||
|
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
||||||
|
JSON.stringify({ message: { role: "user", content: "First" } }),
|
||||||
|
JSON.stringify({ message: { role: "assistant", content: "Old" } }),
|
||||||
|
];
|
||||||
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
|
const readSpy = vi.spyOn(fs, "readSync");
|
||||||
|
|
||||||
|
const first = readSessionTitleFieldsFromTranscript(sessionId, storePath);
|
||||||
|
const readsAfterFirst = readSpy.mock.calls.length;
|
||||||
|
expect(first.lastMessagePreview).toBe("Old");
|
||||||
|
|
||||||
|
fs.appendFileSync(
|
||||||
|
transcriptPath,
|
||||||
|
`\n${JSON.stringify({ message: { role: "assistant", content: "New" } })}`,
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const second = readSessionTitleFieldsFromTranscript(sessionId, storePath);
|
||||||
|
expect(second.lastMessagePreview).toBe("New");
|
||||||
|
expect(readSpy.mock.calls.length).toBeGreaterThan(readsAfterFirst);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("readSessionMessages", () => {
|
describe("readSessionMessages", () => {
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
let storePath: string;
|
let storePath: string;
|
||||||
|
|||||||
@@ -12,6 +12,60 @@ import { hasInterSessionUserProvenance } from "../sessions/input-provenance.js";
|
|||||||
import { extractToolCallNames, hasToolCall } from "../utils/transcript-tools.js";
|
import { extractToolCallNames, hasToolCall } from "../utils/transcript-tools.js";
|
||||||
import { stripEnvelope } from "./chat-sanitize.js";
|
import { stripEnvelope } from "./chat-sanitize.js";
|
||||||
|
|
||||||
|
type SessionTitleFields = {
|
||||||
|
firstUserMessage: string | null;
|
||||||
|
lastMessagePreview: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SessionTitleFieldsCacheEntry = SessionTitleFields & {
|
||||||
|
mtimeMs: number;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sessionTitleFieldsCache = new Map<string, SessionTitleFieldsCacheEntry>();
|
||||||
|
const MAX_SESSION_TITLE_FIELDS_CACHE_ENTRIES = 5000;
|
||||||
|
|
||||||
|
function readSessionTitleFieldsCacheKey(
|
||||||
|
filePath: string,
|
||||||
|
opts?: { includeInterSession?: boolean },
|
||||||
|
) {
|
||||||
|
const includeInterSession = opts?.includeInterSession === true ? "1" : "0";
|
||||||
|
return `${filePath}\t${includeInterSession}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCachedSessionTitleFields(cacheKey: string, stat: fs.Stats): SessionTitleFields | null {
|
||||||
|
const cached = sessionTitleFieldsCache.get(cacheKey);
|
||||||
|
if (!cached) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (cached.mtimeMs !== stat.mtimeMs || cached.size !== stat.size) {
|
||||||
|
sessionTitleFieldsCache.delete(cacheKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// LRU bump
|
||||||
|
sessionTitleFieldsCache.delete(cacheKey);
|
||||||
|
sessionTitleFieldsCache.set(cacheKey, cached);
|
||||||
|
return {
|
||||||
|
firstUserMessage: cached.firstUserMessage,
|
||||||
|
lastMessagePreview: cached.lastMessagePreview,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCachedSessionTitleFields(cacheKey: string, stat: fs.Stats, value: SessionTitleFields) {
|
||||||
|
sessionTitleFieldsCache.set(cacheKey, {
|
||||||
|
...value,
|
||||||
|
mtimeMs: stat.mtimeMs,
|
||||||
|
size: stat.size,
|
||||||
|
});
|
||||||
|
while (sessionTitleFieldsCache.size > MAX_SESSION_TITLE_FIELDS_CACHE_ENTRIES) {
|
||||||
|
const oldestKey = sessionTitleFieldsCache.keys().next().value;
|
||||||
|
if (typeof oldestKey !== "string" || !oldestKey) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
sessionTitleFieldsCache.delete(oldestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function readSessionMessages(
|
export function readSessionMessages(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
storePath: string | undefined,
|
storePath: string | undefined,
|
||||||
@@ -181,21 +235,36 @@ export function readSessionTitleFieldsFromTranscript(
|
|||||||
sessionFile?: string,
|
sessionFile?: string,
|
||||||
agentId?: string,
|
agentId?: string,
|
||||||
opts?: { includeInterSession?: boolean },
|
opts?: { includeInterSession?: boolean },
|
||||||
): { firstUserMessage: string | null; lastMessagePreview: string | null } {
|
): SessionTitleFields {
|
||||||
const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile, agentId);
|
const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile, agentId);
|
||||||
const filePath = candidates.find((p) => fs.existsSync(p));
|
const filePath = candidates.find((p) => fs.existsSync(p));
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
return { firstUserMessage: null, lastMessagePreview: null };
|
return { firstUserMessage: null, lastMessagePreview: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let stat: fs.Stats;
|
||||||
|
try {
|
||||||
|
stat = fs.statSync(filePath);
|
||||||
|
} catch {
|
||||||
|
return { firstUserMessage: null, lastMessagePreview: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = readSessionTitleFieldsCacheKey(filePath, opts);
|
||||||
|
const cached = getCachedSessionTitleFields(cacheKey, stat);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stat.size === 0) {
|
||||||
|
const empty = { firstUserMessage: null, lastMessagePreview: null };
|
||||||
|
setCachedSessionTitleFields(cacheKey, stat, empty);
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
|
||||||
let fd: number | null = null;
|
let fd: number | null = null;
|
||||||
try {
|
try {
|
||||||
fd = fs.openSync(filePath, "r");
|
fd = fs.openSync(filePath, "r");
|
||||||
const stat = fs.fstatSync(fd);
|
|
||||||
const size = stat.size;
|
const size = stat.size;
|
||||||
if (size === 0) {
|
|
||||||
return { firstUserMessage: null, lastMessagePreview: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Head (first user message)
|
// Head (first user message)
|
||||||
let firstUserMessage: string | null = null;
|
let firstUserMessage: string | null = null;
|
||||||
@@ -265,7 +334,9 @@ export function readSessionTitleFieldsFromTranscript(
|
|||||||
// ignore tail read errors
|
// ignore tail read errors
|
||||||
}
|
}
|
||||||
|
|
||||||
return { firstUserMessage, lastMessagePreview };
|
const result = { firstUserMessage, lastMessagePreview };
|
||||||
|
setCachedSessionTitleFields(cacheKey, stat, result);
|
||||||
|
return result;
|
||||||
} catch {
|
} catch {
|
||||||
return { firstUserMessage: null, lastMessagePreview: null };
|
return { firstUserMessage: null, lastMessagePreview: null };
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user